From 0fa3006da7c2300504403669caeb6e905c55de0e Mon Sep 17 00:00:00 2001 From: gramps Date: Mon, 9 Nov 2026 13:05:44 -0800 Subject: [PATCH] feat(33): shed our own capture cost first; the task is done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Models/StepDownPolicy.cs | 14 ++++++++++++++ Services/ScreenCaptureFrameSource.cs | 10 +++++++++- Services/ScreenCaptureManager.cs | 9 +++++++++ TASKS/task-33-bandwidth-step-down.md | 13 +++++++++---- ViewModels/MainViewModel.StepDown.cs | 8 ++++++++ ytLive.Tests/StepDownPlannerTests.cs | 2 +- ytLive.Tests/StepDownPolicyTests.cs | 27 +++++++++++++++++++++++---- 7 files changed, 73 insertions(+), 10 deletions(-) diff --git a/Models/StepDownPolicy.cs b/Models/StepDownPolicy.cs index 287db2f..5602dd5 100644 --- a/Models/StepDownPolicy.cs +++ b/Models/StepDownPolicy.cs @@ -18,6 +18,8 @@ public enum StepCause public enum StepAction { None, + /// Cheapest first: halve LlamaCasty's own capture cost, change nothing the viewer sees. + 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"); diff --git a/Services/ScreenCaptureFrameSource.cs b/Services/ScreenCaptureFrameSource.cs index 97ded68..897a757 100644 --- a/Services/ScreenCaptureFrameSource.cs +++ b/Services/ScreenCaptureFrameSource.cs @@ -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); + /// 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. + 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? FrameAvailable; + /// 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. + 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(); diff --git a/Services/ScreenCaptureManager.cs b/Services/ScreenCaptureManager.cs index c876884..d7a89c3 100644 --- a/Services/ScreenCaptureManager.cs +++ b/Services/ScreenCaptureManager.cs @@ -55,6 +55,15 @@ public sealed class ScreenCaptureManager : IDisposable _uiDispatcher = uiDispatcher; } + /// 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. + public void ShedLoad(bool shed) + { + lock (_gate) + foreach (var session in _sessions.Values) + if (session.Source is ScreenCaptureFrameSource source) source.ShedLoad(shed); + } + /// Increments the refcount for a target key, starting capture the first time. public async Task AcquireAsync(string key) { diff --git a/TASKS/task-33-bandwidth-step-down.md b/TASKS/task-33-bandwidth-step-down.md index bdaad08..433d4bc 100644 --- a/TASKS/task-33-bandwidth-step-down.md +++ b/TASKS/task-33-bandwidth-step-down.md @@ -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 diff --git a/ViewModels/MainViewModel.StepDown.cs b/ViewModels/MainViewModel.StepDown.cs index 7db4615..1592c1b 100644 --- a/ViewModels/MainViewModel.StepDown.cs +++ b/ViewModels/MainViewModel.StepDown.cs @@ -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 } /// 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(); diff --git a/ytLive.Tests/StepDownPlannerTests.cs b/ytLive.Tests/StepDownPlannerTests.cs index 6bbcb85..aaafdf7 100644 --- a/ytLive.Tests/StepDownPlannerTests.cs +++ b/ytLive.Tests/StepDownPlannerTests.cs @@ -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); diff --git a/ytLive.Tests/StepDownPolicyTests.cs b/ytLive.Tests/StepDownPolicyTests.cs index 311b959..bd3ec8b 100644 --- a/ytLive.Tests/StepDownPolicyTests.cs +++ b/ytLive.Tests/StepDownPolicyTests.cs @@ -30,20 +30,39 @@ public sealed class StepDownPolicyTests new(WindowSeconds: 5, PumpStalls: 0, PumpDrops: 0, EncoderDrops: 0, AvgRenderMs: 1, AvgSubmitWaitMs: 1); - /// 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). - private static StepDownDecision Run(StepDownPolicy policy, StepDownSample sample, ref double t, int maxWindows = 12) + /// 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. + 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() {