diff --git a/harness/mame/NETWORK_REPORT.md b/harness/mame/NETWORK_REPORT.md index 230b9c1..35b4049 100644 --- a/harness/mame/NETWORK_REPORT.md +++ b/harness/mame/NETWORK_REPORT.md @@ -5086,3 +5086,297 @@ So "stream Bad Apple into VRAM" is real, but the resolution/fps trade is set by the wire and the blitter, not by the CPU: full screen is a ~5-8 fps proposition, half resolution ~20 fps. `cmd 10` (`PIXBLT L,XY` from a RAM buffer) and `cmd 11` are the benchmarks behind this table. + +## 76. The tile stream, end to end: 1.3 KB keyframes, 67 B deltas, 30/30 frames + +Scheme C (the 256-entry 32x32 dictionary, full-frame indices and changed-tile +deltas) is Tasks 1–5 of the tile-codec plan. This section is Task 6: the clip → +dictionary → cached stream → replay path, and the numbers it produces. It +records what was run, on what, and — in the same breath — what was **not** run. + +### 76.1 What was run, and what was not + +Three things were run: + +1. the host tests for the pipeline (`harness/udpstream/tests/`, 137 passing); +2. `make_tiles.py` over a deterministic 30-frame clip, producing the committed + cache `harness/udpstream/tiles/` (dictionary + index/delta stream); +3. the **MAME replay**: the same cache played through the payload's own RX ring + (`VT1200_MODE=replay`), with the screen dumped after **every** clip frame and + checked by `tile_check.py --replay`, which decodes `stream.bin` itself and + renders each frame from the dictionary. + +Two things were **not** run, and neither can be run on this machine: + +* **No network run.** The MAME driver creates no `device_network_interface` + (ruling R11), so no packet can reach the emulated LANCE and `vidsend.py` + cannot be pointed at MAME. The sender is exercised through an injected + transport and a `--dry-run` counter, not a socket. +* **No unit run.** No physical terminal is attached to this host, so plan Task + 6 step 5 (`sudo ./test-stream.sh --tiles` for 300 frames, then + the 6,300-frame soak) was not executed. §76.8 gives the exact commands and + what to record. `test-stream.sh` now takes `--tiles`. + +### 76.2 The clip is synthetic, and the report says so + +There is no Bad Apple source on this machine (`ffmpeg` is not even installed), +so `make_tiles.py` with no `--clip` generates one: +`make_tiles.synthetic_clip(30)` — 30 full-screen 1280x1024 1-bpp frames built +from a static 8-phase hatched background, a black ground band and a silhouette +sweeping 7 px right per frame, with cut-outs and an "eye". Integer arithmetic +only, no randomness, so the clip and its cache are byte-identical on any host. + +It is built to stress the codec, not to look like the video: the background is +static (so a delta carries the *motion* rather than the wallpaper), the +silhouette's edges cross tile boundaries (so the dictionary must represent +partly covered tiles), and the clip holds **564 distinct tiles against 256 +dictionary entries** (so the error bound is real). The numbers below are +properties of this pipeline; a real Bad Apple clip will move them, in both +directions. The real-clip route is §76.7. + +### 76.3 The pipeline + +``` +clip (frames) make_tiles.py cache + synthetic, or a directory -> build_dictionary (Task 1) -> dictionary.bin 32,768 B + of raw 1-bpp *.bin frames quantise every frame indices.bin 1,280 B/frame + plan full/delta (Task 5) stream.bin the plan + manifest.json the numbers +``` + +`stream.bin` is the plan in wire order — `TS01`, a u16 frame count, then +`kind u8, frame_no u16, len u16, payload` per frame — with `kind 0` meaning +"nothing to send, the screen does not change". Both the sender and the replay +rebuild the 16-byte VS header from it with `frame.py` / the Lua's own +implementation, so there is one schedule and one wire format, not two. + +`read_cache()` refuses a cache whose stream does not decode to `indices.bin`, +so an inconsistent cache cannot reach MAME or the terminal. + +### 76.4 The numbers (30-frame synthetic clip, 1,280x1,024) + +Per-frame payload is 1,280 B for a `TILES_FULL` and `2 + 3 * changed_tiles` for +a `TILES_DELTA`. "Wire" adds the 14-byte Ethernet header and the 16-byte VS +header, and the 60-byte Ethernet minimum where a delta is that small. + +| frame | kind | payload B | changed tiles | wire B | bit errors | PSNR | +|---|---|---|---|---|---|---| +| 0 | full | 1,280 | — | 1,310 | 1,308 | 30.01 dB | +| 1 | delta | 38 | 12 | 68 | 1,351 | 29.87 dB | +| 2 | delta | 44 | 14 | 74 | 1,420 | 29.65 dB | +| 3 | delta | 41 | 13 | 71 | 1,366 | 29.82 dB | +| 4 | delta | 23 | 7 | 60 | 1,309 | 30.01 dB | +| … | delta | 23–47 | 7–15 | 60–77 | 1,260–1,809 | 28.60–30.17 dB | +| 29 | delta | 32 | 10 | 62 | 1,809 | 28.60 dB | + +| quantity | value | +|---|---| +| delta payload, frames 1–29 | mean **36.4 B**, min 23, max 47 | +| changed tiles per frame | mean **11.5**, min 7, max 15 | +| wire per frame, keyframe | **1,310 B** | +| wire per frame, delta | mean **66.9 B**, min 60, max 77 | +| clip payload total | 2,337 B | +| clip on the wire (frames only) | 3,250 B (108 B/frame) | +| dictionary upload | 32,768 B in **23 datagrams**, 33,504 B on the wire | +| clip + dictionary on the wire | 36,754 B (1,225 B/frame including the one-off upload) | + +So a delta is **35x smaller than a keyframe** (36 B of tiles vs 1,280 B), and +on the wire **20x** smaller (67 B vs 1,310 B) because the headers do not +compress. The 32 KB dictionary costs about 26 keyframes once (33,504 B), and a +6,300-frame soak amortises it to 5 B/frame. + +**Keyframe schedule.** `tiledict.KEYFRAME_EVERY = 60` (ruling R2, ~2 s at +30 fps): a full frame at 0, 60, 120, …, 6240 — **105 keyframes in a 6,300-frame +run**, and exactly one (frame 0) in the 30-frame clip. A status frame whose +cumulative `drops` has risen forces the *next* frame to be a keyframe instead +of waiting for the cadence; the sender threads `drops`/`seen_drops` into +`keyframe_due` before each decision, and a unit test pins that frame 7 is full +when a report says `drops=3` and frame 8 is a delta again. + +**Reconstruction error, and what "bound" means.** Quantising stores the nearest +dictionary entry under Hamming distance, so the per-tile distance *is* the +number of pixels the frame will differ by, and it is the minimum any encoder +using this dictionary could achieve (a test checks it against brute force). + +| quantity | value | +|---|---| +| distinct tiles in the clip | 564 (dictionary holds 256; all 256 used) | +| tiles in the clip | 38,400 (30 x 1,280) | +| tiles reproduced exactly | 37,230 (**97.0%**) | +| per-tile error | mean 1.16 bits, max **144 bits** (of 1,024) | +| frame error | 1,260–1,809 bits of 1,310,720 = 0.10–0.14% | +| clip bit-error rate | 44,443 / 39,321,600 = **0.001130** | +| PSNR (`-10 log10 BER`) | **29.47 dB** (per frame 28.60–30.17 dB) | + +A best-case dictionary would make this zero; that the clip is 564 tiles wide and +the index byte is 8 bits wide is exactly what 29.5 dB buys. + +### 76.5 The MAME replay: 30/30 frames + +``` +$ BENCH=0 VT1200_MODE=replay LOGDIR=/tmp/opencode/replay6 VID_SECONDS=600 \ + harness/mame/run-vidstream.sh +VID: replay /home/arti/.local/share/opencode/worktree/26e92e/video-streaming/harness/udpstream/tiles: 30 frame(s), 30 on the wire, dictionary 32768 B +VID: replay PASS: the 23 chunks made DICT_READY=1 and DICT_BYTES=32768 +VID: replay PASS: the payload holds dictionary.bin (first bad byte -1) +VID: replay PASS: frame 0 was accepted (RX_OK=1, expected 1) +... +VID: replay PASS: frame 29 was accepted (RX_OK=30, expected 30) +VID: replay PASS: every wire frame was accepted (RX_OK=30, expected 30) +VID: replay PASS: no tile frame was dropped (TILES_DROP=0) +VID: replay PASS: no frame was rejected (RX_BAD=0) +VID: replay PASS: the VRAM canary survives the replay (SENTINEL=A5C3) +VID: replay PASS: the OS did not repaint over the replay (OS_TOUCH=0) +VID: replay: 0 check(s) failed; 30/30 frames dumped, RX_OK=30 TILES_DROP=0 +VID: RESULT PASS +tile_check: replay /tmp/opencode/replay6: 30 frames, dictionary 32768 B +tile_check: replay: 30/30 frames match +tile_check: PASS +``` + +Each clip frame is delivered as the datagrams the sender would send (a keyframe +or delta is one datagram at 1,450 bytes), the harness waits for the ring to +consume it, and then dumps the 160 active bytes of every screen row to +`vram-replay-.bin` (1,024 x 160 = 163,840 B a frame). The checker +decodes the stream independently, renders each frame through the dictionary and +compares row by row: **30/30 frames match**, so 29 consecutive deltas are +accumulated correctly on the emulated terminal, not just the keyframe. + +With the source frames at hand, the same checker also measures the error of the +screens the terminal *actually painted* (``--clip``), not of a host render: + +``` +$ python3 harness/mame/tile_check.py --replay /tmp/opencode/replay6 \ + --clip /tmp/opencode/clip30 +tile_check: frame 0 reconstruction: 1308 bit(s) (BER 0.000998) +... +tile_check: replay: 30/30 frames match +tile_check: reconstruction: 44443 bit(s) over 30 frame(s), BER 0.001130, PSNR 29.47 dB +tile_check: PASS +``` + +44,443 bits — the same number the host's render-vs-source comparison gives, to +the bit. So the chain is closed on the emulated terminal: source → (dictionary +bound) → render → (exact) → painted screen. + +**Negative control.** Flip one bit in dictionary entry 17 of the evidence copy +and re-run the checker: + +``` +tile_check: FAIL: frame 3: screen row 417 byte 113 (1 differing of 160): + expected FF, got 00 +FAIL: frame 3: 1 of 1024 active screen rows do not match the render +FAIL: 29 of 30 frames match their render +tile_check: FAIL (2 checks) # exit status 1 +``` + +So the pixel verdict is not a rubber stamp; the host tests carry the rest of +the negative controls (one flipped dump bit, a stream that paints something +else, a missing dump, and each bad status counter). + +The other MAME modes still pass after the replay mode was added: `alloc`, `rx`, +`ring`, `dict`, `tiles` (1280/1280), `delta`, `stream` (4/4 status frames), and +the 200-frame boot baseline is unchanged (`samples=198`, 3 distinct PCs). + +### 76.6 The host sender in tile mode + +`vidsend.py --tiles [--tiles-dir DIR]` sends the cache: the 23 dictionary +chunks first, then one full or delta frame per clip frame, one CSV row per +frame: + +``` +frame,elapsed_s,fps,bytes,kind,payload_bytes,changed_tiles,keyframe +0,0.000,...,34814,full,1280,0,1 +1,0.034,...,34882,delta,38,12,0 +... +``` + +The keyframe decision is live, not a replay of the cache: before each frame the +sender reads any status frames, takes the terminal's **cumulative** `drops`, +and asks `keyframe_due(frame_no, drops, seen_drops)`. A rise forces a full +frame at the next opportunity, which is what Task 5's review carried forward; a +test with an injected status (`drops=1`) pins kinds `[full, delta, full]`. + +A clip shorter than `--frames` loops (frame numbers keep counting, so the +cadence spans the loop). A 6,300-frame `--dry-run` against a counting transport +— no socket, no terminal — sends: + +``` +RESULT DRY-RUN: 6300 frames (105 full, 6195 delta, 0 unchanged), + 23 dictionary datagrams, 6323 datagrams, 606384 bytes counted +``` + +that is **105 keyframes and 606 KB for 3.5 minutes**, 96 B/frame, which is the +host-side budget the soak needs to beat on the wire (FCS, preamble and IFG will +add ~7%). + +### 76.7 The real clip: the command, untested here + +The clip format is pinned by `make_tiles.load_clip`: a directory of `*.bin` +files, each one raw 1-bpp frame — whole 160-byte rows, whole 32-row tile bands, +top-aligned, MSB first, 1 = ink, at most 1,024 rows. A partial row or band is +rejected with the file's name, not silently reshaped. With ffmpeg (not +installed on this machine, so this route is documented and **not executed**): + +``` +ffmpeg -i bad-apple.mp4 -vf "scale=1280:1024:flags=area,format=monob" \ + -f rawvideo /tmp/badapple.raw +mkdir -p /tmp/badapple +split -b 163840 -d -a 4 /tmp/badapple.raw /tmp/badapple/frame- +for f in /tmp/badapple/frame-*; do mv "$f" "$f.bin"; done + +python3 harness/udpstream/make_tiles.py --clip /tmp/badapple \ + --out harness/udpstream/tiles-badapple +``` + +`monob` is MSB-first 1-bpp; if the picture comes out negative, invert it in the +filter chain (`...,format=monob,negate`). To keep a run's length independent of +the source, `vidsend.py --tiles` loops a short cache. + +The clip loader is not an untested path in the code sense: the 30-frame +synthetic clip written out as `frame-0000.bin … frame-0029.bin` and fed back +through `--clip` produces a cache **byte-identical** to the generated one +(`dictionary.bin`, `indices.bin` and `stream.bin` all `cmp` equal). Only the +frame source differs. + +### 76.8 The unit run (not executed): what the operator must do + +Prerequisite, unchanged from plan 1: the payload must already be running on the +terminal (build-stream.sh, then hostload.py — see `test-stream.sh`'s header; +the load is one shot because the payload takes the LANCE over). + +``` +# 1. the demo, 300 frames (10 loops of the 30-frame clip) +sudo ./harness/udpstream/test-stream.sh 08:00:2b:16:4b:5a --tiles + +# 2. the soak: 6,300 frames, ~3.5 min at 30 fps +FRAMES=6300 sudo ./harness/udpstream/test-stream.sh 08:00:2b:16:4b:5a --tiles +``` + +Record, from the script's interval table and the terminal's own debugger: + +* **fps / delivery**: the paired interval `delivery_pct` and the final + `sent / accepted / gap` line (the 63-frame reporting lag is the budget); +* **drops**: the status frame's `drops` and `crc_err` — the script exits + non-zero if either is reported; +* **`OS_TOUCH`** (marker bit `0x01810220`) and the `SENTINEL` word, read on the + terminal alongside the run: a non-zero `OS_TOUCH` means the OS repainted over + the tile screen, which the emulator cannot show (R12); +* **whether the OS repainted**: visible on the display, and in `OS_TOUCH`. + +If `OS_TOUCH` moves, the run is a failure of the *interference* question, not of +the codec; the cadence will still repair the indices within 60 frames, but the +screen is being fought over. + +### 76.9 What this cannot show + +* Whether the terminal *receives* the stream: R11 and the missing unit, stated + above. Every "accepted" here is the payload accepting bytes the harness wrote + into its RX ring, not a wire. +* Whether 30 fps is reachable on the real CPU. The replay's timing is MAME's + (the frames go in as fast as the ring frees), and the blit cost is plan 1's + measurement, not a new one. +* That the synthetic clip's 29.5 dB is representative of Bad Apple. It is the + best case for a *static* background; a real clip changes far more tiles per + frame (deltas nearer 1–2 KB) and, if its tile vocabulary is larger, has a + lower PSNR — the pipeline will report both when it is run. diff --git a/harness/mame/run-vidstream.sh b/harness/mame/run-vidstream.sh index 87f9bb1..60b116c 100755 --- a/harness/mame/run-vidstream.sh +++ b/harness/mame/run-vidstream.sh @@ -10,6 +10,7 @@ # VT1200_MODE=dict harness/mame/run-vidstream.sh # dictionary upload + kinds # VT1200_MODE=tiles harness/mame/run-vidstream.sh # the 32x32 tile blit # VT1200_MODE=delta harness/mame/run-vidstream.sh # deltas and keyframes +# VT1200_MODE=replay harness/mame/run-vidstream.sh # the make_tiles.py clip # STREAM_POOL_BYTES=0x7FFF000 harness/mame/run-vidstream.sh # force fallback # # Mode rx delivers one valid frame and one corrupted frame through the payload's @@ -68,6 +69,16 @@ # list and the value list straddle a fragment boundary; every tile is compared # with its new entry. Evidence: $LOGDIR/vram-delta-*.bin and delta-status.txt. # +# Mode replay is Task 6's end-to-end check: it plays the cache make_tiles.py +# wrote (dictionary.bin + stream.bin) through the payload's own RX ring, one +# VS frame per clip frame, and dumps the 160 active bytes of every screen row +# after each one. This script then runs tile_check.py --replay, which decodes +# stream.bin with its own code, renders every frame through the dictionary and +# compares it with the dump. The pixels are the checker's verdict; the Lua's is +# the transport record (every frame accepted, none dropped, dictionary intact). +# The clip is synthetic: no Bad Apple source exists on this machine, and the +# script says so rather than implying otherwise. +# # The run uses VT1200_SKIP_LEVEL1=1: the payload's heap is only initialised in # the OS phase. In the diagnostics menu the free counter (0x1E8BA0) and the # free-list head (0x1E8AC0) both read 0, so the malloc success path cannot be @@ -137,9 +148,26 @@ if [ "$MODE" = "delta" ]; then "$LOGDIR/vram-delta-large.bin" "$LOGDIR/dict-delta.bin" \ "$LOGDIR/indices-delta.bin" "$LOGDIR/delta-status.txt" fi +if [ "$MODE" = "replay" ]; then + # One dump per clip frame: the count comes from the stream, so a glob is + # the only way to be sure no previous run's frame survives (ruling R15). + rm -f "$LOGDIR"/vram-replay-*.bin "$LOGDIR/replay-status.txt" \ + "$LOGDIR/dictionary.bin" "$LOGDIR/stream.bin" \ + "$LOGDIR/tiles-manifest.json" +fi # Absolute: the Lua runs with MAME's cwd, so a relative default only resolves # when the caller happens to be at the repo root. VID_PAYLOAD="$ROOT/harness/udpstream/build/vidstream.bin" +VID_TILE_DIR=${VID_TILE_DIR:-$ROOT/harness/udpstream/tiles} +if [ "$MODE" = "replay" ]; then + # The run's directory must be checkable on its own: the checker renders + # from these exact bytes, and a stale copy would be evidence of another + # stream (ruling R15). + cp "$VID_TILE_DIR/dictionary.bin" "$VID_TILE_DIR/stream.bin" "$LOGDIR/" + if [ -f "$VID_TILE_DIR/manifest.json" ]; then + cp "$VID_TILE_DIR/manifest.json" "$LOGDIR/tiles-manifest.json" + fi +fi set +e VT1200_SKIP_LEVEL1=1 \ VT1200_MODE="$MODE" \ @@ -148,6 +176,7 @@ VID_DUMP_DIR="$LOGDIR" \ VID_POOL_BYTES="$POOL_BYTES" \ VID_STATIC_PTR="$STATIC_PTR" \ VID_ENTRY_PTR="$ENTRY_PTR" \ +VID_TILE_DIR="$VID_TILE_DIR" \ "$MAME" vt1200 -rompath "$HERE/roms" -video none -sound none -nothrottle \ -skip_gameinfo -debug -debugger none \ -seconds_to_run "${VID_SECONDS:-120}" \ @@ -218,4 +247,18 @@ if [ "$MODE" = "tiles" ]; then exit "$STATUS" fi fi + +# The replay's picture is checked the same way: tile_check.py --replay reads +# the stream itself and renders every frame. A dispatch pass with a wrong +# picture is a failure, and so is a run whose dumps do not match the stream. +if [ "$MODE" = "replay" ]; then + set +e + python3 "$HERE/tile_check.py" --replay "$LOGDIR" + STATUS=$? + set -e + if [ "$STATUS" -ne 0 ]; then + echo "run-vidstream.sh: replay check failed (log $LOG)" >&2 + exit "$STATUS" + fi +fi exit 0 diff --git a/harness/mame/tile_check.py b/harness/mame/tile_check.py index fdd2234..152d44d 100755 --- a/harness/mame/tile_check.py +++ b/harness/mame/tile_check.py @@ -39,13 +39,28 @@ the negative control Task 4 asks for: every dictionary entry has 32 distinct rows, and this script refuses to call a dictionary usable unless that holds, so a blit that re-reads row 0 for every row cannot match. +``--replay DIR`` is Task 6's check: ``make_tiles.py`` writes a cache +(``dictionary.bin``, ``stream.bin``), the MAME replay mode paints its frames in +order and dumps the 160 active bytes of every screen row after each one +(``vram-replay-.bin``), and this script decodes ``stream.bin`` itself -- +a second reading of the full-frame and delta layouts, sharing no code with the +encoder -- renders every frame through the dictionary and compares. The +stream, not ``indices.bin``, decides the picture: a cache whose stream paints +something else fails here, which is the point of re-deriving it. With +``--clip DIR`` the dumps are also compared with the source frames and the +reconstruction error (BER/PSNR) is printed -- a measurement of what the +terminal painted, which does not change the verdict. + usage: tile_check.py [--vram PATH] [--dict PATH] [--indices PATH] [--status PATH] + tile_check.py --replay DIR Exit status 0 only if every check passes. """ import argparse +import math import os +import struct import sys HEADER_SIZE = 16 @@ -62,6 +77,15 @@ VRAM_PITCH = 256 # bytes per screen row (2048 bits) SCREEN_ROWS = 1024 CANARY = 0xA5C3 # SENT_VALUE in vidstream.c, at row 1023 / column 1280 +# The replay stream (make_tiles.STREAM_MAGIC and record layout), decoded here +# independently of the encoder. A SAME record (kind 0) carries no payload and +# is not sent: the screen must not change for it. +STREAM_MAGIC = b"TS01" +STREAM_HEADER = struct.Struct("<4sH") +RECORD_HEADER = struct.Struct(" len(data): + raise ValueError("truncated in a record header") + kind, frame_no, length = RECORD_HEADER.unpack_from(data, offset) + offset += RECORD_HEADER.size + if offset + length > len(data): + raise ValueError("truncated in frame %d's payload" % frame_no) + records.append((kind, frame_no, data[offset:offset + length])) + offset += length + if len(records) != frames: + raise ValueError("%d records for %d frames" % (len(records), frames)) + for n, (kind, frame_no, payload) in enumerate(records): + if frame_no != n: + raise ValueError("record %d names frame %d" % (n, frame_no)) + if kind not in (KIND_SAME, KIND_FULL, KIND_DELTA): + raise ValueError("frame %d has unknown kind %d" % (n, kind)) + return frames, records + + +def apply_delta(state, payload): + """Apply one delta payload to ``state``; raise ``ValueError`` on bad input. + + The same rules the payload's C decoder enforces, written out here from the + layout rather than imported: exact length, a positive count, on-grid + positions and strictly ascending order. + """ + if len(payload) < 2: + raise ValueError("a delta opens with a 2-byte count, got %d bytes" + % len(payload)) + count = struct.unpack_from("= TILES_PER_SCREEN: + raise ValueError("position %d is off the grid" % position) + if position <= previous: + raise ValueError("positions are not ascending at %d" % position) + previous = position + state[position] = payload[2 + 2 * count + k] + + +def load_clip(directory): + """Read the source clip's raw frames, sorted by name, or raise ValueError.""" + paths = sorted(p for p in os.listdir(directory) if p.endswith(".bin")) + if not paths: + raise ValueError("no *.bin frames in %s" % (directory,)) + frames = [] + for name in paths: + with open(os.path.join(directory, name), "rb") as f: + data = f.read() + if not data or len(data) % ACTIVE_ROW_BYTES: + raise ValueError("%s is not whole %d-byte rows" + % (name, ACTIVE_ROW_BYTES)) + if len(data) > SCREEN_ROWS * ACTIVE_ROW_BYTES: + raise ValueError("%s is taller than the screen" % name) + frames.append(data) + return frames + + +def check_replay(directory, clip_dir=None): + """Compare every replayed frame with the stream's own render. + + With ``clip_dir`` the *dumped* screens are also compared with the source + frames and the reconstruction error is reported (BER and PSNR) -- a + measurement of what the terminal actually painted, not of what the host + thought it would paint. It does not change the verdict: the verdict is + "the screen equals the render", which is checked with or without a clip. + """ + dictionary = read_file(os.path.join(directory, "dictionary.bin")) + stream_path = os.path.join(directory, "stream.bin") + status = read_status(os.path.join(directory, "replay-status.txt")) + if dictionary is None or status is None: + return 2 + try: + frames, records = read_stream(stream_path) + except (OSError, ValueError) as exc: + print("tile_check: cannot read %s: %s" % (stream_path, exc)) + return 2 + + print("tile_check: replay %s: %d frames, dictionary %d B" + % (directory, frames, len(dictionary))) + clip = None + if clip_dir: + try: + clip = load_clip(clip_dir) + except (OSError, ValueError) as exc: + print("tile_check: cannot read the clip in %s: %s" + % (clip_dir, exc)) + return 2 + if check(len(clip) >= frames, + "the clip has %d frames, the stream %d" + % (len(clip), frames)): + clip = clip[:frames] + else: + clip = None + check(len(dictionary) == DICT_BYTES, + "dictionary is %d B, expected %d" % (len(dictionary), DICT_BYTES)) + + # --- the payload's own record ------------------------------------------ + wire = sum(1 for kind, _frame_no, _payload in records + if kind != KIND_SAME) + if check(status.get("TIMEOUT", 0) == 0, + "TIMEOUT=%r: the replay did not finish" % (status.get("TIMEOUT"),)): + check(status.get("FRAMES") == frames, + "FRAMES=%r, the stream has %d" % (status.get("FRAMES"), frames)) + check(status.get("WIRE") == wire, + "WIRE=%r, the stream sends %d frames" % (status.get("WIRE"), wire)) + check(status.get("RX_OK") == wire, + "RX_OK=%r, expected %d accepted frames" + % (status.get("RX_OK"), wire)) + check(status.get("RX_BAD") == 0, + "RX_BAD=%r, expected 0 rejected frames" % (status.get("RX_BAD"),)) + check(status.get("TILES_DROP") == 0, + "TILES_DROP=%r: every replayed frame describes the same " + "dictionary the terminal holds" % (status.get("TILES_DROP"),)) + check(status.get("DICT_MATCHED") == 1, + "DICT_MATCHED=%r: the payload's dictionary is not the file " + "uploaded" % (status.get("DICT_MATCHED"),)) + check(status.get("DICT_READY") == 1 and + status.get("DICT_BYTES") == DICT_BYTES, + "DICT_READY=%r DICT_BYTES=%r, expected 1 and %d" + % (status.get("DICT_READY"), status.get("DICT_BYTES"), DICT_BYTES)) + check(status.get("OS_TOUCH") == 0, + "OS_TOUCH=%r: something else wrote the sentinel corner of VRAM" + % (status.get("OS_TOUCH"),)) + check(status.get("SENTINEL") == CANARY, + "canary word is %r, expected 0x%04X" + % (status.get("SENTINEL"), CANARY)) + + # --- every frame, rendered from the stream alone ----------------------- + state = None + matched = 0 + clip_bits = 0 + for frame_no, (kind, _named, payload) in enumerate(records): + try: + if kind == KIND_FULL: + if len(payload) != TILES_PER_SCREEN: + raise ValueError("full frame is %d bytes, not %d" + % (len(payload), TILES_PER_SCREEN)) + state = bytearray(payload) + elif kind == KIND_DELTA: + if state is None: + raise ValueError("a delta before any full frame") + apply_delta(state, payload) + elif kind != KIND_SAME: + raise ValueError("unknown kind %d" % kind) + except ValueError as exc: + check(False, "frame %d: stream does not decode: %s" + % (frame_no, exc)) + break + if state is None: + check(False, "frame %d: the stream does not begin with a full " + "frame" % frame_no) + break + if kind == KIND_SAME and payload: + check(False, "frame %d: a SAME record carries %d payload bytes" + % (frame_no, len(payload))) + + path = os.path.join(directory, "vram-replay-%d.bin" % frame_no) + dump = read_file(path) + if dump is None: + check(False, "frame %d: no dump at %s" % (frame_no, path)) + continue + if len(dump) != SCREEN_ROWS * ACTIVE_ROW_BYTES: + check(False, "frame %d: dump is %d B, expected %d" + % (frame_no, len(dump), SCREEN_ROWS * ACTIVE_ROW_BYTES)) + continue + bad_rows = 0 + for y in range(SCREEN_ROWS): + want = rendered_row(dictionary, state, y // TILE_PIXELS, + y % TILE_PIXELS) + got = dump[y * ACTIVE_ROW_BYTES:(y + 1) * ACTIVE_ROW_BYTES] + if got != want: + bad_rows += 1 + if bad_rows == 1: + at = next(i for i in range(ACTIVE_ROW_BYTES) + if got[i] != want[i]) + print("tile_check: FAIL: frame %d: screen row %d byte %d " + "(%d differing of %d): expected %02X, got %02X" + % (frame_no, y, at, sum(a != b for a, b in + zip(got, want)), + ACTIVE_ROW_BYTES, want[at], got[at])) + if check(bad_rows == 0, + "frame %d: %d of %d active screen rows do not match the " + "render" % (frame_no, bad_rows, SCREEN_ROWS)): + matched += 1 + if clip is not None and len(clip[frame_no]) == len(dump): + bits = sum((a ^ b).bit_count() + for a, b in zip(dump, clip[frame_no])) + clip_bits += bits + print("tile_check: frame %d reconstruction: %d bit(s) (BER %.6f)" + % (frame_no, bits, bits / (SCREEN_ROWS * 1280))) + if failures > 8: + print("tile_check: stopping after the first failures") + break + + check(matched == frames, + "%d of %d frames match their render" % (matched, frames)) + print("tile_check: replay: %d/%d frames match" % (matched, frames)) + if clip is not None: + pixels = frames * SCREEN_ROWS * 1280 + psnr = ("inf" if clip_bits == 0 else + "%.2f dB" % (-10.0 * math.log10(clip_bits / pixels))) + print("tile_check: reconstruction: %d bit(s) over %d frame(s), " + "BER %.6f, PSNR %s" + % (clip_bits, frames, clip_bits / pixels, psnr)) + if failures: + print("tile_check: FAIL (%d checks)" % failures) + return 1 + print("tile_check: PASS") + return 0 + + def tile_index(indices, tx, ty): return indices[ty * TILE_COLS + tx] @@ -123,14 +384,27 @@ def rendered_row(dictionary, indices, ty, row): return bytes(out) -def main(): +def main(argv=None): + # The failure counter is module state, and the tests call main() more than + # once in one process; a run's verdict must not inherit the last one's. + global failures + failures = 0 ap = argparse.ArgumentParser() d = os.environ.get("VID_DUMP_DIR", "/tmp/opencode") + ap.add_argument("--replay", metavar="DIR", + help="check a make_tiles.py replay directory instead of " + "one tile frame") + ap.add_argument("--clip", metavar="DIR", + help="with --replay: the source frames, for the " + "reconstruction error (BER/PSNR)") ap.add_argument("--vram", default=os.path.join(d, "vram-tiles.bin")) ap.add_argument("--dict", default=os.path.join(d, "dict-tiles.bin")) ap.add_argument("--indices", default=os.path.join(d, "indices-tiles.bin")) ap.add_argument("--status", default=os.path.join(d, "tiles-status.txt")) - args = ap.parse_args() + args = ap.parse_args(argv) + + if args.replay: + return check_replay(args.replay, args.clip) vram = read_file(args.vram) dictionary = read_file(args.dict) diff --git a/harness/mame/vidstream.lua b/harness/mame/vidstream.lua index 9d5fc40..8a05087 100644 --- a/harness/mame/vidstream.lua +++ b/harness/mame/vidstream.lua @@ -113,8 +113,8 @@ -- harness/udpstream/frame_parse.h. -- -- Env: --- VT1200_MODE alloc (default), rx, draw, stream, ring, dict, tiles or --- delta +-- VT1200_MODE alloc (default), rx, draw, stream, ring, dict, tiles, +-- delta or replay -- VID_AT load/entry frame (default 2400, the OS phase) -- VID_END inspection/exit frame (default VID_AT + 60) -- VID_DUMP_DIR where draw, stream, ring, dict and tiles modes write their @@ -137,6 +137,10 @@ -- chunk or the tile frame to be consumed (default 300) -- VID_DELTA_WAIT delta mode only: emulated frames to wait for a dictionary -- chunk or a delta frame to be consumed (default 300) +-- VID_TILE_DIR replay mode only: the make_tiles.py cache to play +-- (default harness/udpstream/tiles) +-- VID_REPLAY_WAIT replay mode only: emulated frames to wait for a dictionary +-- chunk or a clip frame to be consumed (default 600) -- VID_PAYLOAD raw .text image (default harness/udpstream/build/vidstream.bin) -- VID_ORIGIN payload load (poke) bit address (default 0x018C0000) -- VID_ENTRY_PTR _start bit address from vidstream.env (default VID_ORIGIN) @@ -182,6 +186,13 @@ elseif MODE == "delta" and not os.getenv("VID_END") then -- ring wait, 23 dictionary chunks and six frames, each bounded by -- VID_DELTA_WAIT; the first timeout dumps and exits. DEFAULT_END = AT + 34 * tonumber(os.getenv("VID_DELTA_WAIT") or "300") + 120 +elseif MODE == "replay" and not os.getenv("VID_END") then + -- One ring wait, 23 dictionary chunks and one wait per clip frame, each + -- bounded by VID_REPLAY_WAIT (600: a frame is a datagram plus a 160 KB + -- screen dump, and the first timeout dumps and exits). The stream's own + -- length is not known until the mode reads it, so the backstop is sized + -- for the 30-frame demo and VID_END overrides it for a longer stream. + DEFAULT_END = AT + 26 * tonumber(os.getenv("VID_REPLAY_WAIT") or "600") + 3600 end local STOP_AT = tonumber(os.getenv("VID_END") or tostring(DEFAULT_END)) local ORIGIN = tonumber(os.getenv("VID_ORIGIN") or "0x018C0000") @@ -313,7 +324,7 @@ local DUMP_DIR = os.getenv("VID_DUMP_DIR") or "/tmp/opencode" if MODE ~= "alloc" and MODE ~= "rx" and MODE ~= "draw" and MODE ~= "stream" and MODE ~= "ring" and MODE ~= "dict" - and MODE ~= "tiles" and MODE ~= "delta" then + and MODE ~= "tiles" and MODE ~= "delta" and MODE ~= "replay" then print("VID ERROR: mode '" .. MODE .. "' is not implemented") machine:exit() return @@ -405,8 +416,9 @@ local alive0 = nil local alive_last = nil local injected = false local injected_bad = false -local readback_a = false -local readback_b = false +-- The two receive-mode readbacks are one local, not two: this file is at Lua's +-- 200-local limit for the main chunk, and each mode closure added another one. +local readback = { a = false, b = false } local function load_payload() local n = math.floor(#payload / 2) @@ -650,6 +662,13 @@ local function clear_dump_files() end return end + if MODE == "replay" then + -- vram-replay-*.bin is one file per clip frame and the count is not + -- known until the stream is read (the closure reads it after this), + -- so run-vidstream.sh removes those; this removes the verdict. + os.remove(DUMP_DIR .. "/replay-status.txt") + return + end if MODE ~= "draw" and MODE ~= "stream" then return end @@ -2673,6 +2692,369 @@ return { } end)() +-- +-- Mode "replay" (Task 6) plays the *cache make_tiles.py wrote*: the clip's own +-- 32 KB dictionary, uploaded exactly as the sender uploads it, then one VS +-- frame per stream record -- TILES_FULL where the plan says keyframe, +-- TILES_DELTA otherwise. The payload paints it with no knowledge that a file +-- is behind the bytes, and after every clip frame the harness dumps the 160 +-- active bytes of each screen row to vram-replay-.bin. The pixel +-- verdict is harness/mame/tile_check.py --replay: it decodes stream.bin with +-- its own code, renders each frame through the dictionary and compares. A +-- SAME record (kind 0) sends nothing and still dumps, so "the screen did not +-- change" is checked rather than assumed. +-- +-- The stream file is make_tiles.STREAM_MAGIC's layout (TS01, a u16 frame +-- count, then kind/frame_no/len/payload per frame). This mode reads it +-- directly -- it is the harness's copy of the plan, not a second wire format. +local replay = (function() +local TILE_DICT_SIZE = 32768 +local TILE_DICT_BYTES = 128 +local DICT_CHUNK_BODY = 1448 -- tiledict.MAX_BODY +local DICT_OFF = 2 * SLOT_BYTES -- 0xC000: after the two slots +local ACTIVE_ROW = 160 -- 40 tiles x 4 bytes +local KIND_SAME = 0 +local KIND_FULL = 2 +local KIND_DELTA = 3 +local REPLAY_WAIT = tonumber(os.getenv("VID_REPLAY_WAIT") or "600") +local TILE_DIR = os.getenv("VID_TILE_DIR") or "harness/udpstream/tiles" + +local function replay_read(path) + local f, err = io.open(path, "rb") + if not f then + return nil, err + end + local s = f:read("*a") + f:close() + return s +end + +local function u16(s, i) + return s:byte(i) | (s:byte(i + 1) << 8) +end + +-- Only replay mode needs the cache. The closure is created for every mode, so +-- without this guard a missing cache would break the other seven. +if MODE ~= "replay" then + return { + step = function() return false end, + phase = function() return 0 end, + dump = function() end, + } +end + +local DICT, derr = replay_read(TILE_DIR .. "/dictionary.bin") +local STREAM, serr = replay_read(TILE_DIR .. "/stream.bin") +local records, frame_count, wire = {}, 0, 0 + +if not DICT or not STREAM then + print("VID ERROR: cannot read " .. TILE_DIR .. "/{dictionary,stream}.bin: " + .. tostring(derr or serr)) + machine:exit() + return +end +if #DICT ~= TILE_DICT_SIZE then + print(string.format("VID ERROR: dictionary.bin is %d bytes, expected %d", + #DICT, TILE_DICT_SIZE)) + machine:exit() + return +end +if #STREAM < 6 or STREAM:sub(1, 4) ~= "TS01" then + print("VID ERROR: stream.bin is not a make_tiles.py stream (no TS01 magic)") + machine:exit() + return +end +frame_count = u16(STREAM, 5) +local at = 7 +for n = 0, frame_count - 1 do + if at + 4 > #STREAM then + print(string.format("VID ERROR: stream.bin is truncated at frame %d", n)) + machine:exit() + return + end + local kind = STREAM:byte(at) + local len = u16(STREAM, at + 3) + if kind ~= KIND_SAME and kind ~= KIND_FULL and kind ~= KIND_DELTA then + print(string.format("VID ERROR: frame %d has kind %d", n, kind)) + machine:exit() + return + end + if at + 4 + len > #STREAM then + print(string.format("VID ERROR: frame %d's payload runs past the file", n)) + machine:exit() + return + end + records[n + 1] = { kind = kind, len = len, at = at + 5 } + if kind ~= KIND_SAME then + wire = wire + 1 + end + at = at + 5 + len +end +print(string.format("VID: replay %s: %d frame(s), %d on the wire, dictionary %d B", + TILE_DIR, frame_count, wire, #DICT)) + +local phase = 0 +local phase_at = nil +local pos = 0 +local chunk_i = 0 +local nchunks = (TILE_DICT_SIZE + DICT_CHUNK_BODY - 1) // DICT_CHUNK_BODY +local frame_i = 0 +local pending = nil -- the frame waiting for the ring to settle +local wire_sent = 0 +local dict_matched = 0 +local fail = 0 +local lines = {} + +local function replay_check(cond, what) + if cond then + lines[#lines + 1] = "PASS " .. what + print("VID: replay PASS: " .. what) + else + lines[#lines + 1] = "FAIL " .. what + print("VID: replay FAIL: " .. what) + fail = fail + 1 + end + return cond +end + +-- One KIND_DICT_CHUNK frame: the u16 little-endian offset, then the body. +local function chunk_frame(off, body) + return make_frame(KIND_DICT_CHUNK, 0, 0, 0, 2 + body, function(k) + if k == 0 then return off & 0xFF end + if k == 1 then return (off >> 8) & 0xFF end + return DICT:byte(off + k - 1) + end) +end + +-- Read the pixels the tile grid owns: 1024 rows x 160 bytes. +local function read_active() + local rows = {} + for y = 0, VRAM_ROWS - 1 do + local off = VRAM_BASE + y * VRAM_ROW_BITS + local chars = {} + for w = 0, ACTIVE_ROW // 2 - 1 do + local v = prog:read_u16(off + w * 16) + chars[#chars + 1] = string.char(v & 0xFF, (v >> 8) & 0xFF) + end + rows[#rows + 1] = table.concat(chars) + end + return table.concat(rows) +end + +local function dump_frame(no, kind, len) + local ok = write_file(string.format("%s/vram-replay-%d.bin", DUMP_DIR, no), + read_active()) + lines[#lines + 1] = string.format("FRAME %d KIND %d LEN %d RX_OK %d", + no, kind, len, prog:read_u32(RX_OK)) + return ok +end + +local function settled() + return prog:read_u32(RX_RING_POS) == pos + and prog:read_u32(RX_RING_ACC) == 0 +end + +local function timed_out(what) + if pending ~= nil then + return frames >= pending.at + REPLAY_WAIT + end + if phase_at ~= nil and frames >= phase_at + REPLAY_WAIT then + print(string.format("VID: timed out in phase %d waiting for %s", phase, what)) + return true + end + return false +end + +local function verify_dict() + local base = prog:read_u32(POOL_PTR) + local bit, bad, first, i, v, want + + if base == 0 then + return false, -1 + end + bit = base + DICT_OFF * 8 + bad = 0 + first = -1 + for i = 0, TILE_DICT_SIZE // 2 - 1 do + v = prog:read_u16(bit + i * 16) + want = DICT:byte(i * 2 + 1) | (DICT:byte(i * 2 + 2) << 8) + if v ~= want then + bad = bad + 1 + if first < 0 then + first = i * 2 + end + end + end + return bad == 0, first +end + +local function replay_dump(timed) + local ok = write_file(DUMP_DIR .. "/replay-status.txt", table.concat({ + string.format("FRAMES=%d", frame_count), + string.format("WIRE=%d", wire), + string.format("RX_OK=%d", prog:read_u32(RX_OK)), + string.format("RX_BAD=%d", prog:read_u32(RX_BAD)), + string.format("TILES_DROP=%d", prog:read_u32(TILES_DROP)), + string.format("RX_LAST_LEN=%d", prog:read_u32(RX_LAST_LEN)), + string.format("DICT_READY=%d", prog:read_u32(DICT_READY)), + string.format("DICT_BYTES=%d", prog:read_u32(DICT_BYTES)), + string.format("DICT_BAD=%d", prog:read_u32(DICT_BAD)), + string.format("DICT_MATCHED=%d", dict_matched), + string.format("OS_TOUCH=%d", prog:read_u32(OS_TOUCH)), + string.format("SENTINEL=%d", prog:read_u16(VRAM_BASE + + (VRAM_ROWS - 1) * VRAM_ROW_BITS + 1280)), + string.format("REPLAY_CHECKS_FAILED=%d", fail), + string.format("TIMEOUT=%d", timed and 1 or 0), + }, "\n") .. "\n" .. table.concat(lines, "\n") .. "\n") + print(string.format("VID: replay: %d check(s) failed; %d/%d frames dumped, RX_OK=%d TILES_DROP=%d", + fail, frame_i, frame_count, prog:read_u32(RX_OK), + prog:read_u32(TILES_DROP))) + print(ok and not timed and fail == 0 + and "VID: RESULT PASS" or "VID: RESULT FAIL") + machine:exit() +end + +-- One call per emulated frame. Returns true when the run is over. +local function replay_step() + if phase == 0 then + phase_at = frames + if not ring_ready() then + if timed_out("the RX ring") then + replay_check(false, string.format( + "the payload published an RX ring (RX_RING_BASE=%s)", + hex(prog:read_u32(RX_RING_BASE)))) + replay_dump(true) + return true + end + return false + end + local _, p = ring_state() + pos = p + phase = 1 + phase_at = frames + return false + end + + -- Every injection is one frame or one dictionary chunk. Wait for the ring + -- to consume it before the next decision, exactly as the sender waits for + -- the terminal. + if pending ~= nil then + if not settled() then + if timed_out("frame " .. tostring(pending.no)) then + replay_check(false, "frame " .. tostring(pending.no) + .. " was accepted") + replay_dump(true) + return true + end + return false + end + if pending.chunk then + pending = nil + return false + end + local ok = replay_check(prog:read_u32(RX_OK) == pending.want_ok, + string.format("frame %d was accepted (RX_OK=%d, expected %d)", + pending.no, prog:read_u32(RX_OK), pending.want_ok)) + ok = dump_frame(pending.no, pending.kind, pending.len) and ok + pending = nil + phase_at = frames + return false + end + + if phase == 1 then + if chunk_i < nchunks then + local off = chunk_i * DICT_CHUNK_BODY + local body = TILE_DICT_SIZE - off + if body > DICT_CHUNK_BODY then + body = DICT_CHUNK_BODY + end + local f = chunk_frame(off, body) + local ndg = inject_frame(f, 16 + 2 + body, DRAW_CHUNK) + if ndg == nil then + replay_check(false, "dictionary chunk " .. chunk_i .. " reached the ring") + replay_dump(true) + return true + end + pos = (pos + ndg) % RX_DESC + pending = { chunk = true, at = frames } + chunk_i = chunk_i + 1 + return false + end + dict_matched = 0 + local matched, first = verify_dict() + dict_matched = matched and 1 or 0 + replay_check(prog:read_u32(DICT_READY) == 1 + and prog:read_u32(DICT_BYTES) == TILE_DICT_SIZE + and prog:read_u32(DICT_BAD) == 0, + string.format("the %d chunks made DICT_READY=1 and DICT_BYTES=%d", + nchunks, prog:read_u32(DICT_BYTES))) + replay_check(matched, + string.format("the payload holds dictionary.bin (first bad byte %d)", + first)) + phase = 2 + phase_at = frames + return false + end + + if phase == 2 then + if frame_i >= frame_count then + phase = 3 + return false + end + local rec = records[frame_i + 1] + if rec.kind == KIND_SAME then + -- Nothing on the wire: the screen must not move, and the dump + -- after it proves that. + dump_frame(frame_i, KIND_SAME, 0) + frame_i = frame_i + 1 + phase_at = frames + return false + end + local no = frame_i + local f = make_frame(rec.kind, no, 0, 0, rec.len, function(k) + return STREAM:byte(rec.at + k) + end) + local ndg = inject_frame(f, 16 + rec.len, DRAW_CHUNK) + if ndg == nil then + replay_check(false, "frame " .. no .. " reached the ring") + replay_dump(true) + return true + end + wire_sent = wire_sent + 1 + pos = (pos + ndg) % RX_DESC + pending = { no = no, kind = rec.kind, len = rec.len, + want_ok = wire_sent, at = frames } + frame_i = frame_i + 1 + return false + end + + -- Phase 3: the record's own numbers, then the verdict. + replay_check(prog:read_u32(RX_OK) == wire, + string.format("every wire frame was accepted (RX_OK=%d, expected %d)", + prog:read_u32(RX_OK), wire)) + replay_check(prog:read_u32(TILES_DROP) == 0, + string.format("no tile frame was dropped (TILES_DROP=%d)", + prog:read_u32(TILES_DROP))) + replay_check(prog:read_u32(RX_BAD) == 0, + string.format("no frame was rejected (RX_BAD=%d)", + prog:read_u32(RX_BAD))) + replay_check(prog:read_u16(VRAM_BASE + (VRAM_ROWS - 1) * VRAM_ROW_BITS + + 1280) == 0xA5C3, string.format("the VRAM canary survives the replay (SENTINEL=%04X)", + prog:read_u16(VRAM_BASE + (VRAM_ROWS - 1) * VRAM_ROW_BITS + 1280))) + replay_check(prog:read_u32(OS_TOUCH) == 0, + string.format("the OS did not repaint over the replay (OS_TOUCH=%d)", + prog:read_u32(OS_TOUCH))) + replay_dump(false) + return true +end + +return { + step = replay_step, + phase = function() return phase end, + dump = function(timed_out) replay_dump(timed_out) end, +} +end)() + -- -- `timed_out` matters for what the checker is allowed to say: if the payload -- never accepted the frame, the screen shows the previous picture and calling @@ -2837,11 +3219,11 @@ local function inspect_rx() ok = false end - if readback_a and readback_b then + if readback.a and readback.b then print("VID: both delivered frames read back byte for byte from their buffers -> PASS") else print(string.format("VID: delivered frame readback mismatch (A=%s B=%s) -> FAIL", - tostring(readback_a), tostring(readback_b))) + tostring(readback.a), tostring(readback.b))) ok = false end @@ -2926,7 +3308,7 @@ emu.register_frame_done(function() local base, pos = ring_state() injected = true local okb, badb = verify_chunk(base + pos * RXBUFLEN * 8, rx_valid, 0, RX_FRAME) - readback_a = okb + readback.a = okb print(string.format("VID: frame %d: delivered the valid frame as %d datagram(s) into slot %d (rx_ok=%s, %d bad)", frames, ndg, prog:read_u32(RX_RING_ARMED), okb and "OK" or "FAILED", badb)) end @@ -2936,7 +3318,7 @@ emu.register_frame_done(function() local base, pos = ring_state() injected_bad = true local okb, badb = verify_chunk(base + pos * RXBUFLEN * 8, rx_corrupt, 0, RX_FRAME) - readback_b = okb + readback.b = okb print(string.format("VID: frame %d: delivered the header-flipped frame as %d datagram(s) into slot %d (rx_ok=%s, %d bad)", frames, ndg, prog:read_u32(RX_RING_ARMED), okb and "OK" or "FAILED", badb)) end @@ -2971,6 +3353,13 @@ emu.register_frame_done(function() end end + -- Mode replay: the clip make_tiles.py cached, frame by frame (replay.step()). + if entered and MODE == "replay" then + if replay.step() then + return + end + end + -- Draw mode: inject once, then wait for the payload to accept the frame. -- Two separate observations, because they answer different questions: -- @@ -3095,8 +3484,16 @@ emu.register_frame_done(function() return end + if MODE == "replay" and frames >= STOP_AT then + print(string.format("VID: replay mode did not finish (phase %d) by frame %d", + replay.phase(), frames)) + replay.dump(true) + return + end + if frames >= STOP_AT and MODE ~= "stream" and MODE ~= "ring" and MODE ~= "dict" and MODE ~= "tiles" and MODE ~= "delta" + and MODE ~= "replay" and not (MODE == "draw" and draw_at ~= nil) then if not entered then print("VID: payload never entered -> FAIL") diff --git a/harness/udpstream/make_tiles.py b/harness/udpstream/make_tiles.py new file mode 100644 index 0000000..fd9719a --- /dev/null +++ b/harness/udpstream/make_tiles.py @@ -0,0 +1,645 @@ +#!/usr/bin/env python3 +"""make_tiles.py -- a clip to a tile dictionary and a cached index/delta stream. + +Scheme C's host half, end to end: read a clip, build the 32x32 1-bpp dictionary +(Task 1), quantise every frame to 1,280 indices, plan a ``TILES_FULL`` keyframe +or a changed-tile delta for each frame (Task 5's cadence), and write the whole +thing to a directory the sender and the MAME replay both consume: + + tiles/ + dictionary.bin 32,768 bytes: 256 entries of 128 bytes + indices.bin one 1,280-byte index frame per clip frame + stream.bin the plan, in wire order (see below) + manifest.json what the numbers mean: bytes, changes, PSNR + +``stream.bin`` is the *plan*, not a capture of a wire run -- the 16-byte frame +header is ``frame.py``'s job and is rebuilt identically by the sender and by the +replay Lua, so there is one implementation of the wire format and one of the +schedule: + + offset size field + 0 4 magic 'TS01' + 4 2 frame count, u16 little-endian + 6 - records, one per clip frame, in order: + 1 kind: 0 = SAME (nothing to send), 2 = TILES_FULL, + 3 = TILES_DELTA + 2 frame_no, u16 little-endian (the clip frame number) + 2 payload length, u16 little-endian + - payload (empty for SAME) + +A SAME frame is a record with no payload rather than an omitted record: the +replay then knows the frame exists, paints nothing for it, and can still compare +the screen it did not touch. Neither the sender nor the replay sends kind 0. + +The clip +-------- + +``--clip DIR`` reads every ``*.bin`` in ``DIR`` in sorted order; each file is +one raw packed 1-bpp frame -- whole 160-byte rows, whole 32-row tile bands, at +most the 1024-row screen, top-aligned like ``tilecost.quantise``. With no +``--clip`` the deterministic :func:`synthetic_clip` is used, because there is no +Bad Apple clip on this machine; see NETWORK_REPORT.md. + +The error bound +--------------- + +Quantising a tile stores the nearest dictionary entry under Hamming distance, +and that distance is exactly the number of pixels the rendered frame will +differ by. So the reconstruction error is not estimated: :func:`tile_errors` +returns the per-tile distance, the frame's total is their sum, and no encoder +using the same dictionary can beat it. :func:`psnr_db` reports it as +``-10 log10(BER)`` (the peak-to-peak of a 1-bpp image is 1, and MSE equals BER), +which is infinite -- ``None`` -- only when the dictionary happens to hold every +tile of the clip. + +Pure standard library. +""" +from __future__ import annotations + +import argparse +import functools +import json +import math +import os +import struct +import sys +from collections import namedtuple +from pathlib import Path + +if __package__ in (None, ""): # run as a script, not a module + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__))))) + +from harness.udpstream.frame import KIND_TILES_DELTA, KIND_TILES_FULL # noqa +from harness.udpstream.tilecost import ( # noqa + MAX_TILES, SCREEN_TILE_ROWS, TILE_BYTES, TILE_COLS, TILES_PER_SCREEN, + _frame_tiles, build_dictionary, quantise) +from harness.udpstream.tiledict import ( # noqa + KEYFRAME_EVERY, delta_frame, keyframe_due, unpack_delta) +from harness.udpstream.vidsrc import ROW_BYTES, ROW_PIXELS, SCREEN_ROWS # noqa + +__all__ = [ + "ACTIVE_ROW_BYTES", "CACHE_VERSION", "Cache", "DEFAULT_CACHE", + "KIND_DELTA", "KIND_FULL", "KIND_SAME", "Record", "STREAM_MAGIC", "Tiles", + "ber", "build", "load_clip", "plan", "psnr_db", "read_cache", "render", + "synthetic_clip", "tile_errors", "write_cache", +] + +#: A stream record's kind is a wire kind, except SAME: that frame is not sent. +KIND_SAME = 0 +KIND_FULL = KIND_TILES_FULL # 2 +KIND_DELTA = KIND_TILES_DELTA # 3 + +#: The stream file's magic, and the version of the cache layout. +STREAM_MAGIC = b"TS01" +CACHE_VERSION = 1 + +#: ``magic, frame count`` then one ``kind u8, frame_no u16, len u16`` per frame. +STREAM_HEADER = struct.Struct("<4sH") +RECORD_HEADER = struct.Struct(" int: + """The :data:`ROW_PIXELS`-bit mask of pixels ``left``..``right`` exclusive.""" + left = max(0, left) + right = min(ROW_PIXELS, right) + if right <= left: + return 0 + return (_MASK ^ ((1 << left) - 1)) & ~((1 << right) - 1) & _MASK + + +def _background_tile(tx: int, ty: int) -> list[int]: + """One 32x32 background tile: a diagonal hatch, 8 phases, mirrored. + + The phase is a function of the tile's place in the grid, so the background + is static across the clip (deltas then carry the *motion*, not the + wallpaper) and its vocabulary is small enough that k-means has something to + do but not so large that the dictionary is meaningless. Integer arithmetic + throughout, so the clip is byte-identical on any host. + """ + phase = (tx * 7 + ty * 13) % 64 + rows = [] + for r in range(32): + value = 0 + for c in range(32): + if (c + 2 * r + phase) % 8 < 3: + value |= 1 << (31 - c) + if (phase >> 1) & 1: + value ^= 0xFFFF0000 # mirror the right half + if (phase >> 2) & 1: + value = ((value & 0xFFFF) << 16) | (value >> 16) + rows.append(value) + return rows + + +@functools.lru_cache(maxsize=1) +def _background_rows() -> tuple[int, ...]: + """The whole static background, one :data:`ROW_PIXELS`-bit int per row.""" + rows = [0] * SCREEN_ROWS + for ty in range(SCREEN_TILE_ROWS): + for tx in range(TILE_COLS): + pattern = _background_tile(tx, ty) + shift = ROW_PIXELS - 32 * (tx + 1) + for r in range(32): + rows[ty * 32 + r] |= pattern[r] << shift + return tuple(rows) + + +def _silhouette(rows: list[int], n: int) -> None: + """Draw frame ``n``'s moving silhouette over ``rows`` (black over paper). + + An ellipse sweeping 7 px right per frame, with two horizontal cut-outs and + a slot for an "eye". The odd step is deliberate: it keeps the silhouette's + edges from landing on tile boundaries, so the dictionary has to represent + partly-covered tiles and the measured error is not zero by construction. + """ + x0 = 30 + 7 * n + for dy in range(340): + y = 200 + dy + if not 0 <= y < SCREEN_ROWS: + continue + # Integer form of 170 * (1 - ((dy - 170) / 180)^2), never negative. + half = 170 * (180 * 180 - (dy - 170) ** 2) // (180 * 180) + if half <= 0: + continue + rows[y] &= ~_span(x0 + 170 - half, x0 + 170 + half) & _MASK + if (dy // 47) % 3 == 1: + rows[y] = 0 # a cut-out across the body + if 120 <= dy <= 160: + rows[y] |= _span(x0 + 205, x0 + 250) & _MASK # the "eye" + + +def _synthetic_frame(n: int) -> bytes: + rows = list(_background_rows()) + for y in range(SCREEN_ROWS - 160, SCREEN_ROWS): + rows[y] = 0 # a black ground band + _silhouette(rows, n) + return b"".join(row.to_bytes(ROW_BYTES, "big") for row in rows) + + +def synthetic_clip(frames: int = 30) -> list[bytes]: + """Return ``frames`` deterministic full-screen 1-bpp frames. + + This is the clip the pipeline is measured on because no Bad Apple source is + available on this machine (NETWORK_REPORT.md says so plainly). It is built + to exercise the codec rather than to look like the video: a static textured + background (so deltas carry motion), a silhouette moving 7 px per frame (so + tile boundaries are crossed), more distinct tiles than the dictionary can + hold (so the error bound is real), and no randomness at all. + """ + if not isinstance(frames, int) or isinstance(frames, bool) or frames < 1: + raise ValueError("frames is a positive int, got %r" % (frames,)) + return [_synthetic_frame(n) for n in range(frames)] + + +# --- reading a real clip -------------------------------------------------- + +def _check_frame(data: bytes, name: str) -> bytes: + """Validate one raw frame: whole rows, whole tile bands, top of screen.""" + if not data: + raise ValueError("%s is empty" % name) + if len(data) % ROW_BYTES: + raise ValueError("%s is %d bytes, not whole %d-byte rows" + % (name, len(data), ROW_BYTES)) + rows = len(data) // ROW_BYTES + if rows % 32: + raise ValueError("%s is %d rows, not whole 32-row tile bands" + % (name, rows)) + if rows > SCREEN_ROWS: + raise ValueError("%s is %d rows, more than the %d-row screen" + % (name, rows, SCREEN_ROWS)) + return data + + +def load_clip(directory) -> list[bytes]: + """Read a clip directory's ``*.bin`` frames, sorted by name. + + Each file is one raw packed 1-bpp frame, exactly what + :func:`harness.udpstream.tilecost.quantise` takes. A ``.bin`` that is not + whole rows and whole tile bands is an error naming the file, not a silent + reshape -- the terminal's 1,280-byte frame is only meaningful for a whole + grid. The sort is by name, so number the frames zero-padded: ``frame-0007`` + sorts before ``frame-0010`` and ``frame-7`` before ``frame-10`` does not. + """ + paths = sorted(Path(directory).glob("*.bin")) + if not paths: + raise ValueError("no *.bin frames in %s" % (directory,)) + return [_check_frame(path.read_bytes(), str(path)) for path in paths] + + +# --- the error, which is a bound ------------------------------------------ + +def _entries(dictionary: bytes) -> list[bytes]: + if len(dictionary) % TILE_BYTES: + raise ValueError("a dictionary is whole %d-byte tiles, got %d" + % (TILE_BYTES, len(dictionary))) + return [dictionary[i * TILE_BYTES:(i + 1) * TILE_BYTES] + for i in range(len(dictionary) // TILE_BYTES)] + + +def tile_errors(frame: bytes, indices: bytes, dictionary: bytes) -> list[int]: + """Hamming distance from each source tile to the entry ``indices`` names. + + These are the pixels the rendered screen will differ by, not an estimate: + the quantiser picks the nearest entry, so the distance is what the blit + reproduces. The tile split is :func:`tilecost._frame_tiles` -- the same one + ``quantise`` used, so this measures the encoder's own tiles rather than a + second reading of the geometry (the *renderer* is the second reading). + """ + entries = _entries(dictionary) + tiles = _frame_tiles(frame) + if len(indices) != TILES_PER_SCREEN: + raise ValueError("an index frame is %d bytes, got %d" + % (TILES_PER_SCREEN, len(indices))) + return [(tile ^ int.from_bytes(entries[indices[position]], "big")).bit_count() + for position, tile in enumerate(tiles)] + + +def render(indices: bytes, dictionary: bytes) -> bytes: + """The screen ``indices`` and ``dictionary`` require, as 1,024 x 160 bytes. + + Only the 160 bytes a row that the tile grid covers: 40 tiles x 32 px. The + rest of a 256-byte VRAM row belongs to the resident OS and no tile blit + touches it (``tile_check.py`` draws the same line). + """ + entries = _entries(dictionary) + if len(indices) != TILES_PER_SCREEN: + raise ValueError("an index frame is %d bytes, got %d" + % (TILES_PER_SCREEN, len(indices))) + screen = bytearray(SCREEN_ROWS * ACTIVE_ROW_BYTES) + for ty in range(SCREEN_TILE_ROWS): + for tx in range(TILE_COLS): + entry = entries[indices[ty * TILE_COLS + tx]] + left = tx * 4 + for row in range(32): + offset = (ty * 32 + row) * ACTIVE_ROW_BYTES + left + screen[offset:offset + 4] = entry[row * 4:row * 4 + 4] + return bytes(screen) + + +def ber(bit_errors: int, pixels: int = SCREEN_ROWS * ROW_PIXELS) -> float: + """Bit-error rate: the fraction of pixels the render gets wrong.""" + if bit_errors < 0 or pixels <= 0: + raise ValueError("ber needs a non-negative count over positive pixels") + return bit_errors / pixels + + +def psnr_db(bit_errors: int, pixels: int = SCREEN_ROWS * ROW_PIXELS): + """PSNR for a 1-bpp reconstruction, or ``None`` when it is exact. + + MSE = BER because the samples are 0/1 and the peak-to-peak is 1, so + PSNR = 10 log10(1/MSE) = -10 log10(BER). An exact reconstruction has no + finite PSNR; returning ``None`` (JSON ``null``) says that instead of a + made-up ceiling. + """ + value = ber(bit_errors, pixels) + if value == 0: + return None + return -10.0 * math.log10(value) + + +# --- the plan ------------------------------------------------------------- + +def plan(indices, every: int = KEYFRAME_EVERY, drops=None) -> list[Record]: + """Plan one record per frame: full, delta, or nothing to send. + + ``drops`` maps a frame number to the terminal's *cumulative* drop count as + reported before that frame is sent (the status frame's field). The rule is + Task 5's :func:`tiledict.keyframe_due`: a full frame every ``every`` frames, + and immediately after a rise in ``drops``. The counter is consumed by the + decision, so one report forces exactly one keyframe. + """ + frames = [bytes(frame) for frame in indices] + if not frames: + raise ValueError("a plan needs at least one frame") + reported = {} if drops is None else dict(drops) + records = [] + previous = None + seen = 0 + for n, current in enumerate(frames): + now = max(seen, int(reported.get(n, 0))) + changed = 0 if previous is None else sum( + 1 for a, b in zip(previous, current) if a != b) + if keyframe_due(n, now, seen, every): + records.append(Record(n, KIND_FULL, current, changed)) + else: + payload = delta_frame(previous, current) + if payload is None: + records.append(Record(n, KIND_SAME, b"", 0)) + else: + records.append(Record(n, KIND_DELTA, payload, + (len(payload) - 2) // 3)) + seen = now + previous = current + return records + + +def build(frames, k: int = MAX_TILES, seed: int = 0, + every: int = KEYFRAME_EVERY, drops=None) -> Tiles: + """Quantise ``frames`` through a new dictionary and plan the stream.""" + frames = [_check_frame(bytes(frame), "frame %d" % n) + for n, frame in enumerate(frames)] + if not frames: + raise ValueError("build needs at least one frame") + dictionary = build_dictionary(frames, k=k, seed=seed) + indices = [quantise(frame, dictionary) for frame in frames] + records = plan(indices, every=every, drops=drops) + errors = [sum(tile_errors(frame, index, dictionary)) + for frame, index in zip(frames, indices)] + return Tiles(dictionary, indices, records, errors) + + +# --- the cache ------------------------------------------------------------ + +def _stream_bytes(records, frames: int) -> bytes: + out = [STREAM_HEADER.pack(STREAM_MAGIC, frames)] + for record in records: + out.append(RECORD_HEADER.pack(record.kind, record.frame_no & 0xFFFF, + len(record.payload))) + out.append(record.payload) + return b"".join(out) + + +def _decode(records, frames: int): + """Apply full frames and deltas, returning one index frame per frame. + + This is the *writer's* reading of its own stream, used only to check the + cache is self-consistent. The replay checker decodes ``stream.bin`` with + its own code on purpose; an encoder that blessed itself would prove + nothing. + """ + state = None + if len(records) != frames: + raise ValueError("stream has %d records for %d frames" + % (len(records), frames)) + decoded = [] + for n, record in enumerate(records): + if record.frame_no != n: + raise ValueError("record %d names frame %d" % (n, record.frame_no)) + if record.kind == KIND_FULL: + if len(record.payload) != TILES_PER_SCREEN: + raise ValueError("full frame %d is %d bytes, not %d" + % (n, len(record.payload), TILES_PER_SCREEN)) + state = bytearray(record.payload) + elif record.kind == KIND_DELTA: + if state is None: + raise ValueError("delta at frame %d has no full frame before it" + % n) + for position, value in unpack_delta(record.payload): + state[position] = value + elif record.kind != KIND_SAME: + raise ValueError("frame %d has unknown kind %d" % (n, record.kind)) + decoded.append(bytes(state) if state is not None else None) + if any(frame is None for frame in decoded): + raise ValueError("the stream does not begin with a full frame") + return decoded + + +def write_cache(directory, tiles: Tiles, clip: str = "synthetic", + every: int = KEYFRAME_EVERY, seed: int = 0) -> dict: + """Write the four cache files and return the manifest that was written.""" + path = Path(directory) + path.mkdir(parents=True, exist_ok=True) + frames = len(tiles.indices) + if len(tiles.records) != frames or len(tiles.bit_errors) != frames: + raise ValueError("tiles has %d indices, %d records, %d error counts" + % (frames, len(tiles.records), len(tiles.bit_errors))) + + (path / DICTIONARY_FILE).write_bytes(tiles.dictionary) + (path / INDICES_FILE).write_bytes(b"".join(tiles.indices)) + (path / STREAM_FILE).write_bytes(_stream_bytes(tiles.records, frames)) + + payloads = [len(record.payload) for record in tiles.records] + full = [n for n, record in enumerate(tiles.records) + if record.kind == KIND_FULL] + deltas = [n for n, record in enumerate(tiles.records) + if record.kind == KIND_DELTA] + manifest = { + "version": CACHE_VERSION, + "clip": clip, + "frames": frames, + "screen_rows": SCREEN_ROWS, + "active_row_bytes": ACTIVE_ROW_BYTES, + "k": len(tiles.dictionary) // TILE_BYTES, + "seed": seed, + "keyframe_every": every, + "dictionary_bytes": len(tiles.dictionary), + "entries_used": len(set(b"".join(tiles.indices))), + "keyframes": full, + "delta_frames": deltas, + "unchanged_frames": [n for n, record in enumerate(tiles.records) + if record.kind == KIND_SAME], + "payload_bytes": payloads, + "changed_tiles": [record.changed for record in tiles.records], + "bit_errors": tiles.bit_errors, + "psnr_db": [psnr_db(bits) for bits in tiles.bit_errors], + "full_payload_bytes": sum(payloads[n] for n in full), + "delta_payload_bytes": sum(payloads[n] for n in deltas), + "total_payload_bytes": sum(payloads), + } + (path / MANIFEST_FILE).write_text(json.dumps(manifest, indent=2, + sort_keys=True) + "\n") + return manifest + + +def read_cache(directory) -> Cache: + """Read a cache back and check it is self-consistent. + + The sender and the replay both take a cache, so a stream that does not + decode to ``indices.bin`` is caught here -- once, on the host -- instead of + showing up as a wrong picture in MAME or on the terminal. + """ + path = Path(directory) + try: + dictionary = (path / DICTIONARY_FILE).read_bytes() + raw = (path / INDICES_FILE).read_bytes() + stream = (path / STREAM_FILE).read_bytes() + manifest = json.loads((path / MANIFEST_FILE).read_text()) + except OSError as exc: + raise ValueError("cannot read the tile cache in %s: %s" + % (directory, exc)) + if len(dictionary) % TILE_BYTES or not dictionary: + raise ValueError("dictionary.bin is %d bytes, not whole %d-byte tiles" + % (len(dictionary), TILE_BYTES)) + if len(raw) % TILES_PER_SCREEN: + raise ValueError("indices.bin is %d bytes, not whole %d-byte frames" + % (len(raw), TILES_PER_SCREEN)) + indices = [raw[i:i + TILES_PER_SCREEN] + for i in range(0, len(raw), TILES_PER_SCREEN)] + + if len(stream) < STREAM_HEADER.size: + raise ValueError("stream.bin is shorter than its %d-byte header" + % STREAM_HEADER.size) + magic, frames = STREAM_HEADER.unpack_from(stream, 0) + if magic != STREAM_MAGIC: + raise ValueError("stream.bin magic is %r, expected %r" + % (magic, STREAM_MAGIC)) + records = [] + offset = STREAM_HEADER.size + while offset < len(stream): + if offset + RECORD_HEADER.size > len(stream): + raise ValueError("stream.bin is truncated in a record header") + kind, frame_no, length = RECORD_HEADER.unpack_from(stream, offset) + offset += RECORD_HEADER.size + if offset + length > len(stream): + raise ValueError("stream.bin is truncated in frame %d's payload" + % frame_no) + records.append(Record(frame_no, kind, stream[offset:offset + length], + 0)) + offset += length + + decoded = _decode(records, frames) + if decoded != indices: + raise ValueError("stream.bin does not paint indices.bin (frame %d " + "differs)" % next(n for n, (a, b) + in enumerate(zip(decoded, indices)) + if a != b)) + if manifest.get("frames") != frames: + raise ValueError("manifest says %r frames, stream.bin says %d" + % (manifest.get("frames"), frames)) + if manifest.get("dictionary_bytes") != len(dictionary): + raise ValueError("manifest says %r dictionary bytes, the file is %d" + % (manifest.get("dictionary_bytes"), len(dictionary))) + # The changed-tile counts are informational in the stream file, so read + # them back from the manifest for the sender's reporting. + changed = manifest.get("changed_tiles") or [] + if len(changed) != frames: + raise ValueError("manifest lists %d changed-tile counts for %d frames" + % (len(changed), frames)) + records = [record._replace(changed=changed[n]) + for n, record in enumerate(records)] + return Cache(dictionary, indices, records, manifest) + + +# --- the CLI -------------------------------------------------------------- + +def parse_drops(text: str) -> dict[int, int]: + """Parse ``--drops 7=3,40=9``: frame -> cumulative drops reported then.""" + drops = {} + if not text: + return drops + for item in text.split(","): + try: + frame, count = item.split("=", 1) + drops[int(frame)] = int(count) + except ValueError: + raise argparse.ArgumentTypeError( + "expected frame=count[,frame=count], got %r" % text) + return drops + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--clip", metavar="DIR", + help="directory of raw 1-bpp *.bin frames (default: the " + "synthetic clip)") + ap.add_argument("--frames", type=int, default=30, + help="frames of the synthetic clip (default 30)") + ap.add_argument("--k", type=int, default=MAX_TILES, + help="dictionary entries (default %d, an index is a byte)" + % MAX_TILES) + ap.add_argument("--seed", type=int, default=0, + help="initial-centroid seed (default 0)") + ap.add_argument("--keyframe-every", type=int, default=KEYFRAME_EVERY, + help="full frame cadence (default %d = ~2 s at 30 fps)" + % KEYFRAME_EVERY) + ap.add_argument("--drops", type=parse_drops, default={}, + help="plan for a reported drop: frame=count[,frame=count]") + ap.add_argument("--out", default=DEFAULT_CACHE, + help="cache directory (default %(default)s)") + ap.add_argument("--quiet", action="store_true", + help="write the cache only; no per-frame CSV") + args = ap.parse_args(argv) + + try: + if args.clip: + frames = load_clip(args.clip) + clip = os.path.abspath(args.clip) + else: + frames = synthetic_clip(args.frames) + clip = "synthetic_clip" + tiles = build(frames, k=args.k, seed=args.seed, + every=args.keyframe_every, drops=args.drops) + manifest = write_cache(args.out, tiles, clip=clip, + every=args.keyframe_every, seed=args.seed) + except (OSError, ValueError) as exc: + print("make_tiles.py: %s" % exc, file=sys.stderr) + return 2 + + if not args.quiet: + print("frame,kind,payload_bytes,changed_tiles,bit_errors,psnr_db") + names = {KIND_FULL: "full", KIND_DELTA: "delta", KIND_SAME: "same"} + for record, bits in zip(tiles.records, tiles.bit_errors): + psnr = psnr_db(bits) + print("%d,%s,%d,%d,%d,%s" + % (record.frame_no, names[record.kind], len(record.payload), + record.changed, bits, + "inf" if psnr is None else "%.2f" % psnr)) + + frames_n = len(frames) + pixels = frames_n * SCREEN_ROWS * ROW_PIXELS + total_bits = sum(tiles.bit_errors) + keyframes = manifest["keyframes"] + deltas = manifest["delta_frames"] + payloads = manifest["payload_bytes"] + delta_payloads = [payloads[n] for n in deltas] or [0] + print("make_tiles: %s, %d frames, %d dictionary entries (%d used)" + % (clip, frames_n, args.k, manifest["entries_used"]), + file=sys.stderr) + print("make_tiles: %d keyframe(s) at %s, %d delta(s), %d unchanged, " + "cadence every %d" + % (len(keyframes), keyframes[:8], len(deltas), + len(manifest["unchanged_frames"]), args.keyframe_every), + file=sys.stderr) + print("make_tiles: payload %d B full, %d B mean delta (%d..%d), %d B total" + % (TILES_PER_SCREEN, sum(delta_payloads) // len(delta_payloads), + min(delta_payloads), max(delta_payloads), + manifest["total_payload_bytes"]), + file=sys.stderr) + print("make_tiles: reconstruction %d bit(s) over %d frame(s), BER %.6f, " + "PSNR %s" + % (total_bits, frames_n, ber(total_bits, pixels), + "inf" if total_bits == 0 else "%.2f dB" % psnr_db(total_bits, + pixels)), + file=sys.stderr) + print("make_tiles: cache %s (%d B dictionary, %d B indices, %d B stream)" + % (os.path.abspath(args.out), manifest["dictionary_bytes"], + frames_n * TILES_PER_SCREEN, + os.path.getsize(os.path.join(args.out, STREAM_FILE))), + file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/harness/udpstream/test-stream.sh b/harness/udpstream/test-stream.sh index d0c1850..77890d7 100755 --- a/harness/udpstream/test-stream.sh +++ b/harness/udpstream/test-stream.sh @@ -3,9 +3,11 @@ # report what it received, one CSV line per 30-frame block. # # sudo ./test-stream.sh [rows] [stretch] +# sudo ./test-stream.sh --tiles # # sudo ./test-stream.sh enp197s0f3u1 08:00:2b:16:4b:5a 128 8 # sudo ./test-stream.sh enp197s0f3u1 ff:ff:ff:ff:ff:ff 128 8 +# sudo ./test-stream.sh enp197s0f3u1 08:00:2b:16:4b:5a --tiles # # is the host NIC the terminal is plugged into (a dedicated port or a # hub -- the terminal answers with broadcast status frames on the same wire), @@ -15,6 +17,14 @@ # screen, ruling R3); rows*stretch must be 1024 or the encoder refuses, and rows # may not exceed 144 (the terminal's 16-datagram ring). # +# --tiles sends the tile stream instead: the 32 KB dictionary in 23 chunks, +# then one TILES_FULL/TILES_DELTA per clip frame from the cache +# make_tiles.py wrote (TILES_DIR, default harness/udpstream/tiles). The clip +# loops when FRAMES is longer than the cache; the keyframe cadence keeps +# counting across the loop, and a status frame that reports drops forces a full +# frame at the next opportunity. FRAMES defaults to 300 here and 6300 is the +# plan's 3.5-minute soak. +# # =========================================================================== # BEFORE ANY OF THIS: THE PAYLOAD MUST ALREADY BE RUNNING ON THE TERMINAL. # =========================================================================== @@ -110,6 +120,11 @@ # what the unit costs per destination word # 3. a 6,300-frame soak (~3.5 min at 30 fps): FRAMES=6300 # +# and, for the tile stream (plan 2, Task 6): +# +# 4. sudo ./test-stream.sh 08:00:2b:16:4b:5a --tiles +# 5. FRAMES=6300 sudo ./test-stream.sh 08:00:2b:16:4b:5a --tiles +# # These need the physical terminal (ruling R4, R16) and have NOT been run. set -eu @@ -120,15 +135,28 @@ IFACE=${1:-} MAC=${2:-} ROWS=${3:-128} STRETCH=${4:-8} +TILES=0 +if [ "${3:-}" = "--tiles" ]; then + TILES=1 + ROWS=128 + STRETCH=8 +fi +TILES_DIR=${TILES_DIR:-$HERE/tiles} FRAMES=${FRAMES:-300} FPS=${FPS:-30} REPORT=${REPORT:-30} if [ -z "$IFACE" ] || [ -z "$MAC" ]; then echo "usage: sudo $0 [rows] [stretch]" >&2 + echo " sudo $0 --tiles" >&2 echo " e.g. sudo $0 enp197s0f3u1 08:00:2b:16:4b:5a 128 8" >&2 exit 2 fi +if [ "$TILES" = 1 ] && [ ! -f "$TILES_DIR/stream.bin" ]; then + echo "test-stream.sh: no tile cache at $TILES_DIR (build it first:" >&2 + echo " python3 harness/udpstream/make_tiles.py --out $TILES_DIR)" >&2 + exit 3 +fi # Fail loudly rather than send nothing and call it a run. if [ ! -f "$SENDER" ]; then @@ -151,7 +179,13 @@ esac OUT=${OUT:-/tmp/opencode/test-stream.log} mkdir -p "$(dirname "$OUT")" -echo "test-stream.sh: $IFACE -> $MAC, $ROWS rows x $STRETCH, $FRAMES frames at $FPS fps" >&2 +if [ "$TILES" = 1 ]; then + echo "test-stream.sh: $IFACE -> $MAC, tile stream from $TILES_DIR," \ + "$FRAMES frames at $FPS fps" >&2 +else + echo "test-stream.sh: $IFACE -> $MAC, $ROWS rows x $STRETCH," \ + "$FRAMES frames at $FPS fps" >&2 +fi echo "test-stream.sh: capture $OUT" >&2 # --------------------------------------------------------------------------- @@ -165,9 +199,15 @@ PROBE=${PROBE:-70} PROBE_LOG=$OUT.preflight echo "test-stream.sh: preflight: $PROBE frames, looking for a status frame" >&2 set +e -python3 "$SENDER" --iface "$IFACE" --dst "$MAC" --rows "$ROWS" \ - --stretch "$STRETCH" --fps "$FPS" --frames "$PROBE" \ - --report-every 0 --status-wait 5 > "$PROBE_LOG" 2>&1 +if [ "$TILES" = 1 ]; then + python3 "$SENDER" --iface "$IFACE" --dst "$MAC" --tiles \ + --tiles-dir "$TILES_DIR" --fps "$FPS" --frames "$PROBE" \ + --report-every 0 --status-wait 5 > "$PROBE_LOG" 2>&1 +else + python3 "$SENDER" --iface "$IFACE" --dst "$MAC" --rows "$ROWS" \ + --stretch "$STRETCH" --fps "$FPS" --frames "$PROBE" \ + --report-every 0 --status-wait 5 > "$PROBE_LOG" 2>&1 +fi PROBE_STATUS=$? set -e if ! grep -q '^status ' "$PROBE_LOG"; then @@ -192,9 +232,15 @@ fi echo "test-stream.sh: preflight: the payload is running" >&2 set +e -python3 "$SENDER" --iface "$IFACE" --dst "$MAC" --rows "$ROWS" \ - --stretch "$STRETCH" --fps "$FPS" --frames "$FRAMES" \ - --report-every 1 > "$OUT" 2>&1 +if [ "$TILES" = 1 ]; then + python3 "$SENDER" --iface "$IFACE" --dst "$MAC" --tiles \ + --tiles-dir "$TILES_DIR" --fps "$FPS" --frames "$FRAMES" \ + --report-every 1 > "$OUT" 2>&1 +else + python3 "$SENDER" --iface "$IFACE" --dst "$MAC" --rows "$ROWS" \ + --stretch "$STRETCH" --fps "$FPS" --frames "$FRAMES" \ + --report-every 1 > "$OUT" 2>&1 +fi STATUS=$? set -e if [ "$STATUS" -ne 0 ] && [ ! -s "$OUT" ]; then @@ -219,7 +265,12 @@ import re, sys # probe + run, so every gap and every interval delta is measured between the # same two events, which is all the pairing needs. path, every, sent0 = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]) -row = re.compile(r"^(\d+),([\d.]+),([\d.]+),(\d+)$") +# The tile CSV's first column is the FRAME NUMBER (from 0), not a count: the +# row describes the frame whose number it carries. The strip CSV's is the +# count sent. One frame apart, and both columns have to line up with +# frames_rx, which counts accepted frames from 1. +tiles = len(sys.argv) > 4 and sys.argv[4] == "1" +row = re.compile(r"^(\d+),([\d.]+),([\d.]+),(\d+)(?:,|$)") status = re.compile( r"^status (\d+): frame_no=(\d+) frames_rx=(\d+) crc_err=(\d+) " r"drops=(\d+) csr0=0x([0-9A-Fa-f]+) heap_free_kb=(\d+)$") @@ -240,7 +291,7 @@ with open(path, "r", encoding="utf-8", errors="replace") as f: m = row.match(line) if m: n = int(m.group(1)) - sent = n + sent0 + sent = n + sent0 + (1 if tiles else 0) # The 30-frame cadence is the SENDER's frame number, not the # cumulative one: with a probe offset the cumulative value is never # a multiple of 30, and no row would ever print. diff --git a/harness/udpstream/tests/test_make_tiles.py b/harness/udpstream/tests/test_make_tiles.py new file mode 100644 index 0000000..b34bacf --- /dev/null +++ b/harness/udpstream/tests/test_make_tiles.py @@ -0,0 +1,293 @@ +"""Tests for the clip -> tile dictionary -> cached stream pipeline. + +Task 6's acceptance is "a 30-frame synthetic clip quantised and replayed in +MAME must reproduce each frame's rendered bitmap within the dictionary's error +bound". The MAME half is ``harness/mame/tile_check.py --replay``; this file +pins the host half: the clip generator is deterministic, the plan is Task 5's +cadence rule, the cache round-trips byte for byte, and the render the terminal +is asked to paint differs from the source only by what the dictionary cannot +express. + +The error is a *bound*, not a threshold dressed up as one. For every tile the +plan stores the nearest dictionary entry under Hamming distance; that distance +is exactly the number of pixels the screen will differ by, and it is the +minimum any encoder using this dictionary could achieve. One test checks the +encoder against a brute-force search, so the reported error is a property of +the dictionary and the clip rather than of a lucky sample; another bounds the +whole clip's bit-error rate, which is what a regression would move. +""" +import json +from pathlib import Path + +import pytest + +from harness.udpstream import make_tiles +from harness.udpstream.tilecost import (MAX_TILES, TILE_BYTES, TILE_COLS, + TILES_PER_SCREEN, _frame_tiles) +from harness.udpstream.tiledict import KEYFRAME_EVERY, unpack_delta + +#: The repo root, so the committed cache can be found from anywhere. +REPO = Path(__file__).resolve().parents[3] + +#: Where the demo's cache lives (``make_tiles.py``'s default output). +CACHE = REPO / "harness" / "udpstream" / "tiles" + +#: The bit-error-rate the synthetic clip must stay under. It is an acceptance +#: bound, not a measurement: the measured value is ~0.1% and is reported by +#: the CLI and NETWORK_REPORT.md, so this only has to catch a regression that +#: makes the dictionary meaningfully worse. +MAX_BER = 0.02 + + +@pytest.fixture(scope="module") +def clip30(): + """The 30-frame synthetic clip, built once for the whole module.""" + return make_tiles.synthetic_clip(30) + + +@pytest.fixture(scope="module") +def tiles30(clip30): + """The dictionary, quantised indices and plan for that clip.""" + return make_tiles.build(clip30) + + +# --- the clip ------------------------------------------------------------- + +def test_synthetic_clip_is_deterministic_and_full_screen(clip30): + assert clip30 == make_tiles.synthetic_clip(30) + assert len(clip30) == 30 + assert all(len(f) == 1024 * 160 for f in clip30) + + +def test_synthetic_clip_moves(clip30): + assert len(set(clip30)) == len(clip30), "every frame must differ" + # and the difference is real motion, not a one-pixel flicker + first, second = clip30[0], clip30[1] + differing = sum((a ^ b).bit_count() for a, b in zip(first, second)) + assert differing > 400 + changed = sum(1 for a, b in zip(_frame_tiles(first), _frame_tiles(second)) + if a != b) + assert changed >= 5, "a delta needs changed tiles to carry" + + +def test_synthetic_clip_is_richer_than_the_dictionary(clip30): + """k = 256 must actually be a constraint, or the PSNR says nothing.""" + tiles = set() + for frame in clip30: + tiles |= set(_frame_tiles(frame)) + assert len(tiles) > MAX_TILES + + +# --- the plan ------------------------------------------------------------- + +def test_build_shapes(clip30, tiles30): + assert len(tiles30.dictionary) == MAX_TILES * TILE_BYTES + assert len(tiles30.indices) == len(clip30) + assert all(len(i) == TILES_PER_SCREEN for i in tiles30.indices) + assert len(tiles30.records) == len(clip30) + + +def test_plan_starts_with_a_full_frame_and_keeps_the_cadence(tiles30): + records = tiles30.records + assert records[0].kind == make_tiles.KIND_FULL + assert records[0].payload == tiles30.indices[0] + assert len(records[0].payload) == TILES_PER_SCREEN + + # A 30-frame clip is shorter than the 60-frame cadence, so frame 0 is the + # only full frame; the rule itself is exercised below with a shorter + # interval and again in the drop test. + assert [r.frame_no for r in records if r.kind == make_tiles.KIND_FULL] == [0] + + fast = make_tiles.plan(tiles30.indices, every=10) + assert [r.frame_no for r in fast if r.kind == make_tiles.KIND_FULL] == \ + [0, 10, 20] + + +def test_delta_payloads_carry_exactly_the_changed_tiles(tiles30): + previous = None + for record, frame in zip(tiles30.records, tiles30.indices): + if previous is not None: + changed = sum(a != b for a, b in zip(previous, frame)) + if record.kind == make_tiles.KIND_SAME: + assert changed == 0 + assert record.payload == b"" + else: + assert record.kind == make_tiles.KIND_DELTA + assert record.changed == changed + assert len(record.payload) == 2 + 3 * changed + assert changed > 0 + # positions are strictly ascending and name the changed tiles + pairs = unpack_delta(record.payload) + assert [p for p, _ in pairs] == \ + [i for i in range(TILES_PER_SCREEN) if previous[i] != frame[i]] + assert bytes(v for _, v in pairs) == bytes( + frame[i] for i, _ in pairs) + previous = frame + + +def test_the_stream_decodes_back_to_the_quantiser_output(tiles30): + """The cache's plan, read through the receiver's own decoder, is lossless. + + This is the property the MAME replay checks on the terminal: the stream + alone (not the indices file) determines the picture. + """ + state = None + for record, expected in zip(tiles30.records, tiles30.indices): + if record.kind == make_tiles.KIND_FULL: + state = bytearray(record.payload) + elif record.kind == make_tiles.KIND_DELTA: + assert state is not None + for position, value in unpack_delta(record.payload): + state[position] = value + elif record.kind != make_tiles.KIND_SAME: + raise AssertionError("unexpected kind %r" % (record.kind,)) + assert bytes(state) == expected + + +def test_a_drop_forces_a_keyframe_at_the_next_frame(tiles30): + records = make_tiles.plan(tiles30.indices, drops={7: 3}) + kinds = {r.frame_no: r.kind for r in records} + assert kinds[7] == make_tiles.KIND_FULL + assert kinds[1] == make_tiles.KIND_DELTA + assert kinds[8] == make_tiles.KIND_DELTA + # and the cumulative counter is consumed once, not on every later frame + assert [n for n, k in kinds.items() if k == make_tiles.KIND_FULL] == [0, 7] + + +def test_an_unchanged_frame_is_not_sent(): + black = bytes(TILES_PER_SCREEN) + records = make_tiles.plan([black, black, black], every=60) + assert [r.kind for r in records] == \ + [make_tiles.KIND_FULL, make_tiles.KIND_SAME, make_tiles.KIND_SAME] + assert [len(r.payload) for r in records] == [TILES_PER_SCREEN, 0, 0] + + +# --- the error bound ------------------------------------------------------ + +def test_encoder_attains_the_dictionary_minimum(tiles30, clip30): + """Every stored index is the nearest entry, checked by brute force. + + ``quantise`` caches the answer per distinct tile, so a wrong cache would + pass a self-consistency check; this compares a sample of tiles against an + independent search over all 256 entries. + """ + dictionary = tiles30.dictionary + entries = [dictionary[i * TILE_BYTES:(i + 1) * TILE_BYTES] + for i in range(MAX_TILES)] + for frame_no in (0, 7, 29): + tiles = _frame_tiles(clip30[frame_no]) + indices = tiles30.indices[frame_no] + for position in range(0, len(tiles), 97): + tile = tiles[position].to_bytes(TILE_BYTES, "big") + got = indices[position] + want_distance = min(sum((a ^ b).bit_count() + for a, b in zip(tile, entry)) + for entry in entries) + got_distance = sum((a ^ b).bit_count() + for a, b in zip(tile, entries[got])) + assert got_distance == want_distance, ( + "tile %d of frame %d is %d bits from its entry, but entry %d " + "is %d bits away" % (position, frame_no, got_distance, + got, want_distance)) + + +def test_reconstruction_is_within_the_dictionary_bound(tiles30, clip30): + assert len(tiles30.bit_errors) == len(clip30) + pixels = 1024 * 1280 + for frame_no, (bits, frame) in enumerate(zip(tiles30.bit_errors, clip30)): + # bit_errors is the sum of the per-tile distances, so it is exactly + # the number of pixels that will differ. + assert bits == sum(make_tiles.tile_errors( + frame, tiles30.indices[frame_no], tiles30.dictionary)) + ber = bits / pixels + assert ber <= MAX_BER, (frame_no, ber) + assert make_tiles.psnr_db(bits, pixels) == pytest.approx( + -10.0 * __import__("math").log10(ber)) + + +def test_a_perfect_dictionary_reports_no_error(): + assert make_tiles.psnr_db(0, 1024 * 1280) is None + assert make_tiles.ber(0, 1024 * 1280) == 0.0 + + +def test_render_follows_the_tile_geometry(tiles30): + screen = make_tiles.render(tiles30.indices[0], tiles30.dictionary) + assert len(screen) == 1024 * 160 + for tx, ty in ((0, 0), (TILE_COLS - 1, 31), (17, 9)): + entry = tiles30.indices[0][ty * TILE_COLS + tx] + tile = tiles30.dictionary[entry * TILE_BYTES:(entry + 1) * TILE_BYTES] + for row in (0, 13, 31): + offset = (ty * 32 + row) * 160 + tx * 4 + assert screen[offset:offset + 4] == \ + tile[row * 4:(row + 1) * 4] + + +def test_rendered_frames_differ_from_the_source_only_by_the_error(tiles30, + clip30): + """The full-screen render is the source with exactly the error bits flipped.""" + for frame_no in (0, 15, 29): + screen = make_tiles.render(tiles30.indices[frame_no], + tiles30.dictionary) + source = clip30[frame_no] + differing = 0 + for row in range(1024): + left = row * 160 + right = left + 160 + differing += sum((a ^ b).bit_count() + for a, b in zip(source[left:right], + screen[left:right])) + assert differing == tiles30.bit_errors[frame_no] + + +# --- the cache ------------------------------------------------------------ + +def test_cache_round_trips(tiles30, tmp_path): + make_tiles.write_cache(tmp_path, tiles30, clip="synthetic", + every=KEYFRAME_EVERY, seed=0) + cache = make_tiles.read_cache(tmp_path) + assert cache.dictionary == tiles30.dictionary + assert cache.indices == tiles30.indices + assert cache.records == tiles30.records + assert cache.manifest["frames"] == len(tiles30.indices) + assert cache.manifest["dictionary_bytes"] == len(tiles30.dictionary) + assert cache.manifest["keyframes"] == [0] + assert (tmp_path / "stream.bin").read_bytes().startswith( + make_tiles.STREAM_MAGIC) + + +def test_read_cache_rejects_a_stream_that_does_not_match_the_indices(tiles30, + tmp_path): + make_tiles.write_cache(tmp_path, tiles30, clip="synthetic", + every=KEYFRAME_EVERY, seed=0) + stream = bytearray((tmp_path / "stream.bin").read_bytes()) + # Flip one index byte of the first full frame: the stream now paints a + # different picture than indices.bin says. + stream[10 + 4 + 5] ^= 0xFF + (tmp_path / "stream.bin").write_bytes(stream) + with pytest.raises(ValueError): + make_tiles.read_cache(tmp_path) + + +def test_committed_cache_matches_the_generator(): + """The demo's cache is the generator's output, not a stale leftover.""" + cache = make_tiles.read_cache(CACHE) + tiles = make_tiles.build(make_tiles.synthetic_clip(30)) + assert cache.dictionary == tiles.dictionary + assert cache.indices == tiles.indices + assert cache.records == tiles.records + assert json.loads((CACHE / "manifest.json").read_text()) == cache.manifest + + +def test_load_clip_reads_sorted_raw_frames(tmp_path): + frames = [bytes(32 * 160) for _ in range(3)] + for name, frame in zip(("b.bin", "a.bin", "c.bin"), + (frames[1], frames[0], frames[2])): + (tmp_path / name).write_bytes(frame) + (tmp_path / "notes.txt").write_text("ignored") + assert make_tiles.load_clip(tmp_path) == frames + + +def test_load_clip_rejects_a_partial_tile_band(tmp_path): + (tmp_path / "a.bin").write_bytes(bytes(16 * 160)) # 16 rows, not 32 + with pytest.raises(ValueError): + make_tiles.load_clip(tmp_path) diff --git a/harness/udpstream/tests/test_tile_replay_check.py b/harness/udpstream/tests/test_tile_replay_check.py new file mode 100644 index 0000000..14d258c --- /dev/null +++ b/harness/udpstream/tests/test_tile_replay_check.py @@ -0,0 +1,148 @@ +"""Tests for ``tile_check.py --replay``: the MAME replay's pixel verdict. + +The replay mode in ``harness/mame/vidstream.lua`` paints a stream produced by +``make_tiles.py`` and dumps the screen after every clip frame. This checker +decodes ``stream.bin`` itself -- full frames and deltas, with no code shared +with the encoder -- renders each frame through the dictionary, and compares it +with the dump. + +The tests here build a replay directory in the *host's* format (the same files +the MAME run writes) and check that the checker passes a good replay and fails +each kind of bad one. They are the negative controls for the MAME step: a +checker that cannot fail would make the replay evidence worthless. +""" +from pathlib import Path + +import pytest + +from harness.mame import tile_check +from harness.udpstream import make_tiles + +#: Two frames is enough to have one keyframe and one delta. +CLIP_FRAMES = 2 + + +@pytest.fixture(scope="module") +def tiles(): + return make_tiles.build(make_tiles.synthetic_clip(CLIP_FRAMES)) + + +def write_replay(directory: Path, tiles, status_overrides=None): + """Write the four things a replay run leaves behind.""" + make_tiles.write_cache(directory, tiles, clip="synthetic", every=60, + seed=0) + for frame_no, indices in enumerate(tiles.indices): + (directory / ("vram-replay-%d.bin" % frame_no)).write_bytes( + make_tiles.render(indices, tiles.dictionary)) + wire = sum(1 for r in tiles.records if r.kind != make_tiles.KIND_SAME) + status = { + "FRAMES": len(tiles.indices), + "WIRE": wire, + "RX_OK": wire, + "RX_BAD": 0, + "TILES_DROP": 0, + "DICT_READY": 1, + "DICT_BYTES": len(tiles.dictionary), + "DICT_MATCHED": 1, + "SENTINEL": 0xA5C3, + "OS_TOUCH": 0, + "TIMEOUT": 0, + } + status.update(status_overrides or {}) + (directory / "replay-status.txt").write_text( + "".join("%s=%s\n" % (k, v) for k, v in status.items())) + return directory + + +def run(directory: Path, capsys): + code = tile_check.main(["--replay", str(directory)]) + return code, capsys.readouterr().out + + +def test_a_correct_replay_passes(tmp_path, tiles, capsys): + write_replay(tmp_path, tiles) + code, out = run(tmp_path, capsys) + assert code == 0, out + assert "replay: %d/%d frames match" % (CLIP_FRAMES, CLIP_FRAMES) in out + + +def test_one_wrong_pixel_fails_and_names_the_frame(tmp_path, tiles, capsys): + write_replay(tmp_path, tiles) + dump = tmp_path / "vram-replay-1.bin" + data = bytearray(dump.read_bytes()) + data[5 * 160 + 2] ^= 0x01 # one bit, inside tile row 5, tile 0 + dump.write_bytes(data) + code, out = run(tmp_path, capsys) + assert code == 1 + assert "frame 1" in out + + +def test_a_stream_that_paints_something_else_fails(tmp_path, tiles, capsys): + """The checker must read the stream, not bless indices.bin.""" + write_replay(tmp_path, tiles) + stream = bytearray((tmp_path / "stream.bin").read_bytes()) + # The last record's payload: flip the last value byte of the delta. + stream[-1] ^= 0x7F + (tmp_path / "stream.bin").write_bytes(stream) + code, out = run(tmp_path, capsys) + assert code == 1 + assert "frame 1" in out + + +def test_a_missing_dump_fails(tmp_path, tiles, capsys): + write_replay(tmp_path, tiles) + (tmp_path / "vram-replay-1.bin").unlink() + code, out = run(tmp_path, capsys) + assert code == 1 + assert "frame 1" in out + + +@pytest.mark.parametrize("key,value", [("TILES_DROP", 1), ("RX_BAD", 1), + ("TIMEOUT", 1), ("DICT_READY", 0), + ("OS_TOUCH", 1)]) +def test_a_bad_status_fails(tmp_path, tiles, capsys, key, value): + write_replay(tmp_path, tiles, {key: value}) + code, out = run(tmp_path, capsys) + assert code == 1 + assert key in out + + +def test_the_clip_comparison_measures_the_replayed_error(tmp_path, tiles, + capsys): + """``--clip`` reports BER/PSNR of the *dumped* screens, not the render. + + The screens equal the render (the check above), and the render differs + from the source by exactly the dictionary's error, so the measurement and + the encoder's own count have to agree bit for bit. + """ + frames = make_tiles.synthetic_clip(CLIP_FRAMES) + clip = tmp_path / "clip" + clip.mkdir() + for n, frame in enumerate(frames): + (clip / ("frame-%04d.bin" % n)).write_bytes(frame) + write_replay(tmp_path, tiles) + + code = tile_check.main(["--replay", str(tmp_path), "--clip", str(clip)]) + out = capsys.readouterr().out + assert code == 0, out + expected = sum( + sum(make_tiles.tile_errors(frame, indices, tiles.dictionary)) + for frame, indices in zip(frames, tiles.indices)) + assert "reconstruction: %d bit(s)" % expected in out + assert "PSNR" in out and "BER" in out + + +def test_a_clip_that_is_not_the_source_shows_up_only_in_the_measurement( + tmp_path, tiles, capsys): + """A wrong clip changes the reported error, not the pixel verdict.""" + frames = [bytes(1024 * 160) for _ in range(CLIP_FRAMES)] + clip = tmp_path / "clip" + clip.mkdir() + for n, frame in enumerate(frames): + (clip / ("frame-%04d.bin" % n)).write_bytes(frame) + write_replay(tmp_path, tiles) + + code = tile_check.main(["--replay", str(tmp_path), "--clip", str(clip)]) + out = capsys.readouterr().out + assert code == 0, out # the screen still equals the render + assert "reconstruction: 0 bit(s)" not in out diff --git a/harness/udpstream/tests/test_vidsend.py b/harness/udpstream/tests/test_vidsend.py index 703c7d1..e830975 100644 --- a/harness/udpstream/tests/test_vidsend.py +++ b/harness/udpstream/tests/test_vidsend.py @@ -25,10 +25,11 @@ import struct import pytest -from harness.udpstream import vidsend +from harness.udpstream import make_tiles, vidsend from harness.udpstream.frame import (BROADCAST, ETHERTYPE, HEADER_SIZE, - KIND_STATUS, TERMINAL_MAC, ethernet_frame, - pack_header, pack_status, unpack_header) + KIND_DICT_CHUNK, KIND_STATUS, TERMINAL_MAC, + ethernet_frame, pack_header, pack_status, + unpack_header) from harness.udpstream.vidsrc import CHUNK, ROW_BYTES, StripSource #: The host's own address. Locally administered, so it cannot be mistaken for @@ -383,3 +384,151 @@ def test_a_terminal_reporting_drops_makes_the_verdict_fail(): assert sender.run() == 1 assert "drops=1" in err.getvalue() assert "FAIL" in err.getvalue() + + +# --- tile mode: the cache is the stream ------------------------------------ +# +# ``--tiles`` sends what ``make_tiles.py`` cached: the 32 KB dictionary in +# chunks, then one frame per clip frame -- a TILES_FULL keyframe on the cadence +# (and whenever the terminal's cumulative ``drops`` rises), a TILES_DELTA +# otherwise. Nothing here touches a socket; the counterparty is the transport +# callable, exactly as for the strip tests above. + +@pytest.fixture(scope="module") +def tile_cache(tmp_path_factory): + """A three-frame cache: enough for a keyframe and two deltas.""" + directory = tmp_path_factory.mktemp("tiles") + tiles = make_tiles.build(make_tiles.synthetic_clip(3)) + make_tiles.write_cache(directory, tiles, clip="synthetic", every=60, + seed=0) + return make_tiles.read_cache(directory) + + +def make_tile_sender(cache, clock, transport, **kw): + kw.setdefault("fps", 0.0) + kw.setdefault("status_wait", 0.0) + kw.setdefault("out", io.StringIO()) + kw.setdefault("err", io.StringIO()) + kw.setdefault("src", HOST_MAC) + return vidsend.TileSender(cache=cache, transport=transport, + clock=clock.now, sleep=clock.sleep, **kw) + + +def tile_frames(datagrams): + """The VS frames among ``datagrams``, reassembled (dictionary first).""" + return [d[14:] for d in datagrams] + + +def test_tile_mode_uploads_the_dictionary_before_the_first_frame(tile_cache): + clock = FakeClock() + transport = RecordingTransport(clock) + sender = make_tile_sender(tile_cache, clock, transport, frames=1) + sender.run() + chunks = [] + for data in transport.datagrams: + body = data[14:] + h = unpack_header(body) + if h["kind"] != KIND_DICT_CHUNK: + break + chunks.append(body) + assert len(chunks) == 23, "32 KB at 1,448 body bytes is 23 chunks" + offsets = [struct.unpack_from("= 0 (0 = unpaced): %r" % (fps,)) + if not cache.indices: + raise ValueError("the tile cache has no frames") + self.cache = cache + self.transport = transport + self.receive = receive + self.src = src + self.dst = dst + self.fps = float(fps) + self.frames = frames + self.chunk = chunk + self.every = every + self.clock = clock + self.sleep = sleep + self.report_every = report_every + self.status_wait = status_wait + self.out = out if out is not None else sys.stdout + self.err = err if err is not None else sys.stderr + + self.t0 = 0.0 + self.frames_done = 0 # clip frames the schedule has produced + self.sent_frames = 0 # frames actually put on the wire + self.full_frames = 0 + self.delta_frames = 0 + self.same_frames = 0 + self.sent_datagrams = 0 + self.sent_bytes = 0 + self.dictionary_sent = 0 # datagrams the dictionary upload took + self.keyframes = [] # the clip frame numbers sent as TILES_FULL + self.previous = None # the last index frame the terminal has + self.drops = 0 # the terminal's cumulative drops, latest + self.seen_drops = 0 # the value the last decision consumed + self.statuses = [] + self.bad_status = 0 + self.interrupted = False + + # -- output -------------------------------------------------------- + def say(self, text): + print(text, file=self.out, flush=True) + + def log(self, text): + print(text, file=self.err, flush=True) + + # -- the wire ------------------------------------------------------ + def dictionary_datagrams(self) -> list[bytes]: + """The 32 KB dictionary as Ethernet frames, offsets ascending. + + A chunk's own framing is the protocol's (``tiledict.MAX_PAYLOAD``), not + this sender's ``--chunk``: the two are different sizes for different + reasons. Every chunk is one datagram, so each is padded to the + Ethernet minimum on its own -- none is ever in the middle of a frame. + """ + return [ethernet_frame(packet, src=self.src, dst=self.dst, pad=True) + for packet in dict_chunks(self.cache.dictionary)] + + def frame_plan(self, n: int): + """``(kind, payload, changed, keyframe)`` for clip frame ``n``. + + ``keyframe`` is the decision, ``kind`` the consequence: a full frame is + always a keyframe, a delta is never one, and SAME (nothing changed + since the previous frame in the loop) carries no payload at all. + """ + current = self.cache.indices[n % len(self.cache.indices)] + changed = 0 if self.previous is None else sum( + 1 for a, b in zip(self.previous, current) if a != b) + if keyframe_due(n, self.drops, self.seen_drops, self.every): + return make_tiles.KIND_FULL, bytes(current), changed, True + payload = delta_frame(self.previous, current) + if payload is None: + return make_tiles.KIND_SAME, b"", 0, False + return make_tiles.KIND_DELTA, payload, changed, False + + def datagrams(self, n: int, kind: int, payload: bytes) -> list[bytes]: + """``payload`` as one VS frame, chunked and wrapped for the transport.""" + frame = pack_header(kind, n & 0xFFFF, len(payload), 0, 0, + payload=payload) + payload + chunks = [frame[i:i + self.chunk] + for i in range(0, len(frame), self.chunk)] + return [ethernet_frame(chunk, src=self.src, dst=self.dst, + pad=(i == len(chunks) - 1)) + for i, chunk in enumerate(chunks)] + + def send_frame(self, n: int, kind: int, payload: bytes): + for data in self.datagrams(n, kind, payload): + self.transport(data) + self.sent_datagrams += 1 + self.sent_bytes += len(data) + self.sent_frames += 1 + + def send_dictionary(self): + for data in self.dictionary_datagrams(): + self.transport(data) + self.sent_datagrams += 1 + self.sent_bytes += len(data) + self.dictionary_sent += 1 + + # -- status -------------------------------------------------------- + def poll(self): + if self.receive is None: + return + for data in (self.receive() or [])[:64]: + st = decode_status_frame(data) + if st is None: + continue + if not st["crc_ok"]: + self.bad_status += 1 + self.log("status: header CRC bad, ignored (frames_rx=%d)" + % st["frames_rx"]) + continue + self.statuses.append(st) + self.drops = max(self.drops, st["drops"]) + self.log("status %d: frame_no=%d frames_rx=%d crc_err=%d drops=%d " + "csr0=0x%04X heap_free_kb=%d" + % (len(self.statuses), st["frame_no"], st["frames_rx"], + st["crc_err"], st["drops"], st["csr0"], + st["heap_free_kb"])) + + # -- the run ------------------------------------------------------- + def run(self, judge=True) -> int: + self.t0 = self.clock() + self.send_dictionary() + if self.report_every: + self.say(CSV_HEADER_TILES) + n = 0 + try: + while self.frames is None or n < self.frames: + self.wait_for(n) + # The status is read *before* the decision: a drop reported + # now is a drop this frame can repair. + self.poll() + kind, payload, changed, keyframe = self.frame_plan(n) + self.seen_drops = self.drops + if kind == make_tiles.KIND_SAME: + self.same_frames += 1 + else: + self.send_frame(n, kind, payload) + if kind == make_tiles.KIND_FULL: + self.full_frames += 1 + self.keyframes.append(n) + else: + self.delta_frames += 1 + self.previous = self.cache.indices[n % len(self.cache.indices)] + self.frames_done += 1 + n += 1 + if self.report_every and n % self.report_every == 0: + # The row names the frame it describes (0-based, the same + # number that went in the wire header), not the count. + self.report(n - 1, kind, payload, changed, keyframe) + except KeyboardInterrupt: + self.interrupted = True + self.log("interrupted after %d frames" % n) + if judge: + self.drain() + return self.verdict() + return 0 + + def wait_for(self, n: int): + """Sleep until ``t0 + n/fps``; the first frame does not sleep.""" + if self.fps <= 0: + return + delta = self.t0 + n / self.fps - self.clock() + if delta > 0: + self.sleep(delta) + + def report(self, n: int, kind: int, payload: bytes, changed: int, + keyframe: bool): + elapsed = self.clock() - self.t0 + rate = (n + 1) / elapsed if elapsed > 0 else 0.0 + names = {make_tiles.KIND_FULL: "full", make_tiles.KIND_DELTA: "delta", + make_tiles.KIND_SAME: "same"} + self.say("%d,%.3f,%.2f,%d,%s,%d,%d,%d" + % (n, elapsed, rate, self.sent_bytes, names[kind], + len(payload), changed, 1 if keyframe else 0)) + + def drain(self, limit=None): + if self.receive is None or self.status_wait <= 0: + return + deadline = self.clock() + (self.status_wait if limit is None else limit) + while self.clock() < deadline: + self.poll() + if (self.statuses and self.frames is not None + and self.statuses[-1]["frames_rx"] >= self.sent_frames): + return + self.sleep(0.02) + + def verdict(self) -> int: + elapsed = self.clock() - self.t0 + self.log("sent %d frame(s) in %d datagrams, %d bytes, %.1f fps " + "(%d full, %d delta, %d unchanged, %d dictionary datagrams)" + % (self.sent_frames, self.sent_datagrams, self.sent_bytes, + (self.sent_frames / elapsed) if elapsed > 0 else 0.0, + self.full_frames, self.delta_frames, self.same_frames, + self.dictionary_sent)) + if self.frames is not None and not self.interrupted \ + and self.frames_done < self.frames: + self.log("FAIL: stopped after %d of %d frame(s)" + % (self.frames_done, self.frames)) + return 1 + if not self.statuses: + self.log("FAIL: no status frame received (the terminal said " + "nothing, so drops and crc_err are unknown)") + return 1 + last = self.statuses[-1] + if self.bad_status: + self.log("FAIL: %d status frame(s) had a bad header CRC" + % self.bad_status) + return 1 + if last["drops"] or last["crc_err"]: + self.log("FAIL: terminal reports drops=%d crc_err=%d" + % (last["drops"], last["crc_err"])) + return 1 + lag = (self.sent_frames - last["frames_rx"] + if self.frames is not None else 0) + self.log("PASS: %d status frames, last frames_rx=%d " + "(drops=0 crc_err=0), %d frame(s) sent since that report" + % (len(self.statuses), last["frames_rx"], max(lag, 0))) + return 0 + + # --- transports ----------------------------------------------------------- def parse_mac(text: str) -> bytes: @@ -414,43 +683,83 @@ def main(argv=None) -> int: "LANCE accepts broadcast without a filter entry)") ap.add_argument("--src", type=parse_mac, default=None, help="source MAC (default: the interface's own)") - ap.add_argument("--report-every", type=int, default=REPORT_EVERY, - help="CSV line every N frames (default %d)" + ap.add_argument("--report-every", type=int, default=None, + help="CSV line every N frames (default %d, or 1 with " + "--tiles: the plan asks for one row per frame)" % REPORT_EVERY) ap.add_argument("--status-wait", type=float, default=1.0, help="seconds to keep listening for a status after the " "last frame (default 1.0)") ap.add_argument("--dry-run", action="store_true", help="run the schedule against a counter, no socket") + ap.add_argument("--tiles", action="store_true", + help="send the cached tile stream (dictionary + full/delta " + "frames) instead of strip frames") + ap.add_argument("--tiles-dir", default=make_tiles.DEFAULT_CACHE, + help="the cache make_tiles.py wrote (default %(default)s)") args = ap.parse_args(argv) - # The geometry is vidsrc's, not this script's: `stretch` must divide the - # 1024-row screen or the encoder refuses the frame, and --rows 80 - # --stretch 12 (the plan's own example) is such a pair -- 80*12 = 960 - # rows, the other 64 scanlines unpainted (ruling R3). Report that as a - # usage error instead of a traceback. - try: - StripSource(args.rows, args.stretch) - except ValueError as e: - print("vidsend.py: bad geometry: %s\n" - " for a full screen rows*stretch must be 1024 (128x8 is the " - "ruled default, R3) and rows may not exceed %d, the terminal's " - "ring ceiling." % (e, MAX_ROWS), file=sys.stderr) - return 2 + cache = None + if args.tiles: + try: + cache = make_tiles.read_cache(args.tiles_dir) + except ValueError as exc: + print("vidsend.py: %s" % exc, file=sys.stderr) + print(" build one first: python3 harness/udpstream/make_tiles.py " + "--out %s" % args.tiles_dir, file=sys.stderr) + return 2 + # Tile mode has no strip geometry: the screen is the whole 40x32 grid. + report_every = args.report_every if args.report_every is not None else 1 + else: + # The geometry is vidsrc's, not this script's: `stretch` must divide + # the 1024-row screen or the encoder refuses the frame, and --rows 80 + # --stretch 12 (the plan's own example) is such a pair -- 80*12 = 960 + # rows, the other 64 scanlines unpainted (ruling R3). Report that as + # a usage error instead of a traceback. + try: + StripSource(args.rows, args.stretch) + except ValueError as e: + print("vidsend.py: bad geometry: %s\n" + " for a full screen rows*stretch must be 1024 (128x8 is the " + "ruled default, R3) and rows may not exceed %d, the " + "terminal's ring ceiling." % (e, MAX_ROWS), file=sys.stderr) + return 2 + report_every = (args.report_every if args.report_every is not None + else REPORT_EVERY) + + def build_sender(transport, receive, src): + if cache is not None: + return TileSender(cache, transport, src=src, receive=receive, + fps=args.fps, frames=args.frames or None, + chunk=args.chunk, dst=args.dst, + report_every=report_every, + status_wait=args.status_wait) + return Sender(transport, src=src, receive=receive, rows=args.rows, + stretch=args.stretch, fps=args.fps, + frames=args.frames or None, pattern=args.pattern, + chunk=args.chunk, dst=args.dst, + report_every=report_every, + status_wait=args.status_wait) if args.dry_run: transport = CountingTransport() - sender = Sender(transport, src=b"\x02\x00\x00\x00\x00\x01", - rows=args.rows, stretch=args.stretch, fps=args.fps, - frames=args.frames or None, pattern=args.pattern, - chunk=args.chunk, dst=args.dst, receive=None, - report_every=args.report_every, status_wait=0.0) + sender = build_sender(transport, None, b"\x02\x00\x00\x00\x00\x01") + sender.status_wait = 0.0 sender.run(judge=False) # stdout holds the CSV table only; the verdict of a dry run is prose. - print("RESULT DRY-RUN: %d frames, %d datagrams, %d bytes counted; " - "nothing left the host and no status was received" - % (sender.sent_frames, sender.sent_datagrams, sender.sent_bytes), - file=sys.stderr) + if cache is not None: + print("RESULT DRY-RUN: %d frames (%d full, %d delta, %d unchanged), " + "%d dictionary datagrams, %d datagrams, %d bytes counted; " + "nothing left the host and no status was received" + % (sender.sent_frames, sender.full_frames, + sender.delta_frames, sender.same_frames, + sender.dictionary_sent, sender.sent_datagrams, + sender.sent_bytes), file=sys.stderr) + else: + print("RESULT DRY-RUN: %d frames, %d datagrams, %d bytes counted; " + "nothing left the host and no status was received" + % (sender.sent_frames, sender.sent_datagrams, + sender.sent_bytes), file=sys.stderr) return 0 if not args.iface: @@ -466,12 +775,7 @@ def main(argv=None) -> int: return 2 src = args.src if args.src is not None else net.src - sender = Sender(net.send, src=src, receive=net.recv, rows=args.rows, - stretch=args.stretch, fps=args.fps, - frames=args.frames or None, pattern=args.pattern, - chunk=args.chunk, dst=args.dst, - report_every=args.report_every, - status_wait=args.status_wait) + sender = build_sender(net.send, net.recv, src) try: return sender.run() finally: