Files
vt1200/bad-apple

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.

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.

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-call movi 1 / move @0C0000150h.
  • VRAM polarity is inverted from the canvas: bit 1 = white, bit 0 = black. tools/check_vram.py downsamples 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 bit 0x01810000: a 2.4 MB heap allocation can cross that address. player.env publishes MARK_PTR; load_mame.lua reads 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.