Commit Graph
568 Commits
Author SHA1 Message Date
gramps 005b459fe0 feat(alerts): a Test alert button in the alert properties
Sits to the right of the Built-in video pill, labelled "Test alert". It enqueues one synthetic
Super Chat through the real AlertOverlayLayer, so what plays is exactly what a viewer triggers —
the custom clip when one is set, the built-in clip otherwise — and it goes through the same
output path (preview and encoder) as a live alert. Local only: nothing reaches YouTube and no
chat row is invented.
2026-11-01 14:04:20 -08:00
gramps 754377c5db ui(audio): every header label in the sound area uses one colour
MIC's device name was the only header label at full-strength OnSurface (it was brightened
because it shows a choice), and the lip-sync reading was Tertiary — three different values
across four bays in the same row. All four header labels are now Brush.OnSurfaceVariant, the
standard secondary-text token. No other colours touched.
2026-11-01 13:59:57 -08:00
gramps b75c2b3001 ui(audio): one row grid across the dock — ladders on a line, volume bars on a line
Every bay is now header / ladder / controls. Lip-sync has no ladder, so it holds an empty 20px
row (10px meter + the two margins) that keeps its slider exactly level with the other three,
and MIC's ladder gained the missing 4px bottom margin that had its slider sitting 4px high.

Presets stay under the slider in the narrow lip-sync bay.
2026-11-01 13:57:36 -08:00
gramps 2fedc7cb64 ui(audio): the lip-sync slider moves up one line — onto the header
Not under it: the offset slider now rides the header line beside the LIP-SYNC badge and the
reading, and the preset row is what stays below. The box is narrow, so a slider on its own line
was the only thing making it two-and-a-bit rows tall.
2026-11-01 13:56:53 -08:00
gramps 789272142d fix(build): the wordmark number counts commits again, not compiles
GenerateBuildStamp read BuildCounter.txt, added 1 and wrote it back on every CoreCompile, so
the number tracked BUILDS: a day of iteration reached #1122 against 563 commits. It now uses
git rev-list count of HEAD, which is what the comment always claimed (and why the file existed
in .gitignore). No counter file, no drift; a missing git still falls back to the start.

Also moves the MIC and lip-sync volume bars up one line: both were pinned to the floor of their
bay by a star row plus VerticalAlignment=Bottom. They now sit directly under their headers,
with the auto rows sized to content.
2026-11-01 13:56:17 -08:00
gramps 35c7809c28 fix(audio): bay ladders fill their bay again — the 320px cap was a mistake
Capping the three bay meters at 320px and left-aligning them made each ladder look cut off
mid-bay, which is what the creator saw as deleted bars. The caps are gone; the master ladder
keeps its own 420-900px range so it stays the longest without shortening anything else.
2026-11-01 13:52:31 -08:00
gramps 4c4f22e6bb ui(audio): the master ladder is always the longest one
The master meter was a fixed 420px, but each sound bay's ladder fills its column — on a wide
window a bay could be longer than the master, which is the opposite of what was asked.

Master now stretches between 420 and 760 px, and the three bay ladders cap at 320 with the same
cap for each, so they stay the same relative size to one another and stay shorter than the
master at every window width.
2026-11-01 13:49:05 -08:00
gramps f8e4f2cf29 ui(audio): master meter centred and biggest; TRAX gets its own bay and meter
Creator rulings, 2026-11-01.

- Master strip is 44px (from 36) and its meter is now the largest on screen: 420x14 with 32
  segments, centred under the preview, with a 12px MASTER label — bigger than the 9px bay
  labels. L/R imbalance is a flag on the left, MONO and the session clock on the right.
- The kbps / fps / dropped counters are gone from that line: the live readouts already sit
  above the preview, and the duplicate was noise rather than telemetry.
- TRAX is its own bay (MIC | GAME | TRAX | LIP-SYNC) with its own meter bound to
  TraxMeterFraction. It lived inside the game bay with no ladder, which meant whether the bed
  was actually playing could not be answered from the dock — and the player already measures
  its own output.
- Lip-sync is narrow (0.45 star) since it is one slider and three presets; the three sound bays
  are equal width, so their meters keep the same relative size.

AudioDockLayout moves with it (156 = 112 + 44; collapsed 44) and the layout test is updated.
TraxBayTests now asserts the opposite of the old ruling: the bay HAS a meter, and the game bay
no longer carries it.
2026-11-01 13:48:28 -08:00
gramps 9b337ad91b feat(blur): the brand logo cannot be blurred
The blur is universal, so it would swallow the logo along with everything else. The compositor
now defers every visible brand logo: skipped in both element loops, blitted immediately AFTER
the blur group, before the flash/social/ticker overlays. One logo per scene, so it is cheap.

Preview mirrors it: the staged scene's brand logo is drawn above the blur host (StagedBrandLogo),
so what you see in the editor is what the encoder gets.

Test: a blur covering the logo's area leaves the logo's pixels exactly white while the coloured
background beneath it blends.
2026-11-01 13:32:23 -08:00
gramps bb51164f2f feat(blur): universal — the blur covers every layer, webcam included
The creator found the use case that reframes the feature: a moment of privacy while the webcam is
on. A blur that can only cover the desktop cannot do that.

- SceneCompositor applies the blur group AFTER every element is composited (still before the
  flash / social bar / ticker / zoom overlays), so it covers webcam, chat, alerts — anything. Its
  position in the layer list no longer affects the output, so it is added at the top and the
  reorder clamp is gone.
