spec: Bad Apple 30s vector-polygon demo

This commit is contained in:
arti committed 2026-10-31 23:59:46 +02:00
1 parent 6febcf00f4
commit bc3b20113c
1 file changed
+601
@@ -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.