A new folder so the demo can be reproduced instead of re-derived: the delivery
and the clip packing were living in throwaway scripts under /tmp, and the only
written route to a real clip was an ffmpeg recipe that packs MSB first -- the
order that mirrors every eight pixels on a display.
harness/badapple/rdebug_load.py image -> terminal RAM over the firmware's
own remote-debug service (TCP 771), then
jump to the entry from vidstream.env
harness/badapple/clip_from_video.py video -> LSB-first 1-bpp frames for
make_tiles.py --clip
harness/badapple/demo.sh ICMP gate, load, send; one command
harness/badapple/README.md setup, run in MAME, run on real hardware,
what success looks like, failure modes
The loader records the two protocol facts that cost time to find: a 512-byte
poke chunk (1024 is mis-parsed as a following opcode: "Invalid command
0xA5A5A5A5"), and that nothing can be probed after the jump, so the image is
read back BEFORE jumping and the run is observed from the machine afterwards.
Tests cover what a wrong assumption would silently corrupt -- chunk arithmetic,
the linkage the entry point is read from, and pixel 0 landing on bit 0 -- and
the tool was exercised end to end against MAME: demo.sh loaded the payload and
the terminal reported DICT_BYTES=32768, DICT_BAD=0, RX_OK=28, TILES_DROP=0
while the sender saw frames_rx=129, drops=0.
153 tests pass (7 new).
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
├── 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
└── 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
cd /path/to/vt1200
# 1. MAME + the driver (skip if you already have vt1200 built)
harness/mame/build.sh
# 2. the payload (TI SDK under DOSBox)
harness/udpstream/build-stream.sh # -> harness/udpstream/build/vidstream.bin
# 3. a clip cache (skip to use the committed 30-frame synthetic clip)
python3 harness/badapple/clip_from_video.py bad-apple.mp4 /tmp/badapple --fps 15
python3 harness/udpstream/make_tiles.py --clip /tmp/badapple --out /tmp/badapple-tiles
clip_from_video.py writes LSB-first 1-bpp rows (bit 0 of a byte is the
leftmost pixel), which is 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.
make_tiles.py prints what it built — on the real clip (3,292 frames at 15 fps,
1280x960) that is 256/256 dictionary entries, 55 keyframes, mean delta 496 B,
1.63 MB of stream, PSNR 24.4 dB.
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):
VT1200_VRAM_REAL=1 harness/mame/run.sh net
VT1200_VRAM_REAL=1 renders the framebuffer at the real unit's geometry
(2048-bit / 256-byte rows, 1280 px). Without it the window uses the driver's
derived 128-byte pitch and the tile picture is sheared — the divergence is
documented in harness/mame/RENDER_REPORT.md §8, and it is a property of the
renderer, not of the demo.
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 |
| 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 | the window is rendering at the derived 128-byte pitch; use VT1200_VRAM_REAL=1 |
jump seems to hang the service |
expected: the payload never returns, so rdebug is gone until the machine is rebooted |