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.
This commit is contained in:
opencode.energia.arti.ee committed 2026-10-31 01:55:06 +00:00
1 parent 84349d6da3
commit 0b726fda3a
5 files changed
+817 -20

No files matched your search

+11
View File
@@ -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"
+450 -3
View File
@@ -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")
+74 -7
View File
@@ -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)
+26 -1
View File
@@ -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
+256 -9
View File
@@ -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