Files
vt1200/docs/superpowers/plans/2026-11-01-bad-apple-vector.md
T

35 KiB
Raw Blame History

Bad Apple 30 s Vector Demo Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Play the first 30 s of hd60.mp4 full-screen on a VT1200 as quantised black-and-white polygon art, loaded once and played from the terminal's 4 MB of RAM with FILL rectangles.

Architecture: Host pipeline turns video into a 640×512 binary picture (2×2 screen blocks), traces it into simplified straight-edged polygons, rasterises them back, and packs only changed 32×32-tile rectangles into a delta stream. A TMS34010 payload decodes that stream and paints dirty tiles with FILL L. A small rdebug bootstrap kills the OS, receives the stream over LANCE, verifies it, and jumps to the player.

Tech Stack: Python 3 + numpy + Pillow + ffmpeg + potrace + pytest; TMS34010 C/assembly built with the 1987 TI SDK under DOSBox; MAME's vt1200 driver and Lua debugger for verification.

Spec: docs/superpowers/specs/2026-10-31-bad-apple-vector-design.md

Global Constraints

  • Source is /tmp/opencode/badapple-real/hd60.mp4; first 30 s; 1,800 frames at 60 fps.
  • Output is full screen, 1280×1024, 1 bpp, stretched from 4:3 to 5:4.
  • Logical picture is 640×512; each logical pixel is a 2×2 screen block.
  • Tiles are 32×32 logical (64×64 screen); 20×16 = 320 tiles.
  • Movie data must be <= 3.0 MB and must never exceed the hard data area 0x010000..0x317FFF (3.03 MB).
  • Runtime uses FILL L only; LINE is debug-only.
  • Incremental dirty-tile painting is accepted; a brief wipe is not a failure.
  • The OS dies before the first byte lands in low RAM; recovery is a power cycle.
  • The payload never skips a record. If the unit is slower than the encoder estimate, the movie stretches.
  • Every image is LSB-first: bit 0 of a byte is the leftmost pixel.
  • Do not edit harness/ or the ROM. Stage only paths under bad-apple/ and the two design/plan docs.
  • The 1987 C compiler is K&R/C89: no //, no declarations after statements, no stdint.h, no for (int ...).

Review Focus

  • Malformed or truncated movie data must be refused with an error block and halt, never read past the buffer or paint a partial movie. Tests: test_movie_truncated_record, test_player_rejects_bad_header.
  • Tile rectangle edge cases: w=32/h=32 must survive the 5-bit w-1/h-1 encoding, and a tile with more than 127 rectangles must encode and decode without truncation. Tests: test_rect_full_tile_bias, test_more_than_127_rects.
  • Data cap: the encoder must fail loudly above 3.03 MB, not truncate. Test: test_write_movie_refuses_over_cap.
  • Delta chain: no runtime record skipping; a slow unit stretches. Test: test_select_records_holds_time_not_records.
  • Partial network load: the player must not jump into a movie whose checksum failed. Test: test_loader_rejects_bad_crc.

File Structure

bad-apple/
├── README.md
├── REPORT.md
├── .gitignore
├── pytest.ini
├── conftest.py
├── pipeline/
│   ├── __init__.py
│   ├── params.json
│   ├── frames.py          video -> gray -> temporal median -> blur -> binary
│   ├── vectorize.py       potrace + RDP + even-odd rasteriser
│   ├── movie.py           header, frame records, pack/unpack, cap check
│   ├── tilepack.py        frame diff -> per-tile background + rectangles
│   ├── cost.py            13.9 ms cost model and record selection
│   ├── build.py           CLI: video -> movie.bin + manifest.json
│   └── preview.py         side-by-side PNG and MP4 previews
├── player/
│   ├── movie.h
│   ├── movie.c            pure decoder, host-testable
│   ├── main.c             hardware setup, playback loop, pacing
│   ├── fill.asm           fill_rect() over FILL L
│   ├── entry.asm          _start + ROM thunks (copied from udpstream)
│   ├── build.sh           DOSBox SDK build
│   └── tests/
│       ├── test_movie_decode.c
│       └── fixture.bin    generated by tests/make_fixture.py
├── tools/
│   ├── load_mame.lua      debugger load for one frame / 5 s / full 30 s
│   ├── check_vram.py      VRAM dump -> logical frame -> exact compare
│   ├── send_movie.py      raw-Ethernet sender
│   ├── load.sh            rdebug bootstrap + network load
│   └── tests/
│       ├── test_check_vram.py
│       └── test_send_movie.py
└── tests/
    ├── test_frames.py
    ├── test_vectorize.py
    ├── test_movie.py
    ├── test_tilepack.py
    ├── test_cost.py
    ├── test_build.py
    └── test_c_decoder.py

Task 1: Scaffold and frame extraction primitives

