From c7569efb7d92dcb7a8cfe87714840a8e4266e13e Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Sun, 1 Nov 2026 00:20:35 +0200 Subject: [PATCH 01/15] feat(badapple): frame extraction primitives --- bad-apple/.gitignore | 6 +++ bad-apple/README.md | 12 +++++ bad-apple/conftest.py | 6 +++ bad-apple/pipeline/__init__.py | 0 bad-apple/pipeline/frames.py | 96 ++++++++++++++++++++++++++++++++++ bad-apple/pipeline/params.json | 15 ++++++ bad-apple/pytest.ini | 2 + bad-apple/tests/test_frames.py | 31 +++++++++++ 8 files changed, 168 insertions(+) create mode 100644 bad-apple/.gitignore create mode 100644 bad-apple/README.md create mode 100644 bad-apple/conftest.py create mode 100644 bad-apple/pipeline/__init__.py create mode 100644 bad-apple/pipeline/frames.py create mode 100644 bad-apple/pipeline/params.json create mode 100644 bad-apple/pytest.ini create mode 100644 bad-apple/tests/test_frames.py diff --git a/bad-apple/.gitignore b/bad-apple/.gitignore new file mode 100644 index 0000000..1100c44 --- /dev/null +++ b/bad-apple/.gitignore @@ -0,0 +1,6 @@ +data/* +!data/manifest.json +preview/ +player/build/ +__pycache__/ +.pytest_cache/ diff --git a/bad-apple/README.md b/bad-apple/README.md new file mode 100644 index 0000000..a96420f --- /dev/null +++ b/bad-apple/README.md @@ -0,0 +1,12 @@ +# Bad Apple on VT1200 — 30 s vector prototype + +This is the standalone, polygon-styled 30-second Bad Apple demo described in +[`docs/superpowers/specs/2026-10-31-bad-apple-vector-design.md`](../docs/superpowers/specs/2026-10-31-bad-apple-vector-design.md). + +The host pipeline turns the first 30 s of `hd60.mp4` into a 640×512 black-and-white +vector picture, packs only changed 32×32 tiles as rectangles, and a TMS34010 +payload paints them with `FILL L`. The payload takes over the terminal, so a hang +ends at a power cycle. + +Status: under construction. See the implementation plan at +[`docs/superpowers/plans/2026-11-01-bad-apple-vector.md`](../docs/superpowers/plans/2026-11-01-bad-apple-vector.md). 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/pipeline/__init__.py b/bad-apple/pipeline/__init__.py new file mode 100644 index 0000000..e69de29 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/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/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/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 From 0c93078ee37cbbbb141f1bd4889344c8b827e064 Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Sun, 1 Nov 2026 00:21:17 +0200 Subject: [PATCH 02/15] feat(badapple): polygon tracing and rasterisation --- bad-apple/pipeline/vectorize.py | 170 ++++++++++++++++++++++++++++++ bad-apple/tests/test_vectorize.py | 33 ++++++ 2 files changed, 203 insertions(+) create mode 100644 bad-apple/pipeline/vectorize.py create mode 100644 bad-apple/tests/test_vectorize.py diff --git a/bad-apple/pipeline/vectorize.py b/bad-apple/pipeline/vectorize.py new file mode 100644 index 0000000..99dd87e --- /dev/null +++ b/bad-apple/pipeline/vectorize.py @@ -0,0 +1,170 @@ +"""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()) + + 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/tests/test_vectorize.py b/bad-apple/tests/test_vectorize.py new file mode 100644 index 0000000..cb63146 --- /dev/null +++ b/bad-apple/tests/test_vectorize.py @@ -0,0 +1,33 @@ +import numpy as np + +from pipeline.vectorize import rdp, rasterize, trace + + +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_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] From 8371cdad127644b6fd5b0c581dc8c66b2de98a44 Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Sun, 1 Nov 2026 00:22:16 +0200 Subject: [PATCH 03/15] feat(badapple): tile delta format and movie packer --- bad-apple/pipeline/movie.py | 189 +++++++++++++++++++++++++++++++ bad-apple/pipeline/tilepack.py | 81 +++++++++++++ bad-apple/tests/test_movie.py | 54 +++++++++ bad-apple/tests/test_tilepack.py | 40 +++++++ 4 files changed, 364 insertions(+) create mode 100644 bad-apple/pipeline/movie.py create mode 100644 bad-apple/pipeline/tilepack.py create mode 100644 bad-apple/tests/test_movie.py create mode 100644 bad-apple/tests/test_tilepack.py diff --git a/bad-apple/pipeline/movie.py b/bad-apple/pipeline/movie.py new file mode 100644 index 0000000..747f7dd --- /dev/null +++ b/bad-apple/pipeline/movie.py @@ -0,0 +1,189 @@ +"""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): + 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/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/tests/test_movie.py b/bad-apple/tests/test_movie.py new file mode 100644 index 0000000..877e7cf --- /dev/null +++ b/bad-apple/tests/test_movie.py @@ -0,0 +1,54 @@ +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_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) From 5a1f8429f062fb17f23cafe98083dba6014be512 Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Sun, 1 Nov 2026 00:23:08 +0200 Subject: [PATCH 04/15] feat(badapple): cost model, frame selection and build CLI --- bad-apple/pipeline/build.py | 105 ++++++++++++++++++++++++++++++++++ bad-apple/pipeline/cost.py | 70 +++++++++++++++++++++++ bad-apple/pipeline/preview.py | 25 ++++++++ bad-apple/tests/test_build.py | 21 +++++++ bad-apple/tests/test_cost.py | 26 +++++++++ 5 files changed, 247 insertions(+) create mode 100644 bad-apple/pipeline/build.py create mode 100644 bad-apple/pipeline/cost.py create mode 100644 bad-apple/pipeline/preview.py create mode 100644 bad-apple/tests/test_build.py create mode 100644 bad-apple/tests/test_cost.py 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/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/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_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 From beb45942ad439aa734162666209dcc6ea3164005 Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Sun, 1 Nov 2026 00:24:20 +0200 Subject: [PATCH 05/15] feat(badapple): host-tested movie decoder --- bad-apple/player/movie.c | 157 +++++++++++++++++++++ bad-apple/player/movie.h | 31 ++++ bad-apple/player/tests/fixture.bin | Bin 0 -> 66 bytes bad-apple/player/tests/test_movie_decode.c | 85 +++++++++++ bad-apple/tests/make_fixture.py | 33 +++++ bad-apple/tests/test_c_decoder.py | 23 +++ 6 files changed, 329 insertions(+) create mode 100644 bad-apple/player/movie.c create mode 100644 bad-apple/player/movie.h create mode 100644 bad-apple/player/tests/fixture.bin create mode 100644 bad-apple/player/tests/test_movie_decode.c create mode 100644 bad-apple/tests/make_fixture.py create mode 100644 bad-apple/tests/test_c_decoder.py diff --git a/bad-apple/player/movie.c b/bad-apple/player/movie.c new file mode 100644 index 0000000..5348fd7 --- /dev/null +++ b/bad-apple/player/movie.c @@ -0,0 +1,157 @@ +/* movie.c -- pure decoder for the BAPL tile-delta movie format. + * + * C89-compatible: the 1987 TMS34010 compiler has no stdint.h and no mixed + * declarations. 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(const unsigned char *p) +{ + return (unsigned int)p[0] | ((unsigned int)p[1] << 8); +} + +static unsigned long rd32(const 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(const 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->tile == 0 + || header->tile > 32 || header->tiles_x == 0 + || header->tiles_y == 0) { + return -1; + } + if ((unsigned long)header->index_offset + + (unsigned long)header->frame_count * INDEX_ENTRY_SIZE > len) { + return -1; + } + return 0; +} + +int movie_decode_frame(const unsigned char *data, unsigned long len, + unsigned int frame, + const 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; + 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(const 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]) & 0xFFFFFFFFUL; + } + return (unsigned int)sum; +} diff --git a/bad-apple/player/movie.h b/bad-apple/player/movie.h new file mode 100644 index 0000000..ef694c5 --- /dev/null +++ b/bad-apple/player/movie.h @@ -0,0 +1,31 @@ +#ifndef BADAPPLE_MOVIE_H +#define BADAPPLE_MOVIE_H + +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 x, int y, int w, int h, int color, + void *ctx); + +int movie_open(const unsigned char *data, unsigned long len, + struct movie_header *header); + +int movie_decode_frame(const unsigned char *data, unsigned long len, + unsigned int frame, + const struct movie_header *header, + movie_fill_fn fill, void *ctx); + +unsigned int movie_checksum(const unsigned char *data, unsigned long len); + +#endif diff --git a/bad-apple/player/tests/fixture.bin b/bad-apple/player/tests/fixture.bin new file mode 100644 index 0000000000000000000000000000000000000000..c50c42d90d7cbae43dc46296d96a43abdb3d45c3 GIT binary patch literal 66 zcmZ>A4DexOa9~hiU;<-CAkDy_0>lPDY{TFIWic{X0$Gee2qGE&@Pn9)4T>y`0H6c} Ap#T5? literal 0 HcmV?d00001 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/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_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 From dbfc7b60c5b6367b5e0a9ae4893ef2ce58c05003 Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Sun, 1 Nov 2026 10:04:52 +0200 Subject: [PATCH 06/15] feat(badapple): FILL primitive and one-frame MAME check --- bad-apple/.gitignore | 1 + bad-apple/player/build.sh | 120 ++++++++++++++++++ bad-apple/player/entry.asm | 18 +++ bad-apple/player/fill.asm | 56 +++++++++ bad-apple/player/main.c | 74 +++++++++++ bad-apple/tools/check_vram.py | 73 +++++++++++ bad-apple/tools/load_mame.lua | 151 +++++++++++++++++++++++ bad-apple/tools/make_test_movie.py | 36 ++++++ bad-apple/tools/tests/test_check_vram.py | 11 ++ 9 files changed, 540 insertions(+) create mode 100755 bad-apple/player/build.sh create mode 100644 bad-apple/player/entry.asm create mode 100644 bad-apple/player/fill.asm create mode 100644 bad-apple/player/main.c create mode 100644 bad-apple/tools/check_vram.py create mode 100644 bad-apple/tools/load_mame.lua create mode 100755 bad-apple/tools/make_test_movie.py create mode 100644 bad-apple/tools/tests/test_check_vram.py diff --git a/bad-apple/.gitignore b/bad-apple/.gitignore index 1100c44..2fce34c 100644 --- a/bad-apple/.gitignore +++ b/bad-apple/.gitignore @@ -2,5 +2,6 @@ data/* !data/manifest.json preview/ player/build/ +player/work/ __pycache__/ .pytest_cache/ diff --git a/bad-apple/player/build.sh b/bad-apple/player/build.sh new file mode 100755 index 0000000..ad8c485 --- /dev/null +++ b/bad-apple/player/build.sh @@ -0,0 +1,120 @@ +#!/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 + +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/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/player.bin" > "$OUT/cofextract.txt" + +ENTRY_PTR=$(sed 's/\r$//' "$WORK/PLAYER.MAP" \ + | awk '/ENTRY POINT SYMBOL/ { print $NF; exit }') +if [ -z "$ENTRY_PTR" ]; then + echo "build.sh: no entry point in PLAYER.map" >&2 + exit 1 +fi +printf 'ENTRY_PTR=0x%s\n' "$ENTRY_PTR" > "$OUT/player.env" + +echo "build.sh: $OUT/player.bin ($(wc -c < "$OUT/player.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..9a1bf7f --- /dev/null +++ b/bad-apple/player/entry.asm @@ -0,0 +1,18 @@ +; entry.asm -- payload entry for the Bad Apple player. +; +; The payload is entered by a debugger/rdebug jump and never returns. Set the +; C stack, call main, and spin if main ever returns. + + .text + .globl _start + .globl _main +_start: + setf 32, 0, 1 + movi _cstack, a14 + move a14, a13 + calla _main +spin: + jruc spin + + .bss _cstack, 32768, 16 ; 32768 bits = 4096 bytes + .end diff --git a/bad-apple/player/fill.asm b/bad-apple/player/fill.asm new file mode 100644 index 0000000..c9fc5c7 --- /dev/null +++ b/bad-apple/player/fill.asm @@ -0,0 +1,56 @@ +; 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 _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..ddcadb4 --- /dev/null +++ b/bad-apple/player/main.c @@ -0,0 +1,74 @@ +/* main.c -- Bad Apple payload, one-frame mode and (later) playback. */ +#include "movie.h" + +#define MOVIE_BASE 0x00080000 +#define MOVIE_MAX 0x00308000 +#define VRAM_BASE 0x04000000 +#define ROW_BITS 2048 +#define MARK 0x01810000 + +extern void fill_run_(); +extern void fill_init_(); + +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; + /* The decoder's colour is the canvas value: 1 = black, 0 = white. */ + 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); +} + +int main() +{ + struct movie_header header; + int rc; + + fill_init_(); + + /* Start from white; the OS left its own screen behind. */ + fill_rect(0, 0, 1280, 1024, 0); + + rc = movie_open((unsigned char *)MOVIE_BASE, MOVIE_MAX, &header); + if (rc != 0) { + *(unsigned int *)MARK = 0xBAD00001; + for (;;) { + } + } + + rc = movie_decode_frame((unsigned char *)MOVIE_BASE, MOVIE_MAX, 0, + &header, paint, 0); + if (rc != 0) { + *(unsigned int *)MARK = 0xBAD00002; + for (;;) { + } + } + + *(unsigned int *)MARK = 0; /* FRAME */ + *(unsigned int *)(MARK + 0x10 * 8) = 1; /* STATUS: painted */ + for (;;) { + } +} 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_mame.lua b/bad-apple/tools/load_mame.lua new file mode 100644 index 0000000..b66ab3e --- /dev/null +++ b/bad-apple/tools/load_mame.lua @@ -0,0 +1,151 @@ +-- 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 = 0x00080000 +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 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() +print(string.format("BA: payload %d bytes, movie %d bytes, entry %s", + #payload, #movie, hex(entry))) + +local function write_bytes(data, base) + local i + for i = 0, #data - 1 do + prog:write_u8(base + i * 8, data:byte(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 frames = 0 + +local function load_and_enter() + write_bytes(payload, ORIGIN) + write_bytes(movie, MOVIE) + local ok, bad = verify(payload, ORIGIN) + if not ok then + print("BA ERROR: payload readback mismatch at byte " .. bad) + machine:exit() + return + end + ok, bad = verify(movie, MOVIE) + if not ok then + print("BA ERROR: movie readback mismatch at byte " .. bad) + machine:exit() + return + end + print("BA: loaded; breakpoint at " .. hex(ENTRY_PC)) + dbg:command("bpset " .. hex(ENTRY_PC) .. ",,{ pc = " .. hex(entry) .. " ; g }") +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 not entered and prog:read_u16(MARK) == 0x0000 + and prog:read_u16(MARK + 0x10 * 8) == 0x0001 then + entered = true + done = true + print(string.format("BA: STATUS=painted at frame %d; FRAME=%d", + frames, prog:read_u16(MARK))) + end + if loaded and not done and frames > AT + 30 then + local err = prog:read_u32(MARK) + if err ~= 0 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)", + frames, tostring(loaded), tostring(done))) + 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/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 From 150a6d86c0570093b5db0d653a31d1b8497665c9 Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Sun, 1 Nov 2026 10:10:52 +0200 Subject: [PATCH 07/15] feat(badapple): playback loop and vblank pacing --- bad-apple/.gitignore | 1 + bad-apple/player/main.c | 78 +++++++++++++++++++++++++++++++---- bad-apple/tools/load_mame.lua | 50 ++++++++++++++++------ bad-apple/tools/run_mame.sh | 33 +++++++++++++++ 4 files changed, 140 insertions(+), 22 deletions(-) create mode 100755 bad-apple/tools/run_mame.sh diff --git a/bad-apple/.gitignore b/bad-apple/.gitignore index 2fce34c..4410c71 100644 --- a/bad-apple/.gitignore +++ b/bad-apple/.gitignore @@ -1,5 +1,6 @@ data/* !data/manifest.json +data5/ preview/ player/build/ player/work/ diff --git a/bad-apple/player/main.c b/bad-apple/player/main.c index ddcadb4..0dea369 100644 --- a/bad-apple/player/main.c +++ b/bad-apple/player/main.c @@ -6,6 +6,11 @@ #define VRAM_BASE 0x04000000 #define ROW_BITS 2048 #define MARK 0x01810000 +#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) extern void fill_run_(); extern void fill_init_(); @@ -42,33 +47,88 @@ void *ctx; fill_rect(x, y, w, h, color); } +/* 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; + + /* Clear a stale DI, then wait for the next one. DPYINT is programmed to + * line 1 once at startup, so DI appears at the top of every frame. */ + *(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 frame; + unsigned int last; + unsigned int start; + unsigned int count; + unsigned int pace; + unsigned int movie_base; + unsigned int movie_len; int rc; fill_init_(); + /* Display interrupt at line 1: our only timebase. Interrupts stay off; + * wait_vsync() polls the pending bit. */ + *(unsigned short *)0xC00000A0 = 1; + /* Start from white; the OS left its own screen behind. */ fill_rect(0, 0, 1280, 1024, 0); - rc = movie_open((unsigned char *)MOVIE_BASE, MOVIE_MAX, &header); + movie_base = *(unsigned int *)CTRL_MOVIE; + movie_len = *(unsigned int *)CTRL_MOVIELEN; + if (movie_base == 0) { + movie_base = MOVIE_BASE; + } + if (movie_len == 0) { + movie_len = MOVIE_MAX; + } + + rc = movie_open((unsigned char *)movie_base, movie_len, &header); if (rc != 0) { *(unsigned int *)MARK = 0xBAD00001; for (;;) { } } - rc = movie_decode_frame((unsigned char *)MOVIE_BASE, MOVIE_MAX, 0, - &header, paint, 0); - if (rc != 0) { - *(unsigned int *)MARK = 0xBAD00002; - for (;;) { - } + start = *(unsigned int *)CTRL_START; + count = *(unsigned int *)CTRL_COUNT; + pace = *(unsigned int *)CTRL_PACE; + last = header.frame_count; + if (last != 0) { + last = last - 1; + } + if (count != 0 && start + count - 1 < last) { + last = start + count - 1; } - *(unsigned int *)MARK = 0; /* FRAME */ - *(unsigned int *)(MARK + 0x10 * 8) = 1; /* STATUS: painted */ + for (frame = 0; frame <= last; frame++) { + if (pace != 0 && frame >= start) { + wait_vsync(); + } + rc = movie_decode_frame((unsigned char *)movie_base, movie_len, frame, + &header, paint, 0); + if (rc != 0) { + *(unsigned int *)MARK = 0xBAD00002; + *(unsigned int *)(MARK + 0x14 * 8) = frame; + for (;;) { + } + } + *(unsigned int *)MARK = frame; + } + + *(unsigned int *)(MARK + 0x10 * 8) = (count != 0) ? 2 : 1; for (;;) { } } diff --git a/bad-apple/tools/load_mame.lua b/bad-apple/tools/load_mame.lua index b66ab3e..679d297 100644 --- a/bad-apple/tools/load_mame.lua +++ b/bad-apple/tools/load_mame.lua @@ -28,11 +28,14 @@ if not dbg then end local ORIGIN = 0x018C0000 -local MOVIE = 0x00080000 +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 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" @@ -80,8 +83,20 @@ print(string.format("BA: payload %d bytes, movie %d bytes, entry %s", local function write_bytes(data, base) local i - for i = 0, #data - 1 do + 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 @@ -102,21 +117,28 @@ local frames = 0 local function load_and_enter() write_bytes(payload, ORIGIN) - write_bytes(movie, MOVIE) 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 + 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) + -- Arm the entry breakpoint before the large movie write; the write blocks + -- emulation, so it cannot fire until the movie is in place. + dbg:command("bpset " .. hex(ENTRY_PC) .. ",,{ pc = " .. hex(entry) .. " ; g }") + 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 - print("BA: loaded; breakpoint at " .. hex(ENTRY_PC)) - dbg:command("bpset " .. hex(ENTRY_PC) .. ",,{ pc = " .. hex(entry) .. " ; g }") + print(string.format("BA: loaded; start=%d count=%d pace=%d", START, COUNT, PACE)) end emu.register_frame_done(function() @@ -125,16 +147,18 @@ emu.register_frame_done(function() loaded = true load_and_enter() end - if loaded and not entered and prog:read_u16(MARK) == 0x0000 - and prog:read_u16(MARK + 0x10 * 8) == 0x0001 then - entered = true - done = true - print(string.format("BA: STATUS=painted at frame %d; FRAME=%d", - frames, prog:read_u16(MARK))) + 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 + 30 then + if loaded and not done and frames > AT + 10 then local err = prog:read_u32(MARK) - if err ~= 0 then + if err >= 0xBAD00000 then print(string.format("BA ERROR: marker %s at frame %d", hex(err), frames)) machine:exit() diff --git a/bad-apple/tools/run_mame.sh b/bad-apple/tools/run_mame.sh new file mode 100755 index 0000000..a35a4de --- /dev/null +++ b/bad-apple/tools/run_mame.sh @@ -0,0 +1,33 @@ +#!/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} + +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" \ +"$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 From 116356a9756cdd84661372764b28f350c9a795a7 Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Sun, 1 Nov 2026 10:16:12 +0200 Subject: [PATCH 08/15] feat(badapple): full 30s stream and MAME exactness --- bad-apple/data/manifest.json | 31 +++++++++++++++++++++++++++++ bad-apple/player/fill.asm | 5 +++++ bad-apple/player/main.c | 10 ++++++++++ bad-apple/player/movie.c | 33 +++++++++++++++++++------------ bad-apple/player/movie.h | 16 +++++---------- bad-apple/tools/load_mame.lua | 37 ++++++++++++++++++++++++++--------- bad-apple/tools/run_mame.sh | 3 ++- 7 files changed, 102 insertions(+), 33 deletions(-) create mode 100644 bad-apple/data/manifest.json diff --git a/bad-apple/data/manifest.json b/bad-apple/data/manifest.json new file mode 100644 index 0000000..f04d837 --- /dev/null +++ b/bad-apple/data/manifest.json @@ -0,0 +1,31 @@ +{ + "cost_us": { + "max": 37991, + "mean": 7472.0695503699535, + "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/player/fill.asm b/bad-apple/player/fill.asm index c9fc5c7..d037393 100644 --- a/bad-apple/player/fill.asm +++ b/bad-apple/player/fill.asm @@ -39,6 +39,11 @@ _fill_run_: move *sp+, b2, 1 rets + .globl _vid_dint_ +_vid_dint_: + dint + rets + .globl _fill_init_ _fill_init_: setf 32, 0, 1 diff --git a/bad-apple/player/main.c b/bad-apple/player/main.c index 0dea369..52983d3 100644 --- a/bad-apple/player/main.c +++ b/bad-apple/player/main.c @@ -11,9 +11,12 @@ #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) extern void fill_run_(); extern void fill_init_(); +extern void vid_dint_(); unsigned int fill_daddr; unsigned int fill_dydx; @@ -77,6 +80,7 @@ int main() unsigned int movie_len; int rc; + vid_dint_(); fill_init_(); /* Display interrupt at line 1: our only timebase. Interrupts stay off; @@ -95,6 +99,12 @@ int main() movie_len = MOVIE_MAX; } + if (*(unsigned int *)CTRL_WAIT != 0) { + *(unsigned int *)(MARK + 0x10 * 8) = 0x10; /* takeover, waiting for data */ + while (*(unsigned int *)CTRL_READY == 0) { + } + } + rc = movie_open((unsigned char *)movie_base, movie_len, &header); if (rc != 0) { *(unsigned int *)MARK = 0xBAD00001; diff --git a/bad-apple/player/movie.c b/bad-apple/player/movie.c index 5348fd7..c4a67d6 100644 --- a/bad-apple/player/movie.c +++ b/bad-apple/player/movie.c @@ -1,26 +1,30 @@ /* movie.c -- pure decoder for the BAPL tile-delta movie format. * - * C89-compatible: the 1987 TMS34010 compiler has no stdint.h and no mixed - * declarations. The host test compiles this same file with gcc -std=c99. + * 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(const unsigned char *p) +static unsigned int rd16(p) +unsigned char *p; { return (unsigned int)p[0] | ((unsigned int)p[1] << 8); } -static unsigned long rd32(const unsigned char *p) +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(const unsigned char *data, unsigned long len, - struct movie_header *header) +int movie_open(data, len, header) +unsigned char *data; +unsigned long len; +struct movie_header *header; { unsigned int version; @@ -57,10 +61,13 @@ int movie_open(const unsigned char *data, unsigned long len, return 0; } -int movie_decode_frame(const unsigned char *data, unsigned long len, - unsigned int frame, - const struct movie_header *header, - movie_fill_fn fill, void *ctx) +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; @@ -142,7 +149,9 @@ int movie_decode_frame(const unsigned char *data, unsigned long len, return 0; } -unsigned int movie_checksum(const unsigned char *data, unsigned long len) +unsigned int movie_checksum(data, len) +unsigned char *data; +unsigned long len; { unsigned long sum = 0; unsigned long i; @@ -151,7 +160,7 @@ unsigned int movie_checksum(const unsigned char *data, unsigned long len) return 0; } for (i = 0; i < len; i++) { - sum = (sum + data[i]) & 0xFFFFFFFFUL; + sum = (sum + data[i]) & 0xFFFFFFFF; } return (unsigned int)sum; } diff --git a/bad-apple/player/movie.h b/bad-apple/player/movie.h index ef694c5..abf514b 100644 --- a/bad-apple/player/movie.h +++ b/bad-apple/player/movie.h @@ -1,6 +1,7 @@ #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; @@ -15,17 +16,10 @@ struct movie_header { unsigned int display_hz; }; -typedef void (*movie_fill_fn)(int x, int y, int w, int h, int color, - void *ctx); +typedef void (*movie_fill_fn)(); -int movie_open(const unsigned char *data, unsigned long len, - struct movie_header *header); - -int movie_decode_frame(const unsigned char *data, unsigned long len, - unsigned int frame, - const struct movie_header *header, - movie_fill_fn fill, void *ctx); - -unsigned int movie_checksum(const unsigned char *data, unsigned long len); +int movie_open(); +int movie_decode_frame(); +unsigned int movie_checksum(); #endif diff --git a/bad-apple/tools/load_mame.lua b/bad-apple/tools/load_mame.lua index 679d297..ab60e73 100644 --- a/bad-apple/tools/load_mame.lua +++ b/bad-apple/tools/load_mame.lua @@ -36,6 +36,7 @@ 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" @@ -113,6 +114,7 @@ end local loaded = false local entered = false local done = false +local movie_sent = false local frames = 0 local function load_and_enter() @@ -128,17 +130,20 @@ local function load_and_enter() prog:write_u32(MARK + 0x28 * 8, PACE) prog:write_u32(MARK + 0x2C * 8, MOVIE) prog:write_u32(MARK + 0x30 * 8, #movie) - -- Arm the entry breakpoint before the large movie write; the write blocks - -- emulation, so it cannot fire until the movie is in place. + prog:write_u32(MARK + 0x34 * 8, WAIT) + prog:write_u32(MARK + 0x38 * 8, 0) dbg:command("bpset " .. hex(ENTRY_PC) .. ",,{ pc = " .. hex(entry) .. " ; g }") - 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 + 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", START, COUNT, PACE)) + print(string.format("BA: loaded; start=%d count=%d pace=%d wait=%d", + START, COUNT, PACE, WAIT)) end emu.register_frame_done(function() @@ -147,6 +152,20 @@ emu.register_frame_done(function() 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 diff --git a/bad-apple/tools/run_mame.sh b/bad-apple/tools/run_mame.sh index a35a4de..8e9a842 100755 --- a/bad-apple/tools/run_mame.sh +++ b/bad-apple/tools/run_mame.sh @@ -18,6 +18,7 @@ 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} @@ -27,7 +28,7 @@ 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_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 From 8a1cd1bfb1656a551c5bffe0c74d2307936dc495 Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Sun, 1 Nov 2026 10:19:36 +0200 Subject: [PATCH 09/15] feat(badapple): UDP network loader and sender --- bad-apple/player/build.sh | 15 ++- bad-apple/player/entry.asm | 63 +++++++++++- bad-apple/player/main.c | 123 ++++++++++++++++++++--- bad-apple/player/romapi.h | 93 +++++++++++++++++ bad-apple/tools/load.sh | 52 ++++++++++ bad-apple/tools/load_mame.lua | 1 + bad-apple/tools/send_movie.py | 65 ++++++++++++ bad-apple/tools/tests/test_send_movie.py | 13 +++ 8 files changed, 407 insertions(+), 18 deletions(-) create mode 100644 bad-apple/player/romapi.h create mode 100755 bad-apple/tools/load.sh create mode 100644 bad-apple/tools/send_movie.py create mode 100644 bad-apple/tools/tests/test_send_movie.py diff --git a/bad-apple/player/build.sh b/bad-apple/player/build.sh index ad8c485..c26195b 100755 --- a/bad-apple/player/build.sh +++ b/bad-apple/player/build.sh @@ -14,6 +14,12 @@ 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" @@ -25,6 +31,8 @@ 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" @@ -105,7 +113,8 @@ 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/player.bin" > "$OUT/cofextract.txt" + "$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 }') @@ -113,8 +122,8 @@ if [ -z "$ENTRY_PTR" ]; then echo "build.sh: no entry point in PLAYER.map" >&2 exit 1 fi -printf 'ENTRY_PTR=0x%s\n' "$ENTRY_PTR" > "$OUT/player.env" +printf 'ENTRY_PTR=0x%s\n' "$ENTRY_PTR" > "$OUT/$NAME.env" -echo "build.sh: $OUT/player.bin ($(wc -c < "$OUT/player.bin") bytes)" +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 index 9a1bf7f..f198ab1 100644 --- a/bad-apple/player/entry.asm +++ b/bad-apple/player/entry.asm @@ -1,7 +1,12 @@ -; entry.asm -- payload entry for the Bad Apple player. +; 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 @@ -14,5 +19,61 @@ _start: 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/main.c b/bad-apple/player/main.c index 52983d3..1b7b920 100644 --- a/bad-apple/player/main.c +++ b/bad-apple/player/main.c @@ -1,5 +1,15 @@ -/* main.c -- Bad Apple payload, one-frame mode and (later) playback. */ +/* 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 + +/* The 30 s stream's exact size, from data/manifest.json. The real loader + * allocates this before the first datagram; a different clip needs a rebuild. */ +#define NET_LEN 2401942 #define MOVIE_BASE 0x00080000 #define MOVIE_MAX 0x00308000 @@ -13,6 +23,9 @@ #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 NET_PORT 7001 extern void fill_run_(); extern void fill_init_(); @@ -50,6 +63,69 @@ void *ctx; fill_rect(x, y, w, h, color); } +static unsigned char rxbuf[1600]; +static unsigned char sa[16]; +static unsigned char from[16]; +static int fromlen; + +/* Receive movie.bin over the OS's UDP stack into a malloc'd bit buffer. + * Packet: u32 offset, u16 length, u16 seq, then bytes; length 0 is EOF. */ +static unsigned int receive_movie(expected) +unsigned int expected; +{ + unsigned int ptr; + unsigned int offset; + unsigned int length; + unsigned int total; + unsigned int i; + unsigned char *dst; + int fd; + int n; + + ptr = malloc_(expected); + if (ptr == 0) { + return 0; + } + fd = socket_(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (fd < 0) { + return 0; + } + sa_any(sa, NET_PORT); + if (bind_(fd, sa, SOCKADDR_LEN) < 0) { + close_(fd); + return 0; + } + total = 0; + dst = (unsigned char *)ptr; + for (;;) { + fromlen = SOCKADDR_LEN; + n = recvfrom_(fd, rxbuf, (int)sizeof(rxbuf), 0, from, &fromlen); + if (n < 8) { + 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 (length == 0) { + break; + } + if (offset + length > expected) { + continue; + } + for (i = 0; i < length; i++) { + dst[offset + i] = rxbuf[8 + i]; + } + total += length; + if (total >= expected) { + break; + } + } + close_(fd); + 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() @@ -78,18 +154,9 @@ int main() unsigned int pace; unsigned int movie_base; unsigned int movie_len; + unsigned int net; int rc; - vid_dint_(); - fill_init_(); - - /* Display interrupt at line 1: our only timebase. Interrupts stay off; - * wait_vsync() polls the pending bit. */ - *(unsigned short *)0xC00000A0 = 1; - - /* Start from white; the OS left its own screen behind. */ - fill_rect(0, 0, 1280, 1024, 0); - movie_base = *(unsigned int *)CTRL_MOVIE; movie_len = *(unsigned int *)CTRL_MOVIELEN; if (movie_base == 0) { @@ -99,12 +166,40 @@ int main() movie_len = MOVIE_MAX; } - if (*(unsigned int *)CTRL_WAIT != 0) { - *(unsigned int *)(MARK + 0x10 * 8) = 0x10; /* takeover, waiting for data */ - while (*(unsigned int *)CTRL_READY == 0) { + net = *(unsigned int *)CTRL_NET; + if (net == 0 && NET_DEFAULT) { + net = 1; + } + if (net != 0) { + /* Real delivery: the OS stack is still alive for the receive; the + * payload owns the machine only after the last byte arrives. */ + movie_len = NET_LEN; + *(unsigned int *)(MARK + 0x10 * 8) = 0x20; + movie_base = receive_movie(movie_len); + if (movie_base == 0) { + *(unsigned int *)MARK = 0xBAD00003; + for (;;) { + } + } + vid_dint_(); + } else { + vid_dint_(); + if (*(unsigned int *)CTRL_WAIT != 0) { + *(unsigned int *)(MARK + 0x10 * 8) = 0x10; + while (*(unsigned int *)CTRL_READY == 0) { + } } } + fill_init_(); + + /* Display interrupt at line 1: our only timebase. Interrupts stay off; + * wait_vsync() polls the pending bit. */ + *(unsigned short *)0xC00000A0 = 1; + + /* Start from white; the OS left its own screen behind. */ + fill_rect(0, 0, 1280, 1024, 0); + rc = movie_open((unsigned char *)movie_base, movie_len, &header); if (rc != 0) { *(unsigned int *)MARK = 0xBAD00001; 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/tools/load.sh b/bad-apple/tools/load.sh new file mode 100755 index 0000000..97a7e75 --- /dev/null +++ b/bad-apple/tools/load.sh @@ -0,0 +1,52 @@ +#!/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 + +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 ;; + -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" +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 index ab60e73..693cbbc 100644 --- a/bad-apple/tools/load_mame.lua +++ b/bad-apple/tools/load_mame.lua @@ -132,6 +132,7 @@ local function load_and_enter() 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) diff --git a/bad-apple/tools/send_movie.py b/bad-apple/tools/send_movie.py new file mode 100644 index 0000000..ae5f423 --- /dev/null +++ b/bad-apple/tools/send_movie.py @@ -0,0 +1,65 @@ +#!/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 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: + 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, b""), (host, port)) + finally: + sock.close() + print("sent %d bytes in %d packets" % (len(data), seq + 1)) + 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) + args = parser.parse_args(argv) + return send_movie(args.host, args.port, args.movie, args.chunk, args.delay) + + +if __name__ == "__main__": + raise SystemExit(main()) 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 From 4013f75434cccd5a08fd39fbcaa12dac5056b10f Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Sun, 1 Nov 2026 10:20:23 +0200 Subject: [PATCH 10/15] docs(badapple): usage, measurements and gaps --- bad-apple/README.md | 108 ++++++++++++++++++++++++++++++++++++++++---- bad-apple/REPORT.md | 96 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 bad-apple/REPORT.md diff --git a/bad-apple/README.md b/bad-apple/README.md index a96420f..a3ab315 100644 --- a/bad-apple/README.md +++ b/bad-apple/README.md @@ -1,12 +1,102 @@ -# Bad Apple on VT1200 — 30 s vector prototype +# Bad Apple on a VT1200 — 30 s vector prototype -This is the standalone, polygon-styled 30-second Bad Apple demo described in -[`docs/superpowers/specs/2026-10-31-bad-apple-vector-design.md`](../docs/superpowers/specs/2026-10-31-bad-apple-vector-design.md). +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 host pipeline turns the first 30 s of `hd60.mp4` into a 640×512 black-and-white -vector picture, packs only changed 32×32 tiles as rectangles, and a TMS34010 -payload paints them with `FILL L`. The payload takes over the terminal, so a hang -ends at a power cycle. +The payload takes over the terminal: it disables interrupts and never returns. +**Recovery is a power cycle.** The ROM is never modified. -Status: under construction. See the implementation plan at -[`docs/superpowers/plans/2026-11-01-bad-apple-vector.md`](../docs/superpowers/plans/2026-11-01-bad-apple-vector.md). +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 +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`. diff --git a/bad-apple/REPORT.md b/bad-apple/REPORT.md new file mode 100644 index 0000000..c810bdc --- /dev/null +++ b/bad-apple/REPORT.md @@ -0,0 +1,96 @@ +# 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. + +## Pacing + +`wait_vsync()` programs `DPYINT=1`, clears `INTPEND`, and polls the DI bit at +`0xC0000120`. With `BA_PACE=1`, ten source frames took roughly fifteen MAME +frames (startup included), i.e. the 60-on-72 cadence is being honoured rather +than 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`. + +Status: the packet format 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. From 1d9da7e3cf381903a42b4ec5b54415bfd346e05f Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Sun, 1 Nov 2026 10:29:49 +0200 Subject: [PATCH 11/15] fix(badapple): checksum, packet bounds, ticks and marker placement --- bad-apple/README.md | 9 ++- bad-apple/REPORT.md | 22 +++--- bad-apple/pipeline/movie.py | 3 +- bad-apple/player/build.sh | 9 ++- bad-apple/player/main.c | 131 +++++++++++++++++++++------------- bad-apple/player/movie.c | 30 +++++++- bad-apple/player/movie.h | 1 + bad-apple/tests/test_movie.py | 9 +++ bad-apple/tools/load_mame.lua | 25 +++++-- bad-apple/tools/send_movie.py | 8 ++- 10 files changed, 178 insertions(+), 69 deletions(-) diff --git a/bad-apple/README.md b/bad-apple/README.md index a3ab315..9add77b 100644 --- a/bad-apple/README.md +++ b/bad-apple/README.md @@ -49,7 +49,8 @@ The 1987 TI TMS34010 SDK runs under DOSBox. The C sources are K&R C: the ```sh # one frame, then dump VRAM at MAME frame 2700 -bad-apple/tools/run_mame.sh one +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 \ @@ -100,3 +101,9 @@ block with the background colour, then filling the minority-colour rectangles. * **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 index c810bdc..5801e14 100644 --- a/bad-apple/REPORT.md +++ b/bad-apple/REPORT.md @@ -50,9 +50,11 @@ primitive now re-asserts PSIZE=1 on every call. ## Pacing `wait_vsync()` programs `DPYINT=1`, clears `INTPEND`, and polls the DI bit at -`0xC0000120`. With `BA_PACE=1`, ten source frames took roughly fifteen MAME -frames (startup included), i.e. the 60-on-72 cadence is being honoured rather -than free-running. +`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 @@ -68,12 +70,16 @@ measurement. `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`. +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 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. +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 diff --git a/bad-apple/pipeline/movie.py b/bad-apple/pipeline/movie.py index 747f7dd..7af49cf 100644 --- a/bad-apple/pipeline/movie.py +++ b/bad-apple/pipeline/movie.py @@ -70,7 +70,8 @@ def _align4(value: int) -> int: 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 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)) diff --git a/bad-apple/player/build.sh b/bad-apple/player/build.sh index c26195b..b98b837 100755 --- a/bad-apple/player/build.sh +++ b/bad-apple/player/build.sh @@ -118,11 +118,14 @@ cp "$WORK/PLAYER.OUT" "$OUT/$NAME.coff" ENTRY_PTR=$(sed 's/\r$//' "$WORK/PLAYER.MAP" \ | awk '/ENTRY POINT SYMBOL/ { print $NF; exit }') -if [ -z "$ENTRY_PTR" ]; then - echo "build.sh: no entry point in PLAYER.map" >&2 +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\n' "$ENTRY_PTR" > "$OUT/$NAME.env" +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 diff --git a/bad-apple/player/main.c b/bad-apple/player/main.c index 1b7b920..e0934cf 100644 --- a/bad-apple/player/main.c +++ b/bad-apple/player/main.c @@ -7,15 +7,18 @@ #define NET_DEFAULT 0 #endif -/* The 30 s stream's exact size, from data/manifest.json. The real loader - * allocates this before the first datagram; a different clip needs a rebuild. */ #define NET_LEN 2401942 +#define NET_PORT 7001 #define MOVIE_BASE 0x00080000 #define MOVIE_MAX 0x00308000 #define VRAM_BASE 0x04000000 #define ROW_BITS 2048 -#define MARK 0x01810000 + +/* 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) @@ -24,8 +27,8 @@ #define CTRL_WAIT (MARK + 0x34 * 8) #define CTRL_READY (MARK + 0x38 * 8) #define CTRL_NET (MARK + 0x3C * 8) - -#define NET_PORT 7001 +#define CTRL_MAGIC (MARK + 0x1C * 8) +#define MAGIC_VALUE 0x42414450 extern void fill_run_(); extern void fill_init_(); @@ -47,7 +50,6 @@ int color; } fill_daddr = VRAM_BASE + (unsigned int)y * ROW_BITS + (unsigned int)x; fill_dydx = ((unsigned int)h << 16) | (unsigned int)w; - /* The decoder's colour is the canvas value: 1 = black, 0 = white. */ fill_color = color ? 0x0000 : 0xFFFF; fill_run_(); } @@ -63,20 +65,31 @@ 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; -/* Receive movie.bin over the OS's UDP stack into a malloc'd bit buffer. - * Packet: u32 offset, u16 length, u16 seq, then bytes; length 0 is EOF. */ +/* 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 total; + unsigned int sent_sum; unsigned int i; unsigned char *dst; int fd; @@ -95,12 +108,12 @@ unsigned int expected; close_(fd); return 0; } - total = 0; + sent_sum = 0; dst = (unsigned char *)ptr; for (;;) { fromlen = SOCKADDR_LEN; n = recvfrom_(fd, rxbuf, (int)sizeof(rxbuf), 0, from, &fromlen); - if (n < 8) { + if (n < 10) { continue; } offset = (unsigned int)rxbuf[0] @@ -108,21 +121,27 @@ unsigned int expected; | ((unsigned int)rxbuf[2] << 16) | ((unsigned int)rxbuf[3] << 24); length = (unsigned int)rxbuf[4] | ((unsigned int)rxbuf[5] << 8); - if (length == 0) { + 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); break; } - if (offset + length > expected) { + if (length > (unsigned int)(n - 10)) { + continue; + } + if (offset > expected || length > expected - offset) { continue; } for (i = 0; i < length; i++) { - dst[offset + i] = rxbuf[8 + i]; - } - total += length; - if (total >= expected) { - break; + dst[offset + i] = rxbuf[10 + i]; } } close_(fd); + if (movie_checksum(dst, expected) != sent_sum) { + return 0; + } return ptr; } @@ -133,8 +152,6 @@ static void wait_vsync() unsigned int i; unsigned short pending; - /* Clear a stale DI, then wait for the next one. DPYINT is programmed to - * line 1 once at startup, so DI appears at the top of every frame. */ *(unsigned short *)0xC0000120 = 0; for (i = 0; i < 200000; i++) { pending = *(unsigned short *)0xC0000120; @@ -147,44 +164,54 @@ static void wait_vsync() 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 net; + unsigned int ticks; + unsigned int now; + unsigned int next; int rc; - movie_base = *(unsigned int *)CTRL_MOVIE; - movie_len = *(unsigned int *)CTRL_MOVIELEN; - if (movie_base == 0) { + 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; - } - if (movie_len == 0) { movie_len = MOVIE_MAX; } - - net = *(unsigned int *)CTRL_NET; if (net == 0 && NET_DEFAULT) { net = 1; } + if (net != 0) { - /* Real delivery: the OS stack is still alive for the receive; the - * payload owns the machine only after the last byte arrives. */ movie_len = NET_LEN; *(unsigned int *)(MARK + 0x10 * 8) = 0x20; movie_base = receive_movie(movie_len); if (movie_base == 0) { - *(unsigned int *)MARK = 0xBAD00003; - for (;;) { - } + show_fault(0xBAD00003); } vid_dint_(); } else { vid_dint_(); - if (*(unsigned int *)CTRL_WAIT != 0) { + if (wait != 0) { *(unsigned int *)(MARK + 0x10 * 8) = 0x10; while (*(unsigned int *)CTRL_READY == 0) { } @@ -192,24 +219,14 @@ int main() } fill_init_(); - - /* Display interrupt at line 1: our only timebase. Interrupts stay off; - * wait_vsync() polls the pending bit. */ *(unsigned short *)0xC00000A0 = 1; - - /* Start from white; the OS left its own screen behind. */ fill_rect(0, 0, 1280, 1024, 0); rc = movie_open((unsigned char *)movie_base, movie_len, &header); if (rc != 0) { - *(unsigned int *)MARK = 0xBAD00001; - for (;;) { - } + show_fault(0xBAD00001); } - start = *(unsigned int *)CTRL_START; - count = *(unsigned int *)CTRL_COUNT; - pace = *(unsigned int *)CTRL_PACE; last = header.frame_count; if (last != 0) { last = last - 1; @@ -218,19 +235,33 @@ int main() last = start + count - 1; } - for (frame = 0; frame <= last; frame++) { + 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) { - *(unsigned int *)MARK = 0xBAD00002; - *(unsigned int *)(MARK + 0x14 * 8) = frame; - for (;;) { - } + 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; diff --git a/bad-apple/player/movie.c b/bad-apple/player/movie.c index c4a67d6..c83a94d 100644 --- a/bad-apple/player/movie.c +++ b/bad-apple/player/movie.c @@ -49,11 +49,17 @@ struct movie_header *header; 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->tile == 0 + 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; @@ -61,6 +67,25 @@ struct movie_header *header; 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; @@ -140,6 +165,9 @@ void *ctx; 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), diff --git a/bad-apple/player/movie.h b/bad-apple/player/movie.h index abf514b..349b77e 100644 --- a/bad-apple/player/movie.h +++ b/bad-apple/player/movie.h @@ -20,6 +20,7 @@ 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/tests/test_movie.py b/bad-apple/tests/test_movie.py index 877e7cf..7800a0e 100644 --- a/bad-apple/tests/test_movie.py +++ b/bad-apple/tests/test_movie.py @@ -27,6 +27,15 @@ def test_more_than_127_rects_roundtrip(): 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)])])) diff --git a/bad-apple/tools/load_mame.lua b/bad-apple/tools/load_mame.lua index 693cbbc..35a3f49 100644 --- a/bad-apple/tools/load_mame.lua +++ b/bad-apple/tools/load_mame.lua @@ -79,8 +79,22 @@ if not payload or not movie then return end local entry = entry_from_env() -print(string.format("BA: payload %d bytes, movie %d bytes, entry %s", - #payload, #movie, hex(entry))) + +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 @@ -125,6 +139,7 @@ local function load_and_enter() 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) @@ -185,8 +200,10 @@ emu.register_frame_done(function() end end if frames >= STOP_AT then - print(string.format("BA: end at frame %d (loaded=%s done=%s)", - frames, tostring(loaded), tostring(done))) + 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") diff --git a/bad-apple/tools/send_movie.py b/bad-apple/tools/send_movie.py index ae5f423..2cf35e0 100644 --- a/bad-apple/tools/send_movie.py +++ b/bad-apple/tools/send_movie.py @@ -19,6 +19,10 @@ 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(" Date: Sun, 1 Nov 2026 11:06:49 +0200 Subject: [PATCH 12/15] fix(badapple): pace the UDP load at 2 ms per packet --- bad-apple/tools/load.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bad-apple/tools/load.sh b/bad-apple/tools/load.sh index 97a7e75..d132366 100755 --- a/bad-apple/tools/load.sh +++ b/bad-apple/tools/load.sh @@ -15,6 +15,7 @@ PORT=7001 MOVIE=$ROOT/bad-apple/data/movie.bin WAIT=180 SETTLE=3 +DELAY=0.002 while [ $# -gt 0 ]; do case "$1" in @@ -23,6 +24,7 @@ while [ $# -gt 0 ]; do --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 @@ -48,5 +50,6 @@ python3 "$ROOT/harness/badapple/rdebug_load.py" \ --env "$ROOT/bad-apple/player/build/player-net.env" sleep "$SETTLE" -python3 "$HERE/send_movie.py" --host "$HOST" --port "$PORT" --movie "$MOVIE" +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." From c3ae1269e43ff0774be8d61f81b1af9a8b8d8f8e Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Sun, 1 Nov 2026 11:08:55 +0200 Subject: [PATCH 13/15] fix(badapple): distinguish UDP load failures and repeat the send --- bad-apple/player/main.c | 15 ++++++++++++--- bad-apple/tools/send_movie.py | 34 +++++++++++++++++++--------------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/bad-apple/player/main.c b/bad-apple/player/main.c index e0934cf..f14568e 100644 --- a/bad-apple/player/main.c +++ b/bad-apple/player/main.c @@ -79,6 +79,7 @@ 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. @@ -97,15 +98,18 @@ unsigned int expected; 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; @@ -126,7 +130,11 @@ unsigned int expected; | ((unsigned int)rxbuf[11] << 8) | ((unsigned int)rxbuf[12] << 16) | ((unsigned int)rxbuf[13] << 24); - break; + if (movie_checksum(dst, expected) == sent_sum) { + break; + } + sent_sum = 0; + continue; } if (length > (unsigned int)(n - 10)) { continue; @@ -139,7 +147,8 @@ unsigned int expected; } } close_(fd); - if (movie_checksum(dst, expected) != sent_sum) { + if (sent_sum == 0) { + rx_error = 6; return 0; } return ptr; @@ -206,7 +215,7 @@ int main() *(unsigned int *)(MARK + 0x10 * 8) = 0x20; movie_base = receive_movie(movie_len); if (movie_base == 0) { - show_fault(0xBAD00003); + show_fault(0xBAD00000 | rx_error); } vid_dint_(); } else { diff --git a/bad-apple/tools/send_movie.py b/bad-apple/tools/send_movie.py index 2cf35e0..25042ef 100644 --- a/bad-apple/tools/send_movie.py +++ b/bad-apple/tools/send_movie.py @@ -34,25 +34,27 @@ def parse_ack(data: bytes) -> int: def send_movie(host: str, port: int, path: str, chunk: int = 1450, - delay: float = 0.0005) -> int: + delay: float = 0.0005, passes: int = 3) -> int: data = Path(path).read_bytes() sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: - 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)) + 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 packets" % (len(data), seq + 1)) + print("sent %d bytes in %d pass(es)" % (len(data), passes)) return 0 @@ -63,8 +65,10 @@ def main(argv=None) -> int: 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) + return send_movie(args.host, args.port, args.movie, args.chunk, + args.delay, args.passes) if __name__ == "__main__": From 983784d9ec028ee1d0451791d1afcdf212897438 Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Sun, 1 Nov 2026 11:15:57 +0200 Subject: [PATCH 14/15] fix(badapple): flip potrace y-up polygons to the framebuffer --- bad-apple/data/manifest.json | 4 ++-- bad-apple/pipeline/vectorize.py | 10 ++++++++++ bad-apple/tests/test_vectorize.py | 10 +++++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/bad-apple/data/manifest.json b/bad-apple/data/manifest.json index f04d837..3b8b15b 100644 --- a/bad-apple/data/manifest.json +++ b/bad-apple/data/manifest.json @@ -1,7 +1,7 @@ { "cost_us": { - "max": 37991, - "mean": 7472.0695503699535, + "max": 37992, + "mean": 7472.069550369951, "p50": 7948, "p95": 12011 }, diff --git a/bad-apple/pipeline/vectorize.py b/bad-apple/pipeline/vectorize.py index 99dd87e..1e5c74f 100644 --- a/bad-apple/pipeline/vectorize.py +++ b/bad-apple/pipeline/vectorize.py @@ -113,6 +113,16 @@ def trace(mask: np.ndarray, params: dict) -> list[Polygon]: 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 = [] diff --git a/bad-apple/tests/test_vectorize.py b/bad-apple/tests/test_vectorize.py index cb63146..673d73d 100644 --- a/bad-apple/tests/test_vectorize.py +++ b/bad-apple/tests/test_vectorize.py @@ -1,6 +1,6 @@ import numpy as np -from pipeline.vectorize import rdp, rasterize, trace +from pipeline.vectorize import rdp, rasterize, trace, vectorize_frame def test_rdp_drops_collinear_point(): @@ -23,6 +23,14 @@ def test_rasterize_square_with_hole(): 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 From cb2d1c37df1b8c4f1b130a017c18c4de505a7784 Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Sun, 1 Nov 2026 11:22:05 +0200 Subject: [PATCH 15/15] docs(badapple): record the potrace y-up orientation fix --- bad-apple/REPORT.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bad-apple/REPORT.md b/bad-apple/REPORT.md index 5801e14..7151873 100644 --- a/bad-apple/REPORT.md +++ b/bad-apple/REPORT.md @@ -47,6 +47,12 @@ 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