diff --git a/docs/superpowers/specs/2026-10-31-bad-apple-vector-design.md b/docs/superpowers/specs/2026-10-31-bad-apple-vector-design.md new file mode 100644 index 0000000..22f1413 --- /dev/null +++ b/docs/superpowers/specs/2026-10-31-bad-apple-vector-design.md @@ -0,0 +1,601 @@ +# Bad Apple, 30 s, vector polygons — design + +Date: 2026-10-31 +Status: **rev 1** — first draft, awaiting approval. +Author: this session. + +This is a **separate prototype** from +[`2026-10-31-badapple-60p-design.md`](2026-10-31-badapple-60p-design.md) +(the raw-tile, full-length, streamed design in `harness/badapple/`). It does +not supersede it and does not change the existing harness. It answers a +different brief: a **standalone, first-30-seconds, polygon-styled** demo in a +new top-level `bad-apple/` folder. + +Decisions marked **human:** were given in this session. Decisions marked +**call:** are the builder's proposals, not yet agreed. + +--- + +## 1. Intent + +Play the first **30 seconds** of +`/tmp/opencode/badapple-real/hd60.mp4` on a VT1200, **full screen** at +**1280×1024**, as **flat black-and-white polygon art** in the spirit of +*Another World* — not a dithered bitmap and not a 32×32 tile dictionary. + +The picture is quantised to straight-edged polygons, then to a coarse logical +grid, then to rectangles that the TMS34010's `FILL` engine can paint fast. +After a small bootstrap is delivered over `rdebug`, the payload takes over the +machine and receives the movie data over Ethernet; playback is then entirely +local from the VT1200's 4 MB of main RAM. Recovery is a hardware restart. + +## 2. What the human partner decided + +* **Source:** `/tmp/opencode/badapple-real/hd60.mp4`, first **30 s**. +* **Frame:** fill the whole 1280×1024 panel; no letterboxed 4:3. +* **Look:** black-and-white **polygons**, similar to *Another World*; drop + dithered sections and threshold/simplify them into flat shapes. +* **Motion:** **60 fps is the target**; complex scenes may drop frames. +* **Ownership:** after the initial load, the program may take the whole 4 MB + of RAM; the OS may die; recovery is a power cycle. +* **Delivery:** `rdebug` is the bootstrap path; the network loader in + `harness/badapple/` is fair game to make the big load fast. +* **Location:** a new **top-level `bad-apple/`** folder. + +## 3. Success criteria + +1. `bad-apple/` reproduces the whole pipeline from the source video with one + command per stage, and documents the data budget it measured. +2. In MAME, the first 30 s plays full-screen, recognisably Bad Apple, with + clean straight-edged shapes and no corrupt or stale frames. Typical frames + run at 60 fps; heavy frames may drop to 30 fps or hold for one refresh. + The baseline repaints dirty tiles incrementally; a brief wipe on a large + change is accepted for this prototype (§8.5). +3. The movie data plus code fits in the VT1200's 4 MB of main RAM, excluding + the 256 KB framebuffer in video RAM. +4. On the real unit, the same payload is delivered by `rdebug` plus the + Ethernet loader and plays without a host attached. +5. Existing `harness/` behaviour and the MAME no-env regression baseline are + untouched. The ROM stays byte-identical. + +## 4. Non-goals + +* The full 3:39 clip (the 30 s limit is what makes local RAM playback possible). +* Audio (the VT1200 has no audio path in this harness). +* Grey, colour, dither, or anti-aliased pixels; this is 1 bpp. +* Pixel-exact reproduction of the source. The target is a recognisable, + clean vector reinterpretation. +* Coexistence with the resident OS after takeover. +* A general-purpose video player; this is a demo with a fixed 30 s stream. + +## 5. Constraints and measured facts + +### 5.1 Hardware + +| item | value | source | +|---|---|---| +| CPU | TMS34010FNL-50, 50 MHz, 1 state = 160 ns | `harness/mame/vt1200.cpp` | +| panel | 1280×1024 visible, 2048-bit (256-byte) row pitch, 72 Hz | `RENDER_REPORT.md`, driver | +| main RAM | 4 MiB at bit address 0 | `vt1200.cpp` address map | +| video RAM | separate, at bit `0x04000000` | `vt1200.cpp` | +| `FILL L` on the unit | **0.40 µs per destination word** (16 pixels), plus ~2.6–3.3 µs per call | `2026-10-31-badapple-60p-design.md` §5 | +| full 1280×1024 repaint | ~82k words ≈ **33 ms** | derived from the unit rate | +| 60 fps budget | 16.7 ms ≈ **41k words** ≈ **667k pixels** | derived | +| 72 Hz refresh | 13.9 ms per refresh | driver | + +The 72 Hz display and 60 fps source have no common period; the player must +pace a 60 Hz timeline against a 72 Hz refresh (§8.6). + +### 5.2 Source + +| item | value | +|---|---| +| video | 960×720, 60 fps, h264, 219.5 s, 13,167 frames | +| audio | AAC stereo, ignored | +| first 30 s | 1,800 frames | +| black coverage | mean 55 %, p75 80 %, p95 98 % (1280×1024 threshold) | + +The source is already high-contrast black-on-white; it is the ideal input for +a threshold-and-trace pipeline. + +### 5.3 The measured data budget + +A host-side pass over the first 30 s at a **640×512 logical grid** (each +logical pixel is a 2×2 screen block), hard threshold, no vectorisation yet, +measured the tile-delta format in §8: + +| quantity | mean | p50 | p95 | max | +|---|---|---|---|---| +| dirty 32×32 logical tiles per frame | 47.4 | 49 | 79 | 295 | +| rectangles per frame | 437 | 451 | 820 | 1,381 | +| bytes per frame (5 B tile header + 3 B/rect) | 1,548 | 1,598 | 2,855 | 5,618 | + +**Total for 1,800 frames: 2.79 MB.** The data area is +`0x010000..0x317FFF` = **3.03 MB**, so this leaves ~0.24 MB of headroom. A +scene cut touches up to 295 tiles, still only 5.6 KB and one slow repaint. + +The vectorisation step will change these numbers; the first milestone +re-measures them. The gate is **≤ 3.0 MB** and a p95 paint cost within the +refresh budget. If vectorisation pushes past 3.0 MB, the fallback is stronger +simplification or a 4×4 logical grid, not a shorter clip without asking. + +**Why 3.03 MB and not 3.5 MB:** the movie data starts at byte `0x010000` and +must stop below the payload's `rdebug` origin at bit `0x018C0000` = byte +`0x318000`. `0x318000 - 0x010000 = 0x308000` = 3.03 MB. Moving the payload +origin would cost the one free block the real loader is known to accept, so +the design keeps the origin and caps the data instead. + +## 6. Approach + +### 6.1 Why not store rectangles for every frame? + +A full-frame rectangle list is simple but forces a full repaint: at the unit's +0.40 µs/word, 1280×1024 costs ~33 ms, i.e. two 60 fps frames. The design must +paint **only what changed**. The measured dirty-tile format does that: typical +frames touch ~47 tiles (≈192k screen pixels ≈ 4.8 ms of fill), and the data +still fits in 4 MB. + +### 6.2 Why a 640×512 logical grid? + +At 1×1 (1280×1024) the same delta format measures ~4× the data (≈10.8 MB) and +does not fit. At 2×2 it fits with room to spare, and 640×512 is still four +times the resolution of *Another World*'s 320×200. The 2×2 quantisation is +also part of the requested look: it turns pixel-level fuzz into flat blocks +with straight polygon edges. + +**call:** 640×512 logical, 2×2 screen blocks. If the first milestone shows +the data is unexpectedly small, 1×1 can be re-measured as a quality mode; it +is not the baseline. + +### 6.3 Why polygons then rectangles? + +The polygon step is the **look**: threshold, despeckle, trace contours, simplify +to straight segments, and fill the result. That produces the flat, angular +*Another World* silhouette. The runtime does not rasterise polygons; the +host rasterises the simplified polygons back to the 640×512 grid and decomposes +each dirty tile into rectangles. Rectangles are what `FILL L` paints fastest +and most simply. The player is a rectangle decoder, not a polygon engine. + +### 6.4 Why not `LINE`? + +`LINE` costs 5 states/pixel (≈800 ns/pixel), while `FILL` costs ~25 ns/pixel +on the unit. Filling a polygon with `LINE` outlines would be 30× slower and +would not fill the interior. `LINE` is reserved for an optional debug view +that draws polygon outlines; the animation uses `FILL L` only. The request +said "FILL and/or LINE"; this is the FILL branch, for a measured reason. + +## 7. Pipeline + +``` +hd60.mp4 + │ ffmpeg: first 30 s, 60 fps, 640×512 gray + ▼ +gray frames ──► temporal median (3) ──► 3×3 blur ──► threshold + │ + ▼ +binary frames ──► potrace -a 0 (straight polygons) ──► RDP simplify + │ + ▼ +simplified polygons ──► rasterise to 640×512 binary + │ + ▼ +tile diff (32×32 logical) ──► per-tile background + rectangles + │ + ▼ +movie.bin + index + manifest.json + │ + ▼ +player payload (TMS34010 C + FILL asm) +``` + +### 7.1 Extract and stylise + +* `ffmpeg -t 30 -vf fps=60,scale=640:512:flags=lanczos,format=gray`. +* **Temporal median over 3 frames** before threshold. This is the main defence + against dither flicker and per-frame contour boiling; it is cheap on the + host and does not cost the player anything. +* **3×3 box/Gaussian blur** then threshold at 127. This removes halftone + patterns and turns them into flat regions, as requested. +* **Despeckle:** drop connected components below ~8 logical pixels. +* **Trace:** `potrace -b geojson -a 0 -t 4 -u 1`, which emits straight-line + polygons only (`-a 0` disables curves). Parse the GeoJSON rings. +* **Simplify:** Ramer–Douglas–Peucker on each ring, epsilon 1.5 logical pixels + (tunable), minimum segment 2 px. This is the "straight lines" step. +* **Rasterise:** even-odd scanline fill of the simplified rings back to + 640×512. The result is the frame the player will actually show. + +All parameters live in one `pipeline/params.json` so the look can be tuned +without touching code. A `preview` command writes side-by-side PNGs of source, +thresholded, traced and final frames. + +### 7.2 Tile-delta format + +The logical frame is divided into **32×32 logical tiles** (64×64 screen +pixels): 20 × 16 = **320 tiles**, row-major. + +For each frame, compare with the previous frame. For every tile with any +changed pixel: + +* choose the tile's **background colour** (black or white) — the majority + colour in that tile; +* decompose the **minority colour** into rectangles (merge identical + horizontal runs vertically); +* emit the tile index, the background bit, the rectangle count, and the + rectangles. + +The player paints a dirty tile by: + +1. `FILL` the whole 64×64 screen tile with the background colour (one call); +2. `FILL` each rectangle with the opposite colour. + +This reconstructs the tile exactly, including erasing old ink. Unchanged +tiles are not touched. The background choice keeps mostly-black tiles cheap +(one fill, few white holes). + +**Record layout (little-endian):** + +``` +header: + magic "BAPL" 4 + version u16 + logical_width u16 (640) + logical_height u16 (512) + scale u16 (2) + tile_logical u16 (32) + tiles_x, tiles_y u16 (20, 16) + frame_count u32 + index_offset u32 + data_offset u32 + source_fps u16 (60) + display_hz u16 (72) + +index[frame_count]: + offset u32 (byte offset of the record) + ticks u8 (1 = 1/60 s, 2 = held for 2/60 s, ...) + flags u8 (bit 0: scene cut / full repaint) + +record: + dirty_tile_count u16 + for each dirty tile: + tile_index u16 + bg_and_count u16 (bit 15 = background colour, bits 0-14 = rect count) + rects[count]: + x0:5 y0:5 w:5 h:5 packed into 3 bytes + (logical tile coordinates; w,h are stored as w-1,h-1 so 1..32 fits in 5 bits) +``` + +Rectangles use the minority colour; the tile background is the majority. A +rect count of 0 is legal (a solid tile). The count is 15 bits, so the +theoretical maximum of 512 horizontal runs in a 32×32 tile cannot overflow; +the decoder rejects a count larger than the remaining record bytes. + +**Sizing:** 5 bytes of tile header + 3 bytes per rect. The measured mean is +1.55 KB/frame, total 2.79 MB for 1,800 frames. A scene-cut record with 295 +tiles and ~1,300 rects is ~5.6 KB, still one record. + +### 7.3 Frame-rate adaptation + +The encoder estimates each record's cost from the tile and rectangle areas +(at 0.40 µs per 16-pixel word), the per-`FILL` call overhead (~3 µs), and the +C decoder's per-rect work. The calibration constants come from milestone 3; +the initial values are 0.40 µs/word, 3 µs/call and 1 µs/rect. The gate is +**13.9 ms** — one 72 Hz refresh — not the 16.7 ms nominal 60 fps period, +because a record that misses 13.9 ms loses a whole refresh on the 72 Hz panel. + +If a record exceeds the gate: + +* first try stronger simplification for that frame (larger RDP epsilon, + larger minimum feature); +* if it still exceeds the gate, **hold the previous frame for an extra + 1/60 s** and compute the next delta from the held frame. The index `ticks` + field records the hold. + +This is build-time frame dropping, so the delta chain stays exact. The player +never skips a record at runtime: every record is a delta from the one before, +so skipping one would leave its tiles stale. If the real unit is slower than +the encoder's estimate, the movie **stretches** — every frame is still shown, +in order, with the correct picture — rather than dropping or corrupting +frames. That is the safe failure mode; the encoder is re-tuned from the unit's +measured cost if the stretch is noticeable. + +### 7.4 Scene cuts + +A large frame-to-frame difference is a cut, not motion. The encoder marks the +record `flags & 1`, and emits every tile as dirty. The player repaints the +screen tile by tile. This costs one slow frame (~30–40 ms) and may drop the +next refresh; that is the documented price of a cut. Future work could split a +cut over two frames if it looks bad. + +## 8. Player + +### 8.1 Memory layout + +| region | byte address | use | +|---|---|---| +| `0x000000`–`0x00FFFF` | 64 KB | low staging / scratch; free after load | +| `0x010000`–`0x317FFF` | 3.03 MB | movie data (`movie.bin`) | +| `0x318000`–`0x3FFFFF` | 0.9 MB | payload code, LANCE block, read-only tables, stack | +| VRAM `0x04000000` (bit) | 256 KB | single framebuffer (separate) | + +`0x018C0000` is a **bit** address = byte `0x318000`, the low end of the high +code block. The existing loader's origin is reused so `rdebug` needs no new +address. The movie data starts at byte `0x010000` and is loaded over the +network after takeover; it overwrites the OS, which is dead by then. + +The LANCE block (init block, rings, RX buffers) lives in the high code block, +following `harness/udpstream/vidstream.c`, which places its RX buffers near +byte `0x3BC000` and is documented as having run on the unit. The 64 KB low +reservation is a **fallback staging area** if the real chip turns out to +truncate a high RX buffer address; the loader then receives low and copies +up. Milestone 5 settles this before the full 30 s run. It is a loader change, +not a player change. + +### 8.2 Takeover + +The payload is entered from `rdebug`. It: + +1. publishes a marker word; +2. executes `DINT` once, for good, and stops the OS's LANCE use; +3. takes the LANCE over with the verified init/ring sequence and receives + `movie.bin` into `0x010000` (the OS is already dead; the display controller + keeps scanning out because it was configured before takeover); +4. verifies the movie header and a whole-file checksum; +5. reads the display registers the OS programmed (`DPYCTL`, `DPYSTRT`, + `DPYINT`, pitch) and publishes them for diagnosis; it inherits the mode + rather than hardcoding one; +6. clears the screen to white with one full-screen `FILL`; +7. enters the playback loop. + +Interrupts stay disabled. The OS's timer and DUART ISRs are never entered +again. The LANCE is stopped after the load; the player no longer needs it. +**The OS dies before the first byte lands in low RAM** — that is what makes +overwriting its heap and ISRs safe. The earlier wording, "receive while the +OS is alive, then DINT", was wrong; it would crash mid-load. + +**Recovery:** a hang after step 2 is a power cycle. The README says so in the +first screenful. + +### 8.3 Loader + +Two paths, same payload: + +* **MAME / development:** a Lua script writes `movie.bin` into MAME's RAM at + byte `0x010000` and the payload at `0x318000`, then jumps. Slow but fine + for 5 s slices. +* **Real unit / full 30 s:** `rdebug` writes a small bootstrap into the high + block. The bootstrap disables interrupts, takes the LANCE over with the + proven register/ring sequence from `harness/udpstream` (copied, not edited), + receives `movie.bin` in 1,450-byte datagrams into `0x010000`, ACKs every + window, and jumps to the player entry. The host sender is a small Python + tool using the same raw-Ethernet framing as `harness/badapple` / + `harness/udpstream`. + + The baseline places the RX buffers in the high code block, as + `vidstream.c` does. If the real chip truncates a high buffer address, the + fallback receives into a 32 KB ring below 64 KB and copies each datagram up + with `MOVE`/word copy. That copy rate is measured in milestone 5; it is a + loader-only fallback, not the baseline. + +The protocol is deliberately simple: `u32 offset`, `u16 length`, `u16 seq`, +`u16 crc`, then data. The receiver ACKs the highest contiguous offset every +32 datagrams; the sender retransmits from the first gap. A final whole-file +CRC-32 is checked before the jump. If the load fails, the payload paints an +error block and halts rather than jumping into a partial movie. + +### 8.4 Rendering + +All drawing goes through one assembly primitive: + +```asm +; fill_rect(x, y, w, h, color) +; x,y,w,h in screen pixels (already scaled by 2) +; color 0 = black, 0xFFFF = white +``` + +It sets `DADDR`, `DYDX`, `DPTCH=0x800`, `PSIZE=1`, `PMASK=0`, `CONTROL` +replace, and `COLOR1`, then executes `FILL L`. Constants are programmed once +per run; only `DADDR`, `DYDX` and `COLOR1` change per call, and `FILL` destroys +`DADDR`/`DYDX`, so those two are reloaded each time. This is the same +implied-operand discipline as `harness/udpstream/vram.asm`, which is already +verified on the unit. + +The C player converts each packed tile rect to screen coordinates: + +``` +sx = (tile_x*32 + x0) * 2 +sy = (tile_y*32 + y0) * 2 +sw = (w + 1) * 2 // stored as w-1 +sh = (h + 1) * 2 // stored as h-1 +``` + +and calls `fill_rect`. A dirty tile is one background fill plus its rects. + +**Hoisting:** `PSIZE`, `PMASK`, `CONTROL`, `DPTCH` are set once at startup. +`CONVDP`/`CONVSP` are not needed for `FILL L` (linear destination), but are +set to `0x14` for consistency with the verified primitive. + +### 8.5 Frame buffer and presentation + +**Baseline: one framebuffer, incremental dirty-tile updates.** Drawing a +record takes ~2–10 ms for a typical frame, a fraction of the 13.9 ms refresh. +Updates are visible as they land; the TMS34010's VRAM is dual-ported, so the +display does not stall the CPU. At 60 fps the eye usually reads this as +motion, but a large change can show a brief top-to-bottom wipe. + +**Tear-free is a separate mode, not free.** It needs either: + +* **two framebuffers and a vblank flip.** The real panel uses a 256-byte row + stride, so one 1280×1024 buffer is **256 KB**, not 160 KB; two are 512 KB. + Whether the unit has that much VRAM is not established. It also doubles the + fill work unless each delta is applied to both buffers, which costs the + p95 frame its refresh. +* **beam chasing.** Draw each dirty tile only after the scanout has passed it, + so the new content appears on the next frame. This is tear-free with one + buffer but needs `VCOUNT` scheduling and leaves one frame of latency. + +Neither is in the baseline. **human:** incremental painting is acceptable for +the prototype, so the baseline is one framebuffer and dirty-tile updates. +Milestone 2 still measures VRAM capacity for the record, but a tear-free mode +is not built unless the incremental look fails on the unit. + +### 8.6 Pacing: 60 fps content on a 72 Hz panel + +The player polls the TMS34010 display interrupt pending bit (`INTPEND` DI) at +each vertical blank; interrupts remain disabled, so this is a poll, not an +ISR. A 16.16 fixed-point accumulator advances the 60 Hz timeline: + +``` +each vsync: now += 60/72 + if now >= next_frame_time: + draw next record // never skip a record + next_frame_time += record.ticks + if now - next_frame_time > 1.0: // fell behind + next_frame_time = now // slow down, do not skip +``` + +If a record's paint overruns a vsync, the movie **stretches**: the next record +is drawn as soon as the CPU is free, and the timeline is re-based to `now` +rather than accumulating debt. Records are never skipped, because each one is +a delta from the previous picture. This produces the correct average rate on +a unit that keeps up, and slow motion — not corruption — on one that does +not. The 5:6 cadence (five source frames per six refreshes) is visible only as +the usual 60-on-72 judder; it is a hardware property, not a decoder stall. + +If `INTPEND` polling proves unreliable in MAME, the fallback is a DUART counter +programmed as a 60 Hz timebase, polled with interrupts off. That is a builder +detail; the timeline arithmetic is unchanged. + +## 9. Verification + +### 9.1 Host pipeline tests + +* Frame count, dimensions and LSB-first bit order (the harness's long-standing + trap: an MSB-first packer mirrors every eight pixels). +* Polygon round-trip: rasterised simplified polygons differ from the + thresholded source only in the intended simplified regions; report PSNR and + changed-pixel count, not a pass/fail on aesthetics. +* Tile-delta round-trip: applying every record to a blank 640×512 canvas + reproduces the encoded frame **bit for bit**. +* Budget: total `movie.bin` ≤ 3.0 MB (the hard cap is 3.03 MB), p95 paint cost + ≤ 13.9 ms including the C decoder and `FILL` call overhead. +* Manifest records the actual numbers (data size, dirty-tile distribution, + paint-cost distribution, dropped frames). + +### 9.2 MAME pixel verdict + +The player publishes a marker page in high RAM: + +``` +frame_index, ticks_played, last_record_bytes, paint_loops, data_error +``` + +`VT1200_DUMP_RAM` and `VT1200_DUMP_VRAM` at the same MAME frame give both the +marker and the picture. A checker reads the marker, reconstructs the expected +logical frame from the encoder's records, downsamples the 2×2 VRAM to +640×512, and compares **exactly**. A single wrong bit is a bug, not a PSNR. + +Sample frames: 0, 60, 300, 600, 900, 1200, 1500, 1799, plus every scene cut. +Also run a 5 s slice first; do not debug the full 30 s before one frame is +exact. + +### 9.3 MAME motion verdict + +Run with `-window`, throttle at the emulated 72 Hz, and capture with +`x11grab` as the existing `run-gui.sh` does. Check: + +* no half-painted or stale regions after a cut; +* no accumulating offset (a delta applied twice or skipped); +* the 30 s ends on the expected last frame; +* the paint-cost histogram matches the encoder's estimate. + +MAME cannot prove the real unit's 60 fps: its TMS34010 interpreter has a +different instruction/cycle mix from silicon. It proves correctness and gives a +relative cost. The unit is the performance verdict. + +### 9.4 Real unit + +The existing `harness/badapple/demo.sh` boot gate (wait for the OS's ICMP +reply, then load over rdebug) is the model. The new `bad-apple/load.sh`: + +1. waits for the unit's stack; +2. `rdebug` writes the bootstrap; +3. jumps; +4. sends `movie.bin` over the tap/interface; +5. waits for the bootstrap's final ACK; +6. observes the screen (there is no back-channel after the jump). + +Record a video of the 30 s run and note the actual dropped frames. Recovery is +a power cycle; say so before running. + +## 10. Milestones + +1. **Measure the stylised stream (host only).** Extract, vectorise, tile-diff, + and report the numbers. Gate: ≤3.0 MB, p95 paint cost within 13.9 ms. + Produce a preview PNG/MP4 of the vectorised look for approval. +2. **One exact frame in MAME.** Build the payload with a single hardcoded + record, paint it, dump VRAM, compare bit-exact. This proves `FILL` geometry, + the 2×2 scaling, the test harness and the marker page. +3. **Five-second player.** Pack a 5 s slice, play it, compare every sampled + frame, and measure the paint-cost histogram. No loader yet; use the Lua + path. +4. **Full 30 s.** Pack 1,800 frames, play in MAME, tune simplification and + frame drops until the data and motion gates pass. +5. **Delivery.** Build the rdebug bootstrap + LANCE loader, run the same 30 s + in MAME over the tap, then on the real unit. +6. **Report.** `bad-apple/README.md` and a short report with the measured data + size, paint-cost distribution, dropped frames, unit behaviour and the + remaining gaps. + +## 11. Risks + +| risk | why it matters | mitigation | +|---|---|---| +| Potrace/RDP temporal jitter ("boiling") | looks like a glitch even when every frame is exact | 3-frame temporal median, spatial blur, minimum feature size; preview at 60 fps before committing to the full 30 s | +| Vectorised data exceeds the 3.03 MB data area | no room for the 30 s | measured 2.79 MB before vectorisation; gate at 3.0 MB; fallback is 4×4 blocks or stronger simplification, not a shorter clip without asking | +| Dirty tiles exceed the paint budget | frames hold or stretch | build-time `ticks` holds, adaptive simplification, per-frame cost estimate; report the distribution | +| Incremental updates show a wipe | accepted for the prototype, but may look worse than expected on the unit | baseline is incremental by human decision; if it reads as a glitch, milestone 2's VRAM measurement feeds a double-buffered/beam-chasing variant | +| The real unit is slower than the model | the movie stretches rather than drops | never skip a record; re-base the timeline; re-encode with stronger simplification from the measured unit cost | +| LANCE loader loses datagrams | partial movie, dead machine | offsets + CRC + ACK/retransmit; whole-file CRC before jump; error block and halt instead of jumping | +| Real C-LANCE high buffer addresses | loader cannot receive above 64 KB | baseline reuses `vidstream.c`'s high block, documented as run on the unit; fallback is a low staging ring plus copy, measured in milestone 5 | +| 60 fps on 72 Hz | unavoidable judder | fixed-point 5:6 timeline; document it; do not call it a decoder bug | +| OS video mode not configured | no picture after takeover | inherit and publish the OS registers; boot path is "wait for the OS, then take over" | +| One demo per boot | a hang costs a power cycle | verify the image/checksum before jumping; README warning; no automatic retry after takeover | +| MAME timing is not silicon | false confidence in 60 fps | correctness in MAME, performance measured on the unit; report both | + +## 12. Left to the builder + +* Exact RDP epsilon, minimum feature size, and temporal-filter width: tune by + preview, then freeze in `params.json`. +* Whether the tile background choice is per-tile majority or a cost-minimising + search; both are host-side and cheap. +* The precise `CONTROL` mask and hoisting split for `FILL L`; copy the verified + `vram.asm` discipline and measure once on the unit. +* Whether a short slice uses the Lua/debugger load or the same bootstrap; the + full 30 s uses raw LANCE because the OS is dead before the data lands. +* The marker page layout and the exact MAME dump procedure. +* Whether the first 30 s starts at t=0 (assumed) or at a musically chosen + offset; t=0 is the default. +* Whether a stop/pause key is worth keeping. It would require one live ISR or + polling the keyboard DUART; the default is no input, as the OS is dead. + +## 13. Decisions from this session + +* **human:** full screen, 1280×1024, first 30 s. +* **human:** as close to `hd60.mp4` as the medium allows. +* **human:** flat black-and-white polygons in the *Another World* spirit; + drop dither and simplify. +* **human:** 60 fps target, drops allowed for complex scenes. +* **human:** the program may take the whole 4 MB; the OS may die; recovery is a + hardware restart. +* **human:** bootstrap over `rdebug`; the existing network loader may be used + to speed the big load. +* **human:** new top-level `bad-apple/` folder. +* **human:** incremental dirty-tile painting is acceptable; a brief wipe on a + large change is not a failure for this prototype. +* **human:** stretch the 4:3 source to the 5:4 panel; keep all content. +* **call:** 640×512 logical grid (2×2 blocks), 32×32 logical tiles. +* **call:** `FILL`-only runtime; `LINE` reserved for debug outlines. +* **call:** 60 Hz timeline on a 72 Hz panel, with build-time `ticks` holds. +* **call:** 30 s from t=0. +* **call:** a `--fit crop` switch remains available for comparison, but stretch + is the default.