Files:

  • Create: bad-apple/README.md
  • Create: bad-apple/.gitignore
  • Create: bad-apple/pytest.ini
  • Create: bad-apple/conftest.py
  • Create: bad-apple/pipeline/__init__.py
  • Create: bad-apple/pipeline/params.json
  • Create: bad-apple/pipeline/frames.py
  • Test: bad-apple/tests/test_frames.py

Interfaces:

  • Produces:

    • temporal_median3(frames: Sequence[np.ndarray]) -> np.ndarray
    • box_blur3(gray: np.ndarray) -> np.ndarray
    • binarize(gray: np.ndarray, threshold: int = 127) -> np.ndarray (True = black)
    • pack_lsb(mask: np.ndarray) -> bytes
    • extract_gray(video: str, start: float, duration: float, fps: int, width: int, height: int) -> Iterator[np.ndarray]
    • extract_binary(video: str, start: float, duration: float, fps: int, width: int, height: int, params: dict) -> Iterator[np.ndarray]
  • Step 1: Write the failing tests

# bad-apple/tests/test_frames.py
import numpy as np
from pipeline.frames import temporal_median3, box_blur3, binarize, pack_lsb

def test_temporal_median_removes_single_frame_flash():
    a = np.zeros((4, 4), dtype=np.uint8)
    b = np.zeros((4, 4), dtype=np.uint8); b[1, 1] = 255
    c = np.zeros((4, 4), dtype=np.uint8)
    out = temporal_median3([a, b, c])
    assert out[1, 1] == 0

def test_pack_lsb_is_leftmost_pixel_bit_zero():
    mask = np.zeros((1, 8), dtype=bool)
    mask[0, 0] = True
    assert pack_lsb(mask) == b"\x01"
    mask[0, 7] = True
    assert pack_lsb(mask) == b"\x81"

def test_binarize_black_is_true():
    gray = np.array([[0, 255]], dtype=np.uint8)
    assert binarize(gray).tolist() == [[True, False]]

def test_box_blur_softens_a_single_pixel():
    gray = np.zeros((3, 3), dtype=np.uint8); gray[1, 1] = 9
    out = box_blur3(gray)
    assert out[1, 1] == 1
  • Step 2: Run the tests to verify they fail

Run: cd bad-apple && python3 -m pytest tests/test_frames.py -q Expected: FAIL with ModuleNotFoundError: No module named 'pipeline' or missing names.

  • Step 3: Implement the primitives

temporal_median3 returns np.median(np.stack(frames), axis=0).astype(np.uint8). box_blur3 is a 3×3 mean with edge clamping (do not np.roll, which wraps). binarize is gray < threshold (black is True). pack_lsb uses np.packbits(mask, axis=1, bitorder="little"). extract_gray runs:

ffmpeg -v error -ss <start> -t <duration> -i <video>
  -vf fps=<fps>,scale=<w>:<h>:flags=lanczos,format=gray
  -f rawvideo -

and yields (h, w) uint8 arrays. extract_binary buffers three gray frames, applies temporal_median3, box_blur3, binarize, then yields; it duplicates the first and last frame at the boundaries so exactly duration*fps frames are produced. Add pipeline/params.json with the values from the spec:

{
  "start": 0.0, "duration": 30.0, "fps": 60,
  "width": 640, "height": 512, "threshold": 127,
  "temporal_median": 3, "blur": 3,
  "tile": 32, "scale": 2,
  "rdp_epsilon": 1.5, "min_segment": 2, "min_component": 8
}

