diff --git a/harness/mame/run-vidstream.sh b/harness/mame/run-vidstream.sh index 2af8b53..a5d0b88 100755 --- a/harness/mame/run-vidstream.sh +++ b/harness/mame/run-vidstream.sh @@ -9,6 +9,7 @@ # VT1200_MODE=ring harness/mame/run-vidstream.sh # the RX ring's structure # 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 # 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 @@ -52,6 +53,19 @@ # vram_tile.asm is the negative control: the frame is accepted and the pixels # are wrong, which only the checker can see. # +# Mode delta asserts the changed-tile list: a TILES_FULL keyframe is painted, +# then a one-tile TILES_DELTA must change exactly that 32x32 block and nothing +# else, with RX_OK/TILES_DROP/RX_LAST_LEN saying so. Two negative controls +# follow -- a delta naming a position off the 1,280-tile grid, and one whose +# count disagrees with its length -- and each must be refused (TILES_DROP, no +# RX_OK) with ZERO bytes of the screen changed; a good delta after them proves +# the refusal left no latch behind. The Lua holds the before/after dumps in +# memory and compares them itself: the assertion is a relation between two +# screens, not a rendering to re-derive, and it is why the first negative +# control's valid first entry matters (a decoder that painted as it decoded +# would have painted it). Evidence: $LOGDIR/vram-delta-*.bin and +# delta-status.txt. +# # 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 @@ -115,6 +129,12 @@ if [ "$MODE" = "tiles" ]; then rm -f "$LOGDIR/vram-tiles.bin" "$LOGDIR/dict-tiles.bin" \ "$LOGDIR/indices-tiles.bin" "$LOGDIR/tiles-status.txt" fi +if [ "$MODE" = "delta" ]; then + rm -f "$LOGDIR/vram-delta-before.bin" "$LOGDIR/vram-delta-after.bin" \ + "$LOGDIR/vram-delta-reject.bin" "$LOGDIR/vram-delta-recover.bin" \ + "$LOGDIR/dict-delta.bin" "$LOGDIR/indices-delta.bin" \ + "$LOGDIR/delta-status.txt" +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" diff --git a/harness/mame/vidstream.lua b/harness/mame/vidstream.lua index fa835a3..68f18f2 100644 --- a/harness/mame/vidstream.lua +++ b/harness/mame/vidstream.lua @@ -81,6 +81,19 @@ -- vram_tile.asm: the frame is then accepted (RX_OK moves) and the checker must -- FAIL on the picture. -- +-- Mode "delta" (Task 5) is the changed-tile list and the keyframe cadence. It +-- uploads the same dictionary, paints one TILES_FULL keyframe, then delivers a +-- one-tile TILES_DELTA and compares the screen before and after: all 128 bytes +-- of that 32x32 block must change and NOT ONE byte outside it. The negative +-- controls are a delta naming a position off the 1,280-tile grid (its first +-- entry is valid on purpose, so a decoder that painted as it read would paint +-- it) and a delta whose count disagrees with its length; both must be refused +-- in TILES_DROP with zero pixels changed, and a good delta afterwards proves +-- the refusal left no latch. Unlike tiles mode the assertion is a comparison +-- of two screens, so this Lua makes it: the property is relational, not a +-- rendering the geometry could be re-derived from. Evidence: +-- vram-delta-{before,after,reject,recover}.bin and delta-status.txt. +-- -- PLAYING THE CHIP. Ruling R11: harness/mame/vt1200.cpp never creates a -- device_network_interface, so the emulated LANCE is not attached to anything -- and no packet can reach it. The harness therefore does what am79c90.cpp's @@ -100,7 +113,8 @@ -- harness/udpstream/frame_parse.h. -- -- Env: --- VT1200_MODE alloc (default), rx, draw, stream, ring, dict or tiles +-- VT1200_MODE alloc (default), rx, draw, stream, ring, dict, tiles or +-- delta -- 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 @@ -121,6 +135,8 @@ -- chunk to be consumed (default 300) -- VID_TILE_WAIT tiles mode only: emulated frames to wait for a dictionary -- 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_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) @@ -161,6 +177,11 @@ elseif MODE == "tiles" and not os.getenv("VID_END") then -- frame, each bounded (VID_TILE_WAIT, 300); the first timeout dumps and -- exits, so this is only the catastrophic-stall backstop. DEFAULT_END = AT + 26 * tonumber(os.getenv("VID_TILE_WAIT") or "300") + 120 +elseif MODE == "delta" and not os.getenv("VID_END") then + -- Same shape as tiles mode plus the keyframe and the three deltas: one + -- ring wait, 23 dictionary chunks and five frames, each bounded by + -- VID_DELTA_WAIT. + DEFAULT_END = AT + 30 * tonumber(os.getenv("VID_DELTA_WAIT") or "300") + 120 end local STOP_AT = tonumber(os.getenv("VID_END") or tostring(DEFAULT_END)) local ORIGIN = tonumber(os.getenv("VID_ORIGIN") or "0x018C0000") @@ -292,7 +313,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" then + and MODE ~= "tiles" and MODE ~= "delta" then print("VID ERROR: mode '" .. MODE .. "' is not implemented") machine:exit() return @@ -607,6 +628,9 @@ local DRAW_FILES = { "vram-draw.bin", "frame-draw.bin", "draw-status.txt" } local STREAM_FILES = { "stream-status.bin", "stream-status.txt" } local TILE_FILES = { "vram-tiles.bin", "dict-tiles.bin", "indices-tiles.bin", "tiles-status.txt" } +local DELTA_FILES = { "vram-delta-before.bin", "vram-delta-after.bin", + "vram-delta-reject.bin", "vram-delta-recover.bin", "dict-delta.bin", + "indices-delta.bin", "delta-status.txt" } local function clear_dump_files() if MODE == "dict" then @@ -619,6 +643,12 @@ local function clear_dump_files() end return end + if MODE == "delta" then + for _, name in ipairs(DELTA_FILES) do + os.remove(DUMP_DIR .. "/" .. name) + end + return + end if MODE ~= "draw" and MODE ~= "stream" then return end @@ -1940,6 +1970,633 @@ return { } end)() +-- --- Task 5: delta frames and keyframes ------------------------------------ +-- +-- Mode "delta" is the changed-tile list, checked RELATIVELY: the screen is +-- read before and after each frame and only the 32x32 block the frame names may +-- differ. That is the property a delta decoder must have -- a delta is a +-- promise not to touch the other 1,248 tiles -- and it is not something +-- tile_check.py can see, because tile_check.py renders one whole frame. +-- +-- The sequence: +-- +-- 1. upload the same 32 KB dictionary tiles mode uses (every entry and every +-- row distinct) and read it back byte for byte; +-- 2. paint ONE TILES_FULL keyframe (index = (i*7 + 3) & 0xFF, as in tiles +-- mode) and dump the screen as vram-delta-before.bin -- the reference; +-- 3. deliver ONE TILES_DELTA naming tile (tx=11, ty=7) with a new entry; +-- dump vram-delta-after.bin and assert RX_OK moved, TILES_DROP did not, +-- exactly that block's 128 bytes changed, they now equal dictionary[new], +-- and no byte outside the block changed; +-- 4. negative control A: a delta whose FIRST entry is on the grid and whose +-- second position is 1,280 (off the grid). A decoder that painted as it +-- decoded would paint the first tile and report success; the assertions +-- are TILES_DROP +1, RX_OK and RX_LAST_LEN unchanged, and ZERO bytes of +-- the active area changed since the previous dump. The first entry is +-- what makes "nothing painted" mean validated-first, not happened-to-fail; +-- 5. negative control B: a count that disagrees with the payload length (two +-- entries claimed, one carried). Same assertions -- the list may not run +-- past the frame; +-- 6. recover: one more valid delta at a different tile, so the refusals left +-- no latch behind; the assertions are the same shape as step 3. +-- +-- The active area is screen rows 0..1023, bytes 0..159 of each 256-byte row. +-- Bytes 160..255 are outside every tile and belong to the resident OS, exactly +-- as tile_check.py says; the canary word at row 1023 / column 1280 is asserted +-- separately (SENTINEL) and OS_TOUCH must stay 0. +-- +-- This Lua asserts the pixels itself, because the property is a comparison of +-- two dumps rather than a rendering the geometry could be re-derived from: it +-- holds both screens in memory, and writing a third file for a checker to +-- diff would only move the same string comparison outside the run. +-- +-- The mode's own state lives in a closure for the same reason tiles mode's +-- does: Lua 5.3 allows 200 locals in the main chunk of a file this size. The +-- dictionary and chunk helpers are duplicated here rather than shared, so this +-- mode cannot change tiles mode's behaviour by being added. +local delta = (function() +local TILE_PIXELS = 32 +local TILE_COLS = 40 +local TILE_ROWS = 32 +local TILES_PER_FRAME = TILE_COLS * TILE_ROWS +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 DELTA_HDR = 2 -- the u16 count, frame_parse.h +local ACTIVE_ROW = 160 -- 40 tiles x 4 bytes: the pixels tiles cover +local DELTA_WAIT = tonumber(os.getenv("VID_DELTA_WAIT") or "300") + +-- Tile positions this mode uses. TOUCH and RECOVER are ordinary interior +-- tiles (a corner would also show a swapped tx/ty, which tiles mode already +-- covers); BAD_POS is the entry a progressively-painting decoder would paint +-- before it met the off-grid position. +local TOUCH = 7 * TILE_COLS + 11 -- (tx 11, ty 7) +local BAD_POS = 3 * TILE_COLS + 22 -- (tx 22, ty 3) +local RECOVER = 20 * TILE_COLS + 3 -- (tx 3, ty 20) +local BAD_OUT = TILES_PER_FRAME -- 1,280: the first position off the grid + +-- The dictionary: every 128-byte entry and every row inside it differs from +-- its neighbours, the same pattern tiles mode uses. A delta that painted the +-- wrong entry, or a blit that re-read row 0, cannot match. +local function tile_byte(entry, row, col) + return (entry * 37 + row * 11 + col * 3 + 5) & 0xFF +end + +local function tile_dictionary() + local out = {} + for e = 0, 255 do + local entry = {} + for r = 0, TILE_PIXELS - 1 do + for c = 0, 3 do + entry[#entry + 1] = string.char(tile_byte(e, r, c)) + end + end + out[#out + 1] = table.concat(entry) + end + return table.concat(out) +end + +local TILE_DICT = tile_dictionary() + +local function tile_index(i) + return (i * 7 + 3) & 0xFF +end + +local TILE_INDICES = (function() + local out = {} + for i = 0, TILES_PER_FRAME - 1 do + out[#out + 1] = string.char(tile_index(i)) + end + return table.concat(out) +end)() + +local delta_phase = 0 +local delta_phase_at = nil +local delta_fail = 0 +local delta_lines = {} +local delta_pos = 0 -- RX_RING_POS expected once the last frame settles +local delta_last = 0 -- datagrams the frame in flight is waiting on +local delta_at = 0 +local delta_dict_matched = 0 +local delta_before = nil -- the screen after the keyframe +local delta_prev = nil -- the screen after the previous accepted frame +local delta_after = nil +local delta_reject = nil +local delta_recover = nil +local delta_last_len = 0 + +local function delta_chunk_frame(off, body) + local f = 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 TILE_DICT:byte(off + k - 1) + end) + return f, 16 + 2 + body +end + +local function delta_chunk(i) + local off = i * DICT_CHUNK_BODY + local n = TILE_DICT_SIZE - off + + if n > DICT_CHUNK_BODY then + n = DICT_CHUNK_BODY + end + return delta_chunk_frame(off, n) +end + +-- One TILES_DELTA payload (frame_parse.h): u16 count, `count` u16 positions, +-- `count` one-byte entries. `positions` and `values` are Lua arrays of the +-- same length, strictly ascending by construction at every call site here. +local function delta_bytes(positions, values) + local n = #positions + local b = {} + b[1] = n & 0xFF + b[2] = (n >> 8) & 0xFF + for i = 1, n do + b[#b + 1] = positions[i] & 0xFF + b[#b + 1] = (positions[i] >> 8) & 0xFF + end + for i = 1, n do + b[#b + 1] = values[i] & 0xFF + end + return b +end + +-- A raw payload, for the negatives a well-formed encoder would never produce. +local function delta_frame_of(payload, frame_no) + return make_frame(KIND_TILES_DELTA, frame_no, 0, 0, #payload, function(k) + return payload[k + 1] + end), 16 + #payload +end + +local function delta_send(positions, values, frame_no) + local payload = delta_bytes(positions, values) + + return delta_frame_of(payload, frame_no) +end + +local function delta_check(cond, what) + if cond then + delta_lines[#delta_lines + 1] = "PASS " .. what + print("VID: delta PASS: " .. what) + else + delta_lines[#delta_lines + 1] = "FAIL " .. what + print("VID: delta FAIL: " .. what) + delta_fail = delta_fail + 1 + end + return cond +end + +local function delta_settled() + return prog:read_u32(RX_RING_POS) == delta_pos + and prog:read_u32(RX_RING_ACC) == 0 +end + +local function delta_take(f, n) + local ndg = inject_frame(f, n, DRAW_CHUNK) + + if ndg == nil then + return false + end + delta_pos = (delta_pos + ndg) % RX_DESC + delta_last = ndg + return true +end + +local function delta_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 = TILE_DICT:byte(i * 2 + 1) | (TILE_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 + +-- Compare two screens over the active area (rows 0..1023, bytes 0..159 of +-- each row) and classify every differing byte: inside the 32x32 block at +-- (tx, ty), or outside it. `first` is the row*256+x of the first byte found +-- outside the block, for a message that names where a stray write landed. +-- +-- The whole 256-byte row is not compared: bytes 160..255 are outside every +-- tile and the OS owns them, which is why tile_check.py stops at 160 too. +local function block_diff(a, b, tx, ty) + local x0 = tx * 4 + local y0 = ty * TILE_PIXELS + local inside, outside, first = 0, 0, -1 + local y, x + + for y = 0, VRAM_ROWS - 1 do + local row = y * VRAM_ROW_BYTES + local inband = y >= y0 and y < y0 + TILE_PIXELS + if inband then + -- Outside the block's four-byte column: everything to its left + -- and right. Substring compare first, so a clean row costs two + -- compares and no byte loop. + if a:sub(row + 1, row + x0) ~= b:sub(row + 1, row + x0) + or a:sub(row + x0 + 5, row + ACTIVE_ROW) + ~= b:sub(row + x0 + 5, row + ACTIVE_ROW) then + for x = 0, ACTIVE_ROW - 1 do + if (x < x0 or x >= x0 + 4) + and a:byte(row + x + 1) ~= b:byte(row + x + 1) then + outside = outside + 1 + if first < 0 then + first = row + x + end + end + end + end + else + -- A row outside the tile band has no block on it: every active + -- byte of it is outside, the block's four columns included. + if a:sub(row + 1, row + ACTIVE_ROW) + ~= b:sub(row + 1, row + ACTIVE_ROW) then + for x = 0, ACTIVE_ROW - 1 do + if a:byte(row + x + 1) ~= b:byte(row + x + 1) then + outside = outside + 1 + if first < 0 then + first = row + x + end + end + end + end + end + end + for y = y0, y0 + TILE_PIXELS - 1 do + local row = y * VRAM_ROW_BYTES + for x = x0, x0 + 3 do + if a:byte(row + x + 1) ~= b:byte(row + x + 1) then + inside = inside + 1 + end + end + end + return inside, outside, first +end + +-- The block at (tx, ty) of screen `v` against dictionary entry `entry`. +local function block_equals(v, tx, ty, entry) + local x0 = tx * 4 + local base = entry * TILE_DICT_BYTES + local y, x + + for y = 0, TILE_PIXELS - 1 do + local row = (ty * TILE_PIXELS + y) * VRAM_ROW_BYTES + for x = 0, 3 do + if v:byte(row + x0 + x + 1) + ~= TILE_DICT:byte(base + y * 4 + x + 1) then + return false, y * 4 + x + end + end + end + return true, -1 +end + +local function delta_dump(timed_out) + local ok = write_file(DUMP_DIR .. "/dict-delta.bin", TILE_DICT) + ok = write_file(DUMP_DIR .. "/indices-delta.bin", TILE_INDICES) and ok + if delta_before then + ok = write_file(DUMP_DIR .. "/vram-delta-before.bin", delta_before) and ok + end + if delta_after then + ok = write_file(DUMP_DIR .. "/vram-delta-after.bin", delta_after) and ok + end + if delta_reject then + ok = write_file(DUMP_DIR .. "/vram-delta-reject.bin", delta_reject) and ok + end + if delta_recover then + ok = write_file(DUMP_DIR .. "/vram-delta-recover.bin", delta_recover) and ok + end + local lines = { + 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", delta_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("DELTA_CHECKS_FAILED=%d", delta_fail), + string.format("TIMEOUT=%d", timed_out and 1 or 0), + } + for _, l in ipairs(delta_lines) do + lines[#lines + 1] = "CHECK " .. l + end + ok = write_file(DUMP_DIR .. "/delta-status.txt", + table.concat(lines, "\n") .. "\n") and ok + print(string.format("VID: delta: %d checks failed; RX_OK=%d TILES_DROP=%d RX_LAST_LEN=%d", + delta_fail, prog:read_u32(RX_OK), prog:read_u32(TILES_DROP), + prog:read_u32(RX_LAST_LEN))) + print(ok and not timed_out and delta_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 delta_step() + local f, n, okv, first, inside, outside, stray, okb, v + + if delta_phase == 0 then + if delta_phase_at == nil then + delta_phase_at = frames + end + if not ring_ready() then + if frames >= delta_phase_at + DELTA_WAIT then + delta_check(false, string.format( + "the payload published an RX ring (RX_RING_BASE=%s, ST_INIT=%04X)", + hex(prog:read_u32(RX_RING_BASE)), prog:read_u32(ST_INIT))) + delta_dump(true) + return true + end + return false + end + local _, pos0 = ring_state() + delta_pos = pos0 + delta_check(prog:read_u32(RX_OK) == 0, + string.format("no frame had reached the screen before the dictionary (RX_OK=%d)", + prog:read_u32(RX_OK))) + delta_phase = 1 + delta_phase_at = frames + return false + end + + if delta_phase == 1 then + if delta_last ~= 0 then + if not delta_settled() then + if frames >= delta_phase_at + DELTA_WAIT then + delta_check(false, string.format( + "the payload consumed the frame at phase 1 (RX_RING_POS=%d expected %d)", + prog:read_u32(RX_RING_POS), delta_pos)) + delta_dump(true) + return true + end + return false + end + delta_last = 0 + end + if delta_at >= 23 then + delta_check(prog:read_u32(DICT_READY) == 1 + and prog:read_u32(DICT_BYTES) == TILE_DICT_SIZE, + string.format("the 23 chunks made DICT_READY=1 and DICT_BYTES=%d", + prog:read_u32(DICT_BYTES))) + delta_check(prog:read_u32(DICT_BAD) == 0, + string.format("DICT_BAD=0 after the upload (DICT_BAD=%d)", + prog:read_u32(DICT_BAD))) + okv, first = delta_verify_dict() + delta_dict_matched = okv and 1 or 0 + delta_check(okv, string.format( + "the 32 KB at POOL_PTR + %d is the dictionary sent (first bad byte %d)", + DICT_OFF, first)) + f = make_frame(KIND_TILES_FULL, 0x0090, 0, 0, TILES_PER_FRAME, + function(i) return tile_index(i) end) + if not delta_take(f, 16 + TILES_PER_FRAME) then + delta_check(false, "the ring accepted the TILES_FULL keyframe") + delta_dump(true) + return true + end + delta_phase = 2 + delta_phase_at = frames + return false + end + f, n = delta_chunk(delta_at) + if not delta_take(f, n) then + delta_check(false, string.format("the ring accepted dictionary chunk %d", delta_at)) + delta_dump(true) + return true + end + delta_at = delta_at + 1 + delta_phase_at = frames + return false + end + + if delta_phase == 2 then + if not delta_settled() then + if frames >= delta_phase_at + DELTA_WAIT then + delta_check(false, string.format( + "the payload consumed the keyframe (RX_OK=%d TILES_DROP=%d)", + prog:read_u32(RX_OK), prog:read_u32(TILES_DROP))) + delta_dump(true) + return true + end + return false + end + delta_check(prog:read_u32(RX_OK) == 1, + string.format("the TILES_FULL keyframe reached the screen (RX_OK=%d)", + prog:read_u32(RX_OK))) + delta_check(prog:read_u32(TILES_DROP) == 0, + string.format("no tile frame was dropped (TILES_DROP=%d)", + prog:read_u32(TILES_DROP))) + delta_check(prog:read_u32(RX_BAD) == 0, + string.format("no frame was refused (RX_BAD=%d)", prog:read_u32(RX_BAD))) + delta_before = read_vram() + delta_prev = delta_before + -- The delta under test: one tile, a different dictionary entry. + f, n = delta_send({ TOUCH }, { (tile_index(TOUCH) + 1) & 0xFF }, 0x0091) + if not delta_take(f, n) then + delta_check(false, "the ring accepted the one-tile delta") + delta_dump(true) + return true + end + delta_phase = 3 + delta_phase_at = frames + return false + end + + if delta_phase == 3 then + if not delta_settled() then + if frames >= delta_phase_at + DELTA_WAIT then + delta_check(false, string.format( + "the payload consumed the one-tile delta (RX_OK=%d TILES_DROP=%d)", + prog:read_u32(RX_OK), prog:read_u32(TILES_DROP))) + delta_dump(true) + return true + end + return false + end + delta_check(prog:read_u32(RX_OK) == 2, + string.format("the delta reached the screen (RX_OK=%d)", prog:read_u32(RX_OK))) + delta_check(prog:read_u32(TILES_DROP) == 0, + string.format("no tile frame was dropped (TILES_DROP=%d)", + prog:read_u32(TILES_DROP))) + delta_check(prog:read_u32(RX_LAST_LEN) == 2 + 3, + string.format("RX_LAST_LEN is the delta's payload_len (%d, expected 5)", + prog:read_u32(RX_LAST_LEN))) + delta_last_len = prog:read_u32(RX_LAST_LEN) + delta_after = read_vram() + inside, outside, stray = block_diff(delta_before, delta_after, + TOUCH % TILE_COLS, TOUCH // TILE_COLS) + delta_check(inside == 128, string.format( + "all 128 bytes of the 32x32 block at tile %d changed (%d)", TOUCH, inside)) + delta_check(outside == 0, string.format( + "no byte outside that block changed (first stray write at %d/%d)", + stray, ACTIVE_ROW)) + okv, first = block_equals(delta_after, TOUCH % TILE_COLS, TOUCH // TILE_COLS, + (tile_index(TOUCH) + 1) & 0xFF) + delta_check(okv, string.format( + "the block is dictionary entry %d (first bad byte %d)", + (tile_index(TOUCH) + 1) & 0xFF, first)) + delta_prev = delta_after + -- Negative control A: a valid tile followed by a position off the grid. + f, n = delta_send({ BAD_POS, BAD_OUT }, + { (tile_index(BAD_POS) + 3) & 0xFF, 0x11 }, 0x0092) + if not delta_take(f, n) then + delta_check(false, "the ring accepted the off-grid delta") + delta_dump(true) + return true + end + delta_phase = 4 + delta_phase_at = frames + return false + end + + if delta_phase == 4 then + if not delta_settled() then + if frames >= delta_phase_at + DELTA_WAIT then + delta_check(false, string.format( + "the payload consumed the off-grid delta (RX_OK=%d TILES_DROP=%d)", + prog:read_u32(RX_OK), prog:read_u32(TILES_DROP))) + delta_dump(true) + return true + end + return false + end + delta_check(prog:read_u32(TILES_DROP) == 1, + string.format("the off-grid delta was refused (TILES_DROP=%d, expected 1)", + prog:read_u32(TILES_DROP))) + delta_check(prog:read_u32(RX_OK) == 2, + string.format("the off-grid delta did not reach the screen (RX_OK=%d)", + prog:read_u32(RX_OK))) + delta_check(prog:read_u32(RX_LAST_LEN) == delta_last_len, + string.format("RX_LAST_LEN still names the last painted delta (%d, expected %d)", + prog:read_u32(RX_LAST_LEN), delta_last_len)) + delta_reject = read_vram() + inside, outside, stray = block_diff(delta_prev, delta_reject, + BAD_POS % TILE_COLS, BAD_POS // TILE_COLS) + delta_check(inside == 0, string.format( + "the off-grid delta painted no part of its valid tile %d (%d bytes)", + BAD_POS, inside)) + delta_check(outside == 0, string.format( + "the off-grid delta changed nothing else (first stray write at %d/%d)", + stray, ACTIVE_ROW)) + delta_prev = delta_reject + -- Negative control B: two entries claimed, one carried. + f, n = delta_frame_of({ 0x02, 0x00, BAD_POS & 0xFF, 0x00, 0x22 }, 0x0093) + if not delta_take(f, n) then + delta_check(false, "the ring accepted the truncation delta") + delta_dump(true) + return true + end + delta_phase = 5 + delta_phase_at = frames + return false + end + + if delta_phase == 5 then + if not delta_settled() then + if frames >= delta_phase_at + DELTA_WAIT then + delta_check(false, string.format( + "the payload consumed the truncation delta (RX_OK=%d TILES_DROP=%d)", + prog:read_u32(RX_OK), prog:read_u32(TILES_DROP))) + delta_dump(true) + return true + end + return false + end + delta_check(prog:read_u32(TILES_DROP) == 2, + string.format("the truncated delta was refused (TILES_DROP=%d, expected 2)", + prog:read_u32(TILES_DROP))) + delta_check(prog:read_u32(RX_OK) == 2, + string.format("the truncated delta did not reach the screen (RX_OK=%d)", + prog:read_u32(RX_OK))) + v = read_vram() + inside, outside, stray = block_diff(delta_prev, v, + BAD_POS % TILE_COLS, BAD_POS // TILE_COLS) + delta_check(inside == 0 and outside == 0, string.format( + "the truncated delta changed nothing (%d inside, %d outside, first %d/%d)", + inside, outside, stray, ACTIVE_ROW)) + delta_reject = v + delta_prev = v + -- Recovery: a good delta after two refusals. + f, n = delta_send({ RECOVER }, { (tile_index(RECOVER) + 2) & 0xFF }, 0x0094) + if not delta_take(f, n) then + delta_check(false, "the ring accepted the recovery delta") + delta_dump(true) + return true + end + delta_phase = 6 + delta_phase_at = frames + return false + end + + if delta_phase == 6 then + if not delta_settled() then + if frames >= delta_phase_at + DELTA_WAIT then + delta_check(false, string.format( + "the payload consumed the recovery delta (RX_OK=%d TILES_DROP=%d)", + prog:read_u32(RX_OK), prog:read_u32(TILES_DROP))) + delta_dump(true) + return true + end + return false + end + delta_check(prog:read_u32(RX_OK) == 3, + string.format("the recovery delta reached the screen (RX_OK=%d)", + prog:read_u32(RX_OK))) + delta_check(prog:read_u32(TILES_DROP) == 2, + string.format("the refusals left no further drop (TILES_DROP=%d)", + prog:read_u32(TILES_DROP))) + delta_recover = read_vram() + inside, outside, stray = block_diff(delta_prev, delta_recover, + RECOVER % TILE_COLS, RECOVER // TILE_COLS) + delta_check(inside == 128, string.format( + "all 128 bytes of tile %d changed (%d)", RECOVER, inside)) + delta_check(outside == 0, string.format( + "no byte outside that block changed (first stray write at %d/%d)", + stray, ACTIVE_ROW)) + okv, first = block_equals(delta_recover, RECOVER % TILE_COLS, + RECOVER // TILE_COLS, (tile_index(RECOVER) + 2) & 0xFF) + delta_check(okv, string.format( + "the block is dictionary entry %d (first bad byte %d)", + (tile_index(RECOVER) + 2) & 0xFF, first)) + delta_check(prog:read_u32(DICT_READY) == 1 + and prog:read_u32(DICT_BYTES) == TILE_DICT_SIZE + and prog:read_u32(DICT_BAD) == 0, + "the deltas left the dictionary record alone") + okb = prog:read_u16(VRAM_BASE + (VRAM_ROWS - 1) * VRAM_ROW_BITS + 1280) + delta_check(okb == 0xA5C3, string.format( + "the VRAM canary survives the deltas (SENTINEL=%04X)", okb)) + delta_check(prog:read_u32(OS_TOUCH) == 0, + string.format("the OS did not repaint over the deltas (OS_TOUCH=%d)", + prog:read_u32(OS_TOUCH))) + delta_dump(false) + return true + end + return false +end + +return { + step = delta_step, + phase = function() return delta_phase end, + dump = function(timed_out) delta_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 @@ -2231,6 +2888,13 @@ emu.register_frame_done(function() end end + -- Mode delta: changed-tile lists and the periodic keyframe (delta.step()). + if entered and MODE == "delta" then + if delta.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: -- @@ -2348,8 +3012,15 @@ emu.register_frame_done(function() return end + if MODE == "delta" and frames >= STOP_AT then + print(string.format("VID: delta mode did not finish (phase %d) by frame %d", + delta.phase(), frames)) + delta.dump(true) + return + end + if frames >= STOP_AT and MODE ~= "stream" and MODE ~= "ring" - and MODE ~= "dict" and MODE ~= "tiles" + and MODE ~= "dict" and MODE ~= "tiles" and MODE ~= "delta" and not (MODE == "draw" and draw_at ~= nil) then if not entered then print("VID: payload never entered -> FAIL") diff --git a/harness/udpstream/frame_parse.h b/harness/udpstream/frame_parse.h index 913b851..8dd0f75 100644 --- a/harness/udpstream/frame_parse.h +++ b/harness/udpstream/frame_parse.h @@ -171,6 +171,49 @@ * forget it. * --------------------------------------------------------------------------- * + * Task 4/5: the tile payloads, written down here because this is the wire + * format's one C statement (Task 4's review carried the TILES_FULL layout to + * this task for exactly this reason). Both kinds are decoded against the + * dictionary at POOL_PTR + 2 * SLOT_BYTES -- 32,768 bytes, 256 entries of 128 + * bytes -- and only after DICT_READY. The screen is a 40 x 32 grid of 32x32 + * tiles: 1,280 of them, tile (tx, ty) at row-major position ty * 40 + tx. + * + * TILES_FULL (kind 2) is the keyframe: the whole screen, one dictionary index + * per tile, in one byte each. + * + * offset size field + * 0 1280 index[ty * 40 + tx], dictionary entry 0..255 + * + * The payload is EXACTLY 1,280 bytes. A short frame does not name every tile + * and a longer one is not this kind's wire form, so both are refused rather + * than painted as a partial screen. (A 256-entry dictionary is exactly what + * an index byte can name, which is why the entry count is fixed at 256 and not + * a per-clip parameter on the wire.) + * + * TILES_DELTA (kind 3) is the same grid with only the tiles that changed: + * + * offset size field + * 0 2 count, u16 little-endian, 1..1280 + * 2 2 * count position[k], u16 little-endian, row-major 0..1279, + * STRICTLY ASCENDING + * 2 + 2*count count index[k], one byte each: the new dictionary entry + * + * and payload_len is EXACTLY 2 + 3 * count. The position is two bytes because + * the grid has 1,280 tiles while an entry needs one: 256 fits a byte, 1,280 + * does not. The receiver refuses, in TILES_DROP and without painting anything, + * a count of 0 or above 1,280, a length that disagrees with the count (the list + * may not run past the frame), a position off the grid, a repeated or + * descending position, and (defensively: one byte cannot express it) an entry + * above 255. It validates the WHOLE list before the first blit, so one bad + * entry cannot leave half a delta on the screen; positions being ascending is + * what makes that possible with one cursor and no per-tile division. + * + * The keyframe cadence is the host's: KEYFRAME_EVERY = 60 frames (tiledict.py, + * ruling R2), plus a keyframe whenever the status frame's `drops` rises. The + * terminal does not choose it and has no "delta lost" signal of its own -- a + * delta that never arrives is silence. + * --------------------------------------------------------------------------- + * * K&R C only. The 1987 toolchain in build-stream.sh compiles this header, so * there are no ANSI prototypes, no '//' comments and no 'const' (romapi.h is * written the same way): the parameter lists live in the comments and in the @@ -198,6 +241,12 @@ #define FP_KIND_DICT_CHUNK 4 #define FP_KIND_STATUS 5 +/* Task 4/5: the tile wire layout (see the comment block above). A keyframe is + * one index byte per tile; a delta opens with a u16 count and names each + * changed tile with a u16 position and a one-byte entry. */ +#define FP_TILES_SCREEN 1280 /* 40 x 32 tiles, one index byte each */ +#define FP_DELTA_HDR 2 /* bytes of a delta's u16 count */ + /* Marker page cells; see the table above. */ #define MARK_PAGE 0x01810000 #define MARK_CELL(n) (MARK_PAGE + (n) * 8) diff --git a/harness/udpstream/tests/test_tiledict.py b/harness/udpstream/tests/test_tiledict.py index da31272..dfdae60 100644 --- a/harness/udpstream/tests/test_tiledict.py +++ b/harness/udpstream/tests/test_tiledict.py @@ -17,17 +17,29 @@ leaves implicit but the terminal's receiver (Task 3) depends on: Reassembly is asserted byte for byte: concatenating the bodies in arrival order must give back the input, and the last offset plus the last body length must be the end of the dictionary. + +The last group of tests is Task 5's delta encoding, and it pins the *payload*, +not just the host round trip: the C decoder (``vidstream.c``'s ``paint_delta``) +reads the same layout, so what is asserted here is what ``frame_parse.h`` +writes down. ``unpack_delta`` is the host's reading of that layout and shares +none of the encoder's code, so a delta that round-trips through it is not being +blessed by its own writer. """ import pytest from harness.udpstream.frame import (HEADER_SIZE, KIND_DICT_CHUNK, unpack_header) -from harness.udpstream.tiledict import (MAX_PAYLOAD, dict_chunks, payload_body, - payload_offset) +from harness.udpstream.tiledict import (KEYFRAME_EVERY, MAX_PAYLOAD, + delta_frame, dict_chunks, keyframe_due, + payload_body, payload_offset, + unpack_delta) #: A whole dictionary: 256 entries of 128 bytes. DICTIONARY = bytes(range(256)) * 128 +#: One full-screen index frame: 1,280 row-major tile indices (40 x 32). +FRAME = bytes(range(256)) * 5 # exactly 1,280 bytes + def test_chunks_cover_the_dictionary_once_in_order(): d = bytes(range(256)) * 128 # 32 KB @@ -101,3 +113,114 @@ def test_payload_helpers_reject_a_truncated_payload(): header = unpack_header(packet) with pytest.raises(ValueError): payload_body(bytes(packet[:HEADER_SIZE + header["payload_len"] - 1])) + + +# --- Task 5: delta frames and keyframe cadence --------------------------- +# +# The layout pinned here is the one frame_parse.h writes down for the C +# decoder: a u16 count, `count` u16 row-major tile positions and `count` +# one-byte dictionary entries. Positions are two bytes because a screen has +# 1,280 tiles and one byte cannot name tile 1279 -- the *entry* fits in a byte +# (256 of them), the *position* does not. That is the one layout fact this +# group of tests exists to stop from drifting on one side. + +def test_delta_of_two_changed_tiles_lists_exactly_those_two_indices(): + """The brief's test: two tiles changed, two entries and no others.""" + cur = bytearray(FRAME) + cur[7] = 0x80 + cur[1000] = 0xFF + + payload = delta_frame(FRAME, bytes(cur)) + + assert payload is not None + assert unpack_delta(payload) == [(7, 0x80), (1000, 0xFF)] + # 2 bytes of count, two positions and two values. + assert len(payload) == 2 + 2 * 2 + 2 + + +def test_identical_frames_produce_no_delta(): + assert delta_frame(FRAME, FRAME) is None + assert delta_frame(FRAME, bytes(FRAME)) is None + + +def test_a_keyframe_is_due_at_frame_60_and_120(): + assert KEYFRAME_EVERY == 60 + assert keyframe_due(60) is True + assert keyframe_due(120) is True + # Frame 0 has no previous frame, so the first frame is a keyframe too; the + # 59 frames between two keyframes are deltas. + assert keyframe_due(0) is True + assert not any(keyframe_due(n) for n in range(1, 60)) + assert not any(keyframe_due(n) for n in range(61, 120)) + + +def test_a_reported_drop_forces_a_keyframe(): + """The status frame's `drops` is cumulative: any increase means a loss.""" + assert keyframe_due(7, drops=3, seen_drops=2) is True + assert keyframe_due(7, drops=2, seen_drops=2) is False + assert keyframe_due(7, drops=0, seen_drops=0) is False + + +def test_delta_positions_are_strictly_ascending_in_scan_order(): + cur = bytearray(FRAME) + for position in (1279, 0, 640, 639): + cur[position] = (cur[position] + 1) & 0xFF + + payload = delta_frame(FRAME, bytes(cur)) + + assert [p for p, _ in unpack_delta(payload)] == [0, 639, 640, 1279] + + +def test_a_whole_screen_delta_is_the_largest_frame_and_stays_one_slot(): + cur = bytes((b + 1) & 0xFF for b in FRAME) + + payload = delta_frame(FRAME, cur) + + assert len(payload) == 2 + 3 * 1280 + assert unpack_delta(payload) == [(i, cur[i]) for i in range(1280)] + # A delta can never be larger than 2 + 3 * 1,280 = 3,842 bytes; both that + # and a 1,280-byte keyframe fit the payload's 24 KB slot many times over. + assert len(payload) == 3842 + + +def test_encoder_rejects_a_frame_that_is_not_1280_index_bytes(): + with pytest.raises(ValueError): + delta_frame(FRAME[:-1], FRAME) + with pytest.raises(ValueError): + delta_frame(FRAME, FRAME + b"\x00") + with pytest.raises(TypeError): + delta_frame(FRAME, "not bytes") + + +def test_decoder_rejects_a_list_that_disagrees_with_the_length(): + """`payload_len` is authoritative: the list cannot run past it.""" + changed = bytes([FRAME[0] ^ 0xFF]) + FRAME[1:] + good = delta_frame(FRAME, changed) + + with pytest.raises(ValueError): + unpack_delta(good[:-1]) # one value missing + with pytest.raises(ValueError): + unpack_delta(good + b"\x00") # a tail outside the list + with pytest.raises(ValueError): + unpack_delta(b"") # no count field at all + with pytest.raises(ValueError): + unpack_delta(b"\x00\x00") # count 0: the encoder sends None + + +def test_decoder_rejects_a_tile_position_outside_the_grid(): + """A position >= 1,280 must be refused, never wrapped onto another tile.""" + assert unpack_delta(b"\x01\x00" + (1279).to_bytes(2, "little") + b"\x00") \ + == [(1279, 0)] + + for position in (1280, 5000, 0xFFFF): + with pytest.raises(ValueError): + unpack_delta(b"\x01\x00" + + position.to_bytes(2, "little") + b"\x00") + + +def test_decoder_rejects_a_repeated_or_descending_position(): + """Strictly ascending: a tile is named once, in scan order.""" + with pytest.raises(ValueError): + unpack_delta(b"\x02\x00" + bytes([5, 0, 5, 0]) + b"\x00\x01") + with pytest.raises(ValueError): + unpack_delta(b"\x02\x00" + bytes([6, 0, 5, 0]) + b"\x00\x01") diff --git a/harness/udpstream/tiledict.py b/harness/udpstream/tiledict.py index 39a81f3..b2473d3 100644 --- a/harness/udpstream/tiledict.py +++ b/harness/udpstream/tiledict.py @@ -30,6 +30,31 @@ frames. The ``frame_no`` field is not what orders the chunks; the offset is. The payload's ``frame_parse.h`` mirrors :data:`harness.udpstream.frame.KINDS`, which is where ``KIND_DICT_CHUNK`` is defined. +Delta frames +------------ + +The same module carries Task 5's changed-tile list, because it is the other +half of the tile wire format and the two layouts share the dictionary: + + KIND_TILES_FULL (2) 1,280 dictionary indices, row-major, one byte each + KIND_TILES_DELTA (3) a u16 count, then `count` u16 positions and + `count` one-byte dictionary entries + +A position is a row-major tile index 0..1,279, so it needs **two** bytes while +one dictionary entry fits in one -- the entry space is 256, the tile grid is +1,280. :func:`delta_frame` writes the payload, :func:`unpack_delta` reads it +back with the same rules the C decoder enforces (exact length, positions on the +grid and strictly ascending), and :func:`keyframe_due` is the cadence rule: +a ``TILES_FULL`` whenever the host has no previous frame, every +:data:`KEYFRAME_EVERY` frames, and as soon as the terminal's status frame +reports a drop. + +Positions travel separately from values (rather than interleaved as pairs) for +one reason: a receiver can validate the whole position list before it touches a +pixel, so a frame with one bad position paints **nothing**. Interleaved pairs +invite a decoder that paints as it reads, which is the failure the harness's +off-grid negative control exists to catch. + Pure standard library. """ @@ -37,10 +62,12 @@ import struct from harness.udpstream.frame import (HEADER_SIZE, KIND_DICT_CHUNK, MAGIC, VERSION, pack_header, unpack_header) +from harness.udpstream.tilecost import TILES_PER_SCREEN __all__ = [ - "MAX_BODY", "MAX_DICT_BYTES", "MAX_PAYLOAD", "OFFSET_SIZE", - "dict_chunks", "payload_body", "payload_offset", + "DELTA_HDR", "KEYFRAME_EVERY", "MAX_BODY", "MAX_DICT_BYTES", "MAX_PAYLOAD", + "OFFSET_SIZE", "delta_frame", "dict_chunks", "keyframe_due", + "payload_body", "payload_offset", "unpack_delta", ] #: Largest chunk payload in one packet, offset included. The same 1450-byte @@ -57,7 +84,16 @@ MAX_BODY = MAX_PAYLOAD - OFFSET_SIZE # 1,448 #: offset 65,536 is still addressable: its last chunk's offset fits. MAX_DICT_BYTES = 0x10000 +#: A delta payload opens with the u16 entry count (see frame_parse.h). +DELTA_HDR = 2 + +#: Send a full frame (``TILES_FULL``) this often, whether or not anything was +#: lost. Ruling R2 of the plan: 60 frames is ~2 s at the sender's 30 fps, so a +#: desynchronised screen can never stay wrong for longer than that. +KEYFRAME_EVERY = 60 + _OFFSET = struct.Struct(" bytes: @@ -128,3 +164,119 @@ def payload_offset(packet: bytes) -> int: def payload_body(packet: bytes) -> bytes: """The dictionary bytes ``packet`` carries, offset excluded.""" return _dict_payload(packet)[OFFSET_SIZE:] + + +# --- Task 5: changed-tile lists ------------------------------------------- + +def _indices(name: str, frame) -> bytes: + """Range-check one full-screen index frame and return it as ``bytes``.""" + if not isinstance(frame, (bytes, bytearray, memoryview)): + raise TypeError("%s is bytes-like, got %s" + % (name, type(frame).__name__)) + data = bytes(frame) + if len(data) != TILES_PER_SCREEN: + raise ValueError("%s is one index byte per tile: %d bytes, got %d" + % (name, TILES_PER_SCREEN, len(data))) + return data + + +def delta_frame(prev: bytes, cur: bytes) -> bytes | None: + """Return the ``KIND_TILES_DELTA`` payload from ``prev`` to ``cur``. + + ``None`` when the frames are identical: there is nothing to put on the + wire, and an empty list is not a frame the receiver should have to accept. + Otherwise the payload is + + u16 count | count * u16 position | count * u8 dictionary entry + + with positions in ascending order (a scan of the row-major grid produces + them that way) and each entry the *new* value for that tile. Everything is + recomputed from the two frames -- nothing is carried over from a previous + delta -- so a delta lost in transit cannot corrupt the next one; it is the + periodic keyframe that repairs the screen. + """ + before = _indices("prev", prev) + after = _indices("cur", cur) + + positions = [i for i in range(TILES_PER_SCREEN) if before[i] != after[i]] + if not positions: + return None + values = bytes(after[i] for i in positions) + return (_COUNT.pack(len(positions)) + + b"".join(_COUNT.pack(i) for i in positions) + + values) + + +def unpack_delta(payload: bytes) -> list[tuple[int, int]]: + """Decode a ``KIND_TILES_DELTA`` payload, or raise ``ValueError``. + + The rules are the C decoder's (``paint_delta`` in ``vidstream.c``), and + they are the reason a delta cannot corrupt the screen: + + * the payload holds exactly ``2 + 3 * count`` bytes, so the list cannot run + past the frame the header described; + * ``count`` is at least 1 and at most :data:`TILES_PER_SCREEN`; + * every position is on the 40 x 32 grid, so no blit is ever aimed past the + last tile; + * positions are strictly ascending, so no tile is named twice and a + reordered list is a refusal rather than a different picture. + + Each value is a byte and therefore already a dictionary entry, so the + "entry index < 256" rule needs no check on this side. + """ + if not isinstance(payload, (bytes, bytearray, memoryview)): + raise TypeError("a delta payload is bytes-like, got %s" + % type(payload).__name__) + body = bytes(payload) + if len(body) < DELTA_HDR: + raise ValueError("a delta payload opens with a %d-byte count, got %d" + % (DELTA_HDR, len(body))) + count = _COUNT.unpack_from(body, 0)[0] + if not 1 <= count <= TILES_PER_SCREEN: + raise ValueError("a delta lists 1..%d tiles, got %d" + % (TILES_PER_SCREEN, count)) + if len(body) != DELTA_HDR + 3 * count: + raise ValueError("a %d-entry delta is %d bytes, got %d" + % (count, DELTA_HDR + 3 * count, len(body))) + + positions = [] + previous = -1 + for k in range(count): + position = _COUNT.unpack_from(body, DELTA_HDR + 2 * k)[0] + if position >= TILES_PER_SCREEN: + raise ValueError("tile position %d is off the %d-tile grid" + % (position, TILES_PER_SCREEN)) + if position <= previous: + raise ValueError("tile positions must be strictly ascending: " + "%d follows %d" % (position, previous)) + previous = position + positions.append(position) + values = body[DELTA_HDR + 2 * count:] + return list(zip(positions, values)) + + +def keyframe_due(frame_no: int, drops: int = 0, seen_drops: int = 0, + every: int = KEYFRAME_EVERY) -> bool: + """True when the host must send a ``TILES_FULL`` instead of a delta. + + Two triggers, and both are "the terminal may not have what the host thinks + it has": + + * ``frame_no`` is a multiple of ``every`` -- a fixed cadence that bounds + how long a desynchronised screen can stay wrong (ruling R2). Frame 0 + is in this set because there is no previous frame to delta against; + * ``drops`` rose since ``seen_drops``: the status frame's counter is + cumulative, so any increase means at least one frame never reached the + screen and the host's idea of the screen is stale. + + ``crc_err`` is deliberately not a trigger: a refused frame never painted, + so it cannot have desynchronised anything, and the cadence repairs a drop + the terminal never counted. + """ + if (not isinstance(frame_no, int) or isinstance(frame_no, bool) + or frame_no < 0): + raise ValueError("frame_no is a non-negative int, got %r" + % (frame_no,)) + if not isinstance(every, int) or isinstance(every, bool) or every < 1: + raise ValueError("every is a positive int, got %r" % (every,)) + return frame_no % every == 0 or drops > seen_drops diff --git a/harness/udpstream/vidstream.c b/harness/udpstream/vidstream.c index 0f5ed6b..bc55f24 100644 --- a/harness/udpstream/vidstream.c +++ b/harness/udpstream/vidstream.c @@ -19,7 +19,8 @@ * dictionary is complete is refused and counted instead of being mistaken for * a strip. Plan 2's Task 4 paints TILES_FULL: one PIXBLT B,XY per 32x32 tile * from the dictionary (vram_tile.asm), 1,280 of them per frame inside one - * dint/eint pair. TILES_DELTA is still counted, not painted (Task 5). + * dint/eint pair. Plan 2's Task 5 decodes TILES_DELTA: a validated changed-tile + * list painted by the same per-tile primitive, only the tiles it names. * * Pool layout inside the single allocation (POOL_BYTES = 249856 = 244 KB): * @@ -152,7 +153,8 @@ */ #define DICT_OFF (2 * SLOT_BYTES) /* 0x0C000: after the two slots */ #define DICT_ENTRY 128 /* one 32x32 1-bpp tile, in bytes */ -#define DICT_SIZE (DICT_ENTRY * 256) /* 256 entries = 32 KB */ +#define DICT_ENTRIES 256 /* entries: an index is one byte */ +#define DICT_SIZE (DICT_ENTRY * DICT_ENTRIES) /* 256 entries = 32 KB */ /* * Task 4: a TILES_FULL frame is 1,280 dictionary indices, row-major, one byte @@ -183,6 +185,14 @@ */ char dict_slack_check[(POOL_BYTES >= (DICT_OFF + DICT_SIZE)) ? 1 : -1]; +/* + * Task 5: the wire's tile count and the decoder's geometry must be the same + * grid. frame_parse.h pins it (FP_TILES_SCREEN, the layout block) and this is + * the build-time half: a disagreement is a compile error rather than a paint + * that lands one tile off. Same trick as dict_slack_check above. + */ +char tile_grid_check[(TILES_SCREEN == FP_TILES_SCREEN) ? 1 : -1]; + /* * Task 6: the terminal's status frame. * @@ -582,6 +592,173 @@ fp_header *h; return 1; } +/* + * frag_get(pos, vp) -- one frame byte at frame offset `pos`. 1 on success, 0 + * when the fragment table does not hold it (a gap, or a frame shorter than the + * position asked for). + * + * paint_delta() reads single bytes at offsets its own arithmetic derives -- the + * count, each position and each value -- where frag_copy() moves one contiguous + * run. Same table, same rules: no copy across a gap and nothing past the last + * delivered byte. + */ +static int frag_get(pos, vp) +unsigned int pos; +unsigned int *vp; +{ + unsigned int k; + + k = 0; + while (k < rx_nfrag && pos >= rx_frag_off[k] + rx_frag_len[k]) { + k = k + 1; + } + if (k >= rx_nfrag) { + return 0; + } + if (pos < rx_frag_off[k]) { + return 0; + } + *vp = (unsigned int)*(unsigned char *) + (rx_frag_ptr[k] + (pos - rx_frag_off[k]) * 8); + return 1; +} + +/* + * paint_delta(h) -- draw one accepted TILES_DELTA frame. 1 when the list was + * decoded and painted, 0 when the frame was refused (the caller counts that in + * TILES_DROP and VRAM is untouched). + * + * The wire form (frame_parse.h, tiledict.py's delta_frame) is + * + * count u16 | count x position u16 | count x entry u8 + * + * with payload_len exactly 2 + 3 * count. A position is the row-major tile + * index ty * TILE_COLS + tx and must be strictly ascending; an entry is a + * dictionary index, one byte. + * + * Two passes, and the first one is the safety property: EVERY position and + * entry is read and bounds-checked before the first blit. A frame with one bad + * entry therefore paints nothing at all, where a decoder that painted as it + * read would leave half a delta on the screen -- and half a delta is worse than + * none, because the next delta is computed against a frame this side never had. + * + * Refused (nothing painted), in the order the checks run: + * - no complete dictionary (DICT_READY 0 or no base), as for TILES_FULL; + * - payload_len < 2: no count field; + * - count 0 or above TILES_SCREEN: an empty list is not sent (the encoder + * returns None) and a screen cannot have more changed tiles than tiles; + * - payload_len != 2 + 3 * count: the list would run past the frame, or the + * frame carries bytes that are not part of the list; + * - a position >= TILES_SCREEN: off the 40x32 grid (a blit aimed there would + * write past the last tile band); + * - a position not greater than the previous one: the list is not canonical, + * and a repeated tile would be painted twice for no reason. This is also + * what lets the paint pass run with one cursor: strictly ascending means + * the nested grid loop visits every named tile exactly once; + * - an entry >= DICT_ENTRIES: one byte cannot express it, so this check is + * dead today, but it is the bound that keeps a future wider encoding from + * reading past the 32 KB dictionary. It is written out rather than left to + * the byte width. + * + * The paint pass walks the 40x32 grid once, comparing the running tile position + * with the next named one -- no division, no search, and the same + * one-PIXBLT-per-tile primitive (and dint/eint pair and canary) as + * paint_tiles(). A tile the list does not name is not touched at all, which is + * the whole point of a delta. + * + * Because the list was validated a moment ago from the same slot, the reads in + * the paint pass cannot fail; their return values are ignored deliberately, and + * the slot cannot be written while this frame is being decoded (rx_switch() + * armed the OTHER slot before the dispatch). + */ +static int paint_delta(h) +fp_header *h; +{ + unsigned int count, k, pos, val, prev, lo, hi, sent; + unsigned int tile, tx, ty; + + if (!dict_ready || dict_base == 0) { + return 0; + } + if (h->payload_len < FP_DELTA_HDR) { + return 0; + } + if (!frag_get(h->payload_off, &lo) + || !frag_get(h->payload_off + 1, &hi)) { + return 0; + } + count = lo | (hi << 8); + if (count == 0 || count > TILES_SCREEN) { + return 0; + } + if (h->payload_len != FP_DELTA_HDR + count * 3) { + return 0; + } + + /* Pass 1: the whole list, before anything is painted. */ + prev = 0; + for (k = 0; k < count; k++) { + if (!frag_get(h->payload_off + FP_DELTA_HDR + k * 2, &lo) + || !frag_get(h->payload_off + FP_DELTA_HDR + k * 2 + 1, + &hi)) { + return 0; + } + pos = lo | (hi << 8); + if (pos >= TILES_SCREEN) { + return 0; + } + if (k > 0 && pos <= prev) { + return 0; + } + prev = pos; + if (!frag_get(h->payload_off + FP_DELTA_HDR + count * 2 + k, + &val)) { + return 0; + } + if (val >= DICT_ENTRIES) { + return 0; + } + } + + /* The same canary rule as paint_tiles(): outside every tile, so an + * unchanged value means nobody painted over this frame's screen. */ + sent = (unsigned int)*(unsigned short *)SENT_ADDR; + if (sent != SENT_VALUE) { + *(unsigned int *)OS_TOUCH = *(unsigned int *)OS_TOUCH + 1; + *(unsigned short *)SENT_ADDR = SENT_VALUE; + } + + /* Pass 2: walk the grid, paint the named tiles. `pos` holds the next + * named position; `tile` is the grid position the loop is on. */ + vid_dint_(); + frag_get(h->payload_off + FP_DELTA_HDR, &lo); + frag_get(h->payload_off + FP_DELTA_HDR + 1, &hi); + pos = lo | (hi << 8); + k = 0; + tile = 0; + for (ty = 0; ty < TILE_ROWS; ty++) { + for (tx = 0; tx < TILE_COLS; tx++) { + if (k < count && pos == tile) { + frag_get(h->payload_off + FP_DELTA_HDR + + count * 2 + k, &val); + tile_blit(dict_base + (val << TILE_SHIFT), + tx, ty); + k = k + 1; + if (k < count) { + frag_get(h->payload_off + FP_DELTA_HDR + + k * 2, &lo); + frag_get(h->payload_off + FP_DELTA_HDR + + k * 2 + 1, &hi); + pos = lo | (hi << 8); + } + } + tile = tile + 1; + } + } + vid_eint_(); + return 1; +} + /* * frag_copy(dst, pos, n) -- copy n frame bytes starting at frame offset `pos` * into memory at bit address `dst`. 1 on success, 0 when the fragment table @@ -986,17 +1163,20 @@ unsigned int i; } } else if (h.kind == FP_KIND_TILES_DELTA) { /* - * Task 5 decodes deltas. This tree has no delta - * decoder, and the point of the branch is that such a - * frame is never silently forgotten: it is counted in - * TILES_DROP and does not touch VRAM, so a delta frame - * cannot leave half a picture on the screen. (A delta - * frame that arrives before the dictionary is dropped - * here too; the dictionary is fixed for the clip, so - * DICT_READY is the same condition either way.) + * Task 5: a changed-tile list. paint_delta() validates + * the whole list before it paints, so a refused frame + * touches no pixel; a frame it accepts moves the same + * record cells a TILES_FULL does, and only then. */ - tiles_drop = tiles_drop + 1; - *(unsigned int *)TILES_DROP = tiles_drop; + if (paint_delta(&h)) { + *(unsigned int *)RX_LAST_LEN = h.payload_len; + *(unsigned int *)RX_SLOT = slot; + *(unsigned int *)RX_OK = + *(unsigned int *)RX_OK + 1; + } else { + tiles_drop = tiles_drop + 1; + *(unsigned int *)TILES_DROP = tiles_drop; + } } else { /* A well-formed header whose kind nothing here dispatches * (KIND_STATUS is the terminal's own, travelling the other