The zoom feature shipped without a task record while its index rows carried the
"TASK 51" label — which belongs to the webcam background effects (Remove + Blur).
For an honest catalogue and handoff:
- TASKS/task-52-zoom-box.md: feature provenance (creator-requested), the three
2026-10-08 corrections (loupe -> anchored capture over the full composite drawn
last -> never show the dim shading), shapes, implementation map, tests, DONE.
- TASKS.md: row 52 added (shipped); row 51 reworded — unblocked now that the
zoom build shipped.
- TASK 51 -> TASK 52 in Models/index.md, ViewModels/index.md, and the two code
comments that pointed at the zoom as "TASK 51".
- HANDOFF.md: pre-push readiness notes (5 commits ahead, push-safe).
Creator's final zoom correction: as the box is dragged, its capture region slides
under the dim rectangles and the box picks up the 55% black "div shading" inside
itself, because the VisualBrush samples the whole CanvasGrid and DimRects lived
inside CanvasGrid. The captured area must stay clear regardless of box position.
- PreviewPane.xaml: the DimRects ItemsControl is now a SIBLING of CanvasGrid
(above the composed content, below the ZoomBox), never a descendant — the zoom
source is shading-free by construction.
- ZoomBoxInteractionTests: structural invariant — DimRectsLayer is a direct
child of PreviewCanvas (not inside CanvasGrid) and renders below the ZoomBox.
- Side effect (correctness, not a bug): quality dims now also sedate the global
overlays outside the crop, matching "chrome outside the crop is dimmed".
Creator corrected the "loupe" zoom on 2026-10-08: the click anchors a fixed
capture region (box/2 centred on the click) and the box is a live 2x view of
that region — dragging the box never changes its content; it magnifies the
fully-composited frame (every layer, incl. webcam) and draws LAST (n+1).
Rectangle shape is 4:3 ("more of a 640x480 aspect").
- MainViewModel.Zoom.cs rewritten: ActivateZoom anchors CaptureX/CaptureY
(independently clamped); UpdateZoomMove relocates the box only; the per-frame
BuildZoomFrame/ZoomFrameBuilder path is gone.
- SceneCompositor: skips the active ZoomSceneElement in every layer loop and
BlitZooms it last via the new pure ZoomMagnifier (2x bilinear, temp snapshot
via ArrayPool, round clip).
- Preview: ZoomBox hoisted above CanvasGrid as a sibling; its brush samples the
whole composed preview at the fixed capture (2x).
- Tests: ZoomMagnifierTests (box-over-blue shows green capture at 2x incl. an
extra red "layer" — the loupe/backdrop-only regressions both fail it) +
round-clip; ZoomBoxInteractionTests rewritten (anchored capture, move-doesn't-
change-capture, 4:3/9:16/square). 404 pass, 2 known environment flakes.
Right-click now creates a transient ZoomSceneElement at z-index 1 whose pixels are a
genuine 2x magnified sample of the backdrop (live capture under art), composited into
stream/recording like any layer while the output crop stays the tier. All shapes are
centered on the click and clamped to frame; healed on load/scene-create, never persisted,
hidden from the layers list + element template (IsZoomLayer). BuildCompositorOptions is
now internal = always tier crop (test seam). ZoomFrameBuilder is a pure static sampler
(box/2 window centered on the box center, single-slot cache) proven by a banded-backdrop
test; LayerReorderPersistenceTests drag resolves rows by item since the collapsed zoom
row shifts container indices. Also books TASK 51 (webcam background Remove + Blur) and
syncs memory docs (HANDOFF, MyMistakes, indexes). Full suite: 405 passed, 0 warnings.
Right-click in the live preview now procs a box that is actually visible and
populated with the zoomed region, and each Zoom View Shape sculpts the box per
spec. OBS-style derivative: magnifier box shows the composited region and dims
the surroundings (same pattern as OBS "zoom" / preview region highlights).
Root causes of the invisible box (two WPF traps, recorded in MyMistakes.md):
- Empty <VisualBrush Stretch="None"/> used as an OpacityMask paints nothing,
so the mask alpha is 0 and the WHOLE box (border included) went transparent.
- ZoomBox lived inside CanvasGrid while its VisualBrush sampled CanvasGrid — a
circular reference WPF renders as nothing. Hoisted ZoomBox to a sibling of
CanvasGrid inside a new PreviewCanvas wrapper (same 1920x1080 space).
Shape semantics (MainViewModel.ActivateZoom):
- Circle: square, click point = center.
- VerticalRectangle (phone): tall 9:16, click point = top-left corner.
- HorizontalRectangle (tablet): wide 16:9, click point = top-left corner.
- Boxes stay fully inside 1920x1080 via Math.Clamp.
UI: ZoomRectOutline/ZoomCircleOutline shapes replace the square Border;
circle clips the box to an EllipseGeometry; brush Visual released when inactive.
Zoom View Shape dropdown now uses the standard YtComboBox style.
Test (Good Dog rule, one integration test): ZoomBoxInteractionTests verifies
aspect/anchoring/clamp per shape and the matching outline+clip in the real
MainWindow. Full suite 404 passed, 0 warnings.
Memory: HANDOFF.md zoom state updated; WPF VisualBrush traps appended to
MyMistakes.md.
A composite splits into four horizontal row strips across four DEDICATED
background threads; the calling thread takes a strip too (so four workers
never mean five-way oversubscription), and anything under 64 rows runs
inline. BlitContentRaw, BlitBakedElement and BlitOverlay now fan out.
Output is byte-identical — strips already partitioned by row.
The demotion deliberately does NOT come from Parallel.For. The pool is
shared, so Thread.CurrentThread.Priority = BelowNormal inside it leaks
into every unrelated work item that thread later runs — a full test run
after trying it broke AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_
KillsTheLoopback. Established guidance agrees: use your own producer/consumer
queue with your own Threads and set their priority yourself
(https://stackoverflow.com/questions/524483/net-backgroundworker-objects-thread-priority,
https://stackoverflow.com/questions/1578599/c-sharp-lower-thread-priority-in-thread-pool).
Helpers/StripPool.cs is that queue. Four threads is the measured sweet
spot: fewer leaves render over budget, more steals cycles from the game
and the encoder and made render worse.
The scaled blit's column LUT moves from a shared static buffer to
ArrayPool<float>.Shared with a finally — a single static buffer is a data
race the moment strips run in parallel.
Gate: SceneCompositorTests.StripParallel_RepeatedRenders_AreByteIdentical —
8 renders of a scaled round webcam + mirrored image + master-sized flash over
a short backdrop must be byte-for-byte identical, and must differ from a
backdrop-only render so it cannot pass vacuously. It caught a loop-variable
capture (new Thread(() => Worker(i)) saw i == Workers) that crashed the test
host on the first run.
Handle the case where an opaque source exactly covers the destination
canvas with 1:1 dimensions (even if offsets differ in calculation). This
avoids per-pixel alpha branching and mirrors the existing memcpy fast
path. Byte-identical for opaque sources.
Add timing for social bar, ticker, flash delegates and scratch acquire,
and extend the 5s stats line to report them (ms per render). No behavior
change, only measurement.
The Stream Alerts properties panel was incorrectly showing for the webcam
layer (a WebcamSceneConfig is not a Source) because IsAlertBox only existed
on Source, causing WPF binding to be treated as false-but-invisible context
or the trigger to bind against the wrong type. Adding a virtual IsAlertBox
to the base SceneElement (default false) and overriding on Source prevents
the UI block from rendering under webcam selection. Scope: Models/SceneElement.cs, Models/Source.cs, ytLive.Tests/AlertBoxPreviewVisibilityTests.cs. Build/test: app + 4 alert-box tests pass, other failures unrelated (float precision).
Take ty-20261005-1201-556-0001.mp4 (75.3s) plus its log settle it. The
C920 delivers 30.0/s with ZERO gaps over 50ms and its own device stamps
agree - camera, USB and driver are clean. The pump managed 34 composites per
5s at ~141ms each, and the file holds 6.8 distinct webcam images per second
at 88.7% duplicates with no arrival hitch whatsoever. The judder is us
holding one face for 140ms and padding the rest of a 60fps container, which
is precisely what a lipreader experiences as "cutting out".
So 720p is a SHARPNESS win, not a smoothness win: it triples the bytes the
compositor stretches per render on a path already 4.4x over budget. The
exclusive camera lock stays agreed and stays queued, but it moves after the
compositor work or it makes the judder measurably worse.
The number still unexplained: the 10:01 take hit 25.6 images/s and this one
6.8, with the same scene shape (full-render split=0 elements=6 dynamic=5)
and the same 640x480 camera - composites went 29-34ms to 143-155ms between
sessions and nothing logged says why. There is no per-element composite
breakdown and no scene dump at pump start, so the next unit is measurement,
not a fix. Recorded the pump-counter overcount (13/s vs 6.8/s: one 141ms
composite lets ~4 camera frames arrive, and the counter stamps the first
epoch the resolver sees, not the frame actually drawn) so it is not
re-derived as a bug later.
The creator is DHH and lipreads their own recordings, so a webcam that
judders is an accessibility defect, not a polish item. Take
ty-20261005-1001-821-0001.mp4 holds 25.6 distinct webcam images/s against
the camera's 30fps with 281 gaps of 50-100ms - roughly one hitch in five -
in a file that muxes flawlessly. Nothing in the app could see that: rawvideo
carries no per-frame timestamps (ffmpeg stamps by ARRIVAL at the declared
60fps), and the stats line divided every average by the number of frames
SUBMITTED, which deadline padding always fills to the full target. So the
pump reported "300/300 frames, avg render 12.4ms" while producing 60
distinct composites.
Helpers/FrameCadence.cs is one pure counter fed from three points:
MediaCaptureFrameSource logs the device's own SystemRelativeTime stamps
against our arrival times (TryAcquireLatestFrame keeps only the LATEST
frame, so falling behind is silent by design), and FramePump logs the
distinct webcam Epoch values that reached a composite. Read together they
name the culprit - device/USB, our reader, or our compositor - instead of
guessing. Render/resolve/wait now divide by the real render count, and
"frames per 5s from R renders" is the duplicate-on-lag ratio stated
honestly.
An out-of-order timestamp counts as a frame but produces no gap and does not
become the next interval's reference, so one bad stamp cannot invent a
multi-second hitch twice.
Also corrects a stale ai.md line (the "webcam renders at ~18fps" figure
came from a different session and counted every newly rendered layer) and
re-records the background-removal ruling as permanently out. Records the
agreed-but-unbuilt Units 1-2, the no-fallback-camera ruling, and the
SetFormatAsync dead end found by probing the 19041 projection.
Suite 402/402, 0 warnings. The one red, LayerReorderPersistenceTests'
RealMouseDrag, fails identically on a stashed clean tree - it needs an
interactive desktop session this shell lacks.
Push state was stale (said cc2d1ef, actual origin is 048cab7 with 5 local commits),
the RTMP entry still framed the binding as an open question, and the test count was
381. Also records the frame-pump jitter as unquantified and the creator's future
long-run benchmarking to-do as record-only.
Creator ruling: open in the upper-left of the screen with ~75px padding. The
restore path was placing the window below the fold.
RestoreWindowGeometry now ignores the saved position and calls the pure
TopLeftPlacement(workArea, w, h, padding): work-area origin + LaunchPadding
(75, in DIPs so it scales with DPI), clamped so the window is never pushed
off-screen. A window that fits stays wholly visible; one too big to fit falls
back to the work-area origin.
Size and maximized state are still persisted -- only position was dropped. The
size clamp is unchanged (saved values clamp to [MinWidth, work.Width], which is
why a saved 800x500 returns as 1366x768 per the XAML minimums).
The old clamp tolerated Left as low as work.Left - width + 120 and Top up to
work.Bottom - 80, ranges wide enough to leave the window almost entirely
off-screen while still looking like a successful clamp. A clamp that permits
the window to be unreachable is worse than none: it reads as a guarantee it
does not keep.
BroadcastPullOutTests is renamed ..._Window_Launches_TopLeft_... and now asserts
the constructor applies the derived placement for both an on-screen and a -99999
saved position. Placement math is covered by WindowPlacementTests as a pure
function needing no HWND.
Suite 395/395, 0 warnings.
root cause (proven, not inferred): contentDetails.boundStreamId on
liveBroadcasts.insert does NOT bind the broadcast. Our diagnostic reads
lifeCycle=created privacy=private autoStart=true bound=NONE
boundMatches=NO-BINDING | streamStatus=active
created is Google's "incomplete settings" state, so enableAutoStart has no
bound stream to fire from; close-out then 403s invalidTransition because
created -> complete is not a legal edge. A reusable stream accepts bytes for
a broadcast that isn't watching it, so a perfectly healthy 99MB RTMP session
is exactly what an unbound broadcast looks like -- every observable pointed
away from the binding.
Google's migration guide states the rule outright: a liveBroadcast must be
bound to exactly one stream before it can start, and is not bound at creation.
That was in the docs read during the original design and was not applied.
Shipped:
- BindBroadcastAsync -> POST liveBroadcasts/bind?part=id&id&streamId (no body),
called from PrepareAndStartLiveAsync right after CreateBroadcast, before the pump.
Failure is fatal to the session: an unbound broadcast can never go live, so a
live-looking UI over one is a lie.
- boundStreamId removed from the insert body, and the streamId parameter removed
from CreateBroadcast. A parameter that looks meaningful but cannot affect the
outcome is what made the misdiagnosis possible.
- Replaced CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert, which
asserted the disproven behaviour and therefore laundered a guess into a
guarantee, with tests for the omitted field and the real bind endpoint.
Error messages (creator ruling 2026-10-04): bind failures are not all "check the
sign-in". Six documented reasons exist and only one is auth; BindFailureMessage
gives each its own text, and the fallback passes the raw reason through verbatim
so an unmapped reason stays greppable. liveBroadcastBindingNotAllowed matters
most -- it means our ordering is wrong, and a generic re-auth message would hide
a bug in our own code behind a plausible excuse. See MyMistakes.md.
ai.md: also corrects the TASK 44 claim that liveChatId is only populated once the
broadcast is live. The diagnostic shows it present while lifecycle is created.
That claim was inferred from a sample's active-status guard, which is not a spec.
Suite 395/395, 0 warnings.
The run I asked for came back with three alarming lines:
lifeCycle=? privacy=? recording=? bound=NONE boundMatches=NO-BINDING
autoStart=unset autoStop=unset liveChatId=none | streamStatus=active
Every single broadcast field was empty, and the stream half was fine. That
combination is the tell: not a failed request, a wrong nesting level.
liveBroadcasts.list returns a LIST wrapper, so the broadcast is at items[0]. I
descended into items for the stream and read the response root for the
broadcast, so every lookup missed, and BoundMatches — handed a null it could
not distinguish from a real null — reported a confident NO-BINDING. The
diagnostic invented the bug it was written to find. Three lines of fabricated
alarm on the one run that was supposed to produce evidence.
Two fixes, and the second is the one that matters:
FirstItem() descends into items[0] for both halves, and returns default when
there is genuinely no item.
The line now prints boundMatches=unread when the broadcast could not be
read at all, so an absent parse degrades to "I don't know" instead of a
verdict. A diagnostic that guesses is worse than no diagnostic: this one
would have sent me off to rebuild a binding that may well be correct.
Test guards the regression: FirstItem_DescendsIntoTheListWrapper_AndReports-
AbsenceHonestly, including the empty-items and missing-items cases. Suite
390/390, 0 warnings.
Credit to the run for catching it. What it did establish, from the half that
worked: streamStatus goes ready -> active about 30s into the push. So the
ingest registers as active server-side, which combined with the 99MB muxed
yesterday means the stream half is verified healthy end to end. The broadcast
half needs one more run to say anything.
The creator's standing doubt: "I wasn't sure that the go live video would be
private." Answered by tracing every path that can touch privacy, not by
reassuring:
CreateBroadcast -> privacyStatus = "private" hardcoded
GoLiveViewModel -> Visibility => "Private" hardcoded
BeginTestStream -> StreamVisibility = "Private" hardcoded
UpdateBroadcast -> the only code that can change it — NO CALLER
UpdateBroadcast's sole caller in the repo is BroadcastPullOutTests.cs:120. It
is dead code. So there is no path today by which a broadcast this app creates
becomes non-private, and the "do not populate my channel with videos showing
the app at its worst" concern is fully met. Verified, not assumed.
Which makes the safety accidental rather than structural, and that is the part
worth writing down. LiveBroadcastFormViewModel still offers
VisibilityOptions {private, unlisted, public}, and UpdateBroadcast would honour
it. So the v1 visibility option is precisely the change that first wires that
method up — v1 is the moment private stops being guaranteed and becomes a
user-selectable default. Intended at v1, ruinous before, and "it's dead code,
let me call it" is not a valid reason to do it early.
Also recorded, having been absent from the map entirely: the TEST button is
hidden at v1. Its real job is being the guaranteed-private path (hence the
hardcoded Private at BeginTestStream:105), which the existing "dry-run"
comment on ShowTestButton does not capture — that framing is now known to be
incomplete, and must not be used to re-derive why the button persists.
Corrects a fact I got wrong one commit ago: the method is UpdateBroadcast, not
"UpdateBroadcastMetadataAsync", and I described it as an available retrofit
path when it is in fact unreachable in production.
Open, deliberately, for v1 rather than now: whether a dry run is still wanted
on its own merits once Test is hidden.
Asked twice about a decision that has been settled since 2026-09-01, so this
is a documentation failure rather than a question worth answering a third
time.
The note was already there and it was accurate. It still didn't work, which is
the whole lesson: every sentence in it was descriptive. It said why the lock
exists and where the unlock lives, and not one sentence told the reader what to
DO when it looks like a defect. I read it, recognised it, and asked anyway —
because recognition is not instruction. It also pointed at TASK 36 for the
unlock while the creator's actual answer is v1, so it was confidently wrong on
top of being non-directive, which is worse than saying nothing.
Rewritten as a prohibition carrying its own trigger: verdict in imperative
voice, then the observations that make it look broken (privacy=private in the
diag line, the PRIVATE badge, recordFromStart/DVR on, no delete call in the
codebase) so recognition fires the instruction instead of prompting a question,
then the retired delete question with its verified answer.
Also: it belongs to v1, not TASK 36, and UpdateBroadcastMetadataAsync:112 is a
retrofit path, not the product change.
MyMistakes.md gets the transferable part, because the fix is not "write it
down" — it is "write decisions as prohibitions, not rationale, and treat a
repeated correction as a broken note rather than an impatient user".
TASKS.md gets it under 1.0 gates so it is a tracked deliverable, not prose.
The logs answered the question and closed off a whole branch of investigation.
The long run: broadcast inserted 14:17:04.577, ffmpeg to
rtmp://a.rtmp.youtube.com/live2/5f6z-... at 14:17:05.686, and ffmpeg's own
muxing summary at 14:18:56.473 reads
video:101229KiB audio:1419KiB muxing overhead: 0.202949%
~99MB of real video plus audio over 112s, which is what 8000+128 kb/s is
supposed to produce. No socket error, no 4xx from ingest, no reconnect. And the
broadcast still never left `ready`; close-out 403'd invalidTransition again. The
8s run behaved the same way, so "YouTube needed longer" is disproven — 112s is
longer.
So the RTMP path is provably healthy. That is the finding worth keeping: it
kills the entire "the push is broken" theory, which is where this had been
heading for a week. The break is between ingest being accepted and the broadcast
being told to start.
Which puts the binding in frame, and the app was structurally blind to it.
Google documents contentDetails.boundStreamId and says enableAutoStart fires
"when you start streaming video on the bound live stream" — both valid on
insert, neither restricted against reusable streams. But the migration guide
also says a broadcast "is not bound to any streams at the time that it's
created", and the failure that produces is invisible from the RTMP side
precisely because a reusable stream will happily accept bytes for an unbound
broadcast. Ingest succeeding proves nothing.
Hence LogBroadcastDiagnosticsAsync, riding the existing health poll rather than
adding a timer. One greppable line: lifecycle status, privacy, recording,
boundStreamId *compared against the stream id we are actually pushing*
(BoundMatches: yes / MISMATCH / NO-BINDING / unknown), boundStreamLastUpdate-
TimeMs, autoStart/Stop, liveChatId presence, the stream's own streamStatus and
activeLiveChatId, and the CDN settings we declared.
Deliberately self-limiting: _broadcastSeenLive latches on live/liveStarting so
the diagnostic shouts only while the broadcast is stuck and goes quiet once it
is healthy. A permanent per-tick log line would be noise; this is the opposite.
One test, per the Good Dog rule: BroadcastBindingTests pins the matrix and
specifically pins `unknown` when there is no stream id to compare against, so a
missing expectation can never be misreported as a match — that is the failure
that would make this diagnostic lie.
Suite 387/387, 0 warnings.
The handoff asked for the RTMP diagnostic as item 1 and claimed the next code
unit was packaging. Both are now wrong, and per the map's own rule a stale
handoff is worse than none because the reader trusts it.
1. DIAGNOSTIC ANSWERED, and it narrowed the question rather than closing it.
ffmpeg reaches YouTube — rtmp://a.rtmp.youtube.com/live2/..., 7.4 MB video
plus 46 KB audio muxed, no socket error. The broadcast never leaves `ready`:
enableAutoStart=true was sent, yet close-out refused `complete` with 403
invalidTransition, which is only legal from `live`. Two things stay honestly
unknown — the push was only 8s, and the app never polls broadcast status
during a stream (only stream health), so the log cannot show whether or when
it flipped. Next step is one log line plus one 60-second test.
2. Packaging is DEFERRED, not next. Creator ruling 2026-10-04: still in active
development, so ship-time work waits. Item 1 of "Next" said the opposite.
3. Push state: origin/main = cc2d1ef as of 2026-10-04, tree clean. Stale push
state has now bitten twice here, so the line now tells the next session to
verify with rev-list instead of believing the file.
4. Test count 381, not 380.
5. New landmine: the frame pump logged 86 stalls per 5s (142 lines in an
8-second window) while stats still read 301/300 frames and 0 dropped. Those
two facts contradict each other; recorded as a measurement to take, not a
bug to fix from the log.
Startup stall (TRAX / unreachable NAS) shipped in cc2d1ef and is documented in
ai.md; not duplicated here.
Startup took 44s and 42 of them were one line. Measured from startup.log:
OnStartup 08:42:59 -> MainWindow loaded 08:43:43, with the whole gap inside
LoadLayout, ending at
TRAX: failed to load '\\192.168.50.86\llamaVault\Assets\Music\...':
The network path was not found. (0x80070035)
LoadTrax called MusicPlayer.Load synchronously on the UI thread, and
new MediaFoundationReader(path) does not fail fast on a dead SMB session — it
burns the full ARP/TCP/SMB timeout and only then throws. The NAS had moved to
.87 after the power outage, so an unreachable path became a 40-second frozen
window on every launch.
The rule, from the creator: a probe may time out; a bind should return
immediately. Only one side of that is achievable here, and the reason matters —
an unreachable UNC path has no fast-fail at all, so probing it on the calling
thread is the worst possible probe. (A device open is the opposite: it normally
fails fast on a sharing violation, which is why this bug is network-specific.)
So: IsTrackReachableAsync runs the probe on a thread-pool thread under a
Task.WhenAny ceiling (TraxProbeBudget, 2s) and treats offline/missing/slow as a
normal state rather than an error. Only a probe that passed gets the real bind,
and that bind deliberately stays on the UI thread alongside every other
MusicPlayer mutation — moving the whole load off-thread would have introduced a
race against PlayTrax/PauseTrax that does not exist today. The orphan probe
thread stays parked until SMB gives up; it is not the thread anyone waits on.
One test, per the Good Dog rule: TraxProbeTests.
AHangingProbeIsAbandonedAtTheBudget_AndReportsOffline stands in a probe that
never returns and asserts the caller is released and told "offline". Drop the
ceiling and it fails on elapsed time — which is the frozen window returning.
Suite 381/381, 0 warnings.
The 2026-10 power outage rotated the NAS off its DHCP lease: 192.168.50.86 is
dead, 192.168.50.87 answers. The app had the literal IP baked into
Music.TrackPath, so TRAX blocked ~40s on an unreachable network path during
LoadLayout before giving up (0x80070035) — an unbounded SMB open on the
startup path.
Two facts banked so this is not re-derived:
- Reach the share as \\llamavault\llamaVault\... The Windows hosts file pins
that name to the current IP, so an IP change is now a hosts-file edit, not
a database edit inside a running app.
- The IP is DHCP. Ping before trusting any hardcoded path.
DB repointed to the name (Music.TrackPath, one row); backup kept alongside it
as ytLlive.db.bak-20261004-090001. Startup log truncated 16.7MB -> 118KB
(AppLog.AppendAllText holds no long-lived handle, so truncating in place is
safe).
The pricing ruling landed twice (2026-09-21 one-time, then 2026-09-27 Store IAP
$10/mo + $400 lifetime) and the map never caught up. Six tracked files still told
a future session that billing was Polar and the price was $29/$49. A stale memory
map is worse than a stale comment: it is the file every session trusts, and this
one was actively steering work toward a deleted provider.
MONETIZATION.md (gitignored, so not in this commit) was rebuilt first:
- "Revenue projections" now carries a modelled Year 1 forecast —
pessimistic $4.0K / base $15.4K / optimistic $53.3K net, ~$22K weighted.
Funnel is installs -> 35-45% activate -> 1.5-3% of active users pay.
- Three findings recorded: Year 1 is ~97% lifetime transactions (subs net $392
of $15.4K, so MRR is not a Year-1 planning input); conversion dominates price
(~1pp of conversion = ~$7K net, and ADA 6(h) locks $10 anyway); subscriber
churn moves Year 1 by under $200 and is a Year-2 problem.
- Break-even restated for $400: $25K net = 74 lifetime buyers, not 862.
The price cut the volume requirement ~10x and raised the skill requirement
to match.
- Market sizing willingness-to-pay row rewritten. The 100K-400K TAM was
price-bound to $29-49 and is wrong at $400; revised ~20K-60K, flagged as an
estimate rather than a sourced number.
- Flagged a real contradiction: $500K ARR needs ~4,900 concurrent subscribers,
so the Exit section's ARR multiples do not describe what Years 1-3 produce.
The defensible asset there is lifetime cash flow, not ARR.
Tracked files corrected to match:
- ai.md: paid-delta, pricing and billing bullets rewritten; Polar product id,
org id, LCYT- key prefix and LLAMAFOUNDER/LLAMA50 marked as deleted;
15% share + net-after-tax recorded with the ADA 6(b)(i) citation.
- TASKS.md: catalog row retitled as a teardown; the "still to verify — the
price is not decided" note closed (it contradicted the ruling 20 lines
below it); the OverlayHost "Polar unlocks alerts" open question marked
resolved; Polar file-hosting deliverable removed from the release build.
- task-10: reframed as a teardown. Steps 4-5 marked as surviving, 1-2-3-6-7 as
deleting, 8 as a compliance action (Velopack downloads AND executes, which
Store policy 10.2.2 does not permit).
- task-36 item 5 (license-expiry reminders) struck — no key, no expiry, no
lapse under Store IAP, so there is nothing to remind anyone about.
- Distribution.md 4.2 / 4.4 / 4.5 marked obsolete; 4.5's "already implemented"
corrected, since PolarLicenseService is being deleted rather than shipped.
- README: "one-time license" -> "paid tier".
Historical Polar sections are retained deliberately — the reasoning is reusable
(merchant-of-record, fee comparison), only the conclusions are dead. Each is now
labelled so it cannot be mistaken for the plan.
No code touched.
Correcting the handoff against the repo, per the rule that a stale map is worse
than no map. Three facts were wrong or missing, and the first two would have
made the next session's first action wrong.
1. PUSH STATE. "Last pushed: e7cded6" with two commits listed as unpushed.
main was actually 9 commits ahead of origin/main. The line under-reported
unpushed work by 7 commits, which is the optimistic-by-construction failure
MyMistakes.md already names for a handoff whose heading promises the work
is live. Now recorded as caught up.
2. TASK 50 WAS ABSENT, AND IT CARRIES A LIVE BUG, NOT A QUEUE ITEM. The task
was carved 2026-09-28 and its mechanism section was CORRECTED the same day
against the creator's own channel: 20+ private videos, all content-less
placeholders. That is not a deferred feature, it is the real explanation of
the 2026-09-26 "I cannot get private test recordings saved" report the map
had been carrying as a closed item. liveBroadcasts.insert succeeds and
creates the placeholder; the RTMP push / LIVE transition / archive chain is
what fails, so no video content is ever produced. Added as an OPEN entry
with the single diagnostic question that localises it (does ffmpeg reach
a.rtmp.youtube.com, and does the broadcast reach LIVE), plus the sequencing
constraint: do NOT flip enableAutomaticArchiving before TASK 50 ships, since
archiving is currently the only route a recording takes to YouTube.
It also carries an open 11.12 re-check — a publish feature makes the app
PUSH creator content to a third party, so the IARC answers must not describe
an app that cannot publish. That is a certification-ordering constraint, not
a code detail, and it belongs where the next session will read it.
3. TEST COUNT WAS STALE. Recorded 379; the suite is 380 (the force-ssl scope
test added one). Re-verified on a clean test-project build.
Also recorded a new flake honestly rather than leaving it for the next session
to rediscover as a regression: MasterLimiter_CapsTheLiveMix_OnThePipe failed
once on a full-suite run with Expected 0.891 / Actual 0, then passed 27/27 as a
class run and 380/380 on an immediate re-run. It reads the live mix off the
named pipe, so a ZERO is the pipe not being fed yet, not the limiter
misbehaving — the same whole-suite scheduling family as the RealAppHost STA
landmine. Recorded with "re-run before believing it".
Next list: the RTMP diagnostic sorts first because it is an observation rather
than a build and it gates a queued task; the packaging unit remains the next
CODE unit, which is not a reordering of the creator's priorities.
verify.sh: clean build, 0 warnings, 380/380, scope check passed.
The trap I hit twice today — a file-wide DoesNotContain matching the doc
comment that explains the ban — was possible because three tests each
hand-rolled their own File.ReadAllText + path-walker, two of them mine. Fixed
at the root rather than per-test: SourceAssert now owns path walking and
literal extraction, with Literal(memberName, params parts) and Scopes() that
assert on the parsed VALUE. Tests that genuinely assert structure (the #if
DEBUG arm in InstanceIsolationTests, the absence of a whole API in the
locator) still read the file themselves, because those claims cannot be
reduced to a value — the helper is for literal facts only.
Shipping the fix introduced two defects, both caught by the build: a method
named Path shadowed System.IO.Path and broke the error message that names the
missing file, and Literal(fileName, memberName) invited a call that silently
resolved to a nonexistent path. Argument order is now required-context-first
so a wrong path is visible at the call site.
Suite 380/380, 0 warnings. LayerReorderPersistenceTests.RealMouseDrag flaked
once mid-session with no ytLive instance running and passed in isolation and
on re-run — the environment flake already recorded in HANDOFF.md, not a
regression from this change.
force-ssl is gone from the requested scope. Its grant ("see, edit, and
permanently delete your videos, ratings, comments and captions") is a strict
subset of auth/youtube's ("Manage your YouTube account"), and Google's own
liveBroadcasts.insert reference lists auth/youtube as an accepted ALTERNATIVE
to force-ssl, so every endpoint this app calls — channels, liveBroadcasts,
liveStreams, liveChat, playlistItems — is covered by the one we keep. Two
consent lines became one, and one fewer scope to justify. Removing a scope
cannot narrow capability when the scope being kept is the broader of the two,
and there is no forced re-consent: a refresh token keeps the scopes it was
originally granted, so existing sessions are untouched.
The playlistItems change is NOT being made, and the map entry recommending it
was wrong. It came out of the verification hunt on the theory that public data
should not ride on user credentials. Walking the code kills it: ChannelStats-
Service is built with one HttpClient, and channels?mine=true on the same
service genuinely needs the session. Splitting it means a second unauthenticated
client PLUS a Google API key, since every YouTube Data API request needs a
credential and the call 403s without one. So the fix would ship a new
extractable, quota-abusable credential inside the binary to replace a token the
app already holds, buying zero verification-surface reduction (the data is
public, no scope involved) and zero correctness. Strictly worse. Recorded as a
do-not-do so it is not "helpfully" reintroduced later.
Test: ExactlyOneScopeIsRequested_AndItIsTheBroadOne asserts on the const's
parsed VALUE rather than scanning the file — a file-wide DoesNotContain
fails on the doc comment that names force-ssl to explain its removal, which is
the same trap I hit and documented earlier today in the locator test. Suite
380/380, 0 warnings.
Store policy 10.2.2 forbids dynamic code inclusion, and this was the only
third-party binary the app ever downloaded and ran. It had already failed in
production: the pin aged out of BtbN's 14-day retention and the download 404'd
on the creator's first real recording attempt (2026-09-01). Bundling kills
that failure class permanently and deletes the startup network dependency.
- scripts/fetch-ffmpeg.sh populates a gitignored ./ffmpeg from a pinned
month-end BtbN LGPL-shared artifact, SHA-256 verified before extraction,
idempotent, FFMPEG_ZIP= override. 135 MB of DLLs in git would tax every
commit on this 9p tree; the URL + hash are the audit trail LGPL §6 wants.
- ytLive.csproj copies ffmpeg\** to output and publish (Always), and a
GuardBundledFfmpeg target fails the BUILD if the folder is missing, so a
fresh clone finds out before a take rather than during one.
- FfmpegLocator loses the downloader, the zip extraction and the
%APPDATA%\ytLlive\tools cache. Order is now bundle -> PATH, deliberately
inverted: a PATH ffmpeg may be a different major version or a GPL build,
and the bundle is what was pinned, license-vetted and tested. A missing
bundle throws loudly; the old "found something on disk" path could satisfy
a missing bundle with a stale copy, and silent drift is the worst failure
mode for a dependency certification inspects.
- ffplay.exe (18 MB) is not shipped; nothing calls it.
- THIRD-PARTY-NOTICES.txt: "downloads" -> "bundled" throughout, and the stale
build tag autobuild-2026-08-09-13-03 — the tag that 404'd — corrected to
autobuild-2026-08-31-13-27 with the exact artifact name and archive hash.
OpenH264 made precise: compiled INTO avcodec-63.dll, no separate binary.
Checked whether other third-party binaries needed bundling: no. e_sqlite3.dll
and WebView2Loader.dll already ship via NuGet into runtimes\win-x64\native\,
OpenH264 is inside avcodec-63.dll, the rest are managed. Two gaps remain that
bundling cannot fix, recorded in the map: the WebView2 Evergreen runtime is a
machine prerequisite (TASK 48 item 2), and Velopack's updater downloads and
executes, so its teardown is a compliance action.
Tests: 16 locator facts, suite 379/379, 0 warnings. The integration test runs
the bundled ffmpeg -version, which is the only assertion that proves the
sibling libav*.dll resolved — presence checks pass with the DLLs missing and a
shared build then fails to start. TheLocatorCarriesNoNetworkCapability keeps
10.2.2 from rotting back.
Analysis: TASKS/research-store-certification.md §4.
The 2026-09-27 entry sat under a "NEXT STEP" heading and framed the Google
verification as the top priority. It was neither un-attempted nor un-blocked:
the attempt had already failed on a Google-side bug and the creator had
already ruled it unimportant for where we sit. A "next step" heading lends a
dead end borrowed authority, and the next session would have opened the
console instead of the editor.
Records the ruling in the terms that change behaviour: attempted, died on
Google's side, ruled unimportant, do not re-attempt. States the cost of
tabling it honestly — an accepted 7-day refresh-token expiry while the
consent screen sits in Testing, which is why the creator kept having to
re-consent. The sensitive-vs-restricted classification was never read and is
moot until a second real user or Store submission.
The mechanism of the Google-side bug was never recorded and is left
deliberately blank rather than reconstructed — a confident fiction in a
memory map is the one sentence the next session would trust. The project
number and console link stay in ai.md:233; they cost a hunt and are still true.
The two code scope cleanups survive the tabling (neither needs verification
to land): drop the redundant force-ssl scope, and move the uploads-playlist
fetch off the user's session token. The channels?mine=true call on the same
service legitimately needs the session — flagged so it is not "fixed" later.
No code touched. Test state unchanged.
Creator-confirmed: the Cloud project (number 198374416003, from the ClientId) is
owned by llamachile.shop@gmail.com. Recorded because finding it cost a hunt, and
because the trap was assuming it was the same account as the YouTube channel used
in the app -- they are separate logins.
Also records what the repo had nothing on at all: the OAuth verification path, the
Testing-mode 100-user cap + 7-day refresh-token expiry that explains the repeated
re-consent, the sensitive-vs-restricted fork (a paid annual CASA assessment if
restricted), and two scope cleanups -- auth/youtube.force-ssl is redundant, and
ChannelStatsService's playlistItems.list needs no OAuth scope at all.
Docs only, no code touched.
Reported 2026-09-27: click avatar -> Change Account -> cancel in the Google
chooser -> back to a signed-out app, then "cannot sign-in because the callback
url was not released".
Two independent defects:
1. ChangeAccountAsync ran `await SignOutYouTubeAsync(); await SignInAsync();`.
SignOutYouTubeAsync -> TokenStore.Clear() -> deletes ytLlive.auth, so the
working account's token was destroyed before the browser even opened. It is
now just `await SignInAsync()`: success overwrites the token via the
sessionChanged hook, cancel/failure keeps the current session. The explicit
sign-out was never needed.
2. Abandoning the browser gives the app no signal, so the OAuth attempt held
localhost:8765 for its full 5-minute timeout and a retry could not bind. A
new AuthenticateAsync now cancels and awaits the abandoned attempt first.
That takeover is serialised by a SemaphoreSlim: the first version cleared
the in-flight marker before awaiting, so a caller arriving in that window saw
a free port and raced to bind it -- reproducing the very failure it was
written to prevent. My own test caught that; reasoning had "proved" it fine.
Also adds an `openBrowser` ctor seam so tests never shell out to a real
browser, and corrects the ai.md line that documented "Logout / Change Account ->
SignOutYouTubeAsync" as intended behaviour.
Test: YouTubeAuthServiceTests.RetryingAfterAnAbandonedAttempt_ReleasesTheCallbackPort
Full suite 370/370, 0 warnings. Coverage gap noted in HANDOFF.md: the ordering
fix has no automated test because _youtubeAuth is hardcoded in MainViewModel
and ChangeAccountAsync is private.
The reported cross-instance effect is NOT this cause and stays open -- no code
path can sign out a second instance (per-instance TokenStore, local delete, no
server-side revoke).
The dev multi-instance use case (one instance screen-capturing the other for
marketing/training videos) exposed a real bug. BuildStartPath in
MainViewModel.Recording.cs builds the start name with no dedup:
var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
var path = Path.Combine(folder, name);
Only FinalizeRecordingAsync calls UniquePath. So two instances that start
recording in the same minute computed the identical path
(ty-20260927-1454-0000.mp4) and both ffmpeg processes wrote one file -- a
corrupt take, not an error message.
Creator's fix (2026-09-27): add a millisecond count to the filename timestamp.
New format is ty-<yyyyMMdd>-<HHmm>-<fff>-<hh2mm2>.mp4. Both the start name and
the final name derive from _recordStartTime, so the ffff segment survives the
rename-on-stop and the final name cannot collide with another same-minute take
either. No UniquePath call needed, which keeps the start path a pure function of
the timestamp and the existing collision suffix behaviour intact.
Tests: 2 new (the same-minute guard and the rename-survival guard), 5 existing
assertions updated for the format. RecordingSaveDialogTests' stem fixtures
updated to match. 369/369 pass, 0 warnings.
Note: the first test run reported 367/367 because only ytLive.csproj had been
built -- the stale test DLL never picked up the new tests. The real count is 369.
Reconcile the pending doc edits with the creator's actual ruling, and close the
revenue-share question that was blocking the monthly price.
Verified from the App Developer Agreement v8.10 PDF itself (downloaded and
text-extracted, not a search excerpt). Section 6(b) has only three tiers:
6(b)(i) 15% for Apps and their In-App Products *not listed in* 6(b)(iii);
6(b)(ii) 12% Games-only; 6(b)(iii) 30% for Xbox console apps/games, Xbox
non-subscription IAP, and Windows 8/Phone 8. LlamaCasty is a Windows PC App,
so 6(b)(i) governs BOTH tiers -- there is no subscription surcharge. The
agreement's own changelog (v8.0, Oct 26 2017) states it outright: "implement
the 85/15 revenue share for non-Game subscriptions."
=> $10/mo nets $8.50 (~$102/yr); $400 lifetime nets $340.
Also confirmed: the 15% applies after VAT/GST (Net Receipts definition),
payouts are monthly above a $50 threshold, and no better small-indie rate
exists in the standard terms.
Creator ruling recorded: $10/mo subscription or $400 lifetime, no annual, no
.99. This supersedes the 2026-09-21 one-time $29 -> $49 model.
Two obligations ADA 6(h) attaches to the recurring tier, recorded because they
change its risk profile and are the reason lifetime is the hedge: we must
fulfil the subscription for the entire period as marketed (on breach Microsoft
may refund the full amount plus taxes in its sole discretion), and raising the
price disables auto-renew -- so $10 is effectively locked for the product's life.
Stale facts fixed in the same change rather than appended:
- research-store-certification.md: IARC was 11.11.1/11.11.2 in one table and
10.11.1 in another; corrected to 10.11.x.
- research-store-certification.md: policy 10.8.1/10.8.2 still asserted "Polar
explicitly permitted" and "tick the third-party purchase box". Void now that
Store IAP is the route and Polar is being torn down.
- task-48: the working YouTube demo account (10.3.1) was accidentally dropped
from the certification list during the Store-IAP edit. Restored -- a reviewer
cannot use the app without one.
- MyMistakes.md: the verified-fact-vs-decided-outcome lesson now closes its
loop (the creator did rule the way the research pointed), and records the
over-correction that followed.
Docs-only. No code, no build, no tests.
The creator raised it as instinct: "who hasn't been screwed over cancelling a
sub early to be told that the extra six months remaining are your loss?" Checking
the mechanism confirms it, and worse than expected.
Microsoft's general position: "Digital goods like apps, add-on content,
subscriptions... aren't refundable unless the offer or applicable law states that
you're eligible for a refund." Pro-rated refunds on cancel exist only in Canada,
Denmark, France, Israel, Korea, Turkey (all lengths) and Finland, Germany,
Netherlands, Poland, Portugal (renewals only). Critically: "monthly subscriptions
and initial (pre-renewal) purchases aren't eligible for a prorated refund."
A first-year annual subscriber who cancels in month 2 loses months 3-12, in most
countries, and the developer cannot refund it. That is verbatim the trap the
creator named, so we must not build it. => monthly only. Max loss $10, max
grievance a rounding error, one fewer product to declare, less support surface.
The chargeback argument gets stronger, not weaker: a customer down $89 on an
annual cliff disputes through their bank, which is frozen funds and account risk
against a solo dev. Monthly bounds that at $10.
Also records two rulings:
- No .99 prices. "Let's stop with the x.99 stuff - I find that irritating. Just say:
ten bucks a month." The convention only makes a price look cheaper, which is
incoherent for a brand sold on honesty and no-dark-patterns.
- No feature gating, now argued as revenue rather than taste: the free tier's reach
is the funnel and the branding flash is an ad running inside other people's
content.
Pricing model itself stays OPEN in TASKS.md — $10/mo is the lean, and a lifetime
product (~$300, the hedge against the no-moat renewal problem) is undecided. The
Store revenue share is still unverified: confirm the net, not the list.
MyMistakes.md records the actual error: I had offered "just refund anyone who
asks" as a mitigation without checking who holds the authority to execute it.
Store refunds run through Microsoft, not the developer. The check that followed
is what produced this constraint.
MONETIZATION.md got the full analysis but is gitignored, so it stayed local.
Creator ruling 2026-09-27. Criteria, verbatim: "zero headaches, minimal
maintenance (for me) while still providing accountability and a reasonably
easy upgrade flow." Route A is the only combination where all four are solved
by handing the work to Microsoft rather than to a certificate vendor: $0/yr,
no certificate, no HSM, no annual renewal, no SmartScreen ramp — plus Store
auto-update, Store-side payments/entitlements/refunds/support, and Microsoft
review as the accountability layer.
The rejected options and their reasons stay in research-store-certification.md
§3 so a later session reads the ruling instead of re-deriving it.
What this deletes:
- The entire licensing backend. PolarLicenseService, PolarLicense,
MainViewModel.License.cs (PremiumUrl, customer portal, the OfflineGracePeriod
= 14 days subscription-era artifact, renewal/lapse copy) and the wrong
"Polar unlocks alerts" string all become dead code. IsPremium is derived from
the Store entitlement instead of an HTTP call, which also removes the whole
"network flaky -> app thinks I'm expired" bug class.
- Velopack, the update URL, and the self-hosted droplet — the Store updates.
- Distribution.md's premise: Polar as the distribution backbone, Polar file
hosting, and code signing as our problem. The IP-protection sections (1, 5,
6, 7) still stand and the build-posture ceiling is unchanged.
What does NOT change: the entitlement. Free gets everything; the branding
flash stays the only paid delta. Store IAP changes how IsPremium is obtained,
never what it gates.
Still open, deliberately: the price. The Store revenue share is unverified (do
not assume a percentage), and MONETIZATION.md's $29 -> $49 one-time decision is
re-opened against a fresh instinct toward ~$99/yr. No price encoded yet.
The first code unit is unchanged: bundle ffmpeg (TASK 48 item 1). That clears
the one hard certification gate and fixes a real user-facing 404.
MARCOM.md and MONETIZATION.md were edited too but are gitignored by design, so
those changes stayed local.
The distribution answer existed only in conversation, so every session re-derived
it. It is now in the map, and the route decision is explicitly parked as the
creator's.
new TASKS/research-store-certification.md — Store Policies 7.20 + MSIX packaging:
which policies bind, which don't (and why), cert economics, camera/mic gating
layers, YouTube age + COPPA, the 11.12 UGC judgment call.
Two real defects surfaced, neither fixed (docs-only unit):
- FfmpegLocator downloads an unsigned exe from GitHub and runs it. That is
policy 10.2.2 (dynamic code inclusion) verbatim, and it is the root cause of
the 2026-09-01 404 — the pin aged out of BtbN's 14-day retention on the
creator's first real recording attempt. -> TASK 48 item 1, not
Store-conditional.
- Distribution.md:318 recommended a $400+/yr EV cert for a SmartScreen bypass
Microsoft removed in March 2024. Fixed; had it shipped it would have cost
$400+/yr to buy what $150 buys.
Also new: TASKS/task-48 (checklist, carved out of TASK 36 item 6) and
TASKS/task-49 (chat profanity filter, not blocked). ai.md gains the durable
invariants — full trust or recording breaks silently, chat is rendered never
stored — plus a correction to the FFmpeg locator section. MyMistakes.md records
the lesson: a policy citation is a claim about scope, not just text.
MARCOM.md got the privacy-copy guard but is gitignored by design, so that edit
stays local and did not travel here.
Creator 2026-09-26: "the ticker should appear over the stream alerts video, not over
the entire preview window". It was a GLOBAL 1920x48 bar blitted last at a hardcoded
(0, 0) -- it covered the whole preview width, not the alert video.
The strip is now rendered at the alert box's own size and the box's origin travels on
the frame in a new VideoFrame.Placement ((int X, int Y)?). OriginX/OriginY default to
0 when Placement is null, so every full-canvas overlay (the branding flash) is
unaffected. Every ticker blit site now reads tickerFrame.OriginX/OriginY instead of a
literal 0, 0 -- SceneCompositor x2 and FramePump's static-bake Overlay path -- and
PreviewPane.xaml's AlertTickerElement binds the same rect (AlertTickerLeft/Top/
Width/Height), so the preview cannot drift from the recording the creator is judging.
Why the position rides on the frame rather than in a full-canvas frame: a 1920x1080
overlay is 8.3MB of large-object-heap garbage per tick, ~2.5GB churned over one 10s
alert at 30fps. A box-sized strip is ~370KB. The branding flash does render
full-canvas but RECYCLES one 8MB buffer and bumps Epoch, so it never allocates per
frame; the ticker allocates per call, so it has to stay small.
Also fixed: CopyStrip now clips ROWS to the target height. The pill rasterises at its
natural 48px, so an alert box shorter than that would have written past the end of the
target buffer once the strip became box-sized.
No alert box in the scene now means no ticker at all -- there is no global position
left for it, and this is the guard against the old bar quietly coming back.
Tests (3 new facts, 27 in the file):
ComposedOutput_PutsTheTickerInsideTheAlertBox_NotAtTheTopEdge -- renders through
the real SceneCompositor and asserts the pixels land at (620, 430) and NOT at the
top-left corner. The creator is judging compositing from local recordings, so the
OUTPUT side is the side that needed proving.
ASceneWithNoAlertBox_PublishesNoTicker
ABoxShorterThanTheStrip_ClipsRowsInsteadOfOverrunning
Updated AlertLayer_PublishesARealTickerFrameToThePreviewSink, which asserted the old
1920 width from a box with no geometry (defaulted to 1px); it now uses the product's
own default box (680x200 at 620,430, MainViewModel.Sources.cs:54-57) and pins the
frame size and origin.
Full suite 367/367.
Creator 2026-09-26: "the made with llamacasty flash should appear in all scenes,
not just live" and "should also appear in recordings". The presenter was
Start()/Stop()-ed from UpdateLiveVisuals()'s IsLive branch, so a recording made
WITHOUT ever going live carried no credit at all -- the exact case the creator hit
while judging compositing from local recordings.
It is now Start()ed once in the MainViewModel ctor and never stopped on live-state
churn. One start covers every scene, the preview, the stream and the recording,
because go-live and local recording are the SAME FramePump: both
Streaming.Operations.cs:68 and :199 call StartAsync with the same brandFlash:
delegate. Verified by reading both call sites, not assumed -- there is no second
encoder path that needed a "redirect". The licence gate needs no live branch at
all: IsPremium's setter already pushes BrandFlashPresenter.Enabled from anywhere.
Fixed a trap that app-lifetime exposed: Enabled = false stops the presenter's
DispatcherTimer to cut the advertisement mid-credit. When Start() was per-go-live
the next go-live restarted it; with a single app-lifetime Start() nothing would,
so a key entered mid-session would leave the credit dead until the process was
restarted. The setter now restarts the timer when re-enabling while _running.
Tests (12 facts in BrandFlashOutputTests, +2):
Credit_IsComposited_WithNoLiveSession_AndSoARecordingCarriesIt -- drives a real
never-live VM past the 5s first-flash delay and asks the frame the pump would
composite. Uses a new internal AdvanceBrandFlash seam; because Advance only
advances the cadence while the presenter is running, a credit coming out
proves Start() happened at construction.
ADowngradeMidSession_RestartsTheCadenceTimer -- asserts the premium/downgrade
edge decision directly (IsCadenceTimerEnabled) instead of sleeping through a
30-60s interval, which a synchronous test body cannot observe.
Full suite 364/364.
The creator reported that clicking Cancel in the post-recording save prompt still
saved the file under the default name: "cancel should cancel the save option,
discarding the recording" — Enter is what accepts the default.
The modal was always correct (Enter -> DialogResult=true, Cancel/Escape/X -> false).
The bug was in the caller: FinalizeRecordingAsync only overwrote `stem` when the
dialog returned true and then ran File.Move UNCONDITIONALLY, so a falsy result fell
straight through into the save. The nullable stem made "I don't want this" look
like "I accept the default".
Extracted the decision into internal MainViewModel.CompleteRecordingSave so it is
testable against real files without a frame pump:
creatorSaved == false (Cancel/Escape/X) -> delete the temp file, leave the
videos folder empty; a failed delete returns DiscardFailed and the toast
names the file + folder (a "discarded" recording still on disk is worse
than no report).
creatorSaved == true (Save, or Enter on the pre-filled default) -> File.Move.
A blank box still means "keep the auto name", but only on an explicit Save.
Test: ytLive.Tests/RecordingSaveDialogTests.cs — 5 facts over real temp files. The
headline asserts Cancel leaves the directory EMPTY, not merely "the stem is
unchanged"; a modal's own test cannot cover the decision it feeds.
Full suite 362/362 (the RealMouseDrag flake passed this run).
Pre-1.0 the creator streams in one instance and screen-captures it from
another. Nothing prevented a second instance - there is no single-instance
mutex and no port anywhere. What broke it was SHARED STATE, and two of the
collisions were hard failures rather than annoyances:
- the layout DB. The model is read-whole-scene / write-whole-scene, so two
instances saving different layouts clobber each other.
- the WebView2 user data folder. Chromium takes an exclusive lock on it, so
the second instance of the same exe does not start at all.
- the auth token store. A test instance would overwrite the real YouTube
sign-in with its own.
- startup.log, where two appenders interleave and a crash in either instance
becomes ambiguous.
Set YTLIVE_INSTANCE=<id> and the process gets a private root at
%APPDATA%\ytLlive\instances/<id>/ for those four. Recording folder and the
ffmpeg tools cache stay shared on purpose - the cache should be shared, and
the creator picks the record folder. Global hotkeys stay un-namespaced: if
both instances register the same one, Windows refusing the second is the
correct answer.
An id that is not letters/digits/dash/underscore is rejected and degrades to
the primary profile, so the variable can never walk out of the profile
directory or name a UNC path.
The entire implementation is inside #if DEBUG. A Release build compiles to
DataRoot => DefaultRoot and WebViewDataFolder => null, and the call sites are
unconditional so Release cannot drift by forgetting an #if. The harness lives
with the tests: InstanceIsolationTests covers the two-identities contract,
the primary-instance no-op, per-instance WebView folders, path-traversal
rejection, that the real output paths actually move with the profile, and a
source-level assertion that the #else arm IS production behaviour.
Verified against a real Release build: the InstanceVariable field is absent
from its metadata and no "instances" path segment survives, while DataRoot
and WebViewDataFolder are present in both configurations. Grepping for
YTLIVE_INSTANCE proves nothing - a const is inlined at compile time and
appears in neither build, which cost one wasted verification round.
Docs for this unit (the InstanceProfile paragraph in ai.md, the 1.0 gate in
TASKS.md, and the MyMistakes/HANDOFF entries) landed in the previous commit,
because they share those files with the branding-credit work.