- Supersedes the earlier "blur sits below the brand" rule: a universal blur does obscure the logo
  when it covers it. Called out in frame-pipeline.md so it is not re-litigated.
- Preview mirrors it exactly: the blur host moved OUT of CanvasGrid to a sibling above it, so the
  VisualBrush samples every layer (and cannot sample itself). It used to sample only the backdrop,
  which is why a circle over the webcam would have shown sharp pixels in the editor and blurred
  ones in the stream.
2026-11-01 13:29:15 -08:00
gramps bb80ec8370 ui(properties): geometry for every placeable layer, and no fake opacity on a blur
Two holes in the blur's property panel, both of which also affected other layers:

- X/Y/W/H were gated on IsWebcamSelected, so a blur (or an image, chat box or alert zone)
  had no numeric position or size at all — only dragging. The block is generic: it always
  edited SelectedElement's own values, so the gate was the only thing excluding them.
- Opacity showed for a blur, but the compositor's blur reads BlurAmount — the slider would
  have moved a number that changes nothing in the output. Hidden for blur layers.

Blur panel is now: Box | Circle, strength, and the shared geometry fields.
2026-11-01 13:20:13 -08:00
gramps da508f0a14 fix(blur): the (+) menu entry re-evaluates — CanAddBlur was never re-notified
The Blur item's Visibility is bound to CanAddBlur, which depends on the staged scene. The scene
setter never raised it, so the binding evaluated once (collapsed) and stayed collapsed for the
session — build 1082: the option simply wasn't there.

StagedScene now notifies CanAddBlur, with a test that pins both halves: Live true, Chat false,
and a notification on every staged-scene change.
2026-11-01 13:17:40 -08:00
gramps 9b44a32054 fix(blur): the layer is draggable, so adding one drops a rectangle with a resize handle
Source.IsDraggable listed Image/Web/Chat/Alert only, so a blur had no selection box, no corner
handle, and no hit-test — the rectangle existed in the render but could not be grabbed. Adding
Blur to that list gives it the same selection chrome and resize path every placeable layer has
(drag to move, corner dot to resize, one undo snapshot per gesture).

It is selected on add (AddSource already does that), so the rectangle appears already outlined
and ready to size.
2026-11-01 13:14:51 -08:00
gramps 1416548f24 feat(blur): the editor shows the blur — whatever is beneath it is blurred, in the preview
The preview canvas is WPF visuals; a blur layer contributes no visual of its own, so until now
the creator saw nothing where the blur was and could not tell what it was hiding.

Each visible blur now draws a rectangle (or ellipse) filled with a VisualBrush of the whole
BackdropLayer — the desktop capture plus the background art — sampled at the blur's own rect and
carrying a BlurEffect at the same strength the compositor uses. The host canvas sits after the
backdrop and before the element layers, which is the model's own z-rule, so the preview shows
exactly what the output will: desktop blurred, brand and everything above it untouched.

Rebuilt on scene change, on add/remove, and on any blur property change (position, size, shape,
strength) — the brush samples the live backdrop, so the picture keeps moving underneath.
2026-11-01 13:12:27 -08:00
gramps 0ed341fd18 fix(blur): the crash on launch — BlurAmount was in the INSERT and the reader, not the SELECT
LayoutStore.Load threw ArgumentOutOfRangeException on GetOrdinal("BlurAmount") during the
MainViewModel constructor, so the app died at startup rather than when a blur was used. The
migration adds the column and Save writes it; the reading query still listed its columns
explicitly and BlurAmount was missing from that list.

Adds LayoutBlurPersistenceTests: a blur saved and reloaded, asserting the amount and geometry
survive. That is the test that should have existed with the feature — the startup path it
covers has no other guard.
2026-11-01 13:08:03 -08:00
gramps 71d0e87cac feat(blur): a privacy blur layer for the Live scene — boxes and circles over the desktop
Creator request 2026-11-01: a layer that hides part of the desktop from viewers. Box or circle,
drag/resize like any layer, one strength slider, several allowed.

**Z-order is policy, not preference.** `AddSource` inserts a blur directly above the background
and therefore below the brand logo, and `OnSceneElementsReordered` pushes any blur that ends up
above the brand back down. Nothing else in the app can move it into the brand's way.

**Overlap = the strongest single blur, never a stack.** `SceneCompositor.ApplyBlurLayers` collects
every visible blur the first time one is reached in the z-order, sorts them strongest-first, and
shares one coverage map: a pixel already covered by a stronger blur is skipped. Blurring
back-to-front would have produced something stronger than any slider setting allows, which is
exactly what was ruled out.

**One blur curve, shared with the webcam.** `RegionBlur` carries the constants the webcam blur
already used — downscale 4, radius 1–6 downscaled px, `RadiusFor(strength)` — and
`BackgroundEffectProcessor` now delegates to it instead of holding its own copy. The layer's
slider is the same 0–1 scale with the same 0.5 default, so "half strength" means the same blur in
both features. Cost is proportional to the blur's own rect, not the frame.

**Live scene only**, enforced in `AddSource` as well as the (+) menu (`CanAddBlur`), so a stale
menu item or a scripted call cannot plant one on BRB. `ElementKind.Dynamic`, so it is never baked
into the static base and every render path gets it — pump, preview, thumbnails, scene dump.