bad-apple/conftest.py inserts the bad-apple/ directory on sys.path. .gitignore ignores data/* but keeps !data/manifest.json, plus preview/, player/build/, __pycache__/, .pytest_cache/. pytest.ini sets testpaths = tests; the two tools/tests files are run explicitly.

  • Step 4: Run the tests to verify they pass

Run: cd bad-apple && python3 -m pytest tests/test_frames.py -q Expected: 4 passed.

  • Step 5: Commit
git add bad-apple/README.md bad-apple/.gitignore bad-apple/pytest.ini \
  bad-apple/conftest.py bad-apple/pipeline/__init__.py \
  bad-apple/pipeline/params.json bad-apple/pipeline/frames.py \
  bad-apple/tests/test_frames.py
git commit -m "feat(badapple): frame extraction primitives"

Task 2: Polygon tracing, simplification and rasterisation

Files:

  • Create: bad-apple/pipeline/vectorize.py
  • Test: bad-apple/tests/test_vectorize.py

Interfaces:

  • Produces:

    • rdp(points: list[tuple[float, float]], epsilon: float) -> list[tuple[float, float]]
    • parse_potrace_geojson(text: str) -> list[list[list[tuple[float, float]]]]
    • trace(mask: np.ndarray, params: dict) -> list[list[list[tuple[float, float]]]]
    • rasterize(polygons, width: int, height: int) -> np.ndarray
    • vectorize_frame(mask: np.ndarray, params: dict) -> np.ndarray
  • Step 1: Write the failing tests

# bad-apple/tests/test_vectorize.py
import numpy as np
from pipeline.vectorize import rdp, rasterize, trace, vectorize_frame

def test_rdp_drops_collinear_point():
    pts = [(0, 0), (1, 0), (2, 0)]
    assert rdp(pts, 0.5) == [(0, 0), (2, 0)]

def test_rasterize_square():
    poly = [[(1, 1), (4, 1), (4, 4), (1, 4), (1, 1)]]
    out = rasterize([poly], 6, 6)
    assert out[2, 2]
    assert not out[0, 0]

def test_rasterize_square_with_hole():
    outer = [(1, 1), (5, 1), (5, 5), (1, 5), (1, 1)]
    hole = [(2, 2), (2, 4), (4, 4), (4, 2), (2, 2)]
    out = rasterize([[outer, hole]], 7, 7)
    assert out[3, 1]       # left wall
    assert not out[3, 3]   # hole

def test_trace_simple_shape_and_roundtrip():
    mask = np.zeros((16, 16), dtype=bool)
    mask[4:12, 4:12] = True
    polys = trace(mask, {"rdp_epsilon": 1.0, "min_segment": 2})
    assert len(polys) >= 1
    roundtrip = rasterize(polys, 16, 16)
    assert roundtrip[6, 6]
    assert not roundtrip[0, 0]
  • Step 2: Run the tests to verify they fail

Run: cd bad-apple && python3 -m pytest tests/test_vectorize.py -q Expected: FAIL with missing module or names.

  • Step 3: Implement the vectoriser

trace writes the black mask to a temporary PBM (P4, 1 = black), runs:

potrace -b geojson -a 0 -t 4 -u 1 -o <json> <pbm>

and parses FeatureCollection → Polygon/MultiPolygon rings. Apply rdp to each closed ring (keep the first/last point). Drop rings with fewer than 3 distinct points or a longest segment below min_segment. rasterize collects every ring edge, intersects it with each scanline at y + 0.5, sorts the x values, and fills pixel centres between pairs (even-odd). vectorize_frame is rasterize(trace(mask, params), width, height).

  • Step 4: Run the tests to verify they pass

Run: cd bad-apple && python3 -m pytest tests/test_vectorize.py -q Expected: 4 passed.

  • Step 5: Commit
git add bad-apple/pipeline/vectorize.py bad-apple/tests/test_vectorize.py
git commit -m "feat(badapple): polygon tracing and rasterisation"

Task 3: Tile diff and movie format

Files:

  • Create: bad-apple/pipeline/movie.py
  • Create: bad-apple/pipeline/tilepack.py
  • Test: bad-apple/tests/test_tilepack.py
  • Test: bad-apple/tests/test_movie.py

Interfaces:

  • Produces:

    • Rect(x0, y0, w, h) frozen dataclass, TileRecord(tile_index, bg, rects), FrameRecord(ticks, flags, tiles), MovieHeader(...)
    • decompose_tile(tile: np.ndarray) -> tuple[int, list[Rect]]
    • diff_tiles(prev: np.ndarray, cur: np.ndarray, tile: int = 32) -> list[TileRecord]
    • apply_record(canvas: np.ndarray, record: FrameRecord, tile: int = 32) -> None
    • encode_frame(record: FrameRecord) -> bytes
    • decode_frame(data: bytes, offset: int) -> tuple[FrameRecord, int]
    • write_movie(path: str, frames: Sequence[FrameRecord], header: MovieHeader) -> int
    • read_movie(path: str) -> tuple[MovieHeader, list[FrameRecord]]
    • MovieFormatError exception
    • MAX_DATA_BYTES = 0x308000
  • Step 1: Write the failing tests

# bad-apple/tests/test_tilepack.py
import numpy as np
from pipeline.tilepack import decompose_tile, diff_tiles, apply_record
from pipeline.movie import Rect, TileRecord, FrameRecord

def test_decompose_solid_tiles():
    black = np.ones((32, 32), dtype=bool)
    bg, rects = decompose_tile(black)
    assert bg == 1 and rects == []
    white = np.zeros((32, 32), dtype=bool)
    bg, rects = decompose_tile(white)
    assert bg == 0 and rects == []

def test_decompose_block_is_one_rect():
    tile = np.zeros((32, 32), dtype=bool)
    tile[4:6, 7:10] = True
    bg, rects = decompose_tile(tile)
    assert bg == 0
    assert rects == [Rect(7, 4, 3, 2)]

def test_diff_only_reports_changed_tiles():
    a = np.zeros((64, 64), dtype=bool)
    b = a.copy(); b[0:32, 0:32] = True
    recs = diff_tiles(a, b, tile=32)
    assert len(recs) == 1 and recs[0].tile_index == 0

def test_apply_record_reconstructs():
    a = np.zeros((32, 32), dtype=bool)
    b = a.copy(); b[4:9, 3:12] = True
    recs = diff_tiles(a, b, tile=32)
    canvas = a.copy()
    for rec in recs:
        apply_record(canvas, FrameRecord(1, 0, [rec]), tile=32)
    assert np.array_equal(canvas, b)
# bad-apple/tests/test_movie.py
import numpy as np
from pipeline.movie import (Rect, TileRecord, FrameRecord, MovieHeader,
                            encode_frame, decode_frame, write_movie, read_movie,
                            MovieFormatError)

def test_rect_full_tile_bias_roundtrip():
    rec = FrameRecord(1, 0, [TileRecord(0, 0, [Rect(0, 0, 32, 32)])])
    back, off = decode_frame(encode_frame(rec), 0)
    assert back.tiles[0].rects[0] == Rect(0, 0, 32, 32)
    assert off == len(encode_frame(rec))

def test_more_than_127_rects_roundtrip():
    rects = [Rect(i % 32, i // 32, 1, 1) for i in range(200)]
    rec = FrameRecord(1, 0, [TileRecord(3, 0, rects)])
    back, _ = decode_frame(encode_frame(rec), 0)
    assert len(back.tiles[0].rects) == 200

def test_truncated_record_raises():
    data = encode_frame(FrameRecord(1, 0, [TileRecord(0, 0, [Rect(0, 0, 4, 4)])]))
    try:
        decode_frame(data[:-1], 0)
    except MovieFormatError:
        return
    assert False, "truncated record was accepted"

def test_write_movie_refuses_over_cap(tmp_path):
    frames = [FrameRecord(1, 0, []) for _ in range(10)]
    header = MovieHeader(640, 512, 2, 32, 20, 16, 10, 60, 72)
    # 10 empty records are tiny; the cap is exercised by a patched constant.
    import pipeline.movie as m
    old = m.MAX_DATA_BYTES
    m.MAX_DATA_BYTES = 8
    try:
        try:
            write_movie(str(tmp_path / "x.bin"), frames, header)
        except MovieFormatError:
            return
        assert False, "cap not enforced"
    finally:
        m.MAX_DATA_BYTES = old
  • Step 2: Run the tests to verify they fail

Run: cd bad-apple && python3 -m pytest tests/test_tilepack.py tests/test_movie.py -q Expected: FAIL with missing modules.

  • Step 3: Implement tile decomposition and the format

decompose_tile chooses bg by majority, makes fg = tile != bool(bg), extracts horizontal runs per row, and merges identical (x0, x1) runs across consecutive rows into Rect(x0, y0, x1-x0+1, height). diff_tiles compares prev and cur tile by tile and calls decompose_tile(cur_tile) for changed tiles. apply_record fills the whole tile with bg, then fills each rect with 1-bg.

Header layout (36 bytes, little-endian): magic "BAPL", version u16=1, width u16, height u16, scale u16, tile u16, tiles_x u16, tiles_y u16, frame_count u32, index_offset u32, data_offset u32, source_fps u16, display_hz u16, reserved u16=0. Index entry is <IBB (offset, ticks, flags). Record is <H dirty count, then per tile <HH (tile_index, (bg << 15) | rect_count), then 3-byte rects packed as x0 | (y0 << 5) | ((w-1) << 10) | ((h-1) << 15).

decode_frame validates counts against the remaining bytes before allocating/reading. write_movie builds the header + index + records in memory, raises MovieFormatError if the total exceeds MAX_DATA_BYTES, and writes atomically via a temporary file.

  • Step 4: Run the tests to verify they pass

Run: cd bad-apple && python3 -m pytest tests/test_tilepack.py tests/test_movie.py -q Expected: all tests pass.

  • Step 5: Commit
git add bad-apple/pipeline/movie.py bad-apple/pipeline/tilepack.py \
  bad-apple/tests/test_tilepack.py bad-apple/tests/test_movie.py
git commit -m "feat(badapple): tile delta format and movie packer"

Task 4: Cost model, record selection and build CLI

Files:

  • Create: bad-apple/pipeline/cost.py
  • Create: bad-apple/pipeline/build.py
  • Create: bad-apple/pipeline/preview.py
  • Test: bad-apple/tests/test_cost.py
  • Test: bad-apple/tests/test_build.py

Interfaces:

  • Produces:

    • CostConstants(word_us=0.40, call_us=3.0, rect_us=1.0)
    • estimate_record_us(record: FrameRecord, constants: CostConstants, scale=2, tile=32) -> float
    • select_records(frames: Sequence[np.ndarray], constants: CostConstants, budget_us: float = 13900.0, max_skip: int = 2) -> list[FrameRecord]
    • build_movie(video: str, out_dir: str, params: dict) -> dict
  • Step 1: Write the failing tests

# bad-apple/tests/test_cost.py
import numpy as np
from pipeline.cost import CostConstants, estimate_record_us, select_records
from pipeline.movie import FrameRecord, TileRecord, Rect

C = CostConstants()

def test_one_solid_tile_cost():
    rec = FrameRecord(1, 0, [TileRecord(0, 1, [])])
    # 64x64 screen tile = 256 words; 256*0.40 + 3us call
    assert abs(estimate_record_us(rec, C) - (256 * 0.40 + 3.0)) < 0.01

def test_full_screen_is_over_budget():
    recs = [TileRecord(i, 1, []) for i in range(320)]
    assert estimate_record_us(FrameRecord(1, 0, recs), C) > 13900.0

def test_select_records_holds_time_not_records():
    quiet = np.zeros((32, 32), dtype=bool)
    busy = np.ones((32, 32), dtype=bool)
    out = select_records([quiet, busy, quiet, quiet], C, budget_us=2000.0)
    assert all(r.ticks >= 1 for r in out)
    assert sum(r.ticks for r in out) == 4
# bad-apple/tests/test_build.py
import json
import numpy as np
import pipeline.build as build
from pipeline.movie import read_movie

def test_build_tiny_movie(tmp_path, monkeypatch):
    frames = [np.zeros((64, 64), dtype=bool) for _ in range(3)]
    frames[1][8:24, 8:24] = True
    monkeypatch.setattr(build, "extract_binary", lambda *a, **k: iter(frames))
    monkeypatch.setattr(build, "vectorize_frame", lambda m, p: m)
    out = tmp_path / "data"
    manifest = build.build_movie("unused.mp4", str(out), {"duration": 3, "fps": 60})
    header, recs = read_movie(str(out / "movie.bin"))
    assert header.frame_count == 3
    assert len(recs) == 3
    assert (out / "manifest.json").exists()
    assert json.loads((out / "manifest.json").read_text())["frame_count"] == 3
  • Step 2: Run the tests to verify they fail

Run: cd bad-apple && python3 -m pytest tests/test_cost.py tests/test_build.py -q Expected: FAIL with missing modules.

  • Step 3: Implement the cost model and build CLI

estimate_record_us sums, per dirty tile, one background fill of 64×64 screen pixels (256 words) plus each rect's ceil(w*scale/16) * h*scale words, all at word_us, plus call_us per fill and rect_us per rect. select_records walks the source frames, computes the record from the last kept frame; if it fits the budget, emit it with ticks=1; otherwise remember it and skip up to max_skip frames, accumulating ticks, then emit the best candidate even if over budget (a cut). It never emits a record from a skipped frame. build_movie streams extract_binary, applies vectorize_frame, computes records, calls select_records, writes movie.bin, manifest.json (data size, dirty-tile percentiles, cost percentiles, dropped count), and previews for frames 0, 60, 300, 900, 1799. preview.py writes PNGs and an optional MP4 via ffmpeg.

  • Step 4: Run the tests to verify they pass

Run: cd bad-apple && python3 -m pytest tests/test_cost.py tests/test_build.py -q Expected: all tests pass.

  • Step 5: Commit
git add bad-apple/pipeline/cost.py bad-apple/pipeline/build.py \
  bad-apple/pipeline/preview.py bad-apple/tests/test_cost.py \
  bad-apple/tests/test_build.py
git commit -m "feat(badapple): cost model, frame selection and build CLI"

Task 5: Host-testable player decoder

Files:

  • Create: bad-apple/player/movie.h
  • Create: bad-apple/player/movie.c
  • Create: bad-apple/player/tests/test_movie_decode.c
  • Create: bad-apple/tests/make_fixture.py
  • Test: bad-apple/tests/test_c_decoder.py

Interfaces:

  • Produces C:

    • struct movie_header { unsigned int width, height, scale, tile, tiles_x, tiles_y, frame_count, index_offset, data_offset, source_fps, display_hz; };
    • int movie_open(const unsigned char *data, unsigned long len, struct movie_header *h);
    • int movie_decode_frame(const unsigned char *data, unsigned long len, unsigned int frame, const struct movie_header *h, void (*fill)(int x, int y, int w, int h, int color, void *ctx), void *ctx);
    • int movie_checksum(const unsigned char *data, unsigned long len);
  • Step 1: Generate the fixture and write the failing tests

bad-apple/tests/make_fixture.py writes player/tests/fixture.bin using pipeline.movie with two frames: frame 0 a white tile with one black Rect(0,0,32,32); frame 1 a black tile with Rect(1,1,2,3). tests/test_c_decoder.py compiles movie.c + the C test with gcc -std=c99 -Wall -Werror into a temp binary, runs it, and compares its printed lines to the Python decoder.

# bad-apple/tests/test_c_decoder.py
import subprocess, sys, tempfile
from pathlib import Path
from pipeline.movie import read_movie

ROOT = Path(__file__).resolve().parents[1]

def test_c_decoder_matches_python(tmp_path):
    subprocess.run([sys.executable, str(ROOT / "tests" / "make_fixture.py")], check=True)
    exe = tmp_path / "decode"
    subprocess.run(["gcc", "-std=c99", "-Wall", "-Werror",
                    str(ROOT / "player" / "movie.c"),
                    str(ROOT / "player" / "tests" / "test_movie_decode.c"),
                    "-o", str(exe)], check=True)
    out = subprocess.run([str(exe), str(ROOT / "player" / "tests" / "fixture.bin")],
                         check=True, capture_output=True, text=True).stdout
    assert "frames=2" in out
    assert "0 0 0 0 0 32 32" in out
    assert "1 1 1 1 1 2 3" in out
  • Step 2: Run the test to verify it fails

Run: cd bad-apple && python3 -m pytest tests/test_c_decoder.py -q Expected: FAIL because player/movie.c does not exist.

  • Step 3: Implement the decoder and the C test

movie.c uses only C89-compatible declarations (K&R style where needed) and no libc beyond what the 1987 compiler provides. It reads little-endian words with byte shifts, validates magic/version, bounds-checks every count against len, and calls fill with screen coordinates already scaled: sx = (tile_x*tile + x0)*scale, sy = (tile_y*tile + y0)*scale, sw = (w+1)*scale, sh = (h+1)*scale. The C test prints frames=N, then one line per rect frame tile bg x y w h.

  • Step 4: Run the test to verify it passes

Run: cd bad-apple && python3 -m pytest tests/test_c_decoder.py -q Expected: PASS.

  • Step 5: Commit
git add bad-apple/player/movie.h bad-apple/player/movie.c \
  bad-apple/player/tests/test_movie_decode.c bad-apple/tests/make_fixture.py \
  bad-apple/tests/test_c_decoder.py bad-apple/player/tests/fixture.bin
git commit -m "feat(badapple): host-tested movie decoder"

Task 6: FILL primitive, payload build and one exact frame in MAME

Files:

  • Create: bad-apple/player/fill.asm
  • Create: bad-apple/player/entry.asm
  • Create: bad-apple/player/main.c
  • Create: bad-apple/player/build.sh
  • Create: bad-apple/tools/load_mame.lua
  • Create: bad-apple/tools/check_vram.py
  • Test: bad-apple/tools/tests/test_check_vram.py

Interfaces:

  • Produces:

    • void fill_rect(int x, int y, int w, int h, int color); (C-callable, published-word form)
    • main.c mode ONE: open the movie, paint frame 0, write FRAME=0 to marker bit 0x01810000, spin.
    • load_mame.lua env: BA_MODE=one, BA_MOVIE, BA_PAYLOAD, BA_AT, BA_ENTRY.
    • check_vram.py --vram <dump> --movie <movie.bin> --frame <n> --scale 2 exits 0 on exact match.
  • Step 1: Write the failing checker test

# bad-apple/tools/tests/test_check_vram.py
import numpy as np
from tools.check_vram import vram_to_logical

def test_vram_downsamples_2x2_lsb_first():
    vram = bytearray(256 * 1024)
    # logical pixel (0,0) black means screen pixels x=0..1,y=0..1 set.
    vram[0] = 0x03          # bits 0 and 1 of row 0
    vram[256] = 0x03        # bits 0 and 1 of row 1
    logical = vram_to_logical(bytes(vram), width=640, height=512, scale=2)
    assert logical[0, 0]
    assert not logical[0, 1]
  • Step 2: Run the test to verify it fails

Run: cd bad-apple && python3 -m pytest tools/tests/test_check_vram.py -q Expected: FAIL because tools/check_vram.py does not exist.

  • Step 3: Implement the FILL primitive, payload, loader and checker

fill.asm follows harness/udpstream/vram.asm: a no-argument entry reads published globals fill_x, fill_y, fill_w, fill_h, fill_color; sets B2 = 0x04000000 + y*2048 + x, B3 = 0x800, B7 = (h << 16) | w, B9 = color, programs PSIZE=1, PMASK=0, CONTROL replace once, then FILL L. The C wrapper fill_rect publishes the globals and calls the asm entry.

entry.asm is copied from harness/udpstream/entry.asm, with _start setting SP and calling _main, then spinning (no return). build.sh copies harness/udpstream/build-stream.sh, changes SRC to player/main.c, copies movie.c as MOVIE.C, links MOVIE.obj, FILL.obj, MAIN.obj, ENTRY.obj, and links .text at bit 0x018C0000, .bss at bit 0x01C00000. It writes build/player.bin and build/player.env with ENTRY_PTR.

load_mame.lua loads player.bin at bit 0x018C0000 and movie.bin at bit 0x00080000, verifies a readback, sets a breakpoint at the OS idle PC (default 0x3F579970, as vidstream.lua), and jumps. check_vram.py reads the first 256*1024 bytes of the dump, extracts bit x of row y (LSB-first), downsamples 2×2 by majority, and compares to apply_record over the movie frames.

  • Step 4: Run the checker test and the MAME one-frame check

Run: cd bad-apple && python3 -m pytest tools/tests/test_check_vram.py -q Expected: PASS.

Run a one-frame movie in MAME with VT1200_SKIP_LEVEL1=1, BA_MODE=one, VT1200_DUMP_VRAM_FRAMES=<after paint>, then: python3 tools/check_vram.py --vram <dump> --movie data/movie.bin --frame 0 Expected: EXACT and exit 0.

  • Step 5: Commit
git add bad-apple/player/fill.asm bad-apple/player/entry.asm \
  bad-apple/player/main.c bad-apple/player/build.sh \
  bad-apple/tools/load_mame.lua bad-apple/tools/check_vram.py \
  bad-apple/tools/tests/test_check_vram.py
git commit -m "feat(badapple): FILL primitive and one-frame MAME check"

Task 7: Playback, pacing and the 5 s slice

Files:

  • Modify: bad-apple/player/main.c
  • Modify: bad-apple/tools/load_mame.lua
  • Create: bad-apple/tools/run_mame.sh
  • Test: manual/MAME (no host unit test)

Interfaces:

  • main.c mode PLAY: open the movie, clear the screen white, loop over records, call movie_decode_frame, pace on INTPEND DI, and write FRAME, TICKS, LAST_BYTES, PAINT_LOOPS, ERROR to the marker page.

  • load_mame.lua env BA_MODE=play, BA_FRAME (start frame), BA_DUMP_FRAMES.

  • Step 1: Add the playback loop and marker page

Implement play() in main.c: DINT, initial full-screen white FILL, then for each frame call the decoder. Before each record, read INTPEND (bit TMS34010_DI = 0x0400) and wait for the next vblank; program DPYINT if the inherited value does not produce the pending bit. Maintain a 16.16 now/next accumulator with now += 60/72 per vsync; if now >= next, paint and next += ticks; if now - next > 1.0, set next = now (slow down, never skip). Publish the marker page at bit 0x01810000.

  • Step 2: Run the 5 s slice in MAME and compare sampled frames

Use the encoder to pack frames 0..300 into data/movie5.bin. Run tools/run_mame.sh with BA_MODE=play, dump VRAM at MAME frames corresponding to movie frames 0, 60, 180, 300, and compare each with check_vram.py. Expected: every sample prints EXACT.

  • Step 3: Record the paint-cost histogram and the first visual clip

Capture a windowed run with x11grab (or tools/run_mame.sh), inspect for stale tiles, double-applied deltas, and a half-painted cut. Record the measured PAINT_LOOPS / LAST_BYTES distribution in bad-apple/REPORT.md (created in Task 9; a temporary note is fine).

  • Step 4: Commit
git add bad-apple/player/main.c bad-apple/tools/load_mame.lua \
  bad-apple/tools/run_mame.sh
git commit -m "feat(badapple): playback loop and vblank pacing"

Task 8: Full 30 s encode and MAME playback

Files:

  • Modify: bad-apple/pipeline/params.json

  • Modify: bad-apple/pipeline/build.py (only if tuning reveals a bug)

  • Test: MAME sampled frames

  • Step 1: Run the real pipeline

Run: cd bad-apple && python3 -m pipeline.build --video /tmp/opencode/badapple-real/hd60.mp4 --out data --duration 30 Expected: data/movie.bin, data/manifest.json, preview frames; manifest reports data_bytes <= 3.0e6 and p95 cost <= 13900.

  • Step 2: If the gate fails, tune in this order

Increase rdp_epsilon (1.5 → 2.0 → 3.0), increase min_component (8 → 16), then raise tile from 32 to 64 (the format supports any power-of-two tile; the player uses the header). Do not shorten the clip. Re-run and re-check the gate after each change.

  • Step 3: Play the full 30 s in MAME

Run tools/run_mame.sh with BA_MODE=play. Dump VRAM at movie frames 0, 60, 300, 600, 900, 1200, 1500, 1799 and every scene cut. Compare each with check_vram.py. Expected: every sample EXACT; the last frame is frame 1799.

  • Step 4: Commit the tuned parameters and manifest summary
git add bad-apple/pipeline/params.json bad-apple/data/manifest.json
git commit -m "feat(badapple): tune the 30s stream to the memory budget"

(data/movie.bin and previews stay gitignored; the manifest is the record.)


Task 9: Network loader and real-unit delivery

Files:

  • Create: bad-apple/player/loader.c or extend main.c with MODE=LOAD
  • Create: bad-apple/tools/send_movie.py
  • Create: bad-apple/tools/load.sh
  • Test: bad-apple/tools/tests/test_send_movie.py

Interfaces:

  • Wire packet: u32 offset, u16 length, u16 seq, u16 crc, then length bytes.

  • send_movie(iface, host_mac, path, chunk=1450) -> int returns 0 after the final ACK.

  • Loader writes to 0x010000, ACKs the highest contiguous offset every 32 datagrams, verifies the whole-file CRC, then jumps.

  • Step 1: Write the failing sender test

# bad-apple/tools/tests/test_send_movie.py
from tools.send_movie import pack_packet, parse_ack

def test_pack_packet_header():
    pkt = pack_packet(0x1234, 1, b"abc")
    assert pkt[0:4] == (0x1234).to_bytes(4, "little")
    assert pkt[4:6] == (3).to_bytes(2, "little")
    assert pkt[6:8] == (1).to_bytes(2, "little")
    assert pkt[10:] == b"abc"

def test_parse_ack():
    assert parse_ack(b"BAK1" + (0x1000).to_bytes(4, "little")) == 0x1000
  • Step 2: Run the test to verify it fails

Run: cd bad-apple && python3 -m pytest tools/tests/test_send_movie.py -q Expected: FAIL because the module does not exist.

  • Step 3: Implement the sender and loader

Copy the raw-Ethernet framing from harness/udpstream / harness/badapple (ethertype and tap/interface selection). Send 1,450-byte chunks, wait for ACKs, retransmit from the first gap, and require a final BAK! ACK. The loader uses the verified LANCE init/ring sequence from harness/udpstream/vidstream.c, receives into 0x010000, and checks the whole-file CRC before jumping. If the real chip truncates a high RX buffer address, fall back to a low 32 KB staging ring and copy each datagram up.

  • Step 4: Run the sender unit test and the MAME tap test

Run: cd bad-apple && python3 -m pytest tools/tests/test_send_movie.py -q Expected: PASS.

Run harness/mame/run.sh net, then tools/load.sh --host 192.168.99.2 --iface vt1200 --movie data/movie.bin. Expected: final ACK, then the 30 s movie plays.

  • Step 5: Real unit

Run the same load.sh against the unit's address/interface, after confirming rdebug is reachable. Record a video. Recovery is a power cycle. Expected: the same picture, no host attached after the load.

  • Step 6: Commit
git add bad-apple/player/loader.c bad-apple/tools/send_movie.py \
  bad-apple/tools/load.sh bad-apple/tools/tests/test_send_movie.py
git commit -m "feat(badapple): network loader and delivery"

Task 10: Documentation, report and regression

Files:

  • Create/modify: bad-apple/README.md

  • Create: bad-apple/REPORT.md

  • Test: cd /home/arti/code/k-space/vt1200 && M=/home/arti/code/k-space/mame0289/vt1200 && ... progress.lua

  • Step 1: Write the README

Cover: one-command build pipeline, preview command, MAME run, real-unit load, recovery warning, data budget numbers, and the fact that the OS dies.

  • Step 2: Write the report

Record: measured data size, dirty-tile and rect percentiles, paint-cost histogram, dropped frames, MAME exact-match evidence, unit observations, and the remaining gaps (potrace temporal jitter, LANCE high-address uncertainty, incremental wipes).

  • Step 3: Run the regression baseline

Run the standard no-env progress.lua at 200 frames and confirm the line is still PC=3FDCE400 samples=199 (2 distinct PCs). Expected: unchanged.

  • Step 4: Commit
git add bad-apple/README.md bad-apple/REPORT.md
git commit -m "docs(badapple): usage, measurements and gaps"

Self-Review

  • Spec coverage: geometry (§5), vectorisation (§7.1), tile format (§7.2), frame adaptation (§7.3), cuts (§7.4), memory layout (§8.1), takeover (§8.2), loader (§8.3), rendering (§8.4), presentation (§8.5), pacing (§8.6), verification (§9), milestones (§10), risks (§11) each map to a task above.
  • Type consistency: Rect, TileRecord, FrameRecord, MovieHeader, MAX_DATA_BYTES are used with the same names in Tasks 3–10.
  • Review Focus: truncated data (Task 3/5), rect bias and >127 count (Task 3), cap (Task 3), delta chain (Task 4), partial load (Task 9) are pinned by tests or MAME checks.
  • Proportion: the plan names files, signatures and tests; it does not transcribe the decoder or the encoder bodies.