Bad Apple on a VT1200 — 30 s vector prototype
A standalone, polygon-styled 30-second Bad Apple demo for the VT1200
(TMS34010). The host pipeline turns the first 30 s of hd60.mp4 into a
640×512 binary picture (2×2 screen blocks), traces it into simplified
straight-edged polygons, and packs only changed 32×32 tiles as rectangles.
The payload paints those rectangles with FILL L.
The payload takes over the terminal: it disables interrupts and never returns. Recovery is a power cycle. The ROM is never modified.
Design: ../docs/superpowers/specs/2026-10-31-bad-apple-vector-design.md
Plan: ../docs/superpowers/plans/2026-11-01-bad-apple-vector.md
Measurements and gaps: REPORT.md
Source video
hd60.mp4 was downloaded by the agent in the earlier OpenCode session
“Bad Apple demo tool” (ses_e6d6b504dffeIRKLu3RqZAiFRp, message seq 641),
not supplied by the user. The design doc's “human-supplied” wording is
inaccurate.
- Item page: https://archive.org/details/hd-60-fps-bad-apple-shadow-art-pv-720-x-960-60fps
- Direct file: https://archive.org/download/hd-60-fps-bad-apple-shadow-art-pv-720-x-960-60fps/_HD_60FPS_%20Bad%20Apple%21%21%20_Shadow%20Art%20PV_%20%28%20720%20X%20960%2060fps%20%29.mp4
- The fetch command was
curl -sL -o hd60.mp4 "<direct file>". - Measured: 34,903,925 bytes, 960×720, 60 fps, 219.521451 s, h264 / yuv420p, AAC stereo.
- Local path used by this demo:
/tmp/opencode/badapple-real/hd60.mp4.
The archive.org item name is scrambled (720-x-960); the file inside is
960×720, 4:3.
Quick start — current MAME demo
From the repository root:
# 1. video -> data/movie.bin
cd bad-apple
python3 -m pipeline.build \
--video /tmp/opencode/badapple-real/hd60.mp4 \
--out data --duration 30
cd ..
# 2. payload
bad-apple/player/build.sh
# 3. watch it
bad-apple/tools/run_mame_gui.sh
run_mame_gui.sh starts MAME with VT1200_SKIP_LEVEL1=1, boots the OS, loads
the payload at frame 2500, lets it take over, writes the movie into low RAM,
and plays it in a window. Expect the 30 s clip at full screen, upright.
BA_END (default 8000) is the MAME frame at which the loader script exits;
raise it if your run is slower.
The network path is currently disabled in practice: the OS heap cannot
provide one contiguous 2.4 MB malloc_ block, so tools/load.sh stops before
it binds UDP. Use the debugger path above. See REPORT.md.
Build the 30 s stream
cd bad-apple
python3 -m pipeline.build \
--video /tmp/opencode/badapple-real/hd60.mp4 \
--out data --duration 30
Outputs:
| file | size | what |
|---|---|---|
data/movie.bin |
2,401,942 B | 1,757 records, 43 source frames dropped |
data/manifest.json |
small | data size, dirty-tile/rect/cost percentiles |
data/preview/frame_*.png |
— | still previews |
Measured: dirty tiles mean 44.9 / p95 72; rectangles mean 393 / p95 670; estimated paint cost mean 7.47 ms / p95 12.01 ms against a 13.9 ms refresh gate. The data area is 3.03 MB, so this fits with ~0.6 MB spare.
Build the payload
bad-apple/player/build.sh # debugger build: build/player.bin
NET=1 bad-apple/player/build.sh # network build: build/player-net.bin
The 1987 TI TMS34010 SDK runs under DOSBox. The C sources are K&R C: the
1987 compiler rejects ANSI prototypes and const. .text is linked at bit
0x018C0000, .bss at 0x01C00000.
The 1990 r5.01 OS/2 toolchain under 2ine-tms34010 also builds it (see
tools/tms34010-sdk/2INE-501.md for setup). Build MAIN.C without
gspopt -O2: the optimizer hoists the CTRL_READY polling load and the
payload waits forever at takeover. MOVIE.C is fine at -O2. The verified
r5.01 build is .text 3,168 bytes, entry 0x018C5C70, and is MAME-exact at
frames 0 and 180. gspac/gspcg exit 139 at teardown but still write valid
files.
Run in MAME (debugger path)
For a window, use tools/run_mame_gui.sh (above). For scripted/headless
checks:
# one frame, then dump VRAM at MAME frame 2700
VT1200_DUMP_VRAM_FRAMES=2700 VT1200_DUMP_DIR=/tmp/ba-one \
bad-apple/tools/run_mame.sh one
# 5 s slice, paced (headless)
BA_MOVIE=bad-apple/data5/movie.bin BA_START=0 BA_COUNT=300 \
bad-apple/tools/run_mame.sh play
# full 30 s, paced (headless)
BA_MOVIE=bad-apple/data/movie.bin BA_MOVIE_BASE=0x00080000 \
BA_WAIT=1 BA_PACE=1 bad-apple/tools/run_mame.sh play
run_mame.sh runs with -video none; it exits at BA_END. The debugger
loader writes the movie into high RAM (0x01D00000) for small slices; for the
full 30 s it uses BA_WAIT=1, lets the payload take over, then writes low RAM
at 0x00080000 — the same ordering as the real delivery path.
Deliver to a real unit (network path)
The terminal must be booted and answering ping. The payload is loaded over
rdebug, then receives movie.bin over UDP into an OS malloc buffer, then
disables interrupts and plays.
bad-apple/tools/load.sh --host <unit-ip> --movie bad-apple/data/movie.bin
This path is currently blocked at malloc_(2401942): the OS heap does not
give one contiguous 2.4 MB block, so the payload never binds UDP. The debugger
path above is the working demo. A fixed low-RAM staging receive or raw-LANCE
loader is the next step. See REPORT.md.
Data format
Header 36 bytes (BAPL, version, geometry, frame count, index/data offsets).
Index entry: u32 offset, u8 ticks, u8 flags. Record: u16 dirty tile count,
then per tile u16 tile index, u16 (bg<<15)|rect count, then 3-byte rects
(x0:5 y0:5 w-1:5 h-1:5). A tile is repainted by filling its 64×64 screen
block with the background colour, then filling the minority-colour rectangles.
Gotchas this prototype records
- PSIZE is re-asserted on every FILL. The resident OS leaves PSIZE at 2 bpp;
a one-time write in
_fill_init_was overwritten, and every span came out twice as wide. The fix is the per-callmovi 1 / move @0C0000150h. - VRAM polarity is inverted from the canvas: bit 1 = white, bit 0 = black.
tools/check_vram.pydownsamples with that polarity. - The OS dies before the movie is in low RAM. The network path receives
into an OS heap buffer while the stack is alive, then DINTs. The debugger
path does the same ordering with
BA_WAIT=1. - MAME is not the unit's performance verdict. MAME needed ~2.1 emulated
frames per movie record on the full stream; the unit's 50 MHz FILL rate is
the number that matters. See
REPORT.md. - The marker/control block lives in
.bss, not at bit0x01810000: a 2.4 MB heap allocation can cross that address.player.envpublishesMARK_PTR;load_mame.luareads it. - Pacing reads each record's
ticks. 60 source frames took 74 MAME frames (72 display frames plus startup), so the 60-on-72 cadence is measured, not assumed.