W2 lane S4 proof: per-look levels, blackout that keeps the state, haze hold
Branch w2-server-playbacks (worktree ~/trc-lighting/wt-server-playbacks), cut from two-screen 26cdd93.
Mac dry run on port 7083 (--dry-run --ui-standalone --camera-from audit-2026-10-10/.camera-scratch); /api/dmx/nodes never called.
Frame values read from GET /api/dmx/frame?u=N&from=1&to=512 on universes 1, 8 and 9 (a dry run renders on request). dotnet build -c Release: 0 errors, 0 CS warnings.
1. Per-look levels, HTP
Look A = 786 at level 100, look B = 812 at level 30, then 15 (/api/look 786, /api/scene/812/on, POST /api/look/812/level {v}).
Each look's solo dims were read first; expected dim = max(A, B × level) for every fixture either look touches (30 fixtures).
| Check | Result |
|---|---|
| level 30: worst |dim − expected| × 255 over 30 fixtures | 0 |
| level 15: same | 0 |
| par 2201 (A 0.2, B 1.0), level 30 | dim 0.3, frame dimmer 76 (B wins) |
| par 2201, level 15 | dim 0.2, frame dimmer 51 (A wins) |
| par 2206 (A only, 0.2) | 0.2, frame 51 at both levels |
GET /api/look/levels and /api/status.levels |
[{id:786,level:100},{id:812,level:15}] |
| level on a look that is not live (799) | 409 look 799 is not live |
| level without v | 400 |
| hard blackout | levels [] (reset) |
SSE levels events seen |
2 (one per change) |
2. Blackout that keeps the state (POST /api/blackout {})
State before: 786 @100 + 812 @30, haze cycle on (hazer 921 at u1 ch 509/510 = 128/26). "Light channels" = every Dimmer channel, plus the colour channels of fixtures without a real dimmer and of the Colorband bars (937 channels over u1/u8/u9, 50 lit before).
| Check | Result |
|---|---|
| reply | mode: keep |
| highest light channel during blackout | 0 (0 nonzero of 937) |
| other channels changed | 36, all Colorband second/third cell triplets (15 → 0), which carry the bars' level. Pan, tilt, colour on dimmer fixtures: unchanged |
| hazer during blackout (exempt) | 128 / 26, unchanged |
/api/rig/state live[] |
[786, 812] before and during |
/api/dmx/state dim (targets) |
2201 = 0.3, 2206 = 0.2, unchanged |
/api/status.blackout.on, /api/rig/state.blackout.on |
true, true |
| undo ring (depth + every label/time) before vs after blackout | identical |
release (POST /api/blackout/release {}): channels differing from before the blackout, all 3 universes, hazer excluded |
0 (max diff 0/255) |
| undo ring before vs after release | identical |
blackout with fade:1, par 2201 every 100 ms |
76 68 60 52 44 36 28 20 12 4 0 0 |
release with fade:1 |
0 8 16 24 33 41 49 58 66 74 76 76 |
| POST /api/undo while blacked out | 200, stage stays dark (0), blackout.on true |
SSE blackout events |
8; SSE rig carried blackout.on true and false |
3. A hit under the blackout
POST /api/hit/selpulse/press {fixtures:[2201,2202]} (ms-only steps 140/460) during the kept blackout, sampled every 50 ms for 1.3 s:
| Check | Result |
|---|---|
| 2201 target dim seen | 0.25 and 1 (the hit runs) |
| 2201 frame dimmer seen | 0 only |
after POST /api/hit/release |
dims 0.3 / 0.3, equal to before the hit |
| after blackout release | 0 channels differ from before the blackout |
4. Haze hold
| Check | Result |
|---|---|
default GET /api/haze |
exempt: true, hazers: [921] (kind hazer in rig.json), written to data/haze.json |
{exempt:false} then blackout |
hazer 0 / 0 |
{exempt:true}, master 50 |
hazer 128 / 26; par 2201 76 → 38 |
{exempt:true}, blackout |
hazer 128 / 26 |
SSE haze events |
2 |
5. Hard blackout ({hard:true}) as before
live [], master 100, haze off, blackout.on false, a new undo entry labelled blackout. After it 135 u1 channels read 255; 134 equal the baseline capture data/dmx-baseline-u1.txt (blinder dimmers idle at 255 with emitters off), the other is a seeded/overridden baseline channel. The render path at blackout factor 1 is the old one.
6. Look fades and HTP stacking
| Check | par 2201 every 100 ms |
|---|---|
/api/look 812 fade 1 from dark |
0 28 57 84 111 139 166 192 219 245 255 255 |
| 812 stacked on 786 (51) with fade 1 | 51 73 94 117 137 160 181 203 226 247 255 255 |
| re-GO of 786 (0.2) under 812 (1.0) with fade 1 | 255 throughout, no dip |
7. Regress harness
tools/regress-v2.sh 7083 unchanged: RED on mood-solo, blackout, undo. It uses POST /api/blackout as its "clear the stage" step and asserts the old meaning. The same harness with {"hard":true} on its five blackout posts (lines 122, 145, 165, 168, 183): GREEN, 13 passed (stack, mood-solo, hit, blackout, undo, timeline, userscene included).
8. Follow-up after the rebase onto 99f2324
- Blinders 6101 / 6107: with Group 6 at 80 they read Dimmer 204 and WW/CW 255. The kept blackout reads Dimmer 0, WW/CW 0. Release reads 204 / 255 again. After a HARD blackout, or at idle, Dimmer reads 255 and WW/CW read 0. That 255 is the console baseline capture (
dmx-baseline-u1.txtch 487 and 495 = 255), and the lamp channels are dark. The hard path is unchanged from 26cdd93. - Hit and undo: each hit press adds a ring entry (selflash over a live look: depth 3 → 4, top
hit selflash). When a hit was released and the stage is back exactly as before, the next press or look replaces that entry instead of adding one. Depth stays the same, and the top label changes fromhit selflashtohit blind. That is the existing PushUndo rule "the last step changed nothing: this one takes its place", because undoing a released hit would restore the same state. It is not a bug, so it is left as is. - Frame on a fresh dry run: the first
/api/dmx/framecall after start answered 200. A render-on-miss fallback was added anyway: an engine that is loaded but not streaming renders once rather than answering 404. - Regress on the rebased build:
tools/regress-v2.sh 7083(14 checks) is RED on mood-solo, blackout and undo, for the same reason as above. The copy with{"hard":true}on its blackout posts is GREEN, 14 passed.
9. Contract flip (conductor, after risk 6): {} is HARD, {keep:true} keeps the state
POST /api/blackoutwith no body and with{}both replymode: hard. The stage is cleared (live[]), and the undo top isblackout.{keep:true, hard:true}is hard as well.- Every section 2 to 4 proof was re-run with
{keep:true}. The kept blackout read 0 on all 937 light channels and live stayed[786, 812]. The hazer held 128/26, and the undo ring was identical through blackout and release. After release 0 channels differed. Under the blackout the selpulse target ran (0.25 / 0.3 / 1) while the frame read 0. Haze: exempt=false gives 0/0; exempt=true gives 128/26 under master 50 and under the blackout. - Keep with
fade:1, par 2201 every 100 ms: 255 229 201 175 148 121 94 68 40 14 0 0. Release withfade:1: 0 26 53 79 105 132 158 186 212 238 255 255. tools/regress-v2.sh 7083, unedited, on the rebased build: GREEN: 14 passed. Sections 7 and 8 above describe the earlier default and are superseded.