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:
@@ -18,6 +18,8 @@ public enum StepCause
|
||||
public enum StepAction
|
||||
{
|
||||
None,
|
||||
/// <summary>Cheapest first: halve LlamaCasty's own capture cost, change nothing the viewer sees.</summary>
|
||||
ShedCaptureCadence,
|
||||
LowerFrameRate,
|
||||
LowerResolution,
|
||||
LowerBitrate,
|
||||
@@ -94,6 +96,7 @@ public sealed class StepDownPolicy
|
||||
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)
|
||||
@@ -175,6 +178,17 @@ public sealed class StepDownPolicy
|
||||
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");
|
||||
|
||||
@@ -64,10 +64,14 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||
// accommodate in-flight frames) and publishes only monotonically newer frames
|
||||
// (MonotonicGate — a slow older completion must never overwrite a newer LatestFrame).
|
||||
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 const int MaxConcurrentConversions = 3;
|
||||
private const int PoolBufferCount = 5;
|
||||
private DateTime _lastConvertAt = DateTime.MinValue;
|
||||
private TimeSpan _convertInterval = MinConvertInterval;
|
||||
private DateTime _telemetryFrom = DateTime.UtcNow;
|
||||
private DateTime _lastTelemetry = DateTime.UtcNow;
|
||||
private long _skippedCadence;
|
||||
@@ -79,6 +83,10 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||
public string Key { get; }
|
||||
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)
|
||||
{
|
||||
Key = key;
|
||||
@@ -171,7 +179,7 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||
return;
|
||||
}
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastConvertAt < MinConvertInterval)
|
||||
if (now - _lastConvertAt < _convertInterval)
|
||||
{
|
||||
_skippedCadence++;
|
||||
frame.Dispose();
|
||||
|
||||
@@ -55,6 +55,15 @@ public sealed class ScreenCaptureManager : IDisposable
|
||||
_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>
|
||||
public async Task<bool> AcquireAsync(string key)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
1. If our capture/convert cadence is what starves, **halve our own capture rate first** — same output
|
||||
tier, less of the creator's machine. This is the "budget streamer self-streaming gameplay" case.
|
||||
1. If our capture/convert cadence is what starves, **halve our own capture rate first**
|
||||
(`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.
|
||||
|
||||
**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
|
||||
|
||||
| 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.
|
||||
It will stay there for this stream."*
|
||||
|
||||
One toast per step, plus one session-report line (`quality stepped down 12:04 — encoder` / `—
|
||||
upload`) so support answers from data, not memory. Wording rules live in
|
||||
One toast per step, plus one **Logs-tab** line (`quality stepped down 12:04 — encoder` / `— upload`,
|
||||
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.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -39,6 +39,7 @@ public partial class MainViewModel
|
||||
_stepDownPlanner = null;
|
||||
_encoderTierOverride = null;
|
||||
_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
|
||||
@@ -64,6 +65,13 @@ public partial class MainViewModel
|
||||
var landed = planner.Current;
|
||||
try
|
||||
{
|
||||
if (decision.Action == StepAction.ShedCaptureCadence)
|
||||
{
|
||||
_screenCaptureManager.ShedLoad(true);
|
||||
AppLog.Write($"Capture load shed: {decision.Why}");
|
||||
return;
|
||||
}
|
||||
|
||||
_encoderTierOverride = landed;
|
||||
AppLog.Write($"Quality step-down: {decision.Why}");
|
||||
await _framePump.ChangeEncoderAsync();
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class StepDownPlannerTests
|
||||
new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0), now);
|
||||
|
||||
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);
|
||||
decision = planner.Observe(Strained(), now);
|
||||
|
||||
@@ -30,20 +30,39 @@ public sealed class StepDownPolicyTests
|
||||
new(WindowSeconds: 5, PumpStalls: 0, PumpDrops: 0, EncoderDrops: 0,
|
||||
AvgRenderMs: 1, AvgSubmitWaitMs: 1);
|
||||
|
||||
/// <summary>Feeds windows every 5 s until the policy acts; returns that decision, or the last
|
||||
/// decision it reached when it never acted (so the "holding" reason is visible to the test).</summary>
|
||||
private static StepDownDecision Run(StepDownPolicy policy, StepDownSample sample, ref double t, int maxWindows = 12)
|
||||
/// <summary>Feeds windows every 5 s until the policy changes QUALITY; the capture-load shed is a
|
||||
/// 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 = 24)
|
||||
{
|
||||
var last = new StepDownDecision(StepCause.None, StepAction.None, null);
|
||||
for (var i = 0; i < maxWindows; i++)
|
||||
{
|
||||
t += 5;
|
||||
last = policy.Observe(sample, TimeSpan.FromSeconds(t));
|
||||
if (last.Acted) return last;
|
||||
if (last.Acted && last.Action != StepAction.ShedCaptureCadence) 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]
|
||||
public void MachineStarvation_LowersFrameRateBeforeAnythingElse()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user