fix(persist): one shared z-order across Source + WebcamSceneConfig rows

A webcam dragged between sources reverted to the bottom of the stack on
every relaunch: Source and WebcamSceneConfig each carried independent
per-type SortOrder counters, and Load appended all Sources before all
configs. Save now stamps both tables' SortOrder from the element's index
within scene.Elements; Load merges the two tables' rows by that shared z
(sources-first tie-break preserves legacy rows). Cross-type reorder now
survives a fresh LayoutStore reload.

Task 37 queued: defaults vs current layout split (creator directive) —
capture out-of-scope work in TASKS.md rather than folding it in.
This commit is contained in:
2026-09-20 09:23:46 -07:00
parent f91bf8715d
commit 5a1993b6db
7 changed files with 327 additions and 160 deletions
+83 -142
View File
@@ -1,148 +1,99 @@
# HANDOFF — 2026-09-18 (drag-reorder persistence proven via real mouse input + recording-dialog height, both local — no push)
# HANDOFF — 2026-09-20 (cross-table z-order bug fixed: webcam between sources survives reload; defaults/current split queued as TASK 37)
## Branch / Commit State
`main` HEAD = **Rename Recording dialog 20% taller + teardown unhooks the full-screen hook** (local).
Below it: the layer drag-reorder persistence fix (`LayerReorderPersistenceTests`), then webcam work.
**Real-input drag verification test added on top** (unpushed, see below).
`main` HEAD = **the cross-type ordering fix** (local — no push). This is a CONTINUATION of the
2026-09-18 drag-reorder work: the creator kept insisting the reorder "doesn't save" even after
commit `f91bf87`. Their repro was real and the per-table `SortOrder` theory was the actual mechanism.
**No push yet.** Working tree clean.
**No push yet.** Working tree clean after the ordering-fix commit.
## ⚠️ Branding (2026-09-14, creator-corrected): product = **llamacasty**, internals = ytLive
## ✅ Committed — cross-type z-order: webcam between sources survives save→reload (2026-09-20)
Product is **llamacasty**; repo path, csproj `AssemblyName`/`RootNamespace`, DB/log paths
(`%APPDATA%\ytLlive\...`), most code names are legacy **ytLive/ytLlive**. User-facing text:
"llamacasty" / UI labels use the catalog names ("Web Cam", "YouTube Chat", "Countdown Timer"…).
**The actual bug the creator reproduced** (`f91bf87` shipped the immediate-save at drop, but the
reorder still reverted on restart): `Source` and `WebcamSceneConfig` are separate tables with
**independent per-type `SortOrder` counters**. Save stamped sources 0..n and configs 0..m;
`LayoutStore.Load` appended **all Sources first, then all WebcamSceneConfigs**. A webcam dragged
between two sources was structurally unrecoverable — the live scene (Background, YouTube Chat,
Image, Web Resource-0 sources + one HD Pro Webcam C920 config) reverted the Webcam to the bottom
of the stack on every relaunch.
## ✅ Committed — webcam app-default gate + attainability (`9d00955`, `75723ac`)
**Fix:** one shared z-space per scene. `Save` stamps both tables' `SortOrder` from the element's
index within `scene.Elements` (the non-source branches bump the counter too); `Load` merges the two
tables' rows **by that shared z** instead of appending tables. Legacy pre-unification rows can tie at
0 — ties break sources-first, so old data reads back exactly as it saved.
**Background:** the DB truth (`%APPDATA%\ytLlive\ytLlive.db`; NOT `layout.db`, a 0-byte legacy
file) put the single `WebcamSceneConfig` in **Chat**, so the old TASK 26 app-wide rule grayed
Live's "+" → Web Cam even though the camera was available and in use. The creator ruled (rigorous
resource model): **the webcam is an app-level resource — ONE app-wide default selection, usable in
every scene that can host it; it is NOT blocked by other scenes' placements.** Scope of this slice:
the availability layer. TASK 26's app-wide gate is **superseded** by creator directive.
**Good Dog test** `CrossTypeReorder_BelowTheWebcam_SurvivesFreshReload` (`LayerReorderPersistenceTests`):
seeds the builder's real Live layout (4 sources + `HD Pro Webcam C920` config row), reorders
"Web Resource-0" DOWN below the webcam, calls `OnSceneElementsReordered`, then opens a **brand-new
`LayoutStore` on the same temp DB — the restart** — and asserts the cross-type order holds. **309/309
tests pass, build 0 warnings.**
- **Per-scene gate:** `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && IsWebcamAttainable`.
A scene already hosting the webcam stays gray (one webcam per stream); another scene holding it
does NOT gray the row elsewhere.
- **Attainable = a live lock, not a saved identity** (`75723ac` refinement): `IsWebcamAttainable` =
`_webcam != null && CameraManager.IsRunning(_webcam.DeviceId)`. An identity whose camera is
unplugged / can't start leaves the row greyed (tooltip "No webcam is currently available…"), and
it un-greys the moment a session is running (startup lock, first frame, picker + acquire).
- **Add places the default directly:** when attainable, "Add Webcam" creates the placement with NO
picker; the Windows picker runs only for the initial selection (`_webcam == null`).
- **Identity survives last-placement removal:** `RemoveElement` no longer nulls `_webcam` (that was
the TASK 26 rule). `LayoutStore` writes the singleton whenever it's non-null, so it persists.
- **Startup lays an app-wide BASE lock:** the single-camera branch ALWAYS acquires (the old
`IsRunning` skip is gone) — on an already-running session it just bumps the refcount, and that ref
is the app's own hold. Result: removing every scene's placement leaves `RefCount = 1`, the session
stays alive, and the row stays offered (the app default outlives the scenes). It still **adopts a
solo camera as the app default** when no identity exists, so a clean layout offers Web Cam at once.
- **Dynamic why-gray tooltip:** `WebcamAddToolTip` (raised wherever the gate can flip: staging,
removal, startup lock success, first frame, camera failure, identity swap) — "Already in this
scene — one webcam per stream. A second camera means you've graduated to OBS." / "No webcam is
currently available — plug one in, or allow camera access in Windows." / "Adds the app default
webcam to this scene."
- **Good Dog integration tests** `WebcamMenuGateTests` (real app + temp DB + camera seams): (1) Chat's
identity does NOT gray Live; Add in Live places the same `wc-1` without a picker; scene-with-
placement stays gray; identity survives both removals (DB row count keeps 1, session kept by the
base lock). (2) **Negation:** identity loaded but its session can't start → row NOT offered.
`WebcamStartupResourceTests` test 1 strengthened: after startup the single camera is adopted →
`CanAddWebcam` true. **305 tests (304 pass + the known audio flake, see Landmines); build 0 warnings.**
**Also committed:** TASK 37 queued — **defaults vs current layout split** (creator directive
2026-09-20): the DB should hold the static brandable `default` layout; the `current` layout carries
the build-id (loaded only when it matches `BuildStamp.Id`), enabling one-click revert. Conceptual
shape `scene[0-4].layerList.[default|current].elementList`; config-file vs second-rows-in-DB not yet
decided (`TASKS/task-37-defaults-current-split.md` has both + facts). **Process rule established:**
capture out-of-scope needed work in TASKS.md rather than bolting it onto the in-flight change.
**Task docs:** `TASKS.md` Open items + `ai.md` Webcam section updated (supersession recorded).
## ✅ Committed earlier — layer-list drag-to-reorder persists (2026-09-18, `f91bf87` + below)
## ✅ Committed — layer-list drag-to-reorder persists (creator-reported, 2026-09-18)
`StagedScene.Elements` reorder (RemoveAt/Insert) bypasses SceneGraph's mutation surface, so the drop
must be trapped + persisted. Fix (2026-09-18): the drag sets `_dragReordered` and `EndListDrag`
funnels the drop through `MainViewModel.OnSceneElementsReordered()` (Sources.cs) =
`SceneGraph.InvalidateBake(staged) + SaveLayoutNow()` — an IMMEDIATE save at drop (no debounce
window). The single write stores BOTH the layer list (SortOrder) AND the preview layout (X/Y/W/H in
the same rows). Tests `LayerReorderPersistenceTests` (real App + temp DB): (1) exact code-behind
mutation + geometry assert; (2) **real-gesture** test `RealMouseDrag_OnTheLayerList_PersistsTheReorder`
(`SetCursorPos` + `mouse_event` on a shown Topmost window) — needs an interactive desktop session
(covered/locked session = pointer no-op = flake). Both green in the 309. **This fix was correct but
INSUFFICIENT — the cross-table bug above is what the builder actually hit.**
`StagedScene.Elements` order (RemoveAt/Insert), bypassing SceneGraph's mutation surface, so the
drop must be trapped and persisted. Fix (c117885): the drag sets `_dragReordered` and, on mouse
release, `EndListDrag` funnels the drop through `MainViewModel.OnSceneElementsReordered()`
(Sources.cs) = `SceneGraph.InvalidateBake(staged) + SaveLayoutNow()` — an IMMEDIATE save at drop
(no debounce window, added 2026-09-18 after the creator re-reported it "doesn't save"; the single
write stores BOTH the **layer list** = Source `SortOrder` AND the **preview layout** = each layer's
`X`/`Y`/`Width`/`Height` in the same Source rows; `LayoutStore.Save` deletes+reinserts Source rows
in `scene.Elements` order, `LayoutStore.Load` reads them back `ORDER BY SortOrder`).
**Good Dog tests** `LayerReorderPersistenceTests` (real App + temp DB): (1) reproduces the exact
code-behind mutation
calls the trap, pumps the dispatcher until the save lands, asserts the DB `Source`
SortOrder matches the in-memory `Elements` order.
**Creator later insisted the reorder "doesn't save" even after the build — settled 2026-09-18 with a
REAL-gesture test.** My first test short-circuited `EndListDrag` — it never exercised the mouse
handlers (impossible with synthetic events: `e.GetPosition` reads the physical cursor). Added
`RealMouseDrag_OnTheLayerList_PersistsTheReorder`: real `SetCursorPos` + `mouse_event`/`SendInput`
against the shown MainWindow physically drag row 3 (ImgC) onto row 1 (ImgA), pumping between steps,
then asserts the DB lands at `[Background, ImgC, ImgA, ImgB]` — AND that ImgC's geometry
(X/Y/W/H, the preview layout) is intact in the same rows. **Both pass.**
The mouse-down/move/up → `_dragReordered` → `EndListDrag` → `OnSceneElementsReordered` → DB seam is
now proven with real input; a real user drag working differently would mean a stale binary (the
Debug exe from 08:42 postdates fix commit 08:18) or a different deployment.
## ✅ Committed earlier — webcam app-default gate + attainability (`9d00955`, `75723ac`)
## ✅ Committed — Rename Recording dialog taller + full-screen hook unhooked on shutdown (2026-09-18)
Webcam is an **app-level resource** — ONE app-wide default, usable in every scene that can host it,
NOT blocked by other scenes' placements (creator ruling; supersedes TASK 26). Per-scene gate
`CanAddWebcam` = scene has no config && attainable (`CameraManager.IsRunning(_webcam.DeviceId)` —
a live lock, not a saved identity). Add places the default directly (no picker). Identity survives
last-placement removal; startup lays an app-wide BASE lock (refcount keeps the session alive);
adopts a solo camera as default. Dynamic why-gray tooltip. Tests: `WebcamMenuGateTests`,
`WebcamStartupResourceTests`. The DB truth lives at `%APPDATA%\ytLlive\ytLlive.db` (NOT `layout.db`).
- **WHERE/HOW:** the end-of-recording save dialog is `RenameRecordingDialog.xaml` (repo root — the
dialog that pops up with a default file name when a recording is ended so it can be saved/renamed;
title "Rename Recording"). Its fixed `Height` attribute controls everything: 230 → **276** (+20%,
textbox clipped at 230) → **304** (+10% more, creator: the +20% revealed the Cancel/Save button
row had ALSO been obscured). The Save button is named `SaveRecordingButton`.
**Good Dog test** `RenameRecordingDialogSizingTests`: shows the real dialog in the RealApp host and
asserts BOTH the file-name textbox bottom AND the Save button bottom sit inside the client area
(client area ≈ Height − ~37px of chrome; measure via `TransformToAncestor` against the dialog's
content root). The dialog's
`Icon` was changed from root-relative `/Assets/...` to the explicit `/ytLive;component/...` (the
form `MainWindow.xaml` already uses) — root-relative only resolves in production via
`Application.ResourceAssembly`, so dialogs couldn't be constructed by tests otherwise. The other
dialogs still use the fragile form; fix as they get tested.
- **Teardown leak fix (dependency, discovered by the above):** `MainViewModel.Shutdown()` now calls
`_fullScreenDetector.StopWatching()`. The global `EVENT_SYSTEM_FOREGROUND` hook was never unhooked,
so after the VM was collected the next foreground event invoked a **garbage-collected
`WinEventProc` delegate → process crash** ("Test Run Aborted" in the suite). Harmless in production
(exits the process) but fatal to the multi-window test host when a test shows/foregrounds a window.
## ✅ Committed earlier — Rename Recording dialog taller + full-screen hook unhooked (2026-09-18)
## ✅ Earlier committed — webcam resource lifecycle (startup slice, `1e4017d`)
Startup poll + tri-state (`ValidateWebcamResourceStartupAsync` after `LoadLayout`): 0 → run on,
no alarm; **1 → `AcquireAsync` as app-wide lock** (now always runs — see base-lock note above);
**≥2 → no auto-lock** (App Settings selector
next). Lock failure → **persistent red alert** (`WebcamLockAlert` in the Layers panel + Retry),
re-polls every 5 s (`_webcamLockPollTimer`), clears on lock success or any first real frame.
`CameraManager.IsRunning(deviceId)` = session exists (started OR starting). Camera test seams
`CameraEnumeratorOverride`/`CameraFrameSourceFactoryOverride` (mirror `LayoutPathOverride`).
Attribution correction committed: "NVIDIA Broadcast opens the webcam exclusively" is a *suspect-list*
claim — `CameraConflictProbe` reads process names only, no device handles; do not restate as fact.
`RenameRecordingDialog.xaml` Height 230 → 276 → **304** (the +20% revealed the Cancel/Save row also
obscured); Save button named `SaveRecordingButton`. Test `RenameRecordingDialogSizingTests` asserts
textbox bottom + Save button bottom inside the client area. **Teardown leak fix:** `Shutdown()` now
calls `_fullScreenDetector.StopWatching()` — the global `EVENT_SYSTEM_FOREGROUND` hook was never
unhooked, invoking a GC'd `WinEventProc` on the next foreground event → test-host abort.
## ⚠️ Open items
- **RenameRecordingDialog too short — DONE (see below).**
- **Webcam take fix 2 of 2 — the 09:16 crash is UNFIXED (hard, untested):** hypothesis — native WMF
death when `MediaCapture.Failed` fires mid-stream and `SafeStopAsync` (fire-and-forget) disposes
reader+capture while the frame thread sits in `TryAcquireLatestFrame`/`Marshal.Copy` (outside the
frame's try/catch). No repro, no integration test, native race not catchable — deferred per
spin-guard. Record a second occurrence's pattern.
- **Multi-camera selector slice (user-queued):** when ≥2 webcams exist, add web-cam selection text +
control to the **App Settings** dialog (gear icon, `OverlayHost` — left-click opens settings,
right-click the context menu). Selecting + successfully allocating/locking activates the WebCam
layer. **Currently ≥2 cameras → nothing shows in settings.** Also: the "Change Webcam" picker
should carry the OBS one-liner (one webcam per stream — a second means OBS).
- **Static (+) catalog slice (user-queued):** all catalog rows always listed (Background, "Web Cam",
"Countdown Timer", "Web Resource", "YouTube Chat", "YouTube Event"…), gray only for (1) system-level
unavailability (persistent alert), (2) max already allocated, (3) not available for the current
scene; tooltips must name the reason. Static labels only, no new code-behind.
- **Device re-verify (one take session):** 1. slice-18 cache verdicts (NR/WH hits, stalls, band
~60); 2. webcam: fresh launch with the browser holding the camera must show the webcam or a NAMED
chip (now also the persistent red alert, and the alert must clear after the browser closes + retry);
3. verify Live now OFFERS the Web Cam layer even while Chat holds the camera (the exact creator
goal: "webcam offered in the layer stack when I switch over to the Live view").
- **No push yet** — after the take verdict, re-measure the clap offset (`/tmp/opencode/avsync.py`),
then decide push with the user.
- **Build-id on command-line exit (creator demand, 2026-09-20):** when the app exits under
`dotnet run`, output the build number (`BuildStamp.Id` — a fresh per-build GUID, logged in
`App.xaml.cs:32` as `Build {Id} (compiled {BuiltLocal})`) as return data on the command line.
NOT yet implemented — queued, do not fold into another slice.
- **TASK 37** — defaults/current layout split (see above + `TASKS/task-37-defaults-current-split.md`).
- **Webcam take fix 2 of 2:** native WMF death when `MediaCapture.Failed` fires mid-stream and
`SafeStopAsync` disposes reader+capture while the frame thread sits in `TryAcquireLatestFrame`. No
repro/test — deferred per spin-guard.
- **Multi-camera selector slice (user-queued):** ≥2 webcams → web-cam selection in the App Settings
dialog (gear). Currently ≥2 cameras → nothing shows in settings.
- **Static (+) catalog slice (user-queued):** all catalog rows always listed, greyed with named
reason tooltips.
- **Device re-verify (one take session):** slice-18 cache verdicts; webcam fresh-launch with the
browser holding the camera → red alert that clears after Retry once the browser closes; Live must
OFFER Web Cam while Chat holds it.
- **No push yet** — after the take verdict, decide push with the user.
## Open threads (carried)
- Audio-silence verification — fixed (`724af14`); creator heard real audio.
- Webcam MJPG missing / ~10–14Hz, layer SortOrder, truncation-with-dynamic-scenes — queued.
- Webcam MJPG missing / ~10–14Hz, truncation-with-dynamic-scenes — queued.
- Sync control user-doc tutorial — REQUIRED before 1.0 (creator directive; TASK 22).
- Signed A/V sync: verify the negative (advance) direction on device.
- Focus-loss capture lag — closed as NOT the cause (1824: delivery healthy ~60-100/s).
- The 2026-09-18 "reorder doesn't save" stale-binary theory — RETRACTED 2026-09-20. The builder was
right; the cross-table z-order bug above is the real mechanism. Never re-air the stale-binary claim.
## Landmines
@@ -153,40 +104,30 @@ claim — `CameraConflictProbe` reads process names only, no device handles; do
- Build/tests: **Windows dotnet host** (`/mnt/c/Program Files/dotnet/dotnet.exe`). 0 warnings —
only `./scripts/verify.sh "<files>"`'s clean build counts. Running `ytLive.csproj` alone does NOT
rebuild `ytLive.Tests.dll` — run the Tests csproj before `vstest`. Known audio flake:
`Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` — a stale loopback sample still in the pipe
when the mute flips (phase 2's `Assert.Equal(0f, f, 6)` sees 0.4). **Confirmed pre-existing and
currently deterministic in this environment: it fails on clean `9d00955` (stash test) and on the
working tree, alone and in-suite.** Unrelated to camera work; do not "fix" it inside a webcam slice.
`Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` — stale loopback sample in the pipe when the
mute flips. Unrelated; do not "fix" inside another slice.
- **Assert footguns in this repo's xUnit:** `Assert.Null(x, "msg")`/`Assert.Single(coll, "msg")` do
NOT take a message — the 2-arg overloads mean something else (predicate/item). Empty message = 1 arg.
- The webcam's real state lives in `%APPDATA%\ytLlive\ytLlive.db` (`layout.db` there is a 0-byte
legacy file). sqlite3 at `/home/gramps/android-sdk/platform-tools/sqlite3`.
- Contention failure modes (startup.log): "no frames within 4s" = init OK but starved;
"OutputFormatNotSupported" = reader subtype below the MJPG-active camera; "file being used by
another process / CaptureMode is SharedReadOnly" = re-negotiation refused while others hold the
device. `CameraConflictProbe` = suspect list, not handle evidence.
- FramePump tests asserting per-frame CONTENT need a STABLE scene; cache-sensitive assertions can't
use a call-count resolver flip (tick resolves twice) — key alternation on `OutputIndex`.
- ffmpeg/ffprobe: `/mnt/c/Program Files/Krita (x64)/bin/` with Windows paths.
- `MyMistakes.md`: WINRT resource-allocation RECIPE, freeze-audit RECIPE, A/V sync recipe, deadline
pacing, CoreMessaging DQ, WGC-CLIP + slice blocks. Grep before re-deriving.
- **Test-host crash (fixed 2026-09-18):** "callback was made on a garbage collected delegate …
`Win32FullScreenDetector+WinEventProc`" — `Shutdown()` now unhooks the foreground-event hook; never
re-introduce `StartWatching` without a matching teardown, and dialogs whose `Icon` is the
root-relative `/Assets/…` form can't be instantiated by tests (use `/ytLive;component/…`).
- **Test-host crash (fixed):** GC'd `WinEventProc` — `Shutdown()` unhooks the foreground-event hook;
never re-introduce `StartWatching` without matching teardown; dialogs with root-relative `/Assets/…`
Icon can't be instantiated by tests (use `/ytLive;component/…`).
- **Scene.Elements layer tests:** any seeded scene with `HasBackground=1` loads a healed
`Background` element pinned at `Elements[0]` (created by `EnsureBackground` on `StagedScene` set) —
compute expected layer orders from the live collection, never hardcode indices.
- **Real-input injection test** (`RealMouseDrag_OnTheLayerList_PersistsTheReorder`) needs an
interactive desktop session: the window is shown Topmost and clicks are injected at real screen
coords — a covered/locked session makes the pointer no-op and the drag never lands. Fine locally.
- **Cross-type element order tests** must assert through a FRESH `LayoutStore` (the restart), not the
running app's store — that's the read-back path the builder's bug exercised.
- `C:\tmpout` is for ffmpeg evidence artifacts; keep them out of the repo.
## Next step
The multi-camera App Settings selector slice (+ static catalog rows after), then the creator's take
on this build. The webcam gate slice answers the creator's "go" ask — switching to the Live view
must now offer Web Cam in the layer stack (Chat holding the camera no longer blocks it). The red
Layers alert remains the new expected behavior to eyeball on the take: browser holds the camera →
fresh launch → alert (unless Chat's config grabbed it) that clears after a Retry once the browser
closes.
The ordering fix is the builder-facing half of the "webcam between sources" bug — a manual take
(relaunch, live view, drag Web Resource-0 below the webcam, restart, expect it pinned there) closes
it. Then the copy with the builder on any remaining repro, then decide push. TASK 37 (defaults vs
current) and the build-id-on-exit demand are queued for a later slice — do not fold them in here.