Persistence: `Source.BlurAmount` column + migration; shape rides the existing `ClipShape`.
Properties panel: Box | Circle plus the strength slider, gated on `IsBlurSelected`.

4 tests: outside the mask is byte-identical, inside loses its hard edges, a circle leaves its
box corners alone, and two identical stacked blurs equal one (the coverage map). Release builds
at 0 warnings.
2026-11-01 09:55:04 -08:00
gramps 2e12c5651e ui(drawers): 20px of content padding on the right of every pull-out
The five drawers each sized their content Grid to Width=320 inside a panel that itself
animates to 320, with 14px margins — so the content box was 348 wide in a 320 window and its
right edge was clipped. That is why every drawer's content jammed into the right margin
regardless of the margin value.

The Grid now fills the panel (no fixed width) with Margin 14,10,20,12: 14 on the left, 20
between the content and the right edge of the div, which is the requested gutter.
2026-11-01 09:35:37 -08:00
gramps a543d3629b ui(settings): 20px right gutter on the drawer, rail tabs centred on the preview
Two build-1047 notes. The settings content gets a 20px right margin (plus 4 left) inside the
scroll viewer, so text and controls stop running into the panel edge and the scrollbar has
somewhere to live. The rail tab stack is VerticalAlignment=Center instead of Stretch, so the
whole group of tabs sits centred against the preview rather than hanging from the top.
2026-11-01 09:22:02 -08:00
gramps a35ec0c5f8 refactor(settings): a pull-out drawer, with Zoom as a category — and the menu moves to the wordmark
Creator directive, 2026-11-01. Settings stopped being a centred modal.

- **Drawer** (Controls/SettingsDrawer): hosted in PreviewPane's pull-out stack, 320px, the same
  animated-width pattern as Stream Settings / YPP / Socials / TEST, opened by the gear rail tab
  on a single left click. SettingsDrawerOpen is exclusive with the other four, both directions.
  This also removes the modal's MaxHeight-700 scroll problem for good: the drawer is the height
  of the preview.
- **Categories**: headings with controls indented beneath them. Zoom now owns *Zoom view shape*
  (Circle / Phone / Tablet) and *Zoom box follows*; Recordings, Brand logo and Scene transition
  are their own categories.
- **The app menu moved to the LlamaCasty wordmark** (right-click → Report Bug / Feature Request /
  About). Left-click still opens About. The gear tab no longer carries a menu at all, so clicking
  it does exactly one thing.

OverlayHost keeps only the Bug / Feature / About panels; its brand-logo handlers and the dev-panel
injection moved with the settings block. BrandLogoPreviewTests follows the control (same assertions,
new path). Release 0 warnings; 15 tests pass across brand logo, zoom, entitlement and preview
history.
2026-11-01 09:16:27 -08:00
gramps c418288215 fix(settings): the App Settings panel scrolls — the lower options were clipped off-screen
The shell is MaxHeight 700 and the settings panel had grown past it with no ScrollViewer,
so everything below roughly the transition section was unreachable. That is why 'Tablet
(Horizontal Rectangle)' existed in the XAML and in the model but never appeared in the app:
the shape control itself was cut off, not the option.

Panel row is now * with an Auto ScrollViewer, so every section is reachable and the next
one added cannot silently fall off the bottom.
2026-11-01 09:08:08 -08:00
gramps 517e60d593 fix(zoom): the magnifier freezes the layers — no picking up objects while it is open
While zoom is active a left drag moves the box and never selects or moves a layer beneath
it (creator ruling 2026-11-01). MouseMove already owned the zoom drag; the missing guard was
in MouseLeftButtonDown, which happily hit-tested a layer first and left a stray selection
plus a half-started drag behind the moving box.
2026-11-01 09:06:05 -08:00
gramps c3bbf1fdda fix(zoom): ignore Ctrl auto-repeat — the box was closing itself as it was created
Creation holds Ctrl (Ctrl+right-click), and Windows repeats a held modifier. The repeat
tick landed after the box existed, so the dismiss handler saw an open box and closed it:
appear, then vanish ~500ms later. Guarded with e.IsRepeat, which only ever skips the
repeat events — a genuine second press still closes the box.
2026-11-01 09:01:02 -08:00
gramps 15734a5477 feat(zoom): Ctrl closes the box, and a Follow pill that re-anchors the capture
Two creator directives, 2026-11-01.

**Ctrl-down dismisses an open zoom box.** Window-level PreviewKeyDown, so the same hand that
opened it can put it away. It cannot cancel itself at creation: Ctrl is already held before
the right-click that makes the box, so the keydown fires while nothing is open.

