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.
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 0xA5A5A5A5for an0xA5-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, sordebug_load.pyverifies 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.
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
python3 harness/badapple/clip_from_video.py bad-apple.mp4 /tmp/badapple --fps 15
- one
*.binper 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 substituteffmpeg'sformat=monob: it packs MSB first and mirrors every eight pixels on the display. --fps 15on the 219 s source gives 3,292 frames;--height 960fills 30 of the 32 tile bands (a 4:3 source at 1280x960, the bottom two rows black), and--height 1024fills 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
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):
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:
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.
# 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_REALdoes 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
0x018C0000with its entry at the address inharness/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 |