diff --git a/docs/superpowers/plans/2026-11-01-bad-apple-vector.md b/docs/superpowers/plans/2026-11-01-bad-apple-vector.md new file mode 100644 index 0000000..a469930 --- /dev/null +++ b/docs/superpowers/plans/2026-11-01-bad-apple-vector.md @@ -0,0 +1,782 @@ +# 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`](../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** + +```python +# 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 -t -i