**Follow mode, default Anchor.** A PillToggle in App Settings ("Follow the box"), persisted
as Zoom.FollowBox. Off — the default — keeps the 2026-10-08 model: the capture stays on the
clicked point and dragging only moves the box over it. On, UpdateZoomMove re-anchors the
capture to the box centre (half the box's size, clamped per axis), so the magnifier shows
whatever it hovers. Flipping to Follow while the box is open snaps it immediately.

Three tests: anchor drags leave CaptureX alone, follow moves it to the expected quarter-offset
centre, and the setting round-trips through LayoutStore (tested against the store, not via two
view models — two live VMs fight over the temp DB, which is a fixture problem, not a bug).

Both new test classes share a collection tag: MainViewModel.LayoutPathOverride is static, and
xUnit runs classes in parallel, so without it the two view models collided (one failure in a
combined run, clean when run apart).

Release build 0 warnings; 14 tests pass across zoom, entitlement and preview history. Debug
build is blocked by the running app (PID 47388) — close it and the exe updates.
2026-11-01 08:51:28 -08:00
gramps 1d2d58f1d7 fix(zoom): the gesture now creates the box instead of only closing it
Creator report: Ctrl+right in edit mode and left-click in preview did nothing when
neither streaming nor recording. The bindings were correct; the action was not.

Two causes, both in the model rather than the handler:
- ActivateZoom opened with `if (!(IsLive || IsRecording)) return;` — the box could not be
  created outside a session at all.
- ToggleZoom only ever called DismissZoom, so "toggle" could turn it off and never on.

Now ToggleZoom(x, y) activates at the cursor or dismisses if already open, and the live-only
guard is gone — the magnifier is an editing aid as much as a live one. Both call sites pass
the canvas point, so the box still anchors on the click rather than the frame centre.

Debug 0 warnings; ZoomBox + ZoomMagnifier tests pass (3).
2026-11-01 08:43:17 -08:00
gramps 4b91760f92 fix(preview): Ctrl+right no longer opens the menu, and the social bar keeps its click
Two event-pairing bugs found by validating every handler instead of trusting the table.

1. Ctrl+right-click opened the context menu in edit mode. Cause: WPF's PopupControlService
   opens menus on right-button-UP, and only the DOWN event was being handled — so the zoom
   toggled and the menu appeared under it. Both events are now handled when Ctrl is down.
   (The creator saw this in edit mode; preview mode had the same latent bug.)

2. The social bar's left-click was dead in preview mode and dragged the layer beneath it in
   edit mode. Cause: PreviewGrid handles the TUNNELLING PreviewMouseLeftButtonDown, which
   fires before the bar's bubbling handler — and since preview-mode left-click now toggles
   zoom, that handler swallowed the bar's click too. The preview handler now steps aside when
   the point is inside the bar's bounds (SocialBarElement.RenderSize, translated to canvas).

Audited and left alone because they are correct: left-down → resize handle / element drag /
deselect; move → zoom drag before edit drag (by design); left-up → EndPreviewDrag; right-up
→ zoom-drag release then the Ctrl swallow.

Build Debug 0 warnings, 22 tests pass across ZoomBox, PreviewHistory and SocialBar.
2026-11-01 08:19:17 -08:00
gramps 6d1c982e26 feat(entitlement): IEntitlement seam — Steam DLC answers, everything fails closed
The first half of surge day 1. One question, "may this session emit Pro output?", with
one method on the interface so nothing downstream knows which rail proved it.

SteamEntitlement reads the lifetime SKU the way Valve intends: SteamAPI.Init() then
SteamApps.BIsDlcInstalled(dlcAppId). Four different failure paths all resolve to the
free tier rather than to Pro or to a crash — no DLC id configured, no Steam client,
steam_api64.dll missing (a throw on first touch), and simply not owned. A copied folder
run without Steam is the common case, not an exceptional one.

NotConfigured() is the shipping default until Steamworks creates the DLC app; it answers
false without ever touching the native library, so today's builds behave exactly as they
did before this commit.

HasProOutput is now IsPremium || DeveloperMode || rail, with an internal EntitlementOverride
test seam (internal, because a public setter here is a licence bypass). Four tests: no id
reads false, no client reads false without throwing, the fake reports what a test sets, and
the view model takes its answer from the rail.

Steamworks.NET 2024.8.0 added; the native DLL still comes from scripts/steam-sdk.sh, so the
csproj change is three lines and no formatting churn.
2026-11-01 08:13:25 -08:00
gramps 279eb08dea feat(preview): right-click is the menu, zoom is the single-handed gesture
Creator ruling 2026-11-01, and the reason is live-stream ergonomics: no two-handed
chords while a broadcast or recording is running.

- Edit mode: left selects / drags / resizes, right opens the context menu, Ctrl+right
  toggles the zoom box. Chords are acceptable here because editing is a stopped activity.
- Preview mode: left toggles the zoom box, right opens the context menu, and Ctrl+right
  is swallowed outright — handled rather than left to fall through, so Ctrl+right cannot
  open a menu by accident while one hand is on the mouse.

This also removes the old live behaviour where right-click activated the magnifier at the
cursor point (and dismissed it). Zoom in preview is now a plain toggle; if point-zoom is
wanted back it needs a one-button gesture, not a chord.

Build: Release 0 warnings. Debug is currently blocked by the running app holding the exe
(PID 47252) — close it and the Debug build copies normally.
2026-11-01 08:10:04 -08:00
gramps fdb27f0758 feat(ui): gear to the rail, EDIT on the panel edge, preview undo/redo
Three creator directives (2026-11-01), done ahead of the surge.

**Gear moved to the preview rail** — first tab, orange (Brush.Attention), gear icon
only. Left-click opens the same app menu the top bar gear owned (Settings / Bug /
Feature / About), right-click goes straight to App Settings; both behaviours moved
with it, so nothing lost an entry point. TopBar keeps the wordmark and EDIT.

**EDIT right edge aligned to the layers panel.** The work area is margin 8 + a 360px
column, the top bar is padding 16, so the shared edge is 352 inside the bar. TopBar
column 0 is now a fixed 352 grid with EDIT right-aligned in it — alignment by
arithmetic instead of by eye.

**Preview undo/redo: Ctrl+Z / Ctrl+Shift+Z.** Models/PreviewHistory snapshots element
REFERENCES plus geometry, visibility, order and membership — a deep clone would mirror
every property of a dozen element subclasses and quietly miss the next one added.
One snapshot per gesture (pushed at drag/resize start, never in the move loop), bounded
at 50, redo survives multiple undos, cleared on layout load. Gated on EditingActive:
undo while the preview shows program output would rewrite a scene nobody is looking at.

Honest scope, in the type's doc comment: layout edits only. Colour/opacity/border/font
edits are not captured yet, so Ctrl+Z after one of those restores the last layout change.

6 tests in PreviewHistoryTests; 19 pass across it and the neighbouring UI classes; Debug
and Release both build at 0 warnings.
2026-11-01 07:46:10 -08:00
gramps bd669f2e73 docs(handoff): the six-day surge is the next action 2026-11-01 07:35:49 -08:00
gramps 600088c794 docs(tasks): six days, all remaining code — no week 2, no after-launch list
Correcting my own plan: the surge is six calendar days (Mon 2 - Sat 7), Saturday included,
and it finishes the coding side. Every open row is assigned to a day; the week-2 and
after-launch sections are gone because there are no such sections.

Days: licensing (entitlement, licence UI, greyed tiers, system messages), release blockers
(visibility picker, output contract, webcam identity, Move transitions), resilience (TASK 32,
bandwidth step-down), content and media (text layer, media wiring, profanity filter,
scheduled streams), data and settings (reward persistence, YPP slice 2, defaults split,
scene audio, settings round, disk hygiene), finish and prove (publish recording, undo, gold
pass, full sweep).

Target 6-8 units a day against a measured baseline of 3-5 — that gap is the surge. If a day
overruns, the smallest item moves; a launch blocker never does. Day 6 is the only polish day.
2026-11-01 07:35:38 -08:00
gramps 68fdacc12d docs(handoff): trim to the 40-line cap 2026-11-01 07:33:00 -08:00
gramps 724f940f5e docs(handoff): 40 lines 2026-11-01 07:32:44 -08:00
gramps 832955bbb8 docs(handoff): fit the 40-line cap 2026-11-01 07:32:30 -08:00
gramps 7dab7ded36 docs(handoff): HANDOFF under the 40-line cap for the surge plan 2026-11-01 07:32:13 -08:00
gramps b2e8b26b6e docs(tasks): the surge week, coding only — five days sized at 3-5 units each
Non-coding is parked on Valve (identity verification, store page, depot); the week is code,
tests, debugging, polish. Ordered by what actually blocks a public build: entitlement seam
and licence UI (days 1-2), greyed tiers (2), visibility picker (3), stream resilience (3-4),
output contract (4), gold pass (5).

Sized honestly against the measured rate: ~50 units remain, 15-25 fit in five days, so the
plan names week-2 candidates and the five items that should not block launch at all
(scheduled streams, scene audio, defaults split, YPP slice 2, feedback batch).

Every unit carries the same definition of done — clean build, per-class tests, verify.sh on
the declared scope, and the falsified doc corrected in the same commit.
2026-11-01 07:31:52 -08:00
gramps f189c5e328 build(steam): the SDK zip IS the only download — ContentBuilder included, links recorded
The partner dashboard offers exactly one download because there is only one: the SDK zip
carries the runtime (steam_api64.dll) and the release tooling (sdk/tools/ContentBuilder
with steamcmd.exe for Windows and steamcmd.sh for Linux/macOS, plus the app_build and
depot_build VDF templates). Verified against the zip rather than assumed.

scripts/steam-sdk.sh gains --contentbuilder, opt-in because it is 27 MB of binaries that
only the uploading machine needs.

Distribution.md §2 now opens with that fact — there is no separate ContentBuilder
download to hunt for — plus a reference table of the pages that actually get used:
SteamPipe upload, DLC setup, BIsDlcInstalled, store artwork sizes, Coming Soon window,
the Wallet rule, recurring subs, partner support and Steamworks.NET.
2026-11-01 07:26:51 -08:00
gramps 1fe84be64a build(steam): stage steam_api64.dll beside the exe — one file, not the whole SDK
The SDK zip is 24 MB of headers, examples and tools we never build. The runtime
dependency is one native library, so scripts/steam-sdk.sh extracts exactly
sdk/redistributable_bin/win64/steam_api64.dll into steam/ (gitignored, like ffmpeg/ —
the script is the audit trail) and writes steam_appid.txt = 480, Valve's Spacewar app,
so SteamAPI.Init() resolves an app id locally before LlamaCasty has one of its own.

Two csproj details that would have cost a debugging session:
- Link="steam_api64.dll" puts it BESIDE the exe; without Link, None items keep their
  relative path and it lands in steam/, where the loader does not look.
- ExcludeFromSingleFile, for the same reason ffmpeg needed it: a native DLL otherwise
  gets swallowed into the single-file bundle and cannot be loaded at runtime.

steam_appid.txt is Debug-only — Valve's guidance is not to ship it — and the publish
output confirms it: dll present at the root, no appid file, exe still 127 MB (proof the
DLL was not bundled).

Also queued, as asked: preview undo (Ctrl+Z / Ctrl+Shift+Z) in edit mode, noted as the
partner to the defaults-vs-current split — undo covers the slip, defaults cover the mess.
2026-11-01 07:23:29 -08:00
gramps 27fc6fb507 feat(profile): the free budget is pixels and fps, enforced once — with a dev-only release hatch
The paid delta is output quality, so the rule needs to be measurable rather than a word:
921,600 pixels and 30 fps, fail-closed. Models/OutputProfile.cs is that rule as pure
policy — a permitted tier passes through untouched (Pro, or the free tier's own 720p30),
a disallowed one resolves to the best tier the budget allows and carries ITS bitrate, so
a capped user is never offered an 8 Mbps budget for a 720p frame.

Applied once, in BuildEncoderOptions. That is genuinely one place: the stream and the
local recording are two output blocks of ONE ffmpeg process fed by one EncoderOptions,
so there is no second encoder to forget. (commercial.md claimed "both encoders — never
two checks"; the code is stronger than the doc, and the doc is corrected.)

Developer override, as asked: a "Developer mode — all resolutions" checkbox in App
Settings, bound to MainViewModel.DeveloperMode, which feeds HasProOutput. It is not
hidden in a release build — Controls/DeveloperPanel's Page and Compile items are removed
when Configuration != Debug, and OverlayHost adds it at runtime inside #if DEBUG, so a
shipped exe has no BAML for it at all. The setter also refuses the value when
DevBuild.IsDevelopment is false, which is now the single source for that question (the
scoreboard partial had its own duplicate const).

Two traps worth their lines: a `const bool` dev flag makes `if (!Flag) return;` an
unreachable-code warning in one configuration or the other (CS0162), so DevBuild uses
static readonly; and a Condition on an ItemGroup is invisible to a test reading
item.Attribute("Condition") — read the parent.

Tests: 14 pass across OutputProfileTests, AssemblyIdentityTests and StorePackagingTests.
The invariant test is the one that matters: iterate every shipped tier under a free
entitlement and assert none emits more than the budget, so a future 4K tier fails here
instead of shipping to everyone. App builds Debug and Release at 0 warnings.
2026-11-01 07:11:49 -08:00
gramps abfab25685 build: the exe is LlamaCasty.exe, and the publish script proves the artifact
Renames AssemblyName to LlamaCasty before the first Steam depot — Steamworks is explicit
that renaming a live exe is painful, so this had to land first, not later.

Four assembly-qualified pack URIs carried the old name, not three: MainWindow (window
icon), OverlayHost (logo), RenameRecordingDialog and KeepBroadcastDialog. Pack URIs
resolve by assembly name at runtime, so a stale one compiles, passes every test and then
silently renders nothing the first time that view opens. AssemblyIdentityTests guards
both halves — the assembly name, and no source XAML still naming the legacy assembly.
Packaging/Package.appxmanifest and package-msix.sh carry the exe name too.

scripts/publish.sh replaces the by-hand publish: single-file win-x64, ReadyToRun,
ffmpeg beside the exe, and it refuses to finish if ffmpeg, LICENSE, PRIVACY.md or
THIRD-PARTY-NOTICES.txt are missing, or if a test assembly leaked in.

That last guard exists because the first publish was wrong in a way no test could see:
PublishSingleFile + IncludeNativeLibrariesForSelfExtract bundled the libav*.dll family
into the exe — a 9-file output with no ffmpeg folder and a 182 MB binary, so the locator
would have found nothing at runtime. ExcludeFromSingleFile="true" on the content keeps
the folder a folder. Diffing against a control publish without the single-file flags
found it; guessing at item globs wasted two attempts first (recorded in MyMistakes.md).

Measured: 127 MB exe + 129 MB ffmpeg = 257 MB depot. Tests: AssemblyIdentity,
StorePackaging, BuildStamp, BundledFonts all pass; app builds with 0 warnings.
2026-11-01 07:01:50 -08:00
gramps 8f79b20a18 docs(distribution): Steam is the only channel — the zip, winget and the Store are dead
The site said "Coming soon to Steam" while the app docs still said direct download plus
winget primary with Steam parked. One of them had to move, and the creator's ruling is
Steam exclusive: a free app on Steam with the paid edition as a Steam DLC, and the
$10/month plan sold off-Steam by Polar.

Records the constraint that shapes the code, not just the prose:
- Steamworks requires in-app purchases to use the Steam Wallet (Microtransactions doc,
  quoted in commercial.md), so the monthly is *stated*, never *sold*, inside a Steam
  build; the lifetime is plain DLC bought on the store page and read with BIsDlcInstalled.
- Entitlement is `steamDlcOwned || polarKeyValid`, both resolving to one OutputProfile.
  steam_api64.dll ships beside the exe and is a functional dependency, not the rejected
  "IP protection" native DLL — without it DLC ownership cannot be read. Launched without
  Steam, the app falls back to the free profile rather than refusing to start.
- Signing is moot: Steam installs never meet SmartScreen. Artifact Signing stays on the
  shelf for the day a non-Steam download exists. Year-one fixed cost is ~$115.
- The exe rename must land before the first depot upload — Steamworks warns about
  renaming a live exe.

Distribution.md is rewritten to SteamPipe mechanics (publish output, depot, steamcmd,
DLC wiring, clean-account purchase/refund test). product.md, decisions.md (a new
"Steam exclusive" entry with every dead vector), TASKS.md (Steamworks entitlement row,
Steam packaging row), ai.md, index.md and the TASK 48 banner follow. MyMistakes.md gains
the Steam rules that cost real time today: installs skip SmartScreen, updates are free,
the Wallet rule, and the recurring-billing caveat.
2026-10-31 11:05:53 -07:00
gramps 57ef9e7a5e docs(commercial): the claim page was never needed, signing waits for revenue, and support is a filter
Four things derived this session superseded what the plan said twenty-four hours earlier,
and the docs are corrected where each one died:

**Licensing needs no server.** Polar's customer-portal endpoints authenticate on the key
itself plus the public organization_id; the org access token is only needed to create
products. Channel binding rides in `conditions: {channel_id}`, re-checked on every
validate — so the "claim page" (a hosted OAuth flow that wrote the UC id onto the key)
and the key database it implied are deleted. Device management, key rotation and
subscription auto-revocation are Polar's; four states, one lease, no callback endpoint.

**Signing is deferred until two subscribers.** It never removes the day-one SmartScreen
warning (EV's instant-bypass died in 2024), so buying it before revenue buys only the
publisher line. Renamed to Azure Artifact Signing, $9.99/mo Basic, and it requires a
paid Azure subscription. Year-one fixed cost drops from ~$135 to ~$15.

**The Store account is free.** Microsoft waived the $19 individual fee and removed the
$99 company fee in May 2026, which makes the Store the only zero-cost route to a
Microsoft-signed install — the free answer to SmartScreen, noted beside the paid one.

**Support is a filter, not a promise.** Email, best effort, no SLA, refunds on request
without argument; no training service, no helpdesk. A repeated question becomes a
training video on @llamacasty, which is content and monetization instead of a ticket.
GitHub issues on the releases repo are the deliberate friction.

Also settled: binaries live in a second Gitea repo (`llamacasty-releases`) push-mirrored
to a public GitHub repo — issues and assets public, code private, one publish direction
(a git mirror carries refs, not GitHub Release assets); `@llamacasty` is
standard-verified with intermediate features, so live streaming, >15 min uploads and
custom thumbnails work today and no SIM or ID is needed; and OAuth comes out of Testing
mode by publishing the app (100-user lifetime cap, no 7-day token death).

TASKS.md now opens with the four-day surge slice — artifact shape, output gate,
entitlement seam, real-machine day — because those are what the first public release
waits on. HANDOFF.md rewritten. MyMistakes.md gains the Polar recipe and the
"verify means three different things" entry. MARCOM.md gains the support→content loop
and the surface roles.
2026-10-31 10:08:05 -07:00
gramps 0b41b7db67 docs(mistakes): the www → apex recipe — CNAME, the silently self-signed cert, and the heredoc that ate $request_uri
www.llamacasty.com now resolves and 301s to the apex. Four traps worth keeping:
yunohost domain add --install-letsencrypt-cert exits SUCCESS with only a self-signed
cert (it skips LE when the parent has no wildcard), plain cert install then insists on
a diagnosis and needs --no-checks, a domain with no app 302s to the YunoHost SSO rather
than 404ing, and writing the nginx snippet over ssh with an unquoted heredoc silently
expands $request_uri to nothing — every path then redirects to the homepage. Also
recorded: both resolvers near this job lie about the name (workstation NXDOMAIN cache,
droplet dnsmasq answering ::1).
2026-10-31 09:05:16 -07:00
gramps 7827d239e2 docs(commercial): one source of truth for the money — prices, entitlement, channels
The commercial plan was spread over five documents written in five different weeks,
and they contradicted each other: product.md sold the Store, decisions.md called the
Store dead then re-opened, Distribution.md (736 lines) still described Polar as a
download host, MARCOM.md quoted "no subscription" next to a paid tier, and README.md
promised "free forever". An outsider could not tell what ships.

docs/ai/commercial.md is now the only place money is decided: free = whole app at a
921,600 px / 30 fps output profile covering the recording as well as the stream; Pro =
$10/mo or $400 lifetime through Polar, key bound to the buyer's YouTube channel ID via
a claim page, 2 activations, 7–14 day lease with offline grace, fail closed, never
downgrade a running stream; one portable zip + winget primary, Steam and the Store
parked; Azure Trusted Signing (~$9.99/mo), no EV; Founding 200 promo; ~$135/yr costs;
anti-piracy posture and what is permanently rejected.

Everything else now points at it instead of restating it:
- product.md — money sections replaced with pointers; packaging rewritten to
  winget-first, Store parked; signing updated to Trusted Signing.
- decisions.md — graveyard with cause of death for free-forever, the Store ordering,
  the updater, and the 2026-10-30 reversal of the quality-ceiling rejection.
- Distribution.md — rewritten to release mechanics only (artifact, GitHub Releases,
  winget manifest, signing, checklist, takedown): 736 → 120 lines.
- TASKS.md — TASK 54 rewritten, TASK 48's route settled, TASK 10 superseded, five new
  rows (entitlement seam, output-profile gate, licence UI, system messages, packaging).
- README.md — "free forever" replaced with the real split.
- capture.md — obs-backgroundremoval is GPL-3.0 and so licence-INCOMPATIBLE since the
  proprietary EULA; the old "compatible" line was falsified by that change.
- TASKS/task-10, task-48, task-51, task-54 — superseded-banner at the top of each, so
  none of them reads as current policy.
- HANDOFF.md — rewritten: plan settled, next code unit is the entitlement seam.

MARCOM.md and MONETIZATION.md (gitignored) were corrected in place: policy pointer at
the top of each, the flash removed from every live claim, the Polar fee break-even
corrected to ~$900/mo, and the projections flagged as written against a dead model.
2026-10-31 08:32:25 -07:00
gramps 90d5140906 docs(mistakes): the llamacasty.com deploy recipe, recorded
Deploying the site cost a session because the only documentation was a destination
('/var/www/linkstack/'), not a transport, and the failure modes looked like credential
problems rather than the two things they were: sshd offers publickey only on 2214, and
it appended a keyboard-interactive 2FA step that a non-interactive tool call cannot
answer (hang, then 'Permission denied (publickey)').

Records the whole shape: web root and owner, port 2214 key-only, password = sudo not
SSH, the 2026-10-30 removal of AuthenticationMethods/PAM 2FA, and the traps — the md
files must never be published (build-this.md now carries INTERNAL pricing), drvfs 777
needs the chmod pass, linkstack__2 is a different app, and 22/1319 are closed.
Recipe lives in llamacasty.web/deploy.sh.
2026-10-30 16:49:33 -07:00
gramps c72b61b61e docs(decisions): the site publishes the model, not the terms
Records the split so neither side drifts: the app repo holds the real numbers (720p30 free, $10/mo
or $400 lifetime, Store individual account) and the site holds only the promise (free forever, every
feature unlocked, a paid edition at launch).
2026-10-30 16:16:31 -07:00
gramps 54de9c3dfb docs(decisions): Store route re-opened via an Individual developer account
Creator ruling 2026-10-30: the Microsoft Store is the distribution target again, through an
Individual (not Company) developer account — which is what made the 2026-10-22 withdrawal necessary
in the first place, and why no legal entity is now required. The publisher line shows the verified
individual name; distribution work stays parked until the account exists, and the site's 'coming
soon to the Microsoft Store' copy is accurate again.
2026-10-30 16:15:28 -07:00
gramps 0498f7e130 legal: proprietary EULA + privacy policy; GPL terms retired
Licensing posture changes to closed-source commercial software (creator ruling 2026-10-30): free
tier caps output at 720p30, a paid licence unlocks 1080p ($10/month or $400 lifetime).

- LICENSE is now an End User License Agreement: grant, what you may do with your own content,
  no redistribution/reverse engineering, third-party components prevail, updates at publisher
  discretion, warranty and liability limits, termination, governing law.
- ADDITIONAL-TERMS.md deleted — those were GPL §7 terms and have no meaning under the EULA.
- THIRD-PARTY-NOTICES.txt keeps every third-party obligation and drops the GPL header and §6
  source offer; it states plainly that LlamaCasty ships no source.
- PRIVACY.md is new: exactly what is written locally (layout DB, DPAPI token, capped log,
  recordings you choose), what leaves the machine (Google's APIs only, with your account), and the
  standing promises (chat rendered never stored, no telemetry, no ad SDKs).
- README, docs/ai/product.md, docs/ai/decisions.md and TASKS.md record the decision and the
  obligations that still bind (ffmpeg LGPL as a separate process, OFL fonts, CC0 icons, model
  provenance, notices in the About screen).

Deliberately NOT decided here: key issuance/verification and the resolution gate itself. Earlier
GPL builds remain GPL for the versions that shipped under it.
2026-10-30 16:10:38 -07:00
gramps 7829610cad docs(mistakes): record the record-struct zero-init trap
Two controls looked dead because new ScoreboardStyle() zero-initialised instead of applying the
declaration's defaults. Recorded with the rule (explicit parameterless ctor) and the testing lesson
(a defaults test must construct with new(), not with explicit arguments).
2026-10-30 15:24:33 -07:00
gramps c03acd0aa0 fix(scoreboard): the defaults were never applied — a record struct zero-initialises
Creator's defaults: white text, Background ON (black, 20%), speed capped at 200 px/s, text size shown
in real pixels, and "plate" dropped from the label.

The important find is underneath those: `ScoreboardStyle` is a record STRUCT, and `new
ScoreboardStyle()` zero-initialises — it does NOT run the constructor's parameter defaults. So the
shipped strip had null text colour, NO background and zero opacity, whatever the source said. That is
why the Background checkbox and the opacity slider looked like they had no code behind them: the
renderer was faithfully drawing the zeros it was handed.

An explicit parameterless constructor now carries the real defaults, and the renderer test asserts
them (white / on / black / 0.20 / max speed 200) so this class of silent-zero cannot return.

Readouts fixed too: the opacity and text-size setters never raised their own property, so the numbers
never moved while the render did. Text size now reads "24 px" instead of a bare slider value, and
snaps to whole pixels.
2026-10-30 15:24:14 -07:00
gramps 28525c90aa feat(scoreboard): scroll speed is a property (20-400 px/s, persisted)
The marquee now has the dial it needs. `ScoreboardPresenter.PixelsPerSecond` is the single owner of
the range (20-400, default 120), the layer's properties expose it with a live px/s readout, and the
value is persisted as Scoreboard.ScrollSpeed because it is a READABILITY choice — re-dialling it
every launch would be a tax on the creator who needs the strip to be readable.

Advance() still takes an optional override so tests can drive exact distances.

Guard: the presenter test proves speed is real (same elapsed time, twice the speed, different
window) — not a value that is stored and ignored — and the panel test pins the control, its range
and the persistence key.
2026-10-30 15:19:28 -07:00