Files
vt1200/harness/badapple/README.md
T
arti 1bb3643144 feat(badapple): measure the 60 fps wire budget before changing any format
Spec 11's first task: decide from the clip, not from assumptions, whether the
raw-tile scheme (no dictionary, tag + bitmap per changed tile) fits a 60 fps
frame.  measure_wire.py extracts at 1280x960/60 fps with the hard threshold the
design requires, diffs each frame on the 40x30 picture grid, and totals the
payload a TILES_DELTA would carry:

    4 + 3*count + 128*m

Full clip, 13,167 frames: mean 16,049 B/frame, p50 14,755, p95 33,353, max
51,523; 22.5% of frames exceed the 21 KB one-frame budget; 87% of changed tiles
are neither solid black nor white, so they carry a 128-byte bitmap (the design
assumed a third); mean 140 changed tiles/frame, max 1,173 at a cut.  Link duty
at 60 fps would be 72% of the measured 1.27 MB/s.

Two consequences for the design, recorded here rather than discovered later:
the raw-tile scheme is borderline rather than comfortable, and the tile churn at
60 fps (140) is nearly the same as at 15 fps (165) -- motion between adjacent
60 fps frames is mostly sub-tile, so sending faster does not divide the wire by
four.
2026-10-31 22:36:34 +02:00

218 lines
9.8 KiB
Markdown

