feat(tiles): bad apple tile stream end to end

This commit is contained in:
opencode.energia.arti.ee committed 2026-10-31 03:06:04 +00:00
1 parent b331b90c39
commit 79e27680c9
14 files changed
+2835 -59

No files matched your search

+294
View File
@@ -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.
+43
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+645
View File
@@ -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())
+60 -9
View File
@@ -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.
+293
View File
@@ -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
+152 -3
View File
@@ -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.
+178
View File
@@ -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
View File
@@ -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: