mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-11-01 12:14:56 +02:00
merge: Bad Apple 30s vector prototype
This commit is contained in:
commit
371fe27bc2
40 files changed
+2762
No files matched your search
@@ -0,0 +1,8 @@
|
||||
data/*
|
||||
!data/manifest.json
|
||||
data5/
|
||||
preview/
|
||||
player/build/
|
||||
player/work/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
@@ -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
|
||||
}
|
||||
Whitespace-only changes.
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Executable
+132
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 (;;) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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())
|
||||
Executable
+55
@@ -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."
|
||||
@@ -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'
|
||||
Executable
+36
@@ -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())
|
||||
Executable
+34
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user