# 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 ` 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--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 --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 |