forked from arti/vt1200
feat(tiles): bad apple tile stream end to end
This commit is contained in:
1 parent
b331b90c39
commit
79e27680c9
14 files changed
+2835
-59
No files matched your search
@@ -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 <iface> <mac> --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-<frame>.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 <iface> 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 <iface> 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.
|
||||
@@ -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
|
||||
+276
-2
@@ -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-<frame>.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("<BHH")
|
||||
KIND_SAME, KIND_FULL, KIND_DELTA = 0, 2, 3
|
||||
ACTIVE_ROW_BYTES = TILE_COLS * 4 # 160
|
||||
|
||||
# The four tiles the brief calls out, plus the order this script reports them.
|
||||
CORNERS = ((0, 0, "top-left"), (TILE_COLS - 1, 0, "top-right"),
|
||||
(0, TILE_ROWS - 1, "bottom-left"),
|
||||
@@ -109,6 +133,243 @@ def tile_dict(dictionary, entry):
|
||||
return dictionary[entry * TILE_BYTES:(entry + 1) * TILE_BYTES]
|
||||
|
||||
|
||||
# --- Task 6: the replay of a make_tiles.py stream -------------------------
|
||||
|
||||
def read_stream(path):
|
||||
"""Parse ``stream.bin``: ``(frame count, [(kind, frame_no, payload), ...])``.
|
||||
|
||||
Raises ``ValueError`` on anything the writer could not have produced, so a
|
||||
truncated cache is reported as such instead of being decoded into a
|
||||
different picture.
|
||||
"""
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
if len(data) < STREAM_HEADER.size:
|
||||
raise ValueError("shorter than the %d-byte header"
|
||||
% STREAM_HEADER.size)
|
||||
magic, frames = STREAM_HEADER.unpack_from(data, 0)
|
||||
if magic != STREAM_MAGIC:
|
||||
raise ValueError("magic %r, expected %r" % (magic, STREAM_MAGIC))
|
||||
records = []
|
||||
offset = STREAM_HEADER.size
|
||||
while offset < len(data):
|
||||
if offset + RECORD_HEADER.size > 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("<H", payload, 0)[0]
|
||||
if not 1 <= count <= TILES_PER_SCREEN:
|
||||
raise ValueError("count %d is not 1..%d" % (count, TILES_PER_SCREEN))
|
||||
if len(payload) != 2 + 3 * count:
|
||||
raise ValueError("count %d needs %d bytes, got %d"
|
||||
% (count, 2 + 3 * count, len(payload)))
|
||||
previous = -1
|
||||
for k in range(count):
|
||||
position = struct.unpack_from("<H", payload, 2 + 2 * k)[0]
|
||||
if position >= 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)
|
||||
|
||||
+406
-9
@@ -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-<frame>.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")
|
||||
|
||||
@@ -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("<BHH")
|
||||
|
||||
#: Width of the pixels the tile grid covers: 40 tiles x 32 px = 160 bytes.
|
||||
ACTIVE_ROW_BYTES = TILE_COLS * 4 # 160
|
||||
|
||||
#: The four files a cache directory holds.
|
||||
DICTIONARY_FILE = "dictionary.bin"
|
||||
INDICES_FILE = "indices.bin"
|
||||
STREAM_FILE = "stream.bin"
|
||||
MANIFEST_FILE = "manifest.json"
|
||||
|
||||
#: ``make_tiles.py``'s default output, and ``vidsend.py --tiles``'s default
|
||||
#: input: the demo's committed cache.
|
||||
DEFAULT_CACHE = os.path.join(os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.abspath(__file__)))), "harness", "udpstream",
|
||||
"tiles")
|
||||
|
||||
#: One frame's plan: what goes on the wire, or SAME with an empty payload.
|
||||
Record = namedtuple("Record", "frame_no kind payload changed")
|
||||
|
||||
#: A built clip: the dictionary, the quantised frames, the plan, the errors.
|
||||
Tiles = namedtuple("Tiles", "dictionary indices records bit_errors")
|
||||
|
||||
#: A cache read back from disk. ``manifest`` is the parsed JSON, verbatim.
|
||||
Cache = namedtuple("Cache", "dictionary indices records manifest")
|
||||
|
||||
|
||||
# --- the synthetic clip ---------------------------------------------------
|
||||
|
||||
_MASK = (1 << ROW_PIXELS) - 1
|
||||
|
||||
|
||||
def _span(left: int, right: int) -> 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())
|
||||
@@ -3,9 +3,11 @@
|
||||
# report what it received, one CSV line per 30-frame block.
|
||||
#
|
||||
# sudo ./test-stream.sh <iface> <mac> [rows] [stretch]
|
||||
# sudo ./test-stream.sh <iface> <mac> --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
|
||||
#
|
||||
# <iface> 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 <iface> 08:00:2b:16:4b:5a --tiles
|
||||
# 5. FRAMES=6300 sudo ./test-stream.sh <iface> 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 <iface> <mac> [rows] [stretch]" >&2
|
||||
echo " sudo $0 <iface> <mac> --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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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("<H", c, HEADER_SIZE)[0] for c in chunks]
|
||||
assert offsets[0] == 0
|
||||
assert offsets == sorted(offsets) and len(set(offsets)) == len(offsets)
|
||||
rebuilt = b"".join(c[HEADER_SIZE + 2:] for c in chunks)
|
||||
assert rebuilt == tile_cache.dictionary
|
||||
|
||||
|
||||
def test_tile_mode_sends_the_cached_plan_on_the_wire(tile_cache):
|
||||
clock = FakeClock()
|
||||
transport = RecordingTransport(clock)
|
||||
sender = make_tile_sender(tile_cache, clock, transport, frames=3)
|
||||
sender.run()
|
||||
# The dictionary goes first (23 chunks); the clip's frames follow, one
|
||||
# datagram each at the default chunk size.
|
||||
frames = [d[14:] for d in transport.datagrams[23:]]
|
||||
assert len(frames) == len(tile_cache.records)
|
||||
for frame, record in zip(frames, tile_cache.records):
|
||||
h = unpack_header(frame)
|
||||
assert h["crc_ok"] and h["kind"] == record.kind
|
||||
assert h["payload_len"] == len(record.payload)
|
||||
assert frame[HEADER_SIZE:] == record.payload
|
||||
assert h["rows"] == 0 and h["stretch"] == 0
|
||||
|
||||
|
||||
def test_tile_mode_forces_a_keyframe_when_drops_rise(tile_cache):
|
||||
"""Carried from Task 5: the cumulative drops must reach keyframe_due."""
|
||||
clock = FakeClock()
|
||||
transport = RecordingTransport(clock)
|
||||
seen = []
|
||||
|
||||
def receive():
|
||||
# A status arrives after the second frame, reporting one drop.
|
||||
if len(seen) == 2:
|
||||
return [status_frame(frames_rx=2, drops=1)]
|
||||
return []
|
||||
|
||||
sender = make_tile_sender(tile_cache, clock, transport, frames=3,
|
||||
receive=receive)
|
||||
# Count the frames the sender builds, before the transport sees them.
|
||||
original = sender.frame_plan
|
||||
def counting(n):
|
||||
kind, payload, _changed, _keyframe = original(n)
|
||||
seen.append(kind)
|
||||
return kind, payload, _changed, _keyframe
|
||||
sender.frame_plan = counting
|
||||
sender.run()
|
||||
assert seen == [make_tiles.KIND_FULL, make_tiles.KIND_DELTA,
|
||||
make_tiles.KIND_FULL]
|
||||
assert sender.keyframes == [0, 2]
|
||||
assert sender.seen_drops == 1
|
||||
|
||||
|
||||
def test_tile_mode_keeps_the_cadence_without_drops(tile_cache):
|
||||
clock = FakeClock()
|
||||
transport = RecordingTransport(clock)
|
||||
kinds = []
|
||||
sender = make_tile_sender(tile_cache, clock, transport, frames=3, every=2)
|
||||
original = sender.frame_plan
|
||||
def counting(n):
|
||||
kind, payload, _changed, _keyframe = original(n)
|
||||
kinds.append(kind)
|
||||
return kind, payload, _changed, _keyframe
|
||||
sender.frame_plan = counting
|
||||
sender.run()
|
||||
assert kinds == [make_tiles.KIND_FULL, make_tiles.KIND_DELTA,
|
||||
make_tiles.KIND_FULL]
|
||||
|
||||
|
||||
def test_tile_mode_prints_one_csv_row_per_frame(tile_cache):
|
||||
clock = FakeClock()
|
||||
transport = RecordingTransport(clock)
|
||||
out = io.StringIO()
|
||||
sender = make_tile_sender(tile_cache, clock, transport, frames=3, out=out)
|
||||
sender.run()
|
||||
lines = out.getvalue().strip().splitlines()
|
||||
assert lines[0] == vidsend.CSV_HEADER_TILES
|
||||
assert [ln.split(",")[0] for ln in lines[1:]] == ["0", "1", "2"]
|
||||
row = lines[2].split(",")
|
||||
assert row[4] == "delta"
|
||||
assert int(row[5]) == len(tile_cache.records[1].payload)
|
||||
assert int(row[6]) == tile_cache.records[1].changed
|
||||
assert row[7] == "0"
|
||||
|
||||
|
||||
def test_tile_mode_verdict_needs_a_clean_status(tile_cache):
|
||||
clock = FakeClock()
|
||||
transport = RecordingTransport(clock)
|
||||
err = io.StringIO()
|
||||
sender = make_tile_sender(tile_cache, clock, transport, frames=1, err=err)
|
||||
assert sender.run() == 1
|
||||
assert "no status frame received" in err.getvalue()
|
||||
|
||||
clock = FakeClock()
|
||||
transport = RecordingTransport(clock)
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
sender = make_tile_sender(tile_cache, clock, transport, frames=1, out=out,
|
||||
err=err, receive=lambda: [status_frame(
|
||||
frames_rx=1, drops=0, crc_err=0)])
|
||||
assert sender.run() == 0
|
||||
assert "PASS" in err.getvalue()
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"active_row_bytes": 160,
|
||||
"bit_errors": [
|
||||
1308,
|
||||
1351,
|
||||
1420,
|
||||
1366,
|
||||
1309,
|
||||
1425,
|
||||
1442,
|
||||
1507,
|
||||
1389,
|
||||
1574,
|
||||
1444,
|
||||
1342,
|
||||
1446,
|
||||
1260,
|
||||
1556,
|
||||
1459,
|
||||
1592,
|
||||
1472,
|
||||
1369,
|
||||
1524,
|
||||
1509,
|
||||
1581,
|
||||
1506,
|
||||
1505,
|
||||
1514,
|
||||
1719,
|
||||
1540,
|
||||
1595,
|
||||
1610,
|
||||
1809
|
||||
],
|
||||
"changed_tiles": [
|
||||
0,
|
||||
12,
|
||||
14,
|
||||
13,
|
||||
7,
|
||||
15,
|
||||
11,
|
||||
11,
|
||||
11,
|
||||
10,
|
||||
11,
|
||||
11,
|
||||
10,
|
||||
12,
|
||||
9,
|
||||
14,
|
||||
10,
|
||||
8,
|
||||
13,
|
||||
15,
|
||||
9,
|
||||
14,
|
||||
13,
|
||||
11,
|
||||
12,
|
||||
12,
|
||||
12,
|
||||
12,
|
||||
11,
|
||||
10
|
||||
],
|
||||
"clip": "synthetic_clip",
|
||||
"delta_frames": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
14,
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
21,
|
||||
22,
|
||||
23,
|
||||
24,
|
||||
25,
|
||||
26,
|
||||
27,
|
||||
28,
|
||||
29
|
||||
],
|
||||
"delta_payload_bytes": 1057,
|
||||
"dictionary_bytes": 32768,
|
||||
"entries_used": 256,
|
||||
"frames": 30,
|
||||
"full_payload_bytes": 1280,
|
||||
"k": 256,
|
||||
"keyframe_every": 60,
|
||||
"keyframes": [
|
||||
0
|
||||
],
|
||||
"payload_bytes": [
|
||||
1280,
|
||||
38,
|
||||
44,
|
||||
41,
|
||||
23,
|
||||
47,
|
||||
35,
|
||||
35,
|
||||
35,
|
||||
32,
|
||||
35,
|
||||
35,
|
||||
32,
|
||||
38,
|
||||
29,
|
||||
44,
|
||||
32,
|
||||
26,
|
||||
41,
|
||||
47,
|
||||
29,
|
||||
44,
|
||||
41,
|
||||
35,
|
||||
38,
|
||||
38,
|
||||
38,
|
||||
38,
|
||||
35,
|
||||
32
|
||||
],
|
||||
"psnr_db": [
|
||||
30.00902182299432,
|
||||
29.868545772656496,
|
||||
29.652215819046237,
|
||||
29.820592269421663,
|
||||
30.005702797369246,
|
||||
29.636950619431516,
|
||||
29.585446659042702,
|
||||
29.393966739730484,
|
||||
29.748076805500645,
|
||||
29.205051982646346,
|
||||
29.579427330540604,
|
||||
29.897574104547072,
|
||||
29.573416333291682,
|
||||
30.171393811701176,
|
||||
29.255003336340103,
|
||||
29.534546343942285,
|
||||
29.155668628860305,
|
||||
29.496021162862004,
|
||||
29.811064781536906,
|
||||
29.345249592840986,
|
||||
29.388206865120907,
|
||||
29.185780563554715,
|
||||
29.396849544229987,
|
||||
29.399734263578182,
|
||||
29.373840511236263,
|
||||
28.82234049600628,
|
||||
29.299892054512174,
|
||||
29.1474923889448,
|
||||
29.106840502558306,
|
||||
28.600713594278666
|
||||
],
|
||||
"screen_rows": 1024,
|
||||
"seed": 0,
|
||||
"total_payload_bytes": 2337,
|
||||
"unchanged_frames": [],
|
||||
"version": 1
|
||||
}
|
||||
Binary file not shown.
+340
-36
@@ -9,8 +9,19 @@ printed. In the other direction the script listens for the terminal's
|
||||
``KIND_STATUS`` frames and decodes them.
|
||||
|
||||
sudo python3 harness/udpstream/vidsend.py --iface vt1200 --frames 200
|
||||
sudo python3 harness/udpstream/vidsend.py --iface vt1200 --tiles --frames 300
|
||||
python3 harness/udpstream/vidsend.py --dry-run --frames 200 # no NIC
|
||||
|
||||
``--tiles`` sends the cached tile stream instead of strip frames: the 32 KB
|
||||
dictionary in chunks (``tiledict.dict_chunks``), then one ``TILES_FULL`` or
|
||||
``TILES_DELTA`` per clip frame from the cache ``make_tiles.py`` wrote. The
|
||||
keyframe decision is live, not a replay of the cache: ``keyframe_due(frame_no,
|
||||
drops, seen_drops)`` sees the terminal's *cumulative* ``drops`` from each status
|
||||
frame, so a reported loss forces a full frame at the next opportunity rather
|
||||
than waiting for the 60-frame cadence. A clip shorter than ``--frames`` loops,
|
||||
and the cadence keeps counting across the loop, which is what makes a 6,300-
|
||||
frame soak from a 30-frame cache a real run and not 210 restarts.
|
||||
|
||||
Exit status is 0 only when a status frame was received and the last one reports
|
||||
``drops == 0`` and ``crc_err == 0``. "No status" is a failure: the two
|
||||
conditions were never observed, and a sender that cannot see the terminal must
|
||||
@@ -46,25 +57,36 @@ 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 import make_tiles # noqa: E402
|
||||
from harness.udpstream.frame import ( # noqa: E402
|
||||
BROADCAST, ETHERTYPE, HEADER_SIZE, KIND_STATUS, MAGIC, STATUS_SIZE,
|
||||
ethernet_frame, unpack_header, unpack_status)
|
||||
ethernet_frame, pack_header, unpack_header, unpack_status)
|
||||
from harness.udpstream.tiledict import ( # noqa: E402
|
||||
KEYFRAME_EVERY, delta_frame, dict_chunks, keyframe_due)
|
||||
from harness.udpstream.vidsrc import ( # noqa: E402
|
||||
CHUNK, MAX_ROWS, ROW_BYTES, StripSource)
|
||||
|
||||
__all__ = [
|
||||
"CSV_HEADER", "Sender", "bars", "split", "PATTERNS",
|
||||
"decode_status_frame", "RawEthernet", "parse_mac",
|
||||
"CSV_HEADER", "CSV_HEADER_TILES", "Sender", "TileSender", "bars", "split",
|
||||
"PATTERNS", "decode_status_frame", "RawEthernet", "parse_mac",
|
||||
]
|
||||
|
||||
#: One CSV line is printed every this many frames.
|
||||
REPORT_EVERY = 30
|
||||
|
||||
#: The CSV columns, in this order: frame number, elapsed seconds, achieved fps,
|
||||
#: bytes handed to the transport so far. The CSV goes to stdout and the prose
|
||||
#: (status lines, the verdict) to stderr, so a run's stdout is a clean table.
|
||||
#: The strip CSV columns, in this order: frame number, elapsed seconds, achieved
|
||||
#: fps, bytes handed to the transport so far. The CSV goes to stdout and the
|
||||
#: prose (status lines, the verdict) to stderr, so a run's stdout is a clean
|
||||
#: table.
|
||||
CSV_HEADER = "frame,elapsed_s,fps,bytes"
|
||||
|
||||
#: The tile CSV adds what plan step 3 asks for: which kind went out, how many
|
||||
#: payload bytes it carried, how many tiles changed, and whether the frame was
|
||||
#: a keyframe. The first four columns are the strip table's, so a run can be
|
||||
#: processed by the same tools.
|
||||
CSV_HEADER_TILES = ("frame,elapsed_s,fps,bytes,kind,payload_bytes,"
|
||||
"changed_tiles,keyframe")
|
||||
|
||||
#: Bars per screen: a source row is ink while ``(r + n) % 16 < 8``.
|
||||
BAR_PERIOD = 16
|
||||
|
||||
@@ -324,6 +346,253 @@ class Sender:
|
||||
return 0
|
||||
|
||||
|
||||
# --- tile mode: the cached stream -----------------------------------------
|
||||
|
||||
class TileSender:
|
||||
"""Send a ``make_tiles.py`` cache: the dictionary, then full/delta frames.
|
||||
|
||||
Everything the world touches is injected exactly as in :class:`Sender`, so
|
||||
the plan, the wire bytes and the verdict are testable without a socket.
|
||||
|
||||
The frames come from the cache's ``indices`` rather than from its
|
||||
``stream`` records: the sender has to *decide* the keyframe at send time,
|
||||
because only it can see the terminal's status frame. With no drops the
|
||||
decision reproduces the cached plan byte for byte -- ``test_vidsend.py``
|
||||
pins that -- and with a drop it inserts a full frame the cache never
|
||||
planned, which is the whole point of carrying ``drops`` into
|
||||
:func:`tiledict.keyframe_due`.
|
||||
|
||||
A clip shorter than the run loops. ``frame_no`` keeps counting, so the
|
||||
cadence and the loop are one timeline: frame 60 is a keyframe whether it is
|
||||
clip frame 0 or clip frame 30.
|
||||
"""
|
||||
|
||||
def __init__(self, cache, transport, src, receive=None, dst=BROADCAST,
|
||||
fps=30.0, frames=None, chunk=CHUNK, clock=time.monotonic,
|
||||
sleep=time.sleep, report_every=1, status_wait=1.0,
|
||||
every=KEYFRAME_EVERY, out=None, err=None):
|
||||
if chunk < HEADER_SIZE + 1:
|
||||
raise ValueError("chunk %d is too small for a frame" % chunk)
|
||||
if fps < 0:
|
||||
raise ValueError("fps must be >= 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:
|
||||
|
||||
Reference in new issue
Block a user