Files
vt1200/harness/badapple
arti c5526332f0 feat(badapple): stylise the clip into blocks, and let the sender widen its keyframe cadence
Two knobs for the 60 fps attempt, both measured on Bad Apple:

clip_from_video.py --block N averages each NxN block, thresholds the average and
draws the block flat.  The point is destination words: at 60 fps the clip changes
80 tiles/frame unstylised, 54 at 8x8 and 29 at 16x16, against the ~44-tile budget
that MAME's ~3.4 us/word allows in 16.7 ms.  A side effect worth knowing: with
16-pixel blocks every 32x32 tile is one of exactly 2^4 combinations, so the 256
entry dictionary represents the stylised clip with zero error (measured: 16
entries used, "reconstruction 0 bit(s)", PSNR inf, mean delta 141 B against 496 B
for the unstylised clip).

vidsend.py --keyframe-every N exposes the cadence that TileSender already had as
a constructor argument.  A keyframe repaints all 1,280 tiles (~264 ms of MAME CPU
at the measured 0.22 ms/tile), so the default 60-frame cadence costs ~4.4 ms on
average per frame at 60 fps -- a tenth of the budget spent on repairs.  The
13,167-frame stylised clip ships 220 keyframes at the default; 600 measured 6 in
the same window.

Measured on the stylised 60 fps cache (13,167 frames, 16 dictionary entries):
  --fps 60, default cadence   643 of 805 accepted (80%), drops=8
  --fps 45                     484 of 520 accepted, drops=0, sender PASS
  --fps 60, --keyframe-every 600  451 of 519 accepted, drops=5
So the stylised clip roughly doubles acceptance against the same 60 fps send
(40% -> 80%) but MAME still caps the clean rate around 36-45 fps.
2026-10-31 23:12:25 +02:00
..

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.

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 *.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

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_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