diff --git a/bad-apple/.gitignore b/bad-apple/.gitignore new file mode 100644 index 0000000..4410c71 --- /dev/null +++ b/bad-apple/.gitignore @@ -0,0 +1,8 @@ +data/* +!data/manifest.json +data5/ +preview/ +player/build/ +player/work/ +__pycache__/ +.pytest_cache/ diff --git a/bad-apple/README.md b/bad-apple/README.md new file mode 100644 index 0000000..9add77b --- /dev/null +++ b/bad-apple/README.md @@ -0,0 +1,109 @@ +# 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`](../docs/superpowers/specs/2026-10-31-bad-apple-vector-design.md) +Plan: [`../docs/superpowers/plans/2026-11-01-bad-apple-vector.md`](../docs/superpowers/plans/2026-11-01-bad-apple-vector.md) +Measurements and gaps: [`REPORT.md`](REPORT.md) + +## Build the 30 s stream + +```sh +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 + +```sh +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) + +```sh +# 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 +BA_MOVIE=bad-apple/data5/movie.bin BA_START=0 BA_COUNT=300 \ + bad-apple/tools/run_mame.sh play + +# full 30 s, paced +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` exits when the payload sets its status marker. 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. + +```sh +bad-apple/tools/load.sh --host --movie bad-apple/data/movie.bin +``` + +This path is implemented but **not yet verified on the unit or over MAME's +tap** in this worktree: the tap was down. The OS socket API it uses is the one +already proven by `harness/udpstream/udpstream.c`. 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. diff --git a/bad-apple/REPORT.md b/bad-apple/REPORT.md new file mode 100644 index 0000000..7151873 --- /dev/null +++ b/bad-apple/REPORT.md @@ -0,0 +1,108 @@ +# Bad Apple 30 s vector prototype — report + +Date: 2026-11-01 +Design: [`2026-10-31-bad-apple-vector-design.md`](../docs/superpowers/specs/2026-10-31-bad-apple-vector-design.md) + +## What was built + +* Host pipeline: video → temporal median → blur/threshold → potrace + straight-line polygons → RDP → even-odd rasteriser → 32×32 tile deltas → + `movie.bin`. +* Payload: TMS34010 C decoder + `FILL L` primitive, 16-bit marker page, + display-interrupt pacing, UDP receive path. +* Tools: MAME debugger loader, VRAM checker, UDP sender, rdebug load script. + +## Measured numbers (first 30 s, 1,800 source frames) + +| quantity | value | +|---|---| +| source | 960×720, 60 fps, first 30 s | +| logical grid | 640×512, 2×2 screen blocks | +| records | 1,757 (43 source frames dropped by the cost gate) | +| data | **2,401,942 bytes** (cap 3.03 MB, gate 3.0 MB) | +| dirty tiles/frame | mean 44.9, p50 47, p95 72, max 320 | +| rectangles/frame | mean 393, p50 409, p95 670, max 1,175 | +| estimated cost/frame | mean 7.47 ms, p50 7.95 ms, **p95 12.01 ms**, max 37.99 ms | +| gate | 13.9 ms (one 72 Hz refresh) | + +The stream fits with ~0.6 MB spare. The p95 frame is inside one refresh; the +max is a scene cut that necessarily takes more than one. + +## Exactness evidence (MAME) + +`tools/check_vram.py` unpacks the 256-byte-stride VRAM dump, downsamples 2×2, +and compares bit-for-bit against the Python application of the records. + +| run | movie frame | result | +|---|---|---| +| test pattern | 0 | **EXACT** | +| 5 s slice | 0 | **EXACT** | +| 5 s slice | 180 | **EXACT** | +| 5 s slice | 297 | **EXACT** | +| 30 s stream | 0 | **EXACT** | +| 30 s stream | 900 | **EXACT** | + +The one-frame MAME run also proved the FILL geometry: a logical black +rectangle 10..109 × 20..69 became exactly screen x 20..219. The first version +was exactly twice as wide; the cause was the OS leaving PSIZE at 2 bpp. The +primitive now re-asserts PSIZE=1 on every call. + +Potrace emits **y-up** GeoJSON; the framebuffer is y-down. The first build was +vertically mirrored, and the VRAM checker still said EXACT because it consumed +the same flipped data. `trace()` now flips y before simplification, and +`test_trace_preserves_vertical_position` pins the orientation. The preview PNGs +were the visible symptom. + +## Pacing + +`wait_vsync()` programs `DPYINT=1`, clears `INTPEND`, and polls the DI bit at +`0xC0000120`. The loop reads each record's `ticks` from the index and advances +a 1/360 s accumulator by 5 per vsync, so a 1/60 s record needs 6 units and a +held record needs 12. Measured with `BA_COUNT=60`: status at MAME frame 2580, +74 frames after takeover at 2506 — 60 source frames in 72 display frames plus +startup, i.e. the 60-on-72 cadence is real, not free-running. + +## The performance gap MAME cannot close + +MAME needed ~2.1 emulated frames per movie record while replaying frames 0..900 +of the full stream. The encoder's model says a mean record is 7.47 ms of a +13.9 ms refresh, so MAME's TMS34010 interpreter is several times slower than the +model. MAME proves the picture and the delta chain; it cannot prove the unit's +60 fps. The unit's measured `FILL` rate (0.40 µs per 16-pixel word) is the +number the cost model uses. A real-unit run is the remaining performance +measurement. + +## Network delivery + +`player-net.bin` uses the OS UDP socket API (`socket_`, `bind_`, +`recvfrom_`, `malloc_`) to receive `movie.bin` on port 7001 into a heap buffer, +then DINTs and plays. The sender is `tools/send_movie.py`. Packets are +10-byte headers (offset, length, seq, crc16) plus body; a final +offset==length-of-file / 4-byte-checksum packet is required, and the receiver +refuses to jump unless the whole-file checksum matches. Out-of-range packets +are dropped without reading or writing outside the buffer. + +Status: the packet format, header offset, checksum path and sender are +unit-tested; the MAME tap was down in this worktree, so the UDP receive was +not exercised end-to-end. The same OS socket API is already proven on the unit +by `harness/udpstream/udpstream.c`. This is the one delivery-path gap. + +## Regression + +The no-env MAME baseline at 200 frames is unchanged: + +``` +--- distinct PCs sampled: 2 + PC=3FDCE400 samples=199 + PC=3FDCE350 samples=1 +``` + +## Remaining gaps + +1. Real-unit 30 s performance: needs a power-cycle run with + `tools/load.sh`. +2. UDP receive on the unit: socket API proven, this payload not yet. +3. Incremental painting can show a brief wipe on a cut; accepted for this + prototype. +4. Potrace/RDP temporal jitter was reduced with a 3-frame median but not + measured as a motion score. diff --git a/bad-apple/conftest.py b/bad-apple/conftest.py new file mode 100644 index 0000000..5334305 --- /dev/null +++ b/bad-apple/conftest.py @@ -0,0 +1,6 @@ +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/bad-apple/data/manifest.json b/bad-apple/data/manifest.json new file mode 100644 index 0000000..3b8b15b --- /dev/null +++ b/bad-apple/data/manifest.json @@ -0,0 +1,31 @@ +{ + "cost_us": { + "max": 37992, + "mean": 7472.069550369951, + "p50": 7948, + "p95": 12011 + }, + "data_bytes": 2401942, + "dirty_tiles": { + "max": 320, + "mean": 44.899829254410925, + "p50": 47, + "p95": 72 + }, + "display_hz": 72, + "dropped": 43, + "frame_count": 1757, + "height": 512, + "rects": { + "max": 1175, + "mean": 393.14968696642, + "p50": 409, + "p95": 670 + }, + "scale": 2, + "source_fps": 60, + "source_frames": 1800, + "tile": 32, + "video": "/tmp/opencode/badapple-real/hd60.mp4", + "width": 640 +} \ No newline at end of file diff --git a/bad-apple/pipeline/__init__.py b/bad-apple/pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bad-apple/pipeline/build.py b/bad-apple/pipeline/build.py new file mode 100644 index 0000000..7d3bcfb --- /dev/null +++ b/bad-apple/pipeline/build.py @@ -0,0 +1,105 @@ +"""CLI: video -> vector frames -> tile deltas -> movie.bin + manifest.""" +from __future__ import annotations + +import argparse +import json +import os + +import numpy as np + +from .cost import CostConstants, estimate_record_us, select_records +from .frames import extract_binary +from .movie import MovieHeader, write_movie +from .preview import write_previews +from .vectorize import vectorize_frame + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def _stats(values) -> dict: + if not values: + return {"mean": 0.0, "p50": 0, "p95": 0, "max": 0} + arr = np.asarray(values, dtype=float) + return { + "mean": float(arr.mean()), + "p50": int(np.percentile(arr, 50)), + "p95": int(np.percentile(arr, 95)), + "max": int(arr.max()), + } + + +def build_movie(video: str, out_dir: str, params: dict | None = None) -> dict: + with open(os.path.join(HERE, "params.json"), encoding="utf-8") as handle: + p = json.load(handle) + if params: + p.update(params) + + frames = [] + for index, frame in enumerate(extract_binary( + video, p["start"], p["duration"], p["fps"], + p["width"], p["height"], p)): + frames.append(vectorize_frame(frame, p)) + if (index + 1) % 60 == 0: + print(" vectorised %d frames" % (index + 1)) + if not frames: + raise RuntimeError("no frames extracted from %s" % video) + + constants = CostConstants() + records = select_records(frames, constants, budget_us=13900.0, max_skip=2) + + height, width = frames[0].shape + header = MovieHeader( + width=width, height=height, scale=int(p.get("scale", 2)), + tile=int(p.get("tile", 32)), tiles_x=width // int(p.get("tile", 32)), + tiles_y=height // int(p.get("tile", 32)), frame_count=len(records), + source_fps=int(p.get("fps", 60)), display_hz=72) + + os.makedirs(out_dir, exist_ok=True) + data_bytes = write_movie(os.path.join(out_dir, "movie.bin"), records, header) + + dirty = [len(record.tiles) for record in records] + rects = [sum(len(tile.rects) for tile in record.tiles) for record in records] + costs = [estimate_record_us(record, constants) for record in records] + manifest = { + "video": video, + "data_bytes": data_bytes, + "frame_count": len(records), + "source_frames": len(frames), + "dropped": len(frames) - len(records), + "width": width, + "height": height, + "scale": header.scale, + "tile": header.tile, + "source_fps": header.source_fps, + "display_hz": header.display_hz, + "dirty_tiles": _stats(dirty), + "rects": _stats(rects), + "cost_us": _stats(costs), + } + with open(os.path.join(out_dir, "manifest.json"), "w", + encoding="utf-8") as handle: + json.dump(manifest, handle, indent=2, sort_keys=True) + write_previews(os.path.join(out_dir, "preview"), frames, + [0, 60, 300, 900, 1799]) + return manifest + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--video", required=True) + parser.add_argument("--out", default="data") + parser.add_argument("--duration", type=float, default=None) + parser.add_argument("--start", type=float, default=None) + args = parser.parse_args(argv) + overrides = {} + if args.duration is not None: + overrides["duration"] = args.duration + if args.start is not None: + overrides["start"] = args.start + manifest = build_movie(args.video, args.out, overrides) + print(json.dumps(manifest, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bad-apple/pipeline/cost.py b/bad-apple/pipeline/cost.py new file mode 100644 index 0000000..35f0b55 --- /dev/null +++ b/bad-apple/pipeline/cost.py @@ -0,0 +1,70 @@ +"""Paint-cost model and build-time frame selection.""" +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Sequence + +import numpy as np + +from .movie import FrameRecord, TileRecord +from .tilepack import diff_tiles + + +@dataclass(frozen=True) +class CostConstants: + word_us: float = 0.40 + call_us: float = 3.0 + rect_us: float = 1.0 + + +def _words(width_px: int) -> int: + return max(1, int(math.ceil(width_px / 16.0))) + + +def estimate_record_us(record: FrameRecord, constants: CostConstants, + scale: int = 2, tile: int = 32) -> float: + """Unit-time estimate for one record, including call and decode overhead.""" + tile_words = _words(tile * scale) * tile * scale + total = 0.0 + for rec in record.tiles: + total += tile_words * constants.word_us + constants.call_us + for rect in rec.rects: + total += (_words(rect.w * scale) * rect.h * scale + * constants.word_us) + total += constants.call_us + constants.rect_us + return total + + +def select_records(frames: Sequence[np.ndarray], constants: CostConstants, + budget_us: float = 13900.0, + max_skip: int = 2) -> list[FrameRecord]: + """Build exact deltas, dropping source frames that cannot fit the gate. + + The dropped frame is never emitted; the next kept frame is diffed from the + last *shown* frame and carries the accumulated ``ticks``. A run of heavy + frames is eventually emitted anyway (a cut), so the movie still advances. + """ + if not frames: + return [] + prev = np.zeros_like(frames[0], dtype=bool) + out: list[FrameRecord] = [] + pending = 0 + skipped = 0 + last = frames[0] + for current in frames: + pending += 1 + last = current + rec_tiles = diff_tiles(prev, current, tile=32) + cost = estimate_record_us(FrameRecord(1, 0, rec_tiles), constants) + if cost <= budget_us or skipped >= max_skip: + out.append(FrameRecord(pending, 0, rec_tiles)) + prev = current + pending = 0 + skipped = 0 + else: + skipped += 1 + if pending: + rec_tiles = diff_tiles(prev, last, tile=32) + out.append(FrameRecord(pending, 0, rec_tiles)) + return out diff --git a/bad-apple/pipeline/frames.py b/bad-apple/pipeline/frames.py new file mode 100644 index 0000000..4ba37b1 --- /dev/null +++ b/bad-apple/pipeline/frames.py @@ -0,0 +1,96 @@ +"""Video extraction and the small image primitives the pipeline shares.""" +from __future__ import annotations + +import subprocess +from typing import Iterator, Sequence + +import numpy as np + + +def temporal_median3(frames: Sequence[np.ndarray]) -> np.ndarray: + """Median of three same-shaped frames, as uint8.""" + if len(frames) != 3: + raise ValueError("temporal_median3 wants exactly three frames") + return np.median(np.stack(frames), axis=0).astype(np.uint8) + + +def box_blur3(gray: np.ndarray) -> np.ndarray: + """3x3 box blur with edge clamping (no wraparound).""" + if gray.ndim != 2: + raise ValueError("box_blur3 wants a 2-D gray image") + p = np.pad(gray.astype(np.float32), 1, mode="edge") + total = ( + p[0:-2, 0:-2] + p[0:-2, 1:-1] + p[0:-2, 2:] + + p[1:-1, 0:-2] + p[1:-1, 1:-1] + p[1:-1, 2:] + + p[2:, 0:-2] + p[2:, 1:-1] + p[2:, 2:] + ) + return np.rint(total / 9.0).astype(np.uint8) + + +def binarize(gray: np.ndarray, threshold: int = 127) -> np.ndarray: + """True means black ink (the source is dark-on-light).""" + return gray < threshold + + +def pack_lsb(mask: np.ndarray) -> bytes: + """Pack rows LSB-first: bit 0 of a byte is the leftmost pixel.""" + if mask.ndim != 2 or mask.shape[1] % 8: + raise ValueError("pack_lsb wants 2-D rows whose width is a multiple of 8") + return np.packbits(mask.astype(bool), axis=1, bitorder="little").tobytes() + + +def extract_gray(video: str, start: float, duration: float, fps: int, + width: int, height: int) -> Iterator[np.ndarray]: + """Yield (height, width) uint8 gray frames from ffmpeg.""" + vf = "fps=%s,scale=%d:%d:flags=lanczos,format=gray" % ( + fps, width, height) + cmd = [ + "ffmpeg", "-v", "error", + "-ss", str(start), + "-i", video, + "-t", str(duration), + "-vf", vf, + "-f", "rawvideo", "-", + ] + size = width * height + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, bufsize=size * 4) + try: + while True: + buf = proc.stdout.read(size) + if len(buf) < size: + break + yield np.frombuffer(buf, dtype=np.uint8).reshape(height, width) + finally: + proc.stdout.close() + proc.wait() + if proc.returncode != 0: + raise RuntimeError("ffmpeg failed with status %d" % proc.returncode) + + +def extract_binary(video: str, start: float, duration: float, fps: int, + width: int, height: int, + params: dict) -> Iterator[np.ndarray]: + """Yield stylised binary frames: temporal median, blur, threshold. + + Exactly ``duration * fps`` frames are produced. The first output is the + first frame against itself; each later output is the median of the + previous, current and next frame. + """ + threshold = int(params.get("threshold", 127)) + want_blur = int(params.get("blur", 3)) + it = extract_gray(video, start, duration, fps, width, height) + try: + first = next(it) + except StopIteration: + return + + def render(median_frame: np.ndarray) -> np.ndarray: + gray = box_blur3(median_frame) if want_blur >= 3 else median_frame + return binarize(gray, threshold) + + a = first + b = first + yield render(temporal_median3([a, b, first])) + for current in it: + yield render(temporal_median3([a, b, current])) + a, b = b, current diff --git a/bad-apple/pipeline/movie.py b/bad-apple/pipeline/movie.py new file mode 100644 index 0000000..7af49cf --- /dev/null +++ b/bad-apple/pipeline/movie.py @@ -0,0 +1,190 @@ +"""The on-RAM movie format: header, index, and per-frame tile deltas.""" +from __future__ import annotations + +import os +import struct +from dataclasses import dataclass, field +from typing import Sequence + +MAGIC = b"BAPL" +VERSION = 1 +HEADER_SIZE = 36 +INDEX_ENTRY_SIZE = 6 + +#: The data must stop below the payload's rdebug origin at byte 0x318000. +MAX_DATA_BYTES = 0x308000 + + +class MovieFormatError(Exception): + pass + + +@dataclass(frozen=True) +class Rect: + x0: int + y0: int + w: int + h: int + + +@dataclass(frozen=True) +class TileRecord: + tile_index: int + bg: int + rects: list[Rect] = field(default_factory=list) + + +@dataclass(frozen=True) +class FrameRecord: + ticks: int + flags: int + tiles: list[TileRecord] = field(default_factory=list) + + +@dataclass(frozen=True) +class MovieHeader: + width: int + height: int + scale: int + tile: int + tiles_x: int + tiles_y: int + frame_count: int + source_fps: int + display_hz: int + + def pack(self) -> bytes: + index_offset = HEADER_SIZE + data_offset = _align4(index_offset + INDEX_ENTRY_SIZE * self.frame_count) + return struct.pack( + "<4s7H3I3H", + MAGIC, VERSION, self.width, self.height, self.scale, self.tile, + self.tiles_x, self.tiles_y, self.frame_count, + index_offset, data_offset, + self.source_fps, self.display_hz, 0) + + +def _align4(value: int) -> int: + return (value + 3) & ~3 + + +def _pack_rect(rect: Rect) -> bytes: + if not (0 <= rect.x0 < 32 and 0 <= rect.y0 < 32 + and 1 <= rect.w <= 32 and 1 <= rect.h <= 32 + and rect.x0 + rect.w <= 32 and rect.y0 + rect.h <= 32): + raise MovieFormatError("rect outside a 32x32 tile: %r" % (rect,)) + value = (rect.x0 | (rect.y0 << 5) | ((rect.w - 1) << 10) + | ((rect.h - 1) << 15)) + return value.to_bytes(3, "little") + + +def _unpack_rect(data: bytes, offset: int) -> tuple[Rect, int]: + if offset + 3 > len(data): + raise MovieFormatError("truncated rect") + value = data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) + rect = Rect( + x0=value & 0x1F, + y0=(value >> 5) & 0x1F, + w=((value >> 10) & 0x1F) + 1, + h=((value >> 15) & 0x1F) + 1, + ) + return rect, offset + 3 + + +def encode_frame(record: FrameRecord) -> bytes: + if len(record.tiles) > 0xFFFF: + raise MovieFormatError("too many dirty tiles in one frame") + out = bytearray(struct.pack(" 0x7FFF: + raise MovieFormatError("too many rects in one tile") + out += struct.pack(" tuple[FrameRecord, int]: + if offset < 0 or offset + 2 > len(data): + raise MovieFormatError("truncated frame count") + count = data[offset] | (data[offset + 1] << 8) + offset += 2 + tiles: list[TileRecord] = [] + for _ in range(count): + if offset + 4 > len(data): + raise MovieFormatError("truncated tile header") + tile_index = data[offset] | (data[offset + 1] << 8) + packed = data[offset + 2] | (data[offset + 3] << 8) + offset += 4 + bg = (packed >> 15) & 1 + rect_count = packed & 0x7FFF + if offset + rect_count * 3 > len(data): + raise MovieFormatError("truncated tile rects") + rects: list[Rect] = [] + for _ in range(rect_count): + rect, offset = _unpack_rect(data, offset) + rects.append(rect) + tiles.append(TileRecord(tile_index, bg, rects)) + return FrameRecord(1, 0, tiles), offset + + +def write_movie(path: str, frames: Sequence[FrameRecord], + header: MovieHeader) -> int: + """Write the movie; return its size. Refuses anything over the cap.""" + records = [encode_frame(frame) for frame in frames] + count = len(records) + index_offset = HEADER_SIZE + data_offset = _align4(index_offset + INDEX_ENTRY_SIZE * count) + total = data_offset + sum(len(record) for record in records) + if total > MAX_DATA_BYTES: + raise MovieFormatError( + "movie is %d bytes, over the %d-byte data cap" + % (total, MAX_DATA_BYTES)) + + out = bytearray(total) + out[0:HEADER_SIZE] = MovieHeader( + header.width, header.height, header.scale, header.tile, + header.tiles_x, header.tiles_y, count, + header.source_fps, header.display_hz).pack() + offset = data_offset + for i, (frame, record) in enumerate(zip(frames, records)): + struct.pack_into(" tuple[MovieHeader, list[FrameRecord]]: + with open(path, "rb") as handle: + data = handle.read() + if len(data) < HEADER_SIZE: + raise MovieFormatError("truncated header") + fields = struct.unpack("<4s7H3I3H", data[:HEADER_SIZE]) + (magic, version, width, height, scale, tile, tiles_x, tiles_y, + frame_count, index_offset, data_offset, source_fps, display_hz, + _reserved) = fields + if magic != MAGIC or version != VERSION: + raise MovieFormatError("bad magic or version") + header = MovieHeader(width, height, scale, tile, tiles_x, tiles_y, + frame_count, source_fps, display_hz) + if index_offset + frame_count * INDEX_ENTRY_SIZE > len(data): + raise MovieFormatError("truncated index") + + frames: list[FrameRecord] = [] + for i in range(frame_count): + offset, ticks, flags = struct.unpack_from( + " len(data): + raise MovieFormatError("frame offset outside data area") + record, _ = decode_frame(data, offset) + frames.append(FrameRecord(ticks, flags, record.tiles)) + return header, frames diff --git a/bad-apple/pipeline/params.json b/bad-apple/pipeline/params.json new file mode 100644 index 0000000..25c7bc4 --- /dev/null +++ b/bad-apple/pipeline/params.json @@ -0,0 +1,15 @@ +{ + "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 +} diff --git a/bad-apple/pipeline/preview.py b/bad-apple/pipeline/preview.py new file mode 100644 index 0000000..4700855 --- /dev/null +++ b/bad-apple/pipeline/preview.py @@ -0,0 +1,25 @@ +"""Write human-viewable previews of the logical frames.""" +from __future__ import annotations + +import os +from typing import Sequence + +import numpy as np +from PIL import Image + + +def write_previews(out_dir: str, frames: Sequence[np.ndarray], + indices: Sequence[int], scale: int = 4) -> list[str]: + os.makedirs(out_dir, exist_ok=True) + written: list[str] = [] + for index in indices: + if index < 0 or index >= len(frames): + continue + gray = np.where(frames[index], 0, 255).astype(np.uint8) + image = Image.fromarray(gray, mode="L") + image = image.resize((gray.shape[1] * scale, gray.shape[0] * scale), + Image.Resampling.NEAREST) + path = os.path.join(out_dir, "frame_%04d.png" % index) + image.save(path) + written.append(path) + return written diff --git a/bad-apple/pipeline/tilepack.py b/bad-apple/pipeline/tilepack.py new file mode 100644 index 0000000..a36a676 --- /dev/null +++ b/bad-apple/pipeline/tilepack.py @@ -0,0 +1,81 @@ +"""Turn binary frames into per-tile background + rectangle deltas.""" +from __future__ import annotations + +import numpy as np + +from .movie import FrameRecord, Rect, TileRecord + + +def _row_runs(row: np.ndarray) -> list[tuple[int, int]]: + """Inclusive (start, end) runs of True in one row.""" + padded = np.concatenate(([0], row.astype(np.int8), [0])) + diff = np.diff(padded) + starts = np.where(diff == 1)[0] + ends = np.where(diff == -1)[0] - 1 + return list(zip(starts.tolist(), ends.tolist())) + + +def decompose_tile(tile: np.ndarray) -> tuple[int, list[Rect]]: + """Majority background plus rectangles of the minority colour.""" + tile = tile.astype(bool) + if tile.ndim != 2: + raise ValueError("a tile must be 2-D") + height, width = tile.shape + black = int(tile.sum()) + bg = 1 if black > tile.size // 2 else 0 + fg = tile if bg == 0 else ~tile + + active: dict[tuple[int, int], int] = {} + rects: list[Rect] = [] + for y in range(height): + runs = set(_row_runs(fg[y])) + for key in list(active): + if key not in runs: + y0 = active.pop(key) + rects.append(Rect(key[0], y0, key[1] - key[0] + 1, y - y0)) + for key in runs: + if key not in active: + active[key] = y + for key, y0 in active.items(): + rects.append(Rect(key[0], y0, key[1] - key[0] + 1, height - y0)) + rects.sort(key=lambda r: (r.y0, r.x0)) + return bg, rects + + +def diff_tiles(prev: np.ndarray, cur: np.ndarray, + tile: int = 32) -> list[TileRecord]: + """The tiles whose pixels changed, with the new tile's rectangle list.""" + if prev.shape != cur.shape: + raise ValueError("prev and cur must have the same shape") + height, width = cur.shape + if height % tile or width % tile: + raise ValueError("frame dimensions must be a multiple of the tile") + tiles_x = width // tile + out: list[TileRecord] = [] + for ty in range(height // tile): + for tx in range(tiles_x): + y = ty * tile + x = tx * tile + old = prev[y:y + tile, x:x + tile] + new = cur[y:y + tile, x:x + tile] + if np.array_equal(old, new): + continue + bg, rects = decompose_tile(new) + out.append(TileRecord(ty * tiles_x + tx, bg, rects)) + return out + + +def apply_record(canvas: np.ndarray, record: FrameRecord, + tile: int = 32) -> None: + """Apply one frame record to a logical canvas in place.""" + height, width = canvas.shape + tiles_x = width // tile + for rec in record.tiles: + ty, tx = divmod(rec.tile_index, tiles_x) + y = ty * tile + x = tx * tile + block = canvas[y:y + tile, x:x + tile] + block[:, :] = bool(rec.bg) + for rect in rec.rects: + block[rect.y0:rect.y0 + rect.h, + rect.x0:rect.x0 + rect.w] = not bool(rec.bg) diff --git a/bad-apple/pipeline/vectorize.py b/bad-apple/pipeline/vectorize.py new file mode 100644 index 0000000..1e5c74f --- /dev/null +++ b/bad-apple/pipeline/vectorize.py @@ -0,0 +1,180 @@ +"""Turn a binary frame into simplified straight-edged polygons, and back.""" +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +from typing import Iterable, Sequence + +import numpy as np + +Point = tuple[float, float] +Ring = list[Point] +Polygon = list[Ring] + + +def _distance_to_line(p: Point, a: Point, b: Point) -> float: + if a == b: + return ((p[0] - a[0]) ** 2 + (p[1] - a[1]) ** 2) ** 0.5 + ax, ay = a + bx, by = b + px, py = p + dx, dy = bx - ax, by - ay + return abs(dy * px - dx * py + bx * ay - by * ax) / ((dx * dx + dy * dy) ** 0.5) + + +def rdp(points: Sequence[Point], epsilon: float) -> list[Point]: + """Ramer-Douglas-Peucker on an open polyline.""" + pts = list(points) + if len(pts) <= 2: + return pts + a, b = pts[0], pts[-1] + worst = 0.0 + index = 0 + for i in range(1, len(pts) - 1): + d = _distance_to_line(pts[i], a, b) + if d > worst: + worst, index = d, i + if worst <= epsilon: + return [a, b] + left = rdp(pts[:index + 1], epsilon) + right = rdp(pts[index:], epsilon) + return left[:-1] + right + + +def rdp_closed(points: Sequence[Point], epsilon: float) -> list[Point]: + """Simplify a closed ring, keeping it closed.""" + pts = list(points) + if len(pts) > 1 and pts[0] == pts[-1]: + pts = pts[:-1] + if len(pts) < 3: + return list(points) + # Split at the point farthest from the first so the endpoints are not + # coincident (RDP degenerates when a == b). + far = max(range(1, len(pts)), + key=lambda i: (pts[i][0] - pts[0][0]) ** 2 + + (pts[i][1] - pts[0][1]) ** 2) + first = rdp(pts[:far + 1], epsilon) + second = rdp(pts[far:] + [pts[0]], epsilon) + ring = first[:-1] + second + if ring[0] != ring[-1]: + ring.append(ring[0]) + return ring + + +def parse_potrace_geojson(text: str) -> list[Polygon]: + """Parse potrace's GeoJSON into polygons of (outer, holes...) rings.""" + data = json.loads(text) + out: list[Polygon] = [] + for feature in data.get("features", []): + geometry = feature.get("geometry") or {} + kind = geometry.get("type") + coordinates = geometry.get("coordinates") or [] + if kind == "Polygon": + polygons = [coordinates] + elif kind == "MultiPolygon": + polygons = coordinates + else: + continue + for rings in polygons: + poly: Polygon = [] + for ring in rings: + poly.append([(float(x), float(y)) for x, y in ring]) + if poly: + out.append(poly) + return out + + +def trace(mask: np.ndarray, params: dict) -> list[Polygon]: + """Trace a black mask into simplified straight-line polygons.""" + if mask.ndim != 2: + raise ValueError("trace wants a 2-D mask") + height, width = mask.shape + if width % 8: + raise ValueError("potrace input width must be a multiple of 8") + epsilon = float(params.get("rdp_epsilon", 1.5)) + min_segment = float(params.get("min_segment", 2)) + + pbm = np.packbits(mask.astype(bool), axis=1, bitorder="big").tobytes() + with tempfile.TemporaryDirectory(prefix="ba-trace-") as tmp: + pbm_path = os.path.join(tmp, "frame.pbm") + json_path = os.path.join(tmp, "frame.json") + with open(pbm_path, "wb") as handle: + handle.write(b"P4\n%d %d\n" % (width, height)) + handle.write(pbm) + subprocess.run( + ["potrace", "-b", "geojson", "-a", "0", "-t", "4", "-u", "1", + "-o", json_path, pbm_path], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + with open(json_path, "r", encoding="utf-8") as handle: + polygons = parse_potrace_geojson(handle.read()) + + # potrace's GeoJSON uses a y-up coordinate system; the framebuffer is + # y-down. Without this the whole movie is vertically mirrored. + flipped: list[Polygon] = [] + for poly in polygons: + rings: Polygon = [] + for ring in poly: + rings.append([(x, float(height) - y) for x, y in ring]) + flipped.append(rings) + polygons = flipped + + simplified: list[Polygon] = [] + for poly in polygons: + rings: Polygon = [] + for ring in poly: + ring = rdp_closed(ring, epsilon) + if len(ring) < 4: + continue + longest = max( + ((ring[i][0] - ring[i - 1][0]) ** 2 + + (ring[i][1] - ring[i - 1][1]) ** 2) ** 0.5 + for i in range(1, len(ring)) + ) + if longest < min_segment: + continue + rings.append(ring) + if rings: + simplified.append(rings) + return simplified + + +def rasterize(polygons: Iterable[Polygon], width: int, height: int) -> np.ndarray: + """Even-odd scanline fill of polygons (outer rings and holes).""" + out = np.zeros((height, width), dtype=bool) + edges: list[tuple[float, float, float, float]] = [] + for poly in polygons: + for ring in poly: + for i in range(1, len(ring)): + x0, y0 = ring[i - 1] + x1, y1 = ring[i] + if y0 == y1: + continue + edges.append((x0, y0, x1, y1)) + if not edges: + return out + + for y in range(height): + scan = y + 0.5 + xs: list[float] = [] + for x0, y0, x1, y1 in edges: + if (y0 <= scan < y1) or (y1 <= scan < y0): + xs.append(x0 + (scan - y0) * (x1 - x0) / (y1 - y0)) + xs.sort() + for i in range(0, len(xs) - 1, 2): + left, right = xs[i], xs[i + 1] + start = max(0, int(np.ceil(left - 0.5))) + end = min(width - 1, int(np.floor(right - 0.5))) + if end >= start: + out[y, start:end + 1] = True + return out + + +def vectorize_frame(mask: np.ndarray, params: dict) -> np.ndarray: + """Trace and re-rasterise one logical frame.""" + height, width = mask.shape + return rasterize(trace(mask, params), width, height) diff --git a/bad-apple/player/build.sh b/bad-apple/player/build.sh new file mode 100755 index 0000000..b98b837 --- /dev/null +++ b/bad-apple/player/build.sh @@ -0,0 +1,132 @@ +#!/bin/sh +# Build the Bad Apple payload with the 1987 TI TMS34010 SDK under DOSBox. +# +# .text at bit 0x018C0000, .bss at bit 0x01C00000 (the free block the rdebug +# loader already uses). Outputs: build/player.coff, player.bin, player.env. +set -eu + +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +DIR=$(cd "$(dirname "$0")" && pwd) +WORK="$DIR/work" +OUT="$DIR/build" +IMG="$ROOT/tools/tms34010-sdk/1987-05 TMS34010 SDK/1987-11-06 TMS34010 Simulator-C Tools-ASM Tools.img" +DOSBOX_CONF="$ROOT/harness/sdk-scratch/dosbox-bench.conf" + +TEXT=018C0000 +BSS=01C00000 +NET=${NET:-0} +if [ "$NET" = 1 ]; then + NAME=player-net +else + NAME=player +fi + +mkdir -p "$WORK" "$OUT" + +if [ ! -x "$WORK/GSPCG.EXE" ]; then + mcopy -s -n -i "$IMG" "::/A2/*" "$WORK/" + mcopy -s -n -i "$IMG" "::/C2/*" "$WORK/" +fi + +cp "$DIR/main.c" "$WORK/MAIN.C" +cp "$DIR/movie.c" "$WORK/MOVIE.C" +cp "$DIR/movie.h" "$WORK/MOVIE.H" +cp "$DIR/romapi.h" "$WORK/ROMAPI.H" +printf '#define NET_DEFAULT %s\n' "$NET" > "$WORK/MODECFG.H" +cp "$DIR/fill.asm" "$WORK/FILL.ASM" +cp "$DIR/entry.asm" "$WORK/ENTRY.ASM" + +{ + printf '%s\n' '-a' + printf '%s\n' '-o PLAYER.out' + printf '%s\n' '-m PLAYER.map' + printf '%s\n' '-e _start' + printf '%s\n' 'MAIN.obj' + printf '%s\n' 'MOVIE.obj' + printf '%s\n' 'FILL.obj' + printf '%s\n' 'ENTRY.obj' + printf '%s\n' '-l RTS.LIB' + printf '%s\n' 'MEMORY' + printf '%s\n' '{' + printf '%s\n' " prog: origin = ${TEXT}h, length = 20000h" + printf '%s\n' " ram: origin = ${BSS}h, length = 200000h" + printf '%s\n' '}' + printf '%s\n' 'SECTIONS' + printf '%s\n' '{' + printf '%s\n' ' .text: {} > prog' + printf '%s\n' ' .data: {} > prog' + printf '%s\n' ' .cinit: {} > prog' + printf '%s\n' ' .bss: {} > ram' + printf '%s\n' '}' +} > "$WORK/PLAYER.CMD" +sed -i 's/$/\r/' "$WORK/PLAYER.CMD" + +{ + printf 'gspcpp MAIN\r\n' + printf 'gspcc MAIN\r\n' + printf 'gspcg MAIN\r\n' + printf 'gspa MAIN MAIN.obj MAIN.lst -l\r\n' + printf 'gspcpp MOVIE\r\n' + printf 'gspcc MOVIE\r\n' + printf 'gspcg MOVIE\r\n' + printf 'gspa MOVIE MOVIE.obj MOVIE.lst -l\r\n' + printf 'gspa FILL FILL.obj FILL.lst -l\r\n' + printf 'gspa ENTRY ENTRY.obj ENTRY.lst -l\r\n' + printf 'gsplnk PLAYER.cmd\r\n' + printf 'exit\r\n' +} > "$WORK/BUILD.BAT" + +rm -f "$WORK"/MAIN.OBJ "$WORK"/MOVIE.OBJ "$WORK"/FILL.OBJ \ + "$WORK"/ENTRY.OBJ "$WORK"/PLAYER.OUT "$WORK"/PLAYER.MAP \ + "$WORK"/MAIN.LST "$WORK"/MOVIE.LST "$WORK"/FILL.LST \ + "$WORK"/ENTRY.LST + +SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \ +timeout 240 dosbox -conf "$DOSBOX_CONF" -noprimaryconf -nolocalconf \ + -c "mount c $WORK" -c "c:" \ + -c "BUILD.BAT" \ + -c "exit" -exit > "$WORK/dosbox.log" 2>&1 + +if [ ! -f "$WORK/PLAYER.OUT" ]; then + echo "build.sh: GSPLNK produced no PLAYER.OUT" >&2 + sed 's/\r$//' "$WORK/PLAYER.MAP" 2>/dev/null | tail -20 >&2 || true + for f in MAIN.LST MOVIE.LST FILL.LST ENTRY.LST; do + sed 's/\r$//' "$WORK/$f" 2>/dev/null | grep -i 'error' \ + | grep -vi 'no errors' >&2 || true + done + exit 1 +fi +for f in MAIN.LST MOVIE.LST FILL.LST ENTRY.LST; do + if sed 's/\r$//' "$WORK/$f" 2>/dev/null | grep -i 'error' \ + | grep -qvi 'no errors'; then + echo "build.sh: $f diagnostics:" >&2 + sed 's/\r$//' "$WORK/$f" | grep -in 'error' \ + | grep -vi 'no errors' >&2 + exit 1 + fi +done + +cp "$WORK/PLAYER.OUT" "$OUT/player.coff" +sed 's/\r$//' "$WORK/PLAYER.MAP" > "$OUT/player.map" +sed 's/\r$//' "$WORK/MAIN.LST" > "$OUT/main.lst" 2>/dev/null || true +sed 's/\r$//' "$WORK/MOVIE.LST" > "$OUT/movie.lst" 2>/dev/null || true +sed 's/\r$//' "$WORK/FILL.LST" > "$OUT/fill.lst" 2>/dev/null || true + +python3 "$ROOT/harness/hello-world/cofextract.py" \ + "$WORK/PLAYER.OUT" "$OUT/$NAME.bin" > "$OUT/cofextract.txt" +cp "$WORK/PLAYER.OUT" "$OUT/$NAME.coff" + +ENTRY_PTR=$(sed 's/\r$//' "$WORK/PLAYER.MAP" \ + | awk '/ENTRY POINT SYMBOL/ { print $NF; exit }') +MARK_PTR=$(sed 's/\r$//' "$WORK/PLAYER.MAP" \ + | awk '$2 == "_marker_page" { print $1; exit }') +if [ -z "$ENTRY_PTR" ] || [ -z "$MARK_PTR" ]; then + echo "build.sh: no entry point or _marker_page in PLAYER.map" >&2 + exit 1 +fi +printf 'ENTRY_PTR=0x%s\nMARK_PTR=0x%s\n' "$ENTRY_PTR" "$MARK_PTR" \ + > "$OUT/$NAME.env" + +echo "build.sh: $OUT/$NAME.bin ($(wc -c < "$OUT/$NAME.bin") bytes)" +grep -E "ENTRY POINT|^\.(text|cinit|bss)" "$OUT/player.map" | head -8 +echo "build.sh: entry bit 0x$ENTRY_PTR" diff --git a/bad-apple/player/entry.asm b/bad-apple/player/entry.asm new file mode 100644 index 0000000..f198ab1 --- /dev/null +++ b/bad-apple/player/entry.asm @@ -0,0 +1,79 @@ +; entry.asm -- payload entry and ROM socket/allocator thunks. +; +; The payload is entered by a debugger/rdebug jump and never returns. Set the +; C stack, call main, and spin if main ever returns. +; +; The firmware's wrappers end in RETS 2 (they pop the return address and two +; argument words, and restore A14 from a caller-pushed slot). A plain calla +; would leave SP off by one word; these thunks save/restore SP and A14 exactly +; as harness/udpstream/entry.asm does. + + .text + .globl _start + .globl _main +_start: + setf 32, 0, 1 + movi _cstack, a14 + move a14, a13 + calla _main +spin: + jruc spin + + .globl _socket_ +_socket_: + move sp, @_sys_sp, 1 + move a14, @_sys_a14, 1 + calla 3F5C9A00h + move @_sys_sp, sp, 1 + move @_sys_a14, a14, 1 + rets 2 + + .globl _bind_ +_bind_: + move sp, @_sys_sp, 1 + move a14, @_sys_a14, 1 + calla 3F5CAA80h + move @_sys_sp, sp, 1 + move @_sys_a14, a14, 1 + rets 2 + + .globl _recvfrom_ +_recvfrom_: + move sp, @_sys_sp, 1 + move a14, @_sys_a14, 1 + calla 3F5D7D50h + move @_sys_sp, sp, 1 + move @_sys_a14, a14, 1 + rets 2 + + .globl _close_ +_close_: + move sp, @_sys_sp, 1 + move a14, @_sys_a14, 1 + calla 3F5E8930h + move @_sys_sp, sp, 1 + move @_sys_a14, a14, 1 + rets 2 + + .globl _malloc_ +_malloc_: + move sp, @_sys_sp, 1 + move a14, @_sys_a14, 1 + calla 3F701230h + move @_sys_sp, sp, 1 + move @_sys_a14, a14, 1 + rets 2 + + .globl _delay_ +_delay_: + move sp, @_sys_sp, 1 + move a14, @_sys_a14, 1 + calla 3F833A20h + move @_sys_sp, sp, 1 + move @_sys_a14, a14, 1 + rets 2 + + .bss _cstack, 32768, 16 ; 32768 bits = 4096 bytes + .bss _sys_sp, 32, 16 + .bss _sys_a14, 32, 16 + .end diff --git a/bad-apple/player/fill.asm b/bad-apple/player/fill.asm new file mode 100644 index 0000000..d037393 --- /dev/null +++ b/bad-apple/player/fill.asm @@ -0,0 +1,61 @@ +; fill.asm -- one FILL L rectangle into the 1280x1024, 256-byte-stride VRAM. +; +; The C wrapper publishes: +; fill_daddr linear bit address of the top-left pixel +; fill_dydx (rows << 16) | columns, in screen pixels +; fill_color 0x0000 = black, 0xFFFF = white +; +; FILL L destroys DADDR and DYDX, so the wrapper reloads them every call. +; PSIZE, PMASK and CONTROL are programmed once by _fill_init_. + + .ref _fill_daddr + .ref _fill_dydx + .ref _fill_color + .text + + .globl _fill_run_ +_fill_run_: + setf 32, 0, 1 + move b2, -*sp, 1 + move b3, -*sp, 1 + move b7, -*sp, 1 + move b9, -*sp, 1 + + move @_fill_daddr, b2, 1 + movi 800h, b3 ; 2048 bits = 256-byte row pitch + move @_fill_dydx, b7, 1 + move @_fill_color, b9, 1 + + ; PSIZE is cheap to re-assert; if the resident OS left it at 2 bpp, + ; FILL would write twice as many pixels as asked for. + movi 1, a1 + move a1, @0C0000150h, 0 + + fill l + + move *sp+, b9, 1 + move *sp+, b7, 1 + move *sp+, b3, 1 + move *sp+, b2, 1 + rets + + .globl _vid_dint_ +_vid_dint_: + dint + rets + + .globl _fill_init_ +_fill_init_: + setf 32, 0, 1 + movi 1, a1 + move a1, @0C0000150h, 0 ; PSIZE = 1 + clr a1 + move a1, @0C0000160h, 0 ; PMASK = 0 + move a1, @0C0000170h, 0 ; upper half, forward compatibility + move @0C00000B0h, a1, 0 ; CONTROL + andi 0801Fh, a1 ; keep CD and RM/RR, raster op 0 + move a1, @0C00000B0h, 0 + move @0C00000B0h, a1, 0 ; read back: I/O writes are pipelined + rets + + .end diff --git a/bad-apple/player/main.c b/bad-apple/player/main.c new file mode 100644 index 0000000..f14568e --- /dev/null +++ b/bad-apple/player/main.c @@ -0,0 +1,279 @@ +/* main.c -- Bad Apple payload: network receive, then tile playback. */ +#include "movie.h" +#include "romapi.h" +#include "modecfg.h" + +#ifndef NET_DEFAULT +#define NET_DEFAULT 0 +#endif + +#define NET_LEN 2401942 +#define NET_PORT 7001 + +#define MOVIE_BASE 0x00080000 +#define MOVIE_MAX 0x00308000 +#define VRAM_BASE 0x04000000 +#define ROW_BITS 2048 + +/* The marker/control block lives in .bss, not at bit 0x01810000: a 2.4 MB + * heap allocation can cross that address and would corrupt the movie. */ +unsigned int marker_page[16]; +#define MARK ((unsigned int)marker_page) +#define CTRL_START (MARK + 0x20 * 8) +#define CTRL_COUNT (MARK + 0x24 * 8) +#define CTRL_PACE (MARK + 0x28 * 8) +#define CTRL_MOVIE (MARK + 0x2C * 8) +#define CTRL_MOVIELEN (MARK + 0x30 * 8) +#define CTRL_WAIT (MARK + 0x34 * 8) +#define CTRL_READY (MARK + 0x38 * 8) +#define CTRL_NET (MARK + 0x3C * 8) +#define CTRL_MAGIC (MARK + 0x1C * 8) +#define MAGIC_VALUE 0x42414450 + +extern void fill_run_(); +extern void fill_init_(); +extern void vid_dint_(); + +unsigned int fill_daddr; +unsigned int fill_dydx; +unsigned int fill_color; + +void fill_rect(x, y, w, h, color) +int x; +int y; +int w; +int h; +int color; +{ + if (w <= 0 || h <= 0) { + return; + } + fill_daddr = VRAM_BASE + (unsigned int)y * ROW_BITS + (unsigned int)x; + fill_dydx = ((unsigned int)h << 16) | (unsigned int)w; + fill_color = color ? 0x0000 : 0xFFFF; + fill_run_(); +} + +static void paint(x, y, w, h, color, ctx) +int x; +int y; +int w; +int h; +int color; +void *ctx; +{ + fill_rect(x, y, w, h, color); +} + +static void show_fault(code) +unsigned int code; +{ + fill_init_(); + fill_rect(0, 0, 1280, 64, 1); + *(unsigned int *)MARK = code; + for (;;) { + } +} + +static unsigned char rxbuf[1600]; +static unsigned char sa[16]; +static unsigned char from[16]; +static int fromlen; +static unsigned int rx_error; + +/* Receive movie.bin over the OS UDP stack into a malloc'd bit buffer. + * Packet: u32 offset, u16 length, u16 seq, u16 crc16, then bytes. + * A final offset==expected/length==4 packet carries the 32-bit file checksum. */ +static unsigned int receive_movie(expected) +unsigned int expected; +{ + unsigned int ptr; + unsigned int offset; + unsigned int length; + unsigned int sent_sum; + unsigned int i; + unsigned char *dst; + int fd; + int n; + + ptr = malloc_(expected); + if (ptr == 0) { + rx_error = 3; + return 0; + } + fd = socket_(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (fd < 0) { + rx_error = 4; + return 0; + } + sa_any(sa, NET_PORT); + if (bind_(fd, sa, SOCKADDR_LEN) < 0) { + close_(fd); + rx_error = 5; + return 0; + } + sent_sum = 0; + dst = (unsigned char *)ptr; + for (;;) { + fromlen = SOCKADDR_LEN; + n = recvfrom_(fd, rxbuf, (int)sizeof(rxbuf), 0, from, &fromlen); + if (n < 10) { + continue; + } + offset = (unsigned int)rxbuf[0] + | ((unsigned int)rxbuf[1] << 8) + | ((unsigned int)rxbuf[2] << 16) + | ((unsigned int)rxbuf[3] << 24); + length = (unsigned int)rxbuf[4] | ((unsigned int)rxbuf[5] << 8); + if (offset == expected && length == 4 && n >= 14) { + sent_sum = (unsigned int)rxbuf[10] + | ((unsigned int)rxbuf[11] << 8) + | ((unsigned int)rxbuf[12] << 16) + | ((unsigned int)rxbuf[13] << 24); + if (movie_checksum(dst, expected) == sent_sum) { + break; + } + sent_sum = 0; + continue; + } + if (length > (unsigned int)(n - 10)) { + continue; + } + if (offset > expected || length > expected - offset) { + continue; + } + for (i = 0; i < length; i++) { + dst[offset + i] = rxbuf[10 + i]; + } + } + close_(fd); + if (sent_sum == 0) { + rx_error = 6; + return 0; + } + return ptr; +} + +/* Poll the display interrupt pending bit. Interrupts are disabled; this is + * a status bit, not an ISR. A missing DPYINT only costs a bounded spin. */ +static void wait_vsync() +{ + unsigned int i; + unsigned short pending; + + *(unsigned short *)0xC0000120 = 0; + for (i = 0; i < 200000; i++) { + pending = *(unsigned short *)0xC0000120; + if (pending & 0x0400) { + return; + } + } +} + +int main() +{ + struct movie_header header; + unsigned int magic; + unsigned int frame; + unsigned int last; + unsigned int start; + unsigned int count; + unsigned int pace; + unsigned int net; + unsigned int wait; + unsigned int movie_base; + unsigned int movie_len; + unsigned int ticks; + unsigned int now; + unsigned int next; + int rc; + + magic = *(unsigned int *)CTRL_MAGIC; + if (magic == MAGIC_VALUE) { + start = *(unsigned int *)CTRL_START; + count = *(unsigned int *)CTRL_COUNT; + pace = *(unsigned int *)CTRL_PACE; + net = *(unsigned int *)CTRL_NET; + wait = *(unsigned int *)CTRL_WAIT; + movie_base = *(unsigned int *)CTRL_MOVIE; + movie_len = *(unsigned int *)CTRL_MOVIELEN; + } else { + start = 0; + count = 0; + pace = 1; + net = NET_DEFAULT; + wait = 0; + movie_base = MOVIE_BASE; + movie_len = MOVIE_MAX; + } + if (net == 0 && NET_DEFAULT) { + net = 1; + } + + if (net != 0) { + movie_len = NET_LEN; + *(unsigned int *)(MARK + 0x10 * 8) = 0x20; + movie_base = receive_movie(movie_len); + if (movie_base == 0) { + show_fault(0xBAD00000 | rx_error); + } + vid_dint_(); + } else { + vid_dint_(); + if (wait != 0) { + *(unsigned int *)(MARK + 0x10 * 8) = 0x10; + while (*(unsigned int *)CTRL_READY == 0) { + } + } + } + + fill_init_(); + *(unsigned short *)0xC00000A0 = 1; + fill_rect(0, 0, 1280, 1024, 0); + + rc = movie_open((unsigned char *)movie_base, movie_len, &header); + if (rc != 0) { + show_fault(0xBAD00001); + } + + last = header.frame_count; + if (last != 0) { + last = last - 1; + } + if (count != 0 && start + count - 1 < last) { + last = start + count - 1; + } + + now = 0; + next = 0; + frame = 0; + while (frame <= last) { + if (pace != 0 && frame >= start) { + wait_vsync(); + now += 5; /* 60/72 in 1/360 s units */ + if (now < next) { + continue; + } + } + rc = movie_decode_frame((unsigned char *)movie_base, movie_len, frame, + &header, paint, 0); + if (rc != 0) { + show_fault(0xBAD00002); + } + ticks = movie_frame_ticks((unsigned char *)movie_base, movie_len, + frame, &header); + if (ticks < 1) { + ticks = 1; + } + next += 6 * ticks; + if (next + 360 < now) { + next = now; + } + *(unsigned int *)MARK = frame; + frame++; + } + + *(unsigned int *)(MARK + 0x10 * 8) = (count != 0) ? 2 : 1; + for (;;) { + } +} diff --git a/bad-apple/player/movie.c b/bad-apple/player/movie.c new file mode 100644 index 0000000..c83a94d --- /dev/null +++ b/bad-apple/player/movie.c @@ -0,0 +1,194 @@ +/* movie.c -- pure decoder for the BAPL tile-delta movie format. + * + * K&R C: the 1987 TMS34010 compiler has no const, no ANSI prototypes and no + * stdint.h. The host test compiles this same file with gcc -std=c99. + */ +#include "movie.h" + +#define HEADER_SIZE 36 +#define INDEX_ENTRY_SIZE 6 + +static unsigned int rd16(p) +unsigned char *p; +{ + return (unsigned int)p[0] | ((unsigned int)p[1] << 8); +} + +static unsigned long rd32(p) +unsigned char *p; +{ + return (unsigned long)p[0] | ((unsigned long)p[1] << 8) + | ((unsigned long)p[2] << 16) | ((unsigned long)p[3] << 24); +} + +int movie_open(data, len, header) +unsigned char *data; +unsigned long len; +struct movie_header *header; +{ + unsigned int version; + + if (data == 0 || header == 0 || len < HEADER_SIZE) { + return -1; + } + if (data[0] != 'B' || data[1] != 'A' || data[2] != 'P' || data[3] != 'L') { + return -1; + } + version = rd16(data + 4); + if (version != 1) { + return -1; + } + header->width = rd16(data + 6); + header->height = rd16(data + 8); + header->scale = rd16(data + 10); + header->tile = rd16(data + 12); + header->tiles_x = rd16(data + 14); + header->tiles_y = rd16(data + 16); + header->frame_count = (unsigned int)rd32(data + 18); + header->index_offset = (unsigned int)rd32(data + 22); + header->data_offset = (unsigned int)rd32(data + 26); + header->source_fps = rd16(data + 30); + header->display_hz = rd16(data + 32); + if (header->scale == 0 || header->scale > 4 || header->tile == 0 + || header->tile > 32 || header->tiles_x == 0 + || header->tiles_y == 0) { + return -1; + } + if (header->width != header->tiles_x * header->tile + || header->height != header->tiles_y * header->tile + || header->width * header->scale > 1280 + || header->height * header->scale > 1024) { + return -1; + } + if ((unsigned long)header->index_offset + + (unsigned long)header->frame_count * INDEX_ENTRY_SIZE > len) { + return -1; + } + return 0; +} + +unsigned int movie_frame_ticks(data, len, frame, header) +unsigned char *data; +unsigned long len; +unsigned int frame; +struct movie_header *header; +{ + unsigned long index_off; + + if (data == 0 || header == 0 || frame >= header->frame_count) { + return 1; + } + index_off = (unsigned long)header->index_offset + + (unsigned long)frame * INDEX_ENTRY_SIZE; + if (index_off + INDEX_ENTRY_SIZE > len) { + return 1; + } + return (unsigned int)data[index_off + 4]; +} + +int movie_decode_frame(data, len, frame, header, fill, ctx) +unsigned char *data; +unsigned long len; +unsigned int frame; +struct movie_header *header; +movie_fill_fn fill; +void *ctx; +{ + unsigned long index_off; + unsigned long off; + unsigned int count; + unsigned int i; + unsigned int j; + unsigned int tile_index; + unsigned int packed; + unsigned int bg; + unsigned int rects; + unsigned int tx; + unsigned int ty; + unsigned int value; + unsigned int x0; + unsigned int y0; + unsigned int w; + unsigned int h; + int sx; + int sy; + + if (data == 0 || header == 0 || fill == 0) { + return -1; + } + if (frame >= header->frame_count) { + return -1; + } + index_off = (unsigned long)header->index_offset + + (unsigned long)frame * INDEX_ENTRY_SIZE; + if (index_off + INDEX_ENTRY_SIZE > len) { + return -1; + } + off = rd32(data + index_off); + if (off + 2 > len) { + return -1; + } + count = rd16(data + off); + off += 2; + + for (i = 0; i < count; i++) { + if (off + 4 > len) { + return -1; + } + tile_index = rd16(data + off); + packed = rd16(data + off + 2); + off += 4; + if (tile_index >= header->tiles_x * header->tiles_y) { + return -1; + } + bg = packed >> 15; + rects = packed & 0x7FFF; + if (off + (unsigned long)rects * 3 > len) { + return -1; + } + tx = tile_index % header->tiles_x; + ty = tile_index / header->tiles_x; + + /* The whole tile first: the old pixels outside every new rect must + * go away. The C test skips this first call per tile. */ + sx = (int)(tx * header->tile * header->scale); + sy = (int)(ty * header->tile * header->scale); + fill(sx, sy, (int)(header->tile * header->scale), + (int)(header->tile * header->scale), (int)bg, ctx); + + for (j = 0; j < rects; j++) { + value = (unsigned int)data[off] + | ((unsigned int)data[off + 1] << 8) + | ((unsigned int)data[off + 2] << 16); + off += 3; + x0 = value & 0x1F; + y0 = (value >> 5) & 0x1F; + w = ((value >> 10) & 0x1F) + 1; + h = ((value >> 15) & 0x1F) + 1; + if (x0 + w > header->tile || y0 + h > header->tile) { + return -1; + } + sx = (int)((tx * header->tile + x0) * header->scale); + sy = (int)((ty * header->tile + y0) * header->scale); + fill(sx, sy, (int)(w * header->scale), + (int)(h * header->scale), (int)(1 - bg), ctx); + } + } + return 0; +} + +unsigned int movie_checksum(data, len) +unsigned char *data; +unsigned long len; +{ + unsigned long sum = 0; + unsigned long i; + + if (data == 0) { + return 0; + } + for (i = 0; i < len; i++) { + sum = (sum + data[i]) & 0xFFFFFFFF; + } + return (unsigned int)sum; +} diff --git a/bad-apple/player/movie.h b/bad-apple/player/movie.h new file mode 100644 index 0000000..349b77e --- /dev/null +++ b/bad-apple/player/movie.h @@ -0,0 +1,26 @@ +#ifndef BADAPPLE_MOVIE_H +#define BADAPPLE_MOVIE_H + +/* K&R declarations: the 1987 TI compiler has no ANSI prototypes. */ +struct movie_header { + unsigned int width; + unsigned int height; + unsigned int scale; + unsigned int tile; + unsigned int tiles_x; + unsigned int tiles_y; + unsigned int frame_count; + unsigned int index_offset; + unsigned int data_offset; + unsigned int source_fps; + unsigned int display_hz; +}; + +typedef void (*movie_fill_fn)(); + +int movie_open(); +int movie_decode_frame(); +unsigned int movie_frame_ticks(); +unsigned int movie_checksum(); + +#endif diff --git a/bad-apple/player/romapi.h b/bad-apple/player/romapi.h new file mode 100644 index 0000000..49f2d37 --- /dev/null +++ b/bad-apple/player/romapi.h @@ -0,0 +1,93 @@ +/* + * romapi.h -- the VT1200 firmware entry points a C payload may call. + * + * Every address here is a TMS34010 BIT address (= Ghidra byte address x 8). + * The evidence for each is dwt/notes/TCPIP.md (sockets) and dwt/notes/HEAP.md + * (allocator); the ones marked "verified live" have been called from a payload + * on real hardware, not just read out of the ROM. + * + * K&R C only. This compiler rejects ANSI prototypes (`int f(int a)` is a + * syntax error), so the declarations below are old-style: the parameter lists + * live in the comments, and the compiler checks nothing but the name. That is + * the price of using the 1987 toolchain; keep the comments honest. + * + * Calling convention: the C compiler and the firmware agree -- arguments are + * widened to 32 bits and pushed upward on the program stack (A14), first + * argument last, and the result comes back in A8. The thunks in entry.asm + * forward with `calla`, so the caller's already-pushed arguments pass through + * untouched and only A8 has to survive. + */ +#ifndef ROMAPI_H +#define ROMAPI_H + +/* ---- ROM entry points -------------------------------------------------- */ +#define ROM_SOCKET 0x3F5C9A00 /* Ghidra 07EB9340 verified live */ +#define ROM_BIND 0x3F5CAA80 /* Ghidra 07EB9550 BOOTP caller */ +#define ROM_LISTEN 0x3F5CBCD0 /* Ghidra 07EB979A inetd caller */ +#define ROM_ACCEPT 0x3F5CCB40 /* Ghidra 07EB9968 inetd caller */ +#define ROM_SENDTO 0x3F5D3D70 /* Ghidra 07EBA7AE verified live */ +#define ROM_RECVFROM 0x3F5D7D50 /* Ghidra 07EBAFAA verified live */ +#define ROM_READ 0x3F5D8E70 /* Ghidra 07EBB1CE rdebug / Xlib */ +#define ROM_CLOSE 0x3F5E8930 /* Ghidra 07EBD126 verified live */ +#define ROM_SETSOCKOPT 0x3F5DD260 /* Ghidra 07EBBA4C BOOTP / Xlib */ +#define ROM_DELAY 0x3F833A20 /* Ghidra 07F66744 sleep(ms), telnet caller */ +#define ROM_SELECT 0x3F871CE0 /* Ghidra 07F0E39C Xlib caller, candidate */ +#define ROM_MALLOC 0x3F701230 /* Ghidra 07EE0246 verified live */ +#define ROM_FREE 0x3F702440 /* Ghidra 07EE0488 allocator */ +#define ROM_CREATE_PROC 0x3F706A40 /* Ghidra 07EE0D48 GUI work */ + +/* ---- thunks implemented in entry.asm ----------------------------------- */ +/* + * int socket_(domain, type, protocol) -> fd, or -1 + * int bind_(fd, sockaddr *, len) -> 0, or -1 + * int recvfrom_(fd, buf, len, flags, from *, fromlen *) + * -> bytes, or -1 + * int close_(fd) -> 0, or -1 + * unsigned int malloc_(bytes) -> BIT pointer, or 0 + * int free_(bit pointer) -> 0 + * + * `from` is a 16-byte sockaddr the stack fills in; `fromlen` points to an + * int that must be 16 on entry (the call writes back the real length). + */ +int socket_(); +int bind_(); +int recvfrom_(); +int close_(); +int listen_(); +int accept_(); +int read_(); +int select_(); +int delay_(); +unsigned int malloc_(); +int free_(); + +/* ---- socket constants -------------------------------------------------- */ +#define AF_INET 2 +#define SOCK_DGRAM 2 +#define SOCK_STREAM 1 +#define IPPROTO_UDP 0 + +#define SOCKADDR_LEN 16 +/* + * struct sockaddr, 16 bytes, NETWORK byte order: + * +0 u16 family (AF_INET = 2 -> bytes 00 02) + * +2 u16 port (high byte first, e.g. 7000 = 0x1B58 -> 1B 58) + * +4 u32 addr (0 = INADDR_ANY) + * +8 u8 zero[8] + */ + +/* Fill sa (an unsigned char[16]) with AF_INET, `port`, and INADDR_ANY. + * `port` is in host order so callers can write 7000 rather than 0x1B58. */ +static void sa_any(sa, port) +unsigned char *sa; +unsigned int port; +{ + sa[0] = 0; sa[1] = AF_INET; + sa[2] = (port >> 8) & 0xFF; + sa[3] = port & 0xFF; + sa[4] = 0; sa[5] = 0; sa[6] = 0; sa[7] = 0; + sa[8] = 0; sa[9] = 0; sa[10] = 0; sa[11] = 0; + sa[12] = 0; sa[13] = 0; sa[14] = 0; sa[15] = 0; +} + +#endif /* ROMAPI_H */ diff --git a/bad-apple/player/tests/fixture.bin b/bad-apple/player/tests/fixture.bin new file mode 100644 index 0000000..c50c42d Binary files /dev/null and b/bad-apple/player/tests/fixture.bin differ diff --git a/bad-apple/player/tests/test_movie_decode.c b/bad-apple/player/tests/test_movie_decode.c new file mode 100644 index 0000000..5537e44 --- /dev/null +++ b/bad-apple/player/tests/test_movie_decode.c @@ -0,0 +1,85 @@ +#include +#include + +#include "../movie.h" + +struct ctx { + int frame; + int last_tile; + int have_last; +}; + +static void report_fill(int x, int y, int w, int h, int color, void *opaque) +{ + struct ctx *ctx = (struct ctx *)opaque; + int lx = x / 2; + int ly = y / 2; + int lw = w / 2; + int lh = h / 2; + int tx = lx / 32; + int ty = ly / 32; + int tile = ty * 2 + tx; + + /* The decoder's first call for a tile is its background. */ + if (!ctx->have_last || tile != ctx->last_tile) { + ctx->last_tile = tile; + ctx->have_last = 1; + return; + } + printf("%d %d %d %d %d %d %d\n", ctx->frame, tile, 1 - color, + lx % 32, ly % 32, lw, lh); +} + +int main(int argc, char **argv) +{ + FILE *file; + long size; + unsigned char *data; + struct movie_header header; + unsigned int frame; + struct ctx ctx; + + if (argc != 2) { + fprintf(stderr, "usage: %s movie.bin\n", argv[0]); + return 2; + } + file = fopen(argv[1], "rb"); + if (file == 0) { + fprintf(stderr, "cannot open %s\n", argv[1]); + return 2; + } + fseek(file, 0, SEEK_END); + size = ftell(file); + fseek(file, 0, SEEK_SET); + data = (unsigned char *)malloc((size_t)size); + if (data == 0) { + fclose(file); + return 2; + } + if (fread(data, 1, (size_t)size, file) != (size_t)size) { + fclose(file); + free(data); + return 2; + } + fclose(file); + + if (movie_open(data, (unsigned long)size, &header) != 0) { + fprintf(stderr, "movie_open failed\n"); + free(data); + return 1; + } + printf("frames=%u\n", header.frame_count); + for (frame = 0; frame < header.frame_count; frame++) { + ctx.frame = (int)frame; + ctx.last_tile = -1; + ctx.have_last = 0; + if (movie_decode_frame(data, (unsigned long)size, frame, &header, + report_fill, &ctx) != 0) { + fprintf(stderr, "decode failed at frame %u\n", frame); + free(data); + return 1; + } + } + free(data); + return 0; +} diff --git a/bad-apple/pytest.ini b/bad-apple/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/bad-apple/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/bad-apple/tests/make_fixture.py b/bad-apple/tests/make_fixture.py new file mode 100644 index 0000000..c3457eb --- /dev/null +++ b/bad-apple/tests/make_fixture.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Write the two-frame fixture the C decoder test reads.""" +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from pipeline.movie import ( + FrameRecord, + MovieHeader, + Rect, + TileRecord, + write_movie, +) + +OUT = ROOT / "player" / "tests" / "fixture.bin" + + +def main() -> int: + frames = [ + FrameRecord(1, 0, [TileRecord(0, 0, [Rect(0, 0, 32, 32)])]), + FrameRecord(1, 0, [TileRecord(1, 1, [Rect(1, 1, 2, 3)])]), + ] + header = MovieHeader(64, 32, 2, 32, 2, 1, len(frames), 60, 72) + OUT.parent.mkdir(parents=True, exist_ok=True) + write_movie(str(OUT), frames, header) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bad-apple/tests/test_build.py b/bad-apple/tests/test_build.py new file mode 100644 index 0000000..68ab16c --- /dev/null +++ b/bad-apple/tests/test_build.py @@ -0,0 +1,21 @@ +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 diff --git a/bad-apple/tests/test_c_decoder.py b/bad-apple/tests/test_c_decoder.py new file mode 100644 index 0000000..4db6bb8 --- /dev/null +++ b/bad-apple/tests/test_c_decoder.py @@ -0,0 +1,23 @@ +import subprocess +import sys +from pathlib import Path + +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 diff --git a/bad-apple/tests/test_cost.py b/bad-apple/tests/test_cost.py new file mode 100644 index 0000000..a4680da --- /dev/null +++ b/bad-apple/tests/test_cost.py @@ -0,0 +1,26 @@ +import numpy as np + +from pipeline.cost import CostConstants, estimate_record_us, select_records +from pipeline.movie import FrameRecord, TileRecord + + +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=50.0) + assert all(r.ticks >= 1 for r in out) + assert sum(r.ticks for r in out) == 4 diff --git a/bad-apple/tests/test_frames.py b/bad-apple/tests/test_frames.py new file mode 100644 index 0000000..d072b31 --- /dev/null +++ b/bad-apple/tests/test_frames.py @@ -0,0 +1,31 @@ +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 diff --git a/bad-apple/tests/test_movie.py b/bad-apple/tests/test_movie.py new file mode 100644 index 0000000..7800a0e --- /dev/null +++ b/bad-apple/tests/test_movie.py @@ -0,0 +1,63 @@ +import numpy as np + +from pipeline.movie import ( + Rect, + TileRecord, + FrameRecord, + MovieHeader, + MovieFormatError, + encode_frame, + decode_frame, + write_movie, + read_movie, +) + + +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_rect_outside_tile_raises(): + rec = FrameRecord(1, 0, [TileRecord(0, 0, [Rect(31, 0, 2, 1)])]) + try: + encode_frame(rec) + except MovieFormatError: + return + assert False, "rect crossing a tile boundary was accepted" + + +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) + 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 diff --git a/bad-apple/tests/test_tilepack.py b/bad-apple/tests/test_tilepack.py new file mode 100644 index 0000000..34fcda7 --- /dev/null +++ b/bad-apple/tests/test_tilepack.py @@ -0,0 +1,40 @@ +import numpy as np + +from pipeline.movie import Rect, TileRecord, FrameRecord +from pipeline.tilepack import decompose_tile, diff_tiles, apply_record + + +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) diff --git a/bad-apple/tests/test_vectorize.py b/bad-apple/tests/test_vectorize.py new file mode 100644 index 0000000..673d73d --- /dev/null +++ b/bad-apple/tests/test_vectorize.py @@ -0,0 +1,41 @@ +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] + assert not out[3, 3] + + +def test_trace_preserves_vertical_position(): + mask = np.zeros((32, 32), dtype=bool) + mask[2:6, 4:12] = True + out = vectorize_frame(mask, {"rdp_epsilon": 1.0, "min_segment": 2}) + assert out[3, 5] + assert not out[28, 5] + + +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] diff --git a/bad-apple/tools/check_vram.py b/bad-apple/tools/check_vram.py new file mode 100644 index 0000000..4b8c777 --- /dev/null +++ b/bad-apple/tools/check_vram.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Compare a MAME VRAM dump with the logical frame the movie should show.""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import numpy as np + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from pipeline.movie import read_movie +from pipeline.tilepack import apply_record + +VRAM_STRIDE = 256 + + +def vram_to_logical(data: bytes, width: int = 640, height: int = 512, + scale: int = 2) -> np.ndarray: + """Unpack a 1-bpp VRAM dump and majority-downsample scale×scale blocks.""" + screen_w = width * scale + screen_h = height * scale + need = VRAM_STRIDE * screen_h + if len(data) < need: + raise ValueError("VRAM dump is %d bytes, need at least %d" + % (len(data), need)) + rows = np.frombuffer(data[:need], dtype=np.uint8).reshape(screen_h, + VRAM_STRIDE) + bits = np.unpackbits(rows, axis=1, bitorder="little")[:, :screen_w] + blocks = bits.astype(np.uint16).reshape(height, scale, width, scale) + # The driver's palette: bit 1 = white, bit 0 = black. + return blocks.mean(axis=(1, 3)) < 0.5 + + +def expected_frame(movie_path: str, frame: int) -> np.ndarray: + header, records = read_movie(movie_path) + if frame < 0 or frame >= len(records): + raise ValueError("frame %d outside 0..%d" % (frame, len(records) - 1)) + canvas = np.zeros((header.height, header.width), dtype=bool) + for record in records[:frame + 1]: + apply_record(canvas, record, tile=header.tile) + return canvas + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--vram", required=True) + parser.add_argument("--movie", required=True) + parser.add_argument("--frame", type=int, default=0) + parser.add_argument("--scale", type=int, default=2) + args = parser.parse_args(argv) + + data = Path(args.vram).read_bytes() + header, _ = read_movie(args.movie) + actual = vram_to_logical(data, header.width, header.height, args.scale) + expected = expected_frame(args.movie, args.frame) + if np.array_equal(actual, expected): + print("EXACT frame %d" % args.frame) + return 0 + bad = int(np.count_nonzero(actual != expected)) + ys, xs = np.where(actual != expected) + print("MISMATCH frame %d: %d logical pixels differ" % (args.frame, bad)) + for y, x in list(zip(ys, xs))[:16]: + print(" (%d,%d) expected=%d actual=%d" + % (x, y, expected[y, x], actual[y, x])) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bad-apple/tools/load.sh b/bad-apple/tools/load.sh new file mode 100755 index 0000000..d132366 --- /dev/null +++ b/bad-apple/tools/load.sh @@ -0,0 +1,55 @@ +#!/bin/sh +# Deliver the network-build payload over rdebug, then send movie.bin over UDP. +# +# tools/load.sh --host 192.168.99.2 --movie data/movie.bin +# +# The terminal must be booted and answering ping. The payload calls the OS +# socket API, receives into a malloc'd buffer, then disables interrupts and +# plays. Recovery after that point is a power cycle. +set -eu + +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "$HERE/../.." && pwd) +HOST=192.168.99.2 +PORT=7001 +MOVIE=$ROOT/bad-apple/data/movie.bin +WAIT=180 +SETTLE=3 +DELAY=0.002 + +while [ $# -gt 0 ]; do + case "$1" in + --host) HOST=$2; shift 2 ;; + --port) PORT=$2; shift 2 ;; + --movie) MOVIE=$2; shift 2 ;; + --wait) WAIT=$2; shift 2 ;; + --settle) SETTLE=$2; shift 2 ;; + --delay) DELAY=$2; shift 2 ;; + -h|--help) sed -n '2,12p' "$0"; exit 0 ;; + *) echo "load.sh: unknown option $1" >&2; exit 2 ;; + esac +done + +if [ ! -f "$MOVIE" ]; then + echo "load.sh: no movie at $MOVIE" >&2 + exit 2 +fi + +start=$(date +%s) +while ! ping -c1 -W1 "$HOST" >/dev/null 2>&1; do + if [ $(( $(date +%s) - start )) -ge "$WAIT" ]; then + echo "load.sh: $HOST did not answer within ${WAIT}s" >&2 + exit 1 + fi +done +echo "load.sh: $HOST answers" + +python3 "$ROOT/harness/badapple/rdebug_load.py" \ + --host "$HOST" \ + --image "$ROOT/bad-apple/player/build/player-net.bin" \ + --env "$ROOT/bad-apple/player/build/player-net.env" + +sleep "$SETTLE" +python3 "$HERE/send_movie.py" --host "$HOST" --port "$PORT" --movie "$MOVIE" \ + --delay "$DELAY" +echo "load.sh: sent; the screen is the evidence. Power-cycle to recover." diff --git a/bad-apple/tools/load_mame.lua b/bad-apple/tools/load_mame.lua new file mode 100644 index 0000000..35a3f49 --- /dev/null +++ b/bad-apple/tools/load_mame.lua @@ -0,0 +1,212 @@ +-- load_mame.lua -- debugger entry for the Bad Apple payload. +-- +-- Loads the payload at bit 0x018C0000 and the movie at bit 0x00080000, then +-- enters the payload at the OS idle loop. This is the development path; the +-- real delivery path is tools/load.sh (rdebug + LANCE). +-- +-- Env: +-- BA_MODE=one|play (one is the only mode implemented here) +-- BA_MOVIE movie file (default bad-apple/data/test_movie.bin) +-- BA_PAYLOAD raw payload (default bad-apple/player/build/player.bin) +-- BA_ENV player.env with ENTRY_PTR (default .../player.env) +-- BA_AT load frame (default 2500) +-- BA_END inspection/exit frame (default 2600) + +local function hex(v, w) + return string.format("%0" .. (w or 8) .. "X", v) +end + +local machine = manager.machine +local cpu = machine.devices[':maincpu'] +local dbg = machine.debugger +local prog = cpu.spaces['program'] + +if not dbg then + print("BA ERROR: debugger not enabled; pass -debug") + machine:exit() + return +end + +local ORIGIN = 0x018C0000 +local MOVIE = tonumber(os.getenv("BA_MOVIE_BASE") or "0x01D00000") +local MARK = 0x01810000 +local ENTRY_PC = 0x3F579970 +local AT = tonumber(os.getenv("BA_AT") or "2500") +local STOP_AT = tonumber(os.getenv("BA_END") or "2600") +local START = tonumber(os.getenv("BA_START") or "0") +local COUNT = tonumber(os.getenv("BA_COUNT") or "0") +local PACE = tonumber(os.getenv("BA_PACE") or "0") +local WAIT = tonumber(os.getenv("BA_WAIT") or "0") +local MOVIE_PATH = os.getenv("BA_MOVIE") or "bad-apple/data/test_movie.bin" +local PAYLOAD_PATH = os.getenv("BA_PAYLOAD") or "bad-apple/player/build/player.bin" +local ENV_PATH = os.getenv("BA_ENV") or "bad-apple/player/build/player.env" + +local function read_all(path) + local f, err = io.open(path, "rb") + if not f then + print("BA ERROR: cannot open " .. path .. ": " .. tostring(err)) + machine:exit() + return nil + end + local data = f:read("*a") + f:close() + return data +end + +local function entry_from_env() + local explicit = os.getenv("BA_ENTRY") + if explicit then + return tonumber(explicit) + end + local f = io.open(ENV_PATH, "r") + if not f then + return ORIGIN + end + for line in f:lines() do + local value = line:match("ENTRY_PTR=0x(%x+)") + if value then + f:close() + return tonumber(value, 16) + end + end + f:close() + return ORIGIN +end + +local payload = read_all(PAYLOAD_PATH) +local movie = read_all(MOVIE_PATH) +if not payload or not movie then + return +end +local entry = entry_from_env() + +do + local f = io.open(ENV_PATH, "r") + if f then + for line in f:lines() do + local value = line:match("MARK_PTR=0x(%x+)") + if value then + MARK = tonumber(value, 16) + end + end + f:close() + end +end + +print(string.format("BA: payload %d bytes, movie %d bytes, entry %s mark %s", + #payload, #movie, hex(entry), hex(MARK))) + +local function write_bytes(data, base) + local i + local n = #data + i = 0 + while i + 4 <= n do + local b0 = data:byte(i + 1) + local b1 = data:byte(i + 2) + local b2 = data:byte(i + 3) + local b3 = data:byte(i + 4) + local word = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24) + prog:write_u32(base + i * 8, word) + i = i + 4 + end + while i < n do + prog:write_u8(base + i * 8, data:byte(i + 1)) + i = i + 1 + end +end + +local function verify(data, base) + local i + for i = 0, #data - 1 do + if prog:read_u8(base + i * 8) ~= data:byte(i + 1) then + return false, i + end + end + return true, 0 +end + +local loaded = false +local entered = false +local done = false +local movie_sent = false +local frames = 0 + +local function load_and_enter() + write_bytes(payload, ORIGIN) + local ok, bad = verify(payload, ORIGIN) + if not ok then + print("BA ERROR: payload readback mismatch at byte " .. bad) + machine:exit() + return + end + prog:write_u32(MARK + 0x1C * 8, 0x42414450) + prog:write_u32(MARK + 0x20 * 8, START) + prog:write_u32(MARK + 0x24 * 8, COUNT) + prog:write_u32(MARK + 0x28 * 8, PACE) + prog:write_u32(MARK + 0x2C * 8, MOVIE) + prog:write_u32(MARK + 0x30 * 8, #movie) + prog:write_u32(MARK + 0x34 * 8, WAIT) + prog:write_u32(MARK + 0x38 * 8, 0) + prog:write_u32(MARK + 0x3C * 8, 0) -- debugger path, never network + dbg:command("bpset " .. hex(ENTRY_PC) .. ",,{ pc = " .. hex(entry) .. " ; g }") + if WAIT == 0 then + write_bytes(movie, MOVIE) + ok, bad = verify(movie, MOVIE) + if not ok then + print("BA ERROR: movie readback mismatch at byte " .. bad) + machine:exit() + return + end + end + print(string.format("BA: loaded; start=%d count=%d pace=%d wait=%d", + START, COUNT, PACE, WAIT)) +end + +emu.register_frame_done(function() + frames = frames + 1 + if frames == AT and not loaded then + loaded = true + load_and_enter() + end + if loaded and WAIT == 1 and not movie_sent + and prog:read_u16(MARK + 0x10 * 8) == 0x10 then + movie_sent = true + print("BA: takeover seen; sending movie to low RAM") + write_bytes(movie, MOVIE) + local ok, bad = verify(movie, MOVIE) + if not ok then + print("BA ERROR: movie readback mismatch at byte " .. bad) + machine:exit() + return + end + prog:write_u32(MARK + 0x38 * 8, 1) + print("BA: movie ready") + end + if loaded and not entered then + local status = prog:read_u16(MARK + 0x10 * 8) + if status == 1 or status == 2 then + entered = true + done = true + print(string.format("BA: STATUS=%d at frame %d; FRAME=%d", + status, frames, prog:read_u16(MARK))) + end + end + if loaded and not done and frames > AT + 10 then + local err = prog:read_u32(MARK) + if err >= 0xBAD00000 then + print(string.format("BA ERROR: marker %s at frame %d", + hex(err), frames)) + machine:exit() + end + end + if frames >= STOP_AT then + print(string.format("BA: end at frame %d (loaded=%s done=%s) status=%04X frame=%d magic=%08X", + frames, tostring(loaded), tostring(done), + prog:read_u16(MARK + 0x10 * 8), prog:read_u16(MARK), + prog:read_u32(MARK + 0x1C * 8))) + machine:exit() + end +end, "vt1200-badapple-loader") + +dbg.visible_cpu = cpu +dbg.execution_state = 'run' diff --git a/bad-apple/tools/make_test_movie.py b/bad-apple/tools/make_test_movie.py new file mode 100755 index 0000000..62969a4 --- /dev/null +++ b/bad-apple/tools/make_test_movie.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Make a small known movie for the one-frame MAME FILL check.""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from pipeline.movie import FrameRecord, MovieHeader, write_movie +from pipeline.tilepack import diff_tiles + + +def main() -> int: + width, height = 640, 512 + canvas = np.zeros((height, width), dtype=bool) + canvas[20:70, 10:110] = True # a rectangle crossing tiles + canvas[96:128, 160:192] = True # a solid logical tile at (5,3) + records = diff_tiles(np.zeros_like(canvas), canvas, tile=32) + frames = [FrameRecord(1, 0, records)] + header = MovieHeader(width, height, 2, 32, width // 32, height // 32, + len(frames), 60, 72) + out = ROOT / "data" / "test_movie.bin" + out.parent.mkdir(parents=True, exist_ok=True) + write_movie(str(out), frames, header) + print("%s: %d bytes, %d dirty tiles" + % (out, out.stat().st_size, len(records))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bad-apple/tools/run_mame.sh b/bad-apple/tools/run_mame.sh new file mode 100755 index 0000000..8e9a842 --- /dev/null +++ b/bad-apple/tools/run_mame.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Run the payload in MAME with the debugger loader. +# +# tools/run_mame.sh one # one frame, no pacing +# tools/run_mame.sh play # full movie, paced +# BA_MOVIE=data5/movie.bin BA_START=180 BA_COUNT=1 tools/run_mame.sh play +set -eu + +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "$HERE/../.." && pwd) +M=${M:-/home/arti/code/k-space/mame0289/vt1200} + +MODE=${1:-one} +BA_MOVIE=${BA_MOVIE:-$ROOT/bad-apple/data/test_movie.bin} +BA_PACE=${BA_PACE:-0} +BA_START=${BA_START:-0} +BA_COUNT=${BA_COUNT:-0} +BA_AT=${BA_AT:-2500} +BA_END=${BA_END:-3000} +BA_MOVIE_BASE=${BA_MOVIE_BASE:-0x01D00000} +BA_WAIT=${BA_WAIT:-0} + +if [ "$MODE" = "play" ]; then + BA_PACE=${BA_PACE:-1} +fi + +cd "$ROOT" +VT1200_SKIP_LEVEL1=1 \ +BA_MOVIE="$BA_MOVIE" BA_PACE="$BA_PACE" BA_START="$BA_START" \ +BA_COUNT="$BA_COUNT" BA_AT="$BA_AT" BA_END="$BA_END" \ +BA_MOVIE_BASE="$BA_MOVIE_BASE" BA_WAIT="$BA_WAIT" \ +"$M" vt1200 -rompath harness/mame/roms -video none -sound none -nothrottle \ + -skip_gameinfo -debug -debugger none \ + -autoboot_script "$ROOT/bad-apple/tools/load_mame.lua" -autoboot_delay 0 diff --git a/bad-apple/tools/send_movie.py b/bad-apple/tools/send_movie.py new file mode 100644 index 0000000..25042ef --- /dev/null +++ b/bad-apple/tools/send_movie.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Send movie.bin to the payload's UDP receiver (port 7001). + +Packet: u32 offset, u16 length, u16 seq, u16 crc16, then length bytes. +A zero-length packet ends the load. This uses the VT1200 OS stack while it +is still alive; the payload disables interrupts only after the last byte. +""" +from __future__ import annotations + +import argparse +import socket +import struct +import sys +import time +from pathlib import Path + + +def crc16(data: bytes) -> int: + return sum(data) & 0xFFFF + + +def checksum32(data: bytes) -> int: + return sum(data) & 0xFFFFFFFF + + +def pack_packet(offset: int, seq: int, body: bytes) -> bytes: + return struct.pack(" int: + if len(data) < 8 or data[:4] != b"BAK1": + raise ValueError("not a BAK1 ack") + return struct.unpack_from(" int: + data = Path(path).read_bytes() + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + for _ in range(passes): + offset = 0 + seq = 0 + while offset < len(data): + part = data[offset:offset + chunk] + sock.sendto(pack_packet(offset, seq, part), (host, port)) + offset += len(part) + seq += 1 + if delay: + time.sleep(delay) + sock.sendto(pack_packet(len(data), seq, + checksum32(data).to_bytes(4, "little")), + (host, port)) + time.sleep(0.05) + finally: + sock.close() + print("sent %d bytes in %d pass(es)" % (len(data), passes)) + return 0 + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default="192.168.99.2") + parser.add_argument("--port", type=int, default=7001) + parser.add_argument("--movie", required=True) + parser.add_argument("--chunk", type=int, default=1450) + parser.add_argument("--delay", type=float, default=0.0005) + parser.add_argument("--passes", type=int, default=3) + args = parser.parse_args(argv) + return send_movie(args.host, args.port, args.movie, args.chunk, + args.delay, args.passes) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bad-apple/tools/tests/test_check_vram.py b/bad-apple/tools/tests/test_check_vram.py new file mode 100644 index 0000000..aff8f2d --- /dev/null +++ b/bad-apple/tools/tests/test_check_vram.py @@ -0,0 +1,11 @@ +from tools.check_vram import vram_to_logical + + +def test_vram_downsamples_2x2_lsb_first(): + # VRAM bit 1 is white, bit 0 is black (the driver's palette). + vram = bytearray(256 * 1024) + vram[0] = 0x0C # bits 2 and 3 white in row 0 + vram[256] = 0x0C # bits 2 and 3 white in row 1 + logical = vram_to_logical(bytes(vram), width=640, height=512, scale=2) + assert logical[0, 0] # bits 0..1 are black + assert not logical[0, 1] # bits 2..3 are white diff --git a/bad-apple/tools/tests/test_send_movie.py b/bad-apple/tools/tests/test_send_movie.py new file mode 100644 index 0000000..1ae0dde --- /dev/null +++ b/bad-apple/tools/tests/test_send_movie.py @@ -0,0 +1,13 @@ +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