2641 lines
183 KiB
Markdown
2641 lines
183 KiB
Markdown
|
||
## 2026-10-30 — C# record struct: `new T()` ignores the parameter defaults
|
||
|
||
**Symptom:** two property controls "had no code behind them" — the Background checkbox and the
|
||
opacity slider changed nothing visible.
|
||
|
||
**Cause:** `public readonly record struct ScoreboardStyle(string TextColor = "#FFFFFF", bool BackgroundEnabled = true, …)`.
|
||
`new ScoreboardStyle()` binds to the implicit **parameterless constructor**, which zero-initialises —
|
||
the defaults in the parameter list are only used when the constructor is called with those arguments
|
||
omitted *at a call site that supplies some of them*. Result: null colour, `BackgroundEnabled = false`,
|
||
`FontOpacity = 0`, `FontSize = 0`. The renderer drew the zeros faithfully, so the UI looked dead
|
||
while every unit test of the renderer passed (they constructed the style explicitly).
|
||
|
||
**Rule:** a record struct with meaningful defaults needs an explicit parameterless constructor
|
||
(`public T() : this(<defaults>) { }`). And a "defaults" test must construct the type the same way
|
||
production does — with `new()` — never with explicit arguments, or it proves nothing.
|
||
|
||
**Guard:** `ScoreboardRendererTests` asserts `new ScoreboardStyle()` yields white / on / black / 0.20 /
|
||
max 200.
|
||
|
||
# MyMistakes.md
|
||
|
||
> **Two jobs**, distinguished by heading:
|
||
>
|
||
> 1. **Per-task failure log** — updated before every commit touching that task:
|
||
> current iteration + why the last one failed. On task complete, committed AND
|
||
> pushed → truncate to this stub. A new task does NOT seed this file until its
|
||
> first failure.
|
||
> 2. **Recipes registry** (DERIVED-SOLUTION RULE, see `AGENTS.md` 🔬) — the durable
|
||
> home for one-off derived solutions, recipes, and how-tos. The moment you work
|
||
> out a reusable solution, write it here **in the same session**. GREP THIS FILE
|
||
> FIRST when you hit a "I've done this before but have to figure it out again"
|
||
> wall. Recipe entries stay permanently (they are NOT truncated on task
|
||
> completion) — only the failure log truncates.
|
||
|
||
## 🔬 Recipes registry
|
||
|
||
### DEPLOYING llamacasty.com TO THE DROPLET (RECIPE, 2026-10-30)
|
||
|
||
**Problem:** "upload the site" burned a session: `ssh -p 2214 gramps@llamachile.tube` hung on a
|
||
prompt, then answered `Permission denied (publickey)` everywhere, and the two md files that document
|
||
the deploy only said *"copy this working tree into `/var/www/linkstack/`"* — a destination, not a
|
||
transport.
|
||
|
||
**What it actually is:** the static site lives in **`/var/www/linkstack/`** on the YunoHost droplet
|
||
(`llamachile.tube` = `llamacasty.com` = `143.244.176.131`), owned `linkstack:www-data`. The host
|
||
sshd is **port 2214** with **key-only auth** (`PasswordAuthentication no`; publickey is the only
|
||
method offered for every user). The `CREDENTIALS.md` password is the **sudo** password — it is not
|
||
an SSH auth method and never was.
|
||
|
||
**The hang was the second factor:** sshd had `AuthenticationMethods publickey,keyboard-interactive`
|
||
(PAM `pam_google_authenticator`), so a key got through and then the server asked for a verification
|
||
code that a non-interactive tool call cannot answer. Disabled permanently 2026-10-30 by commenting
|
||
that line and the PAM module in `/etc/pam.d/sshd`, then `sshd -t && systemctl restart ssh`.
|
||
|
||
**The recipe:** `llamacasty.web/deploy.sh` — tars the **public** files only (`index.html`,
|
||
`privacy.html`, `css/`, `js/`, `assets/`), `scp`s the tarball to `/tmp`, then one `sudo -S` shell
|
||
that backs up the live root to `/var/backups/linkstack-<stamp>.tgz`, extracts over it, restores
|
||
`linkstack:www-data` + `755/644`, deletes `build-this.md` from the root, and curls the site.
|
||
Password comes in on **stdin** (`SITE_SUDO_PW=... ./deploy.sh`), never on a command line.
|
||
|
||
- ⚠️ **Never publish the md files.** `build-this.md` shipped in the web root until 2026-10-30 and
|
||
now carries **INTERNAL** pricing notes. The deploy script's file list is explicit for that reason.
|
||
- ⚠️ **drvfs sets 777 on copied files** — the chmod pass in the script is not cosmetic; a world-
|
||
writable web root is a defacement waiting to happen.
|
||
- ⚠️ **`/var/www/linkstack__2` is a different app's directory** (the old LinkStack PHP install).
|
||
The static site is `linkstack`, without the suffix.
|
||
- ⚠️ Ports **22 and 1319 are closed**; 2214 is the only SSH door, and it is the *host*, not Gitea
|
||
(Gitea pushes go over **HTTPS** with the credential helper — see `docs/ai/environment.md`).
|
||
|
||
### POINTING www AT THE APEX ON THE YUNOHOST DROPLET (RECIPE, 2026-10-31)
|
||
|
||
**Problem:** `www.llamacasty.com` did not resolve at all, and once a record existed the server half
|
||
was four separate traps: YunoHost added the vhost with a **self-signed** cert, refused to issue
|
||
Let's Encrypt without a diagnosis, and served the **SSO login** because the domain has no app.
|
||
|
||
**1. DNS** — Namecheap BasicDNS, zone `llamacasty.com`. `www CNAME llamacasty.com.` (YunoHost's own
|
||
subdomain example uses a CNAME per subdomain, and a CNAME follows the apex if the droplet IP moves;
|
||
an `A www` record would need a second edit for every rebuild). The apex `A` is the only IP holder.
|
||
|
||
**2. Domain + cert on the droplet:**
|
||
|
||
```bash
|
||
yunohost domain add www.llamacasty.com --install-letsencrypt-cert # vhost + SELF-SIGNED cert
|
||
yunohost domain cert install www.llamacasty.com --no-checks # real LE cert
|
||
```
|
||
|
||
- ⚠️ The `--install-letsencrypt-cert` flag **does nothing here**: *"Skipping Let's Encrypt certificate
|
||
attempt because there's no wildcard configured on the parent domain's DNS records."* It exits
|
||
SUCCESS with a self-signed cert, so check `openssl s_client -servername <host>` afterwards.
|
||
- ⚠️ Plain `yunohost domain cert install` refuses with *"no diagnosis result for domain … use
|
||
`--no-checks`"*. `--no-checks` is safe when the name resolves publicly — LE's HTTP-01 validation
|
||
is itself the DNS proof (it failed over to a real cert on the first attempt).
|
||
|
||
**3. The redirect** — with no app installed the vhost 302s to `/yunohost/sso?r=…`, so a `301` is
|
||
needed. YunoHost includes `/etc/nginx/conf.d/<domain>.d/*.conf` **inside the server{} block**;
|
||
`location / { return 301 https://llamacasty.com$request_uri; }` there wins over SSO because `return`
|
||
runs in the rewrite phase, before `access_by_lua_file` (the global SSO guard). The `.d` include
|
||
survives YunoHost regenerating the `.conf`; never edit the `.conf` itself.
|
||
|
||
- ⚠️ **The heredoc ate `$request_uri`.** Writing that file through `ssh "sudo bash -c 'cat > … <<EOF'"`
|
||
expands nginx's variable to empty and silently produces `return 301 https://llamacasty.com;` —
|
||
every path redirects to the homepage. Write the file **locally** and `scp` + `install -m 644` it.
|
||
(`printf "---x---"` in the same command also dies on `--`: *invalid option*.)
|
||
- ⚠️ **Two resolvers lie about this name.** The workstation's resolver caches the old NXDOMAIN, and
|
||
on the droplet `getent hosts www.llamacasty.com` returns **`::1`** because YunoHost's dnsmasq
|
||
answers for its own vhosts. Neither is a DNS test — use a public DoH query or
|
||
`curl --resolve www.llamacasty.com:443:<ip>`.
|
||
|
||
**Verify:** `curl --resolve www.llamacasty.com:443:143.244.176.131 -o /dev/null -w '%{http_code}
|
||
%{redirect_url}' https://www.llamacasty.com/privacy.html` → `301 https://llamacasty.com/privacy.html`;
|
||
the cert must read `CN=www.llamacasty.com`, issuer Let's Encrypt.
|
||
|
||
### LICENSING WITHOUT A SERVER — POLAR'S CUSTOMER-PORTAL API (RECIPE, 2026-10-31)
|
||
|
||
**Problem:** the plan carried a "claim page" — a hosted OAuth flow that wrote the buyer's channel ID
|
||
onto the licence key — plus an implied server to hold keys. Both were invented from an assumption
|
||
that binding needs a browser.
|
||
|
||
**It does not.** Polar's customer-portal endpoints authenticate on the **key itself** plus the
|
||
`organization_id` (a public identifier, safe in the binary). The **org access token is only needed
|
||
to create products** and must never ship.
|
||
|
||
```bash
|
||
# activate (label is required; conditions are re-checked on every validate)
|
||
POST https://api.polar.sh/v1/customer-portal/license-keys/activate
|
||
{ "key": "LCYT-…", "organization_id": "c05fb364-…", "label": "DESKTOP-NAME",
|
||
"conditions": { "channel_id": "UC…" } }
|
||
|
||
# validate on launch + daily, with the SAME conditions object
|
||
POST https://api.polar.sh/v1/customer-portal/license-keys/validate
|
||
{ "key": "LCYT-…", "organization_id": "c05fb364-…", "activation_id": "…",
|
||
"conditions": { "channel_id": "UC…" } }
|
||
```
|
||
|
||
- `limit_activations` on the benefit = the device cap (2). Polar's **customer portal** already does
|
||
deactivation, key rotation and "where is my key", so build no UI for it.
|
||
- A **subscription key is auto-revoked when the subscription ends** — that is what makes lapse a
|
||
real state; check `status`, not `expires_at`.
|
||
- ⚠️ `conditions` is **agreement, not cryptography**: a patched client can send someone else's ID.
|
||
Accepted — the free tier is the defence, not the check.
|
||
- ⚠️ There is **no push to a desktop app**: delivery is email or the customer portal. Auto-delivery
|
||
needs a webhook plus an endpoint *you* host, holding keys. Not worth it for one copy-paste.
|
||
|
||
### YOUTUBE "VERIFIED" — THREE DIFFERENT THINGS (2026-10-31)
|
||
|
||
`youtube.com/verify` saying "your account is verified" answers only the first tier. The tiers live
|
||
in Studio → Settings → Channel → **Feature eligibility**:
|
||
|
||
| Tier | Requires | Gives |
|
||
|---|---|---|
|
||
| Standard | nothing | uploads, playlists, posts (low daily limits) |
|
||
| **Intermediate** | phone verification | >15 min videos, **live streaming on a computer**, custom thumbnails |
|
||
| Advanced | **channel history** or ID/video verification | higher daily limits, clickable description links, chapters, pin comments, monetization application |
|
||
|
||
- **Live streaming is intermediate, not advanced** — a verified channel can stream; the API's
|
||
"live streaming not enabled" error means the *user's* channel is unverified, so the app must map
|
||
it to "verify at youtube.com/verify".
|
||
- **2 channels per phone number per year**, 365-day reset; VoIP/Google Voice numbers are refused.
|
||
That is the "used too many times" wall, and there is no appeal.
|
||
- Verification is **sticky** — it survives the phone being removed, which is why an old account can
|
||
read "verified" while no number on it will pass today.
|
||
- Advanced via **ID/video verification** is a privacy trade with a 24 h review; channel history is
|
||
free and usually sufficient within ~2 months of normal activity. Prefer history.
|
||
- ⚠️ Only the **primary owner** can do ID/video verification, and brand-account managers hit
|
||
*"Advanced YouTube features aren't available for this account"*. If ID verification is ever
|
||
wanted on a personal channel, do it **before** converting to a brand account.
|
||
|
||
### THE `const` THAT MAKES A GUARD UNREACHABLE (2026-11-01)
|
||
|
||
A dev-build flag declared `const bool` turns `if (!DevBuild.IsDevelopment) return;` into a
|
||
compile-time branch: Debug reports **CS0162 unreachable** on the guard, Release reports it on the
|
||
body — and the repo builds at 0 warnings, so either config fails the gate.
|
||
|
||
**Fix:** `public static readonly bool IsDevelopment =` … set once from `#if DEBUG`. Both arms
|
||
compile clean in both configurations, and the JIT still folds the branch. The *real* guarantee for
|
||
"this must not ship" is not the flag anyway — it is removing the UI's `Page`/`Compile` items from
|
||
the Release build (`<ItemGroup Condition="'$(Configuration)' != 'Debug'">`).
|
||
|
||
⚠️ A Condition on the **ItemGroup** is invisible to a test that reads `item.Attribute("Condition")`.
|
||
Read the parent too — that mistake cost one failing test here.
|
||
|
||
### THE SINGLE-FILE PUBLISHER EATS ffmpeg (2026-11-01)
|
||
|
||
**Problem:** `dotnet publish` with `PublishSingleFile=true` +
|
||
`IncludeNativeLibrariesForSelfExtract=true` produced a **9-file** output with **no `ffmpeg\`
|
||
folder** and a **182 MB exe** — the libav\*.dll family had been bundled *inside* the binary. The
|
||
build was green, the tests passed, and the app would have shipped unable to find ffmpeg at
|
||
runtime (the locator looks for `ffmpeg\ffmpeg.exe` beside the exe).
|
||
|
||
**Diagnosis that worked:** diff the publish output against a **control publish with the
|
||
single-file flags removed** — ffmpeg was there. Then add flags back one at a time;
|
||
`IncludeNativeLibrariesForSelfExtract` was the one that broke it.
|
||
|
||
**Fix:** mark the content `ExcludeFromSingleFile="true"`.
|
||
|
||
```xml
|
||
<None Include="ffmpeg\**" CopyToOutputDirectory="PreserveNewest"
|
||
CopyToPublishDirectory="Always" ExcludeFromSingleFile="true" />
|
||
```
|
||
|
||
After: 127 MB exe, `ffmpeg\` present, depot ≈ 257 MB total.
|
||
|
||
- ⚠️ **The `<None Remove="ffmpeg\**" />` before it is not the fix** — it silences a duplicate-item
|
||
warning from the SDK's default glob, and I tried it first and wrongly believed it had worked.
|
||
Two failed remedies in a row is the research trigger: read the publisher's own metadata
|
||
(`ExcludeFromSingleFile`) instead of guessing at item globs.
|
||
- ⚠️ **A clean build and green tests cannot see this.** Only the artifact can, which is why
|
||
`scripts/publish.sh` fails loudly when `ffmpeg\`, `LICENSE`, `PRIVACY.md` or
|
||
`THIRD-PARTY-NOTICES.txt` are missing, and when test assemblies leak in.
|
||
|
||
### STEAM DISTRIBUTION, COMMERCIAL USE, AND THE DEMO OWNERSHIP CHECK (2026-11-02)
|
||
|
||
**Why recorded:** the Steam Subscriber Agreement's private-use wording was misread as a blocker
|
||
for monetized streaming. Check the exception and the established Steam software precedent before
|
||
raising it again.
|
||
|
||
- **Steam installs skip SmartScreen entirely** — Valve's client delivers the files, so there is no
|
||
browser download, no Mark-of-the-Web and no publisher warning. An unsigned exe is *fine* in this
|
||
channel, and a certificate is only needed if a non-Steam download ever exists.
|
||
- **Steam updates the app** — no self-updater, no version banner, no update endpoint.
|
||
- **⛔ In-app purchases must use the Steam Wallet.** Steamworks, *Microtransactions (In-Game
|
||
Purchases)*: *"For any in-game purchases, you'll need to use the microtransaction API so Steam
|
||
customers can only make purchases from the Steam Wallet."* LlamaCasty has no in-app checkout.
|
||
- **The private-use wording is not, by itself, a commercial-use ban for LlamaCasty.** The Steam
|
||
Subscriber Agreement §2.A permits commercial use when the applicable product terms expressly
|
||
allow it; §5 has general private/personal-use wording for third-party software. OBS Studio is
|
||
distributed on Steam as video-production software under GPL, and OBS confirms the Steam build is
|
||
the same as its website build. GPL permits commercial use. LlamaCasty's EULA should expressly
|
||
allow commercial and monetized streaming; confirm the wording with Valve or counsel. Do not call
|
||
this a Steam blocker based only on §5.
|
||
Sources: https://store.steampowered.com/subscriber_agreement/ ·
|
||
https://obsproject.com/kb/steam-faq · https://www.gnu.org/licenses/gpl-faq.html
|
||
- **Demo ownership check:** the free Demo uses its own App ID; `ISteamApps::BIsSubscribedApp`
|
||
checks ownership of the paid app. The paid App ID must be configured; zero/missing ID fails closed.
|
||
Valve reference: https://partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp
|
||
- **Steam sells one paid app and distributes its associated free Demo.** Same code; no GitHub
|
||
binary. The demo links to the paid store page and can send a one-time wishlist notification.
|
||
- **Renaming a live exe is painful** (Steamworks → *Tip – Renaming a live exe*). Do the
|
||
`LlamaCasty.exe` rename **before** the first depot upload, not after.
|
||
- **The paid app's Steam store page must be public two weeks before release.** The Steamworks
|
||
application and $100 fee are already submitted; Valve's decision is pending.
|
||
|
||
### CONVERTING A MEDIAPIPE .tflite (WITH CUSTOM OPS) TO ONNX — AND PROVING IT (RECIPE, TASK 51)
|
||
|
||
**Problem:** MediaPipe segmentation models end in the custom op `Convolution2DTransposeBias`;
|
||
tf2onnx and tflite2onnx refuse it. **Recipe:** `tools/selfie-seg/convert_tflite_to_onnx.py` —
|
||
parse the flatbuffer with the `tflite` pip package, emit ONNX with `onnx.helper`, transpose
|
||
NHWC→NCHW once at input/output, fold every `DEQUANTIZE` (fp16 weights) into fp32 initializers.
|
||
Weight layouts: CONV OHWI→OIHW; DEPTHWISE [1,H,W,C]→[C,1,H,W] with `group=C`; transpose-conv
|
||
[O,H,W,I]→[I,O,H,W]. TF `SAME` padding = ONNX `auto_pad=SAME_UPPER`.
|
||
- ⚠️ **Enum trap:** the custom op's options are the C-API `TfLiteTransposeConvParams`, whose
|
||
`padding` uses `TfLitePadding` (Unknown=0, **SAME=1**, VALID=2) — NOT the flatbuffer `Padding`
|
||
enum (SAME=0). Comparing against `Padding.SAME` rejects the real model.
|
||
- **Proof, not hope:** `pip install mediapipe` ships the TFLite runtime WITH the custom op. Feed
|
||
an image already at the model's exact input size (no resampling on either side) to MediaPipe's
|
||
`ImageSegmenter` and to onnxruntime; compare masks (`verify_against_mediapipe.py`, exit 0 at
|
||
max |diff| < 1e-3 — we got 5e-5). Work in a throwaway venv under `/tmp/opencode/`.
|
||
|
||
### ⛔ NEVER PRINT CREDENTIALS.md — NOT EVEN "FILTERED" (2026-10-09 incident)
|
||
|
||
**What happened:** to look for an API key, a `sed -n '1,40p' CREDENTIALS.md` piped through a
|
||
redaction regex was run. The regex only matched `password: \`…\``; the **"Sudo pattern"
|
||
line stores the password inside `echo '…'`**, slipped past the filter, and the full sudo
|
||
password was printed into the session output (and so into local session logs and the model
|
||
provider's transcript). Owner had to rotate it.
|
||
|
||
**Rule:** never `cat`/`sed`/`grep`/`read` CREDENTIALS.md (or any secrets file) in a way that
|
||
can print values. Redaction regexes are not a safety net — secrets hide in shapes you didn't
|
||
anticipate.
|
||
- **Need a secret for a command?** Load it into a variable, never echo it:
|
||
`PW=$(sed -n 's/.*password:.*\`\(.*\)\`.*/\1/p' CREDENTIALS.md | head -1)` then use `"$PW"`.
|
||
- **Need to know what's in the file?** List **keys only**, never values:
|
||
`grep -oE '^\s*- \*\*[^*]+\*\*' CREDENTIALS.md` (bold labels) — or ask the owner.
|
||
- **Looking for a specific secret?** Test for presence without printing:
|
||
`grep -qi 'gitea' CREDENTIALS.md && echo present || echo absent`.
|
||
- Secrets in source (e.g. the Google client secret) live in gitignored
|
||
`Helpers/OAuthCredentials.cs` — check with `git check-ignore -v`, don't open it.
|
||
|
||
**Rotation (YunoHost user `gramps`):** YunoHost owns the password (LDAP) — plain `passwd`
|
||
may not apply. Owner rotates via web admin → Users → gramps, or
|
||
`sudo yunohost user update gramps -p` (prompts). Never route the new password through the AI.
|
||
|
||
### YOUTUBE STATISTICS ARE JSON STRINGS; TryGetInt64 THROWS ON STRINGS (RECIPE)
|
||
|
||
YouTube Data API v3 returns `statistics.subscriberCount / viewCount / videoCount` as
|
||
JSON **strings** (`"350"`), not numbers. (2026-09-23: the YPP parse bug surfaced the
|
||
moment the auditDetails 403 stopped masking it.) Fix = a tolerant read that checks
|
||
`ValueKind` FIRST, then `TryGetInt64` for `Number`, then `long.TryParse(GetString())`
|
||
for `String`. Trap within the fix: **`JsonElement.TryGetInt64` THROWS
|
||
`InvalidOperationException` on any non-Number token** ("requires an element of type
|
||
'Number'") — it is try-type-in, not try-catch. Guard on `value.ValueKind` before
|
||
calling it; a leaked 403→parse chain means your "whole request failed" symptom can
|
||
paper over a second crash that only appears once the 403 is fixed (test the full
|
||
happy path, not just the error path).
|
||
|
||
### liveChatId LIVES IN SNIPPET AND ONLY EXISTS ONCE THE BROADCAST IS LIVE (RECIPE)
|
||
|
||
YouTube Data API v3 `liveBroadcasts`. `liveChatId` is in **`snippet.liveChatId`** —
|
||
`contentDetails` has NO such property. AND it is only populated once the broadcast is
|
||
**live** (the official `GetLiveChatId.java` sample lists `broadcastStatus=active`);
|
||
a fetch right after insert (lifecycleStatus `ready`) returns nothing. (2026-09-25: this
|
||
cost a whole investigation — the TEST-tab "Chat polling couldn't start — Mock Chat Input
|
||
is disabled" report — because the code read `part=contentDetails` + ran before the frame
|
||
pump pushed RTMP.) Fix = read part=snippet + bounded retry AFTER the encoder starts
|
||
(`GetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)`). Debug lens:
|
||
missing liveChatId ≈ "broadcast not live yet", NOT an auth failure.
|
||
|
||
### liveChat/MESSAGES.INSERT BODY REQUIRES snippet.type (RECIPE)
|
||
|
||
YouTube Data API v3 `liveChat/messages.insert` rejects the body with
|
||
`400 MISSING_REQUIRED_FIELD` (`domain: youtube.api.v3.LiveChatMessageInsertResponse.Error`)
|
||
unless the snippet declares `snippet.type` = `textMessageEvent` (or `pollEvent`) alongside
|
||
`liveChatId` and `textMessageDetails.messageText` — the official insert reference lists
|
||
`type` as a required property. (2026-09-25: the TEST-tab "YouTube rejected the message
|
||
(error 400)" report — the liveChatId fix in TASK 44 had worked and the drawer's Mock Chat
|
||
Input was issuing a real insert, but the body omitted `type`, and the TASK 41 test only
|
||
asserted `liveChatId` + `messageText` were present so it stayed green while real YouTube
|
||
rejected every send. Fix = add `type = "textMessageEvent"` to the body + assert it in the
|
||
Good Dog test.) Debug lens: MISSING_REQUIRED_FIELD ≠ auth/scope — it means the request
|
||
body shape is wrong, and the false-green test is the classic trap: assertion on the
|
||
body was about WHAT WE SEND, so YouTube's required fields must be mirrored in the test.
|
||
|
||
### CHANNELS.LIST auditDetails PART 403s THE WHOLE REQUEST WITHOUT A PARTNER SCOPE (RECIPE)
|
||
|
||
YouTube Data API v3 `channels.list` rejects the ENTIRE request with
|
||
`403 insufficientPermissions` if your `part=` list includes `auditDetails` but the
|
||
token lacks `https://www.googleapis.com/auth/youtubepartner-channel-audit` — the
|
||
docs' exact words: "A request that retrieves the auditDetails part for a channel
|
||
resource must provide an authorization token that contains the
|
||
youtubepartner-channel-audit scope". That scope is MCN content-partner tooling
|
||
(with a two-week token-revocation rule); normal-creator apps should NEVER ask for
|
||
it. 2026-09-23 real-log evidence: YPP refresh 403'd three times in a row while the
|
||
mock-fake tests stayed green ("current scopes suffice, no re-consent" was wrong).
|
||
Fixes: (1) drop `auditDetails` from `part=`; (2) log the response BODY — the bare
|
||
status code could not name `insufficientPermissions`, which is what made this
|
||
failure undiagnosable for days; (3) when a feature needs data no ordinary scope
|
||
grants, deep-link to the site instead of requesting the partner privilege.
|
||
|
||
### TRANSITION(COMPLETE) RACES AUTOSTOP: PRE-FLIGHT lifeCycleStatus (RECIPE)
|
||
|
||
A blind `liveBroadcasts.transition?broadcastStatus=complete` POST can 403
|
||
`invalidTransition` even though the stream just ended normally — 2026-09-22 logged
|
||
it on EVERY session end. Cause: the broadcast's own state moves toward complete
|
||
via `enableAutoStop`/YouTube auto-complete; the transition method's errors doc
|
||
(https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition)
|
||
shows `invalidTransition` = "can't transition from its current status". A blind
|
||
POST just races — read the broadcast's `lifeCycleStatus` first
|
||
(`liveBroadcasts.list?part=status`) and only POST complete from `live`/`testing`;
|
||
skip silently otherwise and let `enableAutoStop` finish it. Never throw on the
|
||
close-out either way.
|
||
|
||
### YOUTUBE liveStreams LIST: healthStatus IS AN OBJECT, NOT A STRING (RECIPE)
|
||
|
||
YouTube Data API v3 `liveStreams.list` nests health under
|
||
`status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` —
|
||
the `healthStatus` element is an **OBJECT**, and `configurationIssues[]` lives
|
||
INSIDE it, not directly under `status`. Reading `healthStatus.GetString()` throws
|
||
System.Text.Json's `The requested operation requires an element of type 'String',
|
||
but the target element has type 'Object'` — the exact log line seen 2026-09-22 on
|
||
every live health poll (two test sessions). The parse must read
|
||
`healthStatus["status"]` (and nested `healthStatus["configurationIssues"]`).
|
||
Something like REST-shape drift is a good reason to grep the API reference
|
||
(https://developers.google.com/youtube/v3/live/docs/liveStreams) before writing
|
||
parsers against a "remembered" shape — our own flat-string fixture was the wrong
|
||
assumption the whole time.
|
||
|
||
### WINRT RESOURCE-ALLOCATION CALLS: WRAP PER-CALL, DEGRADE TO NEXT OPTION (RECIPE)
|
||
|
||
Creator callout (2026-09-15): WinRT/COM calls that allocate or start a resource — `InitializeAsync`,
|
||
`CreateFrameReaderAsync`, `StartAsync`, `CreateReaderAsync` & friends — are THROW-HEAVY. An unsupported
|
||
subtype/format, a device that just vanished, an access mode rejected mid-flight: these surface as
|
||
`ArgumentException`/`E_INVALIDARG` ("value does not fall within the expected range") or HRESULTs, NOT as
|
||
a returned status you can switch on. Relying on ONE outer catch to "handle failures" is not handling —
|
||
one rejected call inside a fallback ladder aborts the whole ladder and every untried option. Rule:
|
||
|
||
- Each allocation/start call inside a try/catch of its OWN, so a throw on candidate N falls through to
|
||
candidate N+1 (log each rejection with its message/status; collect them for the final error string).
|
||
- `return`/`break` on success must be reached WITHOUT passing through a `finally` that disposes the
|
||
resource you just committed (classic reader/capture dispose-after-commit bug).
|
||
- Unsubscribe + dispose the partial resource in the catch block when the subscription happened before
|
||
the throwing call.
|
||
- The outer catch stays as the LAST-RESORT net for device-level errors, not the primary one.
|
||
- Same discipline applies to the frame-consumption side: teardown races reader threads (see HANDOFF
|
||
crash follow-up) — a frame callback can't assume the pipeline is alive.
|
||
|
||
Applied in `MediaCaptureFrameSource`'s reader-subtype ladder (2026-09-15, webcam-take fix).
|
||
|
||
### "SAVE-RECORDING DIALOG IS CLIPPING CONTENT" — WHERE + HOW (RECIPE)
|
||
|
||
**WHERE:** the end-of-recording modal (creator: "when I end a recording the app throws up a
|
||
save-recording dialog with a default filename") is **`RenameRecordingDialog.xaml` at the repo ROOT**
|
||
(title "Rename Recording", `x:Class="ytLive.RenameRecordingDialog"`). It is NOT one of the
|
||
`*Dialog.xaml` files under `Controls/` — grep for the window title, not the filename, when the
|
||
user names a dialog by what it does. Only fix dialogs the user actually named; do not "while I'm
|
||
here" bump sibling dialogs.
|
||
|
||
**HOW:** the dialog is `ResizeMode="NoResize"` with a fixed `Height` attribute; content client area
|
||
≈ `Height − ~37px` of window chrome. Sizing/margin changes pushed content down in Z-order until it
|
||
clipped — first the file-name textbox (230 too short), and after the +20% bump (→276) the creator
|
||
found the Cancel/Save button row had ALSO been obscured. Fixed `Height` to 304 (+10% more),
|
||
`x:Name` the last interactive control (`SaveRecordingButton`) so a test can measure it.
|
||
|
||
**Verify (never eyeball-predict):** `RenameRecordingDialogSizingTests` (RealApp host) instantiates
|
||
the real dialog, `Show()` + `UpdateLayout()`, then asserts the BOTTOM EDGE of each named control
|
||
(`TransformToAncestor` against the content root → `TransformBounds(RenderSize).Bottom`) is ≤ the
|
||
client `ActualHeight`. Every such dialog fix ships with that assertion for every control that was
|
||
reported obscured. (Client-area math means the button row needs MORE slack than the textbox: its
|
||
bottom sits deeper because of the `Margin="0,20,0,0"` above it.)
|
||
|
||
### SPIN GUARD → RESOLVED — web overlay transparency + bounding box (RECIPE)
|
||
|
||
**THE ONE ROOT CAUSE THAT EXPLAINS EVERY FAILED TAKE:** WebView2's `CapturePreviewAsync`
|
||
produces an **OPAQUE** PNG. From the WebView2 spec (sender: MicrosoftEdge/WebView2Feedback
|
||
`specs/BackgroundColor.md`): "WebView will always honor a webpage's background content."
|
||
`DefaultBackgroundColor = Transparent` only shows through pages with NO background style —
|
||
the widget's own CSS paints html/body opaque. Every take below built on the false premise
|
||
"the capture has transparent margins, alpha=0"; it never did. `FindContentBounds` then had
|
||
no alpha-0 margins to find → wrong crop → black bounding box. The compositor blend saw
|
||
alpha=255 → black over webcam = "transparency broken". Same bug, three symptoms.
|
||
|
||
**THE FIX (the OBS way, applied 2026-09-08):** inject the transparency BEFORE the page
|
||
parses using `CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync` — documented to run
|
||
"before the HTML document has been parsed and before any other script included by the HTML
|
||
document is run" (learn.microsoft.com/dotnet/api/microsoft.web.webview2.core.corewebview2.addscripttoexecuteondocumentcreatedasync).
|
||
The old `ExecuteScriptAsync` on NavigationStarting/NavigationCompleted ran AFTER page
|
||
scripts/CSS → widget page repainted background opaque → lost the fight. OBS browser sources
|
||
do the same via a pre-parse user.css. Kept the nav handlers as a post-load re-assertion.
|
||
|
||
**Take timeline (the honest record):**
|
||
- `bccdb48` (Aug 28): added FindContentBounds crop — correct idea (tight bbox, no dead
|
||
space), but the capture was OPAQUE so the bbox math was built on nothing.
|
||
- `5348b5c` (Aug 28, 2 min later): reverted to full-frame no-crop — looked "good" for a
|
||
full-bleed widget, but floating widgets regained dead space ("ghost boundary").
|
||
- take-21 (`e002847`) full canvas → shrunken/offset widget (UnifomToFill of whole canvas
|
||
into a small element = lost resizing).
|
||
- take-22 (`ed9d7c1`) crop width used as canvas stride for buffer indexing → garbage.
|
||
- take-23 (`f6802c7`) stride fixed with `src.Width`; PasteKey lacked CropBounds → stale
|
||
raster cache → stale crop.
|
||
- take-24 (`081e4c1`) CropBounds in PasteKey — STILL BROKEN because the SOURCE ALPHA WAS
|
||
NEVER REAL.
|
||
- take-25 (**ME, this session — the user's "RE-INTRODUCING THE BOUNDING-BOX PROBLEM"**):
|
||
I removed FindContentBounds + the crop path entirely, betting full-canvas UniformToFill
|
||
was the answer. It WASN'T — the source is opaque-black, so the element rendered as a
|
||
SOLID BLACK BOX (the user's screenshot: "a black box in the lower right corner"). Killed
|
||
the crop → dead space returned AND black box. The compositor math was ALREADY correct;
|
||
gutting it was vandalism in response to a source-level bug.
|
||
|
||
**Rules, self-inflicted:**
|
||
1. Instrument FIRST. This session added: first-capture PNG dump of the raw WebView2 PNG
|
||
(to `%TEMP%\ytLive-web-<id>.png`) + alpha min/max/mean/%zero + FindContentBounds result
|
||
logged to startup.log once per session. That's the diff between a five-take loop and a
|
||
five-minute diagnosis.
|
||
2. When the same symptom loops across takes, the PREMISE is wrong, not the code — the
|
||
capture being transparent was the load-bearing premise and it was never verified.
|
||
3. Do not delete code paths that fix one axis (crop=bbox) while debugging another
|
||
(source alpha). Revert scope creep; keep layer contributions separable.
|
||
|
||
**2026-09-10 follow-up → NOW VERIFIED and hardened.** The 12:54 and 13:50 sessions
|
||
showed the REAL widget document capturing TRANSPARENT (widget dumps `w1..5` for
|
||
`8d7234ec`: alpha 100% zero, content 12×3; whole-file decodes of
|
||
`%TEMP%\ytLive-web-*.png` alpha max = 0) while the RECORDING kept showing a black,
|
||
opaque box over the element rect (crisp edges at the exact rect 1231,679 705×396 —
|
||
NOT the desktop showing through). Concluson: branch (a) — the OLD inline
|
||
`element.style.background='transparent'` injection only wins while the page has
|
||
nothing to paint; once the widget connects and paints its own container
|
||
background-COLOR, the capture goes opaque again → black box. The OBS-validated
|
||
answer (valid for arbitrary pages for a decade) is an injected pre-parse `<style>`
|
||
with `!important` beating every page rule:
|
||
https://obsproject.com/forum/threads/translucent-transparent-browser-source.59549/
|
||
(`body { background-color: rgba(0,0,0,0) !important }`) + the div-level variant for
|
||
stubborn widgets (woahtech.com OBS custom-CSS guide). Applied 2026-09-10 (b4bba4b):
|
||
injection appends a style element wiping `background-color:transparent!important`
|
||
on `html,body,html *`. **Second half (2026-09-10, 15:34 take): a background-COLOR wipe
|
||
is NOT enough.** The 15:34 interior ASCII shows a black void with bright content strips
|
||
at top/bottom + a right-edge bar — the widget's full-canvas CSS **background-image**
|
||
(gradient/backdrop) painted after connect. The OBS fix for that is
|
||
`background: none !important` / `background-image:none!important`
|
||
(obsproject/obs-studio#6659 — "set the CSS for html and body to background: none
|
||
!important"). Wipe both moving forward; overlay art is `<img>`/DOM and survives.
|
||
Also the dumps only covered +1s (blank-transparent); the widget's backdrop paint
|
||
arrives later, so the mid-recording dump set is now re-armed ~30s in. Verify take:
|
||
element rect shows scene bg behind the widget art (animation, no black void) → loop
|
||
closes. Self-inflicted again: the agent re-derived the whole transparency story
|
||
(recording pixel archaeology, compositor blend re-verification) instead of reading
|
||
this entry — the instrument said transparent because the capture had NOT been
|
||
repainted yet. Do NOT re-derive this story a third time.
|
||
|
||
**2026-09-12 → RESOLVED — the box lived in the paste-cache RASTER, not the page.**
|
||
The 15:51 facts (alpha max=255, mean ~19, zero 57%, white rounded panel, real
|
||
transparent margins) + preview-correct + recording-box meant the capture transparency was
|
||
REAL all along; the fracture sat in `SceneCompositor.BlitContentRaw`
|
||
(SceneCompositor.cs:419-550) — the sampler that builds the element-space paste-cache
|
||
raster, the ONE path the take loop never read in full. Its partial-alpha branch applied
|
||
the OPAQUE-dst blend onto a TRANSPARENT raster base: `dst = (src*sa + dst*inv)/255`
|
||
with dst black → color PREMULTIPLIED by sa, then `dst[+3] = 255` — a 50%-alpha widget
|
||
pixel became darkened color + FULL alpha. At paste time `BlendRowOpaque` saw alpha 255 →
|
||
straight copy → the scene behind was overwritten by darkened ink. Fully-transparent
|
||
margins (alpha 0, `continue`) and fully-opaque content (alpha 255 branch) survived —
|
||
which is why every take showed a box while the dumps and the preview (raw WriteableBitmap,
|
||
unaffected by the compositor) stayed correct, and why the CSS-wipe fixes (`b4bba4b` /
|
||
`0f72c53`) were red herrings: they treated the page as the villain, but the capture was
|
||
transparent from the start. **FIX:** `BlitContentRaw` gained `transparentDst=false`;
|
||
the raster call site passes `true` and writes STRAIGHT color + straight alpha so the
|
||
paste rows (`BlendRowOpaque`/`BlendRowWeighted`) do the real source-over onto the opaque
|
||
master. The master paths are untouched (bit-identical). ONE test
|
||
`PasteCache_SemiTransparentLayer_RevealsBackdrop_NotOpaqueInk` fails on the old code with
|
||
exactly the bug encoded: 50%-blue over red reads `(0,0,128)` instead of `(127,0,128)` —
|
||
backdrop never shows through. Next: verify take (element rect shows the backdrop behind
|
||
the widget art), then push gate #1 clears.
|
||
|
||
**2026-09-12 → COLLATERAL — the SAME transparency saga broke the webcam next.**
|
||
Verify-take of the transparency fix showed a perfect flat gray rectangle where the webcam
|
||
should be (preview fine, recording flat — stdev <1 across the whole element, despite the
|
||
raw camera frame handed to the compositor sampling min=0/max=255 one line earlier in the
|
||
pipeline — proven by instrumenting BOTH ends before touching any code, not guessing).
|
||
Root cause: `ed9d7c1` ("CropBounds metadata + Fill-style scaling... widget fills element
|
||
rect" — take-22 of the SAME transparency saga above) added `cbX/cbY/cbW/cbH` to
|
||
`BlitContentRaw`'s general sampler but only assigned them inside the
|
||
`src.CropBounds is {} cb` branch; every CropBounds-LESS source (webcam, images — anything
|
||
but the web widget) fell through with `cbW=cbH=0`. Since `sxCrop = sxNorm * cbW` and
|
||
`syCrop = syNorm * cbH`, both were always 0, so `sxCanvas`/`syCanvas` collapsed to
|
||
`cbX`/`cbY` = (0,0) for every destination pixel — an entire scaled webcam sampled ONE
|
||
source corner pixel. The web widget (the only CropBounds-bearing source) was never
|
||
affected, which is exactly why the transparency fix's own test suite stayed green while
|
||
this broke. **FIX:** default `cbW = src.Width`, `cbH = src.Height` (cbX/cbY = 0) before
|
||
the branch, only overridden when CropBounds is actually present. ONE test
|
||
`BlitContentRaw_NoCropBounds_SamplesAcrossFullSource_NotJustOrigin` (two-color split
|
||
source, scaled non-1:1 through the paste cache) fails on the old code — right-half pixel
|
||
reads the left half's color — and passes on the fix; proven both ways with a stash/build/
|
||
revert cycle, not by inspection alone.
|
||
**Lesson:** when a shared low-level sampler gains a new optional code path (CropBounds),
|
||
audit EVERY variable the new branch introduces for a safe default in the branch it did
|
||
NOT touch — an uninitialized-to-zero "crop region" silently means "sample only pixel
|
||
(0,0)", not "no crop." grep for this shape (`var x = 0;` followed by an `if (cond) x = ...`
|
||
with no `else`) whenever a conditional metadata field is added to pixel math.
|
||
|
||
### Shrink / re-encode an image for the README (screenshots → small hero image)
|
||
|
||
Worked out 2026-08-29 (the recipe was NEVER recorded the first time it was done, so
|
||
it had to be re-derived from scratch — that's the incident this entry exists to end).
|
||
|
||
**Approach:** a throwaway Windows-dotnet console app uses WPF's imaging stack
|
||
(`System.Windows.Media.Imaging`) — same framework the app runs on, zero NuGet
|
||
packages, high-quality downscale via `TransformedBitmap`. Screenshots compress
|
||
**far smaller as JPEG than PNG** (PNG 1400px = ~1.2MB; JPEG q82 1400px = ~188KB).
|
||
|
||
**Recipe (run via the Windows dotnet host from WSL):**
|
||
|
||
1. Create `imgresize.csproj` targeting `net8.0-windows` with `<UseWPF>true</UseWPF>`
|
||
(SDK controller). Put it in a Windows-visible temp path, e.g.
|
||
`C:\Users\gramp\AppData\Local\Temp\imgresize` — NOT `/tmp` (Windows dotnet can't
|
||
reach a Linux-only path reliably).
|
||
2. `Program.cs`: load `BitmapImage` (`CacheOption=OnLoad` → `Freeze()`), downscale
|
||
with `TransformedBitmap(src, new ScaleTransform(scale, scale))` to max width
|
||
(1400 for the README hero), encode with `JpegBitmapEncoder { QualityLevel = 82 }`,
|
||
save.
|
||
3. Run:
|
||
```bash
|
||
"/mnt/c/Program Files/dotnet/dotnet.exe" run -c Release --project .
|
||
-- "C:\Users\gramp\Downloads\Screenshot 2026-08-29 075626.png"
|
||
"C:\Users\gramp\Documents\Code\projects\ytLive\docs\ytLlive-preview.jpg" 1400
|
||
```
|
||
4. Point `README.md` at the `.jpg` (not `.png`).
|
||
|
||
**Result:** 3.2MB screenshot → 1400×794 → **188KB** `ytLlive-preview.jpg` in `docs/`.
|
||
|
||
---
|
||
|
||
### Measuring audio-video A/V sync from a recording (no ears needed) — RECIPE
|
||
|
||
Derived 2026-09-12/14 (the "audio cut off at the end" / "audio delayed" reports). You
|
||
cannot "listen" to a take — measure it. Proven on two independent takes (talking+claps,
|
||
clean-clap test) with a tight, matching result each time.
|
||
|
||
**Method (FFmpeg probe + numpy, run from WSL):**
|
||
|
||
1. **Click/hiss-SILENT test takes are the gold standard.** Have the creator do a
|
||
loud, single-frame-syncable event (one clap after ~10s of near-silence) — the
|
||
video position of the hands-meet peak and the audio position of the transient
|
||
are both unambiguous. That single event replaced counting words forever.
|
||
2. **Extract audio as raw mono PCM** (`ffmpeg -vn -ac 1 -ar 48000 -f s16le`) and
|
||
compute a short window RMS envelope (5ms windows) in numpy. The clap is the global
|
||
max (`argmax`); also print a coarse 100ms table — it shows the pre-clap artifacts
|
||
(faint blips) vs the event (100× larger) vs true digital silence.
|
||
3. **Video motion per frame** — `-vf "tblend=all_mode=difference,signalstats,metadata=print"`
|
||
captured to stdout (NOT `file=` inside the filter — the `\\` path escapes mangle;
|
||
have PowerShell-style quoting bite; `metadata=print` writes to ffmpeg's log, so
|
||
redirect stdout to a file). Parse `YAVG` values; the clap is the frame with the
|
||
motion spike (2.75 vs a ~0.1–0.5 animated-widget background).
|
||
4. **Offset = audio_event_time − video_event_time**; every positive second means audio
|
||
is BEHIND video by that much. Cross-correlate the two full envelopes (60fps-resampled
|
||
RMS vs YAVG, normalized) to double-check the single-event peak — the autocorr-style
|
||
peak must be sharp (unique max at +133 frames, second-best ≈0.26).
|
||
5. **Sanity checks baked in:** count frames (`nb_frames` vs wall log) — if video is
|
||
real-time you've ruled out the truncation bug as the cause; compare stream
|
||
durations (audio > video by the lag is the SIGNATURE of a delayed audio tail, not
|
||
truncation); check `dropped writes` — dropped audio moves events EARLIER (opposite
|
||
sign), so it can never explain a lag. **A constant offset ≠ drift:** fixed lag =
|
||
buffering/backlog, growing offset = clock mismatch.
|
||
|
||
**The ROOT CAUSE the recipe led to (record it so it's never re-derived):** the mixer's
|
||
capture rings are fed from APP STARTUP (for the meters); `StartLive` never clears them,
|
||
so the drained audio trail begins ~a full ring-depth (2.0s) behind go-live. **Rule: any
|
||
"live preview/drain" sink fed by a continuously-capturing buffer MUST clear the buffer
|
||
at go-live, or early output is stale backlog.** The 2s ring + configured 300ms sync
|
||
delay + ~100ms pipeline = exactly the measured 2.11–2.22s. The lag ALSO scales with
|
||
"time since app launch" up to the ring depth — a take right after a restart reads as
|
||
~370ms while a fully-warmed app reads ~2.2s. Same code, wildly different numbers.
|
||
|
||
### Measuring webcam CAMERA cadence in a recording — the MAD method (RECIPE, 2026-10-16)
|
||
|
||
Derived while reading the first instrumented take. Answers "did the camera's cadence reach the
|
||
FILE?" — the ground truth beside the pump's own counters. Tool: **`scripts/webcam-cadence.py`**
|
||
(`python3 scripts/webcam-cadence.py TAKE.mp4 --x X --y Y --size 180 --start SECONDS`).
|
||
Three wrong readings were produced before it worked; each is a trap to skip:
|
||
|
||
1. **Exact pixel equality is useless.** H.264 re-encodes even a *fully static* region slightly
|
||
differently every frame, so `cur == prev` reported **57.9 distinct images/s** on a take whose
|
||
camera delivers 30/s — every frame "new". Use a THRESHOLD on the mean absolute difference
|
||
(MAD) between consecutive frames: the distribution is bimodal (codec noise < 0.1, real camera
|
||
motion > 1), so read the threshold off the histogram (0.5 for a moving subject) instead of
|
||
guessing.
|
||
2. **The crop must sit INSIDE a round clip.** The webcam is `clip=Round`, radius `r = min(w,h)/2`
|
||
= 145 for a 526×290 box. A square crop's corners lie at `s/√2` from the centre, so `s = 240`
|
||
→ 170 > 145 → the corners sampled the 60fps BACKGROUND behind the circle and the probe counted
|
||
60/s. Keep `s ≤ 1.41·r` (180 is safe for r=145). **The crop rect comes from the pump's
|
||
`FramePump scene dump:` line** (`rect=1425,748 526x290` → centre 1688,893).
|
||
3. **One scene at a time.** Element rects differ per scene, so a crop carried across a scene
|
||
switch sits on background and reports a multi-second "hold" that never happened (a 13.8s
|
||
"freeze" in the first pass was just the `Chat` scene). Pick a window from the dump timestamps
|
||
with `--start`.
|
||
4. **A still subject also produces long holds.** Threshold counting cannot distinguish "the
|
||
camera had nothing new to send" from "we repeated a frame" — read it BESIDE the pump's
|
||
`FramePump stats` lines (`N renders/5s` = composites/s, `webcam N/s` = arrivals), which do not
|
||
depend on subject motion.
|
||
|
||
**Healthy shape:** the hold histogram peaks at **2 frames** (30fps camera in a 60fps container:
|
||
every image twice → ~50% duplicates), distinct/s ≈ the camera's own rate, longest holds in the
|
||
low hundreds of ms. Oct 16 Release take: 29.6/s, **82% of images held exactly 2 frames**, longest
|
||
267ms. A peak at 4+ frames, or distinct/s approaching the composites/s figure, is the judder.
|
||
|
||
**Cross-check with ffmpeg's own filters (no numpy, uses this file's A/V-sync technique):** the metric
|
||
above is the mean absolute frame-to-frame difference, which is exactly what
|
||
`tblend=all_mode=difference,signalstats,metadata=print` reports as `YAVG`. Same crop, 40s window:
|
||
|
||
```bash
|
||
ffmpeg -v error -ss 45 -t 40 -i TAKE.mp4 \
|
||
-vf "crop=180:180:1598:803,scale=64:64,format=gray,tblend=all_mode=difference,signalstats,metadata=print:file=-" \
|
||
-f null - | grep YAVG | sed 's/.*YAVG=//' # count values <= threshold
|
||
```
|
||
|
||
It agreed with the script on the same take (52.2% duplicates / 28.6 distinct/s vs 50.7% / 29.6/s —
|
||
the windows differ slightly), so either path is trustworthy; use the script when the full take and
|
||
the hold histogram matter, this pipeline for a fast spot check.
|
||
|
||
### Feeding a rawvideo pipe at 60fps: deadline pacing + row-blit budget
|
||
|
||
Derived 2026-09-03 (take-3 diagnosis — the stats seam from `97ffc42` named the stage
|
||
in one line: `17/300 frames per 5s, avg render 258.1ms, avg submit 1.5ms`).
|
||
Both halves were solved by OBS/libyuv long ago; do not re-derive:
|
||
|
||
1. **Pacing is a DEADLINE, never a post-render sleep.** `sleep(interval)` after each
|
||
frame makes the period `render + submit + interval` — the producer can hit ≤ half
|
||
the declared rate even with a free render. OBS's `video_thread`
|
||
(`libobs/media-io/video-io.c`) advances an absolute `nextTick += intervalTicks` and
|
||
sleeps only the remainder. **NEVER "rebase" the deadline to wall-now when it blows** —
|
||
the take-3 note I originally wrote here ("skip … the missed ticks (rebase)") was the
|
||
PROVEN-wrong advice: resetting `nextTick` erases the missed slots, so a 43fps reality
|
||
was authored into a 60fps container and every recording played ~1.4x fast (take 12/13,
|
||
2026-09-10). The rebase even kept the stats looking honest (render+wait == period, no
|
||
loss) because a rebased frame is never "late". OBS keeps the counter MOVING and outputs
|
||
ONE frame per interval tick — a late render shows as REPEATED footage (judder; duration
|
||
stays correct), never a skip and never a wall-now reset. Critical with rawvideo:
|
||
pts is stamped by ARRIVAL, so the muxed duration is pure frame count ÷ fps — the only
|
||
way to make duration == wall time under ANY load is exactly one frame per interval slot.
|
||
Corollary: **never add a second pacer.** `ffmpeg -re` on the rawvideo input throttles
|
||
the pipe independently and fights the pump (its "Resumed reading … after a lag" grew
|
||
0.79s→4.82s in the same take). Removed; the pump IS the pacer.
|
||
2. **A 1080p frame is ~2.07M pixels — the hot path must be row-simple.** Per-pixel
|
||
`Math.Round` + float source-over in managed code costs ~100ns/px = the whole 258ms.
|
||
libyuv's pattern (https://chromium.googlesource.com/libyuv/libyuv/): branch per
|
||
pixel on source alpha (opaque → 4-byte copy, transparent → skip), integer
|
||
fixed-point blend `(s*a + d*(255-a) + 127)/255` otherwise; and ALWAYS clip the loop
|
||
to the intersection rect (our social-bar overlay scanned all 2M dst px for a 64px
|
||
strip). A full-cover 1:1 blit also obsoletes the opaque-black pre-fill — skip dead
|
||
writes.
|
||
3. **On Windows, `Task.Delay` is a 15.6ms QUANTUM, not a timer.** Any request under one
|
||
system-clock tick sleeps a full tick (documented — learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.delay:
|
||
"approximately 15 milliseconds on Windows systems"). A deadline pacer built on Task.Delay caps the
|
||
producer at ~40fps-ish EVEN IF render is instant — take 9 proved the signature: work fell 26.5→22.4ms
|
||
but the period sat at ~37ms (≈ one padded wait/frame), so two real optimizations read as "zero change".
|
||
Frame-accurate loops (OBS/Chromium/game-loop canon — stackoverflow.com/questions/5441464) do:
|
||
`timeBeginPeriod(1)` for the session (paired with `timeEndPeriod`), sleep only the BULK of the
|
||
remainder, SPIN the last ~2ms across the deadline. Diagnostic before touching the compositor again:
|
||
period ≈ work + 15.6 → the SLEEP is the bug, not the work.
|
||
|
||
Related (take 14, 2026-09-04): **recycled ring buffers are a race you must SIZE, not just own.**
|
||
Deepening shared frames to kill GC churn (a fresh 8.3MB/tick array) hands out REUSED memory — the
|
||
ring's depth × source period must EXCEED the worst consumer hold (compositor read + lagged UI
|
||
preview copy), not just "a few frames". 4 slots at 144Hz capture laps in ~27ms vs a ≤50ms read: half
|
||
a new screen frame flashed over an old one in the recording ("bits flashing over other bits").
|
||
Depth 8 everywhere (screen/camera/web output rings); the paste-cache Epoch still guards identity.
|
||
|
||
4. **Hermetic pacing test:** inject the delay seam to RECORD the requested TimeSpan and
|
||
genuinely await it (`Task.Delay(d, ct)`) — a fake that returns
|
||
`Task.CompletedTask` synchronously makes the whole pump loop run on `StartAsync`'s
|
||
sync continuation and hang the test run (hit this 2026-09-03; the existing fakes all
|
||
yield for exactly this reason). Assert the REQUESTED wait (< interval with a
|
||
≥cost-ms fake render) — never wall-clock rate, which flakes on loaded machines.
|
||
5. **Expensive content: raster on change, never on read (take 5, 2026-09-04).** A source
|
||
that updates once a minute (chat text!) must not full-rasterize (`FormattedText` +
|
||
`RenderTargetBitmap` + `CopyPixels` ≈ 15-25ms) every compositor tick. OBS text sources
|
||
re-render on property/message change; the per-tick pass blits the cache. Implement as:
|
||
content version (collection-changed counter) + config key (size/appearance) → cached
|
||
immutable `VideoFrame` returned by identity. Gotcha: buffers that SURVIVE sessions
|
||
(the chat log) silently arm the per-tick cost even in flows that never touch the
|
||
feature (signed-out record-only takes paid chat rendering!).
|
||
|
||
6. **An async loop started from a UI handler runs ON THE UI THREAD until you take it off.**
|
||
`await` continuations re-capture the current `SynchronizationContext` — the frame pump was started
|
||
from a WPF command handler, so the "WPF-free, hermetic" compositor rendered and read capture state
|
||
ON THE DISPATCHER, serialized behind the live preview itself, for the whole starvation saga. The
|
||
`wait` stat caught it only when the numbers became self-contradictory (render 22 + wait 10 > any
|
||
rebasing deadline — a blown deadline cannot sleep). OBS runs `obs_graphics_thread`/`video_thread`
|
||
as dedicated threads for exactly this reason. Pattern: `_task = Task.Run(() => Loop())` (null
|
||
context inside), then audit EVERY object the loop touches for UI affinity (RenderTargetBitmap /
|
||
DrawingVisual / WriteableBitmap: marshal the work or the rare miss; plain locked byte[] lookups:
|
||
fine) and pin it with a context test (`Pump_Produces_OffTheStartingContext`, inline-pumping
|
||
SynchronizationContext that the old code failed by construction). Cost: takes 3–10.
|
||
|
||
7. **Prove the stage, then the fix — and re-prove after every slice (2026-09-04, takes 6-8).**
|
||
The chat raster fix was REAL but the composer blamed it for the residual slowness it did not
|
||
own; two takes burned before the render/resolve split showed `resolve ≈ 0` and pointed at the
|
||
compositor pasting static layers per tick (`BlitCachedLayer` finished the job OBS-style). Before
|
||
shipping a perf fix: name the stage with a measurement, not a story; after shipping one, the
|
||
NEXT number must move — a fix that doesn't change the stat wasn't the bottleneck.
|
||
|
||
8. **An encoder refed from a real-time loop must ENQUEUE, never pipe-write in the loop
|
||
(2026-09-10, take 15/16 — the "1...23...4...56..." smeared ticker).** After slice 9 the
|
||
recording played at the right DURATION but the content still hiccuped — and the aggregates
|
||
(301/300, uniform file PTS, 15.6s wall vs 15.74s file) could NOT see it. The cause finally
|
||
measured in `FfmpegEncoder.SubmitFrameAsync`: the `WriteAsync(8.3MB)+FlushAsync` to ffmpeg's
|
||
stdin BLOCKS whenever the encoder lags the pipe, and the slice-9 burst `while (now>=nextTick)`
|
||
then re-wrote that SAME stale composite for every slot that ticked past — frozen content runs.
|
||
OBS's answering machinery is the encoder queue (`libobs/obs-encoder.c`): the encoder thread
|
||
NEVER couples back into the video thread; overflow = dropped data, NEVER a frozen producer.
|
||
Fixed as: bounded `Channel<byte[]>` (cap 120) + a dedicated drain task owning stdin,
|
||
`SubmitFrameAsync` = copy-to-pool-array + `TryWrite` (drop-newest + count when full), stop
|
||
flushes the queue then EOF. **Rule: verify with a clock-independent judge.** The WSL ticker that
|
||
"proved" slice 9 has its
|
||
own Host-timer jitter under Windows load — so this slice burns a dot-matrix `_outputIndex`
|
||
into the bottom-right of every composite; decoding the recording reads the honest sequence
|
||
(+1/frame, jumps = counted drops) with no external clock involved. Take 17 must read
|
||
+1/frame from that strip. (A whole-frame duplicate scan was tried and is
|
||
UNRELIABLE here: the scene is always animating — session elapsed timer + REC pulse — so
|
||
no two frames are ever byte-identical.)
|
||
|
||
**SUPERSEDED by slice 15 (2026-09-14):** the last clause "pump emits ONE fresh composite per
|
||
iteration (no burst re-write)" had it BACKWARDS for the deadline. Slice 10 chose a skip when
|
||
the render overruns — an overrun's slots vanish from the file — and that AUTHORS ACCELERATED
|
||
playback (my item-1 lesson already said it: "NEVER a skip … a late render shows as REPEATED
|
||
footage"). Device takes proved it: one fresh frame per 35ms stall → `ty-20260914-1726` = 163
|
||
video frames (2.72s) against 2.93s of audio, video ending 0.22s early ("audio speeds up then
|
||
cuts off"). OBS's answer (docs.obsproject.com/backend-design: "If the video frame queue is
|
||
full, it will duplicate the last frame"; `libobs/obs-output.c` counts "lagged frames due to
|
||
rendering lag/stalls" — never a time-hole) is to fill each missed slot by DUPLICATING the
|
||
newest frame; duration == wall, judder not fast-forward. That's what the pump's submit now
|
||
does: a catch-up loop over the missed slots emitting this iteration's composite again, clamped
|
||
to a `deadlineNow` captured once (bounded — the smear that justified slice 10 was the
|
||
BLOCKING pipe-write re-copying during a long freeze; the queue makes each emit nanoseconds,
|
||
so the burst is safe). The burned index moved inside the submit loop: EVERY emitted slot
|
||
carries its own +1 (this also fixed the old unconditional `_outputIndex++` that gapped the
|
||
sequence on non-submitting fast-render iterations). A/V sync must be re-clap-measured after
|
||
this fix — the +0.6s audio-late reading on 1726 was confounded by the 1.1x acceleration.
|
||
|
||
**Latest A/V sync numbers (2026-09-14, pre-slice-15 pacing):** keep the recipe below honest on
|
||
clap measures. ty-1723: 697 video frames (60fps) = 11.62s vs 11.84s audio. ty-1726 (clap): 163
|
||
frames = 2.72s vs 2.93s audio; clap audio env peak 1.655s vs video motion cluster 0.75–1.03s →
|
||
single-peak offset ≈ +0.64–0.89s, cross-correlation lag +0.667s (audio late) — BOTH confounded by
|
||
the acceleration; re-measure after slice 15.
|
||
|
||
|
||
**Slice 16 (2026-09-14) — the desktop capture conversion was the bottleneck; here is
|
||
the freeze-audit recipe (RECIPE — re-deriving it cost this session):**
|
||
The slice-15 build fixed pacing but the desktop layer of the recording was still
|
||
"jerky / laggy / frozen with a horizontal tear". Measure, don't guess — and the
|
||
measurement said something different AND worse than the running render theory.
|
||
`FramePump stall… worst render 33-36ms` was real but MOOT: once the camera+desktop
|
||
take was decoded to raw frames, the **desktop band was frozen 21s of 23.35s (90%)**
|
||
with ~6.1 content updates/s and freeze intervals up to 2.28-2.78s. The capture
|
||
CONVERSION was the wall: the monitor delivers at the **240Hz DWM cadence**, the
|
||
source converts ONE frame at a time (`_framePending` latest-wins), and each
|
||
2560×1440→1920×1080 `DownscaleBgra` — naive double-per-pixel bilinear — cost
|
||
~30-45ms quiet and ~150ms+ under load (GPU-copy contention on 240Hz HDR). Result:
|
||
~6-9 fresh frames/s of DESKTOP content inside a 60fps file. The webcam (its own
|
||
MediaCapture path) and audio were fine — exactly what the user reported.
|
||
|
||
**The audit recipe (ffmpeg → raw gray → numpy):**
|
||
```
|
||
ffmpeg -i ty-*.mp4 -pix_fmt gray -f rawvideo /mnt/c/tmpout/f.take.raw
|
||
python3 - <<EOF
|
||
import numpy as np
|
||
fr = np.memmap("/mnt/c/tmpout/f.take.raw", np.uint8, mode="r").reshape(n,h,w)
|
||
band = fr[:,40:320,20:620] # desktop band, skip title/social bars
|
||
d = [np.abs(band[i].astype(int16)-band[i-1]).mean() for i in range(1,n)]
|
||
thr = np.percentile(d,25) + 0.5*(np.percentile(d,97)-np.percentile(d,25))
|
||
print(sum(x>thr for x in d)/ (n/60)) # fresh content-updates/s
|
||
EOF
|
||
```
|
||
"fresh content-updates/s" in the DESKTOP band vs 60 slots is the bottleneck read;
|
||
the compositor render stats led nowhere until this number existed. A **per-row
|
||
split detector** (`cumsum` of per-row diff-to-next minus diff-to-prev, argmax =
|
||
split row) then separated real mid-frame tears (score ≈ huge, both halves match
|
||
neighbours) from bottom-strip social-bar churn — the pairs it flagged at 97-100%
|
||
were the session UI, not tears.
|
||
|
||
**The fix (this slice):**
|
||
- **integer 8.8 fixed-point downscale, "shift only at the end"** — the SAME math as
|
||
`SceneCompositor.Bilinear` (rounded both stages in one 16.8 scale) ported into
|
||
`DownscaleBgra`, dropping per-pixel doubles to row-walk integer ops. The capture
|
||
ring already had the integer-bilinear lesson; the capture downscale itself was
|
||
still the naive float twin of the 258ms disaster.
|
||
- **throttle to the slot cadence** (`MinConvertInterval = 10ms`): the 240Hz arrival
|
||
is ~4.2ms — accepting every delivery queues ~150ms of serialized conversion per
|
||
second minimum; a 10ms floor caps the open edge just above the ~60/s the 60fps
|
||
pump can use.
|
||
- **ring reuse-distance, not ownership** (`FrameRingBuffer`, redLine 4): the
|
||
take-14 "depth × period" rule guards size; the slice-16 addition makes it
|
||
structural — a slot is only rewritten ≥4 rents after its last hand-out, else a
|
||
fresh buffer. `session.LatestFrame` survives across conversions and the
|
||
dispatcher preview copy lags, so "who released it" is unknowable without a
|
||
consumer API; a reuse-DISTANCE contract needs no consumer cooperation. The 1742
|
||
tear (new-top/old-bottom midway) is that read-under-write closed.
|
||
- **measure before trusting the inherited plan:** the approved native-res capture +
|
||
composite-side downscale (C1) was recast to "fix the downscale in place" —
|
||
relocating a 30ms float downscale from the capture thread to the render thread
|
||
and caching by Epoch only moves the same ~30ms cost into the slot budget. The
|
||
measurement said the cost ITSELF was the enemy; keep the architecture, make the
|
||
op fast.
|
||
|
||
**Slice 17 follow-up (2026-09-14) — the readback, not the downscale, was the real
|
||
wall; and the pool-size lever was a trap (RESEARCH fact — would have shipped a
|
||
bug):**
|
||
Slice 16's downscale fix landed (20-50ms conversion, healthy) yet take ty-1824 was
|
||
still ~90% frozen in the desktop band (6.8 updates/s, 4.85s max freeze). The new
|
||
telemetry said it plainly: `conv avg 46-50ms, max ~61ms, skip busy 37-83` — the
|
||
**GPU→CPU readback (`CreateCopyFromSurfaceAsync`), NOT `DownscaleBgra`**, is ~45ms of
|
||
that conversion on a 240Hz-HDR box sharing the GPU with the encoder. One-in-flight =
|
||
readback-bound at ~17-20 conversions/s — the real cap the whole way down. Two
|
||
follow-on decisions fixed by evidence:
|
||
- **pool size is a clip, not a scale (near-miss).** The "obvious" fix was shrinking
|
||
the pool to the master size to read back less. Microsoft's screen-capture page
|
||
forbids it: "the underlying Direct3D surface is always the size specified when
|
||
creating … the Direct3D11CaptureFramePool. If content is larger than the frame,
|
||
the contents are **clipped**." Shrinking to 1920×1080 would CROP a 1440p monitor,
|
||
not scale — silently encode the desktop cut off. Readback must stay native; the
|
||
lever is concurrency, not size. **Rule: read the platform doc for the exact
|
||
primitive before "fixing" the pool/format; scaling assumptions about capture APIs
|
||
have been wrong twice now.**
|
||
- **overlap the readbacks + a monotonic publish gate.** With up to 3 conversions in
|
||
flight, completions can land out of order; a slow OLDER readback finishing last
|
||
would stomp a newer frame (a backwards time hole — the mirror of the 1742 tear).
|
||
`MonotonicGate` (seq set via `Interlocked.Increment` before the copy, verified by
|
||
compare-exchange publish) drops stale completions instead. Ring gets a lock because
|
||
rents are now concurrent; downscale row-scratch became per-conversion locals.
|
||
Lesson: keep a *single* "what is bound?" number per layer (telemetry line) before
|
||
choosing between throughput and latency fixes — both previous slices picked the
|
||
wrong slot ("render" vs "conversion") until the audit existed.
|
||
|
||
---
|
||
|
||
|
||
**Take-4 follow-ups (2026-09-04) — the symptom needed a second pass, so cite again:**
|
||
render was still 58.9ms after slice 1. Slice 2 (buffer pool + opaque-row memcpy +
|
||
integer bilinear) followed the same libyuv research
|
||
(https://chromium.googlesource.com/libyuv/libyuv/ — `row.cc`/`scale.cc` keep both
|
||
interpolation stages in ONE fixed-point scale; rounding constant only at the end).
|
||
My first `Bilinear` shifted stage 1 back to 8-bit AND shifted the final result >>16 —
|
||
double scaling turned solid-255 samples into ~1, i.e. the "fixed" general path drew
|
||
NOTHING (green webcam silently vanished from output; the pixel probes caught what
|
||
the eye in a 2x time-lapse would not). **Rule: multi-stage fixed point shifts only
|
||
at the end; verify against a uniform-255 sample before believing it.** Second trap:
|
||
a stale-byte sentinel test whose source pattern can generate the sentinel value
|
||
itself (0xAB was a legitimate `x+y` pixel) — pick the sentinel coprime/out-of-range
|
||
to every channel formula (0xFD: odd, not ×4, above the R max). Third: a fake encoder
|
||
that HOLDS submitted frames now must snapshot them (`Clone`) once the producer
|
||
legitimately recycles buffers — mirror the real consumer's copy semantics in the fake.
|
||
|
||
---
|
||
|
||
|
||
### A web widget captured at 10Hz inside a 60fps recording plays at ~1/6 speed
|
||
|
||
Derived 2026-09-10 (the "widget animation too slow" report). The recording is 60fps and the
|
||
WebView2 capture loop was a blind 100ms `DispatcherTimer` = 10Hz — each captured web frame gets
|
||
repeated ~6× in the file, so whatever the page animates at, the OUTPUT is capped at the *capture*
|
||
cadence, not the page's. Two stacked throttles, both real:
|
||
|
||
1. **The capture rate is the hard ceiling.** web-layer motion in the recording can never beat
|
||
`CapturePreviewAsync` frequency. But you can't just raise the timer: full-HD PNG capture costs
|
||
~10-30ms (WebView2Feedback#20: "CapturePreviewAsync … produces PNG/JPG and is very slow"), so
|
||
concurrent captures stack CPU AND can publish stale-after-fresh. The mandatory shape is a
|
||
**latest-wins drop**: at most one capture in flight per session; a tick during the in-flight
|
||
window is DROPPED, never queued; effective cadence = max(interval, capture duration). Do this
|
||
before anyone touches the interval constant.
|
||
2. **Chromium throttles hidden pages.** An off-screen WebView2 (we place it at (-5000,-5000)) is a
|
||
hidden page the moment the host window is unfocused or covered: `requestAnimationFrame` parks and
|
||
JS timers clamp to ~1s (WebView2Feedback#1172 — background-throttled rAF; #3070 — a WebView2 with
|
||
`Visibility.Collapsed` slows timers to 1s; Chrome-88 blog — heavy timer throttling of hidden tabs).
|
||
There is NO supported per-page opt-out (#5250 still open). The embedder answer is browser args on
|
||
the `CoreWebView2EnvironmentOptions` created BEFORE `EnsureCoreWebView2Async`:
|
||
`--disable-backgrounding-occluded-windows --disable-renderer-backgrounding
|
||
--disable-features=CalculateNativeWinOcclusion` (the Electron/Streamlabs-class fix for
|
||
occluded-window animation throttling). Share ONE environment across sessions (one browser process).
|
||
3. **Measure before picking the cadence.** Log the first ~30 captures' elapsed ms on the first
|
||
recording run; PNG encode + WPF decode of 1920×1080 is the per-capture cost that decides whether
|
||
~30Hz is affordable or it must drop to ~20Hz. The FramePump drops frames (never time-lapses) if
|
||
UI-thread GC churn starves it, so cost shows up as dropped-frame stats — read them.
|
||
|
||
|
||
---
|
||
|
||
|
||
## Splitting a large file into partials — NEVER `awk … > SRC` while awking SRC
|
||
|
||
(2026-08-31, Commit D) Tried to split `SocialsDialogViewModel.cs` in one line:
|
||
`{ awk '…' SRC; echo ""; awk '…2…' SRC; } > SRC`. The **first write truncated
|
||
SRC to 1 line**, so the second `awk` read the already-truncated file → the whole
|
||
source was lost (1 line left). Recovered with `git checkout -- SRC`, then redid
|
||
it, but the same bug could have meant making it up from scratch.
|
||
|
||
**Rule:** when a cut needs N blocks from one source into N files, never write a
|
||
block back onto the source that the `awk`s still read. Instead:
|
||
1. Read the source **once** at the start into temp files (`mktemp -d`, one file
|
||
per block), with a `$D` variable you carry forward.
|
||
2. Verify block sizes (`wc -l`) and brace balance (`python3 -c` counting `{`/`}`)
|
||
before touching any real file.
|
||
3. Then assemble each new file from `cat D/block …` — never truncating the source
|
||
until every read is done.
|
||
|
||
`git status` can't save you here if you don't notice until the file is gone —
|
||
`git checkout -- <path>` from the last commit is the recovery. Cheap insurance:
|
||
restore-then-retry, do it atomically from temp files the first time.
|
||
|
||
---
|
||
|
||
## Verifying an ffmpeg decode contract from WSL (no real CLR needed)
|
||
|
||
(2026-08-31, TASK 21) When a change depends on ffmpeg producing output with an
|
||
exact frame-size contract (rawvideo W×H×4 BGRA), you can prove the **command +
|
||
frame accounting** here without any .NET process:
|
||
|
||
1. Fetch a **static Linux ffmpeg** into `/tmp/opencode` (no sudo needed):
|
||
`curl -sLO https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
|
||
&& tar -xf …`
|
||
2. Generate a tiny known clip: `ffmpeg -f lavfi -i "testsrc2=duration=1:size=640x360:rate=30" -pix_fmt yuv420p clip.mp4`
|
||
3. Decode with **exactly the app's args**: `-f rawvideo -pix_fmt bgra -vf scale=640:360 -an`
|
||
4. Assert `total_bytes % (W*H*4) == 0` (python3) → exact integer frames, no pad.
|
||
|
||
**Why not a dotnet-spawned ffmpeg here:** the only CLR on this box
|
||
(`/home/gramps/bin/dotnet`) is a **Windows-bound shim** — `Process.Start` resolves
|
||
paths to `\\wsl.localhost\Debian\…` and throws "not a valid application for this
|
||
OS" when handed a Linux ELF ffmpeg. So never plan to have dotnet exec a Linux
|
||
ffmpeg here; verify the contract with shell/python instead, and leave the
|
||
CLR→real-ffmpeg run to the native Windows suite.
|
||
|
||
## WPF hit-test truth in tests: `UIElement.InputHitTest`, NOT `VisualTreeHelper.HitTest`
|
||
|
||
(2026-09-01, the RoundClip "known failure" post-mortem — a failure the map carried as
|
||
"not a regression" for weeks without ever recording WHY.)
|
||
|
||
**The trap:** `VisualTreeHelper.HitTest(window, pt)` returned the window's
|
||
`WebViewHostPanel` overlay (`IsHitTestVisible="False"`, `Opacity=0`, ZERO children — since the
|
||
2026-09-14 composition-capture redesign the overlay is DELETED, but the API lesson stands) for
|
||
EVERY point in the window — so a "corner is grabbable" assertion could never pass, and
|
||
it looked like a real interaction bug. The actual input pipeline (`UIElement.InputHitTest`,
|
||
what Mouse routing uses) correctly returned the element's Grid at elem-center/corner-in/
|
||
corner-exact and fell through to CanvasGrid just past the corner. The product was fine;
|
||
the TEST was probing an API that doesn't model input semantics.
|
||
|
||
**Rule:** any test asserting "where does a click land" uses `window.InputHitTest(pt)` +
|
||
`IsDescendantOf` — never `VisualTreeHelper.HitTest`.
|
||
|
||
**Diagnosis recipe (how the lie was caught in ~3 probe cycles, no guessing):** add a TEMP
|
||
probe `[Fact]` in the RealApp collection that hit-tests a spread of points
|
||
(elem-center / corner-in / corner-exact / corner-out / bg-center) and `Assert.Fail`s with a
|
||
composed dump: per-point VTH hit + `InputHitTest` hit + ancestor chain (`GetParent` walk
|
||
with `#Name`) + panel properties (`IsHitTestVisible/Opacity/children/actual size`) +
|
||
`TranslatePoint` origins. Run the class alone, read the message, delete the probe.
|
||
|
||
**Two sibling facts learned the same session (record-once):**
|
||
1. A UserControl owns its own XAML namescope — after extracting a region out of a window,
|
||
`window.FindName("InnerPart")` returns null; resolve the UserControl by its window-level
|
||
name, then `pane.FindName("InnerPart")`. And window-scope STYLES are invisible to a
|
||
UserControl's `StaticResource` at parse time — move such styles to `Themes/Controls.xaml`
|
||
(the app-scope rule exists for this).
|
||
2. Per-class `dotnet.exe vstest` from WSL DOES execute the RealApp/`MainWindow` tests fine
|
||
(they passed natively 2026-09-01) — only the FULL suite hangs (WASAPI startup). And the
|
||
test process shares `%APPDATA%\ytLlive\startup.log` with the real app: lines like
|
||
`camera 'test-camera' failed` are test noise, not DB state — to check pollution, query
|
||
the DB directly (`python3 sqlite3`, `SELECT DeviceId FROM Webcam`), not the log.
|
||
|
||
## Deterministic test for a selected layer above an overlap (2026-10-28)
|
||
|
||
When preview rendering uses WPF z-order but dragging uses an additional custom hit-test, test both
|
||
contracts without injecting a physical cursor: in a real `MainWindow` on the `RealApp` STA, overlap
|
||
two scene elements, select the lower one through the layer list, assert `Panel.GetZIndex` raises its
|
||
container, assert `window.InputHitTest(point)` resolves the selected visual, and call the same
|
||
production hit-test method used by the drag handler to assert it chooses that selected model. Clear
|
||
selection and assert both hit paths return the original top layer and the scene's element order is
|
||
unchanged. WPF documents that input hit testing returns the input element at a point and that visual
|
||
hit-test results follow z-order: <https://learn.microsoft.com/en-us/dotnet/desktop/wpf/graphics-multimedia/hit-testing-in-the-visual-layer>.
|
||
|
||
Do not use `SetCursorPos`/`mouse_event` for this specific assertion: the test passed alone but was
|
||
intermittent in the full suite, with a cursor move landing at the canvas edge (`X=1900`) rather than
|
||
the intended offset. Physical injection remains appropriate only when the behavior under test is the
|
||
actual OS mouse-routing/drag path (as in the layer-list reorder test).
|
||
|
||
## A "known failure" label without a recorded cause = a bug on life support
|
||
|
||
(2026-09-01, the audio triple-take) One line — `_delayedMix` (nullable, added by TASK 22,
|
||
never initialized) dereferenced as `delayed.Length` — produced THREE symptoms that lived in
|
||
the map as two separate "pre-existing, do-not-chase" entries: (a)
|
||
`Mix_HonorsProviderGains…` "known failure", (b) `AudioPipelineTests` hangs when run at all
|
||
(a test reading a named pipe with no writer blocks — hung test ≠ flaky test, it's a starved
|
||
producer), (c) startup.log flooded "Audio live loop error" every 10ms (the mixer loop caught
|
||
and logged only `ex.Message` — stack thrown away).
|
||
|
||
Rules derived:
|
||
1. NEVER label a test "known/pre-existing" without writing WHY (exception type + first app
|
||
frame). An unexplained known-failure is deferred archaeology that hardens into fog.
|
||
2. A test that waits on IPC + a producer whose output vanished are usually ONE bug — look
|
||
for the producer before blaming the test.
|
||
3. Catch-and-log-swallow of `ex.Message` hides root causes; log with stack (`AppLog.Write(ex,
|
||
...)`) and throttle (5s) instead of dropping or flooding.
|
||
|
||
**Second instance (2026-10-16) — a "timing flake" that was a test-side data race.** A full-suite run
|
||
failed `FramePumpTests.Stats_ReportRealRendersAndDistinctWebcamFrames_NotJustPaddedSubmits`; it passed
|
||
3/3 in isolation, which is exactly how a load-sensitive flake presents. It was not timing:
|
||
`System.InvalidOperationException: Collection was modified; enumeration operation may not execute` at
|
||
the test's own `log.Any(...)` line — the test handed `FramePump` a plain `List<string>` and enumerated
|
||
it from the test thread while the pump reported stats from its loop thread (`pacingDelay` is
|
||
`Task.Yield()`, so the loop really is concurrent). The app was innocent (production passes the
|
||
thread-safe `AppLog`). Fix: `ConcurrentBag<string>` (snapshot enumeration). **The lesson that produced
|
||
it:** the previous commit ADDED log lines from the pump thread, which raised the collision rate from
|
||
rare to routine — a latent race is promoted to a routine failure by any change that increases the
|
||
frequency of the shared access, and the failure then looks like a flake in the new code rather than a
|
||
race in the old test. When a test fails ONLY under load, check for a shared mutable collection on the
|
||
producer's thread before reaching for a retry.
|
||
Bonus: the "failing" test encoded the MAP's contract (`loopbackGain = GameAudioVolume`); the
|
||
code had drifted to unity on a disproven premise (loopback capture does NOT follow endpoint
|
||
volume — creator's 20%-volume/pegged-meter observation killed it). The test was right all
|
||
along — failing tests may be the last honest witnesses; interrogate, don't pardon.
|
||
|
||
## Feature provenance: record WHO asked and WHY, in the task entry itself
|
||
|
||
(2026-08-31/09-01, the SYNC slider scare) TASK 22's lip-sync slider surfaced on the preview rail and
|
||
the creator's reaction was "totally don't remember ordering that" — because the queue entry recorded
|
||
WHAT shipped (a slider, 0-500ms, a converter class) but not WHO asked (the creator, explicitly, for
|
||
OBS's delay-filter fix built natively). Eight days later his own request read like AI drift and nearly
|
||
got deleted. Rule: the moment a creator-driven feature is queued or shipped, its entry carries a
|
||
one-line provenance — *who asked, what triggered it* ("creator: OBS delay-filter lip-sync fix, native").
|
||
Features without attribution become roadmap orphans that get punted, removed, or re-litigated. Same
|
||
disease as an unexplained "known failure" label — a fact recorded without its reason is a future
|
||
argument.
|
||
|
||
## An un-attributed build invalidated three takes of a perf saga — stamp the binary
|
||
|
||
(2026-09-04, takes 4–6) After each render-perf fix the creator "exed the code" and re-recorded, but
|
||
the exe timestamp ≠ binary contents (incremental builds reuse whatever compiles clean; a source edit
|
||
with no rebuild serves the OLD exe). Take 6 measured render WORSE than take 5 (35-41ms) and there was
|
||
no honest way to tell "the chat cache fix doesn't work" from "the fix was never running" — three
|
||
hours of diagnosis on an unattributable sample. Rule: if takes measure the app, EVERY build carries
|
||
an id and EVERY log line traces to it — `GenerateBuildStamp` (csproj) writes a fresh GUID per
|
||
compile (deliberately defeating incremental lies), the wordmark shows it as a superscript, startup.log
|
||
records `Build <id> (compiled <time>)`. A perf claim without build attribution is a guess; ask for the
|
||
stamp BEFORE theorizing. (Also this session: a sentinel-byte test where the source pattern could
|
||
GENERATE the sentinel, and a fixed-point bilinear that shifted BOTH stages and silently drew nothing —
|
||
see the rawvideo recipe.)
|
||
|
||
## Signed A/V sync: negative offset = eat the buffer head (OBS semantics), armed at go-live
|
||
|
||
(2026-09-14) The ring-backlog fix shrank A/V lag from ~2.2s to ~0.54s, and ~300ms of that was
|
||
baked into the DB (`Audio.SyncOffsetMs=300`) via a POSITIVE-only control. For audio running BEHIND
|
||
video you cannot push audio later — positive delay makes it worse. OBS's answer is a NEGATIVE offset
|
||
that eats the buffer head: drop the first |N| ms of the written stream so every audio event lands
|
||
|N| ms EARLIER relative to video. It only makes sense at the head of the stream, so it is armed once
|
||
at `StartLive` (positive stays live-reactive via the delay line). Rule: record the signed semantics
|
||
together — positive = delay (ahead), negative = eat-head advance (behind) — and lock the control
|
||
while live (`IsEditMode`) since a mid-stream advance flip is meaningless. Test recipe:
|
||
`StartLive_NegativeOffset_AdvancesAudio_ByDroppingTheStreamHead` (emit 6×0.9 into an 8-tick budget,
|
||
long 0.2 bed, wire must show only 0.2).
|
||
|
||
## Composition video capture of WebView2: the CoreMessaging DQ recipe + the build gotchas
|
||
|
||
(2026-09-14, the ~20→60fps web-capture redesign — record-once so the next GPU-path work never
|
||
re-derives it. Everything that follows was already settled by OBS/Flutter `webview_windows`; the
|
||
cost this session was the 19041 projection's gaps.)
|
||
|
||
1. **No `DispatcherQueueController.CreateOnCurrentThread()` on the 10.0.19041 projection**
|
||
(CS0117 — only `CreateOnDedicatedThread` + `FromAbi(IntPtr)` exist). P/Invoke
|
||
`coreMessaging.dll!CreateDispatcherQueueController` with a sequential
|
||
`DispatcherQueueOptions { DwSize, ThreadType=2 (DQTYPE_THREAD_CURRENT), ApartmentType=2 (DQTAT_COM_STA) }`,
|
||
wrap via `DispatcherQueueController.FromAbi(ptr)` (mirror of `CaptureInterop`), THEN `new Compositor()`.
|
||
One controller + one compositor per UI thread, created once.
|
||
2. **`CoreWebView2CompositionController` needs a real parent HWND** — there is no window in the
|
||
MainWindow ctor, so `InitWebView2()` moved from the ctor to `MainWindow_Loaded` (source
|
||
registration is guarded by `ContainsKey`, so the layout-load path is safe).
|
||
3. **Capture the ROOT visual, not the control.** `GraphicsItem` for a composition controller comes
|
||
from `GraphicsCaptureItem.CreateFromVisual(root)` where `root` is your own 1920×1080
|
||
`ContainerVisual` (`IsVisible=true`) holding the controller's `RootVisualTarget` as a
|
||
`RelativeSizeAdjustment=1,1` child — exactly what `webview_windows`'s `graphics_context.cc`
|
||
does (`CreateGraphicsCaptureItemFromVisual` on the root `surface_` visual). Hide nothing,
|
||
move nothing, poll nothing — the capture is frame-driven at the renderer's pace.
|
||
4. **Straight alpha, never premultiplied.** Read back with `BitmapAlphaMode.Straight`; the
|
||
compositor's blend is straight-alpha, so `Premultiplied` readback wrecks corner anti-aliasing.
|
||
5. **Namespace landmines that each cost a build cycle in `Services/`:** `Compositor` resolves to the
|
||
repo's OWN `ytLive.Services.Compositor` namespace — fully-qualify `Windows.UI.Composition.Compositor`;
|
||
`CoreWebView2CompositionController.Close()` is the disposal call (no `Dispose()`); `Color` is
|
||
ambiguous (`System.Drawing` vs `System.Windows.Media`) — qualify `System.Drawing.Color.Transparent`.
|
||
6. **Testing a compositor-built bitmap without a runtime:** the internal seam ctor
|
||
`(Dispatcher, Func<string, IScreenCaptureSource>?)` + a `FakeWebSource` + a real background-STA
|
||
`DispatcherPump` (borrowed from ScreenCaptureManagerTests) drives the whole session path hermetic.
|
||
When byte-comparing a crop, slice the source with stride gaps (helper `CropBytes`) — a contiguous
|
||
range silently spans rows.
|
||
|
||
Recipe verified on green suite + 0-warning build; the composition path itself still needs a device
|
||
take (open item, HANDOFF).
|
||
|
||
## A pipe-read test that samples one tick is a timing flake by construction
|
||
|
||
(2026-09-14, re-discovered) `Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` fails
|
||
sporadically in ISOLATION (3/3 on the clean tree) but passes in the full suite: it reads exactly one
|
||
tick's worth after muting, and if the pipe still carries pre-mute buffered bytes the read spikes at
|
||
0.4 instead of silence. Any caller of `ReadFullyAsync` on a live pipe must either close/drain the
|
||
pipe first or assert on a multi-tick window. Do not blame a sync change for this — verify the grain
|
||
against the clean tree in the same mode before touching the mixer.
|
||
|
||
---
|
||
|
||
### Slice-18 follow-up (2026-09-15) — C4 composite cache: test fakes must mirror production's STABLE scene and per-tick purity
|
||
|
||
The C4 blit-on-change cache keys on a hash of the resolved inputs, INCLUDING per-element reference
|
||
identity (`RuntimeHelpers.GetHashCode(element)`). Two test-setup habits silently broke/starved it:
|
||
|
||
1. **`NewPump`'s default scene is fresh per tick** (`() => BackgroundScene()` — fine for pacing
|
||
tests) — churned the element refs, so the signature NEVER matched and the cache looked broken
|
||
(301 "renders" instead of 1). Production hands a STABLE `StagedScene`. **Rule: any FramePump test
|
||
that asserts per-frame content or cache behavior must pass `scene: () => scene` with one Scene
|
||
instance; the fresh-scene default is only for pacing/diagnostic tests.**
|
||
2. **A call-count resolver flip (`flip++ % 2`) double-advances under a cache-aware pump** — the
|
||
tick resolves TWICE (signature pass + compositor pass). Key per-tick content on the burned
|
||
`OutputIndex` (stable until submit, which happens AFTER render) or on `encoder.Frames.Count`
|
||
instead. A stable-per-tick token, not a call counter, is the deterministic alternation.
|
||
3. New frames that must invalidate the cache need BOTH a fresh array AND a fresh Epoch (array
|
||
identity alone is unchanged for a mutated-in-place array; `Epoch` is the monotonic generation
|
||
marker the compositor's paste key and the C4 signature share).
|
||
|
||
---
|
||
|
||
### ALERT-CLIP DECODE PIPE (RECIPE — TASK 47, 2026-09-26) — read a per-play mp4 into loose frames + stereo 48k audio
|
||
|
||
Worked out for the alert-box video: a short mp4 played **per alert** (one-shot) and dropped at
|
||
drain, NOT the refcounted media-source manager. Recipe:
|
||
|
||
- **Two ffmpeg pipe outputs, one child:** `ffmpeg -loglevel error -i "{file}" -f rawvideo -pix_fmt
|
||
bgra -vf scale={W}:{H} - -f f32le -ac 2 -ar 48000 -` — `-loglevel error` keeps stderr quiet (the
|
||
other pipes' ffmpeg rivals read the whole FD so a chatty stderr deadlocks); do **not** drain
|
||
stderr, mirror the `MediaVideoSource` split. Video pipe read backs onto `RawVideoFrameReader`
|
||
(TASK 21) which already pace-throttles to rtc-frame-rate… earlier live pacing is VFR-driven.
|
||
- **Audio = a carry-buffer loop, never a per-read slice.** PCM16 bytes → float32 chunks changes the
|
||
per-frame byte count; the naive `ReadFullyAsync(count)` per chunk **drops samples that straddle a
|
||
pipe read boundary** (the first draft's bug). The fix: an in-loop buffer that carries leftovers —
|
||
read `count - pending` bytes, emit whole float chunks from `pending + fresh`, keep the remainder.
|
||
Pacer: 50ms audio chunks → `Thread.Sleep` the (chunk length − read wall time) remainder.
|
||
- **bgra from ffmpeg is OPAQUE** (alpha=255). Whoever fades must copy the frame and scale alpha
|
||
across the copy (straight-alpha); the decoder never touches alpha. That keeps the decoder pure
|
||
and the fade a layer concern.
|
||
- **Fake-decoder convention:** the integration test implements `IAlertClipDecoder` inline with
|
||
`EmitFrame/EmitAudio/End` helpers — Start/Stop/Dispose are recorded as int counters, frames and
|
||
audio are pushed from the test thread, EOF is a test-raised event. No ffmpeg, no threads, still
|
||
the full real-time path through the layer.
|
||
- Re-grep here before building a second "play a file into the mix" path — snippet above is the
|
||
whole non-trivial part.
|
||
|
||
### A component's drain must reset its OWN anchor state (TASK 47 post-mortem — the test caught what a no-frame path hid)
|
||
|
||
`AlertOverlayLayer.Advance` gained a clip branch for video (TASK 47). In the ANIMATION branch the
|
||
old code did `_current = null; _elapsed = 0;` before `AdvanceToNext()`; the new clip branch called
|
||
only `StopClip(); AdvanceToNext();` — so after the EOF fade-out the layer kept `_current` pointing
|
||
at the finished message: `IsPlaying` stayed true, `RenderFrame` re-rendered the alert through the
|
||
animation fallback, and the layer never returned to idle. The six-animation tests couldn't see it
|
||
(they only ever exercise the animation branch); the video test's explicit
|
||
`Assert.False(layer.IsPlaying)` after drain nailed it on the first run. Rule: **when a second
|
||
execution path is added to a state machine, the drain/precondition contract of the ORIGINAL path is
|
||
part of the spec** — assert the component's end-state on the new path, don't assume the old path's
|
||
cleanup. The forest for the trip: the fallback path (animation) MASKED the broken path because its
|
||
output still looked like "an alert rendering", exactly the flick I'd have shipped if the test
|
||
hadn't forced the null-frame idle state.
|
||
|
||
### A seam fed from the chat poller must marshal EVERY WPF-object write to the UI thread (AlertOverlayLayer crash, 2026-09-26)
|
||
|
||
First crash in the app's history (startup.log `11:22:01.301`), and the sims gave zero warning for
|
||
months. Test-tab alert buttons run on the UI thread, so `OnMessageReceived` → preview writes were
|
||
always UI-threaded there. The moment a REAL message round-tripped through the chat poller:
|
||
`System.ArgumentException: Must create DependencySource on same Thread as the DependencyObject` in
|
||
`DataBindEngine.ProcessCrossThreadRequests` — WriteableBitmap created on the MTA poller thread,
|
||
`alertBox.VideoImageSource` raises INPC (`DisplaySource`) and is WPF-bound, the binding engine
|
||
re-binds cross-thread, process dies.
|
||
|
||
The chat layer already had the rule (`ChatOverlayLayer.OnMessageReceived` wraps its body in
|
||
`Application.Current.Dispatcher.Invoke`); the alert layer's copy of the seam didn't. Rule now
|
||
recorded for every layer: **the chat poller is an MTA producer — any INPC-raising seam it feeds
|
||
must marshal every WPF-object write (WriteableBitmap, DP/INPC-raised bound sources) to the UI
|
||
thread; wrap the WHOLE ingest, not just one writer, so enqueue/timer/preview stay coherent on the
|
||
UI thread. Guard `Application.Current` null like AlertTickerRenderer.Render does (pure seams).**
|
||
|
||
Red/green proof pattern: call the seam from a raw `new Thread` (MTA) while the RealApp message
|
||
loop runs, return to the loop so the marshalled work can execute (never `Join` on the UI thread),
|
||
then assert on the UI thread that `source.VideoImageSource.Dispatcher` is the App's dispatcher.
|
||
Red pre-fix (it was the poller's), green post-fix.
|
||
|
||
Grep-ahead: a `DispatcherTimer` ctor guarded by `if (Application.Current != null)` marks a class
|
||
with UI-thread affinity — audit every production ingress point for the marshal when adding one.
|
||
|
||
---
|
||
|
||
## A decoder that "starts" but yields nothing is DEAD AIR — fall back on a no-frame grace, never trust silent catches (2026-09-26)
|
||
|
||
Second live alert failure of the day, a different void than the crash: creator's repro — On-Air →
|
||
test → remove and re-add the Stream Alerts layer → TEST event → procs in chat but the alert box
|
||
stayed transparent, and startup.log 11:44's `peakMix 0.105` proved the alert audio never reached the
|
||
mix (vs 11:21's 0.375–0.891). ZERO exceptions anywhere; every failure stage was silent BY DESIGN:
|
||
`UpdatePreview`'s catch was `Debug.WriteLine`-only (nothing in release), and `AlertClipDecoder.RunAsync`
|
||
had a bare `catch { }` around the whole run (fires `Completed` regardless). A decode run that fails
|
||
AFTER `BeginClip` succeeds leaves `_clip != null` with `_latestClipFrame == null` → `RenderClipFrame`
|
||
returns null forever → box transparent FOREVER, and the six-animation fallback only ever ran on
|
||
setup-time throws. Even frame pacing was silently off (no frame-rate probe → `frameDuration = 0` →
|
||
video dumps instantly while 50 ms audio chunks pace the whole clip).
|
||
|
||
The established answer (derivative: any video-player/booth failure budget has the same contract):
|
||
**an alert that plays is a promise — the moment data stops arriving, degrade, don't vanish.** Fixed
|
||
in `AlertOverlayLayer.Advance`: a started clip that produced neither a video frame NOR an audio chunk
|
||
inside a `NoFrameFallbackSeconds` (1.0 s) grace is torn down, `_elapsed` resets to 0, and the SAME
|
||
alert continues as the six-animation branch — never drained early, never blank. And the silence is
|
||
gone: `BeginClip` writes the box id / resolved path / size + setup failure; `RunAsync` now logs the
|
||
frame/chunk end-state AND the exception it swallowed. Rules for every future gamble:
|
||
(1) a `catch` that hides WHY the output vanished is a lie — log the exception and the counters;
|
||
(2) any producer that yields zero output inside a grace becomes a fallback trigger, not a wait;
|
||
(3) one integration test per rescue: `SilentDecoder_FallsBackToTheAnimationAfterTheNoFrameGrace`
|
||
pins transparent→fell-back→renders→drains.
|
||
|
||
The pacing half that this entry flagged came true the same day: the 12:35 session showed a FULLY
|
||
HEALTHY decoder (`frames=240 audioChunks=156 failed=False`, alert audio in the mix) and the creator
|
||
still saw "no video / you lost the video". The clip played at ~240× for a second then froze on the
|
||
last frame: `AlertClipDecoderFor` passed no `frameRateProbe`, so `RunVideoAsync`'s pace
|
||
(`if (frameDuration > 0)`) was dead code while the audio chunk-pacer ran real-time. LESSON, sharper
|
||
than the whole fallback saga: **a decoder that drops data faster than wall-clock looks identical to a
|
||
dead decoder on screen — "plays but you don't see it" is a PACING bug before it's a decode bug.**
|
||
A healthy pipeline still needs an explicit pace per stream; never ship a pipe whose `frameDuration`
|
||
can be `TimeSpan.Zero`. Fix: the alert factory passes `FfmpegFrameRateProbe` exactly like the media
|
||
path does (MainViewModel.cs:308). Verify-by-seam once, everywhere: the same fake-process +
|
||
fake-probe delay-recorder test now pins real-time video pacing
|
||
(`AlertClipDecoderTests.AlertClipDecoder_PacesVideoFramesToTheProbedFrameRate`). Rule added to the
|
||
list above: (4) if output arrives but the USER still reports no/motionless picture, check pacing
|
||
before decode — count the bytes/s against wall-clock, don't re-audit the codec.
|
||
|
||
**RULE (5) — the last hop is a hop. A producer that hands over a correct frame has still
|
||
delivered nothing.** The 2026-09-26 alert hunt burned four builds (89/90 + two more) proving
|
||
the video was PERFECT: real h264, 240 frames, bright pixels at every timestamp, byte-identical
|
||
to the creator's source file, opaque `a255` frames logged every second into both the preview
|
||
and the compositor resolver. The box was blank because `PreviewPane.xaml`'s per-element
|
||
`Image` is `Collapsed` unless a DataTrigger says otherwise, and nobody had ever written the
|
||
`IsAlertBox` one. Two lessons, both expensive:
|
||
- **"It decodes, it's handed over, it's still invisible" ⇒ stop auditing the producer and
|
||
audit the CONSUMER'S VISIBILITY.** Bytes existing is not pixels being drawn. Diff the two
|
||
consumers' behaviour: chat box rendered, alert box didn't — that asymmetry was the whole
|
||
clue, and every per-frame log said "healthy".
|
||
- **A test whose fakes replace the failing seam will pass forever.** `AlertLayerVideoTests`
|
||
drove `RenderFrame` with a fake decoder and was green through the entire outage; the decode
|
||
test used a fake process; nothing ever crossed the XAML. When a feature's output is
|
||
*displayed*, the test must build the real display path (`AlertBoxPreviewVisibilityTests`
|
||
drives the real `MainWindow`+`PreviewPane`). And when the symptom is "nothing appears",
|
||
the honest test asserts on the CONSUMER (is the Image Visible / are the canvas pixels
|
||
different), never on the producer's own counters.
|
||
- Corollary: never trust "the data arrived" as a root cause. Ask what draws it.
|
||
|
||
**RULE (6) — a per-element `Image` is the wrong home for a GLOBAL overlay, and an
|
||
output-only producer is invisible by construction.** Same hunt, one layer up
|
||
(2026-09-26, after the `IsAlertBox` fix): the creator confirmed the clip plays, then
|
||
asked "where is the scrolling text? I do not see it." The ticker was healthy the whole
|
||
time — `AlertTickerRenderer` produced a 1920x48 frame and `FramePump` blitted it at
|
||
(0,0) — but it existed ONLY as a frame-pump callback (`alertTicker: () =>
|
||
_alertLayer.AlertTickerFrame`). `PreviewPane.xaml` had no element for it, because the
|
||
strip is master-width, not a `Source`, so it can't ride a per-element `Image`. Two
|
||
lessons:
|
||
- **When a feature's output is global (full frame width, a fixed overlay slot), it needs
|
||
its own root element + its own VM-published `WriteableBitmap`, exactly like
|
||
`SocialBarElement` — not a `Source`.** A global overlay that only a background thread
|
||
consumes will reach the stream and never the creator's preview, and no amount of
|
||
producer-side logging will show it: there is no consumer to be unhealthy.
|
||
- **Ask "who displays this?" while designing, not after the report.** RULE (5) says
|
||
audit the consumer when a picture is missing. RULE (6) is the earlier lesson: the
|
||
consumer may not exist yet, and that is invisible from the producer side.
|
||
|
||
**RULE (7) — pace a marquee in READS PER ALERT, never in pixels per second.** The ticker
|
||
shipped at a fixed `PixelsPerSecond = 140`, which sounds reasonable and was wrong: one
|
||
full pass took ~17s, so the 10s default clip showed the announcement barely once,
|
||
entering from the right and never crossing. The number that matches the creator's intent
|
||
("readable three times") is **passes per alert**, divided into the alert's own length —
|
||
the same convention the incumbent uses (Streamlabs exposes "Alert Duration: choose how
|
||
long your alert stays on your stream" and "Text Delay", never a scroll-speed slider;
|
||
https://support.streamlabs.com/hc/en-us/articles/52499995174299-Setting-up-Your-Streamlabs-Alerts).
|
||
So: `readsPerAlert / alertSeconds` => a pass length => a speed derived from it. Corollary
|
||
that cost a test rewrite: **a seamless marquee can never go blank**, so "the frame went
|
||
empty" is NOT a valid way to count passes — count the pill's leading edge resetting to
|
||
the right edge instead. And phase-start the run half a frame in, or the first published
|
||
frame is blank (the strip sits exactly off the right edge) and the preview opens on
|
||
nothing.
|
||
|
||
**RULE (8) — a bound `ItemsSource` ComboBox can break an unrelated real-mouse test.**
|
||
`LeftPanel.xaml` gained a display selector written as
|
||
`ItemsSource="{Binding ...}" + DisplayMemberPath + SelectedValuePath`; it compiled, my
|
||
feature's tests passed, and it broke `LayerReorderPersistenceTests.RealMouseDrag_OnThe
|
||
LayerList_PersistsTheReorder` — a test that injects PHYSICAL mouse input
|
||
(`SetCursorPos` + `mouse_event`). A data-bound selection resolves during load and
|
||
re-measures the panel under the test's feet, so the real cursor lands on the wrong row.
|
||
Rewriting it as inline `<ComboBoxItem>`s (the shape the chat Font selector already uses
|
||
in the same panel) fixed it with no other change. LESSON: **when a UI-only change breaks a
|
||
real-input test, the control's data-binding style is the suspect, not the feature** — and
|
||
bisect by reverting ONE file (`git checkout Controls/LeftPanel.xaml`) before theorising.
|
||
Prefer the idiom already in the file you are editing.
|
||
|
||
---
|
||
|
||
## 2026-09-26 — A blocking wait on the shared `RealApp` STA thread hangs the ENTIRE suite silently
|
||
|
||
**Symptom:** `vstest` printed 4 lines and then nothing. No test ever completed, no timeout, no
|
||
stack trace. Ran it three times, plus after a full rebuild and a reboot. Looked exactly like a
|
||
broken test runner.
|
||
|
||
**It was not the runner.** Two things were stacked:
|
||
|
||
1. **The runner output was being swallowed by my own shell plumbing.** Running `vstest` in the
|
||
foreground with a pipe to `tail` produced *zero* bytes even when the run finished. Launching it
|
||
with `nohup … > log 2>&1 &` and polling the log worked every time. **Recipe: always background
|
||
a `vstest` run and poll the log file.** Never trust a foreground piped `vstest` in this shell.
|
||
2. **The real hang was my test.** `LayerReorderPersistenceTests` and friends share ONE STA thread
|
||
via the `RealApp` collection. I had written the frame-pump tests as
|
||
`_app.Run(() => { pump.StartAsync(ct).GetAwaiter().GetResult(); … })`. `Run` *occupies* that
|
||
single thread, so the pump had nowhere to run and the two deadlocked — permanently, with no
|
||
timeout to save it.
|
||
|
||
**The rule:** a test that drives the frame pump MUST be `async` and use the new
|
||
`RealAppHost.RunAsync(Func<Task>)`, with plain `await pump.StartAsync(...)`. `await` yields the
|
||
STA thread back to the dispatcher; `.GetAwaiter().GetResult()` holds it hostage. **Never put a
|
||
blocking wait inside `RealAppHost.Run`.** A collection-serialized UI thread turns a missing
|
||
`await` into a whole-suite hang, which is why this presented as "the runner is broken."
|
||
|
||
**Also:** isolate before fixing. Running just the suspect class proved the runner was fine and my
|
||
test was the hang. Three blind retries on the same command would have "confirmed" a false theory.
|
||
|
||
---
|
||
|
||
## 2026-09-26 — Two real bugs my own new tests caught, and the cadence trap
|
||
|
||
Both were found only because the tests asserted the creator's *stated numbers* rather than
|
||
"something was drawn":
|
||
|
||
1. **Interval cadence implemented as an absolute deadline.** I had `_sinceStart` accumulate forever
|
||
and compared it to `_untilNextFlash`, which I *replaced* with a new interval on each fire. That
|
||
makes the second value an absolute time, not an interval, so gaps **shrink every cycle**:
|
||
measured 6.7s, 3.5s, 2.8s instead of 30–60s. Fix: keep an absolute `_nextFlashAt` but set it to
|
||
`_sinceStart + interval` on each fire, so the interval runs *from the last flash*. General trap:
|
||
whenever a timer holds "when did I last do X" and "when next", decide explicitly whether the
|
||
period is relative to the last event or to the start.
|
||
2. **A fade-in that starts at exactly 0 opacity emits a blank first frame.** `OpacityAt(0) == 0`, so
|
||
`Frame` returned null on the tick right after `Show()`. Not wrong (it is the start of a fade) but
|
||
it wasted a tick and made "is a credit on screen right now?" answer false. Worth knowing before
|
||
writing the assertion — my test's arithmetic was wrong, not the renderer, and I nearly "fixed"
|
||
the renderer to match the bad test.
|
||
|
||
**The lesson:** pin the *numbers the creator asked for* (2s, 30–60s, fully-on-canvas). "It rendered
|
||
something" passes against almost any implementation and caught neither bug.
|
||
|
||
**Third trap, cost me a wasted verification:** to prove a `const` isn't shipped I grepped the
|
||
Release binary for it — and it was absent from the *Debug* binary too, because **consts are inlined
|
||
at compile time and never reach metadata**. The control (run it against Debug as well) is what
|
||
exposed that. Grep for a **field/type** (`InstanceVariable`) or use reflection; never grep for an
|
||
inlined literal, and always run the check against the build you expect it to FAIL in.
|
||
|
||
## 2026-09-26 — A falsy `ShowDialog()` fell through into the side effect: "Cancel" that saved
|
||
|
||
The recording save prompt let the creator name the file. It always had a **Cancel** button, and
|
||
Cancel always *appeared* to work — the dialog closed, the toast was skipped, nothing visibly went
|
||
wrong. The footage was saved anyway, under the default name.
|
||
|
||
```csharp
|
||
// WRONG — and it type-checked, read fine, and looked correct
|
||
string stem = autoStem;
|
||
var dialog = new RenameRecordingDialog(autoStem);
|
||
if (dialog.ShowDialog() == true && !string.IsNullOrEmpty(dialog.ResultFileName))
|
||
stem = dialog.ResultFileName;
|
||
|
||
var finalPath = UniquePath(Path.Combine(dir, stem + ".mp4"));
|
||
File.Move(startPath, finalPath); // <-- runs even when the creator hit Cancel
|
||
```
|
||
|
||
**The trap:** `ShowDialog()` returning `false` was collapsed into the *default* branch instead of
|
||
being treated as a distinct outcome. "Not what I typed" and "I don't want this" are different
|
||
answers, and the nullable stem made the first one look like a legitimate value of the second. The
|
||
default value of the accumulator absorbed the cancel.
|
||
|
||
**The rule, and the grep-able form of it:** when a modal's result gates a side effect, every branch
|
||
is explicit and the side effect sits **inside** the accepting branch:
|
||
|
||
```csharp
|
||
if (!dialog.ShowDialog() == true) { DoTheOppositeThing(); return; } // read the FALSE case first
|
||
DoTheThing(); // the effect is not a fall-through
|
||
```
|
||
|
||
A `DialogResult`/return value that only ever *modifies* a later value (rather than *permitting* the
|
||
effect) is the smell. `null` from a dialog is the same trap wearing a nullable.
|
||
|
||
**Why it survived so long:** the side effect is in a *different method* from the modal, and the modal
|
||
had its own passing test (`RenameRecordingDialogSizingTests`, which only checks layout). The test
|
||
suite was green because the bug was never in the tested unit. **A modal's own test proves nothing
|
||
about the decision the modal feeds** — cover the commit-or-discard decision itself, against real
|
||
files (`ytLive.Tests/RecordingSaveDialogTests.cs` drives the extracted
|
||
`MainViewModel.CompleteRecordingSave` and asserts Cancel leaves the directory EMPTY, not just "the
|
||
stem is unchanged").
|
||
|
||
**Creator's actual ruling, worth keeping verbatim:** "if I click cancel, that doesn't mean to accept
|
||
the default file name and save the file — that's what pressing enter should do. Cancel should cancel
|
||
the save option, discarding the recording." A Cancel button on a destructive-and-final dialog means
|
||
*discard*, not *accept default*. When a dialog is the last gate before data is committed, ask what
|
||
Cancel should destroy — don't infer "keep the default" just because the default already exists.
|
||
|
||
---
|
||
|
||
## 2026-09-27 — A policy number in my head is not a policy finding: check that the policy *applies*
|
||
|
||
Two confident, wrong claims in one Store-research pass, both of the same shape — I had a policy
|
||
number and a confident paraphrase attached to it, and never asked whether the policy's own scope
|
||
clause covered us.
|
||
|
||
**1. "Store policy 10.10 (Advertising) requires X"** — 10.10 does not apply to LlamaCasty at
|
||
all. Every clause in it is conditioned on **ads your product displays**. YouTube injects ads
|
||
*server-side after the RTMP handoff*; the app renders no ad, hosts no ad SDK, and makes no ad
|
||
decision. I spent a cycle designing around a rule that was never in force. Same error later with
|
||
**10.2.5** (Store-only installation): the current policy text scopes it to **game and Xbox
|
||
console products**, and the older v7.7 wording that said "all of your apps" has been narrowed —
|
||
citing the stale wording would have produced a confidently wrong conclusion in the *opposite*
|
||
direction (claiming we were locked to MSIX).
|
||
|
||
**2. "Buy an EV certificate — EV gets instant SmartScreen reputation."** This one was worse than
|
||
wrong, because it was already **written into `Distribution.md`** and would have cost $400+/yr.
|
||
Microsoft **removed the SmartScreen instant-bypass for EV certs in March 2024**. EV and OV now
|
||
accrue reputation identically. I recited a fact that had been dead for over two years, in a
|
||
document that already contained the recommendation.
|
||
|
||
**The rule, and the generalisation:** *a policy citation is a claim about scope, not just about
|
||
text.* Before acting on one, read the **applicability sentence** and answer three questions —
|
||
does it cover our product class, does it cover our distribution model, and is the wording the
|
||
**current** version? A policy number without a verified scope is a guess wearing a citation's
|
||
clothes, and the failure mode is invisible because the sentence still *sounds* authoritative.
|
||
|
||
Corollary: **a stale external fact inside a shipped document is worse than a missing one** — it
|
||
is trusted, acted on, and costed. When a doc makes a factual claim about the outside world
|
||
(certificates, platform behaviour, policy text), re-verify it on touch, not on memory. This is
|
||
the same class as the dead-Pin incident in `FfmpegLocator` ([`docs/ai/output.md`](docs/ai/output.md) → FFmpeg locator): an
|
||
external thing changed, our record of it did not, and the mismatch was discovered by a user
|
||
rather than by a test. **Records of the outside world rot silently and are believed until they
|
||
cost money.**
|
||
|
||
---
|
||
|
||
## 2026-09-27 — I offered a mitigation I had not checked the mechanics of: "just refund anyone who asks"
|
||
|
||
While discussing a subscription price, I wrote that at `$99/yr` with zero marginal cost the
|
||
creator could **"just refund anyone who asks"** — presented as a free mitigation against the
|
||
churn/refund burden of a subscription. **That lever does not exist for Microsoft Store IAP.**
|
||
Refunds run through Microsoft, not the developer; the buyer requests them from the Store, and
|
||
the developer cannot unilaterally issue one. I proposed it before ever checking how Store
|
||
refund mechanics work, and it sounded confident because it was arithmetically true and
|
||
operationally impossible.
|
||
|
||
**The rule:** *a mitigation is not an answer until you know who has the authority to execute
|
||
it.* Before offering "we can just do X," ask **who presses the button** — and if the answer is
|
||
"Microsoft, or the user's bank, or a regulator," then it is not our mitigation and must not be
|
||
counted as one. The same sentence reappears in a subtler failure: the underlying instinct
|
||
(refunds *should* be cheap when marginal cost is zero) was sound; the *agency* was invented.
|
||
|
||
**The verification that came out of it is worth more than the mistake.** Checking Store refund
|
||
policy produced a hard constraint that **did** reshape the pricing design and would otherwise
|
||
have produced a product we could not fix: pro-rated refunds on cancellation are limited to a narrow
|
||
country list, and **"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. That is precisely the trap the creator had already named as
|
||
irritating — and it is unfixable from the developer side. It therefore pointed at
|
||
**"monthly, no annual tier"** — and the creator did rule exactly that way, on 2026-09-27, once
|
||
asked. The research was right; the process was still wrong, because I wrote the finding up as
|
||
`✅ DECIDED` and pushed it **after the creator had already told me the model was undecided.**
|
||
|
||
**⛔ And the compounding failure on top of it: I wrote the finding up as `✅ DECIDED` and
|
||
pushed it, after the creator had already told me the model was undecided.** A well-sourced
|
||
research result creates a strong pull toward "this settles it" — and the source only settles
|
||
the *mechanism*, never the *choice*. **Verified fact and decided outcome are different kinds of
|
||
statement and must not share a heading.** Ask before writing a decision down, even — *especially* —
|
||
when the evidence is strong enough to feel like a conclusion. **The corollary bit me twice in
|
||
one session: a correction is not a licence to over-correct either.** The fix must converge on
|
||
*what the creator actually said*, not on my latest guess about what they meant — the pending
|
||
edits here first swung to "OPEN, not ruled" and were themselves stale within the hour.
|
||
|
||
**Generalisation, and it is the same shape as the policy-applicability lesson above:** the
|
||
expensive mistakes are never the ones where I knew nothing. They are the ones where a *plausible,
|
||
well-formed, unverified* claim got to speak without a check — a dead EV-certificate benefit, a
|
||
policy number with no scope, a refund lever I did not control. **Sound reasoning about a
|
||
mechanism I had not verified is the failure mode, not the safety net.** Before any external
|
||
mechanism drives a decision, name the one fact that would falsify it, then go get that fact.
|
||
|
||
## 2026-09-27 — "Change Account" signed out BEFORE the browser opened: destroying state you cannot get back
|
||
|
||
**The symptom:** the creator clicked their avatar → Change Account, cancelled in the Google account
|
||
chooser, came back **signed out of a perfectly working account**, and then could not sign in again
|
||
("the callback url was not released"). They also observed it hitting *both* running instances.
|
||
|
||
**The cause, and it is embarrassing in its simplicity.** `ChangeAccountAsync` was:
|
||
|
||
```csharp
|
||
await SignOutYouTubeAsync(); // → TokenStore.Clear() → File.Delete(ytLlive.auth)
|
||
await SignInAsync(); // the browser has not even opened yet
|
||
```
|
||
|
||
The sign-out ran **first**, before any possibility of success, and `SignOutYouTubeAsync` does not
|
||
"log out" — it **deletes the DPAPI token file**. So the moment the creator committed to the intent of
|
||
switching accounts, the old account was already unrecoverable. Cancelling the browser flow then had
|
||
nothing to fall back to. The explicit sign-out was not merely redundant, it was the bug: success
|
||
already overwrites the token through the `sessionChanged → TokenStore.Save` hook.
|
||
|
||
**The transferable rule — order of operations on unrecoverable state:**
|
||
|
||
> **Never destroy the working state before the replacement is secured. Authenticate first, then
|
||
> replace.** If the new value can fail, cancel, or be abandoned, the old value is the *only* thing
|
||
> standing between the user and a re-authentication loop — so it must outlive the attempt, not
|
||
> precede it.
|
||
|
||
The tell for this class of bug: a call named for an *intent* ("Change Account") that internally
|
||
performs a *destructive reset* before doing the requested work. "Change" implies A→B, and the naive
|
||
implementation of A→B is `Reset(); Set(B)`. The safe one is `B = Compute(); if (B != null) Commit(B)`.
|
||
|
||
**The second defect was hiding behind the first, and the fix for it was also a race.** Abandoning the
|
||
browser gives the app no signal, so the OAuth attempt just sat on its loopback port `localhost:8765`
|
||
for the full 5-minute timeout; a retry could not bind and reported the callback URL as "not released".
|
||
Cancel-and-await-the-previous fixed the serial case, but my first version still had a window:
|
||
`Interlocked.Exchange(ref _authInFlight, null)` nulls the field *before* awaiting the old attempt, so
|
||
a third caller arriving in that window sees "nothing in flight" and races to bind the same port
|
||
anyway — **the takeover reintroduced the exact failure it was written to prevent.** A `SemaphoreSlim`
|
||
around cancel→await→bind made it atomic. My own test caught this; reasoning alone had "proved" it
|
||
correct. The generalisation: **when you serialise a handover, the whole handover is one critical
|
||
section — clearing the "who is doing this" marker early is the same bug as not having the lock.**
|
||
|
||
**Also worth recording: the cross-instance part of the report was NOT the cause.** The creator
|
||
believed one instance's sign-out hit both. It could not have — `TokenStore.DefaultPath` resolves
|
||
through `InstanceProfile.DataRoot` and `Clear()` only deletes a local file, never a server-side revoke,
|
||
and no second instance had a token to clear. The two claims were independent, and believing they were
|
||
linked would have sent me to debug the wrong subsystem. **One report can carry two unrelated defects;
|
||
split the report into claims and check each against the code before building a theory on it.**
|
||
|
||
### Known timing flake: `NotificationAreaIntegrationTests.Info_Auto_Dismisses_And_Error_Sticks`
|
||
|
||
Failed once on 2026-09-27 with "info toast never auto-dismissed" after an 8s wait, then passed in
|
||
isolation and on a clean full-suite run. It is a real-time WPF toast timer, so it loses under
|
||
parallel load — **it is not a signal that your change broke notifications.** Before believing it,
|
||
re-run it alone (`vstest --Tests:Info_Auto_Dismisses_And_Error_Sticks`); only treat it as yours if it
|
||
fails in isolation too. See also the two other suite-level timing traps already recorded above
|
||
(the one-tick pipe read, and blocking waits on the shared `RealApp` STA thread).
|
||
|
||
### Writing the *plan* into the handoff instead of the *outcome* — the OAuth verification trap
|
||
|
||
On 2026-09-27 I wrote a HANDOFF entry titled "record the Google Cloud project owner + the OAuth
|
||
verification blocker". What it actually contained was the two console questions I was about to go
|
||
answer, filed under `⏭ NEXT STEP` — which made the top of the file say *the next thing we do is
|
||
verify with Google*. The verification attempt had **already failed on a Google-side bug** and the
|
||
creator had **already ruled it unimportant for where we sit**. I knew the ruling; the map still said
|
||
the opposite, and the next session would have opened the console before opening the editor.
|
||
|
||
**The failure is not that the fact is missing — it's that the file is optimistic by construction.**
|
||
A "next step" heading is a promise that this is un-attempted and un-blocked, so parking a dead end
|
||
under one is worse than omitting it: the heading lends the dead end borrowed authority it does not
|
||
deserve. When an attempt dies on someone else's bug, the handoff must say **attempted → died on their
|
||
side → ruled unimportant → do not re-attempt**, in those words, where the next session will read it.
|
||
|
||
Two corollaries, both paid for here:
|
||
|
||
- **Record the ruling, not just the discovery.** "Here is the project number" is cheap and I wrote
|
||
it. "Here is why we stopped, and what it cost us to stop" is the part that changes behaviour, and
|
||
it is the part I skipped. The real cost — an accepted 7-day refresh-token expiry while the consent
|
||
screen sits in Testing — was never written anywhere.
|
||
- **Leave the unknown blank, loudly.** I cannot now say what the Google-side bug *was*. Writing a
|
||
plausible mechanism would be fabrication, and it would be the most trusted sentence in the file,
|
||
because it is the one that explains everything else. `HANDOFF.md` now carries an explicit
|
||
"deliberately left blank, do not reconstruct it" marker. An honest gap beats a confident fiction
|
||
in a memory map; in a memory map especially, since the whole point is that the next session skips
|
||
the search.
|
||
|
||
**Generalisation: a memory map entry that makes the reader's *next action* wrong is a bug, not an
|
||
omission.** When a fact is superseded by a ruling, the ruling wins the headline — the stale detail
|
||
belongs underneath it, labelled as what it is.
|
||
|
||
### Bundling a pinned binary: verify the pin, then delete the whole download path (TASK 48 item 1)
|
||
|
||
FFmpeg was the app's only download-and-execute third-party binary, which is exactly what Store
|
||
policy 10.2.2 names — and it had already failed in production (2026-09-01, the pin aged out of
|
||
BtbN's 14-day retention and the download 404'd mid-task). Bundling it took one unit. Four things
|
||
worth keeping, in the order I learned them:
|
||
|
||
**1. Check whether you already have the bytes before you re-fetch them.** The creator's local
|
||
`%APPDATA%\ytLlive\tools` already held the pinned build from the September download. Extracting the
|
||
pinned zip fresh and comparing SHA-256 per file proved all 9 were byte-identical, which upgraded
|
||
"probably the right build" to "provably the right build" for the price of one download. **An
|
||
existing cache is a hypothesis, not a fact — but it is a cheap hypothesis to test, and testing it
|
||
is what makes the bundle trustworthy.**
|
||
|
||
**2. Flip the probe order and say why, in the code comment.** The locator probed PATH first
|
||
("the user's own install wins"). Once the binaries are in the package, that is backwards: a PATH
|
||
`ffmpeg` may be a different major version or a **GPL** build, and the bundle is the one you pinned,
|
||
license-vetted and tested. PATH stays as the escape hatch, which is what it was actually for.
|
||
I would not have caught this without asking "which of these two is the one I tested?"
|
||
|
||
**3. Delete the fallback, do not just deprioritise it.** The old ladder had
|
||
`PATH → tools-cache → download`, and the tools-cache step meant a **missing bundle could be
|
||
silently satisfied by a stale copy**. For a dependency that Store certification inspects, silent
|
||
drift is the failure mode you least want: the package is wrong and nothing says so. A missing
|
||
bundle now throws, naming the folder and the fetch script. Likewise a build-time guard in the
|
||
csproj, so a fresh clone learns before a take rather than during one.
|
||
|
||
**4. 135 MB of DLLs in git is a real, recurring tax — the hash is the better audit trail.** The
|
||
temptation is to commit the binaries so "the build just works". On a 9p-bridged working tree that
|
||
tax is paid on *every* commit, status and checkout, forever, to serve a one-time packaging step.
|
||
Shipping a `fetch-ffmpeg.sh` that pins the URL and **verifies SHA-256 before extracting** keeps
|
||
the repo small, keeps the network dependency at build time instead of run time (which is what the
|
||
policy actually forbids), and gives the LGPL written-offer something better to point at than an
|
||
opaque blob: a URL, an artifact name, and a hash a reviewer can recompute.
|
||
|
||
**The test that earned its keep:** the integration test runs the bundled `ffmpeg.exe -version`.
|
||
Presence checks would all have passed with the DLLs missing — a shared build simply fails to
|
||
start, so *executing* it is the only assertion that proves the `libav*.dll` siblings resolved. When
|
||
a binary's correctness depends on its neighbours being adjacent, test the neighbours.
|
||
|
||
**Also: check what you just claimed about the licences.** `THIRD-PARTY-NOTICES.txt` said OpenH264
|
||
"ships inside the FFmpeg build". Verified rather than assumed: the encoder is compiled *into*
|
||
`avcodec-63.dll` (8 references in that DLL) and there is no separate OpenH264 binary to ship — so
|
||
the claim was right but imprecise, and it now says precisely that, with the check and its date.
|
||
The same file was still naming build tag `autobuild-2026-08-09-13-03` — **the tag that 404'd.**
|
||
A licence record that names a stale artifact is worse than no licence record, because it reads as
|
||
verified. When you pin something, the pin and the paperwork must move in the same commit.
|
||
|
||
### I repeated, in the same session, the mistake I had just written down
|
||
|
||
The ffmpeg-bundling unit added `TheLocatorCarriesNoNetworkCapability`, which failed on its first
|
||
run because a file-wide `DoesNotContain("Download")` matched that file's own doc comment — the one
|
||
saying "nothing is downloaded". I diagnosed it properly (bare word matches hit prose; use
|
||
call-shaped patterns) and wrote the lesson into this file. **Two hours later, in the very next
|
||
change, I wrote `DoesNotContain("force-ssl")` against `YouTubeAuthService.cs` and it failed for
|
||
the identical reason**: that file now names `force-ssl` in its doc comment *precisely to explain
|
||
why it was removed*. Writing the lesson down did not prevent the repeat; the lesson was not yet
|
||
load-bearing in the place the decision gets made.
|
||
|
||
**The actual fix, and it generalises past source-scan tests:** don't scan the *file* for a
|
||
token, **extract the value and assert on the value.** A `Regex` that pulls
|
||
`private const string Scope = "([^"]*)";` and then splits the captured string into scopes tests
|
||
the thing that matters — "exactly one scope, and it is the broad one" — and is structurally
|
||
incapable of matching prose. The general form: **when a fact must be asserted about a literal
|
||
that the surrounding documentation is also allowed to discuss, assert on the parsed literal, not
|
||
on the text that contains it.** A test that reads a file and greps it is a test whose failure
|
||
mode is "the comment changed", and that is how source-scan tests get deleted rather than obeyed.
|
||
|
||
**Meta-lesson worth more than the fix:** I made the same class of error twice inside one session
|
||
while actively documenting it. Repeating a documented mistake is not a knowledge failure — the
|
||
knowledge was fine — it is a *check* failure. The check that would have caught it: when a
|
||
`DoesNotContain` is aimed at a file, ask "does this file's own documentation have a legitimate
|
||
reason to contain the string?" If yes, the assertion is aimed at the wrong target.
|
||
|
||
### Extracting the duplicated helper created two fresh bugs in the same commit (2026-09-28)
|
||
|
||
Pulling the three hand-rolled `RepoFile` path-walkers into one `SourceAssert` helper — the fix for
|
||
the repeated prose-matching trap — shipped with two defects of its own, both caught by the build:
|
||
|
||
- **A method named `Path` shadowed `System.IO.Path` inside the class**, so the failure message that
|
||
wanted to *name the missing file* was itself a compile error (`CS0119`). Renamed to `RepoPath`.
|
||
A helper whose name collides with a BCL type is a trap for the next reader, not just for me.
|
||
- **The signature invited the bug it was meant to prevent.** `Literal(string fileName, string
|
||
memberName)` looked like it took a file name, but the body resolved the path from the repo root,
|
||
so the only legal call was a full directory path — and the call site passed
|
||
`"YouTubeAuthService.cs"`, which resolves to a path that does not exist. The failure was a
|
||
bare `FileNotFoundException` with no hint. Flipping the argument order to
|
||
`Literal(memberName, params string[] parts)` makes a wrong path *visible at the call site*
|
||
instead of buried in a resolved path.
|
||
|
||
**The generalisation:** a parameter shape that cannot express the common legal call is a defect
|
||
even when the compiler accepts it. `fileName` + `memberName` looks fine and is wrong; the honest
|
||
shape puts the variable part last and the required context first. This is the same family as the
|
||
prose-matching lesson — **the aim is to make the wrong thing hard to write, not to detect it
|
||
afterward** — and I got it wrong in the very commit written to apply that idea.
|
||
|
||
## Asked twice about a deliberate, already-documented decision (2026-10-04)
|
||
|
||
**Superseded 2026-11-04:** the creator clarified that forcing Private is a development/test safeguard,
|
||
not a permanent product lock. Release go-live must let the creator choose Private/Unlisted/Public,
|
||
default to Private, and allow live changes. Do not repeat the former “always Private until v1” rule
|
||
as current policy; keep development and test runs Private.
|
||
|
||
**What happened:** the creator asked whether the test streams still upload private, and added "I
|
||
don't think you're able to delete them". I answered, researched `videos.delete`, and then — in the
|
||
very next exchange — *offered to build a cleanup tool*, which re-litigated a decision that has been
|
||
settled since 2026-09-01. Then got asked about it again. The creator's response: "I keep telling
|
||
you; maybe you should write it down." It **was** written down. The note existed, was accurate, and
|
||
named the decision correctly. It still did not work.
|
||
|
||
**Why the note failed — this is the actual lesson.** The old text read:
|
||
|
||
> **Visibility picker — DELIBERATE TEST-PHASE LOCK, not drift.** The "always Private" enforcement
|
||
> stays while the creator runs multi-month real-world testing… The unlock is a deliberate final-pass
|
||
> item in **TASK 36**, wired with the dialog selection — never opportunistic.
|
||
|
||
Every sentence is **descriptive**. It explains *why* the lock exists and *where* the unlock lives.
|
||
Not one sentence instructs the reader. So it told me what the truth was but never told me what to
|
||
*do* when the truth looked like a defect — and `privacy=private` in a log line, a PRIVATE badge, and
|
||
the absence of any delete call all *look* like defects. I read it, recognised it, and still asked,
|
||
because recognition is not instruction. Worse, "never opportunistic" and "TASK 36" pointed at a
|
||
*different* answer than the one the creator actually gave me (v1, not TASK 36), so the note was
|
||
partly stale on top of being non-directive.
|
||
|
||
**The generalisation:** a decision note must be written as a **prohibition addressed to the future
|
||
reader, carrying its own trigger** — not as rationale. Three parts, all required:
|
||
1. **The verdict, in imperative voice:** "do not change it", not "it stays because".
|
||
2. **The trigger condition:** *what you will observe* that makes it look wrong — `privacy=private`
|
||
in a log, the PRIVATE badge, no delete call — so recognition fires the instruction automatically.
|
||
3. **The correction, verbatim, where it differs from the note.** The creator's answer (v1) was not
|
||
what the note said (TASK 36). A note that is confidently wrong is worse than no note: it makes me
|
||
confidently re-ask.
|
||
|
||
Corollary, and the part I keep skipping: **a repeated user correction is a documentation failure,
|
||
not a user annoyance.** The second time the same question gets asked, the correct response is not a
|
||
better answer — it is to go find why the written answer did not land, and fix *that*. Answering
|
||
well the first time was never the problem; the map was not carrying the instruction.
|
||
|
||
---
|
||
|
||
## Error messages: write for the maintenance dev, not for the toast (creator ruling 2026-10-04)
|
||
|
||
The creator's standard, verbatim: *"my advice for error messages is to give some thought to the
|
||
poor maintenance dev who's got to slog through this code tracking down bugs... generic error
|
||
messages or, worse, error consolidation would ruin their day completely."* Extended on the ANSI
|
||
point: *"didn't matter how cool it looked if you didn't understand what it was trying to tell you."*
|
||
|
||
**The rule: legibility outranks polish, and a cause must never be collapsed into a bucket.**
|
||
|
||
1. **Never consolidate distinct causes.** The first bind-failure implementation mapped seven
|
||
documented reasons to seven messages and then funnelled everything unmapped into
|
||
`_ => $"YouTube rejected the stream binding ({status})."` — which is the trap wearing a
|
||
friendly face. YouTube adds an eighth reason, the generic branch swallows it, and the dev
|
||
greps for a string the API never emits. **An unmapped reason is passed through verbatim so it
|
||
stays greppable against the raw response.** A friendly sentence may be *added* to an unknown
|
||
cause; it may never *replace* it.
|
||
2. **Never let the toast be the only record.** The user-facing string may be reworded; the log
|
||
must keep status + reason + the whole response body verbatim. Toast = friendly, log = exact.
|
||
3. **"Re-authenticate" is not a universal answer.** It was my first instinct for a bind failure and
|
||
it is correct for exactly one of six documented reasons. Wrong for the other five it actively
|
||
misdirects — worst for `liveBroadcastBindingNotAllowed`, which means *our own ordering* is
|
||
broken. Sending the dev to re-authenticate hides a bug in our own code behind a plausible
|
||
excuse. **Before writing a fix-it suggestion, ask which of the documented reasons this text
|
||
would be wrong for.**
|
||
4. **A sentinel that means three things is not a sentinel.** The diagnostic emitted `?` for
|
||
"field absent", "field unparseable" and "endpoint returned something else" — an ANSI-bright
|
||
`?` that looked informative and told the reader nothing. Distinct states get distinct tokens
|
||
(`unset` vs `unread` vs a real value), and unreadable input is never reported as a finding.
|
||
5. **Check the docs' failure modes, not just the happy path** (this one already bit a week of
|
||
diagnosis — see the SOLVED box in `ai.md`). Every reason the API can refuse must have its own
|
||
handling and its own test, or the refusal is invisible.
|
||
|
||
**Corollary, same shape as the decision-note lesson above:** the test suite is part of this
|
||
standard. A test that asserts a *disproven* behaviour doesn't just fail to help — it launders a
|
||
guess into a guarantee and makes the next reader stop looking. If a test's expectation is wrong,
|
||
the expectation is the bug; fix the belief, don't delete the assertion.
|
||
|
||
## 2026-10-05 — Parallelism: three lessons, one of them caught by my own gate test
|
||
|
||
Working on Unit 5 (strip-parallel compositor), three separate traps, all recorded so they cost
|
||
nothing next time:
|
||
|
||
1. **You cannot demote the ThreadPool — only your own threads.** The cheap-looking
|
||
`Thread.CurrentThread.Priority = ThreadPriority.BelowNormal` inside `Parallel.For` "works" and
|
||
silently poisons the process: `Parallel.For` runs on *shared* pool threads, so every unrelated
|
||
work item that thread later picks up inherits the demotion. I hit exactly that — a full test run
|
||
broke `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`, an audio test
|
||
that has nothing to do with compositing. Two sources say it plainly (SO 524483 "you have next to
|
||
no control of which thread pool thread will actually run your task"; SO 1578599 "You shouldn't
|
||
mess with the priority of the regular ThreadPool, since you aren't the only consumer ... write
|
||
your own producer/consumer queue, using your own Thread(s) as the consumer(s) - setting the
|
||
thread priority yourself"). The fix is `Helpers/StripPool.cs`: dedicated `Thread`s whose
|
||
priority I own. **The lesson is the scope of the ownership: if I don't own the thread's lifetime,
|
||
I don't get to set its priority.**
|
||
2. **`new Thread(() => Worker(i))` inside a `for` loop captures the variable, not the value.** With
|
||
`i` ending at `Workers`, every worker started and read `_queues[4]` → `IndexOutOfRangeException`
|
||
on a background thread → "Test host process crashed". Copy to a local (`var index = i;`) before
|
||
the lambda. It crashed the host on the FIRST run of the new test, which is the cheapest possible
|
||
warning — a shared static buffer I added earlier for "allocation reduction" had also become a
|
||
data race the moment strips ran in parallel (now `ArrayPool<float>` + `finally`).
|
||
3. **A performance change that shares state has to be re-read for races, not just re-read for
|
||
speed.** Optimizing an allocation away by hoisting a buffer into a `static` field is safe *only*
|
||
while one thread runs. The moment that same method fans out, "we never had a race" stops being
|
||
true. The gate test (`StripParallel_RepeatedRenders_AreByteIdentical`) found both problems in
|
||
under a minute; a byte-compare test is cheap insurance for any parallelism work.
|
||
|
||
**And the test flake that was NOT mine:** `Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`
|
||
captures **audio loopback**, so it fails whenever the creator has anything playing on the system —
|
||
it failed while the user was in a Division 2 boss fight and passed minutes later. **Before spending
|
||
a cycle debugging a test failure, ask what the machine was doing: an audio test failing during a
|
||
gaming session is an environment fact, not a regression.** (Same family as the known
|
||
`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` interactive-desktop
|
||
flake — both are in the "run it in a quiet session" bucket, and neither is a code defect.)
|
||
|
||
## 2026-10-07 — WPF VisualBrush: two silent "invisible box" traps
|
||
|
||
Working on the zoom-magnifier box in PreviewPane. Two gold-plated WPF gotchas that made the box
|
||
render NOTHING (no border, no content):
|
||
|
||
1. **An empty `<VisualBrush Stretch="None"/>` used as an `OpacityMask` paints nothing → the mask
|
||
alpha is 0 → the WHOLE element (border included) becomes transparent.** A VisualBrush without a
|
||
`Visual` is not "mask = none", it is "mask = fully transparent". An opacity mask wants a tile or a
|
||
visual source; if you meant "no mask", OMIT the Grid.OpacityMask element entirely.
|
||
2. **A `VisualBrush` whose `Visual` is an *ancestor* of the element it is painted on is a circular
|
||
reference — WPF renders nothing (no exception, no content).** The zoom box lived inside CanvasGrid
|
||
and the brush sampled CanvasGrid. Fix: hoist the box OUT so it becomes a *sibling* of the visual
|
||
being sampled (wrap CanvasGrid + ZoomBox in a container Canvas, coordinates unchanged since both
|
||
are in the same 1920x1080 space). Then `VisualBrush.Visual = CanvasGrid` samples cleanly and the
|
||
box magnifies the already-composited preview.
|
||
|
||
Release the retained visual when the box hides (`VisualBrush.Visual = null`) or the long-lived
|
||
source visual stays pinned for the app lifetime.
|
||
|
||
## 2026-10-08 — Editing a file that carries ANOTHER work unit's uncommitted changes
|
||
|
||
LeftPanel.xaml was dirty with unrelated "pinned chat" work; the zoom build needed one DataTrigger
|
||
inserted into its ItemContainerStyle. Committing `git add Controls/LeftPanel.xaml` would have swept
|
||
the OTHER work unit's hunks into my commit — a scope-lock violation. The clean recipe:
|
||
|
||
```
|
||
git stash push -m my-wunit-isolate -- Controls/LeftPanel.xaml # park the OTHER work unit
|
||
# edit + build + test + commit my hunk (file is now only-mine vs HEAD)
|
||
git stash pop # restore the other work unit
|
||
```
|
||
|
||
Rules that make it safe: stash the WHOLE file (a per-hunk stash is a conflict factory), verify the
|
||
playback diff only touches regions I did not edit (`git show 'stash@{0}' -- <file>` before popping),
|
||
and check the other unit is preserved after the pop. Non-overlapping hunks pop cleanly; if a future
|
||
stash pop conflicts, resolve with the OTHER work unit winning.
|
||
|
||
## 2026-10-08 — The zoom window formula: source = box/2 centered on the BOX CENTER
|
||
|
||
First cut of `ZoomFrameBuilder` sampled the box's LEFT HALF (`mx = zx + (ox+.5)/2`), a silent
|
||
half-frame mis-focus — the preview's VisualBrush Viewbox is `(X+W/4, Y+H/4, W/2, H/2)`, i.e. the
|
||
half-width window centered on the box center. The output sampler must mirror it exactly:
|
||
|
||
```
|
||
mx = zx + W/4 + (ox + 0.5)/2 // ox over [0,W)
|
||
```
|
||
|
||
A one-constant difference (`W/4` vs nothing) is invisible in a real scene but fatal to a banded
|
||
gradient test — which is exactly why the magnification proof exists: 4 vertical bands, a box whose
|
||
1x region straddles two of them, assert ALL output pixels are the center-band color. If you change
|
||
the preview window, change `ZoomFrameBuilder` in the same edit or the preview lies about the output.
|
||
|
||
## 2026-10-08 — The zoom box is NOT a loupe: anchored capture, all-layer content, drawn LAST
|
||
|
||
I shipped the whole first zoom layer (commit `4b3d82f`) with a "loupe" mental model — the box shows
|
||
what's *under it* in the output — and the creator corrected it in one message: the click fixes a
|
||
**capture region** and the box is a live, always-on 2x view of THAT region; moving the box changes
|
||
NOTHING about its content; it magnifies the fully-composited frame (every layer, including webcam),
|
||
and it must draw **on top of everything** (layer n+1), not slot at z-index 1. Three wrongs, all made
|
||
"elegantly": a BoxFollowing frame builder, a Backdrop+first-layer-only sample, and a z=1 layering.
|
||
|
||
The corrected test that would have caught all three in one shot (`ZoomMagnifierTests`): a banded
|
||
buffer (blue left / green right) with the box's left quarter OVER a blue region while the capture
|
||
sits wholly in green — assert **no box pixel is blue**. A loupe-style sampler fails it, a backdrop-
|
||
only sampler fails the "red webcam square appears magnified" assert, and `SceneCompositor` skips the
|
||
active zoom in every layer loop so nothing else can cover it. Lessons: (1) ask the *triggering
|
||
semantics* — anchored-capture vs follow-the-box — before building any "zoom" UI; (2) prove content
|
||
captures ALL layers with an extra layer in the fixture and assert it appears; (3) "draw above
|
||
everything" is a compositor ordering property (skip in the layer loops + blit last), not a z-index
|
||
of the element among the others.
|
||
|
||
## 2026-10-17 — Your own conversation history is in `session_message`, NOT `part`
|
||
|
||
I was asked to repeat the creator's original query verbatim. After a compaction I had only the summary,
|
||
so I searched the session store and landed on the wrong table twice. In `part` my search for the
|
||
creator's phrases returned hits — but they were **my own reasoning text quoting the phrase back**, which
|
||
looks exactly like a real hit and sent me chasing roles (`message.data` role), then
|
||
`instruction_entry`/`instruction_blob` (which do **not** hold prompts at all).
|
||
|
||
The creator's turns live in **`session_message`**, one row per turn: `type='user'`, the text inside the
|
||
JSON `data.text`.
|
||
|
||
```bash
|
||
# find the session by any distinctive phrase the creator used
|
||
sqlite3 ~/.local/share/opencode/opencode.db \
|
||
"SELECT DISTINCT session_id FROM session_message WHERE data LIKE '%<phrase>%';"
|
||
# then dump that session's turns in order
|
||
sqlite3 ~/.local/share/opencode/opencode.db \
|
||
"SELECT seq, json_extract(data,'\$.text') FROM session_message \
|
||
WHERE session_id='ses_…' AND type='user' ORDER BY seq;"
|
||
```
|
||
|
||
Lessons: (1) when compacted and unsure *what was asked*, query the store — never reconstruct the
|
||
creator's words from a summary; (2) a phrase hit in `part` may be my own echo of it, so check the
|
||
table's `type` before trusting a match; (3) this is the fix for "I can't search my own history" — it is
|
||
one query away, and the creator found their own query manually while I guessed.
|
||
|
||
## 2026-10-17 — I asked the creator to verify a preview visual I could have looked at
|
||
|
||
Planning the vv webcam feature, I asked: "when you select the vertical tier, does the preview actually
|
||
dim outside the band?" The answer was, in effect, *of course — that's the standard*. Two failures
|
||
stacked:
|
||
|
||
1. **I propagated an unverified claim.** My only reason to doubt it was a `HANDOFF.md` landmine
|
||
("vertical-format regression … no longer shrinks the capture border", marked STALE/root-cause-
|
||
unknown). It had never been verified, and it was **wrong** as stated. A stale, unverified handoff
|
||
line is a question-generator, not a fact — check it against the code or the app before letting it
|
||
shape what I ask.
|
||
2. **It was checkable without the creator.** The crop rectangle and the dimming are wired in code
|
||
(`PreviewPane.xaml:319-322` + `DimRects`/`ApplyStreamQuality`), and the creator can see the result in
|
||
one glance. I spent the creator's scarcest resource on something one look would have settled.
|
||
|
||
Created 2026-10-17 and standing (the creator stated these explicitly, after being left unable to tell
|
||
my reasoning from my replies):
|
||
|
||
- **Every reply begins with the reply marker on its own line.** The creator will not read an unmarked
|
||
reply — reasoning and reply are indistinguishable in their UI. Do not drop it; do not rely on a
|
||
leading blank line instead (the renderer trims it).
|
||
|
||
**The marker, verbatim — the single line, first line of the reply, nothing before it:**
|
||
|
||
```
|
||
**✂ ━━━━━━━ ── REPLY ── ━━━━━━━ ✂**
|
||
```
|
||
|
||
**How to recover it if it is ever lost again (it cost a whole session once, 2026-10-17):** assistant
|
||
turns live in `session_message` (`type='assistant'`), and BOTH the reasoning and the rendered reply
|
||
are concatenated into that row's `content[0].text` — so the marker sits *after* the reasoning prose.
|
||
Search the store for the scissors character, not for the word "marker":
|
||
|
||
```bash
|
||
sqlite3 ~/.local/share/opencode/opencode.db \
|
||
"SELECT session_id, seq, data FROM session_message WHERE data LIKE '%✂%' \
|
||
ORDER BY time_created DESC LIMIT 5;"
|
||
```
|
||
|
||
(The `part` table has the same reasoning+text concatenation for v1 sessions; `event` holds no text.)
|
||
- **One explanation and one question per reply.** No question batteries, no "three things I need"
|
||
lists, no blue-sky option menus (A-vs-B, clamp behaviour) — those read as derailing.
|
||
- **State the decision I would make, in one line, and let the creator veto** — rather than presenting a
|
||
menu of choices for them to sort out.
|
||
- **Ask the creator only what only they can decide** (product intent). Anything the code, the app, or a
|
||
single look can answer, I answer myself.
|
||
|
||
## 2026-10-17 — officerFriendly's clock: no RTC exists, and "the cron I installed" did not exist
|
||
|
||
Chasing the PS5's "DNS errors" I took the creator's premise at face value — *"the pi's battery has
|
||
failed, which is why you installed a cron task to correct drift"* — and went looking for that cron.
|
||
There is none, and there is no battery:
|
||
|
||
- **Raspberry Pi 400 Rev 1.0** (`cat /proc/device-tree/model`) — a Pi 4-class board with **no RTC
|
||
hardware at all**: `ls /dev/rtc*` → nothing, `hwclock -r` → "Cannot access the Hardware Clock",
|
||
no `dns-rtc`/`i2c-rtc` line in `/boot/firmware/config.txt`, no `rtc_*` module in `lsmod`. So "the
|
||
battery failed" cannot be the mechanism — there is nothing to hold time across a power cut except
|
||
`fake-hwclock`'s **file** (`/etc/fake-hwclock.data`, saved hourly by the stock
|
||
`/etc/cron.hourly/fake-hwclock`, which only ever *saves* — it never corrects).
|
||
- **No drift-correction cron**: root crontab empty, `/var/spool/cron/crontabs/` empty, the only
|
||
time-related job in any cron dir is that stock `fake-hwclock` save.
|
||
- What *was* installed (this is the thing to point at next time): `/etc/systemd/timesyncd.conf.d/lan.conf`
|
||
(created in the work window) with IP-literal NTP servers —
|
||
`NTP=162.159.200.1 162.159.200.123 216.239.35.0 216.239.35.4` (Cloudflare + Google) so NTP needs no
|
||
DNS — but **poll settings left at Debian defaults** (`PollIntervalMinSec=32`, `PollIntervalMaxSec=34min8s`).
|
||
`timedatectl show-timesync` prints the live server/interval; `systemd-timesyncd` logs only the *step*
|
||
("Contacted time server…" + "Initial clock synchronization"), so a long silent gap means the first
|
||
successful contact was that late, nothing more.
|
||
- **The oscillator was healthy, so do not confuse "drift" with "wrong base".** `adjtimex` frequency
|
||
correction was **−484296 raw = −7.39 ppm = −0.64 s/day**: a fine crystal. A clock that is *two days*
|
||
off got there by a bad base + a slow first sync, not by drift.
|
||
- **The signature that this was a clock problem, not a KSK/trust-anchor problem**: with a validating
|
||
resolver a wrong clock makes *good* signatures BOGUS, so `pihole-FTL.db` fills with `dnssec=3` rows
|
||
while the root still validates fine (`dig @127.0.0.1 . DNSKEY` → `flags: qr rd ra **ad**`). Both root
|
||
anchors (`20326` KSK-2017 **and** `38696` KSK-2024) were already present in
|
||
`/etc/pihole/dnsmasq.conf` and `/usr/share/dns/root.key` months before — check the clock *before*
|
||
blaming a root KSK rollover.
|
||
- Remote-survey hygiene: pipe `pf.py --script` output to a file (`> /tmp/opencode/out.txt`) and read the
|
||
file — piping to `head` silently cut a survey in half mid-section and hid the very answer being sought.
|
||
|
||
## 2026-10-21 — I broke a working HA light system, then burned the session, and the one-line cause was an entity id
|
||
|
||
**What happened.** The Home Assistant light alert system (leak / alarm / pump / doorbell on five Tapo
|
||
bulbs, for a deaf user who needs lights, not sounds) was **verified working** — leak gave warm-white
|
||
wake bulbs + purple, alarm gave red, clear restored pump status. The creator then asked for **one**
|
||
change: the water pump should light the Death Star only. Instead of making that one change, I rewrote
|
||
the render script twice (nested `if/then/else`, then `choose:`), broke the alert path, and spent the
|
||
rest of the session on it. The day's planned work (llamacasty / the vv 9:16 webcam Unit 1) did not
|
||
happen. The creator's read — *"you took something that was working just fine and fucked it up"* — was
|
||
exactly correct.
|
||
|
||
**The actual root cause, found only after three failed remedies.** The script's YAML key is
|
||
`alerts_render`, but its **entity id was `script.alerts_render_lights`** (a leftover `unique_id`). Every
|
||
caller — `script.alerts_clear` and both render automations — called `script.alerts_render`, which **does
|
||
not exist**. `script.turn_on` on a non-existent entity returns **HTTP 200 and silently does nothing**.
|
||
So clear never re-rendered and alerts froze on the last colour painted. One `grep` of the real entity
|
||
list would have found it; I instead spent the session theorising about templates, races and `choose:`
|
||
semantics.
|
||
|
||
**Rules this produces.**
|
||
- **Do the one change asked for.** When a system is verified working, a requested tweak is a *tweak*.
|
||
Rewriting the surrounding structure is how working things become broken things.
|
||
- **NEVER trust `script.turn_on` returning 200.** It is a silent no-op on a bad entity id — no error, no
|
||
log line. Before blaming logic, confirm the entity exists: compare the YAML key against the real
|
||
entity id (`/api/states`, or the entity registry `unique_id`). A YAML key and the derived entity id
|
||
can differ whenever a `unique_id` predates a rename.
|
||
- **A second failed remedy for the same symptom = STOP and verify the plumbing, not the logic.** The
|
||
diff between "logic wrong" and "call going nowhere" is one status/entity check, and I skipped it
|
||
three times.
|
||
- **Test reads need the right window.** The doorbell's green lasted 8s; I polled at 9s, saw dark, and
|
||
called it broken. Read the state *inside* the window the code defines.
|
||
|
||
## HA TAPO TP-LINK: A HUB ENTRY POISONED BY DISCOVERY IS FIXED BY RE-ADDING WITH A PINNED HOST (RECIPE)
|
||
|
||
**Symptom:** a Tapo hub (H500) shows its entities as unavailable or `-100001`, and the config entry's
|
||
`host` has been silently rewritten to the IP of a **camera** on the same LAN. `.storage/core.config_entries`
|
||
shows the hub entry pointing at the camera's address while `alias`/`model` also got mangled.
|
||
|
||
**Cause:** HA's `tplink` discovery rewrites the entry host to whatever answers on the LAN, so a camera
|
||
that shares the hub's MAC (Tapo uses one MAC across its wifi/ethernet interfaces — `ip neigh` showing the
|
||
hub MAC against several IPs is a **red herring**) wins. Upstream: core#178994 (H200 host flips to camera
|
||
IPs), "closed" only by a python-kasa bump that does not fix it.
|
||
|
||
**Recipe (works — verified):**
|
||
1. Note the hub's real IP (the one with the hub's TLS cert, no RTSP open) and its MAC.
|
||
2. Delete the poisoned entry: `POST /api/config/config_entries/entry/{entry_id}/remove`.
|
||
3. Re-add through the config flow **specifying the host explicitly** so discovery never probes the camera:
|
||
- `POST /api/config/config_entries/flow {"handler":"tplink","show_advanced_options":true}`
|
||
- `user` step: `{"host":"<hub-ip>"}`
|
||
- `user_auth_confirm` step: `{"username":…,"password":…}` (Tapo account; `-40404` + `sec_left` =
|
||
account lockout, wait it out)
|
||
- returns `create_entry` with a **new** entry id and `host` pinned to the literal IP.
|
||
4. Verify the new entry is `loaded` and the alarm sensors (Tapo base bores into HA via the hub) come back.
|
||
|
||
**Two traps:** (a) re-POSTing a removed alarm sensor with `enabled:false` **re-creates** it — remove once,
|
||
don't touch; (b) if the host re-poisons after a restart, temporarily disable the offending camera entry to
|
||
starve discovery.
|
||
|
||
## HA: RECALL MEASUREMENTS THAT RULE OUT FLASHING TAPO BULBS (RECIPE)
|
||
|
||
Tapo bulbs cannot be flashed usefully. Measured on `light.officeone` (2026-10-20): API round-trip ~130ms,
|
||
**colour change ~500ms**, **brightness change ~5s** (255→25 consistently). So:
|
||
- **Only steady states.** A "flash" is a colour change with extra steps; stacking commands mid-ramp is
|
||
what leaves a bulb stuck on an old colour (the creator's green-at-100% bulbs came from exactly that).
|
||
- If a flash is ever truly needed, colour-only, ≥1000ms per state. Never pulse brightness.
|
||
- HA drops `rgb_color`/`brightness` from a light's state when it is off (core#2316, closed as
|
||
"not a bug"), so an event must end by **explicitly turning off** bulbs that were off — otherwise the
|
||
bulb keeps its internal colour memory and comes back the wrong colour.
|
||
|
||
## 2026-10-21 — GOG Galaxy says "offline": a Fastly "unknown domain" 500 is GOG's outage, not your LAN
|
||
|
||
**Symptom:** the GOG Galaxy launcher (2.0) shows the offline badge. The instinct is "my network" — on this
|
||
LAN (Pi-hole, dnscrypt, an ASUS router, a Pi with a 43 h-wrong clock two weeks ago) that instinct is
|
||
usually wrong, and chasing it burns a session. Read the launcher's own log FIRST.
|
||
|
||
**Where Galaxy 2.0 logs live (the hunt):** `C:\ProgramData\GOG.com\Galaxy\logs\` — **NOT**
|
||
`%LOCALAPPDATA%\GOG.com\Galaxy\logs` (that folder exists but has no `logs` in it; the layout/config
|
||
lives in `%LOCALAPPDATA%\GOG.com\Galaxy\Configuration\config.json`).
|
||
- `CommunicationService.log` — auth/API side.
|
||
- `GalaxyClient.log` — the Electron/Qt UI side, which is where the **offline decision** is made.
|
||
|
||
**The launcher decides "offline" here** (`GalaxyClient.log`):
|
||
`Failed to check API status. Assuming it's offline.` → `Authentication failed but API is offline. Assuming
|
||
persisted refresh token is valid.` → `nativeReceiver received an unknown command: ShowOfflinePage`.
|
||
So "offline" = *the API status check failed*, not "the socket is down".
|
||
|
||
**Recipe (works — verified 2026-10-21):**
|
||
1. `grep -iE "offline|Failed to sign in|responseCode=500" "C:\ProgramData\GOG.com\Galaxy\logs\GalaxyClient.log" CommunicationService.log`
|
||
2. Curl GOG's hosts from Windows and **read the body, not the status code**:
|
||
`curl.exe -sS -i https://www.gog.com/` and `https://auth.gog.com/auth`.
|
||
3. **Fastly answering `500` with `Fastly error: unknown domain: <host>` is GOG's outage, not your DNS.**
|
||
Their `gog.com` services are simply not configured on the edge right now. The cert still verifies
|
||
(`ssl_verify_result=0`), so it is NOT a MITM and NOT a hijack; the DNS (real Fastly 151.101.x.241) and
|
||
TLS are both fine.
|
||
4. Prove global-vs-LAN by hitting the same URL from a **second egress that is provably not yours** —
|
||
check its IP first (see the correction below: `webfetch` is NOT one). Same 503 ⇒ GOG-side, stop
|
||
looking at your network.
|
||
5. `status.gog.com` was **NXDOMAIN globally** (Google DoH, `Status:3`, authority = gog.com's Cloudflare
|
||
NS) during the outage — no status page to consult, so the maintenance page on `www.gog.com` is the
|
||
authoritative signal. It states it outright: *"GOG.COM is temporarily down for maintenance work…
|
||
Galaxy Launcher will also be down."*
|
||
|
||
**What it actually looked like:** `www.gog.com` → 503 (GOG's own maintenance page), `auth.gog.com` /
|
||
`api.gog.com` / `remote-config.gog.com` → 500 "unknown domain", `notifications-pusher.gog.com` → the
|
||
websocket refused to connect. Nothing to fix locally; installed GOG games still launch (DRM-free).
|
||
|
||
### ⛔ 2026-10-21 — CORRECTION: the "second egress" was NOT a second egress, and the timeline was wrong
|
||
|
||
The creator pushed back: *"I think windows networking fucked something up. No way GOG would be down for
|
||
days."* **The second half was right and my evidence was wrong.** Two corrections, both load-bearing:
|
||
|
||
- **`webfetch` is NOT an independent egress — it leaves through THIS machine's own connection.**
|
||
Verified: `webfetch https://ipinfo.io/json` and `curl.exe https://ipinfo.io/json` from Windows returned
|
||
the **same public IP (201.170.13.206, AS8151 Uninet/Telnor, Tijuana)**. So "the same 503 from another
|
||
network" was the same network twice — it proved nothing. **Before using any fetcher as an independent
|
||
vantage point, read its egress IP first** (`ipinfo.io/json` or `api.ipify.org`) and compare it with the
|
||
local machine's. Off-LAN fetchers that actually work: **`api.allorigins.win/raw?url=<encoded>`** (live
|
||
fetch, cloud egress) and the **Wayback Machine** (`web.archive.org/cdx/search/cdx?url=…` for snapshots,
|
||
`web.archive.org/web/<ts>id_/<url>` for the raw archived body). `r.jina.ai` answers **422** and
|
||
`api.codetabs.com/v1/proxy` answers **522** for these targets — they fail open and look like outages.
|
||
- **The timeline was wrong: it broke OVERNIGHT, not "for days".** The Wayback snapshot of
|
||
`https://www.gog.com/en/` at **2026-10-21 06:56 UTC** is **HTTP 200 with 295 KB of live store content**
|
||
(product listings, "Cyberpunk: Edgerunners 2.5 Update coming Oct 22"). 06:56 UTC = 23:56 local Oct 20.
|
||
By 08:11 local today Galaxy's first API check failed and the site served the maintenance page. So the
|
||
window is **00:00–08:11 local on 2026-10-21**, and the creator was right to refuse a "days" story.
|
||
|
||
**The proof stack that actually holds (all local checks, no assumptions):**
|
||
- **No TLS interception:** the cert presented for `www/api/auth.gog.com` is `CN=gog.com, O=GOG sp. z o.o,
|
||
L=WARSZAWA` issued by `DigiCert Global G2 TLS RSA SHA256 2020 CA1` — read identically from WSL
|
||
(`openssl s_client`) and from Windows (`SslStream`). A local middlebox would need GOG's private key.
|
||
- **No DNS poisoning:** local answers (Pi-hole `.26` + `1.1.1.1`) are byte-identical to Google's
|
||
authoritative answers for all three hosts. Pi-hole is clean; the router is clean.
|
||
- **Real Fastly edge:** `Server: Varnish`, `Via: 1.1 varnish`, `X-Served-By: cache-cyw-mmcy1560056-CYW`.
|
||
A local filter cannot forge Fastly's POP headers.
|
||
- **GOG's own (non-Fastly) servers still answer:** `notifications-pusher.gog.com` (91.222.185.x) completes
|
||
TCP+TLS and returns HTTP 400 on a plain GET — only the **Fastly-fronted** hostnames are gone. That is
|
||
GOG's edge config, not your network.
|
||
- **Off-LAN confirmation:** allorigins (cloud egress) returns the identical maintenance page.
|
||
- **Galaxy's own history:** across all rotated logs, `Failed to check API status` appears **only** on
|
||
2026-10-07 (a blip) and 2026-10-21; the `responseCode=500` / `Failed to retrieve remote configuration`
|
||
/ `Failed to sign in` lines exist **only in today's log**. The `OnConnectionFailed` noise from
|
||
`notifications-pusher` also fired on Oct 9/10/20 — that websocket has been failing for days and is
|
||
**not** the offline cause.
|
||
|
||
**Rule this produces:** a clean TLS handshake with a *real* vendor cert plus vendor edge headers plus an
|
||
authoritative-DNS match is a strong local-exoneration chain. Build it before arguing about whose fault
|
||
it is, and never let a same-egress fetch masquerade as a second opinion.
|
||
|
||
**Separate, unrelated noise in the same logs:** `Waiting for process initialization failed … Return code:
|
||
4294967295` for `GalaxyUpdater.exe`, repeating hourly — the **updater**, not the connection. Do not let it
|
||
steer the offline diagnosis.
|
||
|
||
**Also false-lead here:** `w32tm /query /status` said "The service has not been started. (0x80070426)" —
|
||
the Windows Time *service* is off, but the clock was right and TLS verified, so it was not the cause. The
|
||
clock is the first thing to suspect on *TLS* failures (the officerFriendly PS5 case), never on a clean
|
||
handshake with a valid cert.
|
||
|
||
## HA: THE ALERT SYSTEM'S ARCHITECTURE (RECIPE — the shape that works, do not "improve" it)
|
||
|
||
Producer/consumer split, and **one single writer** of the bulbs:
|
||
- `script.alerts_arm` / `alerts_clear` only write `input_text.light_alert` (`none|leak|alarm|motion`),
|
||
which survives a restart.
|
||
- `script.alerts_render_lights` is the **only** thing that touches the bulbs. Priority: an alert always
|
||
beats pump status. Flat top-level `if`/`else` blocks — a nested `if` or a `choose:` both silently lit
|
||
**nothing** in this configuration, so the flat shape is deliberate.
|
||
- Pump is **status**, not an alert: auto-clearing, Death Star only (creator directive), keyed off
|
||
`sensor.water_pump_current_consumption`, **never** off `switch.water_pump_2` — that switch was observed
|
||
latched `on` for hours at 0.0A draw, and using its state painted the bedroom blue at 5am.
|
||
- Alerts **hold** until a human clears them; a sensor returning to normal notifies but does not clear.
|
||
- Colours: leak purple, alarm red, motion orange (reserved), pump blue, doorbell green (doorbell is not
|
||
an alert — it guards on `light_alert != 'none'` and hands back to the render).
|
||
|
||
## 2026-10-21 — TWO LAYOUTS FOR ONE ELEMENT ACROSS OUTPUT TIERS, WITHOUT TOUCHING THE RENDER PATH (RECIPE — TASK 57)
|
||
|
||
The vertical (9:16) tier is a centred **band crop** of the same 1920×1080 master, so the 16:9
|
||
placement of an element lands outside the cropped window and the element vanishes from the
|
||
stream. The render path (preview + compositor) reads the element's own `X`/`Y`/`Width`/`Height` +
|
||
`ClipShape` and knows nothing about tiers — and that is the point: **do not teach the render
|
||
path about tiers.**
|
||
|
||
**The recipe (four moves):**
|
||
|
||
1. **Park the inactive layout in the model.** The element keeps a transient copy of the layout
|
||
that is not live (`LandscapeX/Y/Width/Height/ClipShape/RectWidth/RectHeight`) next to the
|
||
persisted copy of the other one (`VerticalX/…`, NULL = "not set for this scene").
|
||
2. **Swap on the tier change.** `BeginVerticalLayout(...)` parks the live layout and brings the
|
||
other in; `EndVerticalLayout()` commits the outgoing layout back into its stored slot and
|
||
restores the parked one. The live geometry IS the render input → the compositor and the
|
||
preview XAML stay untouched.
|
||
3. **Keep the persisted columns for ONE layout.** The old columns (X/Y/W/H/ClipShape) keep
|
||
meaning "the 16:9 layout"; the save writes the parked values there while the other tier is
|
||
live. The new columns carry the other layout. A save can then never lose either.
|
||
4. **Commit the live copy before you persist it.** The stored copy lags the live one until the
|
||
save — so the tier switch itself commits (otherwise switching away and back loses the
|
||
creator's placement), and the save commits again (the DB write must not race the copy).
|
||
|
||
**The clamp belongs in ONE place: the model's own setters.** Make the base geometry setters
|
||
`virtual` and override them in the derived element, so *every* writer — drag, resize handle,
|
||
typed properties fields, code — is constrained by the same rule. Three writers each re-implementing
|
||
"inside the band" is three chances to forget one. (This is why `SceneElement.X/Y/Width/Height`
|
||
became virtual.)
|
||
|
||
**Why not the alternative:** a computed "effective geometry" property (`RenderX`) would have
|
||
touched the compositor's element read *and* every preview binding — a render-path change for a
|
||
geometry-only problem. The swap keeps the render path a pure consumer of the live geometry.
|
||
|
||
**Test seam:** the drag logic must live in a method the VM exposes, not in the control's mouse
|
||
handler — the test then drives the placement without synthesising mouse input.
|
||
|
||
## 2026-10-21 — A XAML TEST THAT FINDS A PANEL BY BINDING TEXT BREAKS WHEN A SECOND PANEL SHARES THAT BINDING (TASK 57)
|
||
|
||
`WebcamBackgroundPillTests` locates the webcam's properties panel by **searching the XAML text
|
||
for `SelectedElement.IsWebcam`** and then asserts on it. Adding a second panel with the same
|
||
visibility binding made the selector match twice → `Sequence contains more than one matching
|
||
element` → a test failure that had nothing to do with the feature under construction.
|
||
|
||
**Rule:** a properties panel that a test finds by binding text must have a **unique** binding.
|
||
Use the VM's semantic flag (`IsWebcamSelected`) for a new panel instead of repeating
|
||
`SelectedElement.IsWebcam`, and when a test selects UI by binding text, expect to have to keep
|
||
that text unique. Cheap fix, but only if you know the test selects that way — read the test
|
||
before you add UI.
|
||
|
||
## 2026-10-21 — THE LAYOUT STORE WRITES THE WEBCAM ROWS ONLY WHEN A WEBCAM IDENTITY EXISTS (TASK 57)
|
||
|
||
`LayoutStore.Save(scenes, webcam, …)` writes the `Webcam` row **and** the per-scene
|
||
`WebcamSceneConfig` rows only when `webcam != null` — the config rows hang off the identity.
|
||
A test that adds a `WebcamSceneConfig` to a scene and saves **without** an identity persists
|
||
nothing, and the reload comes back with no camera (a confusing "my placement vanished"
|
||
failure). Supply a `Webcam` identity in the test (`new Webcam { Id = "webcam-1", DeviceId = … }`)
|
||
or the round-trip assertion is meaningless.
|
||
|
||
## 2026-10-21 — PUSHING TO THE GITEA INSTANCE: ASK THE API FIRST, PUSH OVER HTTPS (RECIPE)
|
||
|
||
The creator approved a push. The remote was `ssh://gitea@llgit.llamachile.tube:2214/gramps/ytLlive.git`
|
||
and the push failed with `Permission denied (keyboard-interactive)`. **I burned a session on SSH key
|
||
archaeology before checking the one thing that answered every question: the REST API.**
|
||
|
||
**What actually happened, in order:**
|
||
1. `git push origin main` → `Permission denied (keyboard-interactive)`; no `ssh-askpass` binary in WSL.
|
||
2. Key archaeology: two local keys, neither fingerprint matched the account's registered keys;
|
||
`llgit:2214` later refused connections outright. All dead ends.
|
||
3. **The fix, in one shot:** the account password in `CREDENTIALS.md` is also the **Gitea** account
|
||
password. `curl` with basic auth against `https://llgit.llamachile.tube/api/v1/user` returned
|
||
HTTP 200 (the `gramps` account, `is_admin: true`), and a push over **HTTPS** with
|
||
`GIT_ASKPASS` worked immediately: `931ab46..6be062c main -> main`.
|
||
4. The API also revealed **the repo was renamed** `gramps/ytLlive` → `gramps/LlamaCasty` (the old
|
||
path 301-redirects) — the local `origin` still pointed at the old name.
|
||
|
||
**The recipe (do this in this order next time):**
|
||
1. `curl --config <cfg> https://llgit.llamachile.tube/api/v1/user` with the portal password →
|
||
confirms the account and that the API is reachable. **Do this before touching SSH.**
|
||
2. `GET /api/v1/repos/<owner>/<repo>` → the repo's REAL name and its `ssh_url`/`clone_url`.
|
||
3. `GET /api/v1/user/keys` → which public keys the account actually has (compare fingerprints with
|
||
`ssh-keygen -lf` locally — the titles are the hint: id 6 "Corsair VSC" was this machine's
|
||
`~/.ssh/id_ed25519`, and the server still refused it, so the SSH path was never the answer).
|
||
4. Push over **HTTPS**: `origin = https://llgit.llamachile.tube/gramps/LlamaCasty.git` with a
|
||
credential helper that reads the password from the gitignored `CREDENTIALS.md` on demand
|
||
(`/home/gramps/bin/gitea-credential`, wired as
|
||
`credential.https://llgit.llamachile.tube.helper`). Plain `git push` then just works, with no
|
||
prompt, no plaintext credential on disk, and no secret in any command line.
|
||
|
||
**The lesson:** when a push fails, the transport is a hypothesis, not a fact — and the **API is a
|
||
cheaper probe than the transport**. Ten minutes of `ssh -v` archaeology on a key that the server had
|
||
already recorded (and refused) was pure waste; one authenticated `curl` answered "the repo moved,
|
||
the password works, use HTTPS".
|
||
|
||
**Also:** `ssh_askpass` is not installed in this WSL — `SSH_ASKPASS`/`GIT_ASKPASS` helpers must be
|
||
scripts you write yourself (`chmod 700`), and they should read the secret from `CREDENTIALS.md`
|
||
rather than embed it.
|
||
|
||
## 2026-10-21 — A RULE THAT APPLIES TO SOME ELEMENTS ONLY: LOCK THE MODEL PROPERTY, NOT THE UI BUTTON (RECIPE — TASK 57)
|
||
|
||
The creator's table says the **Chat** scene's vertical camera is **rect only — the circle option is
|
||
not offered**, while the Live scene's camera can be a circle. The obvious implementation hides the
|
||
Shape button for that camera. That implementation is wrong: the button is only ONE writer. The
|
||
model, the placement swap and the load path are the others, and a rule that lives only in the UI is
|
||
a rule that will be broken by the next writer.
|
||
|
||
**The recipe — lock the property, and let the UI follow:**
|
||
1. Make the property **`virtual`** in the base class (`SceneElement.ClipShape`) and **override** it
|
||
in the derived class (`WebcamSceneConfig`): the setter coerces the value when the lock is on.
|
||
Make the *action* virtual too (`ToggleClipShape()`) so the toggle can no-op **without** touching
|
||
the round-trip fields (`RectWidth/RectHeight`) — a half-applied toggle is worse than none.
|
||
2. The lock flag lives on the model (`VerticalShapeLocked`), is set **per scene** by the view model
|
||
(`ResolveVerticalCameraLayout`) and is **cleared BEFORE the other layout is restored** — clear it
|
||
after and the restore is silently coerced (the desktop circle comes back as a rectangle).
|
||
3. The UI gate is then a *read-out* of the model rule, not the rule itself: a view-model property
|
||
(`CanToggleClipShape`) that the button binds its `Visibility` to. "Not offered" = the button is
|
||
**gone**, not merely greyed out.
|
||
4. Test the model path as well as the UI path: call the toggle directly and assert the shape did not
|
||
change — the UI's absence is not the rule.
|
||
|
||
**The general shape:** a per-element variant of a layout rule → one `virtual` property override +
|
||
one lock flag, with the UI's visibility derived from it.
|
||
|
||
## 2026-10-21 — "SIZE A OR SIZE B": GIVE THE RESIZE HANDLE TWO DETENTS, NOT A FREE SIZE (RECIPE — TASK 57)
|
||
|
||
The creator's table says the Chat camera in the vertical tier is either the small tile (270×152) or
|
||
**enlarged to fill one whole 607×540 area**. The obvious implementation lets the resize handle take
|
||
any width in between — and then the camera is neither of the two things the creator asked for, and
|
||
the "enlarged" state has to be *guessed* from the width.
|
||
|
||
**The recipe:**
|
||
- Put the allowed sizes in one pure function next to the other band math
|
||
(`VerticalCameraLayout.SizeFor(band, requestedWidth)`) and return **the size nearest the
|
||
requested one** — the handle then has exactly two detents and no in-between.
|
||
- The "enlarged" state is **derived**, not stored: `FillsArea(band, width)` (the camera is wide
|
||
enough to fill an area). No new DB column, no state to keep in sync.
|
||
- The position of an enlarged camera is **not free**: it fills the area its **centre** is in (top or
|
||
bottom) — `AreaFor(band, isBottom)`. Drag it up/down and it swaps areas; that IS the interaction.
|
||
- Watch the order of the writes: set the **size first**, then the position, because a position clamp
|
||
that knows the size (the band's edge minus the width) will otherwise fight the new size.
|
||
|
||
## 2026-10-21 — THE EDIT TOOL CANNOT MATCH AN oldString CONTAINING AN EM DASH (RECIPE)
|
||
|
||
Three `edit` calls in a row failed with "Could not find oldString" on text I had just read in the
|
||
file. The common factor: the matched block contained the **em dash** `—` (and in one case the
|
||
`×` multiplication sign) — the same non-ASCII typography the repo's comments are full of. The tool
|
||
documents a retry that ignores "typographic quote, dash, and space variants", but that retry did not
|
||
save the match.
|
||
|
||
**The workaround that works:** keep the `oldString` **ASCII-only**. Match the smallest unique slice
|
||
that does not contain a dash (e.g. the `if (...) return false;` line instead of the comment above
|
||
it), and put the new prose in the `newString` where any character is fine. When a whole stale block
|
||
must go (e.g. half-overwritten comments), a three-line `python3` script that deletes the exact line
|
||
range is faster and safer than fighting the matcher.
|
||
|
||
## 2026-10-21 — A WPF CONTROL WITH NO STYLE DRAWS THE LIGHT SYSTEM CHROME (RECIPE — TASK 58)
|
||
|
||
The creator asked why the Stream Alerts "Display" drop-down looked like it belonged to another
|
||
app. The cause is not a colour: **setting `Background`/`Foreground`/`FontSize` on a WPF control
|
||
changes its paint, not its TEMPLATE.** An unstyled `ComboBox` still renders the system template —
|
||
grey border, system arrow, **white popup list** — so a dark blue box sat on the dark panel
|
||
wearing light-system chrome. Unstyled `CheckBox` is the same story.
|
||
|
||
**The recipe (audit + fix + guard):**
|
||
1. **Audit by grep, not by eye:** `grep -rn "<ComboBox\|<CheckBox" --include="*.xaml"` and look at
|
||
each one's `Style` attribute. Hand-painted colours are the smell — they mean "no style".
|
||
2. **Fix by style, never by more painting:** put the look in `Themes/Controls.xaml` once
|
||
(`YtComboBox`, `YtCheckBox`, a `BasedOn` compact variant when the panel is dense) and delete
|
||
the per-control colours. A dense panel needs a *variant* of the style, not a re-painted control.
|
||
3. **Guard it with a test** that parses the XAML and fails when a control of that type has no
|
||
style — otherwise the next hand-painted control walks the look back.
|
||
4. **Check the inherited defaults while you are there:** a compact `BasedOn` variant must set the
|
||
metrics it overrides (`FontSize`, `Padding`, `MinHeight`) or the panel silently changes density.
|
||
|
||
**Side effect worth naming:** the sweep also exposed a typo'd foreground (`#e0d0e0` instead of
|
||
`#e0e0e0`) that only a single source of truth can prevent from recurring.
|
||
|
||
## 2026-10-21 — A CHOICE IN WPF: MUTUAL EXCLUSION BELONGS IN THE MODEL, NOT IN A RADIOBUTTON GROUP (RECIPE — TASK 59)
|
||
|
||
A "pick one of these" control (Rect | Circle) bound in WPF has two classic traps, both
|
||
of which break the panel silently — the control keeps showing what it last showed while
|
||
the model says something else.
|
||
|
||
**Trap 1 — the RadioButton group kills the binding.** A `RadioButton` group unchecks its
|
||
siblings by setting `IsChecked` **in code** (`RadioButton.UncheckRadioButton` →
|
||
`SetCurrentValueInternal`). WPF sees the target property being set outside the binding
|
||
and **drops the binding** — the options stop reflecting the model for good
|
||
("Losing the binding for radio button after I set the property manually in code",
|
||
stackoverflow 7376373; the same trap in a template, 57275046).
|
||
|
||
**Trap 2 — a ToggleButton unchecks itself when clicked while checked.** A plain
|
||
`ToggleButton` (unlike `RadioButton`) toggles `IsChecked` off when you click it while it
|
||
is on. With a TwoWay binding the model receives `false` — and if the model refuses that
|
||
write, the UI must be told again, or the option stays visibly unchecked while the model
|
||
holds the truth.
|
||
|
||
**The recipe:**
|
||
1. **Give the model one bool per option** (`IsRectShape`, `IsRoundShape`) — each
|
||
settable, each doing the real work through the existing `Toggle…()` path so the
|
||
side-effects (store/restore the pre-round rect) are not duplicated.
|
||
2. **The setter ALWAYS re-raises** its own property name (`Raise(nameof(IsRectShape))`),
|
||
even when the write changed nothing. The binding re-reads the getter and the UI snaps
|
||
back to the model's truth — that is what makes a refused write (a locked shape)
|
||
visible instead of sticky.
|
||
3. **Raise BOTH option properties whenever the underlying value changes** (in the
|
||
`ClipShape` setter), so the pair can never disagree on screen.
|
||
4. **Do NOT use a `RadioButton` `GroupName`** for a bound pair. Two plain
|
||
`ToggleButton`s (styled as segments) with model-side mutual exclusion keep their
|
||
bindings alive (stackoverflow 50980687 measures the same behaviour with
|
||
`IsChecked` bound to separate bools).
|
||
5. **Show the CURRENT state, not the next action.** A button labelled "Round" when the
|
||
camera is a rectangle tells the user what will happen; a selected "Rect" option tells
|
||
them what IS. The creator called this out: *"Rect should be selected as a default if
|
||
none exists."*
|
||
|
||
**Bonus:** the same "title owns its content, content is indented one level" rule applies
|
||
to any dense settings panel — one indent level per ownership level, two when a title is
|
||
itself nested (Strength under Blur under Background).
|
||
|
||
## 2026-10-21 — A LIST THE CREATOR MAY WANT TO EXTEND BELONGS IN A TESTABLE CATALOGUE (RECIPE — TASK 60)
|
||
|
||
The creator asked for one thing ("add a colour name next to the hex") and the code could not
|
||
even express it: the drop-down's colours were a `string[]` **inside the click handler**. A
|
||
list of bare strings cannot carry a name, and a list hidden in UI code cannot be asserted on
|
||
without reading the UI code.
|
||
|
||
**The recipe (catalogue → UI consumer):**
|
||
1. **Move the list out of the handler into a plain data type** — `NamedColor(Hex, Name)` +
|
||
`static IReadOnlyList<NamedColor> All` in `Models/`. Models = "plain data types, no logic
|
||
beyond what a property can carry" (see `Models/index.md`).
|
||
2. **Give it a lookup** (`NameOf(hex)`, case-insensitive) that answers for a value the user
|
||
typed, not only for the palette's own entries. **What it answers for a colour OUTSIDE the
|
||
palette is a judgement call, and it changed once (TASK 62):** it used to fall back to the
|
||
value itself, so the UI never showed an empty name — but "no name" is the honest answer, and
|
||
the caller decides what to show (the border field shows the hex alone for a custom hex). The
|
||
fallback that lies is worse than the empty string.
|
||
3. **The UI becomes a consumer**: the handler iterates the catalogue and builds the rows. The
|
||
handler must not know any colour by heart.
|
||
4. **Guard it in the test**: every entry valid + named, no hex or name repeated, the colours
|
||
that used to be offered are still offered (a name is added, never a colour dropped), the
|
||
lookup answers, and **the old list is gone from the handler** (a source-level assertion:
|
||
`Assert.False(source.Contains("\"#FFFFFF\""))`).
|
||
5. **Name the colours so they do not collide** — the palette had two greens, two blues and two
|
||
warm colours, so `#00FF00` is **Lime** and `#22c55e` **Emerald**, `#0000FF` **Blue** and
|
||
`#3b82f6` **Azure**, `#FF8800` **Orange** and `#f59e0b` **Amber**. A name that repeats
|
||
another's meaning is worse than no name.
|
||
|
||
**Why this matters beyond names:** the next request ("add a dark mode", "let me type a
|
||
custom colour", "reorder them") is now a change in ONE file instead of a change inside a
|
||
click handler.
|
||
|
||
## 2026-10-21 — A ROW OF SAME-KIND FIELDS NEEDS EXPLICIT WIDTHS (RECIPE — TASK 61)
|
||
|
||
The creator: *"the Y and H boxes should be the same width as the X and W boxes and those
|
||
widths should be fixed to accomodate the max number of possible digits."* The row was a
|
||
`DockPanel`, and WPF gave the four fields **two different widths** for no reason anyone chose.
|
||
|
||
**The trap (worth remembering on its own):**
|
||
- A WPF element **sizes itself to its content** unless it is told otherwise. So the X/W fields
|
||
(not the last child) were as wide as their text ("1920" ≈ 34px).
|
||
- In a `DockPanel` the **LAST child fills the remaining space** — so the Y/H fields stretched to
|
||
the full width of the row. Two widths for the same kind of field, purely by position.
|
||
|
||
**The recipe:**
|
||
1. Give **every** field in the row an explicit `Width` (one value, repeated — the point is that
|
||
they are EQUAL).
|
||
2. Set `LastChildFill="False"` on the row, or the last field ignores its own `Width` and fills
|
||
anyway.
|
||
3. **Measure the width, don't guess it.** The needed width = the widest value the field can
|
||
hold, measured in the app's own font at the field's size, plus the field's padding and
|
||
border. WPF measures it for you, headlessly, with no window:
|
||
|
||
```csharp
|
||
var typeface = new Typeface(SystemFonts.MessageFontFamily, FontStyles.Normal,
|
||
FontWeights.Normal, FontStretches.Normal);
|
||
var text = new FormattedText("-1920", CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
|
||
typeface, 11, Brushes.Black, pixelsPerDip: 1.0).Width;
|
||
var needed = text + 8 /* padding */ + 2 /* border */;
|
||
```
|
||
|
||
`SystemFonts.MessageFontFamily` is exactly what a control uses when no font is set, so the
|
||
measurement matches what is drawn. (The geometry fields came out at 44px — 28 of text, 10 of
|
||
chrome, 6 of slack.)
|
||
4. **Assert the measurement in the test**, not a magic number: parse the XAML, take the widths,
|
||
and fail if the row's width cannot hold the widest value. A hard-coded "44" in the test
|
||
would pass even if the app's font changed and 44 stopped fitting.
|
||
5. **A label beside a field needs the field to be FIXED too.** The same trap in a different
|
||
costume: a field that fills its row pushes whatever sits after it to the far edge, so the
|
||
label drifts away from the value it labels (TASK 62 — the colour name beside the hex field).
|
||
|
||
## 2026-10-21 — A STYLE SWEEP THAT LOOKS AT CONTROL TYPES CANNOT SEE A HAND-ROLLED PICKER (RECIPE — TASK 63)
|
||
|
||
The creator: *"one of the first things we did was define branding. We established look-and-feel
|
||
standards. And yet you keep ignoring these and I'm wondering why"* — about the webcam's
|
||
border-colour drop-down.
|
||
|
||
**The trap:** the TASK 58 sweep had already walked the whole app for un-themed controls, and the
|
||
border picker STILL was not themed. The reason is a one-line blind spot in the sweep itself:
|
||
it asked *"does this `ComboBox`/`CheckBox`/`TextBox` carry a theme style?"* — and the border
|
||
picker was **none of those types**. It was a `Button` whose `Click` handler built a
|
||
`ContextMenu` of swatches in code. The sweep's guard (`PropertiesStyleTests.cs`) asserts control
|
||
**types**, so it could not see it either. A picker that LOOKS like a drop-down is invisible to a
|
||
type-based audit.
|
||
|
||
**The recipe:**
|
||
1. When auditing for the design system, search for **what a control DOES**, not what it IS:
|
||
grep for `Click=` handlers that open a `ContextMenu`/`Menu`/`Popup`, and for `new
|
||
ContextMenu`/`new MenuItem` in code-behind. A menu built in code is a control that never
|
||
wears the theme.
|
||
2. The guard must assert the **design system**, not the control type: for every control that
|
||
presents a CHOICE to the creator, assert it is the app's widget (`Style="{StaticResource
|
||
YtComboBox}"` etc.). "It is a ComboBox" is not the requirement; "it is the app's ComboBox" is.
|
||
3. **A hand-painted brush in code is the same smell as a hand-painted `Background`** — the theme
|
||
owns the colour. A colour is DATA: bind it and let a converter paint it
|
||
(`Helpers/HexToBrushConverter.cs`).
|
||
|
||
## 2026-10-21 — A COMBOBOX'S CLOSED BOX AND ITS ROWS CAN ONLY DIFFER THROUGH A TEMPLATE SELECTOR (RECIPE — TASK 63)
|
||
|
||
WPF copies `ComboBox.ItemTemplate` into `SelectionBoxItemTemplate`, and that property is
|
||
**read-only in C#** ("Property or indexer 'ComboBox.SelectionBoxItemTemplate' cannot be assigned
|
||
to -- it is read only"). So one `ItemTemplate` renders the SAME content in the closed box and in
|
||
every row — you cannot show "Crimson" when shut and "#e94560 Crimson" when open.
|
||
|
||
**The one mechanism that works — an `ItemTemplateSelector`.** WPF calls it with the
|
||
`ContentPresenter`'s **templated parent**:
|
||
|
||
| What is rendering | The container's `TemplatedParent` |
|
||
|---|---|
|
||
| the CLOSED selection box | the **`ComboBox`** |
|
||
| a drop-down ROW | the **`ComboBoxItem`** |
|
||
|
||
```csharp
|
||
public override DataTemplate? SelectTemplate(object item, DependencyObject container)
|
||
=> container is FrameworkElement { TemplatedParent: ComboBox } ? ClosedTemplate : RowTemplate;
|
||
```
|
||
|
||
**What does NOT work:** setting `ComboBoxItem.ContentTemplate` through an `ItemContainerStyle`.
|
||
WPF overwrites the container's `ContentTemplate` with the ComboBox's `ItemTemplate` (null) when
|
||
it prepares the container, so the style's value is wiped.
|
||
|
||
**How I found it (the probe recipe):** a throwaway xunit test in the test project (STA via the
|
||
`RealApp` collection host), a `ComboBox` in a shown `Window`, an `ItemTemplateSelector` that
|
||
logs `container.GetType().Name` + the parent's type, and a `DispatcherPriority.Loaded` pump so
|
||
the popup materialises its rows. The log `item=a,parent=ComboBox | item=a,parent=ComboBoxItem`
|
||
settled the design in one run. **Probe, then code** — this is not in any WPF book.
|
||
|
||
**Bonus measurement (the theme's popup):** the `YtComboBox` popup carries no width binding, so it
|
||
sizes to its **content**, not to the ComboBox — measured 142.7px of popup for a 100px ComboBox
|
||
whose rows read "swatch + hex + name". A long row is never clipped, and the popup may be wider
|
||
than the closed box. Measure with `ActualWidth` in a throwaway test; do not guess widths.
|
||
|
||
**2026-10-22 follow-up — `PopupRoot` IS a `Visual`, yet its `VisualTreeHelper.GetParent` is `null`:**
|
||
Switching from a code-built `ContextMenu` (which is not in `MainWindow`'s routed-event path) to a
|
||
`ComboBox` (`PART_Popup`) exposed a latent bug in `LeftPanel.IsDescendantOf`:
|
||
```csharp
|
||
child = child is Visual or System.Windows.Media.Media3D.Visual3D
|
||
? VisualTreeHelper.GetParent(child)
|
||
: LogicalTreeHelper.GetParent(child);
|
||
```
|
||
When the user clicked a colour row inside the open `ComboBox` popup, `PreviewMouseLeftButtonDown`
|
||
tunnelled through `MainWindow.Window_PreviewMouseLeftButtonDown` first. `IsDescendantOf` walked up
|
||
`VisualTreeHelper.GetParent` to `PopupRoot` (the root `Visual` of the popup's separate Win32
|
||
`HwndSource`). Because `PopupRoot` IS a `Visual`, the ternary took the `VisualTreeHelper.GetParent`
|
||
branch and got `null` — never crossing `PopupRoot`'s **logical** parent
|
||
(`LogicalTreeHelper.GetParent(popupRoot) == PART_Popup`). `LeftPanel.IsClickInside` returned
|
||
`false`, and `MainWindow` set `SelectedElement = null` (exiting the properties listing before the
|
||
`ComboBoxItem` click could register). Fix: always coalesce
|
||
`VisualTreeHelper.GetParent(child) ?? LogicalTreeHelper.GetParent(child) ?? (child as FrameworkElement)?.TemplatedParent`.
|
||
|
||
## 2026-10-22 — WINDOWS GIT IDENTITY + PRE-TASK GIT STATUS (RECIPE)
|
||
|
||
- **Windows `git` vs WSL `~/.gitconfig`:** the creator's `user.name` / `user.email` lived only in
|
||
WSL's `~/.gitconfig`, so running `git commit` from the Windows PowerShell host failed with
|
||
`Author identity unknown`. Fixed permanently in `.git/config` (`[user] name = gramps`,
|
||
`email = gramps@llamachile.shop`). If you ever need the repo's commit author without running a
|
||
shell command, read `.git/logs/HEAD` directly with `read_file`.
|
||
- **Check `git status` BEFORE starting a task** (and never run multiline/compound shell commands
|
||
when a single simple command or `read_file`/`edit_file` works): `verify.sh` runs `scope-check.sh`
|
||
over `git diff HEAD`, so any pre-existing uncommitted files in the worktree will fail step 3 at
|
||
the end of a 2-minute build+test run unless spotted up front.
|
||
|
||
## 2026-10-22 — SCANNING A GIT HISTORY FOR SECRETS WITHOUT EVER PRINTING ONE (RECIPE)
|
||
|
||
A release gate ("the repo goes public — check the history first"). The rule from
|
||
`MyMistakes.md` → "NEVER PRINT CREDENTIALS.md" applies to the SCAN too: a matched value must
|
||
never reach the screen, the transcript, or a log file.
|
||
|
||
**The pipeline:** `git log --all --reflog -p --unified=0 --format='@@@%H'` piped into a matcher
|
||
that prints **only `sha | path | rule`**. Three details that decide coverage:
|
||
- `--all` covers every branch and tag; **`--reflog` is not optional** — a commit that was
|
||
`reset` away is still reachable by SHA, so it is still exposed; `refs/stash` comes along too.
|
||
- **`git fsck --unreachable`** lists the dangling commits no ref points at. They are in the
|
||
object store and recoverable by SHA, so scan them (`git show -p <sha>` per SHA).
|
||
- The current file comes from the `+++ b/<path>` line; without it every hit is unattributable.
|
||
|
||
**A rule match is a LEAD, not a finding — classify it without printing it:** compare the match
|
||
against known placeholders (`your-client-secret`) and test-marker words (`test`, `dummy`, `fake`,
|
||
`example`) and print only the verdict + length. In this repo's scan, 4 hits were all
|
||
false positives (a doc placeholder and a unit-test dummy) and 2 real secrets were not present —
|
||
so the honest report was "4 hits, all false positives", not "4 leaks".
|
||
|
||
**Never print a matched value — but still say WHAT it is.** The report format that satisfies both:
|
||
commit + path + line + rule + verdict. "README.md:79 | assigned-credential | placeholder" is
|
||
actionable; the same report without the verdict forces the reader to go look at the secret.
|
||
|
||
## 2026-10-22 — CAP A LOGGER'S GROWTH WITHOUT TOUCHING ITS CALL SITES (RECIPE)
|
||
|
||
`AppLog` has **156 call sites** and one job; its log had no size cap (23 MB, growing every few
|
||
seconds — ffmpeg logs every stderr line). The instinct is to add a cap parameter to the logger
|
||
and change the callers. Don't.
|
||
|
||
**The recipe:**
|
||
1. **Put the POLICY in a separate pure function over the path**, not in the logger:
|
||
`LogRotation.RollIfNeeded(logPath, maxBytes, retainedFiles)`. The logger calls it before each
|
||
append; its public surface (`AppLog.Write`) never changes, so no caller is touched.
|
||
2. **Bound the FILE SET, not just the live file.** Rolling the live file alone (`startup.log` →
|
||
`startup.1.log`) still grows forever if the rolled copy is never discarded. Shift the set:
|
||
`name.1.log` → `name.2.log` → … → `name.N.log`, then the live file → `name.1.log`. Keep the
|
||
rolled count explicit (`retainedFiles`) and assert the directory's file list in the test —
|
||
that assertion is the one that proves the growth is bounded.
|
||
3. **Test the logger, not just the helper** — otherwise the helper can be perfect and unwired.
|
||
Two `internal` seams on the logger (`PathOverride`, `MaxBytes`) + the project's existing
|
||
`InternalsVisibleTo("ytLive.Tests")` let the test drive the real `AppLog.Write` against a
|
||
temp directory. Never let the test write to the real `%APPDATA%` log.
|
||
4. **Never throw from the rotation**: a failed rotation must degrade to the old behaviour
|
||
(the log keeps growing), never take the app down. Same rule as the logger itself.
|
||
|
||
## 2026-10-22 — PACK A WPF DESKTOP APP AS A MICROSOFT STORE MSIX (RECIPE)
|
||
|
||
No installer project, no WiX, no `.wapproj`: `dotnet publish` + the Windows SDK's `makeappx`
|
||
is enough, and it is what `scripts/package-msix.sh` does.
|
||
|
||
**The pipeline:**
|
||
1. `dotnet publish -c Release -r win-x64 --self-contained -o artifacts/msix-stage` — the RID
|
||
matters: an RID-less publish copies **every** RID's native folder (ONNX Runtime, ~17 MB
|
||
each) and the package bloats. Self-contained so the package carries its own .NET runtime.
|
||
2. The manifest goes at the **package root as `AppxManifest.xml`** (not `Package.appxmanifest`)
|
||
with its image folder beside it (`Assets\...` paths are relative to the root).
|
||
3. `makeappx pack /d <stage> /p <out>.msix /o` → `Package creation succeeded`. Signing is the
|
||
Store's job; `signtool sign /fd SHA256 /f dev.pfx` is only for a local test install.
|
||
4. Find the tools with a glob, not a hard-coded SDK version:
|
||
`ls -d ".../Windows Kits/10/bin/"*/x64/makeappx.exe | sort -V | tail -1`.
|
||
|
||
**Full trust, for a real Win32 app (this is the part that decides whether the app still works):**
|
||
```xml
|
||
<Application Executable="app.exe" EntryPoint="Windows.FullTrustApplication"
|
||
uap10:TrustLevel="mediumIL" uap10:RuntimeBehavior="packagedClassicApp">
|
||
```
|
||
plus `<rescap:Capability Name="runFullTrust" />` and `DeviceCapability` entries for
|
||
`webcam`/`microphone`. `packagedClassicApp` is what lets the packaged exe launch a **child
|
||
process** (a bundled ffmpeg); an `appContainer` package silently virtualises file writes
|
||
instead — recordings "vanish".
|
||
|
||
**The schema traps — makeappx validates ONE error at a time, so budget 2–3 pack runs:**
|
||
- `Square310x310Logo` and `Wide310x150Logo` are **not** attributes of `uap:VisualElements`;
|
||
they belong on `<uap:DefaultTile>`, a child of `VisualElements`. (`Square44x44Logo` /
|
||
`Square150x150Logo` / `BackgroundColor` *are* on `VisualElements`.)
|
||
- Every referenced image must exist at the stated size (`StoreLogo` 50×50, 44×44, 150×150,
|
||
310×310, 310×150) — the packer fails on a missing file, and the Store rejects a wrong size.
|
||
- Localization: `<Resource Language="en-US"/>` only, unless the listing description is
|
||
translated into every declared language.
|
||
|
||
**Testing the manifest without a Store account:** a source-level xunit guard over the XML —
|
||
identity/architecture, the full-trust application attributes, the capabilities, en-US only, and
|
||
"every referenced logo exists". ⚠️ Check `appContainer` absence on the **attribute values**,
|
||
not `manifest.ToString()` — the file's own comment is *supposed* to say "not appContainer", and
|
||
a text-wide ban fails on it (same trap as the `force-ssl`/`Download` guards).
|