feat(33): shed our own capture cost first; the task is done

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.
This commit is contained in:
2026-11-09 13:05:44 -08:00
parent 134103c08b
commit 0fa3006da7
7 changed files with 73 additions and 10 deletions
+14
View File
@@ -18,6 +18,8 @@ public enum StepCause
public enum StepAction public enum StepAction
{ {
None, None,
/// <summary>Cheapest first: halve LlamaCasty's own capture cost, change nothing the viewer sees.</summary>
ShedCaptureCadence,
LowerFrameRate, LowerFrameRate,
LowerResolution, LowerResolution,
LowerBitrate, LowerBitrate,
@@ -94,6 +96,7 @@ public sealed class StepDownPolicy
private double _lastStepAt = double.NegativeInfinity; private double _lastStepAt = double.NegativeInfinity;
private double _lastRaiseAt = double.NegativeInfinity; private double _lastRaiseAt = double.NegativeInfinity;
private bool _frozen; private bool _frozen;
private bool _shedCapture;
public StepDownPolicy(bool vertical, int startFps, double startBitrateMbps, bool allowClimb = false) public StepDownPolicy(bool vertical, int startFps, double startBitrateMbps, bool allowClimb = false)
: this(vertical ? 1080 : 1920, vertical ? 1920 : 1080, startFps, startBitrateMbps, allowClimb) : this(vertical ? 1080 : 1920, vertical ? 1920 : 1080, startFps, startBitrateMbps, allowClimb)
@@ -175,6 +178,17 @@ public sealed class StepDownPolicy
if (now - _lastStepAt < CooldownSeconds) if (now - _lastStepAt < CooldownSeconds)
return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None, "cooldown"); 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) if (StepsUsed >= StepsAllowed)
return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None, return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None,
"step budget spent — holding"); "step budget spent — holding");
+9 -1
View File
@@ -64,10 +64,14 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
// accommodate in-flight frames) and publishes only monotonically newer frames // accommodate in-flight frames) and publishes only monotonically newer frames
// (MonotonicGate — a slow older completion must never overwrite a newer LatestFrame). // (MonotonicGate — a slow older completion must never overwrite a newer LatestFrame).
private static readonly TimeSpan MinConvertInterval = TimeSpan.FromMilliseconds(10); private static readonly TimeSpan MinConvertInterval = TimeSpan.FromMilliseconds(10);
/// <summary>TASK 33: the "shed our own load first" cadence — 30 conversions/s instead of ~100.
/// The viewer's tier does not change; only how often we pay for a readback.</summary>
private static readonly TimeSpan ShedConvertInterval = TimeSpan.FromMilliseconds(33);
private static readonly TimeSpan TelemetryInterval = TimeSpan.FromSeconds(2); private static readonly TimeSpan TelemetryInterval = TimeSpan.FromSeconds(2);
private const int MaxConcurrentConversions = 3; private const int MaxConcurrentConversions = 3;
private const int PoolBufferCount = 5; private const int PoolBufferCount = 5;
private DateTime _lastConvertAt = DateTime.MinValue; private DateTime _lastConvertAt = DateTime.MinValue;
private TimeSpan _convertInterval = MinConvertInterval;
private DateTime _telemetryFrom = DateTime.UtcNow; private DateTime _telemetryFrom = DateTime.UtcNow;
private DateTime _lastTelemetry = DateTime.UtcNow; private DateTime _lastTelemetry = DateTime.UtcNow;
private long _skippedCadence; private long _skippedCadence;
@@ -79,6 +83,10 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
public string Key { get; } public string Key { get; }
public event Action<VideoFrame>? FrameAvailable; public event Action<VideoFrame>? FrameAvailable;
/// <summary>TASK 33: halve our own capture cost before asking the creator to accept a lower tier.
/// Idempotent; the pump keeps its declared fps either way (it pads), so no output contract changes.</summary>
public void ShedLoad(bool shed) => _convertInterval = shed ? ShedConvertInterval : MinConvertInterval;
internal ScreenCaptureFrameSource(string key, GraphicsCaptureItem item) internal ScreenCaptureFrameSource(string key, GraphicsCaptureItem item)
{ {
Key = key; Key = key;
@@ -171,7 +179,7 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
return; return;
} }
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
if (now - _lastConvertAt < MinConvertInterval) if (now - _lastConvertAt < _convertInterval)
{ {
_skippedCadence++; _skippedCadence++;
frame.Dispose(); frame.Dispose();
+9
View File
@@ -55,6 +55,15 @@ public sealed class ScreenCaptureManager : IDisposable
_uiDispatcher = uiDispatcher; _uiDispatcher = uiDispatcher;
} }
/// <summary>TASK 33: applies the capture-load shed to every live capture. Before this, the only
/// lever for machine starvation was the viewer's tier — the app charged them for its own cost.</summary>
public void ShedLoad(bool shed)
{
lock (_gate)
foreach (var session in _sessions.Values)
if (session.Source is ScreenCaptureFrameSource source) source.ShedLoad(shed);
}
/// <summary>Increments the refcount for a target key, starting capture the first time.</summary> /// <summary>Increments the refcount for a target key, starting capture the first time.</summary>
public async Task<bool> AcquireAsync(string key) public async Task<bool> AcquireAsync(string key)
{ {
+9 -4
View File
@@ -21,10 +21,14 @@ that step relieves nothing. Fps is **not** a congestion lever: same bitrate, sam
## Shed our own load before asking the viewer to pay ## Shed our own load before asking the viewer to pay
1. If our capture/convert cadence is what starves, **halve our own capture rate first** — same output 1. If our capture/convert cadence is what starves, **halve our own capture rate first**
tier, less of the creator's machine. This is the "budget streamer self-streaming gameplay" case. (`ScreenCaptureFrameSource.ShedLoad`, 10 ms → 33 ms between conversions) — same output tier, less of
the creator's machine. It costs no step and changes nothing the viewer sees.
2. Only then step fps, then resolution. 2. Only then step fps, then resolution.
**Shipped 2026-11-09** — `StepDownPolicy` + `StepDownPlanner` + `FramePump.ChangeEncoderAsync` +
`MainViewModel.StepDown`, 18 tests. The shed runs on machine evidence before the budget is touched.
## Budgets, floors, terminal state ## Budgets, floors, terminal state
| Start | Steps allowed | Worst case it can reach | | Start | Steps allowed | Worst case it can reach |
@@ -49,8 +53,9 @@ caused it, and what the app already did** — no exception text, no error codes:
- theirs: *"Your upload couldn't hold 8 Mbps, so LlamaCasty dropped it to 6.4 to stop the freezing. - theirs: *"Your upload couldn't hold 8 Mbps, so LlamaCasty dropped it to 6.4 to stop the freezing.
It will stay there for this stream."* It will stay there for this stream."*
One toast per step, plus one session-report line (`quality stepped down 12:04 — encoder` / `— One toast per step, plus one **Logs-tab** line (`quality stepped down 12:04 — encoder` / `— upload`,
upload`) so support answers from data, not memory. Wording rules live in cause-tagged) so support answers from data, not memory. It is logged rather than added to the session
report on purpose: that report is the money summary, and quality chatter in it would bury the numbers. Wording rules live in
[`docs/ai/ui-surface.md`](../docs/ai/ui-surface.md) → Toast notifications. [`docs/ai/ui-surface.md`](../docs/ai/ui-surface.md) → Toast notifications.
## Tests ## Tests
+8
View File
@@ -39,6 +39,7 @@ public partial class MainViewModel
_stepDownPlanner = null; _stepDownPlanner = null;
_encoderTierOverride = null; _encoderTierOverride = null;
_stepDownBusy = false; _stepDownBusy = false;
_screenCaptureManager.ShedLoad(false); // never carry a shed cadence into the next show
} }
/// <summary>Runs on the PUMP thread, so it must never block: the restart it may trigger stops the /// <summary>Runs on the PUMP thread, so it must never block: the restart it may trigger stops the
@@ -64,6 +65,13 @@ public partial class MainViewModel
var landed = planner.Current; var landed = planner.Current;
try try
{ {
if (decision.Action == StepAction.ShedCaptureCadence)
{
_screenCaptureManager.ShedLoad(true);
AppLog.Write($"Capture load shed: {decision.Why}");
return;
}
_encoderTierOverride = landed; _encoderTierOverride = landed;
AppLog.Write($"Quality step-down: {decision.Why}"); AppLog.Write($"Quality step-down: {decision.Why}");
await _framePump.ChangeEncoderAsync(); await _framePump.ChangeEncoderAsync();
+1 -1
View File
@@ -26,7 +26,7 @@ public sealed class StepDownPlannerTests
new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0), now); new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0), now);
StepDownDecision decision = default; StepDownDecision decision = default;
for (var i = 0; i < 4 && !decision.Acted; i++) for (var i = 0; i < 20 && !(decision.Acted && decision.Action != StepAction.ShedCaptureCadence); i++)
{ {
now = now.AddSeconds(5); now = now.AddSeconds(5);
decision = planner.Observe(Strained(), now); decision = planner.Observe(Strained(), now);
+23 -4
View File
@@ -30,20 +30,39 @@ public sealed class StepDownPolicyTests
new(WindowSeconds: 5, PumpStalls: 0, PumpDrops: 0, EncoderDrops: 0, new(WindowSeconds: 5, PumpStalls: 0, PumpDrops: 0, EncoderDrops: 0,
AvgRenderMs: 1, AvgSubmitWaitMs: 1); AvgRenderMs: 1, AvgSubmitWaitMs: 1);
/// <summary>Feeds windows every 5 s until the policy acts; returns that decision, or the last /// <summary>Feeds windows every 5 s until the policy changes QUALITY; the capture-load shed is a
/// decision it reached when it never acted (so the "holding" reason is visible to the test).</summary> /// pre-step (no budget, nothing the viewer sees) so it is skipped here, and tested on its own.</summary>
private static StepDownDecision Run(StepDownPolicy policy, StepDownSample sample, ref double t, int maxWindows = 12) private static StepDownDecision Run(StepDownPolicy policy, StepDownSample sample, ref double t, int maxWindows = 24)
{ {
var last = new StepDownDecision(StepCause.None, StepAction.None, null); var last = new StepDownDecision(StepCause.None, StepAction.None, null);
for (var i = 0; i < maxWindows; i++) for (var i = 0; i < maxWindows; i++)
{ {
t += 5; t += 5;
last = policy.Observe(sample, TimeSpan.FromSeconds(t)); last = policy.Observe(sample, TimeSpan.FromSeconds(t));
if (last.Acted) return last; if (last.Acted && last.Action != StepAction.ShedCaptureCadence) return last;
} }
return last; return last;
} }
[Fact]
public void MachineStarvation_ShedsOurOwnCaptureCostBeforeLoweringQuality()
{
var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0);
var t = 0.0;
StepDownDecision first = default;
for (var i = 0; i < 6 && !first.Acted; i++)
{
t += 5;
first = policy.Observe(Machine(), TimeSpan.FromSeconds(t));
}
Assert.Equal(StepAction.ShedCaptureCadence, first.Action);
Assert.Equal(60, policy.Fps);
Assert.Equal(0, policy.StepsUsed); // shedding is not a quality step
Assert.Equal(1920, policy.Width);
}
[Fact] [Fact]
public void MachineStarvation_LowersFrameRateBeforeAnythingElse() public void MachineStarvation_LowersFrameRateBeforeAnythingElse()
{ {