Last slice. 590 green, 0 warnings. - **Shed before you charge the viewer.** On machine evidence the policy's first act is `ShedCaptureCadence`: `ScreenCaptureFrameSource` widens its conversion interval 10 ms → 33 ms (≈100 → 30 conversions/s) through a new `ScreenCaptureManager.ShedLoad`. The output tier, the declared fps and the viewer's picture are untouched — we stop paying for readbacks we were throwing away. It costs no step budget; if the starvation persists, the fps ladder runs on the next clean window. - The shed is reset on every stop path, so a cheap session cannot leave a later one capped. - **The session line is a Logs-tab line, not a session-report line.** The session report is the money summary (Super Chats, per currency); quality chatter there would bury the numbers it exists for. The cause-tagged line already lands in the log at the moment of the step. TASK 33 is complete: two ladders, budgets, floors, ambiguity, one-way with recovery behind a flag, the relaunch, the copy, and the cost shed. 18 tests pin the ladders, the copy and the restart.
282 lines
11 KiB
C#
282 lines
11 KiB
C#
using System;
|
||
|
||
namespace ytLive.Models;
|
||
|
||
/// <summary>Why the policy acted. Reported in the toast and the session report, so it is never
|
||
/// guessed at again after the fact.</summary>
|
||
public enum StepCause
|
||
{
|
||
None,
|
||
/// <summary>The machine could not produce the frame: stalls, late renders, pump drops.</summary>
|
||
Machine,
|
||
/// <summary>The frames were produced but the sink could not take them: encoder drops.</summary>
|
||
Upload,
|
||
/// <summary>Both kinds of evidence in the same window — doing nothing is the only safe move.</summary>
|
||
Ambiguous,
|
||
}
|
||
|
||
public enum StepAction
|
||
{
|
||
None,
|
||
/// <summary>Cheapest first: halve LlamaCasty's own capture cost, change nothing the viewer sees.</summary>
|
||
ShedCaptureCadence,
|
||
LowerFrameRate,
|
||
LowerResolution,
|
||
LowerBitrate,
|
||
RaiseFrameRate,
|
||
RaiseResolution,
|
||
RaiseBitrate,
|
||
}
|
||
|
||
/// <summary>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.</summary>
|
||
public readonly record struct StepDownSample(
|
||
double WindowSeconds,
|
||
int PumpStalls,
|
||
int PumpDrops,
|
||
int EncoderDrops,
|
||
double AvgRenderMs,
|
||
double AvgSubmitWaitMs);
|
||
|
||
/// <summary>The decision and the numbers behind it. <paramref name="Why"/> is for the log; the
|
||
/// creator-facing sentence is built in the VM from <see cref="Cause"/>.</summary>
|
||
public readonly record struct StepDownDecision(StepCause Cause, StepAction Action, string? Why)
|
||
{
|
||
public bool Acted => Action != StepAction.None;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Decides when a session must give up quality, and why (TASK 33).
|
||
///
|
||
/// <para><b>Two levers, never mixed.</b> 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.</para>
|
||
///
|
||
/// <para><b>One-way by default.</b> 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.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class StepDownPolicy
|
||
{
|
||
/// <summary>Minimum evidence before acting: ~15 s of qualifying windows.</summary>
|
||
internal const double QualifyingSeconds = 15.0;
|
||
|
||
/// <summary>A sample window shorter than this is ignored — the pump reports every 5 s.</summary>
|
||
internal const double MinWindowSeconds = 4.0;
|
||
|
||
internal const double CooldownSeconds = 30.0;
|
||
|
||
/// <summary>Loss fraction at or above which the upload lever fires.</summary>
|
||
internal const double UploadLossThreshold = 0.05;
|
||
|
||
/// <summary>Render time relative to the frame budget that counts as machine starvation.</summary>
|
||
internal const double RenderBudgetFraction = 0.9;
|
||
|
||
internal const int StallThreshold = 5;
|
||
|
||
/// <summary>Clean seconds required before climbing when recovery is enabled.</summary>
|
||
internal const double ClimbAfterCleanSeconds = 180.0;
|
||
|
||
/// <summary>A climb followed by a fall inside this window freezes the session at the low value.</summary>
|
||
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)
|
||
{
|
||
}
|
||
|
||
/// <summary>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).</summary>
|
||
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;
|
||
/// <summary>True when no lever has anything left: 30 fps, the floor geometry, the floor bitrate.</summary>
|
||
public bool AtFloor => Fps <= 30
|
||
&& (_vertical || Width <= 1280)
|
||
&& BitrateMbps <= _floorBitrateMbps + 0.001;
|
||
|
||
public double FrameBudgetMs => 1000.0 / Math.Max(1, Fps);
|
||
|
||
/// <summary>Classifies one window and, when the evidence is strong enough and the budget allows,
|
||
/// applies exactly one change. <paramref name="elapsed"/> is time since the session started.</summary>
|
||
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);
|
||
}
|
||
}
|