merge: Bad Apple 30s vector prototype

This commit is contained in:
arti committed 2026-11-01 11:23:38 +02:00
commit 371fe27bc2
40 files changed
+2762

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
data/*
!data/manifest.json
data5/
preview/
player/build/
player/work/
__pycache__/
.pytest_cache/
+109
View File
@@ -0,0 +1,109 @@
# Bad Apple on a VT1200 — 30 s vector prototype
A standalone, polygon-styled 30-second Bad Apple demo for the VT1200
(TMS34010). The host pipeline turns the first 30 s of `hd60.mp4` into a
640×512 binary picture (2×2 screen blocks), traces it into simplified
straight-edged polygons, and packs only changed 32×32 tiles as rectangles.
The payload paints those rectangles with `FILL L`.
The payload takes over the terminal: it disables interrupts and never returns.
**Recovery is a power cycle.** The ROM is never modified.
Design: [`../docs/superpowers/specs/2026-10-31-bad-apple-vector-design.md`](../docs/superpowers/specs/2026-10-31-bad-apple-vector-design.md)
Plan: [`../docs/superpowers/plans/2026-11-01-bad-apple-vector.md`](../docs/superpowers/plans/2026-11-01-bad-apple-vector.md)
Measurements and gaps: [`REPORT.md`](REPORT.md)
## Build the 30 s stream
```sh
cd bad-apple
python3 -m pipeline.build \
--video /tmp/opencode/badapple-real/hd60.mp4 \
--out data --duration 30
```
Outputs:
| file | size | what |
|---|---|---|
| `data/movie.bin` | 2,401,942 B | 1,757 records, 43 source frames dropped |
| `data/manifest.json` | small | data size, dirty-tile/rect/cost percentiles |
| `data/preview/frame_*.png` | — | still previews |
Measured: dirty tiles mean 44.9 / p95 72; rectangles mean 393 / p95 670;
estimated paint cost mean 7.47 ms / p95 12.01 ms against a 13.9 ms refresh
gate. The data area is 3.03 MB, so this fits with ~0.6 MB spare.
## Build the payload
```sh
bad-apple/player/build.sh # debugger build: build/player.bin
NET=1 bad-apple/player/build.sh # network build: build/player-net.bin
```
The 1987 TI TMS34010 SDK runs under DOSBox. The C sources are K&R C: the
1987 compiler rejects ANSI prototypes and `const`. `.text` is linked at bit
`0x018C0000`, `.bss` at `0x01C00000`.
## Run in MAME (debugger path)
```sh
# one frame, then dump VRAM at MAME frame 2700
VT1200_DUMP_VRAM_FRAMES=2700 VT1200_DUMP_DIR=/tmp/ba-one \
bad-apple/tools/run_mame.sh one
# 5 s slice, paced
BA_MOVIE=bad-apple/data5/movie.bin BA_START=0 BA_COUNT=300 \
bad-apple/tools/run_mame.sh play
# full 30 s, paced
BA_MOVIE=bad-apple/data/movie.bin BA_MOVIE_BASE=0x00080000 \
BA_WAIT=1 BA_PACE=1 bad-apple/tools/run_mame.sh play
```
`run_mame.sh` exits when the payload sets its status marker. The debugger
loader writes the movie into high RAM (`0x01D00000`) for small slices; for the
full 30 s it uses `BA_WAIT=1`, lets the payload take over, then writes low RAM
at `0x00080000` — the same ordering as the real delivery path.
## Deliver to a real unit (network path)
The terminal must be booted and answering ping. The payload is loaded over
`rdebug`, then receives `movie.bin` over UDP into an OS `malloc` buffer, then
disables interrupts and plays.
```sh
bad-apple/tools/load.sh --host <unit-ip> --movie bad-apple/data/movie.bin
```
This path is implemented but **not yet verified on the unit or over MAME's
tap** in this worktree: the tap was down. The OS socket API it uses is the one
already proven by `harness/udpstream/udpstream.c`. See `REPORT.md`.
## Data format
Header 36 bytes (`BAPL`, version, geometry, frame count, index/data offsets).
Index entry: `u32 offset, u8 ticks, u8 flags`. Record: `u16 dirty tile count`,
then per tile `u16 tile index, u16 (bg<<15)|rect count`, then 3-byte rects
(`x0:5 y0:5 w-1:5 h-1:5`). A tile is repainted by filling its 64×64 screen
block with the background colour, then filling the minority-colour rectangles.
## Gotchas this prototype records
* **PSIZE is re-asserted on every FILL.** The resident OS leaves PSIZE at 2 bpp;
a one-time write in `_fill_init_` was overwritten, and every span came out
twice as wide. The fix is the per-call `movi 1 / move @0C0000150h`.
* **VRAM polarity is inverted from the canvas**: bit 1 = white, bit 0 = black.
`tools/check_vram.py` downsamples with that polarity.
* **The OS dies before the movie is in low RAM.** The network path receives
into an OS heap buffer while the stack is alive, then DINTs. The debugger
path does the same ordering with `BA_WAIT=1`.
* **MAME is not the unit's performance verdict.** MAME needed ~2.1 emulated
frames per movie record on the full stream; the unit's 50 MHz FILL rate is
the number that matters. See `REPORT.md`.
* **The marker/control block lives in `.bss`**, not at bit `0x01810000`: a
2.4 MB heap allocation can cross that address. `player.env` publishes
`MARK_PTR`; `load_mame.lua` reads it.
* **Pacing reads each record's `ticks`.** 60 source frames took 74 MAME frames
(72 display frames plus startup), so the 60-on-72 cadence is measured, not
assumed.
+108
View File
@@ -0,0 +1,108 @@
# Bad Apple 30 s vector prototype — report
Date: 2026-11-01
Design: [`2026-10-31-bad-apple-vector-design.md`](../docs/superpowers/specs/2026-10-31-bad-apple-vector-design.md)
## What was built
* Host pipeline: video → temporal median → blur/threshold → potrace
straight-line polygons → RDP → even-odd rasteriser → 32×32 tile deltas →
`movie.bin`.
* Payload: TMS34010 C decoder + `FILL L` primitive, 16-bit marker page,
display-interrupt pacing, UDP receive path.
* Tools: MAME debugger loader, VRAM checker, UDP sender, rdebug load script.
## Measured numbers (first 30 s, 1,800 source frames)
| quantity | value |
|---|---|
| source | 960×720, 60 fps, first 30 s |
| logical grid | 640×512, 2×2 screen blocks |
| records | 1,757 (43 source frames dropped by the cost gate) |
| data | **2,401,942 bytes** (cap 3.03 MB, gate 3.0 MB) |
| dirty tiles/frame | mean 44.9, p50 47, p95 72, max 320 |
| rectangles/frame | mean 393, p50 409, p95 670, max 1,175 |
| estimated cost/frame | mean 7.47 ms, p50 7.95 ms, **p95 12.01 ms**, max 37.99 ms |
| gate | 13.9 ms (one 72 Hz refresh) |
The stream fits with ~0.6 MB spare. The p95 frame is inside one refresh; the
max is a scene cut that necessarily takes more than one.
## Exactness evidence (MAME)
`tools/check_vram.py` unpacks the 256-byte-stride VRAM dump, downsamples 2×2,
and compares bit-for-bit against the Python application of the records.
| run | movie frame | result |
|---|---|---|
| test pattern | 0 | **EXACT** |
| 5 s slice | 0 | **EXACT** |
| 5 s slice | 180 | **EXACT** |
| 5 s slice | 297 | **EXACT** |
| 30 s stream | 0 | **EXACT** |
| 30 s stream | 900 | **EXACT** |
The one-frame MAME run also proved the FILL geometry: a logical black
rectangle 10..109 × 20..69 became exactly screen x 20..219. The first version
was exactly twice as wide; the cause was the OS leaving PSIZE at 2 bpp. The
primitive now re-asserts PSIZE=1 on every call.
Potrace emits **y-up** GeoJSON; the framebuffer is y-down. The first build was
vertically mirrored, and the VRAM checker still said EXACT because it consumed
the same flipped data. `trace()` now flips y before simplification, and
`test_trace_preserves_vertical_position` pins the orientation. The preview PNGs
were the visible symptom.
## Pacing
`wait_vsync()` programs `DPYINT=1`, clears `INTPEND`, and polls the DI bit at
`0xC0000120`. The loop reads each record's `ticks` from the index and advances
a 1/360 s accumulator by 5 per vsync, so a 1/60 s record needs 6 units and a
held record needs 12. Measured with `BA_COUNT=60`: status at MAME frame 2580,
74 frames after takeover at 2506 — 60 source frames in 72 display frames plus
startup, i.e. the 60-on-72 cadence is real, not free-running.
## The performance gap MAME cannot close
MAME needed ~2.1 emulated frames per movie record while replaying frames 0..900
of the full stream. The encoder's model says a mean record is 7.47 ms of a
13.9 ms refresh, so MAME's TMS34010 interpreter is several times slower than the
model. MAME proves the picture and the delta chain; it cannot prove the unit's
60 fps. The unit's measured `FILL` rate (0.40 µs per 16-pixel word) is the
number the cost model uses. A real-unit run is the remaining performance
measurement.
## Network delivery
`player-net.bin` uses the OS UDP socket API (`socket_`, `bind_`,
`recvfrom_`, `malloc_`) to receive `movie.bin` on port 7001 into a heap buffer,
then DINTs and plays. The sender is `tools/send_movie.py`. Packets are
10-byte headers (offset, length, seq, crc16) plus body; a final
offset==length-of-file / 4-byte-checksum packet is required, and the receiver
refuses to jump unless the whole-file checksum matches. Out-of-range packets
are dropped without reading or writing outside the buffer.
Status: the packet format, header offset, checksum path and sender are
unit-tested; the MAME tap was down in this worktree, so the UDP receive was
not exercised end-to-end. The same OS socket API is already proven on the unit
by `harness/udpstream/udpstream.c`. This is the one delivery-path gap.
## Regression
The no-env MAME baseline at 200 frames is unchanged:
```
--- distinct PCs sampled: 2
PC=3FDCE400 samples=199
PC=3FDCE350 samples=1
```
## Remaining gaps
1. Real-unit 30 s performance: needs a power-cycle run with
`tools/load.sh`.
2. UDP receive on the unit: socket API proven, this payload not yet.
3. Incremental painting can show a brief wipe on a cut; accepted for this
prototype.
4. Potrace/RDP temporal jitter was reduced with a 3-frame median but not
measured as a motion score.
+6
View File
@@ -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))
+31
View File
@@ -0,0 +1,31 @@
{
"cost_us": {
"max": 37992,
"mean": 7472.069550369951,
"p50": 7948,
"p95": 12011
},
"data_bytes": 2401942,
"dirty_tiles": {
"max": 320,
"mean": 44.899829254410925,
"p50": 47,
"p95": 72
},
"display_hz": 72,
"dropped": 43,
"frame_count": 1757,
"height": 512,
"rects": {
"max": 1175,
"mean": 393.14968696642,
"p50": 409,
"p95": 670
},
"scale": 2,
"source_fps": 60,
"source_frames": 1800,
"tile": 32,
"video": "/tmp/opencode/badapple-real/hd60.mp4",
"width": 640
}
View File
Whitespace-only changes.
+105
View File
@@ -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())
+70
View File
@@ -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
+96
View File
@@ -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
+190
View File
@@ -0,0 +1,190 @@
"""The on-RAM movie format: header, index, and per-frame tile deltas."""
from __future__ import annotations
import os
import struct
from dataclasses import dataclass, field
from typing import Sequence
MAGIC = b"BAPL"
VERSION = 1
HEADER_SIZE = 36
INDEX_ENTRY_SIZE = 6
#: The data must stop below the payload's rdebug origin at byte 0x318000.
MAX_DATA_BYTES = 0x308000
class MovieFormatError(Exception):
pass
@dataclass(frozen=True)
class Rect:
x0: int
y0: int
w: int
h: int
@dataclass(frozen=True)
class TileRecord:
tile_index: int
bg: int
rects: list[Rect] = field(default_factory=list)
@dataclass(frozen=True)
class FrameRecord:
ticks: int
flags: int
tiles: list[TileRecord] = field(default_factory=list)
@dataclass(frozen=True)
class MovieHeader:
width: int
height: int
scale: int
tile: int
tiles_x: int
tiles_y: int
frame_count: int
source_fps: int
display_hz: int
def pack(self) -> bytes:
index_offset = HEADER_SIZE
data_offset = _align4(index_offset + INDEX_ENTRY_SIZE * self.frame_count)
return struct.pack(
"<4s7H3I3H",
MAGIC, VERSION, self.width, self.height, self.scale, self.tile,
self.tiles_x, self.tiles_y, self.frame_count,
index_offset, data_offset,
self.source_fps, self.display_hz, 0)
def _align4(value: int) -> int:
return (value + 3) & ~3
def _pack_rect(rect: Rect) -> bytes:
if not (0 <= rect.x0 < 32 and 0 <= rect.y0 < 32
and 1 <= rect.w <= 32 and 1 <= rect.h <= 32
and rect.x0 + rect.w <= 32 and rect.y0 + rect.h <= 32):
raise MovieFormatError("rect outside a 32x32 tile: %r" % (rect,))
value = (rect.x0 | (rect.y0 << 5) | ((rect.w - 1) << 10)
| ((rect.h - 1) << 15))
return value.to_bytes(3, "little")
def _unpack_rect(data: bytes, offset: int) -> tuple[Rect, int]:
if offset + 3 > len(data):
raise MovieFormatError("truncated rect")
value = data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16)
rect = Rect(
x0=value & 0x1F,
y0=(value >> 5) & 0x1F,
w=((value >> 10) & 0x1F) + 1,
h=((value >> 15) & 0x1F) + 1,
)
return rect, offset + 3
def encode_frame(record: FrameRecord) -> bytes:
if len(record.tiles) > 0xFFFF:
raise MovieFormatError("too many dirty tiles in one frame")
out = bytearray(struct.pack("<H", len(record.tiles)))
for tile in record.tiles:
if not (0 <= tile.tile_index <= 0xFFFF):
raise MovieFormatError("tile index out of range")
if len(tile.rects) > 0x7FFF:
raise MovieFormatError("too many rects in one tile")
out += struct.pack("<HH", tile.tile_index,
((tile.bg & 1) << 15) | len(tile.rects))
for rect in tile.rects:
out += _pack_rect(rect)
return bytes(out)
def decode_frame(data: bytes, offset: int) -> 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("<IBB", out, index_offset + i * INDEX_ENTRY_SIZE,
offset, frame.ticks & 0xFF, frame.flags & 0xFF)
out[offset:offset + len(record)] = record
offset += len(record)
tmp = path + ".tmp"
with open(tmp, "wb") as handle:
handle.write(out)
os.replace(tmp, path)
return total
def read_movie(path: str) -> 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(
"<IBB", data, index_offset + i * INDEX_ENTRY_SIZE)
if offset < data_offset or offset > len(data):
raise MovieFormatError("frame offset outside data area")
record, _ = decode_frame(data, offset)
frames.append(FrameRecord(ticks, flags, record.tiles))
return header, frames
+15
View File
@@ -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
}
+25
View File
@@ -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
+81
View File
@@ -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)
+180
View File
@@ -0,0 +1,180 @@
"""Turn a binary frame into simplified straight-edged polygons, and back."""
from __future__ import annotations
import json
import os
import subprocess
import tempfile
from typing import Iterable, Sequence
import numpy as np
Point = tuple[float, float]
Ring = list[Point]
Polygon = list[Ring]
def _distance_to_line(p: Point, a: Point, b: Point) -> float:
if a == b:
return ((p[0] - a[0]) ** 2 + (p[1] - a[1]) ** 2) ** 0.5
ax, ay = a
bx, by = b
px, py = p
dx, dy = bx - ax, by - ay
return abs(dy * px - dx * py + bx * ay - by * ax) / ((dx * dx + dy * dy) ** 0.5)
def rdp(points: Sequence[Point], epsilon: float) -> list[Point]:
"""Ramer-Douglas-Peucker on an open polyline."""
pts = list(points)
if len(pts) <= 2:
return pts
a, b = pts[0], pts[-1]
worst = 0.0
index = 0
for i in range(1, len(pts) - 1):
d = _distance_to_line(pts[i], a, b)
if d > worst:
worst, index = d, i
if worst <= epsilon:
return [a, b]
left = rdp(pts[:index + 1], epsilon)
right = rdp(pts[index:], epsilon)
return left[:-1] + right
def rdp_closed(points: Sequence[Point], epsilon: float) -> list[Point]:
"""Simplify a closed ring, keeping it closed."""
pts = list(points)
if len(pts) > 1 and pts[0] == pts[-1]:
pts = pts[:-1]
if len(pts) < 3:
return list(points)
# Split at the point farthest from the first so the endpoints are not
# coincident (RDP degenerates when a == b).
far = max(range(1, len(pts)),
key=lambda i: (pts[i][0] - pts[0][0]) ** 2
+ (pts[i][1] - pts[0][1]) ** 2)
first = rdp(pts[:far + 1], epsilon)
second = rdp(pts[far:] + [pts[0]], epsilon)
ring = first[:-1] + second
if ring[0] != ring[-1]:
ring.append(ring[0])
return ring
def parse_potrace_geojson(text: str) -> list[Polygon]:
"""Parse potrace's GeoJSON into polygons of (outer, holes...) rings."""
data = json.loads(text)
out: list[Polygon] = []
for feature in data.get("features", []):
geometry = feature.get("geometry") or {}
kind = geometry.get("type")
coordinates = geometry.get("coordinates") or []
if kind == "Polygon":
polygons = [coordinates]
elif kind == "MultiPolygon":
polygons = coordinates
else:
continue
for rings in polygons:
poly: Polygon = []
for ring in rings:
poly.append([(float(x), float(y)) for x, y in ring])
if poly:
out.append(poly)
return out
def trace(mask: np.ndarray, params: dict) -> list[Polygon]:
"""Trace a black mask into simplified straight-line polygons."""
if mask.ndim != 2:
raise ValueError("trace wants a 2-D mask")
height, width = mask.shape
if width % 8:
raise ValueError("potrace input width must be a multiple of 8")
epsilon = float(params.get("rdp_epsilon", 1.5))
min_segment = float(params.get("min_segment", 2))
pbm = np.packbits(mask.astype(bool), axis=1, bitorder="big").tobytes()
with tempfile.TemporaryDirectory(prefix="ba-trace-") as tmp:
pbm_path = os.path.join(tmp, "frame.pbm")
json_path = os.path.join(tmp, "frame.json")
with open(pbm_path, "wb") as handle:
handle.write(b"P4\n%d %d\n" % (width, height))
handle.write(pbm)
subprocess.run(
["potrace", "-b", "geojson", "-a", "0", "-t", "4", "-u", "1",
"-o", json_path, pbm_path],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
with open(json_path, "r", encoding="utf-8") as handle:
polygons = parse_potrace_geojson(handle.read())
# potrace's GeoJSON uses a y-up coordinate system; the framebuffer is
# y-down. Without this the whole movie is vertically mirrored.
flipped: list[Polygon] = []
for poly in polygons:
rings: Polygon = []
for ring in poly:
rings.append([(x, float(height) - y) for x, y in ring])
flipped.append(rings)
polygons = flipped
simplified: list[Polygon] = []
for poly in polygons:
rings: Polygon = []
for ring in poly:
ring = rdp_closed(ring, epsilon)
if len(ring) < 4:
continue
longest = max(
((ring[i][0] - ring[i - 1][0]) ** 2
+ (ring[i][1] - ring[i - 1][1]) ** 2) ** 0.5
for i in range(1, len(ring))
)
if longest < min_segment:
continue
rings.append(ring)
if rings:
simplified.append(rings)
return simplified
def rasterize(polygons: Iterable[Polygon], width: int, height: int) -> np.ndarray:
"""Even-odd scanline fill of polygons (outer rings and holes)."""
out = np.zeros((height, width), dtype=bool)
edges: list[tuple[float, float, float, float]] = []
for poly in polygons:
for ring in poly:
for i in range(1, len(ring)):
x0, y0 = ring[i - 1]
x1, y1 = ring[i]
if y0 == y1:
continue
edges.append((x0, y0, x1, y1))
if not edges:
return out
for y in range(height):
scan = y + 0.5
xs: list[float] = []
for x0, y0, x1, y1 in edges:
if (y0 <= scan < y1) or (y1 <= scan < y0):
xs.append(x0 + (scan - y0) * (x1 - x0) / (y1 - y0))
xs.sort()
for i in range(0, len(xs) - 1, 2):
left, right = xs[i], xs[i + 1]
start = max(0, int(np.ceil(left - 0.5)))
end = min(width - 1, int(np.floor(right - 0.5)))
if end >= start:
out[y, start:end + 1] = True
return out
def vectorize_frame(mask: np.ndarray, params: dict) -> np.ndarray:
"""Trace and re-rasterise one logical frame."""
height, width = mask.shape
return rasterize(trace(mask, params), width, height)
+132
View File
@@ -0,0 +1,132 @@
#!/bin/sh
# Build the Bad Apple payload with the 1987 TI TMS34010 SDK under DOSBox.
#
# .text at bit 0x018C0000, .bss at bit 0x01C00000 (the free block the rdebug
# loader already uses). Outputs: build/player.coff, player.bin, player.env.
set -eu
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
DIR=$(cd "$(dirname "$0")" && pwd)
WORK="$DIR/work"
OUT="$DIR/build"
IMG="$ROOT/tools/tms34010-sdk/1987-05 TMS34010 SDK/1987-11-06 TMS34010 Simulator-C Tools-ASM Tools.img"
DOSBOX_CONF="$ROOT/harness/sdk-scratch/dosbox-bench.conf"
TEXT=018C0000
BSS=01C00000
NET=${NET:-0}
if [ "$NET" = 1 ]; then
NAME=player-net
else
NAME=player
fi
mkdir -p "$WORK" "$OUT"
if [ ! -x "$WORK/GSPCG.EXE" ]; then
mcopy -s -n -i "$IMG" "::/A2/*" "$WORK/"
mcopy -s -n -i "$IMG" "::/C2/*" "$WORK/"
fi
cp "$DIR/main.c" "$WORK/MAIN.C"
cp "$DIR/movie.c" "$WORK/MOVIE.C"
cp "$DIR/movie.h" "$WORK/MOVIE.H"
cp "$DIR/romapi.h" "$WORK/ROMAPI.H"
printf '#define NET_DEFAULT %s\n' "$NET" > "$WORK/MODECFG.H"
cp "$DIR/fill.asm" "$WORK/FILL.ASM"
cp "$DIR/entry.asm" "$WORK/ENTRY.ASM"
{
printf '%s\n' '-a'
printf '%s\n' '-o PLAYER.out'
printf '%s\n' '-m PLAYER.map'
printf '%s\n' '-e _start'
printf '%s\n' 'MAIN.obj'
printf '%s\n' 'MOVIE.obj'
printf '%s\n' 'FILL.obj'
printf '%s\n' 'ENTRY.obj'
printf '%s\n' '-l RTS.LIB'
printf '%s\n' 'MEMORY'
printf '%s\n' '{'
printf '%s\n' " prog: origin = ${TEXT}h, length = 20000h"
printf '%s\n' " ram: origin = ${BSS}h, length = 200000h"
printf '%s\n' '}'
printf '%s\n' 'SECTIONS'
printf '%s\n' '{'
printf '%s\n' ' .text: {} > prog'
printf '%s\n' ' .data: {} > prog'
printf '%s\n' ' .cinit: {} > prog'
printf '%s\n' ' .bss: {} > ram'
printf '%s\n' '}'
} > "$WORK/PLAYER.CMD"
sed -i 's/$/\r/' "$WORK/PLAYER.CMD"
{
printf 'gspcpp MAIN\r\n'
printf 'gspcc MAIN\r\n'
printf 'gspcg MAIN\r\n'
printf 'gspa MAIN MAIN.obj MAIN.lst -l\r\n'
printf 'gspcpp MOVIE\r\n'
printf 'gspcc MOVIE\r\n'
printf 'gspcg MOVIE\r\n'
printf 'gspa MOVIE MOVIE.obj MOVIE.lst -l\r\n'
printf 'gspa FILL FILL.obj FILL.lst -l\r\n'
printf 'gspa ENTRY ENTRY.obj ENTRY.lst -l\r\n'
printf 'gsplnk PLAYER.cmd\r\n'
printf 'exit\r\n'
} > "$WORK/BUILD.BAT"
rm -f "$WORK"/MAIN.OBJ "$WORK"/MOVIE.OBJ "$WORK"/FILL.OBJ \
"$WORK"/ENTRY.OBJ "$WORK"/PLAYER.OUT "$WORK"/PLAYER.MAP \
"$WORK"/MAIN.LST "$WORK"/MOVIE.LST "$WORK"/FILL.LST \
"$WORK"/ENTRY.LST
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \
timeout 240 dosbox -conf "$DOSBOX_CONF" -noprimaryconf -nolocalconf \
-c "mount c $WORK" -c "c:" \
-c "BUILD.BAT" \
-c "exit" -exit > "$WORK/dosbox.log" 2>&1
if [ ! -f "$WORK/PLAYER.OUT" ]; then
echo "build.sh: GSPLNK produced no PLAYER.OUT" >&2
sed 's/\r$//' "$WORK/PLAYER.MAP" 2>/dev/null | tail -20 >&2 || true
for f in MAIN.LST MOVIE.LST FILL.LST ENTRY.LST; do
sed 's/\r$//' "$WORK/$f" 2>/dev/null | grep -i 'error' \
| grep -vi 'no errors' >&2 || true
done
exit 1
fi
for f in MAIN.LST MOVIE.LST FILL.LST ENTRY.LST; do
if sed 's/\r$//' "$WORK/$f" 2>/dev/null | grep -i 'error' \
| grep -qvi 'no errors'; then
echo "build.sh: $f diagnostics:" >&2
sed 's/\r$//' "$WORK/$f" | grep -in 'error' \
| grep -vi 'no errors' >&2
exit 1
fi
done
cp "$WORK/PLAYER.OUT" "$OUT/player.coff"
sed 's/\r$//' "$WORK/PLAYER.MAP" > "$OUT/player.map"
sed 's/\r$//' "$WORK/MAIN.LST" > "$OUT/main.lst" 2>/dev/null || true
sed 's/\r$//' "$WORK/MOVIE.LST" > "$OUT/movie.lst" 2>/dev/null || true
sed 's/\r$//' "$WORK/FILL.LST" > "$OUT/fill.lst" 2>/dev/null || true
python3 "$ROOT/harness/hello-world/cofextract.py" \
"$WORK/PLAYER.OUT" "$OUT/$NAME.bin" > "$OUT/cofextract.txt"
cp "$WORK/PLAYER.OUT" "$OUT/$NAME.coff"
ENTRY_PTR=$(sed 's/\r$//' "$WORK/PLAYER.MAP" \
| awk '/ENTRY POINT SYMBOL/ { print $NF; exit }')
MARK_PTR=$(sed 's/\r$//' "$WORK/PLAYER.MAP" \
| awk '$2 == "_marker_page" { print $1; exit }')
if [ -z "$ENTRY_PTR" ] || [ -z "$MARK_PTR" ]; then
echo "build.sh: no entry point or _marker_page in PLAYER.map" >&2
exit 1
fi
printf 'ENTRY_PTR=0x%s\nMARK_PTR=0x%s\n' "$ENTRY_PTR" "$MARK_PTR" \
> "$OUT/$NAME.env"
echo "build.sh: $OUT/$NAME.bin ($(wc -c < "$OUT/$NAME.bin") bytes)"
grep -E "ENTRY POINT|^\.(text|cinit|bss)" "$OUT/player.map" | head -8
echo "build.sh: entry bit 0x$ENTRY_PTR"
+79
View File
@@ -0,0 +1,79 @@
; entry.asm -- payload entry and ROM socket/allocator thunks.
;
; The payload is entered by a debugger/rdebug jump and never returns. Set the
; C stack, call main, and spin if main ever returns.
;
; The firmware's wrappers end in RETS 2 (they pop the return address and two
; argument words, and restore A14 from a caller-pushed slot). A plain calla
; would leave SP off by one word; these thunks save/restore SP and A14 exactly
; as harness/udpstream/entry.asm does.
.text
.globl _start
.globl _main
_start:
setf 32, 0, 1
movi _cstack, a14
move a14, a13
calla _main
spin:
jruc spin
.globl _socket_
_socket_:
move sp, @_sys_sp, 1
move a14, @_sys_a14, 1
calla 3F5C9A00h
move @_sys_sp, sp, 1
move @_sys_a14, a14, 1
rets 2
.globl _bind_
_bind_:
move sp, @_sys_sp, 1
move a14, @_sys_a14, 1
calla 3F5CAA80h
move @_sys_sp, sp, 1
move @_sys_a14, a14, 1
rets 2
.globl _recvfrom_
_recvfrom_:
move sp, @_sys_sp, 1
move a14, @_sys_a14, 1
calla 3F5D7D50h
move @_sys_sp, sp, 1
move @_sys_a14, a14, 1
rets 2
.globl _close_
_close_:
move sp, @_sys_sp, 1
move a14, @_sys_a14, 1
calla 3F5E8930h
move @_sys_sp, sp, 1
move @_sys_a14, a14, 1
rets 2
.globl _malloc_
_malloc_:
move sp, @_sys_sp, 1
move a14, @_sys_a14, 1
calla 3F701230h
move @_sys_sp, sp, 1
move @_sys_a14, a14, 1
rets 2
.globl _delay_
_delay_:
move sp, @_sys_sp, 1
move a14, @_sys_a14, 1
calla 3F833A20h
move @_sys_sp, sp, 1
move @_sys_a14, a14, 1
rets 2
.bss _cstack, 32768, 16 ; 32768 bits = 4096 bytes
.bss _sys_sp, 32, 16
.bss _sys_a14, 32, 16
.end
+61
View File
@@ -0,0 +1,61 @@
; fill.asm -- one FILL L rectangle into the 1280x1024, 256-byte-stride VRAM.
;
; The C wrapper publishes:
; fill_daddr linear bit address of the top-left pixel
; fill_dydx (rows << 16) | columns, in screen pixels
; fill_color 0x0000 = black, 0xFFFF = white
;
; FILL L destroys DADDR and DYDX, so the wrapper reloads them every call.
; PSIZE, PMASK and CONTROL are programmed once by _fill_init_.
.ref _fill_daddr
.ref _fill_dydx
.ref _fill_color
.text
.globl _fill_run_
_fill_run_:
setf 32, 0, 1
move b2, -*sp, 1
move b3, -*sp, 1
move b7, -*sp, 1
move b9, -*sp, 1
move @_fill_daddr, b2, 1
movi 800h, b3 ; 2048 bits = 256-byte row pitch
move @_fill_dydx, b7, 1
move @_fill_color, b9, 1
; PSIZE is cheap to re-assert; if the resident OS left it at 2 bpp,
; FILL would write twice as many pixels as asked for.
movi 1, a1
move a1, @0C0000150h, 0
fill l
move *sp+, b9, 1
move *sp+, b7, 1
move *sp+, b3, 1
move *sp+, b2, 1
rets
.globl _vid_dint_
_vid_dint_:
dint
rets
.globl _fill_init_
_fill_init_:
setf 32, 0, 1
movi 1, a1
move a1, @0C0000150h, 0 ; PSIZE = 1
clr a1
move a1, @0C0000160h, 0 ; PMASK = 0
move a1, @0C0000170h, 0 ; upper half, forward compatibility
move @0C00000B0h, a1, 0 ; CONTROL
andi 0801Fh, a1 ; keep CD and RM/RR, raster op 0
move a1, @0C00000B0h, 0
move @0C00000B0h, a1, 0 ; read back: I/O writes are pipelined
rets
.end
+279
View File
@@ -0,0 +1,279 @@
/* main.c -- Bad Apple payload: network receive, then tile playback. */
#include "movie.h"
#include "romapi.h"
#include "modecfg.h"
#ifndef NET_DEFAULT
#define NET_DEFAULT 0
#endif
#define NET_LEN 2401942
#define NET_PORT 7001
#define MOVIE_BASE 0x00080000
#define MOVIE_MAX 0x00308000
#define VRAM_BASE 0x04000000
#define ROW_BITS 2048
/* The marker/control block lives in .bss, not at bit 0x01810000: a 2.4 MB
* heap allocation can cross that address and would corrupt the movie. */
unsigned int marker_page[16];
#define MARK ((unsigned int)marker_page)
#define CTRL_START (MARK + 0x20 * 8)
#define CTRL_COUNT (MARK + 0x24 * 8)
#define CTRL_PACE (MARK + 0x28 * 8)
#define CTRL_MOVIE (MARK + 0x2C * 8)
#define CTRL_MOVIELEN (MARK + 0x30 * 8)
#define CTRL_WAIT (MARK + 0x34 * 8)
#define CTRL_READY (MARK + 0x38 * 8)
#define CTRL_NET (MARK + 0x3C * 8)
#define CTRL_MAGIC (MARK + 0x1C * 8)
#define MAGIC_VALUE 0x42414450
extern void fill_run_();
extern void fill_init_();
extern void vid_dint_();
unsigned int fill_daddr;
unsigned int fill_dydx;
unsigned int fill_color;
void fill_rect(x, y, w, h, color)
int x;
int y;
int w;
int h;
int color;
{
if (w <= 0 || h <= 0) {
return;
}
fill_daddr = VRAM_BASE + (unsigned int)y * ROW_BITS + (unsigned int)x;
fill_dydx = ((unsigned int)h << 16) | (unsigned int)w;
fill_color = color ? 0x0000 : 0xFFFF;
fill_run_();
}
static void paint(x, y, w, h, color, ctx)
int x;
int y;
int w;
int h;
int color;
void *ctx;
{
fill_rect(x, y, w, h, color);
}
static void show_fault(code)
unsigned int code;
{
fill_init_();
fill_rect(0, 0, 1280, 64, 1);
*(unsigned int *)MARK = code;
for (;;) {
}
}
static unsigned char rxbuf[1600];
static unsigned char sa[16];
static unsigned char from[16];
static int fromlen;
static unsigned int rx_error;
/* Receive movie.bin over the OS UDP stack into a malloc'd bit buffer.
* Packet: u32 offset, u16 length, u16 seq, u16 crc16, then bytes.
* A final offset==expected/length==4 packet carries the 32-bit file checksum. */
static unsigned int receive_movie(expected)
unsigned int expected;
{
unsigned int ptr;
unsigned int offset;
unsigned int length;
unsigned int sent_sum;
unsigned int i;
unsigned char *dst;
int fd;
int n;
ptr = malloc_(expected);
if (ptr == 0) {
rx_error = 3;
return 0;
}
fd = socket_(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (fd < 0) {
rx_error = 4;
return 0;
}
sa_any(sa, NET_PORT);
if (bind_(fd, sa, SOCKADDR_LEN) < 0) {
close_(fd);
rx_error = 5;
return 0;
}
sent_sum = 0;
dst = (unsigned char *)ptr;
for (;;) {
fromlen = SOCKADDR_LEN;
n = recvfrom_(fd, rxbuf, (int)sizeof(rxbuf), 0, from, &fromlen);
if (n < 10) {
continue;
}
offset = (unsigned int)rxbuf[0]
| ((unsigned int)rxbuf[1] << 8)
| ((unsigned int)rxbuf[2] << 16)
| ((unsigned int)rxbuf[3] << 24);
length = (unsigned int)rxbuf[4] | ((unsigned int)rxbuf[5] << 8);
if (offset == expected && length == 4 && n >= 14) {
sent_sum = (unsigned int)rxbuf[10]
| ((unsigned int)rxbuf[11] << 8)
| ((unsigned int)rxbuf[12] << 16)
| ((unsigned int)rxbuf[13] << 24);
if (movie_checksum(dst, expected) == sent_sum) {
break;
}
sent_sum = 0;
continue;
}
if (length > (unsigned int)(n - 10)) {
continue;
}
if (offset > expected || length > expected - offset) {
continue;
}
for (i = 0; i < length; i++) {
dst[offset + i] = rxbuf[10 + i];
}
}
close_(fd);
if (sent_sum == 0) {
rx_error = 6;
return 0;
}
return ptr;
}
/* Poll the display interrupt pending bit. Interrupts are disabled; this is
* a status bit, not an ISR. A missing DPYINT only costs a bounded spin. */
static void wait_vsync()
{
unsigned int i;
unsigned short pending;
*(unsigned short *)0xC0000120 = 0;
for (i = 0; i < 200000; i++) {
pending = *(unsigned short *)0xC0000120;
if (pending & 0x0400) {
return;
}
}
}
int main()
{
struct movie_header header;
unsigned int magic;
unsigned int frame;
unsigned int last;
unsigned int start;
unsigned int count;
unsigned int pace;
unsigned int net;
unsigned int wait;
unsigned int movie_base;
unsigned int movie_len;
unsigned int ticks;
unsigned int now;
unsigned int next;
int rc;
magic = *(unsigned int *)CTRL_MAGIC;
if (magic == MAGIC_VALUE) {
start = *(unsigned int *)CTRL_START;
count = *(unsigned int *)CTRL_COUNT;
pace = *(unsigned int *)CTRL_PACE;
net = *(unsigned int *)CTRL_NET;
wait = *(unsigned int *)CTRL_WAIT;
movie_base = *(unsigned int *)CTRL_MOVIE;
movie_len = *(unsigned int *)CTRL_MOVIELEN;
} else {
start = 0;
count = 0;
pace = 1;
net = NET_DEFAULT;
wait = 0;
movie_base = MOVIE_BASE;
movie_len = MOVIE_MAX;
}
if (net == 0 && NET_DEFAULT) {
net = 1;
}
if (net != 0) {
movie_len = NET_LEN;
*(unsigned int *)(MARK + 0x10 * 8) = 0x20;
movie_base = receive_movie(movie_len);
if (movie_base == 0) {
show_fault(0xBAD00000 | rx_error);
}
vid_dint_();
} else {
vid_dint_();
if (wait != 0) {
*(unsigned int *)(MARK + 0x10 * 8) = 0x10;
while (*(unsigned int *)CTRL_READY == 0) {
}
}
}
fill_init_();
*(unsigned short *)0xC00000A0 = 1;
fill_rect(0, 0, 1280, 1024, 0);
rc = movie_open((unsigned char *)movie_base, movie_len, &header);
if (rc != 0) {
show_fault(0xBAD00001);
}
last = header.frame_count;
if (last != 0) {
last = last - 1;
}
if (count != 0 && start + count - 1 < last) {
last = start + count - 1;
}
now = 0;
next = 0;
frame = 0;
while (frame <= last) {
if (pace != 0 && frame >= start) {
wait_vsync();
now += 5; /* 60/72 in 1/360 s units */
if (now < next) {
continue;
}
}
rc = movie_decode_frame((unsigned char *)movie_base, movie_len, frame,
&header, paint, 0);
if (rc != 0) {
show_fault(0xBAD00002);
}
ticks = movie_frame_ticks((unsigned char *)movie_base, movie_len,
frame, &header);
if (ticks < 1) {
ticks = 1;
}
next += 6 * ticks;
if (next + 360 < now) {
next = now;
}
*(unsigned int *)MARK = frame;
frame++;
}
*(unsigned int *)(MARK + 0x10 * 8) = (count != 0) ? 2 : 1;
for (;;) {
}
}
+194
View File
@@ -0,0 +1,194 @@
/* movie.c -- pure decoder for the BAPL tile-delta movie format.
*
* K&R C: the 1987 TMS34010 compiler has no const, no ANSI prototypes and no
* stdint.h. The host test compiles this same file with gcc -std=c99.
*/
#include "movie.h"
#define HEADER_SIZE 36
#define INDEX_ENTRY_SIZE 6
static unsigned int rd16(p)
unsigned char *p;
{
return (unsigned int)p[0] | ((unsigned int)p[1] << 8);
}
static unsigned long rd32(p)
unsigned char *p;
{
return (unsigned long)p[0] | ((unsigned long)p[1] << 8)
| ((unsigned long)p[2] << 16) | ((unsigned long)p[3] << 24);
}
int movie_open(data, len, header)
unsigned char *data;
unsigned long len;
struct movie_header *header;
{
unsigned int version;
if (data == 0 || header == 0 || len < HEADER_SIZE) {
return -1;
}
if (data[0] != 'B' || data[1] != 'A' || data[2] != 'P' || data[3] != 'L') {
return -1;
}
version = rd16(data + 4);
if (version != 1) {
return -1;
}
header->width = rd16(data + 6);
header->height = rd16(data + 8);
header->scale = rd16(data + 10);
header->tile = rd16(data + 12);
header->tiles_x = rd16(data + 14);
header->tiles_y = rd16(data + 16);
header->frame_count = (unsigned int)rd32(data + 18);
header->index_offset = (unsigned int)rd32(data + 22);
header->data_offset = (unsigned int)rd32(data + 26);
header->source_fps = rd16(data + 30);
header->display_hz = rd16(data + 32);
if (header->scale == 0 || header->scale > 4 || header->tile == 0
|| header->tile > 32 || header->tiles_x == 0
|| header->tiles_y == 0) {
return -1;
}
if (header->width != header->tiles_x * header->tile
|| header->height != header->tiles_y * header->tile
|| header->width * header->scale > 1280
|| header->height * header->scale > 1024) {
return -1;
}
if ((unsigned long)header->index_offset
+ (unsigned long)header->frame_count * INDEX_ENTRY_SIZE > len) {
return -1;
}
return 0;
}
unsigned int movie_frame_ticks(data, len, frame, header)
unsigned char *data;
unsigned long len;
unsigned int frame;
struct movie_header *header;
{
unsigned long index_off;
if (data == 0 || header == 0 || frame >= header->frame_count) {
return 1;
}
index_off = (unsigned long)header->index_offset
+ (unsigned long)frame * INDEX_ENTRY_SIZE;
if (index_off + INDEX_ENTRY_SIZE > len) {
return 1;
}
return (unsigned int)data[index_off + 4];
}
int movie_decode_frame(data, len, frame, header, fill, ctx)
unsigned char *data;
unsigned long len;
unsigned int frame;
struct movie_header *header;
movie_fill_fn fill;
void *ctx;
{
unsigned long index_off;
unsigned long off;
unsigned int count;
unsigned int i;
unsigned int j;
unsigned int tile_index;
unsigned int packed;
unsigned int bg;
unsigned int rects;
unsigned int tx;
unsigned int ty;
unsigned int value;
unsigned int x0;
unsigned int y0;
unsigned int w;
unsigned int h;
int sx;
int sy;
if (data == 0 || header == 0 || fill == 0) {
return -1;
}
if (frame >= header->frame_count) {
return -1;
}
index_off = (unsigned long)header->index_offset
+ (unsigned long)frame * INDEX_ENTRY_SIZE;
if (index_off + INDEX_ENTRY_SIZE > len) {
return -1;
}
off = rd32(data + index_off);
if (off + 2 > len) {
return -1;
}
count = rd16(data + off);
off += 2;
for (i = 0; i < count; i++) {
if (off + 4 > len) {
return -1;
}
tile_index = rd16(data + off);
packed = rd16(data + off + 2);
off += 4;
if (tile_index >= header->tiles_x * header->tiles_y) {
return -1;
}
bg = packed >> 15;
rects = packed & 0x7FFF;
if (off + (unsigned long)rects * 3 > len) {
return -1;
}
tx = tile_index % header->tiles_x;
ty = tile_index / header->tiles_x;
/* The whole tile first: the old pixels outside every new rect must
* go away. The C test skips this first call per tile. */
sx = (int)(tx * header->tile * header->scale);
sy = (int)(ty * header->tile * header->scale);
fill(sx, sy, (int)(header->tile * header->scale),
(int)(header->tile * header->scale), (int)bg, ctx);
for (j = 0; j < rects; j++) {
value = (unsigned int)data[off]
| ((unsigned int)data[off + 1] << 8)
| ((unsigned int)data[off + 2] << 16);
off += 3;
x0 = value & 0x1F;
y0 = (value >> 5) & 0x1F;
w = ((value >> 10) & 0x1F) + 1;
h = ((value >> 15) & 0x1F) + 1;
if (x0 + w > header->tile || y0 + h > header->tile) {
return -1;
}
sx = (int)((tx * header->tile + x0) * header->scale);
sy = (int)((ty * header->tile + y0) * header->scale);
fill(sx, sy, (int)(w * header->scale),
(int)(h * header->scale), (int)(1 - bg), ctx);
}
}
return 0;
}
unsigned int movie_checksum(data, len)
unsigned char *data;
unsigned long len;
{
unsigned long sum = 0;
unsigned long i;
if (data == 0) {
return 0;
}
for (i = 0; i < len; i++) {
sum = (sum + data[i]) & 0xFFFFFFFF;
}
return (unsigned int)sum;
}
+26
View File
@@ -0,0 +1,26 @@
#ifndef BADAPPLE_MOVIE_H
#define BADAPPLE_MOVIE_H
/* K&R declarations: the 1987 TI compiler has no ANSI prototypes. */
struct movie_header {
unsigned int width;
unsigned int height;
unsigned int scale;
unsigned int tile;
unsigned int tiles_x;
unsigned int tiles_y;
unsigned int frame_count;
unsigned int index_offset;
unsigned int data_offset;
unsigned int source_fps;
unsigned int display_hz;
};
typedef void (*movie_fill_fn)();
int movie_open();
int movie_decode_frame();
unsigned int movie_frame_ticks();
unsigned int movie_checksum();
#endif
+93
View File
@@ -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 */
Binary file not shown.
@@ -0,0 +1,85 @@
#include <stdio.h>
#include <stdlib.h>
#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;
}
+2
View File
@@ -0,0 +1,2 @@
[pytest]
testpaths = tests
+33
View File
@@ -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())
+21
View File
@@ -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
+23
View File
@@ -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
+26
View File
@@ -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
+31
View File
@@ -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
+63
View File
@@ -0,0 +1,63 @@
import numpy as np
from pipeline.movie import (
Rect,
TileRecord,
FrameRecord,
MovieHeader,
MovieFormatError,
encode_frame,
decode_frame,
write_movie,
read_movie,
)
def test_rect_full_tile_bias_roundtrip():
rec = FrameRecord(1, 0, [TileRecord(0, 0, [Rect(0, 0, 32, 32)])])
back, off = decode_frame(encode_frame(rec), 0)
assert back.tiles[0].rects[0] == Rect(0, 0, 32, 32)
assert off == len(encode_frame(rec))
def test_more_than_127_rects_roundtrip():
rects = [Rect(i % 32, i // 32, 1, 1) for i in range(200)]
rec = FrameRecord(1, 0, [TileRecord(3, 0, rects)])
back, _ = decode_frame(encode_frame(rec), 0)
assert len(back.tiles[0].rects) == 200
def test_rect_outside_tile_raises():
rec = FrameRecord(1, 0, [TileRecord(0, 0, [Rect(31, 0, 2, 1)])])
try:
encode_frame(rec)
except MovieFormatError:
return
assert False, "rect crossing a tile boundary was accepted"
def test_truncated_record_raises():
data = encode_frame(
FrameRecord(1, 0, [TileRecord(0, 0, [Rect(0, 0, 4, 4)])]))
try:
decode_frame(data[:-1], 0)
except MovieFormatError:
return
assert False, "truncated record was accepted"
def test_write_movie_refuses_over_cap(tmp_path):
frames = [FrameRecord(1, 0, []) for _ in range(10)]
header = MovieHeader(640, 512, 2, 32, 20, 16, 10, 60, 72)
import pipeline.movie as m
old = m.MAX_DATA_BYTES
m.MAX_DATA_BYTES = 8
try:
try:
write_movie(str(tmp_path / "x.bin"), frames, header)
except MovieFormatError:
return
assert False, "cap not enforced"
finally:
m.MAX_DATA_BYTES = old
+40
View File
@@ -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)
+41
View File
@@ -0,0 +1,41 @@
import numpy as np
from pipeline.vectorize import rdp, rasterize, trace, vectorize_frame
def test_rdp_drops_collinear_point():
pts = [(0, 0), (1, 0), (2, 0)]
assert rdp(pts, 0.5) == [(0, 0), (2, 0)]
def test_rasterize_square():
poly = [[(1, 1), (4, 1), (4, 4), (1, 4), (1, 1)]]
out = rasterize([poly], 6, 6)
assert out[2, 2]
assert not out[0, 0]
def test_rasterize_square_with_hole():
outer = [(1, 1), (5, 1), (5, 5), (1, 5), (1, 1)]
hole = [(2, 2), (2, 4), (4, 4), (4, 2), (2, 2)]
out = rasterize([[outer, hole]], 7, 7)
assert out[3, 1]
assert not out[3, 3]
def test_trace_preserves_vertical_position():
mask = np.zeros((32, 32), dtype=bool)
mask[2:6, 4:12] = True
out = vectorize_frame(mask, {"rdp_epsilon": 1.0, "min_segment": 2})
assert out[3, 5]
assert not out[28, 5]
def test_trace_simple_shape_and_roundtrip():
mask = np.zeros((16, 16), dtype=bool)
mask[4:12, 4:12] = True
polys = trace(mask, {"rdp_epsilon": 1.0, "min_segment": 2})
assert len(polys) >= 1
roundtrip = rasterize(polys, 16, 16)
assert roundtrip[6, 6]
assert not roundtrip[0, 0]
+73
View File
@@ -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())
+55
View File
@@ -0,0 +1,55 @@
#!/bin/sh
# Deliver the network-build payload over rdebug, then send movie.bin over UDP.
#
# tools/load.sh --host 192.168.99.2 --movie data/movie.bin
#
# The terminal must be booted and answering ping. The payload calls the OS
# socket API, receives into a malloc'd buffer, then disables interrupts and
# plays. Recovery after that point is a power cycle.
set -eu
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/../.." && pwd)
HOST=192.168.99.2
PORT=7001
MOVIE=$ROOT/bad-apple/data/movie.bin
WAIT=180
SETTLE=3
DELAY=0.002
while [ $# -gt 0 ]; do
case "$1" in
--host) HOST=$2; shift 2 ;;
--port) PORT=$2; shift 2 ;;
--movie) MOVIE=$2; shift 2 ;;
--wait) WAIT=$2; shift 2 ;;
--settle) SETTLE=$2; shift 2 ;;
--delay) DELAY=$2; shift 2 ;;
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
*) echo "load.sh: unknown option $1" >&2; exit 2 ;;
esac
done
if [ ! -f "$MOVIE" ]; then
echo "load.sh: no movie at $MOVIE" >&2
exit 2
fi
start=$(date +%s)
while ! ping -c1 -W1 "$HOST" >/dev/null 2>&1; do
if [ $(( $(date +%s) - start )) -ge "$WAIT" ]; then
echo "load.sh: $HOST did not answer within ${WAIT}s" >&2
exit 1
fi
done
echo "load.sh: $HOST answers"
python3 "$ROOT/harness/badapple/rdebug_load.py" \
--host "$HOST" \
--image "$ROOT/bad-apple/player/build/player-net.bin" \
--env "$ROOT/bad-apple/player/build/player-net.env"
sleep "$SETTLE"
python3 "$HERE/send_movie.py" --host "$HOST" --port "$PORT" --movie "$MOVIE" \
--delay "$DELAY"
echo "load.sh: sent; the screen is the evidence. Power-cycle to recover."
+212
View File
@@ -0,0 +1,212 @@
-- load_mame.lua -- debugger entry for the Bad Apple payload.
--
-- Loads the payload at bit 0x018C0000 and the movie at bit 0x00080000, then
-- enters the payload at the OS idle loop. This is the development path; the
-- real delivery path is tools/load.sh (rdebug + LANCE).
--
-- Env:
-- BA_MODE=one|play (one is the only mode implemented here)
-- BA_MOVIE movie file (default bad-apple/data/test_movie.bin)
-- BA_PAYLOAD raw payload (default bad-apple/player/build/player.bin)
-- BA_ENV player.env with ENTRY_PTR (default .../player.env)
-- BA_AT load frame (default 2500)
-- BA_END inspection/exit frame (default 2600)
local function hex(v, w)
return string.format("%0" .. (w or 8) .. "X", v)
end
local machine = manager.machine
local cpu = machine.devices[':maincpu']
local dbg = machine.debugger
local prog = cpu.spaces['program']
if not dbg then
print("BA ERROR: debugger not enabled; pass -debug")
machine:exit()
return
end
local ORIGIN = 0x018C0000
local MOVIE = tonumber(os.getenv("BA_MOVIE_BASE") or "0x01D00000")
local MARK = 0x01810000
local ENTRY_PC = 0x3F579970
local AT = tonumber(os.getenv("BA_AT") or "2500")
local STOP_AT = tonumber(os.getenv("BA_END") or "2600")
local START = tonumber(os.getenv("BA_START") or "0")
local COUNT = tonumber(os.getenv("BA_COUNT") or "0")
local PACE = tonumber(os.getenv("BA_PACE") or "0")
local WAIT = tonumber(os.getenv("BA_WAIT") or "0")
local MOVIE_PATH = os.getenv("BA_MOVIE") or "bad-apple/data/test_movie.bin"
local PAYLOAD_PATH = os.getenv("BA_PAYLOAD") or "bad-apple/player/build/player.bin"
local ENV_PATH = os.getenv("BA_ENV") or "bad-apple/player/build/player.env"
local function read_all(path)
local f, err = io.open(path, "rb")
if not f then
print("BA ERROR: cannot open " .. path .. ": " .. tostring(err))
machine:exit()
return nil
end
local data = f:read("*a")
f:close()
return data
end
local function entry_from_env()
local explicit = os.getenv("BA_ENTRY")
if explicit then
return tonumber(explicit)
end
local f = io.open(ENV_PATH, "r")
if not f then
return ORIGIN
end
for line in f:lines() do
local value = line:match("ENTRY_PTR=0x(%x+)")
if value then
f:close()
return tonumber(value, 16)
end
end
f:close()
return ORIGIN
end
local payload = read_all(PAYLOAD_PATH)
local movie = read_all(MOVIE_PATH)
if not payload or not movie then
return
end
local entry = entry_from_env()
do
local f = io.open(ENV_PATH, "r")
if f then
for line in f:lines() do
local value = line:match("MARK_PTR=0x(%x+)")
if value then
MARK = tonumber(value, 16)
end
end
f:close()
end
end
print(string.format("BA: payload %d bytes, movie %d bytes, entry %s mark %s",
#payload, #movie, hex(entry), hex(MARK)))
local function write_bytes(data, base)
local i
local n = #data
i = 0
while i + 4 <= n do
local b0 = data:byte(i + 1)
local b1 = data:byte(i + 2)
local b2 = data:byte(i + 3)
local b3 = data:byte(i + 4)
local word = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)
prog:write_u32(base + i * 8, word)
i = i + 4
end
while i < n do
prog:write_u8(base + i * 8, data:byte(i + 1))
i = i + 1
end
end
local function verify(data, base)
local i
for i = 0, #data - 1 do
if prog:read_u8(base + i * 8) ~= data:byte(i + 1) then
return false, i
end
end
return true, 0
end
local loaded = false
local entered = false
local done = false
local movie_sent = false
local frames = 0
local function load_and_enter()
write_bytes(payload, ORIGIN)
local ok, bad = verify(payload, ORIGIN)
if not ok then
print("BA ERROR: payload readback mismatch at byte " .. bad)
machine:exit()
return
end
prog:write_u32(MARK + 0x1C * 8, 0x42414450)
prog:write_u32(MARK + 0x20 * 8, START)
prog:write_u32(MARK + 0x24 * 8, COUNT)
prog:write_u32(MARK + 0x28 * 8, PACE)
prog:write_u32(MARK + 0x2C * 8, MOVIE)
prog:write_u32(MARK + 0x30 * 8, #movie)
prog:write_u32(MARK + 0x34 * 8, WAIT)
prog:write_u32(MARK + 0x38 * 8, 0)
prog:write_u32(MARK + 0x3C * 8, 0) -- debugger path, never network
dbg:command("bpset " .. hex(ENTRY_PC) .. ",,{ pc = " .. hex(entry) .. " ; g }")
if WAIT == 0 then
write_bytes(movie, MOVIE)
ok, bad = verify(movie, MOVIE)
if not ok then
print("BA ERROR: movie readback mismatch at byte " .. bad)
machine:exit()
return
end
end
print(string.format("BA: loaded; start=%d count=%d pace=%d wait=%d",
START, COUNT, PACE, WAIT))
end
emu.register_frame_done(function()
frames = frames + 1
if frames == AT and not loaded then
loaded = true
load_and_enter()
end
if loaded and WAIT == 1 and not movie_sent
and prog:read_u16(MARK + 0x10 * 8) == 0x10 then
movie_sent = true
print("BA: takeover seen; sending movie to low RAM")
write_bytes(movie, MOVIE)
local ok, bad = verify(movie, MOVIE)
if not ok then
print("BA ERROR: movie readback mismatch at byte " .. bad)
machine:exit()
return
end
prog:write_u32(MARK + 0x38 * 8, 1)
print("BA: movie ready")
end
if loaded and not entered then
local status = prog:read_u16(MARK + 0x10 * 8)
if status == 1 or status == 2 then
entered = true
done = true
print(string.format("BA: STATUS=%d at frame %d; FRAME=%d",
status, frames, prog:read_u16(MARK)))
end
end
if loaded and not done and frames > AT + 10 then
local err = prog:read_u32(MARK)
if err >= 0xBAD00000 then
print(string.format("BA ERROR: marker %s at frame %d",
hex(err), frames))
machine:exit()
end
end
if frames >= STOP_AT then
print(string.format("BA: end at frame %d (loaded=%s done=%s) status=%04X frame=%d magic=%08X",
frames, tostring(loaded), tostring(done),
prog:read_u16(MARK + 0x10 * 8), prog:read_u16(MARK),
prog:read_u32(MARK + 0x1C * 8)))
machine:exit()
end
end, "vt1200-badapple-loader")
dbg.visible_cpu = cpu
dbg.execution_state = 'run'
+36
View File
@@ -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())
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
# Run the payload in MAME with the debugger loader.
#
# tools/run_mame.sh one # one frame, no pacing
# tools/run_mame.sh play # full movie, paced
# BA_MOVIE=data5/movie.bin BA_START=180 BA_COUNT=1 tools/run_mame.sh play
set -eu
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/../.." && pwd)
M=${M:-/home/arti/code/k-space/mame0289/vt1200}
MODE=${1:-one}
BA_MOVIE=${BA_MOVIE:-$ROOT/bad-apple/data/test_movie.bin}
BA_PACE=${BA_PACE:-0}
BA_START=${BA_START:-0}
BA_COUNT=${BA_COUNT:-0}
BA_AT=${BA_AT:-2500}
BA_END=${BA_END:-3000}
BA_MOVIE_BASE=${BA_MOVIE_BASE:-0x01D00000}
BA_WAIT=${BA_WAIT:-0}
if [ "$MODE" = "play" ]; then
BA_PACE=${BA_PACE:-1}
fi
cd "$ROOT"
VT1200_SKIP_LEVEL1=1 \
BA_MOVIE="$BA_MOVIE" BA_PACE="$BA_PACE" BA_START="$BA_START" \
BA_COUNT="$BA_COUNT" BA_AT="$BA_AT" BA_END="$BA_END" \
BA_MOVIE_BASE="$BA_MOVIE_BASE" BA_WAIT="$BA_WAIT" \
"$M" vt1200 -rompath harness/mame/roms -video none -sound none -nothrottle \
-skip_gameinfo -debug -debugger none \
-autoboot_script "$ROOT/bad-apple/tools/load_mame.lua" -autoboot_delay 0
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Send movie.bin to the payload's UDP receiver (port 7001).
Packet: u32 offset, u16 length, u16 seq, u16 crc16, then length bytes.
A zero-length packet ends the load. This uses the VT1200 OS stack while it
is still alive; the payload disables interrupts only after the last byte.
"""
from __future__ import annotations
import argparse
import socket
import struct
import sys
import time
from pathlib import Path
def crc16(data: bytes) -> int:
return sum(data) & 0xFFFF
def checksum32(data: bytes) -> int:
return sum(data) & 0xFFFFFFFF
def pack_packet(offset: int, seq: int, body: bytes) -> bytes:
return struct.pack("<IHHH", offset, len(body), seq, crc16(body)) + body
def parse_ack(data: bytes) -> int:
if len(data) < 8 or data[:4] != b"BAK1":
raise ValueError("not a BAK1 ack")
return struct.unpack_from("<I", data, 4)[0]
def send_movie(host: str, port: int, path: str, chunk: int = 1450,
delay: float = 0.0005, passes: int = 3) -> int:
data = Path(path).read_bytes()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
for _ in range(passes):
offset = 0
seq = 0
while offset < len(data):
part = data[offset:offset + chunk]
sock.sendto(pack_packet(offset, seq, part), (host, port))
offset += len(part)
seq += 1
if delay:
time.sleep(delay)
sock.sendto(pack_packet(len(data), seq,
checksum32(data).to_bytes(4, "little")),
(host, port))
time.sleep(0.05)
finally:
sock.close()
print("sent %d bytes in %d pass(es)" % (len(data), passes))
return 0
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default="192.168.99.2")
parser.add_argument("--port", type=int, default=7001)
parser.add_argument("--movie", required=True)
parser.add_argument("--chunk", type=int, default=1450)
parser.add_argument("--delay", type=float, default=0.0005)
parser.add_argument("--passes", type=int, default=3)
args = parser.parse_args(argv)
return send_movie(args.host, args.port, args.movie, args.chunk,
args.delay, args.passes)
if __name__ == "__main__":
raise SystemExit(main())
+11
View File
@@ -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
+13
View File
@@ -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