From 0b726fda3a885e99cdda6e1c69cc11bf1bed9c0e Mon Sep 17 00:00:00 2001 From: "opencode.energia.arti.ee" Date: Sat, 31 Oct 2026 01:54:15 +0000 Subject: [PATCH] feat(tiles): dictionary storage and kind dispatch in the payload The payload assembles KIND_DICT_CHUNK frames into a 32 KB dictionary at POOL_PTR + 2 * SLOT_BYTES and learns the tile kinds: * frame_parse.h gains FP_KIND_TILES_FULL (2), FP_KIND_TILES_DELTA (3) and FP_KIND_DICT_CHUNK (4), exactly frame.py's KINDS table, plus the marker cells DICT_READY/DICT_BYTES/DICT_BAD and TILES_DROP. A host test parses the header and compares it with KINDS so the two cannot drift apart. * dict_chunk() enforces an ascending, contiguous offset (no gap, overlap, duplicate or reorder; a mid-entry offset only as a continuation), a chunk that does not overrun the last entry, and no chunk after DICT_READY. A refused chunk leaves the prefix alone. Readiness is exactly 32768 bytes (256 whole entries), so a short upload never becomes ready. * The dispatcher routes STRIP to the blit, DICT_CHUNK to the assembler, and refuses TILES_FULL/TILES_DELTA without painting, counting them in TILES_DROP (Task 4 adds the tile blit). * A compile-time assertion refuses a POOL_BYTES that cannot hold the dictionary; the 1987 preprocessor does not fold macro arithmetic in #if, so it is a defined array whose negative length fails the build. * VT1200_MODE=dict injects both tile kinds before the dictionary, 23 contiguous chunks with a duplicate offset, a gap and an overrunning tail mixed in, and reads the stored 32 KB back byte for byte. --- harness/mame/run-vidstream.sh | 11 + harness/mame/vidstream.lua | 453 +++++++++++++++++++++++++- harness/udpstream/frame_parse.h | 81 ++++- harness/udpstream/tests/test_frame.py | 27 +- harness/udpstream/vidstream.c | 265 ++++++++++++++- 5 files changed, 817 insertions(+), 20 deletions(-) diff --git a/harness/mame/run-vidstream.sh b/harness/mame/run-vidstream.sh index 80217bf..80416c1 100755 --- a/harness/mame/run-vidstream.sh +++ b/harness/mame/run-vidstream.sh @@ -7,6 +7,7 @@ # VT1200_MODE=draw harness/mame/run-vidstream.sh # alloc + rx + the blit # VT1200_MODE=stream harness/mame/run-vidstream.sh # the status back-channel # VT1200_MODE=ring harness/mame/run-vidstream.sh # the RX ring's structure +# VT1200_MODE=dict harness/mame/run-vidstream.sh # dictionary upload + kinds # 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 @@ -34,6 +35,13 @@ # carries no bytes) counted in RX_FCS_DROP with its frame abandoned. The verdict # is in the Lua; the evidence is $LOGDIR/ring-status.txt. # +# Mode dict asserts the dictionary assembler and the kind dispatch: a TILES_FULL +# frame before the dictionary is rejected without painting, a 32 KB upload in 23 +# KIND_DICT_CHUNK frames (with a duplicate offset and an overrunning tail chunk +# refused) makes DICT_READY 1 and DICT_BYTES 32,768, and the stored bytes are +# compared with what was sent. The verdict is in the Lua; the evidence is +# $LOGDIR/dict-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 @@ -90,6 +98,9 @@ fi if [ "$MODE" = "ring" ]; then rm -f "$LOGDIR/ring-status.txt" fi +if [ "$MODE" = "dict" ]; then + rm -f "$LOGDIR/dict-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 fe63cf4..63bccf0 100644 --- a/harness/mame/vidstream.lua +++ b/harness/mame/vidstream.lua @@ -59,6 +59,16 @@ -- It writes ring-status.txt and prints the verdict itself: these are structural -- facts about the descriptors, not pixels (draw mode owns the pixels). -- +-- Mode "dict" (Task 3) is the dictionary assembler and the kind dispatch. It +-- sends a TILES_FULL (2) and a TILES_DELTA (3) frame before there is a +-- dictionary and asserts both are rejected without painting; then a 32 KB +-- KIND_DICT_CHUNK (4) upload in 23 chunks with a duplicate-offset chunk, a +-- gapped-offset chunk and an overrunning tail chunk mixed in; then it reads the +-- 32 KB back from POOL_PTR + 2 * SLOT_BYTES and compares it with what was sent. +-- DICT_READY/DICT_BYTES/DICT_BAD are asserted after every chunk, so a +-- premature "ready" fails at the chunk that caused it. Verdict and evidence: +-- the printed checks and $DUMP_DIR/dict-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 @@ -78,10 +88,10 @@ -- harness/udpstream/frame_parse.h. -- -- Env: --- VT1200_MODE alloc (default), rx, draw, stream or ring +-- VT1200_MODE alloc (default), rx, draw, stream, ring or dict -- 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 and ring modes write their dumps +-- VID_DUMP_DIR where draw, stream, ring and dict modes write their dumps -- (default /tmp/opencode) -- VID_SENTINEL_CLOBBER=1 draw mode only: overwrite the VRAM canary word -- before the frame is delivered, so the payload must count @@ -95,6 +105,8 @@ -- status after RX_EOF (default 900) -- VID_RING_WAIT ring mode only: emulated frames to wait for each ring -- transition (default 300) +-- VID_DICT_WAIT dict mode only: emulated frames to wait for each dictionary +-- chunk 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) @@ -127,6 +139,9 @@ if MODE == "stream" and not os.getenv("VID_END") then elseif MODE == "ring" and not os.getenv("VID_END") then -- Three phases, each with its own bounded wait (VID_RING_WAIT, 300). DEFAULT_END = AT + 3 * tonumber(os.getenv("VID_RING_WAIT") or "300") + 120 +elseif MODE == "dict" and not os.getenv("VID_END") then + -- Five phases, each with its own bounded wait (VID_DICT_WAIT, 300). + DEFAULT_END = AT + 5 * tonumber(os.getenv("VID_DICT_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") @@ -177,6 +192,14 @@ local ST_TMD1 = MARK_PAGE + 0x88 * 8 local ST_INIT = MARK_PAGE + 0x8c * 8 local ST_TMLAST = MARK_PAGE + 0x90 * 8 +-- Task 3: the dictionary record and the tile-frame drop count (frame_parse.h). +-- Cells 0x50..0x5c fill the free gap between RX_FCS_DROP (0x4c) and RX_SLOT_A +-- (0x60), at the same four-byte spacing every other cell uses. +local DICT_READY = MARK_PAGE + 0x50 * 8 +local DICT_BYTES = MARK_PAGE + 0x54 * 8 +local DICT_BAD = MARK_PAGE + 0x58 * 8 +local TILES_DROP = MARK_PAGE + 0x5c * 8 + -- Must match vidstream.c. local SLOT_BYTES = 24576 @@ -249,7 +272,7 @@ local VRAM_ROW_BYTES = 256 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" then + and MODE ~= "stream" and MODE ~= "ring" and MODE ~= "dict" then print("VID ERROR: mode '" .. MODE .. "' is not implemented") machine:exit() return @@ -564,6 +587,10 @@ local DRAW_FILES = { "vram-draw.bin", "frame-draw.bin", "draw-status.txt" } local STREAM_FILES = { "stream-status.bin", "stream-status.txt" } local function clear_dump_files() + if MODE == "dict" then + os.remove(DUMP_DIR .. "/dict-status.txt") + return + end if MODE ~= "draw" and MODE ~= "stream" then return end @@ -1154,6 +1181,411 @@ local function ring_step() return false end +-- --- Task 3: the dictionary upload and kind dispatch ------------------------- +-- +-- Mode "dict" is the dictionary assembler's check. It plays the sender with +-- tiledict.py's wire format -- KIND_DICT_CHUNK (4) frames, each a u16 +-- little-endian dictionary offset followed by up to 1,448 dictionary bytes -- +-- and asserts what the payload must do with them: +-- +-- * a TILES_FULL (2) and a TILES_DELTA (3) frame that arrive before the +-- dictionary is complete are rejected without painting: TILES_DROP ticks +-- twice, RX_OK stays 0 and the screen does not change; +-- * 23 contiguous chunks make DICT_BYTES 32,768 and DICT_READY 1, and the +-- 32 KB the payload holds is byte for byte what was sent; +-- * a chunk whose offset is not the next one is refused with DICT_BAD and the +-- upload continues from where it was: a duplicate (overlapping/descending) +-- and a gap are both exercised; +-- * a tail chunk that would overrun the last 128-byte entry is refused the +-- same way -- the "partial final entry" Task 2's review carried; +-- * DICT_READY stays 0 after every short prefix: it is set by the chunk that +-- completes exactly 256 entries, not by the first one. +-- +-- The dictionary is the payload's own copy at POOL_PTR + 2 * SLOT_BYTES (the +-- plan's placement, which Task 4 consumes). This script reads it back from +-- there rather than asking the payload where it put it, so a wrong base is a +-- failure of this check rather than something both ends could agree on. + +local KIND_TILES_FULL = 2 +local KIND_TILES_DELTA = 3 +local KIND_DICT_CHUNK = 4 + +-- Must match vidstream.c and tiledict.py. +local DICT_ENTRY = 128 +local DICT_SIZE = 32768 -- 256 entries x 128 B +local DICT_CHUNK_BODY = 1448 -- tiledict.MAX_BODY +local DICT_OFF = 2 * SLOT_BYTES -- 0xC000: after the two slots + +local DICT_WAIT = tonumber(os.getenv("VID_DICT_WAIT") or "300") +local dict_phase = 0 +local dict_phase_at = nil +local dict_fail = 0 +local dict_lines = {} +local dict_vram0 = nil +local dict_pos = 0 -- RX_RING_POS expected once consumed +local dict_plan = nil +local dict_at = 0 +local dict_last = nil -- the plan entry awaiting its settle + +-- The dictionary pattern: a distinct byte for every position, and every +-- 128-byte entry different from its neighbours. A constant fill would let a +-- wrong entry stride, a reversed copy or a chunk stored twice all pass, and a +-- mismatch here would then be evidence of nothing. +local function dict_byte(i) + return (i * 13 + (i // DICT_ENTRY) * 7 + 3) & 0xFF +end + +-- One KIND_DICT_CHUNK frame: offset u16 little-endian, then `body` dictionary +-- bytes taken from `off`. Bytes past the dictionary are filled with 0xEE so +-- the oversize case carries a recognisable body rather than a repeat. +local function dict_chunk_frame(off, body) + local f = make_frame(KIND_DICT_CHUNK, 0, 0, 0, 2 + body, function(k) + local p + + if k == 0 then return off & 0xFF end + if k == 1 then return (off >> 8) & 0xFF end + p = off + k - 2 + if p >= DICT_SIZE then return 0xEE end + return dict_byte(p) + end) + return f, 16 + 2 + body +end + +local function dict_chunk(i) + local off = i * DICT_CHUNK_BODY + local n = DICT_SIZE - off + + if n > DICT_CHUNK_BODY then + n = DICT_CHUNK_BODY + end + return dict_chunk_frame(off, n) +end + +local function dict_check(cond, what) + if cond then + dict_lines[#dict_lines + 1] = "PASS " .. what + print("VID: dict PASS: " .. what) + else + dict_lines[#dict_lines + 1] = "FAIL " .. what + print("VID: dict FAIL: " .. what) + dict_fail = dict_fail + 1 + end + return cond +end + +-- The plan: 11 chunks, a duplicate-offset chunk, a gapped chunk, chunks +-- 11..21, a tail chunk that overruns entry 255, then the chunk that completes +-- the dictionary. Each entry carries what the record must read once the +-- payload has consumed it, so "premature" DICT_READY and a miscounted +-- DICT_BYTES/DICT_BAD fail at the chunk that caused them instead of at the end +-- of the run. +local function dict_build_plan() + local plan = {} + local i, f, n, off + + for i = 0, 10 do + f, n = dict_chunk(i) + plan[#plan + 1] = { f = f, n = n, + bytes = (i + 1) * DICT_CHUNK_BODY, bad = 0, ready = 0, + label = "chunk " .. i } + end + off = 2 * DICT_CHUNK_BODY + f, n = dict_chunk_frame(off, DICT_CHUNK_BODY) + plan[#plan + 1] = { f = f, n = n, bytes = 11 * DICT_CHUNK_BODY, + bad = 1, ready = 0, + label = string.format("chunk at duplicate offset %d (must be refused)", off) } + -- A gap: the offset skips ahead of the prefix instead of repeating it. + off = 31 * DICT_CHUNK_BODY + f, n = dict_chunk_frame(off, DICT_CHUNK_BODY) + plan[#plan + 1] = { f = f, n = n, bytes = 11 * DICT_CHUNK_BODY, + bad = 2, ready = 0, + label = string.format("chunk at gapped offset %d (must be refused)", off) } + for i = 11, 21 do + f, n = dict_chunk(i) + plan[#plan + 1] = { f = f, n = n, + bytes = (i + 1) * DICT_CHUNK_BODY, bad = 2, ready = 0, + label = "chunk " .. i } + end + off = 22 * DICT_CHUNK_BODY + f, n = dict_chunk_frame(off, DICT_CHUNK_BODY) + plan[#plan + 1] = { f = f, n = n, bytes = 22 * DICT_CHUNK_BODY, + bad = 3, ready = 0, + label = string.format("tail chunk at %d overrunning entry 255 (must be refused)", off) } + f, n = dict_chunk(22) + plan[#plan + 1] = { f = f, n = n, bytes = DICT_SIZE, bad = 3, ready = 1, + label = "chunk 22 (completes 256 entries)" } + return plan +end + +-- The payload has finished with the last injection. +-- +-- RX_DGRAM is NOT the signal: rx_poll() bumps it before it processes the +-- datagram, so a read between the bump and the frame completion sees the count +-- advanced while DICT_BYTES is still the old one (measured: the first run read +-- DICT_BYTES=0 for a chunk it had just stored). RX_RING_POS and RX_RING_ACC +-- are published together at the END of rx_poll(), after the frame has been +-- dispatched, so POS naming the descriptor after the last injected one AND no +-- frame in progress is a consistent "done" -- the DICT_* writes precede both +-- in program order. +local function dict_settled() + return prog:read_u32(RX_RING_POS) == dict_pos + and prog:read_u32(RX_RING_ACC) == 0 +end + +local function dict_take(a) + local ndg = inject_frame(a.f, a.n, DRAW_CHUNK) + + if ndg == nil then + return false + end + -- Cumulative: only ever called when the previous batch is settled, so + -- dict_pos is RX_RING_POS at the moment of injection, and two frames can + -- be handed over back to back. + dict_pos = (dict_pos + ndg) % RX_DESC + dict_last = a + return true +end + +-- Read the 32 KB back from POOL_PTR + 2 * SLOT_BYTES and compare it with the +-- pattern that was sent. Returns ok, first bad byte (-1 if none) and why not. +local function dict_verify() + local base = prog:read_u32(POOL_PTR) + local bit, bad, first, i, v, want + + if base == 0 then + return false, -1, "POOL_PTR is zero" + end + bit = base + DICT_OFF * 8 + bad = 0 + first = -1 + for i = 0, DICT_SIZE // 2 - 1 do + v = prog:read_u16(bit + i * 16) + want = dict_byte(i * 2) | (dict_byte(i * 2 + 1) << 8) + if v ~= want then + bad = bad + 1 + if first < 0 then + first = i * 2 + end + end + end + return bad == 0, first, nil +end + +local function dict_dump(timed_out) + local lines = { + 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("TILES_DROP=%d", prog:read_u32(TILES_DROP)), + string.format("RX_OK=%d", prog:read_u32(RX_OK)), + string.format("RX_BAD=%d", prog:read_u32(RX_BAD)), + string.format("RX_DGRAM=%d", prog:read_u32(RX_DGRAM)), + string.format("POOL_PTR=0x%X", prog:read_u32(POOL_PTR)), + string.format("DICT_AT=0x%X", prog:read_u32(POOL_PTR) + DICT_OFF * 8), + string.format("DICT_SIZE=%d", DICT_SIZE), + string.format("VRAM_ROW0_BEFORE=%s", dict_vram0 and hex_bytes(dict_vram0) or "-"), + string.format("VRAM_ROW0_AFTER=%s", hex_bytes(read_vram_head(32))), + string.format("DICT_CHECKS_FAILED=%d", dict_fail), + string.format("TIMEOUT=%d", timed_out and 1 or 0), + } + for _, l in ipairs(dict_lines) do + lines[#lines + 1] = "CHECK " .. l + end + local okf = write_file(DUMP_DIR .. "/dict-status.txt", + table.concat(lines, "\n") .. "\n") + local ok = okf and not timed_out and dict_fail == 0 + print(string.format("VID: dict: %d checks failed; DICT_READY=%d DICT_BYTES=%d DICT_BAD=%d TILES_DROP=%d", + dict_fail, prog:read_u32(DICT_READY), prog:read_u32(DICT_BYTES), + prog:read_u32(DICT_BAD), prog:read_u32(TILES_DROP))) + print(ok 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 dict_step() + local a, f, n, okv, first, why, after + + if dict_phase == 0 then + if dict_phase_at == nil then + dict_phase_at = frames + end + if not ring_ready() then + if frames >= dict_phase_at + DICT_WAIT then + dict_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))) + dict_dump(true) + return true + end + return false + end + dict_vram0 = read_vram_head(32) + local _, pos0 = ring_state() + dict_pos = pos0 + a = { f = make_frame(KIND_TILES_FULL, 0x0061, 0, 0, + DRAW_ROWS * RX_ROW_BYTES, + function(i) return (i * 5 + 1) & 0xFF end), + n = 16 + DRAW_ROWS * RX_ROW_BYTES } + if not dict_take(a) then + dict_check(false, "the ring accepted the pre-dictionary TILES_FULL frame") + dict_dump(true) + return true + end + dict_phase = 1 + dict_phase_at = frames + return false + end + + if dict_phase == 1 then + if not dict_settled() then + if frames >= dict_phase_at + DICT_WAIT then + dict_check(false, "the pre-dictionary TILES_FULL frame was consumed") + dict_dump(true) + return true + end + return false + end + dict_check(prog:read_u32(TILES_DROP) == 1, + string.format("a TILES_FULL before the dictionary is rejected and counted (TILES_DROP=%d)", + prog:read_u32(TILES_DROP))) + dict_check(prog:read_u32(DICT_READY) == 0, "DICT_READY is still 0") + dict_check(prog:read_u32(RX_OK) == 0, + "RX_OK did not move: the tile frame painted nothing") + after = read_vram_head(32) + dict_check(dict_vram0 ~= nil and after == dict_vram0, + "the screen did not change: the tile frame was not blitted") + dict_check(prog:read_u32(DICT_BYTES) == 0, "DICT_BYTES is still 0") + -- The other tile kind, also before there is anything to decode with. + -- A TILES_DELTA is 8 bytes of changed-tile list here; nothing may + -- read it. One frame per settle: the payload re-arms the whole ring + -- on the other slot when it completes a frame, so a frame injected + -- behind another would be wiped by that re-arm. + a = { f = make_frame(KIND_TILES_DELTA, 0x0063, 0, 0, 8, + function(i) return (i * 3 + 1) & 0xFF end), + n = 16 + 8 } + if not dict_take(a) then + dict_check(false, "the ring accepted the pre-dictionary TILES_DELTA frame") + dict_dump(true) + return true + end + dict_phase = 2 + dict_phase_at = frames + return false + end + + if dict_phase == 2 then + if not dict_settled() then + if frames >= dict_phase_at + DICT_WAIT then + dict_check(false, "the pre-dictionary TILES_DELTA frame was consumed") + dict_dump(true) + return true + end + return false + end + dict_check(prog:read_u32(TILES_DROP) == 2, + string.format("TILES_FULL and TILES_DELTA before the dictionary are both rejected and counted (TILES_DROP=%d)", + prog:read_u32(TILES_DROP))) + dict_check(prog:read_u32(DICT_READY) == 0, + "DICT_READY is still 0 after both tile frames") + dict_check(prog:read_u32(RX_OK) == 0, + "RX_OK did not move: neither tile frame painted anything") + after = read_vram_head(32) + dict_check(dict_vram0 ~= nil and after == dict_vram0, + "the screen did not change: neither tile frame was blitted") + dict_check(prog:read_u32(DICT_BYTES) == 0, "DICT_BYTES is still 0") + dict_plan = dict_build_plan() + dict_at = 0 + dict_last = nil + dict_phase = 3 + return false + end + + if dict_phase == 3 then + if dict_last ~= nil then + if not dict_settled() then + if frames >= dict_phase_at + DICT_WAIT then + dict_check(false, string.format( + "the payload consumed '%s' (DICT_BYTES=%d RX_RING_POS=%d expected %d RX_RING_ACC=%d RX_DGRAM=%d)", + dict_last.label, prog:read_u32(DICT_BYTES), + prog:read_u32(RX_RING_POS), dict_pos, + prog:read_u32(RX_RING_ACC), prog:read_u32(RX_DGRAM))) + dict_dump(true) + return true + end + return false + end + dict_check(prog:read_u32(DICT_BYTES) == dict_last.bytes, + string.format("%s: DICT_BYTES=%d (expected %d)", + dict_last.label, prog:read_u32(DICT_BYTES), dict_last.bytes)) + dict_check(prog:read_u32(DICT_BAD) == dict_last.bad, + string.format("%s: DICT_BAD=%d (expected %d)", + dict_last.label, prog:read_u32(DICT_BAD), dict_last.bad)) + dict_check(prog:read_u32(DICT_READY) == dict_last.ready, + string.format("%s: DICT_READY=%d (expected %d)", + dict_last.label, prog:read_u32(DICT_READY), dict_last.ready)) + dict_last = nil + end + if dict_at >= #dict_plan then + -- The upload is complete. Check the bytes the payload actually + -- holds, not just its own count of them. + okv, first, why = dict_verify() + dict_check(okv, why or string.format( + "the 32 KB at POOL_PTR + %d is the dictionary sent (first bad byte %d)", + DICT_OFF, first)) + dict_check(inspect_pool(), + "the startup allocation is still the one under test") + -- A tile frame after the dictionary is complete: Task 4 paints + -- it. This tree has no tile blit, so it is reported rather than + -- asserted -- the assertion above (before DICT_READY) is Task + -- 3's; the paint is Task 4's. + a = { f = make_frame(KIND_TILES_FULL, 0x0062, 0, 0, + DRAW_ROWS * RX_ROW_BYTES, + function(i) return 0x5A end), + n = 16 + DRAW_ROWS * RX_ROW_BYTES, + label = nil } + if not dict_take(a) then + dict_check(false, "the ring accepted the post-dictionary TILES_FULL frame") + dict_dump(true) + return true + end + dict_phase = 4 + dict_phase_at = frames + return false + end + dict_at = dict_at + 1 + dict_phase_at = frames + a = dict_plan[dict_at] + if not dict_take(a) then + dict_check(false, "the ring accepted '" .. a.label .. "'") + dict_dump(true) + return true + end + return false + end + + if dict_phase == 4 then + if not dict_settled() then + if frames >= dict_phase_at + DICT_WAIT then + dict_check(false, "the post-dictionary TILES_FULL frame was consumed") + dict_dump(true) + return true + end + return false + end + dict_check(prog:read_u32(DICT_READY) == 1 + and prog:read_u32(DICT_BYTES) == DICT_SIZE, + "a TILES_FULL after DICT_READY leaves the dictionary record alone") + dict_lines[#dict_lines + 1] = string.format( + "INFO post-dictionary TILES_FULL: TILES_DROP=%d RX_OK=%d (Task 4 paints this frame; this tree has no tile blit)", + prog:read_u32(TILES_DROP), prog:read_u32(RX_OK)) + print(string.format("VID: dict: post-dictionary TILES_FULL: TILES_DROP=%d RX_OK=%d (Task 4 paints it)", + prog:read_u32(TILES_DROP), prog:read_u32(RX_OK))) + dict_dump(false) + return true + end + return false +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 @@ -1431,6 +1863,13 @@ emu.register_frame_done(function() end end + -- Mode dict: the dictionary upload and the tile-frame dispatch (dict_step()). + if entered and MODE == "dict" then + if dict_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: -- @@ -1534,7 +1973,15 @@ emu.register_frame_done(function() return end + if MODE == "dict" and frames >= STOP_AT then + print(string.format("VID: dict mode did not finish (phase %d) by frame %d", + dict_phase, frames)) + dict_dump(true) + return + end + if frames >= STOP_AT and MODE ~= "stream" and MODE ~= "ring" + and MODE ~= "dict" 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 7a85d32..913b851 100644 --- a/harness/udpstream/frame_parse.h +++ b/harness/udpstream/frame_parse.h @@ -11,7 +11,8 @@ * ------ ---- ------------------------------------------------------ * 0 2 magic, ASCII 'V','S' * 2 1 version, 0x01 - * 3 1 kind (KIND_STRIP = 1, KIND_STATUS = 5) + * 3 1 kind (see the FP_KIND_* block below; the numbers are exactly + * frame.py's KINDS table) * 4 2 frame_no, u16 little-endian * 6 2 payload_len, u16 little-endian * 8 1 rows @@ -48,14 +49,31 @@ * RX_FCS_DROP +0x260 0x01810260 datagrams the chip flagged bad * (no bytes delivered; each * abandons its frame) + * DICT_READY +0x280 0x01810280 1 = the 32 KB dictionary is + * complete (Task 3) + * DICT_BYTES +0x2A0 0x018102A0 dictionary bytes accepted, a + * contiguous prefix from entry 0 + * DICT_BAD +0x2C0 0x018102C0 dictionary chunks refused + * (gap/overlap/duplicate offset, + * overrun, or after ready) + * TILES_DROP +0x2E0 0x018102E0 tile frames that did not become + * pixels (no dictionary yet, or + * no tile paint in this tree) * POOL_PTR +0x00 0x01810100 bit pointer to the 244 KB pool * POOL_FREEB +0x20 0x01810120 heap free bytes before malloc * POOL_FREEA +0x40 0x01810140 heap free bytes after malloc * VID_ALIVE +0x60 0x01810160 heartbeat while the loop runs - * RX_OK +0x80 0x01810180 frames accepted by fp_parse - * RX_BAD +0xA0 0x018101A0 frames rejected by fp_parse - * RX_LAST_LEN +0xC0 0x018101C0 payload_len of the last accepted - * RX_SLOT +0xE0 0x018101E0 slot of the last accepted (0=A) + * RX_OK +0x80 0x01810180 frames that reached the screen + * (a strip painted; Task 4 adds + * tiles). A dictionary chunk + * is counted by DICT_BYTES, not + * here: it is not a picture. + * RX_BAD +0xA0 0x018101A0 frames the receiver refused + * (fp_parse, or a kind the + * dispatcher cannot act on) + * RX_LAST_LEN +0xC0 0x018101C0 payload_len of the last frame + * that reached the screen + * RX_SLOT +0xE0 0x018101E0 slot of that frame (0=A) * OS_TOUCH +0x220 0x01810220 times the VRAM canary was not ours * * NOTE ON OWNERSHIP OF THE PAGE: nothing allocates this page. The firmware's @@ -115,6 +133,44 @@ * further accepted frame; nothing reads it in a continuous stream. * --------------------------------------------------------------------------- * + * Task 3: the frame kinds and the dictionary upload. + * + * The kind byte is frame.py's KINDS table, written down twice -- there in + * Python, here in C -- so the numbers below must be changed in both places at + * once. harness/udpstream/tests/test_frame.py parses this file and compares + * the two tables, so a renumbering on one side fails a host test rather than + * silently mis-dispatching on the wire. + * + * FP_KIND_STRIP 1 packed 1-bpp rows, blitted (scheme A) + * FP_KIND_TILES_FULL 2 a full 40x32 screen of dictionary indices + * FP_KIND_TILES_DELTA 3 only the tiles that changed + * FP_KIND_DICT_CHUNK 4 one slice of the 32x32 tile dictionary + * FP_KIND_STATUS 5 the terminal's report, the other way + * + * A DICT_CHUNK's payload (tiledict.py writes it) is + * + * offset 2 u16 little-endian: dictionary position of the first body byte + * body n dictionary bytes, at most 1448 (1450-byte payload budget) + * + * The dictionary is 256 entries of 128 bytes (32 KB) and lives in the payload's + * own pool at POOL_PTR + 2 * SLOT_BYTES -- right after the slot pair, the + * placement Task 4 consumes. The assembler in vidstream.c refuses, and counts + * in DICT_BAD: an offset that is not the next contiguous byte (gaps, + * overlaps, duplicates, reordering), an offset inside an entry that is not a + * continuation of the accepted prefix (the 128-byte rule), a chunk that would + * overrun the last entry, and any chunk after DICT_READY (the dictionary is + * fixed for the clip -- ruling R1 of plan 2). A refused chunk leaves the + * prefix alone, so the sender's next good chunk still lands. DICT_READY is + * set by the chunk that makes DICT_BYTES exactly 32768: 256 whole entries, so + * a short upload is never complete and never paints anything. + * + * Until DICT_READY a TILES_FULL/TILES_DELTA frame cannot be decoded at all; + * it is refused without touching VRAM and counted in TILES_DROP. Those are + * dispatch rules of the receiver, not of fp_parse(): the header is well formed + * and fp_parse() accepts it, which is exactly why the dispatcher must not + * forget it. + * --------------------------------------------------------------------------- + * * 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 @@ -136,8 +192,11 @@ #define FP_MAGIC1 0x53 /* 'S' */ #define FP_VERSION 1 -#define FP_KIND_STRIP 1 -#define FP_KIND_STATUS 5 +#define FP_KIND_STRIP 1 +#define FP_KIND_TILES_FULL 2 +#define FP_KIND_TILES_DELTA 3 +#define FP_KIND_DICT_CHUNK 4 +#define FP_KIND_STATUS 5 /* Marker page cells; see the table above. */ #define MARK_PAGE 0x01810000 @@ -172,6 +231,14 @@ #define RX_DGRAM MARK_CELL(0x48) #define RX_FCS_DROP MARK_CELL(0x4c) +/* Task 3: the dictionary record and the tile-frame drop count. Cells 0x50 to + * 0x5c fill the gap between RX_FCS_DROP and RX_SLOT_A at the same four-byte + * spacing every other cell uses. */ +#define DICT_READY MARK_CELL(0x50) +#define DICT_BYTES MARK_CELL(0x54) +#define DICT_BAD MARK_CELL(0x58) +#define TILES_DROP MARK_CELL(0x5c) + #define RX_SLOT_A MARK_CELL(0x60) #define RX_SLOT_B MARK_CELL(0x64) diff --git a/harness/udpstream/tests/test_frame.py b/harness/udpstream/tests/test_frame.py index 6299bc6..6b01d8f 100644 --- a/harness/udpstream/tests/test_frame.py +++ b/harness/udpstream/tests/test_frame.py @@ -20,7 +20,8 @@ import struct import pytest from harness.udpstream.frame import (CRC_OFFSET, HEADER_SIZE, KIND_STATUS, - KIND_STRIP, MAGIC, VERSION, STATUS_SIZE, + KIND_STRIP, KINDS, MAGIC, VERSION, + STATUS_SIZE, BROADCAST, ETHERTYPE, TERMINAL_MAC, crc16, ethernet_frame, pack_header, pack_status, seal_header, unpack_header, @@ -288,3 +289,27 @@ def test_ethernet_frame_pads_to_the_minimum_only_at_the_end(): BROADCAST + TERMINAL_MAC + b"\x88\xB7" + long) # Off by default: the chunking layer decides, and only for the last chunk. assert len(ethernet_frame(b"VS" + bytes(10), src=TERMINAL_MAC)) == 26 + + +def test_payload_header_kinds_match_the_wire_table(): + """The terminal's frame_parse.h mirrors this module's KINDS table. + + The kind byte is written by frame.py and read by the payload, so the same + number is written down in Python and in C. A renumbering on one side would + silently mis-dispatch every frame on the other: a TILES_FULL would be + painted as a strip, or a DICT_CHUNK would be counted as a frame. No MAME + check would notice until the kind it exercises itself changed, which is why + this parses the C header rather than trusting the comment that says the two + agree. + """ + import pathlib + import re + + header = (pathlib.Path(__file__).resolve().parents[1] / "frame_parse.h") + text = header.read_text() + found = {name: int(value) for name, value in + re.findall(r"^#define\s+FP_KIND_(\w+)\s+(\d+)\b", text, re.M)} + assert found, "frame_parse.h has no FP_KIND_* definitions" + assert found == KINDS + # And the status kind the payload builds is the one frame.py reads. + assert found["STATUS"] == KIND_STATUS diff --git a/harness/udpstream/vidstream.c b/harness/udpstream/vidstream.c index 08ff25a..71dee21 100644 --- a/harness/udpstream/vidstream.c +++ b/harness/udpstream/vidstream.c @@ -13,17 +13,33 @@ * buffer with the payload's own LANCE RX ring: sixteen descriptors whose * buffers tile a slot, armed per slot, consumed in ring order, so the chip * DMAs the wire bytes into the memory fp_parse() and the blit read. + * Plan 2's Task 3 (this one) adds the tile dictionary: KIND_DICT_CHUNK frames + * are assembled into a 32 KB block after the two slots, and the dispatcher + * learns the TILES_FULL/TILES_DELTA kinds so a frame that arrives before the + * dictionary is complete is refused and counted instead of being mistaken for + * a strip. * * Pool layout inside the single allocation (POOL_BYTES = 249856 = 244 KB): * * +0x00000 frame slot A 24576 bytes (16 x 1526 B RX buffers) * +0x06000 frame slot B 24576 bytes (16 x 1526 B RX buffers) - * +0x0C000 framebuffer 163840 bytes (scheme A, Task 5) - * +0x34000 dictionary 32768 bytes + * +0x0C000 dictionary 32768 bytes (256 x 128 B, Task 3) + * +0x14000 framebuffer 163840 bytes (scheme A reserve; see below) * +0x3C000 LANCE block 4096 bytes (init block, TX ring, status * frame, RX ring) * - * The LANCE block is what was slack. Its ring bases are aligned at run time + * The dictionary sits at POOL_PTR + 2 * SLOT_BYTES, which is the placement the + * plan fixes and plan 2's Task 4 consumes. The 160 KB framebuffer reserve + * follows it: this decoder paints straight from the RX fragments (there is no + * per-frame CPU copy, spec 8.1) and never touches that region, so it is slack + * today, but the layout adds up to exactly POOL_BYTES -- 24576 + 24576 + 32768 + * + 163840 + 4096 = 249856. dict_slack_check below pins the part Task 3 + * depends on (the slot pair and the 32 KB dictionary); the LANCE block's + * offsets at 0x3C000 were fixed before it and are not re-checked here. Had + * the dictionary gone after the framebuffer, the plan's base would not be + * POOL_PTR + 2 * SLOT_BYTES and Task 4 would read 160 KB of zeroes. + * + * The LANCE block is the 4 KB tail. Its ring bases are aligned at run time * (lance_start() explains why the offset alone is not enough), and the status * frame sits right after the 8 TX descriptors, the RX ring after that. * @@ -75,6 +91,9 @@ * injection cells. Task 7's ring record takes cells 0x10, 0x14, 0x18, 0x1c, * 0x40 and 0x48 (the free page prefix R12 left for the RX work, at the same * four-byte spacing every other cell uses); vidstream.lua uses the same rule. + * Plan 2's Task 3 takes cells 0x50 (DICT_READY), 0x54 (DICT_BYTES), 0x58 + * (DICT_BAD) and 0x5c (TILES_DROP) -- the gap between RX_FCS_DROP and + * RX_SLOT_A, still at four-byte spacing. * * K&R C only: no prototypes, no '//', declarations before statements. */ @@ -112,6 +131,41 @@ */ #define SLOT_BYTES 24576 +/* + * Plan 2's Task 3: the tile dictionary. + * + * 256 entries of 32x32 1-bpp tiles, 128 bytes each = 32 KB, held in the + * startup pool right after the slot pair -- POOL_PTR + 2 * SLOT_BYTES, the + * placement the plan fixes and the tile blit (Task 4) consumes. The chunks + * arrive as KIND_DICT_CHUNK frames whose payload is a u16 little-endian + * offset plus up to 1448 dictionary bytes (tiledict.py); dict_chunk() is the + * assembler and states the rules it enforces. + * + * The pool layout in the header comment is exactly POOL_BYTES, so a build with + * a smaller request cannot hold the dictionary: dict_slack_check below refuses + * it at build time, and the run-time guard in main() refuses to point the + * assembler at memory outside the allocation. (POOL_BYTES is generated into + * poolcfg.h by build-stream.sh; the fallback pool is STATIC_BYTES, the same + * size.) + */ +#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 */ + +/* + * The arithmetic check the brief asks for, as a compile-time assertion. + * + * A #if here does NOT work: this preprocessor compiled the file with + * POOL_BYTES = 50000 and no complaint, so it does not fold the macro + * arithmetic in #if. An `extern' array with a negative length is accepted + * too (measured). A DEFINED array whose length is 1 or -1 does fail the + * build (measured: POOL_BYTES=50000 -> "1 Error, No Warnings" and no + * VIDSTRM.OUT), at the cost of one byte of .bss on a good build. The + * dictionary must fit after the slot pair, which is the arithmetic below; the + * full layout is exactly POOL_BYTES (header comment). + */ +char dict_slack_check[(POOL_BYTES >= (DICT_OFF + DICT_SIZE)) ? 1 : -1]; + /* * Task 6: the terminal's status frame. * @@ -274,6 +328,27 @@ static unsigned int rx_frag_off[RX_DESC]; static unsigned int rx_frag_ptr[RX_DESC]; static unsigned int rx_frag_len[RX_DESC]; +/* + * Plan 2's Task 3 state. Like every other static here it is cleared by main() + * before use: the toolchain never runs .cinit. + * + * dict_base bit address of the dictionary in the pool (POOL_PTR + DICT_OFF) + * dict_bytes bytes accepted, always a contiguous prefix from entry 0 + * dict_bad chunks refused (gap/overlap/duplicate, overrun, after ready) + * dict_ready 1 once dict_bytes is exactly DICT_SIZE + * tiles_drop tile frames that could not become pixels + * + * dict_bytes is the assembler's only cursor. It is not a copy of anything the + * sender says: the offset in a chunk is checked against it, never trusted, so a + * missing or replayed chunk is visible as an offset mismatch rather than a hole + * in the dictionary. + */ +static unsigned int dict_base; +static unsigned int dict_bytes; +static unsigned int dict_bad; +static unsigned int dict_ready; +static unsigned int tiles_drop; + /* * blit_row(src, daddr_xy, dydx) * @@ -380,6 +455,129 @@ fp_header *h; vid_eint_(); } +/* + * 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 + * does not hold the whole range (a truncated frame or a gap). + * + * The frame's bytes are not contiguous in the slot: each datagram's body sits + * 14 bytes into its own 1526-byte buffer, so a dictionary chunk that spans two + * datagrams -- every 1450-byte chunk does, since 16 + 1450 = 1466 -- has to be + * stored piece by piece. paint() walks the same table for the blit, so this is + * one reading of the fragment map, not a second one. + */ +static int frag_copy(dst, pos, n) +unsigned int dst; +unsigned int pos; +unsigned int n; +{ + unsigned int k, f, room, i; + + k = 0; + while (n > 0) { + while (k < rx_nfrag + && pos >= rx_frag_off[k] + rx_frag_len[k]) { + k = k + 1; + } + if (k >= rx_nfrag) { + return 0; /* truncated */ + } + if (pos < rx_frag_off[k]) { + return 0; /* a gap: never copy across it */ + } + f = pos - rx_frag_off[k]; + room = rx_frag_len[k] - f; + if (room > n) { + room = n; + } + for (i = 0; i < room; i++) { + *(unsigned char *)(dst + i * 8) = + *(unsigned char *)(rx_frag_ptr[k] + (f + i) * 8); + } + dst = dst + room * 8; + pos = pos + room; + n = n - room; + } + return 1; +} + +/* One refused dictionary chunk: count it and leave the accepted prefix alone, + * so the sender's next good chunk still lands. */ +static void dict_refuse() +{ + dict_bad = dict_bad + 1; + *(unsigned int *)DICT_BAD = dict_bad; +} + +/* + * dict_chunk(h) -- assemble one slice of the 32 KB tile dictionary. + * + * The wire form (tiledict.py, restated in frame_parse.h) is the VS header, + * then a u16 little-endian dictionary offset, then up to 1448 dictionary bytes. + * The rules, and what each refuses: + * + * payload_len >= 3 the offset and at least one dictionary byte. + * off == dict_bytes ascending and contiguous. A gap, an overlap, a + * duplicate and a reordered chunk all fail this one + * test. It is also the 128-byte rule Task 2's + * review carried: an offset inside an entry is legal + * only as a continuation of the accepted prefix -- + * which is exactly "off equals the prefix end" -- and + * a first chunk that does not begin at entry 0 fails + * it too. + * off + body <= DICT_SIZE past the last entry; the tail chunk that would + * overrun 32,768 is the "partial final entry" case. + * frag_copy() succeeds the bytes are really in the fragment table. + * + * DICT_READY is set only by the chunk that makes dict_bytes exactly DICT_SIZE: + * 256 whole entries. A short upload is therefore never ready, so a tile frame + * that follows it is still refused -- the wire has no end-of-upload marker to + * do that earlier. + * + * After DICT_READY a further chunk is refused rather than restarting the + * upload: plan ruling R1 makes the dictionary fixed for the clip, and a + * re-upload would need a coordinated paint stall the protocol does not have. + * + * The offset is read out of the first fragment, whose body always holds the VS + * header and the first bytes after it; frag_copy() then handles the body + * proper, which may cross into the next datagram. + */ +static void dict_chunk(h) +fp_header *h; +{ + unsigned int off, body, p; + + if (dict_ready || dict_base == 0 || h->payload_len < 3) { + dict_refuse(); + return; + } + if (rx_frag_len[0] < h->payload_off + 2) { + dict_refuse(); + return; + } + p = rx_frag_ptr[0] + h->payload_off * 8; + off = (unsigned int)*(unsigned char *)p + | ((unsigned int)*(unsigned char *)(p + 8) << 8); + body = h->payload_len - 2; + if (off != dict_bytes || off + body > DICT_SIZE) { + dict_refuse(); + return; + } + if (!frag_copy(dict_base + off * 8, h->payload_off + 2, body)) { + dict_refuse(); + return; + } + dict_bytes = off + body; + *(unsigned int *)DICT_BYTES = dict_bytes; + *(unsigned int *)DICT_BAD = dict_bad; + if (dict_bytes == DICT_SIZE) { + dict_ready = 1; + /* Published last, after DICT_BYTES: a reader that sees readiness + * sees the complete record. */ + *(unsigned int *)DICT_READY = 1; + } +} + /* * The LANCE, transmit only. The sequence is lance.c's, and the comment on * each step names what makes it necessary (harness/mame/README.md rule 4: a @@ -629,12 +827,37 @@ unsigned int i; lim = SLOT_BYTES; } if (fp_parse((unsigned char *)rx_frag_ptr[0], lim, &h)) { - *(unsigned int *)RX_LAST_LEN = h.payload_len; - *(unsigned int *)RX_SLOT = slot; - paint(&h); - /* Published after the paint: a reader that sees RX_OK move - * sees a picture that is already on the screen. */ - *(unsigned int *)RX_OK = *(unsigned int *)RX_OK + 1; + if (h.kind == FP_KIND_STRIP) { + *(unsigned int *)RX_LAST_LEN = h.payload_len; + *(unsigned int *)RX_SLOT = slot; + paint(&h); + /* Published after the paint: a reader that sees RX_OK move + * sees a picture that is already on the screen. */ + *(unsigned int *)RX_OK = *(unsigned int *)RX_OK + 1; + } else if (h.kind == FP_KIND_DICT_CHUNK) { + /* A chunk is not a picture: it is counted by DICT_BYTES + * and DICT_BAD, never by RX_OK. */ + dict_chunk(&h); + } else if (h.kind == FP_KIND_TILES_FULL + || h.kind == FP_KIND_TILES_DELTA) { + /* + * Task 4 turns these into pixels. This tree has no tile + * blit, and the point of the branch is that such a frame + * is never silently forgotten: before DICT_READY there is + * nothing to decode with, and after it this tree still + * has no paint, so both are counted and neither touches + * VRAM. (A frame that arrived before the dictionary and + * was dropped cannot leave a half-painted screen: the + * branch draws nothing at all.) + */ + 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 + * way): received and refused, so it is a bad frame. */ + *(unsigned int *)RX_BAD = *(unsigned int *)RX_BAD + 1; + } } else { *(unsigned int *)RX_BAD = *(unsigned int *)RX_BAD + 1; } @@ -979,6 +1202,18 @@ int main() *(unsigned int *)RX_DGRAM = 0; *(unsigned int *)OS_TOUCH = 0; + /* Plan 2's Task 3: the dictionary record starts empty and is published + * as zero before the pool is known, so a reader never sees an earlier + * run's readiness on a pool that has no dictionary behind it. */ + *(unsigned int *)DICT_READY = 0; + *(unsigned int *)DICT_BYTES = 0; + *(unsigned int *)DICT_BAD = 0; + *(unsigned int *)TILES_DROP = 0; + dict_ready = 0; + dict_bytes = 0; + dict_bad = 0; + tiles_drop = 0; + /* Arm the VRAM canary before any frame is accepted, so a repaint * between now and the first blit is counted rather than mistaken for * our own last frame. */ @@ -1004,6 +1239,18 @@ int main() slot_base[1] = ptr + SLOT_BYTES * 8; *(unsigned int *)RX_SLOT_A = slot_base[0]; *(unsigned int *)RX_SLOT_B = slot_base[1]; + /* Plan 2's Task 3: the dictionary follows the slot pair. It is derived + * from the allocation, never published separately -- POOL_PTR is the + * record, and the pool layout above fixes the offset. build-stream.sh + * asserts at build time that the request can hold it (dict_slack_check + * above); this is the run-time half, for a payload built another way: a + * pool too small for the dictionary leaves dict_base 0, every chunk is + * refused and nothing is written past the allocation. */ + if (POOL_BYTES >= DICT_OFF + DICT_SIZE) { + dict_base = ptr + DICT_OFF * 8; + } else { + dict_base = 0; + } *(unsigned int *)POOL_PTR = ptr; /* publish last: a complete record */ /* Task 6: the status back-channel. The record starts empty, then the