# Bad Apple on a VT1200
The demo end to end: a payload is **delivered over the network** into a running
terminal, and the picture is **streamed over the network** to it, which paints
it from a 32 KB tile dictionary plus per-frame deltas. Nothing here uses a
debugger — that path (`harness/mame/vidstream.lua`) exists to *test* the payload
against MAME's debugger API and is not how anything reaches a machine.
```
harness/badapple/
├── README.md this file (setup, data preparation, run)
├── demo.sh one command: wait for the OS, load, send
├── rdebug_load.py payload image → terminal RAM over rdebug (TCP 771)
├── clip_from_video.py video → raw 1-bpp frames for make_tiles.py
├── measure_wire.py what the 60 fps raw-tile wire form would cost per frame
└── tests/ unit tests for the two Python tools
```
## How the delivery works
| step | transport | what it does |
|---|---|---|
| boot | — | `harness/mame/run.sh net` (MAME) or the real unit powering up |
| readiness | ICMP | `demo.sh` waits for the terminal's own stack to answer |
| payload | **rdebug, TCP 771** | `poke` the image in 512-byte chunks, read it back, `jump` to the entry |
| dictionary | Ethernet, ethertype `0x88B7` | 23 datagrams, one `TILES_DICT_CHUNK` each |
| pictures | same | one `TILES_FULL` or `TILES_DELTA` per clip frame |
`rdebug` is the firmware's own remote-debug service: `peek`/`poke`/`jump`, the
same protocol `dwt/exploit.py` speaks (`dwt/notes/CROSS.md`). On hardware it is
the delivery path; in MAME it works because the LANCE is attached to a host tap.
Two facts about it are load-bearing and were measured:
* **A 512-byte poke chunk, not 1024.** The firmware documents a 1024-byte clamp,
but a 1024-byte poke is mis-parsed — it reads the body's first word as the
next opcode and answers `Invalid command 0xA5A5A5A5` for an `0xA5`-filled
chunk. 512 is accepted and reads back byte for byte.
* **Nothing can be probed after the `jump`.** A non-returning payload takes the
remote-debug process with it, so `rdebug_load.py` verifies the image *before*
jumping, and the run is observed afterwards from the machine itself (the
screen, or MAME's RAM/VRAM dumps). One demo per boot.
## Setup
Two builds and one piece of data preparation. Steps 1 and 2 are C builds; step 3
is the part that needs a source video.
```sh
cd /path/to/vt1200
# 1. MAME + the driver (skip if vt1200 is already built)
harness/mame/build.sh
# 2. the payload (TI SDK under DOSBox) -> harness/udpstream/build/vidstream.bin
harness/udpstream/build-stream.sh
```
Step 3 is described below, because the numbers (time, disk, what "good" looks
like) are what make it repeatable.
## Data preparation: a video to a tile cache
Nothing ships a full clip: `harness/udpstream/tiles/` in the repo is the
**30-frame synthetic clip**, which is enough to smoke-test the transport but shows
a moving ellipse, not the animation. A real clip is three commands and about
five minutes.
### 3a. Get a source video
Any file ffmpeg can read. For Bad Apple the usable public sources are:
| source | why |
|---|---|
| `archive.org/details/hd-60-fps-bad-apple-shadow-art-pv-720-x-960-60fps` | 960x720, 60 fps, 219 s, ~35 MB — the one this demo was measured on (the item name is scrambled; the file inside is 960x720) |
| `yt-dlp <url>` from YouTube | fine, but the 4K/8K uploads are upscales of the same 2009 render and add no detail |
| the original PV's Flash file (`archive.org/details/dagobah_Bad_Apple_HQ`) | the true master, vector — needs a Flash renderer to rasterise, which nothing here does |
Resolution matters less than it looks: the terminal shows **1 bpp**, and the
pipeline scales to 1280 px wide before thresholding, so anything at 960 px or
more lands on the same picture.
### 3b. Frames
```sh
python3 harness/badapple/clip_from_video.py bad-apple.mp4 /tmp/badapple --fps 15
```
* one `*.bin` per frame, 1280x960, 1 bpp, 160-byte rows, **LSB first** — bit 0 of
a byte is the row's leftmost pixel, the order both the terminal's scanout and
the payload's encoder use. Do not substitute `ffmpeg`'s `format=monob`: it
packs MSB first and mirrors every eight pixels on the display.
* `--fps 15` on the 219 s source gives **3,292 frames**; `--height 960` fills 30
of the 32 tile bands (a 4:3 source at 1280x960, the bottom two rows black), and
`--height 1024` fills the screen by stretching 4:3.
* measured cost: **~12 s**, **489 MB** on disk (153,600 B per frame).
* the source is white paper with black silhouettes, which is already the right
polarity. If yours is inverted, add `--invert`; `--threshold` (default 127)
decides what counts as ink.
### 3c. The cache
```sh
python3 harness/udpstream/make_tiles.py --clip /tmp/badapple --out /tmp/badapple-tiles
```
Takes **~2 min 47 s** (pure-Python Lloyd's algorithm over 3,292 frames) and
writes four files, **6.1 MB** total:
| file | size | what it is |
|---|---|---|
| `dictionary.bin` | 32,768 B | 256 32x32 tiles, the vocabulary every frame is expressed in |
| `indices.bin` | 4,213,760 B | one 1,280-byte index frame per clip frame |
| `stream.bin` | 1,645,081 B | the plan: 55 keyframes + 3,141 deltas + 96 "nothing changed" |
| `manifest.json` | 199 KB | the numbers below, per frame |
It prints the same summary. What it said for this clip, and what to check:
```
3292 frames, 256 dictionary entries (256 used) <- the dictionary is saturated
55 keyframe(s), 3141 delta(s), 96 unchanged
payload 1280 B full, 496 B mean delta (5..3602), 1628615 B total
reconstruction 15823231 bit(s) over 3292 frame(s), BER 0.003667, PSNR 24.36 dB
```
Saturated entries and a 24 dB PSNR are expected, not errors: one byte per tile is
the format's ceiling, and shadow-art edges are expensive. A clip that reports
`inf` PSNR has every tile in the dictionary — that happens only for very flat
footage.
Keep the cache outside the repo: it is 6.1 MB of derived data. Pass it to the
demo with `--tiles /tmp/badapple-tiles`.
## Run it in MAME
Terminal 1 — the machine, with the LANCE on the host tap (`harness/mame/cfg-net`
selects `taptun`; the tap is `tap-mess-<uid>-0` in bridge `vt1200`,
`192.168.99.1/24`):
```sh
harness/mame/run.sh net
```
The harness now models the real unit's **VR319** monitor, so the firmware itself
uses the **1280-px / 2048-bit (256-byte) row layout** the tile payload targets —
and the Terminal Manager before it renders correctly at the same geometry. No
`VT1200_VRAM_REAL` is needed (that knob forced the pitch without changing the
firmware's layout, which is what used to shear the OS screen). To reproduce the
old derived 1024-px layout, run with `VT1200_MONITOR=VR150`; the tile picture
will then be sheared, as before. See `harness/mame/RENDER_REPORT.md` §8.
Terminal 2 — the demo:
```sh
harness/badapple/demo.sh --tiles /tmp/badapple-tiles --frames 3292 --fps 15
```
With no arguments `demo.sh` plays the committed 30-frame synthetic cache: a
smoke test that needs no clip.
## Run it on a real VT1200
The terminal must be reachable over Ethernet and answering rdebug on TCP 771 —
that is all this demo needs, no debug pod and no special build of the firmware.
```sh
# the unit's address, and the host interface that reaches it
harness/badapple/demo.sh --host <unit-ip> --iface <host-iface> \
--tiles /tmp/badapple-tiles --frames 3292 --fps 15
```
Notes that differ from the emulator:
* **Pacing is not needed** for the dictionary upload: the payload's receive ring
is 32 descriptors, one more than the 23-datagram upload, so the whole
dictionary lands before the first chunk is copied. On a slower link the wire
paces it for you.
* **`VT1200_VRAM_REAL` does not exist there** — the unit already uses its own
layout; the switch only affects MAME's renderer.
* The image is position-dependent and is linked at bit `0x018C0000` with its
entry at the address in `harness/udpstream/build/vidstream.env`. Both are
read from that file, so a rebuild moves the entry without editing anything.
## What success looks like
`demo.sh` prints the load verification and the sender's per-frame CSV. The
sender's verdict is about the *transport*:
```
PASS: 2 status frames, last frames_rx=100 (drops=0 crc_err=0)
```
The terminal's own counters, read from a MAME RAM dump (byte offsets from
`MARK_PAGE` = bit `0x01810000`), say what it actually did:
| cell | offset | good value |
|---|---|---|
| `DICT_BYTES` | `0x54` | `32768` (and `DICT_READY` `0x50` = 1) |
| `DICT_BAD` | `0x58` | `0` |
| `RX_OK` | `0x30` | climbs with painted frames |
| `TILES_DROP` | `0x5c` | `0` |
| `RX_DGRAM` | `0x48` | datagrams consumed |
`harness/mame/tile_check.py --replay` is the pixel verdict for the MAME replay
path; for a live run the screen is the evidence.
## Failure modes worth knowing
| symptom | cause |
|---|---|
| `demo.sh: ... did not answer within 180s` | the terminal is not on the tap, or `run.sh net` was started without the LANCE interface selected in its cfg |
| `demo.sh: ... has no dictionary.bin` | `--tiles` wants a **cache** directory, not the frames directory `clip_from_video.py` wrote; the message names the `make_tiles.py --clip … --out …` command that builds it |
| payload already jumped, need to send again | the script refuses a bad cache path *before* the jump for this reason, but if it happens: rerun with `--no-load` — the payload is still running and waiting, and `vidsend` brings the dictionary with it |
| poke reads back a mismatch | the image is being loaded over a stale link, or the payload was built for a different load address |
| `DICT_BYTES` stuck below 32768 | datagrams lost before the payload saw them — check the tap and the sender's interface |
| picture sheared in MAME | `VT1200_MONITOR=VR150` is selected (the old derived 1024-px layout); leave the default VR319 |
| `jump` seems to hang the service | expected: the payload never returns, so rdebug is gone until the machine is rebooted |