forked from opencode.energia.arti.ee/vt1200
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