using System; namespace ytLive.Models; /// Why the policy acted. Reported in the toast and the session report, so it is never /// guessed at again after the fact. public enum StepCause { None, /// The machine could not produce the frame: stalls, late renders, pump drops. Machine, /// The frames were produced but the sink could not take them: encoder drops. Upload, /// Both kinds of evidence in the same window — doing nothing is the only safe move. Ambiguous, } public enum StepAction { None, /// Cheapest first: halve LlamaCasty's own capture cost, change nothing the viewer sees. ShedCaptureCadence, LowerFrameRate, LowerResolution, LowerBitrate, RaiseFrameRate, RaiseResolution, RaiseBitrate, } /// One statistics window as the pump and the encoder report it. Nothing here is derived /// from the network directly: congestion shows up as frames the encoder failed to produce in /// wall-clock time, and backpressure shows up as submit wait. public readonly record struct StepDownSample( double WindowSeconds, int PumpStalls, int PumpDrops, int EncoderDrops, double AvgRenderMs, double AvgSubmitWaitMs); /// The decision and the numbers behind it. is for the log; the /// creator-facing sentence is built in the VM from . public readonly record struct StepDownDecision(StepCause Cause, StepAction Action, string? Why) { public bool Acted => Action != StepAction.None; } /// /// Decides when a session must give up quality, and why (TASK 33). /// /// Two levers, never mixed. Machine starvation is answered with less work (60 → 30 fps, /// then 1080 → 720); upload congestion is answered with fewer bits, sized by the observed loss /// fraction. Resolution is never used as a bandwidth lever, because 1080p30 and 720p60 cost the same /// 6–8 Mbps: dropping pixels at an unchanged bitrate relieves nothing. /// /// One-way by default. A step-down that climbs back is an encoder restart, which is a /// visible hitch; the creator ruled that a show does not oscillate. The state machine still carries /// the ceiling, so enabling recovery later is a constructor flag rather than a rewrite. /// /// public sealed class StepDownPolicy { /// Minimum evidence before acting: ~15 s of qualifying windows. internal const double QualifyingSeconds = 15.0; /// A sample window shorter than this is ignored — the pump reports every 5 s. internal const double MinWindowSeconds = 4.0; internal const double CooldownSeconds = 30.0; /// Loss fraction at or above which the upload lever fires. internal const double UploadLossThreshold = 0.05; /// Render time relative to the frame budget that counts as machine starvation. internal const double RenderBudgetFraction = 0.9; internal const int StallThreshold = 5; /// Clean seconds required before climbing when recovery is enabled. internal const double ClimbAfterCleanSeconds = 180.0; /// A climb followed by a fall inside this window freezes the session at the low value. internal const double FlapWindowSeconds = 120.0; private readonly bool _vertical; private readonly bool _allowClimb; private readonly int _ceilingFps; private readonly int _ceilingWidth; private readonly int _ceilingHeight; private readonly double _ceilingBitrateMbps; private readonly double _floorBitrateMbps; private double _qualifyingSeconds; private double _cleanSeconds; private double _lastStepAt = double.NegativeInfinity; private double _lastRaiseAt = double.NegativeInfinity; private bool _frozen; private bool _shedCapture; public StepDownPolicy(bool vertical, int startFps, double startBitrateMbps, bool allowClimb = false) : this(vertical ? 1080 : 1920, vertical ? 1920 : 1080, startFps, startBitrateMbps, allowClimb) { } /// Explicit geometry: the tier the creator actually chose, which is what decides the /// step budget (a 1080 start gets three, a 720 start gets one). public StepDownPolicy(int startWidth, int startHeight, int startFps, double startBitrateMbps, bool allowClimb = false) { _vertical = startHeight > startWidth; _allowClimb = allowClimb; _ceilingFps = startFps; _ceilingWidth = startWidth; _ceilingHeight = startHeight; _ceilingBitrateMbps = startBitrateMbps; _floorBitrateMbps = _vertical ? 4.0 : 3.0; Fps = startFps; Width = startWidth; Height = startHeight; BitrateMbps = startBitrateMbps; // Creator ruling 2026-11-09: a 1080p start may spend three steps; 720 and vertical get one. StepsAllowed = startWidth >= 1920 ? 3 : 1; } public int Fps { get; private set; } public int Width { get; private set; } public int Height { get; private set; } public double BitrateMbps { get; private set; } public int StepsUsed { get; private set; } public int StepsAllowed { get; } public bool Frozen => _frozen; /// True when no lever has anything left: 30 fps, the floor geometry, the floor bitrate. public bool AtFloor => Fps <= 30 && (_vertical || Width <= 1280) && BitrateMbps <= _floorBitrateMbps + 0.001; public double FrameBudgetMs => 1000.0 / Math.Max(1, Fps); /// Classifies one window and, when the evidence is strong enough and the budget allows, /// applies exactly one change. is time since the session started. public StepDownDecision Observe(in StepDownSample sample, TimeSpan elapsed) { if (sample.WindowSeconds < MinWindowSeconds) return new StepDownDecision(StepCause.None, StepAction.None, null); var now = elapsed.TotalSeconds; var budget = FrameBudgetMs; var machine = sample.PumpStalls >= StallThreshold || sample.PumpDrops > 0 || sample.AvgRenderMs > budget * RenderBudgetFraction; var loss = sample.EncoderDrops / Math.Max(1.0, Fps * sample.WindowSeconds); var upload = loss >= UploadLossThreshold; if (machine && upload) { _qualifyingSeconds = 0; _cleanSeconds = 0; return new StepDownDecision(StepCause.Ambiguous, StepAction.None, $"render {sample.AvgRenderMs:0.0}ms, stalls {sample.PumpStalls}, loss {loss:P0} — not acting"); } if (!machine && !upload) { _qualifyingSeconds = 0; _cleanSeconds += sample.WindowSeconds; return TryClimb(sample, now); } _cleanSeconds = 0; _qualifyingSeconds += sample.WindowSeconds; if (_qualifyingSeconds < QualifyingSeconds) return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None, null); if (now - _lastStepAt < CooldownSeconds) return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None, "cooldown"); // Machine starvation: shed OUR cost before the viewer's tier is touched. Costs no step and is // not a quality change — if it does not fix the starvation, the fps ladder runs next window. if (machine && !_shedCapture) { _shedCapture = true; _qualifyingSeconds = 0; _lastStepAt = now; return new StepDownDecision(StepCause.Machine, StepAction.ShedCaptureCadence, "halving capture cost before lowering quality"); } if (StepsUsed >= StepsAllowed) return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None, "step budget spent — holding"); var decision = machine ? StepMachine() : StepUpload(loss); if (!decision.Acted) return decision; if (now - _lastRaiseAt < FlapWindowSeconds) _frozen = true; StepsUsed++; _lastStepAt = now; _qualifyingSeconds = 0; return decision; } private StepDownDecision StepMachine() { if (Fps > 30) { Fps = 30; return new StepDownDecision(StepCause.Machine, StepAction.LowerFrameRate, $"frame rate 60 → 30 to give the machine its time back"); } // The vertical tier's floor IS its only geometry (1080×1920), so it has no resolution step. if (!_vertical && (Width > 1280 || Height > 720)) { Width = 1280; Height = 720; return new StepDownDecision(StepCause.Machine, StepAction.LowerResolution, $"resolution {Width}×{Height} — fewer pixels to composite"); } return new StepDownDecision(StepCause.Machine, StepAction.None, "at the machine floor — holding"); } private StepDownDecision StepUpload(double loss) { var reduction = Math.Max(0.5, BitrateMbps * loss); var next = Math.Max(_floorBitrateMbps, BitrateMbps - reduction); if (next >= BitrateMbps - 0.05) return new StepDownDecision(StepCause.Upload, StepAction.None, "at the bitrate floor — holding"); var from = BitrateMbps; BitrateMbps = Math.Round(next, 1); return new StepDownDecision(StepCause.Upload, StepAction.LowerBitrate, $"{from:0.0} → {BitrateMbps:0.0} Mbps on {loss:P0} loss"); } private StepDownDecision TryClimb(in StepDownSample sample, double now) { if (!_allowClimb || _frozen) return new StepDownDecision(StepCause.None, StepAction.None, null); if (StepsUsed == 0) return new StepDownDecision(StepCause.None, StepAction.None, null); if (_cleanSeconds < ClimbAfterCleanSeconds) return new StepDownDecision(StepCause.None, StepAction.None, null); if (now - _lastStepAt < CooldownSeconds) return new StepDownDecision(StepCause.None, StepAction.None, null); var (action, caused) = RaiseOneStep(); if (!caused) return new StepDownDecision(StepCause.None, StepAction.None, null); StepsUsed = Math.Max(0, StepsUsed - 1); _lastRaiseAt = now; _lastStepAt = now; _cleanSeconds = 0; return new StepDownDecision(StepCause.None, action, "conditions clean — one step back up"); } private (StepAction Action, bool Changed) RaiseOneStep() { if (Fps < _ceilingFps) { Fps = _ceilingFps; return (StepAction.RaiseFrameRate, true); } if (Width < _ceilingWidth || Height < _ceilingHeight) { Width = _ceilingWidth; Height = _ceilingHeight; return (StepAction.RaiseResolution, true); } if (BitrateMbps < _ceilingBitrateMbps - 0.05) { BitrateMbps = Math.Round(Math.Min(_ceilingBitrateMbps, BitrateMbps + 1.0), 1); return (StepAction.RaiseBitrate, true); } return (StepAction.None, false); } }