feat(tiles): delta frames and periodic keyframes

This commit is contained in:
opencode.energia.arti.ee committed 2026-10-31 02:38:40 +00:00
1 parent 2fa232ba56
commit 8f8454461d
6 files changed
+1214 -19

No files matched your search

+20
View File
@@ -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"
+674 -3
View File
@@ -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")
+49
View File
@@ -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)
+125 -2
View File
@@ -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")
+154 -2
View File
@@ -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("<H")
_COUNT = struct.Struct("<H")
def _dict_payload(packet: bytes) -> 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
+192 -12
View File
@@ -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