forked from arti/vt1200
3627 lines
133 KiB
Lua
3627 lines
133 KiB
Lua
-- vidstream.lua -- load the streaming payload and check its startup allocation
|
|
-- and its receive path.
|
|
--
|
|
-- MECHANISM: debugger entry, not a delivery mechanism. The firmware is run
|
|
-- with VT1200_SKIP_LEVEL1=1 (a documented divergence) to the OS, because the
|
|
-- level-0 diagnostics menu's heap is NOT initialised (both the free counter at
|
|
-- 0x1E8BA0 and the free-list head at 0x1E8AC0 read 0 there -- measured; see
|
|
-- the task-3 report). The payload is poked into RAM at VID_AT and entered at
|
|
-- the OS idle loop by a breakpoint, the same way harness/mame/hello.lua enters
|
|
-- its payload at the diagnostics idle loop.
|
|
--
|
|
-- Mode "alloc" (Task 3) asserts:
|
|
-- * POOL_PTR is non-zero and its BYTE address is in 0x100000..0x400000;
|
|
-- * the pointer is 16-bit-word aligned and, for a real allocation, its byte
|
|
-- address is BELOW the payload origin (a pool above .text would overlap
|
|
-- .text/.bss -- the old range test alone accepted byte 0x318000/0x380000);
|
|
-- * the heap free counter dropped by POOL_BYTES (plus allocator header);
|
|
-- * on a failed malloc, POOL_PTR is the linked static buffer and the low bit
|
|
-- of POOL_FREE (before) marks the fallback, i.e. the payload keeps running.
|
|
--
|
|
-- Mode "rx" (Task 4) is "alloc" plus the receive path: it delivers one VALID
|
|
-- frame and then the same frame with one header byte flipped, and asserts
|
|
-- RX_OK == 1 && RX_BAD == 1, that RX_LAST_LEN is the accepted frame's
|
|
-- payload_len, and that RX_SLOT names the slot the good frame went into.
|
|
--
|
|
-- Mode "draw" (Task 5) is the pixel check. It delivers ONE valid 128-row strip
|
|
-- frame (stretch 8 -- exactly 1024 screen rows, ruling R3) whose rows are a
|
|
-- known pattern, waits for the payload to accept and blit it, then dumps the
|
|
-- screen (1024 rows x 256 bytes from bit 0x04000000), the frame bytes it
|
|
-- delivered, and the payload's counters. The comparison is
|
|
-- harness/mame/vidstream_check.py's job: this Lua does not compare pixels, so a
|
|
-- blit that paints the wrong rows cannot be blessed here.
|
|
--
|
|
-- Mode "stream" (Task 6) is the status frame. It plays the sender: 200 strip
|
|
-- frames, one per emulated frame whenever the ring has room. The payload
|
|
-- accepts them and, every 64 accepted frames, builds a 60-byte status frame in
|
|
-- its own buffer and hands it to the LANCE. This mode watches ST_SENT move,
|
|
-- reads the frame back from ST_BUF, and when the 200th frame has been accepted
|
|
-- it sets RX_EOF so the terminal reports the final count (192 is the last
|
|
-- multiple of 64, so without RX_EOF the run could never see 200). It writes
|
|
-- stream-status.bin (the frames exactly as the payload built them) and
|
|
-- stream-status.txt (counters), and vidstream_status_check.py decodes both with
|
|
-- the same wire format the host sender uses. What this does NOT do is put a
|
|
-- byte on a wire: the emulated LANCE has no network interface (R11), so the
|
|
-- transmit is proven by the descriptor completing (TMD1's OWN cleared) and by
|
|
-- the bytes in the buffer, not by a socket.
|
|
--
|
|
-- Mode "ring" (Task 7) is the transport itself. The payload now owns a real
|
|
-- 16-descriptor LANCE RX ring (2 x 24 KB slots of 16 x 1526-byte buffers), so
|
|
-- every mode injects through that ring -- see "PLAYING THE CHIP" below -- and
|
|
-- this mode asserts the ring's own structure:
|
|
-- * the payload arms all 16 descriptors with OWN set, each pointing at its own
|
|
-- 1526-byte buffer inside slot A, RMD2 = 0x10000 - 1526;
|
|
-- * a complete frame moves the write pointer to slot B: the frame is accepted
|
|
-- (RX_OK 1, RX_SLOT 0), and all 16 descriptors are re-armed on slot B;
|
|
-- * a frame whose header claims more than SLOT_BYTES is rejected without a
|
|
-- blit: RX_BAD becomes 1, RX_OK stays 1, the screen does not change, and the
|
|
-- ring recovers on slot A.
|
|
-- 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.
|
|
-- One more chunk *after* DICT_READY must be refused (ruling R1: the dictionary
|
|
-- is fixed for the clip), and the 32 KB is compared again to show the refusal
|
|
-- wrote nothing. 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.
|
|
--
|
|
-- Mode "tiles" (Task 4) is the tile blit's pixel check. It uploads a 32 KB
|
|
-- dictionary whose every 128-byte entry is a distinct, row-varying pattern,
|
|
-- verifies the payload's copy byte for byte, then delivers ONE TILES_FULL frame
|
|
-- whose 1,280 index bytes name a different dictionary entry at every corner and
|
|
-- a varied entry everywhere else. It waits for the payload to accept and paint
|
|
-- it, then dumps the screen, the dictionary and the indices. The comparison is
|
|
-- harness/mame/tile_check.py's job: this Lua does not render pixels, so a blit
|
|
-- that paints the wrong tile, the wrong row or the wrong colour cannot be
|
|
-- blessed here. The negative control is a deliberately wrong SPTCH in
|
|
-- 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
|
|
-- recv_complete_cb() does: it writes the datagram into the buffer of the
|
|
-- descriptor at RX_RING_POS, then writes RMD3 (delivered length including the
|
|
-- 4-byte FCS) and RMD1 (STP | ENP, OWN clear). A multi-datagram frame fills
|
|
-- consecutive descriptors in ring order, which is the order the chip uses
|
|
-- (INIT resets its ring position to 0 and it advances one descriptor per
|
|
-- packet). That is a stand-in for the DMA only -- the payload's parsing,
|
|
-- framing, slot alternation and painting are all its own.
|
|
--
|
|
-- Marker page: bit 0x01810000. A marker named 0x018100NN in the plan is a
|
|
-- BYTE offset into that page (0x01810024 is four bits after 0x01810020 --
|
|
-- inside the same 32-bit cell -- so the literal reading cannot work). The C
|
|
-- side uses the same rule: bit = 0x01810000 + NN * 8. The full table,
|
|
-- including the RX_* and RX_RING_* cells below, is in
|
|
-- harness/udpstream/frame_parse.h.
|
|
--
|
|
-- Env:
|
|
-- VT1200_MODE alloc (default), rx, draw, stream, ring, dict, tiles,
|
|
-- delta or replay
|
|
-- VID_AT load/entry frame (default 2400, the OS phase)
|
|
-- VID_END inspection/exit frame (default VID_AT + 60)
|
|
-- VID_DUMP_DIR where draw, stream, ring, dict and tiles modes write their
|
|
-- 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
|
|
-- an OS touch. The negative control for OS_TOUCH.
|
|
-- VID_DRAW_WAIT draw mode only: emulated frames to wait for the payload to
|
|
-- accept the frame (default 300). Set it to 1 to exercise
|
|
-- the timeout path.
|
|
-- VID_STREAM_FRAMES stream mode only: how many frames the sender sends
|
|
-- (default 200)
|
|
-- VID_STREAM_WAIT stream mode only: emulated frames to wait for the final
|
|
-- 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_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_TILE_DIR replay mode only: the make_tiles.py cache to play
|
|
-- (default harness/udpstream/tiles)
|
|
-- VID_REPLAY_WAIT replay mode only: emulated frames to wait for a dictionary
|
|
-- chunk or a clip frame to be consumed (default 600)
|
|
-- VID_PAYLOAD raw .text image (default harness/udpstream/build/vidstream.bin)
|
|
-- VID_ORIGIN payload load (poke) bit address (default 0x018C0000)
|
|
-- VID_ENTRY_PTR _start bit address from vidstream.env (default VID_ORIGIN)
|
|
-- VID_ENTRY_PC breakpoint PC used to enter (default 0x3F579970, OS idle)
|
|
-- VID_POOL_BYTES POOL_BYTES the image was built with (default 249856)
|
|
-- VID_STATIC_PTR linked address of the fallback pool, from vidstream.env
|
|
|
|
|
|
local function hex(v, w) return string.format("%0" .. (w or 8) .. "X", v) end
|
|
|
|
local machine = manager.machine
|
|
local cpu = machine.devices[':maincpu']
|
|
local dbg = machine.debugger
|
|
local prog = cpu.spaces['program']
|
|
|
|
if not dbg then
|
|
print("VID ERROR: debugger not enabled; pass -debug")
|
|
machine:exit()
|
|
return
|
|
end
|
|
|
|
local MODE = os.getenv("VT1200_MODE") or "alloc"
|
|
local AT = tonumber(os.getenv("VID_AT") or "2400")
|
|
-- The stop frame depends on the mode: a stream run needs room for
|
|
-- VID_STREAM_FRAMES frames plus the final-status wait, and the payload needs
|
|
-- a few emulated frames per accepted frame (one full-screen blit each).
|
|
local DEFAULT_END = AT + 60
|
|
if MODE == "stream" and not os.getenv("VID_END") then
|
|
DEFAULT_END = AT + tonumber(os.getenv("VID_STREAM_FRAMES") or "200") * 8 + 2000
|
|
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
|
|
-- Six phases, each with its own bounded wait (VID_DICT_WAIT, 300).
|
|
DEFAULT_END = AT + 6 * tonumber(os.getenv("VID_DICT_WAIT") or "300") + 120
|
|
elseif MODE == "tiles" and not os.getenv("VID_END") then
|
|
-- One wait for the ring, 23 for the dictionary chunks, one for the tile
|
|
-- 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 five deltas: one
|
|
-- ring wait, 23 dictionary chunks and six frames, each bounded by
|
|
-- VID_DELTA_WAIT; the first timeout dumps and exits.
|
|
DEFAULT_END = AT + 34 * tonumber(os.getenv("VID_DELTA_WAIT") or "300") + 120
|
|
elseif MODE == "replay" and not os.getenv("VID_END") then
|
|
-- One ring wait, 23 dictionary chunks and one wait per clip frame, each
|
|
-- bounded by VID_REPLAY_WAIT (600: a frame is a datagram plus a 160 KB
|
|
-- screen dump, and the first timeout dumps and exits). The stream's own
|
|
-- length is not known until the mode reads it, so the backstop is sized
|
|
-- for the 30-frame demo and VID_END overrides it for a longer stream.
|
|
DEFAULT_END = AT + 26 * tonumber(os.getenv("VID_REPLAY_WAIT") or "600") + 3600
|
|
end
|
|
local STOP_AT = tonumber(os.getenv("VID_END") or tostring(DEFAULT_END))
|
|
local ORIGIN = tonumber(os.getenv("VID_ORIGIN") or "0x018C0000")
|
|
local ENTRY_PC = tonumber(os.getenv("VID_ENTRY_PC") or "0x3F579970")
|
|
local PATH = os.getenv("VID_PAYLOAD") or "harness/udpstream/build/vidstream.bin"
|
|
local POOL_BYTES = tonumber(os.getenv("VID_POOL_BYTES") or "249856")
|
|
local STATIC_PTR = tonumber(os.getenv("VID_STATIC_PTR") or "0")
|
|
-- _start is at the linked entry point, not at the section origin: GSPLNK
|
|
-- places ENTRY.obj after the C object (see the map). build-stream.sh
|
|
-- publishes the address; ORIGIN is only where the raw .text is poked.
|
|
local ENTRY_PTR = tonumber(os.getenv("VID_ENTRY_PTR") or "0x018C0000")
|
|
|
|
local MARK_PAGE = 0x01810000
|
|
local POOL_PTR = MARK_PAGE + 0x20 * 8
|
|
local POOL_FREEB = MARK_PAGE + 0x24 * 8
|
|
local POOL_FREEA = MARK_PAGE + 0x28 * 8
|
|
local VID_ALIVE = MARK_PAGE + 0x2c * 8
|
|
|
|
-- Task 4: receive counters and (retired by Task 7) the injection cells.
|
|
local RX_OK = MARK_PAGE + 0x30 * 8
|
|
local RX_BAD = MARK_PAGE + 0x34 * 8
|
|
local RX_LAST_LEN = MARK_PAGE + 0x38 * 8
|
|
local RX_SLOT = MARK_PAGE + 0x3c * 8
|
|
local RX_SLOT_A = MARK_PAGE + 0x60 * 8
|
|
local RX_SLOT_B = MARK_PAGE + 0x64 * 8
|
|
|
|
-- Task 7: the RX ring record (frame_parse.h).
|
|
-- Cells are FOUR bytes apart (n increases by 4), so these are 0x10, 0x14,
|
|
-- 0x18, 0x1c, 0x40 and 0x48 -- not six adjacent n values, which would be one
|
|
-- byte apart and would overlap into zeros.
|
|
local RX_RING_BASE = MARK_PAGE + 0x10 * 8
|
|
local RX_RING_POS = MARK_PAGE + 0x14 * 8
|
|
local RX_RING_ACC = MARK_PAGE + 0x18 * 8
|
|
local RX_RING_ARMED = MARK_PAGE + 0x1c * 8
|
|
local RX_RING_DROPS = MARK_PAGE + 0x40 * 8
|
|
local RX_DGRAM = MARK_PAGE + 0x48 * 8
|
|
local RX_FCS_DROP = MARK_PAGE + 0x4c * 8
|
|
|
|
-- Task 5: the OS-repaint canary (frame_parse.h, ruling R12).
|
|
local OS_TOUCH = MARK_PAGE + 0x44 * 8
|
|
|
|
-- Task 6: the sender/keyframe cell and the status record.
|
|
local RX_EOF = MARK_PAGE + 0x78 * 8
|
|
local ST_SENT = MARK_PAGE + 0x7c * 8
|
|
local ST_FRAMES = MARK_PAGE + 0x80 * 8
|
|
local ST_BUF = MARK_PAGE + 0x84 * 8
|
|
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
|
|
|
|
-- The RX ring (Task 7), from vidstream.c: RX_DESC descriptors of 8 bytes each,
|
|
-- one RXBUFLEN-byte buffer per descriptor inside the armed slot.
|
|
local RX_DESC = 16
|
|
local RXBUFLEN = 1526
|
|
local ETH_HDR = 14
|
|
local ETH_FCS = 4
|
|
local RMD1_OWN = 0x8000
|
|
local RMD1_ENP = 0x0100
|
|
local RMD1_STP = 0x0200
|
|
local RMD1_CRC = 0x0800
|
|
local RMD1_ERR = 0x4000
|
|
|
|
-- The frame every mode delivers first: 8 rows of 1280 pixels, 1 bpp MSB-first
|
|
-- (160 bytes a row), each row repeated 8 times. 1280 B of payload -- the same
|
|
-- geometry vidsrc.py's ROW_BYTES produces, so Task 5 can blit it unchanged.
|
|
-- 1296 bytes is ONE datagram, which is what mode rx wants.
|
|
local RX_ROWS = 8
|
|
local RX_STRETCH = 8
|
|
local RX_ROW_BYTES = 160
|
|
local RX_PLEN = RX_ROWS * RX_ROW_BYTES
|
|
local RX_FRAME = 16 + RX_PLEN
|
|
|
|
-- Draw mode: 128 rows x 8 screen rows = 1024, the whole screen (ruling R3).
|
|
-- 20,480 B of payload + 16 B header fits the 24,576 B slot; at the sender's
|
|
-- 1450-byte chunk it is 15 datagrams of the 16 descriptors, which is exactly
|
|
-- the "one descriptor to spare" the design claims.
|
|
local DRAW_ROWS = 128
|
|
local DRAW_STRETCH = 8
|
|
local DRAW_PLEN = DRAW_ROWS * RX_ROW_BYTES
|
|
local DRAW_FRAME = 16 + DRAW_PLEN
|
|
local DRAW_CHUNK = 1450
|
|
|
|
-- Stream mode (Task 6): how many frames the sender plays, how long to wait for
|
|
-- the final status once RX_EOF is set, and the frame's geometry. The terminal
|
|
-- reports every 64 accepted frames (STATUS_EVERY in vidstream.c), so 200 frames
|
|
-- produce reports at 64, 128, 192 and -- after RX_EOF -- at 200.
|
|
--
|
|
-- One frame is one datagram (the 8-row, 1296-byte frame from mode rx): it fits a
|
|
-- single 1526-byte buffer, so the ring position does not have to be a multiple
|
|
-- of anything and every descriptor can hold the same body. Re-poking 200 x 20
|
|
-- KB through the debugger would take minutes, which is why the stream mode does
|
|
-- not use the 128-row frame; modes draw and ring cover that path.
|
|
local STREAM_FRAMES = tonumber(os.getenv("VID_STREAM_FRAMES") or "200")
|
|
local STREAM_WAIT = tonumber(os.getenv("VID_STREAM_WAIT") or "900")
|
|
|
|
-- Emulated frames to give the chip to hand the last TX descriptor back after
|
|
-- the final status. It is a bounded safety net, not a routine delay: MAME's
|
|
-- LANCE model completes a transmit in len/m_bandwidth, where dinetwork.cpp
|
|
-- converts the chip's 10 Mbit/s into m_bandwidth = bandwidth << 17 bytes/s,
|
|
-- so a 60-byte frame takes 60/1310720 = 46 us emulated. Measured in
|
|
-- VT1200_LANCE_DMA_LOG for this run: 49 us from the descriptor read to the
|
|
-- write-back (t=35.992888 -> 35.992937). The payload's own bounded wait
|
|
-- normally sees that completion (ST_TMD1's OWN is clear), and this wait only
|
|
-- matters if the host is too slow to service the model's timer.
|
|
local STREAM_DRAIN = tonumber(os.getenv("VID_STREAM_DRAIN") or "900")
|
|
local STATUS_EVERY = 64
|
|
local STATUS_BYTES = 60 -- STATUS_LEN in vidstream.c
|
|
local STATUS_BODY = 14 + 16 -- Ethernet header + VS header
|
|
|
|
-- Screen memory: bit 0x04000000, 256-byte row pitch (the real unit's, see
|
|
-- harness/c-hello/README.md; MAME's own renderer uses a different pitch and is
|
|
-- not what this check reads).
|
|
local VRAM_BASE = 0x04000000
|
|
local VRAM_ROW_BITS = 0x800
|
|
local VRAM_ROWS = 1024
|
|
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" and MODE ~= "dict"
|
|
and MODE ~= "tiles" and MODE ~= "delta" and MODE ~= "replay" then
|
|
print("VID ERROR: mode '" .. MODE .. "' is not implemented")
|
|
machine:exit()
|
|
return
|
|
end
|
|
|
|
-- CCITT-FALSE (polynomial 0x1021, init 0xFFFF, not reflected, no final xor).
|
|
-- frame.py, frame_parse.c and this loop are three independent readings of the
|
|
-- same spec; the harness signs its own frames rather than reusing a value the
|
|
-- payload computed, so the C parser accepting them is a real check. Since
|
|
-- ruling R14 the CRC input is the 12 header bytes only.
|
|
local function crc_add(crc, bytes, first, count)
|
|
for i = first, first + count - 1 do
|
|
crc = (crc ~ (bytes[i] << 8)) & 0xFFFF
|
|
for _ = 1, 8 do
|
|
if (crc & 0x8000) ~= 0 then
|
|
crc = ((crc << 1) ~ 0x1021) & 0xFFFF
|
|
else
|
|
crc = (crc << 1) & 0xFFFF
|
|
end
|
|
end
|
|
end
|
|
return crc
|
|
end
|
|
|
|
-- A complete frame, header and payload, laid out as frame.py writes it.
|
|
-- `fill`, when given, computes payload byte i; draw mode needs a per-row
|
|
-- pattern, the rx mode keeps the arbitrary (i*7+3) filler.
|
|
local function make_frame(kind, frame_no, rows, stretch, plen, fill)
|
|
local b = {}
|
|
b[0] = 0x56 -- 'V'
|
|
b[1] = 0x53 -- 'S'
|
|
b[2] = 0x01 -- version
|
|
b[3] = kind
|
|
b[4] = frame_no & 0xFF
|
|
b[5] = (frame_no >> 8) & 0xFF
|
|
b[6] = plen & 0xFF
|
|
b[7] = (plen >> 8) & 0xFF
|
|
b[8] = rows
|
|
b[9] = stretch
|
|
b[10] = 0
|
|
b[11] = 0
|
|
for i = 0, plen - 1 do
|
|
if fill then
|
|
b[16 + i] = fill(i)
|
|
else
|
|
b[16 + i] = (i * 7 + 3) & 0xFF
|
|
end
|
|
end
|
|
local crc = crc_add(0xFFFF, b, 0, 12)
|
|
-- Header bytes 0..11 only (ruling R14): the payload is protected by each
|
|
-- datagram's Ethernet FCS, not by this CRC. frame.py, frame_parse.c and
|
|
-- this loop are three independent readings of the same rule.
|
|
b[12] = crc & 0xFF
|
|
b[13] = (crc >> 8) & 0xFF
|
|
b[14] = 0
|
|
b[15] = 0
|
|
return b
|
|
end
|
|
|
|
-- Mode rx's two frames. Both are ONE datagram (1296 + 14 = 1310 bytes, well
|
|
-- inside a 1526-byte buffer); the second carries a flipped header byte, inside
|
|
-- the 12 bytes the CRC covers. Since ruling R14 a flipped PAYLOAD byte is
|
|
-- ACCEPTED by fp_parse (the Ethernet FCS is the chip's job), so a payload flip
|
|
-- would no longer exercise the reject path; the payload-flip case is pinned by
|
|
-- the host tests instead.
|
|
local rx_valid = make_frame(1, 0x0021, RX_ROWS, RX_STRETCH, RX_PLEN)
|
|
local rx_corrupt = make_frame(1, 0x0022, RX_ROWS, RX_STRETCH, RX_PLEN)
|
|
rx_corrupt[4] = rx_corrupt[4] ~ 0x01 -- frame_no low byte, CRC left
|
|
|
|
local payload = nil
|
|
do
|
|
local f, err = io.open(PATH, "rb")
|
|
if not f then
|
|
print("VID ERROR: cannot open " .. PATH .. ": " .. tostring(err))
|
|
machine:exit()
|
|
return
|
|
end
|
|
payload = f:read("*a")
|
|
f:close()
|
|
print(string.format("VID: payload %s bytes=%d origin=%s", PATH, #payload, hex(ORIGIN)))
|
|
end
|
|
|
|
local frames = 0
|
|
local loaded = false
|
|
local entered = false
|
|
local armed = false
|
|
local direct = false
|
|
local alive0 = nil
|
|
local alive_last = nil
|
|
local injected = false
|
|
local injected_bad = false
|
|
-- The two receive-mode readbacks are one local, not two: this file is at Lua's
|
|
-- 200-local limit for the main chunk, and each mode closure added another one.
|
|
local readback = { a = false, b = false }
|
|
|
|
local function load_payload()
|
|
local n = math.floor(#payload / 2)
|
|
local words = {}
|
|
local bad = 0
|
|
for i = 0, n - 1 do
|
|
local lo = payload:byte(i * 2 + 1)
|
|
local hi = payload:byte(i * 2 + 2)
|
|
words[i] = lo | (hi << 8)
|
|
prog:write_u16(ORIGIN + i * 16, words[i])
|
|
end
|
|
for i = 0, n - 1 do
|
|
if prog:read_u16(ORIGIN + i * 16) ~= words[i] then
|
|
bad = bad + 1
|
|
end
|
|
end
|
|
print(string.format("VID: loaded %d words at bit %s; readback %s (%d bad)",
|
|
n, hex(ORIGIN), bad == 0 and "OK" or "FAILED", bad))
|
|
-- Enter through the idle loop, exactly like hello.lua. The action only
|
|
-- assigns PC and resumes; the payload proves itself.
|
|
dbg:command("bpset " .. hex(ENTRY_PC) .. ",,{ pc = " .. hex(ENTRY_PTR) .. " ; g }")
|
|
armed = true
|
|
end
|
|
|
|
local function in_payload(pc)
|
|
return pc >= ORIGIN and pc < ORIGIN + #payload * 8
|
|
end
|
|
|
|
local function enter_direct(reason)
|
|
dbg:command("bpclear") -- clears ALL: see disarm_entry()
|
|
cpu.state['PC'].value = ENTRY_PTR
|
|
direct = true
|
|
print(string.format("VID: frame %d: %s; entered payload directly at %s",
|
|
frames, reason, hex(ENTRY_PTR)))
|
|
end
|
|
|
|
-- Disarm the idle-loop breakpoint and say so.
|
|
--
|
|
-- `bpclear <address>` does NOT clear by address: MAME's command takes a
|
|
-- BREAKPOINT INDEX (debugcmd.cpp, execute_bpclear -> "Invalid breakpoint
|
|
-- number"). Task 3/4 passed the address, so the breakpoint stayed armed the
|
|
-- whole run. It did not matter while the payload never yielded -- but as soon
|
|
-- as a frame burst masked interrupts long enough for the OS to reach its idle
|
|
-- loop, the stale action fired again and re-entered _start (Task 5 measured a
|
|
-- second malloc and RX_OK back at 0). This script owns the debugger session
|
|
-- and sets exactly one breakpoint, so "clear all" is the safe form.
|
|
local function disarm_entry()
|
|
dbg:command("bpclear")
|
|
dbg:command("bplist")
|
|
local last = "?"
|
|
pcall(function() last = tostring(dbg.consolelog[#dbg.consolelog]) end)
|
|
print("VID: entry breakpoint disarmed; breakpoint list: " .. last)
|
|
end
|
|
|
|
-- --- Task 7: playing the chip ----------------------------------------------
|
|
--
|
|
-- The payload owns a 16-descriptor LANCE RX ring; the emulated chip has no
|
|
-- network interface (ruling R11), so the harness plays the CHIP: it writes one
|
|
-- Ethernet frame into the buffer of the descriptor at RX_RING_POS and completes
|
|
-- that descriptor the way am79c90.cpp's recv_complete_cb() does -- RMD3 =
|
|
-- delivered length including the 4-byte FCS, RMD1 = STP | ENP with OWN clear.
|
|
-- A VS frame split over several datagrams fills consecutive descriptors in ring
|
|
-- order, which is the order the chip uses: INIT resets its ring position to 0
|
|
-- (am79c90.cpp device_start) and every completed packet advances it by one.
|
|
--
|
|
-- None of the work under test is done here: framing, header validation, slot
|
|
-- alternation, the fragment map and the paint are the payload's.
|
|
|
|
-- The terminal's Ethernet address (frame.py TERMINAL_MAC, from its address ROM).
|
|
local TERMINAL_MAC = { 0x08, 0x00, 0x2B, 0x16, 0x4B, 0x5A }
|
|
|
|
local function ring_ready()
|
|
return prog:read_u32(RX_RING_BASE) ~= 0
|
|
end
|
|
|
|
-- The armed slot's base (a BIT address), the next descriptor to fill, and which
|
|
-- slot it is.
|
|
local function ring_state()
|
|
local armed = prog:read_u32(RX_RING_ARMED)
|
|
local base = armed == 0 and prog:read_u32(RX_SLOT_A) or prog:read_u32(RX_SLOT_B)
|
|
return base, prog:read_u32(RX_RING_POS), armed
|
|
end
|
|
|
|
-- The bit address of descriptor i's RMD word at byte offset `off` (0 = RMD0
|
|
-- address low, 2 = RMD1, 4 = RMD2 length, 6 = RMD3 message count).
|
|
local function rmd_bit(ringb, i, off)
|
|
return ringb + i * 64 + off * 8
|
|
end
|
|
|
|
local function rmd1_of(ringb, i)
|
|
return prog:read_u16(rmd_bit(ringb, i, 2))
|
|
end
|
|
|
|
-- A descriptor's buffer address as a BYTE address (RMD1 bits 0-7 hold the high
|
|
-- byte, RMD0 the low 16 bits).
|
|
local function buf_addr(ringb, i)
|
|
return ((rmd1_of(ringb, i) & 0xFF) << 16) | prog:read_u16(rmd_bit(ringb, i, 0))
|
|
end
|
|
|
|
-- Write one Ethernet frame -- broadcast destination, the terminal's own source,
|
|
-- the stream's ethertype -- and `len` body bytes from `frame` at offset `off`
|
|
-- into the buffer whose bit address is `bufbit`. The 4-byte FCS the chip would
|
|
-- store after the frame is written as zero: nothing reads it (the payload
|
|
-- subtracts 4 from RMD3 and never looks at those bytes), so inventing a value
|
|
-- would only pretend the harness checked a CRC it does not check.
|
|
local function poke_packet(bufbit, frame, off, len)
|
|
local buf = bufbit // 8 -- BYTE address
|
|
local i
|
|
for i = 0, 5 do
|
|
prog:write_u8((buf + i) * 8, 0xFF)
|
|
end
|
|
for i = 0, 5 do
|
|
prog:write_u8((buf + 6 + i) * 8, TERMINAL_MAC[i + 1])
|
|
end
|
|
prog:write_u8((buf + 12) * 8, 0x88)
|
|
prog:write_u8((buf + 13) * 8, 0xB7)
|
|
for i = 0, len - 1 do
|
|
prog:write_u8((buf + ETH_HDR + i) * 8, frame[off + i])
|
|
end
|
|
for i = 0, ETH_FCS - 1 do
|
|
prog:write_u8((buf + ETH_HDR + len + i) * 8, 0)
|
|
end
|
|
end
|
|
|
|
-- Did the bytes that landed in descriptor `idx`'s buffer match the frame? The
|
|
-- checker for draw mode compares the screen against what was delivered, so a
|
|
-- delivery this harness corrupted would be blamed on the payload's blit -- hence
|
|
-- the readback, exactly as the Task 4/5 modes did it.
|
|
local function verify_chunk(bufbit, frame, off, len)
|
|
local buf = bufbit // 8 + ETH_HDR
|
|
local bad = 0
|
|
for i = 0, len - 1 do
|
|
if prog:read_u8((buf + i) * 8) ~= frame[off + i] then
|
|
bad = bad + 1
|
|
end
|
|
end
|
|
return bad == 0, bad
|
|
end
|
|
|
|
-- Complete descriptor `idx` the way the chip does. Returns false when the
|
|
-- descriptor is not armed: the ledger and the ring disagree and the caller must
|
|
-- not pretend a datagram was delivered.
|
|
local function complete_dgram(ringb, base, idx, len)
|
|
if (rmd1_of(ringb, idx) & RMD1_OWN) == 0 then
|
|
return false
|
|
end
|
|
local byte = base // 8 + idx * RXBUFLEN
|
|
prog:write_u16(rmd_bit(ringb, idx, 6), ETH_HDR + len + ETH_FCS)
|
|
prog:write_u16(rmd_bit(ringb, idx, 2),
|
|
RMD1_STP | RMD1_ENP | ((byte >> 16) & 0xFF))
|
|
return true
|
|
end
|
|
|
|
-- Complete descriptor `idx` as the chip does for a packet whose FCS failed:
|
|
-- am79c90.cpp's recv_complete_cb() writes RMD3 = 0 when RMD1_ERR is set
|
|
-- (`(m_rx_md[1] & RMD1_ERR) ? 0 : (result & RMD3_MCNT)`), and RMD1 carries
|
|
-- ERR | CRC on top of the STP | ENP a complete datagram has. The buffer still
|
|
-- holds the bytes the DMA wrote -- that is the whole point: the payload must
|
|
-- notice the datagram even though the chip gave it no length.
|
|
local function complete_dgram_err(ringb, base, idx)
|
|
if (rmd1_of(ringb, idx) & RMD1_OWN) == 0 then
|
|
return false
|
|
end
|
|
local byte = base // 8 + idx * RXBUFLEN
|
|
prog:write_u16(rmd_bit(ringb, idx, 6), 0)
|
|
prog:write_u16(rmd_bit(ringb, idx, 2),
|
|
RMD1_STP | RMD1_ENP | RMD1_ERR | RMD1_CRC | ((byte >> 16) & 0xFF))
|
|
return true
|
|
end
|
|
|
|
-- Deliver a whole VS frame (16-byte header + payload, `flen` bytes) as
|
|
-- datagrams of at most `chunk` bytes, in ring order from RX_RING_POS. Returns
|
|
-- the number of datagrams delivered, or nil when the ring is not ready or a
|
|
-- descriptor was not armed.
|
|
local function inject_frame(frame, flen, chunk)
|
|
if not ring_ready() then
|
|
return nil
|
|
end
|
|
local ringb = prog:read_u32(RX_RING_BASE)
|
|
local base, pos = ring_state()
|
|
local ndg = (flen + chunk - 1) // chunk
|
|
local k
|
|
for k = 0, ndg - 1 do
|
|
local idx = (pos + k) % RX_DESC
|
|
local off = k * chunk
|
|
local n = flen - off
|
|
if n > chunk then
|
|
n = chunk
|
|
end
|
|
local bufbit = base + idx * RXBUFLEN * 8
|
|
if (rmd1_of(ringb, idx) & RMD1_OWN) == 0 then
|
|
print(string.format("VID: descriptor %d is not armed; the ring and this harness disagree", idx))
|
|
return nil
|
|
end
|
|
poke_packet(bufbit, frame, off, n)
|
|
complete_dgram(ringb, base, idx, n)
|
|
end
|
|
return ndg
|
|
end
|
|
|
|
-- Draw mode: one valid 128-row frame into slot A. Row r is the 16-bit word
|
|
-- 0xF0F0 repeated (160 bytes of 0xF0), except every third row (r % 3 == 2),
|
|
-- which is all zero -- two row classes, so a blit that always read the same
|
|
-- source row cannot pass. The frame bytes are kept for the checker, which
|
|
-- compares against what was actually delivered rather than re-deriving it.
|
|
local draw_at = nil
|
|
local draw_bytes = nil
|
|
local rx_ok_at = nil
|
|
|
|
-- How long to wait for the payload to accept the frame, in EMULATED FRAMES.
|
|
-- This hook only runs once a frame, so that is the resolution of the
|
|
-- measurement below as well as the timeout. VID_DRAW_WAIT exists so the
|
|
-- timeout path can be exercised on purpose (set it to 1).
|
|
local DRAW_WAIT = tonumber(os.getenv("VID_DRAW_WAIT") or "300")
|
|
|
|
-- Evidence freshness (ruling R15): remove this mode's dump files before the
|
|
-- run, so a failed run cannot be checked against the previous run's screen.
|
|
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",
|
|
"vram-delta-large.bin", "dict-delta.bin", "indices-delta.bin",
|
|
"delta-status.txt" }
|
|
|
|
local function clear_dump_files()
|
|
if MODE == "dict" then
|
|
os.remove(DUMP_DIR .. "/dict-status.txt")
|
|
return
|
|
end
|
|
if MODE == "tiles" then
|
|
for _, name in ipairs(TILE_FILES) do
|
|
os.remove(DUMP_DIR .. "/" .. name)
|
|
end
|
|
return
|
|
end
|
|
if MODE == "delta" then
|
|
for _, name in ipairs(DELTA_FILES) do
|
|
os.remove(DUMP_DIR .. "/" .. name)
|
|
end
|
|
return
|
|
end
|
|
if MODE == "replay" then
|
|
-- vram-replay-*.bin is one file per clip frame and the count is not
|
|
-- known until the stream is read (the closure reads it after this),
|
|
-- so run-vidstream.sh removes those; this removes the verdict.
|
|
os.remove(DUMP_DIR .. "/replay-status.txt")
|
|
return
|
|
end
|
|
if MODE ~= "draw" and MODE ~= "stream" then
|
|
return
|
|
end
|
|
for _, name in ipairs(DRAW_FILES) do
|
|
os.remove(DUMP_DIR .. "/" .. name)
|
|
end
|
|
for _, name in ipairs(STREAM_FILES) do
|
|
os.remove(DUMP_DIR .. "/" .. name)
|
|
end
|
|
end
|
|
|
|
clear_dump_files()
|
|
|
|
local function draw_row_byte(i)
|
|
if (i // RX_ROW_BYTES) % 3 == 2 then
|
|
return 0x00
|
|
end
|
|
return 0xF0
|
|
end
|
|
|
|
local function inject_draw()
|
|
-- Negative control for the OS-repaint canary: clobber the sentinel word
|
|
-- (last screen row, column 1280) just before the frame is delivered. The
|
|
-- first blit must then count an OS touch. Off by default; see
|
|
-- VID_SENTINEL_CLOBBER in the env list.
|
|
if os.getenv("VID_SENTINEL_CLOBBER") then
|
|
prog:write_u16(VRAM_BASE + (VRAM_ROWS - 1) * VRAM_ROW_BITS + 1280,
|
|
0x1234)
|
|
print(string.format("VID: frame %d: sentinel clobbered to 1234 (canary control)",
|
|
frames))
|
|
end
|
|
|
|
local f = make_frame(1, 0x0051, DRAW_ROWS, DRAW_STRETCH, DRAW_PLEN,
|
|
draw_row_byte)
|
|
local ndg = inject_frame(f, DRAW_FRAME, DRAW_CHUNK)
|
|
if not ndg then
|
|
return false
|
|
end
|
|
draw_bytes = f
|
|
local base, pos = ring_state()
|
|
local okb, badb = true, 0
|
|
local k
|
|
for k = 0, ndg - 1 do
|
|
local idx = (pos + k) % RX_DESC
|
|
local off = k * DRAW_CHUNK
|
|
local n = DRAW_FRAME - off
|
|
if n > DRAW_CHUNK then
|
|
n = DRAW_CHUNK
|
|
end
|
|
local okc, badc = verify_chunk(base + idx * RXBUFLEN * 8, f, off, n)
|
|
badb = badb + badc
|
|
if not okc then
|
|
okb = false
|
|
end
|
|
end
|
|
print(string.format("VID: frame %d: delivered %d datagrams (%d B, valid, %d rows x %d stretch) into the ring",
|
|
frames, ndg, DRAW_FRAME, DRAW_ROWS, DRAW_STRETCH))
|
|
print(string.format("VID: frame %d: draw frame readback %s (%d bad)",
|
|
frames, okb and "OK" or "FAILED", badb))
|
|
return true
|
|
end
|
|
|
|
-- Read the screen as memory: 1024 rows x 256 bytes from bit 0x04000000.
|
|
-- read_u16 is used rather than read_u32 so the byte order (a TMS34010 word is
|
|
-- little-endian on the wire, low byte first) is explicit rather than assumed.
|
|
local function read_vram()
|
|
local rows = {}
|
|
|
|
for y = 0, VRAM_ROWS - 1 do
|
|
local off = VRAM_BASE + y * VRAM_ROW_BITS
|
|
local chars = {}
|
|
for w = 0, VRAM_ROW_BYTES // 2 - 1 do
|
|
local v = prog:read_u16(off + w * 16)
|
|
chars[#chars + 1] = string.char(v & 0xFF, (v >> 8) & 0xFF)
|
|
end
|
|
rows[#rows + 1] = table.concat(chars)
|
|
end
|
|
return table.concat(rows)
|
|
end
|
|
|
|
local function write_file(path, s)
|
|
local f, err = io.open(path, "wb")
|
|
if not f then
|
|
print("VID ERROR: cannot write " .. path .. ": " .. tostring(err))
|
|
return false
|
|
end
|
|
f:write(s)
|
|
f:close()
|
|
return true
|
|
end
|
|
|
|
-- --- Task 6: stream mode ---------------------------------------------------
|
|
--
|
|
-- The harness plays the sender here, and the terminal's status frame is the
|
|
-- thing under test. The frames go in through the payload's own RX ring, exactly
|
|
-- as in modes rx, draw and ring, because the emulated LANCE has no network
|
|
-- interface (R11).
|
|
--
|
|
-- One frame is ONE datagram here: 8 rows of 160 bytes is a 1296-byte VS frame,
|
|
-- so it fits a single 1526-byte buffer. That is what keeps a 200-frame run
|
|
-- affordable and position-independent: every descriptor buffer in both slots
|
|
-- holds the same frame body, so whichever descriptor the ring is on, the harness
|
|
-- only has to rewrite that body's 16-byte header and complete the descriptor.
|
|
-- The multi-datagram path (a 128-row frame split over 15 or 16 datagrams) is
|
|
-- covered by modes draw and ring; this mode's subject is the status cadence, and
|
|
-- 200 accepted frames exercise the ring position advancing, wrapping and the
|
|
-- slots alternating 200 times.
|
|
|
|
-- The 16-byte VS header for one frame number, sealed over its first 12 bytes
|
|
-- (ruling R14). The body of each descriptor buffer is written once; only this
|
|
-- header changes per frame, so the harness is not slower than the payload.
|
|
local function header_bytes(kind, frame_no, rows, stretch, plen)
|
|
local b = {}
|
|
b[0] = 0x56
|
|
b[1] = 0x53
|
|
b[2] = 0x01
|
|
b[3] = kind
|
|
b[4] = frame_no & 0xFF
|
|
b[5] = (frame_no >> 8) & 0xFF
|
|
b[6] = plen & 0xFF
|
|
b[7] = (plen >> 8) & 0xFF
|
|
b[8] = rows
|
|
b[9] = stretch
|
|
b[10] = 0
|
|
b[11] = 0
|
|
local crc = crc_add(0xFFFF, b, 0, 12)
|
|
b[12] = crc & 0xFF
|
|
b[13] = (crc >> 8) & 0xFF
|
|
b[14] = 0
|
|
b[15] = 0
|
|
return b
|
|
end
|
|
|
|
local function read_bytes(base, n)
|
|
local chars = {}
|
|
local t = {}
|
|
for i = 0, n - 1 do
|
|
local v = prog:read_u8(base + i * 8)
|
|
t[i] = v
|
|
chars[#chars + 1] = string.char(v)
|
|
end
|
|
return table.concat(chars), t
|
|
end
|
|
|
|
local function le16(t, off)
|
|
return t[off] | (t[off + 1] << 8)
|
|
end
|
|
|
|
local function le32(t, off)
|
|
return t[off] | (t[off + 1] << 8) | (t[off + 2] << 16) | (t[off + 3] << 24)
|
|
end
|
|
|
|
local stream_prefilled = false
|
|
local stream_body = nil
|
|
local announced = 0
|
|
local eof_at = nil
|
|
local drain_from = nil
|
|
local st_sent_seen = 0
|
|
local bad_captures = 0
|
|
local captures = {}
|
|
|
|
-- Lay the stream frame's body into EVERY descriptor buffer of BOTH slots, once.
|
|
-- One frame is one datagram, so whichever descriptor the ring is on already
|
|
-- holds the right bytes and only the header has to change per frame.
|
|
local function stream_prefill()
|
|
if not ring_ready() then
|
|
return false
|
|
end
|
|
local a = prog:read_u32(RX_SLOT_A)
|
|
local b = prog:read_u32(RX_SLOT_B)
|
|
local k
|
|
|
|
if a == 0 or b == 0 then
|
|
return false
|
|
end
|
|
stream_body = make_frame(1, 0, RX_ROWS, RX_STRETCH, RX_PLEN,
|
|
draw_row_byte)
|
|
for k = 0, RX_DESC - 1 do
|
|
poke_packet(a + k * RXBUFLEN * 8, stream_body, 0, RX_FRAME)
|
|
poke_packet(b + k * RXBUFLEN * 8, stream_body, 0, RX_FRAME)
|
|
end
|
|
print(string.format("VID: frame %d: stream ring laid out (%d-row frame, %d B, one datagram per descriptor; A=%s B=%s); sender will deliver %d frames",
|
|
frames, RX_ROWS, RX_FRAME, hex(a), hex(b), STREAM_FRAMES))
|
|
return true
|
|
end
|
|
|
|
-- Deliver one frame: rewrite the header in the buffer of the descriptor at
|
|
-- RX_RING_POS and complete it. Returns false while that descriptor is not armed
|
|
-- (the payload has not finished with the previous frame), which is how the
|
|
-- sender learns the terminal's pace -- the role the ready flag played in Task 4.
|
|
local function inject_stream()
|
|
local ringb = prog:read_u32(RX_RING_BASE)
|
|
local base, pos = ring_state()
|
|
local hdr, k
|
|
|
|
if (rmd1_of(ringb, pos) & RMD1_OWN) == 0 then
|
|
return false -- not re-armed yet
|
|
end
|
|
hdr = header_bytes(1, announced & 0xFFFF, RX_ROWS, RX_STRETCH, RX_PLEN)
|
|
local hbuf = base // 8 + pos * RXBUFLEN + ETH_HDR
|
|
for k = 0, 15 do
|
|
prog:write_u8((hbuf + k) * 8, hdr[k])
|
|
end
|
|
complete_dgram(ringb, base, pos, RX_FRAME)
|
|
announced = announced + 1
|
|
return true
|
|
end
|
|
|
|
-- Read the status frame the payload last produced, when ST_SENT moves. The
|
|
-- payload publishes ST_SENT last, so a reader that sees it move reads a frame
|
|
-- that was built, transmitted and recorded. This Lua only decodes enough to
|
|
-- decide when to stop; the wire format is checked by
|
|
-- harness/mame/vidstream_status_check.py, which decodes the dumped bytes.
|
|
local function capture_status()
|
|
local sent = prog:read_u32(ST_SENT)
|
|
|
|
if sent == st_sent_seen then
|
|
return
|
|
end
|
|
st_sent_seen = sent
|
|
|
|
local base = prog:read_u32(ST_BUF)
|
|
if base == 0 then
|
|
return
|
|
end
|
|
|
|
local raw, t = read_bytes(base, STATUS_BYTES)
|
|
local rec = {
|
|
bytes = raw,
|
|
magic_ok = (t[14] == 0x56 and t[15] == 0x53),
|
|
kind = t[17],
|
|
payload_len = le16(t, 20),
|
|
frames_rx = le32(t, STATUS_BODY),
|
|
crc_err = le16(t, STATUS_BODY + 4),
|
|
drops = le16(t, STATUS_BODY + 6),
|
|
csr0 = le16(t, STATUS_BODY + 8),
|
|
heap_kb = le16(t, STATUS_BODY + 10),
|
|
dict_state = t[STATUS_BODY + 12],
|
|
decode_worst = le16(t, STATUS_BODY + 14),
|
|
}
|
|
-- Only a well-formed VS status frame counts. A payload without the
|
|
-- status path leaves ST_SENT/ST_BUF as whatever RAM held, so the first
|
|
-- "capture" would otherwise be garbage presented as a report -- and
|
|
-- "no status frames" is exactly what a Task 5 tree must say.
|
|
if not rec.magic_ok or rec.kind ~= 5 or rec.payload_len ~= 16 then
|
|
bad_captures = bad_captures + 1
|
|
print(string.format("VID: status capture %d is not a VS status frame (magic=%s kind=%d payload_len=%d); ignored",
|
|
bad_captures, rec.magic_ok and "VS" or "WRONG", rec.kind,
|
|
rec.payload_len))
|
|
return
|
|
end
|
|
captures[#captures + 1] = rec
|
|
print(string.format("VID: status %d: frames_rx=%d crc_err=%d drops=%d csr0=%04X heap_free_kb=%d dict_state=%02X decode_worst=%d kind=%d payload_len=%d",
|
|
#captures, rec.frames_rx, rec.crc_err, rec.drops, rec.csr0,
|
|
rec.heap_kb, rec.dict_state, rec.decode_worst, rec.kind,
|
|
rec.payload_len))
|
|
end
|
|
|
|
-- Dump the captured status frames and the run's counters, then end the run.
|
|
-- The verdict here is only "the run produced what the harness asked for"; the
|
|
-- bytes themselves are decoded independently by the checker, so a wrong field
|
|
-- offset or a bad CRC cannot be blessed by this Lua.
|
|
local function dump_stream(timed_out)
|
|
local chunks = {}
|
|
local last = captures[#captures]
|
|
local lines = {
|
|
string.format("INJECTED=%d", announced),
|
|
string.format("STATUS_COUNT=%d", #captures),
|
|
string.format("BAD_STATUS_FRAMES=%d", bad_captures),
|
|
string.format("RX_OK=%d", prog:read_u32(RX_OK)),
|
|
string.format("RX_BAD=%d", prog:read_u32(RX_BAD)),
|
|
string.format("ST_INIT=%d", prog:read_u32(ST_INIT)),
|
|
string.format("ST_TMD1=%d", prog:read_u32(ST_TMD1)),
|
|
string.format("EOF_SET=%d", eof_at and 1 or 0),
|
|
string.format("TIMEOUT=%d", timed_out and 1 or 0),
|
|
}
|
|
for _, rec in ipairs(captures) do
|
|
chunks[#chunks + 1] = rec.bytes
|
|
end
|
|
if last then
|
|
lines[#lines + 1] = string.format("LAST_FRAMES_RX=%d", last.frames_rx)
|
|
lines[#lines + 1] = string.format("LAST_CRC_ERR=%d", last.crc_err)
|
|
lines[#lines + 1] = string.format("LAST_DROPS=%d", last.drops)
|
|
lines[#lines + 1] = string.format("LAST_CSR0=%d", last.csr0)
|
|
lines[#lines + 1] = string.format("LAST_HEAP_KB=%d", last.heap_kb)
|
|
lines[#lines + 1] = string.format("LAST_DICT_STATE=%d", last.dict_state)
|
|
lines[#lines + 1] = string.format("LAST_DECODE_WORST=%d",
|
|
last.decode_worst)
|
|
lines[#lines + 1] = string.format("LAST_KIND=%d", last.kind)
|
|
lines[#lines + 1] = string.format("LAST_PAYLOAD_LEN=%d", last.payload_len)
|
|
end
|
|
|
|
-- The descriptor's state now, and how long the chip took to finish it.
|
|
local tp = prog:read_u32(ST_TMLAST)
|
|
local tmd1_now = tp ~= 0 and prog:read_u16(tp) or 0
|
|
lines[#lines + 1] = string.format("TMD1_DRAINED=%d", tmd1_now)
|
|
lines[#lines + 1] = string.format("DRAIN_FRAMES=%d",
|
|
drain_from and (frames - drain_from) or -1)
|
|
|
|
local okb = write_file(DUMP_DIR .. "/stream-status.bin", table.concat(chunks))
|
|
local okt = write_file(DUMP_DIR .. "/stream-status.txt",
|
|
table.concat(lines, "\n") .. "\n")
|
|
print(string.format("VID: %d status frames captured (%d B) in %s/stream-status.bin",
|
|
#captures, #table.concat(chunks), DUMP_DIR))
|
|
|
|
local ok = okb and okt and not timed_out and bad_captures == 0
|
|
and #captures >= 3 and last ~= nil
|
|
and last.frames_rx == STREAM_FRAMES and last.drops == 0
|
|
and last.crc_err == 0
|
|
print(ok and "VID: RESULT PASS" or "VID: RESULT FAIL")
|
|
machine:exit()
|
|
end
|
|
|
|
-- --- Task 7: ring mode -----------------------------------------------------
|
|
--
|
|
-- The structural check of the payload's own receive ring. Nothing here looks at
|
|
-- pixels -- draw mode owns those. What this mode establishes is the transport's
|
|
-- contract: 16 descriptors armed for one slot, a completed frame moving them to
|
|
-- the other slot, a frame larger than a slot rejected without a blit, and a
|
|
-- datagram the chip flags bad (RMD3 = 0, RMD1_ERR) counted as a drop and its
|
|
-- frame abandoned rather than lost silently. That is as far as an emulator with
|
|
-- no network interface (R11) can go; on the unit the same descriptors are filled
|
|
-- by the chip's DMA instead of by this script.
|
|
|
|
local RING_WAIT = tonumber(os.getenv("VID_RING_WAIT") or "300")
|
|
local inspect_pool -- defined below, after the dumpers
|
|
local ring_phase = 0
|
|
local ring_phase_at = nil
|
|
local ring_fail = 0
|
|
local ring_lines = {}
|
|
local ring_vram0 = nil
|
|
local ring_ringb = 0
|
|
local ring_pos0 = 0
|
|
local ring_dgrams = 0
|
|
|
|
local function ring_check(cond, what)
|
|
if cond then
|
|
ring_lines[#ring_lines + 1] = "PASS " .. what
|
|
print("VID: ring PASS: " .. what)
|
|
else
|
|
ring_lines[#ring_lines + 1] = "FAIL " .. what
|
|
print("VID: ring FAIL: " .. what)
|
|
ring_fail = ring_fail + 1
|
|
end
|
|
return cond
|
|
end
|
|
|
|
-- Are all 16 descriptors armed for `base`? OWN set, buffer i at
|
|
-- base + i*1526, buffers 1526 bytes apart, RMD2 = 0x10000 - 1526.
|
|
local function ring_armed_for(base, what)
|
|
local want = base // 8
|
|
local i, badown, badaddr, badlen, badstride = 0, 0, 0, 0, 0
|
|
local prev = nil
|
|
for i = 0, RX_DESC - 1 do
|
|
if (rmd1_of(ring_ringb, i) & RMD1_OWN) == 0 then
|
|
badown = badown + 1
|
|
end
|
|
if buf_addr(ring_ringb, i) ~= want + i * RXBUFLEN then
|
|
badaddr = badaddr + 1
|
|
end
|
|
if prog:read_u16(rmd_bit(ring_ringb, i, 4)) ~= (0x10000 - RXBUFLEN) % 0x10000 then
|
|
badlen = badlen + 1
|
|
end
|
|
if prev ~= nil and buf_addr(ring_ringb, i) - prev ~= RXBUFLEN then
|
|
badstride = badstride + 1
|
|
end
|
|
prev = buf_addr(ring_ringb, i)
|
|
end
|
|
return ring_check(badown == 0 and badaddr == 0 and badlen == 0 and badstride == 0,
|
|
string.format("%s: 16 descriptors OWN-set, buffers 0x%06X + i*%d (own-bad %d, addr-bad %d, len-bad %d, stride-bad %d)",
|
|
what, want, RXBUFLEN, badown, badaddr, badlen, badstride))
|
|
end
|
|
|
|
-- The 32 bytes at the start of screen row 0. Enough to see a blit: the frame
|
|
-- ring mode delivers paints row 0 with 0xF0 bytes, so any paint changes them.
|
|
local function read_vram_head(n)
|
|
local chars = {}
|
|
for i = 0, n - 1 do
|
|
chars[#chars + 1] = string.char(prog:read_u8(VRAM_BASE + i * 8))
|
|
end
|
|
return table.concat(chars)
|
|
end
|
|
|
|
local function hex_bytes(s)
|
|
return (s:gsub(".", function(c) return string.format("%02X", c:byte()) end))
|
|
end
|
|
|
|
-- A VS frame whose header claims payload_len = 0xFFFF, i.e. 65551 bytes when the
|
|
-- slot is 24576. It can never complete, so the payload must reject it on the
|
|
-- header alone: RX_BAD ticks, nothing is painted, and the ring recovers.
|
|
local function oversize_frame()
|
|
local b = {}
|
|
local i
|
|
b[0] = 0x56
|
|
b[1] = 0x53
|
|
b[2] = 0x01
|
|
b[3] = 1
|
|
b[4] = 0x77
|
|
b[5] = 0x00
|
|
b[6] = 0xFF
|
|
b[7] = 0xFF -- payload_len = 65535
|
|
b[8] = DRAW_ROWS
|
|
b[9] = DRAW_STRETCH
|
|
b[10] = 0
|
|
b[11] = 0
|
|
local crc = crc_add(0xFFFF, b, 0, 12)
|
|
b[12] = crc & 0xFF
|
|
b[13] = (crc >> 8) & 0xFF
|
|
b[14] = 0
|
|
b[15] = 0
|
|
for i = 16, 16 + 1383 do
|
|
b[i] = 0xAA -- a body that would be visible if it painted
|
|
end
|
|
return b, 1400
|
|
end
|
|
|
|
local function dump_ring(timed_out)
|
|
local a = prog:read_u32(RX_SLOT_A)
|
|
local b = prog:read_u32(RX_SLOT_B)
|
|
local lines = {
|
|
string.format("RING_BASE=0x%X", ring_ringb),
|
|
string.format("RING_POS0=%d", ring_pos0),
|
|
string.format("RING_POS=%d", prog:read_u32(RX_RING_POS)),
|
|
string.format("SLOT_A=0x%X", a),
|
|
string.format("SLOT_B=0x%X", b),
|
|
string.format("RING_ARMED=%d", prog:read_u32(RX_RING_ARMED)),
|
|
string.format("RX_OK=%d", prog:read_u32(RX_OK)),
|
|
string.format("RX_BAD=%d", prog:read_u32(RX_BAD)),
|
|
string.format("RX_LAST_LEN=%d", prog:read_u32(RX_LAST_LEN)),
|
|
string.format("RX_SLOT=%d", prog:read_u32(RX_SLOT)),
|
|
string.format("RX_DGRAM=%d", prog:read_u32(RX_DGRAM)),
|
|
string.format("RX_RING_DROPS=%d", prog:read_u32(RX_RING_DROPS)),
|
|
string.format("RX_FCS_DROP=%d", prog:read_u32(RX_FCS_DROP)),
|
|
string.format("RX_RING_ACC=%d", prog:read_u32(RX_RING_ACC)),
|
|
string.format("VRAM_ROW0_BEFORE=%s", ring_vram0 and hex_bytes(ring_vram0) or "-"),
|
|
string.format("VRAM_ROW0_AFTER=%s", hex_bytes(read_vram_head(32))),
|
|
string.format("RING_CHECKS_FAILED=%d", ring_fail),
|
|
string.format("TIMEOUT=%d", timed_out and 1 or 0),
|
|
}
|
|
for _, l in ipairs(ring_lines) do
|
|
lines[#lines + 1] = "CHECK " .. l
|
|
end
|
|
local okf = write_file(DUMP_DIR .. "/ring-status.txt",
|
|
table.concat(lines, "\n") .. "\n")
|
|
local ok = okf and not timed_out and ring_fail == 0
|
|
print(string.format("VID: ring: %d checks failed, %d datagrams consumed, RX_OK=%d RX_BAD=%d",
|
|
ring_fail, prog:read_u32(RX_DGRAM), prog:read_u32(RX_OK),
|
|
prog:read_u32(RX_BAD)))
|
|
print(ok and "VID: RESULT PASS" or "VID: RESULT FAIL")
|
|
machine:exit()
|
|
end
|
|
|
|
-- One call per emulated frame while mode ring is running. Returns true when the
|
|
-- run is over (the caller must not continue).
|
|
local function ring_step()
|
|
local a = prog:read_u32(RX_SLOT_A)
|
|
local b = prog:read_u32(RX_SLOT_B)
|
|
local ndg
|
|
|
|
if ring_phase == 0 then
|
|
if ring_phase_at == nil then
|
|
ring_phase_at = frames
|
|
end
|
|
if not ring_ready() then
|
|
if frames >= ring_phase_at + RING_WAIT then
|
|
ring_check(false, string.format(
|
|
"the payload published an RX ring (RX_RING_BASE=%s, POOL_PTR=%s, ST_INIT=%04X, heartbeat=%d)",
|
|
hex(prog:read_u32(RX_RING_BASE)),
|
|
hex(prog:read_u32(POOL_PTR)),
|
|
prog:read_u32(ST_INIT),
|
|
prog:read_u32(VID_ALIVE)))
|
|
dump_ring(true)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
ring_ringb = prog:read_u32(RX_RING_BASE)
|
|
local _, pos0, armed0 = ring_state()
|
|
ring_pos0 = pos0
|
|
print(string.format("VID: frame %d: RX ring 0x%X armed on slot %d (pos %d), A=%s B=%s",
|
|
frames, ring_ringb, armed0, pos0, hex(a), hex(b)))
|
|
ring_check(a ~= 0 and (b - a) == SLOT_BYTES * 8,
|
|
"slot B follows slot A by SLOT_BYTES")
|
|
ring_check(armed0 == 0, "the ring starts armed on slot A")
|
|
ring_armed_for(a, "slot A")
|
|
-- One complete 128-row frame: 15 datagrams of 1450 bytes.
|
|
local f = make_frame(1, 0x0091, DRAW_ROWS, DRAW_STRETCH, DRAW_PLEN,
|
|
draw_row_byte)
|
|
ndg = inject_frame(f, DRAW_FRAME, DRAW_CHUNK)
|
|
ring_check(ndg == 15, string.format("the frame was delivered as 15 datagrams (got %s)",
|
|
tostring(ndg)))
|
|
ring_phase = 1
|
|
ring_phase_at = frames
|
|
return false
|
|
end
|
|
|
|
if ring_phase == 1 then
|
|
if prog:read_u32(RX_OK) < 1 then
|
|
if frames >= ring_phase_at + RING_WAIT then
|
|
print(string.format("VID: timed out waiting for the frame to be accepted (RX_OK=%d RX_BAD=%d RX_RING_ACC=%d)",
|
|
prog:read_u32(RX_OK), prog:read_u32(RX_BAD),
|
|
prog:read_u32(RX_RING_ACC)))
|
|
dump_ring(true)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
ring_dgrams = prog:read_u32(RX_DGRAM)
|
|
ring_check(prog:read_u32(RX_SLOT) == 0, "the accepted frame came in slot A")
|
|
ring_check(prog:read_u32(RX_LAST_LEN) == DRAW_PLEN,
|
|
string.format("RX_LAST_LEN is the frame's payload_len (%d)", DRAW_PLEN))
|
|
ring_check(prog:read_u32(RX_RING_ACC) == 0,
|
|
"the frame left no bytes in progress")
|
|
ring_check(ring_dgrams >= 15, string.format("the consumer took all 15 datagrams (%d)", ring_dgrams))
|
|
ring_check(prog:read_u32(RX_RING_ARMED) == 1,
|
|
"the completion moved the write pointer to slot B")
|
|
ring_armed_for(b, "slot B after the frame")
|
|
ring_vram0 = read_vram_head(32)
|
|
-- Now the impossible frame, into the slot the ring is armed on (B).
|
|
local big, blen = oversize_frame()
|
|
ndg = inject_frame(big, blen, DRAW_CHUNK)
|
|
ring_check(ndg == 1, "the oversize frame was delivered as one datagram")
|
|
ring_phase = 2
|
|
ring_phase_at = frames
|
|
return false
|
|
end
|
|
|
|
if ring_phase == 2 then
|
|
if prog:read_u32(RX_BAD) < 1 then
|
|
if frames >= ring_phase_at + RING_WAIT then
|
|
print(string.format("VID: timed out waiting for the oversize frame to be rejected (RX_BAD=%d RX_RING_ACC=%d)",
|
|
prog:read_u32(RX_BAD), prog:read_u32(RX_RING_ACC)))
|
|
dump_ring(true)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
ring_check(prog:read_u32(RX_BAD) == 1, "RX_BAD counts the oversize frame")
|
|
ring_check(prog:read_u32(RX_OK) == 1, "RX_OK did not move: the oversize frame was not accepted")
|
|
local after = read_vram_head(32)
|
|
ring_check(ring_vram0 ~= nil and after == ring_vram0,
|
|
"the screen did not change: the oversize frame was not blitted")
|
|
ring_check(prog:read_u32(RX_RING_ARMED) == 0,
|
|
"the rejection re-armed the ring on slot A")
|
|
ring_armed_for(a, "slot A after the rejection")
|
|
ring_check(prog:read_u32(RX_RING_DROPS) == 0,
|
|
"no frame was dropped for want of descriptors")
|
|
-- Phase 3: one datagram the chip flagged bad (FCS). The chip gives
|
|
-- it no length at all (RMD3 = 0), so the payload can only see it
|
|
-- through RMD1_ERR -- and it must count it as a drop, or a
|
|
-- body-corrupting packet would vanish from both counters the gate
|
|
-- is made of (drops and crc_err).
|
|
local abase, apos = ring_state()
|
|
local eidx = apos
|
|
local ebit = abase + eidx * RXBUFLEN * 8
|
|
local ef = make_frame(1, 0x0091, DRAW_ROWS, DRAW_STRETCH, DRAW_PLEN,
|
|
draw_row_byte)
|
|
poke_packet(ebit, ef, 0, DRAW_CHUNK)
|
|
ring_check(complete_dgram_err(ring_ringb, abase, eidx),
|
|
string.format("the FCS-error datagram was completed at descriptor %d, RMD3 = 0", eidx))
|
|
ring_phase = 3
|
|
ring_phase_at = frames
|
|
return false
|
|
end
|
|
|
|
if ring_phase == 3 then
|
|
if prog:read_u32(RX_FCS_DROP) < 1 then
|
|
if frames >= ring_phase_at + RING_WAIT then
|
|
print(string.format("VID: timed out waiting for the FCS-error datagram to be counted (RX_FCS_DROP=%d RX_RING_ACC=%d RX_OK=%d)",
|
|
prog:read_u32(RX_FCS_DROP),
|
|
prog:read_u32(RX_RING_ACC), prog:read_u32(RX_OK)))
|
|
dump_ring(true)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
ring_check(prog:read_u32(RX_FCS_DROP) == 1,
|
|
"RX_FCS_DROP counts the datagram the chip flagged bad")
|
|
ring_check(prog:read_u32(RX_RING_ACC) == 0,
|
|
"the FCS error abandoned its frame: nothing is left in progress")
|
|
ring_check(prog:read_u32(RX_OK) == 1,
|
|
"RX_OK did not move: the errored datagram painted nothing")
|
|
ring_check(prog:read_u32(RX_RING_ARMED) == 1,
|
|
"the FCS error re-armed the ring on slot B")
|
|
ring_armed_for(b, "slot B after the FCS error")
|
|
local after = read_vram_head(32)
|
|
ring_check(ring_vram0 ~= nil and after == ring_vram0,
|
|
"the screen did not change: the errored datagram was not blitted")
|
|
local pool_ok = inspect_pool()
|
|
ring_check(pool_ok, "the startup allocation is still the one under test")
|
|
dump_ring(false)
|
|
return true
|
|
end
|
|
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
|
|
|
|
-- A TILES_FULL frame is one dictionary index per tile, row-major: 40 across x
|
|
-- 32 down = 1,280 bytes (tilecost.TILES_PER_SCREEN). Tasks 3 and 4 both build
|
|
-- one, so the constant lives here.
|
|
local TILES_PER_FRAME = 1280
|
|
|
|
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,
|
|
TILES_PER_FRAME,
|
|
function(i) return (i * 5 + 1) & 0xFF end),
|
|
n = 16 + TILES_PER_FRAME }
|
|
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))
|
|
-- The first refusal (the duplicate-offset chunk) must produce an
|
|
-- out-of-cadence status: no frame has been accepted, so the
|
|
-- 64-frame rule would never fire and the host would see silence
|
|
-- instead of "dictionary incomplete". ST_SENT moves only after
|
|
-- send_status() hands the frame to the chip.
|
|
if dict_last.bad == 1 and prog:read_u32(ST_SENT) == 0 then
|
|
if frames >= dict_phase_at + DICT_WAIT then
|
|
dict_check(false,
|
|
"a refused dictionary chunk produced an early status (ST_SENT=0)")
|
|
dict_dump(true)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
if dict_last.bad == 1 then
|
|
after = select(2, read_bytes(prog:read_u32(ST_BUF)
|
|
+ STATUS_BODY * 8, 16))
|
|
dict_check(le32(after, 0) == 0,
|
|
string.format("the refusal's early status reports frames_rx=0 (got %d)",
|
|
le32(after, 0)))
|
|
dict_check((after[12] & 2) ~= 0,
|
|
string.format("the refusal's early status sets DICT_BAD (dict_state=0x%02X)",
|
|
after[12]))
|
|
dict_check((after[12] & 1) == 0,
|
|
string.format("the refusal's early status does not claim DICT_READY (dict_state=0x%02X)",
|
|
after[12]))
|
|
end
|
|
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 (1,280 indices, every one 0x5A = entry 90). The paint is
|
|
-- reported rather than asserted here -- the assertion above
|
|
-- (before DICT_READY) is Task 3's, and tiles mode owns the
|
|
-- pixel verdict.
|
|
a = { f = make_frame(KIND_TILES_FULL, 0x0062, 0, 0,
|
|
TILES_PER_FRAME,
|
|
function(i) return 0x5A end),
|
|
n = 16 + TILES_PER_FRAME,
|
|
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 dict_last ~= nil 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 it; the pixels are tiles mode's check)",
|
|
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; pixels are tiles mode's check)",
|
|
prog:read_u32(TILES_DROP), prog:read_u32(RX_OK)))
|
|
dict_last = nil
|
|
end
|
|
-- The first accepted frame forces an early status report. Waiting
|
|
-- for ST_FRAMES to say 1 (send_status publishes it after the chip
|
|
-- takes the frame) is also the check on that path, and the body now
|
|
-- carries dict_state and the worst decode time -- the latter must be
|
|
-- nonzero because a TILES_FULL was just painted.
|
|
if prog:read_u32(ST_FRAMES) ~= 1 then
|
|
if frames >= dict_phase_at + DICT_WAIT then
|
|
dict_check(false, string.format(
|
|
"the first accepted frame produced an early status (ST_FRAMES=%d, RX_OK=%d)",
|
|
prog:read_u32(ST_FRAMES), prog:read_u32(RX_OK)))
|
|
dict_dump(true)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
after = select(2, read_bytes(prog:read_u32(ST_BUF)
|
|
+ (STATUS_BODY + 12) * 8, 4))
|
|
dict_check(after[0] == 3,
|
|
string.format("the early status reports dict_state=0x%02X (DICT_READY|DICT_BAD = 3)",
|
|
after[0]))
|
|
dict_check((after[2] | (after[3] << 8)) > 0,
|
|
string.format("the early status reports the painted frame's decode time (decode_worst=%d REFCNT ticks)",
|
|
after[2] | (after[3] << 8)))
|
|
-- Plan 2 ruling R1: the dictionary is fixed for the clip, so a chunk
|
|
-- that arrives after DICT_READY is refused, not applied. This is the
|
|
-- case Task 3's review carried: a late or duplicated upload must not
|
|
-- silently rewrite the picture's vocabulary. dict_at is no longer
|
|
-- needed as the plan cursor, so it holds the pre-refusal DICT_BAD.
|
|
dict_at = prog:read_u32(DICT_BAD)
|
|
a = { f = dict_chunk_frame(0, DICT_ENTRY), n = 16 + 2 + DICT_ENTRY,
|
|
label = "chunk at offset 0 after DICT_READY (must be refused)" }
|
|
if not dict_take(a) then
|
|
dict_check(false, "the ring accepted the post-DICT_READY DICT_CHUNK")
|
|
dict_dump(true)
|
|
return true
|
|
end
|
|
dict_phase = 5
|
|
dict_phase_at = frames
|
|
return false
|
|
end
|
|
|
|
if dict_phase == 5 then
|
|
if not dict_settled() then
|
|
if frames >= dict_phase_at + DICT_WAIT then
|
|
dict_check(false, "the post-DICT_READY DICT_CHUNK was consumed")
|
|
dict_dump(true)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
dict_check(prog:read_u32(DICT_BAD) == dict_at + 1,
|
|
string.format("a DICT_CHUNK after DICT_READY is refused and counted (DICT_BAD=%d, expected %d)",
|
|
prog:read_u32(DICT_BAD), dict_at + 1))
|
|
dict_check(prog:read_u32(DICT_BYTES) == DICT_SIZE,
|
|
string.format("the refused late chunk left DICT_BYTES alone (DICT_BYTES=%d)",
|
|
prog:read_u32(DICT_BYTES)))
|
|
dict_check(prog:read_u32(DICT_READY) == 1,
|
|
"the refused late chunk left DICT_READY 1")
|
|
-- The bytes, not just the counters: a refused chunk that wrote first
|
|
-- and counted after would show up here.
|
|
okv, first, why = dict_verify()
|
|
dict_check(okv, why or string.format(
|
|
"the refused late chunk did not rewrite the dictionary (first bad byte %d)",
|
|
first))
|
|
dict_dump(false)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- --- Task 4: the tile blit --------------------------------------------------
|
|
--
|
|
-- Mode "tiles" is the pixel check for the 32x32 tile blit. It plays the sender
|
|
-- with tiledict.py's wire format -- the same 23 KIND_DICT_CHUNK frames the dict
|
|
-- mode uses -- then one KIND_TILES_FULL (2) frame whose 1,280 payload bytes are
|
|
-- one dictionary index per tile, row-major (40 across, 32 down).
|
|
--
|
|
-- The dictionary is generated here rather than borrowed from dict mode, and the
|
|
-- generator is built to be hard to fool:
|
|
--
|
|
-- tile_byte(entry, row, col) = (entry*37 + row*11 + col*3 + 5) & 0xFF
|
|
--
|
|
-- so every 128-byte entry differs from every other entry, and within an entry
|
|
-- every row differs from every other row. The second property is what makes a
|
|
-- wrong SPTCH a visible failure: with SPTCH = 0 every row would read row 0 and
|
|
-- the picture would repeat the first four bytes 32 times. tile_check.py
|
|
-- refuses to score the run unless it can see both properties in the files, so a
|
|
-- degenerate dictionary cannot hide a broken blitter.
|
|
--
|
|
-- The index frame is `(tile*7 + 3) & 0xFF`: a different entry nearly everywhere,
|
|
-- and four distinct entries in the four corners, so a blit that always reads the
|
|
-- same dictionary entry, or that swaps tx and ty, changes the picture.
|
|
--
|
|
-- The dictionary the payload holds is read back from POOL_PTR + 2 * SLOT_BYTES
|
|
-- (the plan's placement) and compared with what was sent BEFORE the tile frame
|
|
-- goes out, so an upload mismatch is reported as DICT_MATCHED=0 and the pixel
|
|
-- comparison is never blamed for it.
|
|
--
|
|
-- Files written for tile_check.py: vram-tiles.bin, dict-tiles.bin (the bytes
|
|
-- that were uploaded; whether the payload's copy matched is DICT_MATCHED in the
|
|
-- status file, and tile_check.py refuses to score the run unless it did),
|
|
-- indices-tiles.bin and tiles-status.txt.
|
|
|
|
-- The mode's own state and helpers live in a closure: Lua 5.3 allows only 200
|
|
-- locals in the main chunk of a file this size, and this mode's constants,
|
|
-- counters and helpers are thirty of them. One table is the interface.
|
|
local tiles = (function()
|
|
local TILE_PIXELS = 32
|
|
local TILE_DICT_SIZE = 32768
|
|
local TILE_WAIT = tonumber(os.getenv("VID_TILE_WAIT") or "300")
|
|
|
|
local function tile_byte(entry, row, col)
|
|
return (entry * 37 + row * 11 + col * 3 + 5) & 0xFF
|
|
end
|
|
|
|
-- The 32 KB dictionary as a string: 256 entries of 32 rows of four bytes,
|
|
-- row-major, MSB-first within each byte -- exactly tilecost.py's layout.
|
|
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 tile_phase = 0
|
|
local tile_phase_at = nil
|
|
local tile_fail = 0
|
|
local tile_lines = {}
|
|
local tile_vram0 = nil
|
|
local tile_pos = 0 -- RX_RING_POS expected once the last frame settles
|
|
local tile_last = nil -- the chunk awaiting its settle
|
|
local tile_at = 0
|
|
local tile_dict_matched = 0
|
|
|
|
local function tile_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 tile_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 tile_chunk_frame(off, n)
|
|
end
|
|
|
|
local function tile_check(cond, what)
|
|
if cond then
|
|
tile_lines[#tile_lines + 1] = "PASS " .. what
|
|
print("VID: tiles PASS: " .. what)
|
|
else
|
|
tile_lines[#tile_lines + 1] = "FAIL " .. what
|
|
print("VID: tiles FAIL: " .. what)
|
|
tile_fail = tile_fail + 1
|
|
end
|
|
return cond
|
|
end
|
|
|
|
-- The payload has finished with the last injection: rx_poll() publishes
|
|
-- RX_RING_POS and RX_RING_ACC together after the dispatch, so POS naming the
|
|
-- descriptor after the last injected one AND no frame in progress is a
|
|
-- consistent "done" (the same signal dict mode uses; RX_DGRAM is bumped before
|
|
-- the datagram is processed).
|
|
local function tile_settled()
|
|
return prog:read_u32(RX_RING_POS) == tile_pos
|
|
and prog:read_u32(RX_RING_ACC) == 0
|
|
end
|
|
|
|
local function tile_take(f, n)
|
|
local ndg = inject_frame(f, n, DRAW_CHUNK)
|
|
|
|
if ndg == nil then
|
|
return false
|
|
end
|
|
tile_pos = (tile_pos + ndg) % RX_DESC
|
|
return true
|
|
end
|
|
|
|
-- Compare the payload's 32 KB at POOL_PTR + 2 * SLOT_BYTES with what was sent.
|
|
local function tile_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
|
|
|
|
local function tile_dump(timed_out)
|
|
local vram = read_vram()
|
|
local ok1 = write_file(DUMP_DIR .. "/vram-tiles.bin", vram)
|
|
local ok2 = write_file(DUMP_DIR .. "/dict-tiles.bin", TILE_DICT)
|
|
local ok3 = write_file(DUMP_DIR .. "/indices-tiles.bin", TILE_INDICES)
|
|
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("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", tile_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("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("VRAM_ROW0_BEFORE=%s", tile_vram0 and hex_bytes(tile_vram0) or "-"),
|
|
string.format("VRAM_ROW0_AFTER=%s", hex_bytes(read_vram_head(32))),
|
|
string.format("TILE_CHECKS_FAILED=%d", tile_fail),
|
|
string.format("TIMEOUT=%d", timed_out and 1 or 0),
|
|
}
|
|
for _, l in ipairs(tile_lines) do
|
|
lines[#lines + 1] = "CHECK " .. l
|
|
end
|
|
local okf = write_file(DUMP_DIR .. "/tiles-status.txt",
|
|
table.concat(lines, "\n") .. "\n")
|
|
local ok = ok1 and ok2 and ok3 and okf and not timed_out and tile_fail == 0
|
|
print(string.format("VID: tiles: %d checks failed; RX_OK=%d TILES_DROP=%d DICT_READY=%d DICT_MATCHED=%d",
|
|
tile_fail, prog:read_u32(RX_OK), prog:read_u32(TILES_DROP),
|
|
prog:read_u32(DICT_READY), tile_dict_matched))
|
|
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 tile_step()
|
|
local f, n, okv, first
|
|
|
|
if tile_phase == 0 then
|
|
if tile_phase_at == nil then
|
|
tile_phase_at = frames
|
|
end
|
|
if not ring_ready() then
|
|
if frames >= tile_phase_at + TILE_WAIT then
|
|
tile_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)))
|
|
tile_dump(true)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
tile_vram0 = read_vram_head(32)
|
|
local _, pos0 = ring_state()
|
|
tile_pos = pos0
|
|
tile_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)))
|
|
tile_phase = 1
|
|
tile_phase_at = frames
|
|
return false
|
|
end
|
|
|
|
if tile_phase == 1 then
|
|
if tile_last ~= nil then
|
|
if not tile_settled() then
|
|
if frames >= tile_phase_at + TILE_WAIT then
|
|
tile_check(false, string.format(
|
|
"the payload consumed dictionary chunk %d (DICT_BYTES=%d RX_RING_POS=%d expected %d)",
|
|
tile_at - 1, prog:read_u32(DICT_BYTES),
|
|
prog:read_u32(RX_RING_POS), tile_pos))
|
|
tile_dump(true)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
tile_check(prog:read_u32(DICT_BYTES) == tile_last.bytes,
|
|
string.format("dictionary chunk %d: DICT_BYTES=%d (expected %d)",
|
|
tile_at - 1, prog:read_u32(DICT_BYTES), tile_last.bytes))
|
|
tile_check(prog:read_u32(DICT_BAD) == 0,
|
|
string.format("dictionary chunk %d: DICT_BAD=%d (expected 0)",
|
|
tile_at - 1, prog:read_u32(DICT_BAD)))
|
|
tile_last = nil
|
|
end
|
|
if tile_at >= 23 then
|
|
tile_check(prog:read_u32(DICT_READY) == 1,
|
|
string.format("DICT_READY=1 after the upload (DICT_READY=%d DICT_BYTES=%d)",
|
|
prog:read_u32(DICT_READY), prog:read_u32(DICT_BYTES)))
|
|
okv, first = tile_verify_dict()
|
|
tile_dict_matched = okv and 1 or 0
|
|
tile_check(okv, string.format(
|
|
"the 32 KB at POOL_PTR + %d is the dictionary sent (first bad byte %d)",
|
|
DICT_OFF, first))
|
|
-- The tile frame goes out only after the dictionary readback: a
|
|
-- wrong upload must not be reported as a wrong picture.
|
|
f = make_frame(KIND_TILES_FULL, 0x0071, 0, 0, TILES_PER_FRAME,
|
|
function(i) return tile_index(i) end)
|
|
n = 16 + TILES_PER_FRAME
|
|
if not tile_take(f, n) then
|
|
tile_check(false, "the ring accepted the TILES_FULL frame")
|
|
tile_dump(true)
|
|
return true
|
|
end
|
|
tile_phase = 2
|
|
tile_phase_at = frames
|
|
return false
|
|
end
|
|
f, n = tile_chunk(tile_at)
|
|
if not tile_take(f, n) then
|
|
tile_check(false, string.format("the ring accepted dictionary chunk %d", tile_at))
|
|
tile_dump(true)
|
|
return true
|
|
end
|
|
tile_at = tile_at + 1
|
|
tile_last = { bytes = math.min(tile_at * DICT_CHUNK_BODY, TILE_DICT_SIZE) }
|
|
tile_phase_at = frames
|
|
return false
|
|
end
|
|
|
|
if tile_phase == 2 then
|
|
if not tile_settled() then
|
|
if frames >= tile_phase_at + TILE_WAIT then
|
|
tile_check(false, string.format(
|
|
"the payload consumed the TILES_FULL frame (RX_OK=%d TILES_DROP=%d RX_RING_POS=%d expected %d)",
|
|
prog:read_u32(RX_OK), prog:read_u32(TILES_DROP),
|
|
prog:read_u32(RX_RING_POS), tile_pos))
|
|
tile_dump(true)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
tile_check(prog:read_u32(RX_OK) == 1,
|
|
string.format("the TILES_FULL frame reached the screen (RX_OK=%d)",
|
|
prog:read_u32(RX_OK)))
|
|
tile_check(prog:read_u32(TILES_DROP) == 0,
|
|
string.format("TILES_DROP did not move (TILES_DROP=%d)",
|
|
prog:read_u32(TILES_DROP)))
|
|
tile_check(prog:read_u32(RX_BAD) == 0,
|
|
string.format("no frame was refused (RX_BAD=%d)",
|
|
prog:read_u32(RX_BAD)))
|
|
tile_check(prog:read_u32(DICT_READY) == 1
|
|
and prog:read_u32(DICT_BYTES) == TILE_DICT_SIZE,
|
|
"the tile frame left the dictionary record alone")
|
|
tile_dump(false)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- The one interface into this mode: step() returns true when the run is over,
|
|
-- phase() names the phase for the stall message, dump() writes the evidence.
|
|
return {
|
|
step = tile_step,
|
|
phase = function() return tile_phase end,
|
|
dump = function(timed_out) tile_dump(timed_out) end,
|
|
}
|
|
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;
|
|
-- 7. large delta: all 1,280 tiles changed. 2 + 3*1,280 = 3,842 payload bytes
|
|
-- = THREE datagrams, so the count, the position list and the value list
|
|
-- each straddle a fragment boundary -- the path a one-tile delta cannot
|
|
-- reach, and the one that would break silently if the fragment walk were
|
|
-- off by a byte. Every tile is compared with its new entry.
|
|
--
|
|
-- 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_large = 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
|
|
if delta_large then
|
|
ok = write_file(DUMP_DIR .. "/vram-delta-large.bin", delta_large) 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_prev = delta_recover
|
|
-- Large delta: every tile, a new entry for each. 1,280 entries is a
|
|
-- 2 + 3*1,280 = 3,842-byte payload, delivered as THREE datagrams, so
|
|
-- the count, the position list and the value list all straddle a
|
|
-- fragment boundary -- the one path a one-tile delta never reaches.
|
|
local positions, values = {}, {}
|
|
for i = 0, TILES_PER_FRAME - 1 do
|
|
positions[i + 1] = i
|
|
values[i + 1] = (tile_index(i) + 1) & 0xFF
|
|
end
|
|
f, n = delta_send(positions, values, 0x0095)
|
|
if n <= DRAW_CHUNK then
|
|
delta_check(false, string.format(
|
|
"the large delta is more than one datagram (%d bytes)", n))
|
|
delta_dump(true)
|
|
return true
|
|
end
|
|
if not delta_take(f, n) then
|
|
delta_check(false, "the ring accepted the large delta")
|
|
delta_dump(true)
|
|
return true
|
|
end
|
|
delta_phase = 7
|
|
delta_phase_at = frames
|
|
return false
|
|
end
|
|
|
|
if delta_phase == 7 then
|
|
local bad
|
|
if not delta_settled() then
|
|
if frames >= delta_phase_at + DELTA_WAIT then
|
|
delta_check(false, string.format(
|
|
"the payload consumed the large 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(delta_last == 3, string.format(
|
|
"the large delta arrived as 3 datagrams (%d)", delta_last))
|
|
delta_check(prog:read_u32(RX_OK) == 4,
|
|
string.format("the large delta reached the screen (RX_OK=%d)",
|
|
prog:read_u32(RX_OK)))
|
|
delta_check(prog:read_u32(TILES_DROP) == 2,
|
|
string.format("the large delta was not dropped (TILES_DROP=%d)",
|
|
prog:read_u32(TILES_DROP)))
|
|
delta_check(prog:read_u32(RX_LAST_LEN) == 2 + 3 * TILES_PER_FRAME,
|
|
string.format("RX_LAST_LEN is the large delta's payload_len (%d, expected %d)",
|
|
prog:read_u32(RX_LAST_LEN), 2 + 3 * TILES_PER_FRAME))
|
|
delta_large = read_vram()
|
|
bad = 0
|
|
first = -1
|
|
for i = 0, TILES_PER_FRAME - 1 do
|
|
okv = block_equals(delta_large, i % TILE_COLS, i // TILE_COLS,
|
|
(tile_index(i) + 1) & 0xFF)
|
|
if not okv then
|
|
bad = bad + 1
|
|
if first < 0 then
|
|
first = i
|
|
end
|
|
end
|
|
end
|
|
delta_check(bad == 0, string.format(
|
|
"all %d tiles of the large delta match their new entry (%d wrong, first tile %d)",
|
|
TILES_PER_FRAME, bad, 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)()
|
|
|
|
--
|
|
-- Mode "replay" (Task 6) plays the *cache make_tiles.py wrote*: the clip's own
|
|
-- 32 KB dictionary, uploaded exactly as the sender uploads it, then one VS
|
|
-- frame per stream record -- TILES_FULL where the plan says keyframe,
|
|
-- TILES_DELTA otherwise. The payload paints it with no knowledge that a file
|
|
-- is behind the bytes, and after every clip frame the harness dumps the 160
|
|
-- active bytes of each screen row to vram-replay-<frame>.bin. The pixel
|
|
-- verdict is harness/mame/tile_check.py --replay: it decodes stream.bin with
|
|
-- its own code, renders each frame through the dictionary and compares. A
|
|
-- SAME record (kind 0) sends nothing and still dumps, so "the screen did not
|
|
-- change" is checked rather than assumed.
|
|
--
|
|
-- The stream file is make_tiles.STREAM_MAGIC's layout (TS01, a u16 frame
|
|
-- count, then kind/frame_no/len/payload per frame). This mode reads it
|
|
-- directly -- it is the harness's copy of the plan, not a second wire format.
|
|
local replay = (function()
|
|
local TILE_DICT_SIZE = 32768
|
|
local TILE_DICT_BYTES = 128
|
|
local DICT_CHUNK_BODY = 1448 -- tiledict.MAX_BODY
|
|
local DICT_OFF = 2 * SLOT_BYTES -- 0xC000: after the two slots
|
|
local ACTIVE_ROW = 160 -- 40 tiles x 4 bytes
|
|
local KIND_SAME = 0
|
|
local KIND_FULL = 2
|
|
local KIND_DELTA = 3
|
|
local REPLAY_WAIT = tonumber(os.getenv("VID_REPLAY_WAIT") or "600")
|
|
local TILE_DIR = os.getenv("VID_TILE_DIR") or "harness/udpstream/tiles"
|
|
|
|
local function replay_read(path)
|
|
local f, err = io.open(path, "rb")
|
|
if not f then
|
|
return nil, err
|
|
end
|
|
local s = f:read("*a")
|
|
f:close()
|
|
return s
|
|
end
|
|
|
|
local function u16(s, i)
|
|
return s:byte(i) | (s:byte(i + 1) << 8)
|
|
end
|
|
|
|
-- Only replay mode needs the cache. The closure is created for every mode, so
|
|
-- without this guard a missing cache would break the other seven.
|
|
if MODE ~= "replay" then
|
|
return {
|
|
step = function() return false end,
|
|
phase = function() return 0 end,
|
|
dump = function() end,
|
|
}
|
|
end
|
|
|
|
local DICT, derr = replay_read(TILE_DIR .. "/dictionary.bin")
|
|
local STREAM, serr = replay_read(TILE_DIR .. "/stream.bin")
|
|
local records, frame_count, wire = {}, 0, 0
|
|
|
|
if not DICT or not STREAM then
|
|
print("VID ERROR: cannot read " .. TILE_DIR .. "/{dictionary,stream}.bin: "
|
|
.. tostring(derr or serr))
|
|
machine:exit()
|
|
return
|
|
end
|
|
if #DICT ~= TILE_DICT_SIZE then
|
|
print(string.format("VID ERROR: dictionary.bin is %d bytes, expected %d",
|
|
#DICT, TILE_DICT_SIZE))
|
|
machine:exit()
|
|
return
|
|
end
|
|
if #STREAM < 6 or STREAM:sub(1, 4) ~= "TS01" then
|
|
print("VID ERROR: stream.bin is not a make_tiles.py stream (no TS01 magic)")
|
|
machine:exit()
|
|
return
|
|
end
|
|
frame_count = u16(STREAM, 5)
|
|
local at = 7
|
|
for n = 0, frame_count - 1 do
|
|
if at + 4 > #STREAM then
|
|
print(string.format("VID ERROR: stream.bin is truncated at frame %d", n))
|
|
machine:exit()
|
|
return
|
|
end
|
|
local kind = STREAM:byte(at)
|
|
local len = u16(STREAM, at + 3)
|
|
if kind ~= KIND_SAME and kind ~= KIND_FULL and kind ~= KIND_DELTA then
|
|
print(string.format("VID ERROR: frame %d has kind %d", n, kind))
|
|
machine:exit()
|
|
return
|
|
end
|
|
if at + 4 + len > #STREAM then
|
|
print(string.format("VID ERROR: frame %d's payload runs past the file", n))
|
|
machine:exit()
|
|
return
|
|
end
|
|
records[n + 1] = { kind = kind, len = len, at = at + 5 }
|
|
if kind ~= KIND_SAME then
|
|
wire = wire + 1
|
|
end
|
|
at = at + 5 + len
|
|
end
|
|
print(string.format("VID: replay %s: %d frame(s), %d on the wire, dictionary %d B",
|
|
TILE_DIR, frame_count, wire, #DICT))
|
|
|
|
local phase = 0
|
|
local phase_at = nil
|
|
local pos = 0
|
|
local chunk_i = 0
|
|
local nchunks = (TILE_DICT_SIZE + DICT_CHUNK_BODY - 1) // DICT_CHUNK_BODY
|
|
local frame_i = 0
|
|
local pending = nil -- the frame waiting for the ring to settle
|
|
local wire_sent = 0
|
|
local dict_matched = 0
|
|
local fail = 0
|
|
local lines = {}
|
|
|
|
local function replay_check(cond, what)
|
|
if cond then
|
|
lines[#lines + 1] = "PASS " .. what
|
|
print("VID: replay PASS: " .. what)
|
|
else
|
|
lines[#lines + 1] = "FAIL " .. what
|
|
print("VID: replay FAIL: " .. what)
|
|
fail = fail + 1
|
|
end
|
|
return cond
|
|
end
|
|
|
|
-- One KIND_DICT_CHUNK frame: the u16 little-endian offset, then the body.
|
|
local function chunk_frame(off, body)
|
|
return make_frame(KIND_DICT_CHUNK, 0, 0, 0, 2 + body, function(k)
|
|
if k == 0 then return off & 0xFF end
|
|
if k == 1 then return (off >> 8) & 0xFF end
|
|
return DICT:byte(off + k - 1)
|
|
end)
|
|
end
|
|
|
|
-- Read the pixels the tile grid owns: 1024 rows x 160 bytes.
|
|
local function read_active()
|
|
local rows = {}
|
|
for y = 0, VRAM_ROWS - 1 do
|
|
local off = VRAM_BASE + y * VRAM_ROW_BITS
|
|
local chars = {}
|
|
for w = 0, ACTIVE_ROW // 2 - 1 do
|
|
local v = prog:read_u16(off + w * 16)
|
|
chars[#chars + 1] = string.char(v & 0xFF, (v >> 8) & 0xFF)
|
|
end
|
|
rows[#rows + 1] = table.concat(chars)
|
|
end
|
|
return table.concat(rows)
|
|
end
|
|
|
|
local function dump_frame(no, kind, len)
|
|
local ok = write_file(string.format("%s/vram-replay-%d.bin", DUMP_DIR, no),
|
|
read_active())
|
|
lines[#lines + 1] = string.format("FRAME %d KIND %d LEN %d RX_OK %d",
|
|
no, kind, len, prog:read_u32(RX_OK))
|
|
return ok
|
|
end
|
|
|
|
local function settled()
|
|
return prog:read_u32(RX_RING_POS) == pos
|
|
and prog:read_u32(RX_RING_ACC) == 0
|
|
end
|
|
|
|
local function timed_out(what)
|
|
if pending ~= nil then
|
|
return frames >= pending.at + REPLAY_WAIT
|
|
end
|
|
if phase_at ~= nil and frames >= phase_at + REPLAY_WAIT then
|
|
print(string.format("VID: timed out in phase %d waiting for %s", phase, what))
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function verify_dict()
|
|
local base = prog:read_u32(POOL_PTR)
|
|
local bit, bad, first, i, v, want
|
|
|
|
if base == 0 then
|
|
return false, -1
|
|
end
|
|
bit = base + DICT_OFF * 8
|
|
bad = 0
|
|
first = -1
|
|
for i = 0, TILE_DICT_SIZE // 2 - 1 do
|
|
v = prog:read_u16(bit + i * 16)
|
|
want = DICT:byte(i * 2 + 1) | (DICT:byte(i * 2 + 2) << 8)
|
|
if v ~= want then
|
|
bad = bad + 1
|
|
if first < 0 then
|
|
first = i * 2
|
|
end
|
|
end
|
|
end
|
|
return bad == 0, first
|
|
end
|
|
|
|
local function replay_dump(timed)
|
|
local ok = write_file(DUMP_DIR .. "/replay-status.txt", table.concat({
|
|
string.format("FRAMES=%d", frame_count),
|
|
string.format("WIRE=%d", wire),
|
|
string.format("RX_OK=%d", prog:read_u32(RX_OK)),
|
|
string.format("RX_BAD=%d", prog:read_u32(RX_BAD)),
|
|
string.format("TILES_DROP=%d", prog:read_u32(TILES_DROP)),
|
|
string.format("RX_LAST_LEN=%d", prog:read_u32(RX_LAST_LEN)),
|
|
string.format("DICT_READY=%d", prog:read_u32(DICT_READY)),
|
|
string.format("DICT_BYTES=%d", prog:read_u32(DICT_BYTES)),
|
|
string.format("DICT_BAD=%d", prog:read_u32(DICT_BAD)),
|
|
string.format("DICT_MATCHED=%d", dict_matched),
|
|
string.format("OS_TOUCH=%d", prog:read_u32(OS_TOUCH)),
|
|
string.format("SENTINEL=%d", prog:read_u16(VRAM_BASE
|
|
+ (VRAM_ROWS - 1) * VRAM_ROW_BITS + 1280)),
|
|
string.format("REPLAY_CHECKS_FAILED=%d", fail),
|
|
string.format("TIMEOUT=%d", timed and 1 or 0),
|
|
}, "\n") .. "\n" .. table.concat(lines, "\n") .. "\n")
|
|
print(string.format("VID: replay: %d check(s) failed; %d/%d frames dumped, RX_OK=%d TILES_DROP=%d",
|
|
fail, frame_i, frame_count, prog:read_u32(RX_OK),
|
|
prog:read_u32(TILES_DROP)))
|
|
print(ok and not timed and fail == 0
|
|
and "VID: RESULT PASS" or "VID: RESULT FAIL")
|
|
machine:exit()
|
|
end
|
|
|
|
-- One call per emulated frame. Returns true when the run is over.
|
|
local function replay_step()
|
|
if phase == 0 then
|
|
phase_at = frames
|
|
if not ring_ready() then
|
|
if timed_out("the RX ring") then
|
|
replay_check(false, string.format(
|
|
"the payload published an RX ring (RX_RING_BASE=%s)",
|
|
hex(prog:read_u32(RX_RING_BASE))))
|
|
replay_dump(true)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
local _, p = ring_state()
|
|
pos = p
|
|
phase = 1
|
|
phase_at = frames
|
|
return false
|
|
end
|
|
|
|
-- Every injection is one frame or one dictionary chunk. Wait for the ring
|
|
-- to consume it before the next decision, exactly as the sender waits for
|
|
-- the terminal.
|
|
if pending ~= nil then
|
|
if not settled() then
|
|
if timed_out("frame " .. tostring(pending.no)) then
|
|
replay_check(false, "frame " .. tostring(pending.no)
|
|
.. " was accepted")
|
|
replay_dump(true)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
if pending.chunk then
|
|
pending = nil
|
|
return false
|
|
end
|
|
local ok = replay_check(prog:read_u32(RX_OK) == pending.want_ok,
|
|
string.format("frame %d was accepted (RX_OK=%d, expected %d)",
|
|
pending.no, prog:read_u32(RX_OK), pending.want_ok))
|
|
ok = dump_frame(pending.no, pending.kind, pending.len) and ok
|
|
pending = nil
|
|
phase_at = frames
|
|
return false
|
|
end
|
|
|
|
if phase == 1 then
|
|
if chunk_i < nchunks then
|
|
local off = chunk_i * DICT_CHUNK_BODY
|
|
local body = TILE_DICT_SIZE - off
|
|
if body > DICT_CHUNK_BODY then
|
|
body = DICT_CHUNK_BODY
|
|
end
|
|
local f = chunk_frame(off, body)
|
|
local ndg = inject_frame(f, 16 + 2 + body, DRAW_CHUNK)
|
|
if ndg == nil then
|
|
replay_check(false, "dictionary chunk " .. chunk_i .. " reached the ring")
|
|
replay_dump(true)
|
|
return true
|
|
end
|
|
pos = (pos + ndg) % RX_DESC
|
|
pending = { chunk = true, at = frames }
|
|
chunk_i = chunk_i + 1
|
|
return false
|
|
end
|
|
dict_matched = 0
|
|
local matched, first = verify_dict()
|
|
dict_matched = matched and 1 or 0
|
|
replay_check(prog:read_u32(DICT_READY) == 1
|
|
and prog:read_u32(DICT_BYTES) == TILE_DICT_SIZE
|
|
and prog:read_u32(DICT_BAD) == 0,
|
|
string.format("the %d chunks made DICT_READY=1 and DICT_BYTES=%d",
|
|
nchunks, prog:read_u32(DICT_BYTES)))
|
|
replay_check(matched,
|
|
string.format("the payload holds dictionary.bin (first bad byte %d)",
|
|
first))
|
|
phase = 2
|
|
phase_at = frames
|
|
return false
|
|
end
|
|
|
|
if phase == 2 then
|
|
if frame_i >= frame_count then
|
|
phase = 3
|
|
return false
|
|
end
|
|
local rec = records[frame_i + 1]
|
|
if rec.kind == KIND_SAME then
|
|
-- Nothing on the wire: the screen must not move, and the dump
|
|
-- after it proves that.
|
|
dump_frame(frame_i, KIND_SAME, 0)
|
|
frame_i = frame_i + 1
|
|
phase_at = frames
|
|
return false
|
|
end
|
|
local no = frame_i
|
|
local f = make_frame(rec.kind, no, 0, 0, rec.len, function(k)
|
|
return STREAM:byte(rec.at + k)
|
|
end)
|
|
local ndg = inject_frame(f, 16 + rec.len, DRAW_CHUNK)
|
|
if ndg == nil then
|
|
replay_check(false, "frame " .. no .. " reached the ring")
|
|
replay_dump(true)
|
|
return true
|
|
end
|
|
wire_sent = wire_sent + 1
|
|
pos = (pos + ndg) % RX_DESC
|
|
pending = { no = no, kind = rec.kind, len = rec.len,
|
|
want_ok = wire_sent, at = frames }
|
|
frame_i = frame_i + 1
|
|
return false
|
|
end
|
|
|
|
-- Phase 3: the record's own numbers, then the verdict.
|
|
replay_check(prog:read_u32(RX_OK) == wire,
|
|
string.format("every wire frame was accepted (RX_OK=%d, expected %d)",
|
|
prog:read_u32(RX_OK), wire))
|
|
replay_check(prog:read_u32(TILES_DROP) == 0,
|
|
string.format("no tile frame was dropped (TILES_DROP=%d)",
|
|
prog:read_u32(TILES_DROP)))
|
|
replay_check(prog:read_u32(RX_BAD) == 0,
|
|
string.format("no frame was rejected (RX_BAD=%d)",
|
|
prog:read_u32(RX_BAD)))
|
|
replay_check(prog:read_u16(VRAM_BASE + (VRAM_ROWS - 1) * VRAM_ROW_BITS
|
|
+ 1280) == 0xA5C3, string.format("the VRAM canary survives the replay (SENTINEL=%04X)",
|
|
prog:read_u16(VRAM_BASE + (VRAM_ROWS - 1) * VRAM_ROW_BITS + 1280)))
|
|
replay_check(prog:read_u32(OS_TOUCH) == 0,
|
|
string.format("the OS did not repaint over the replay (OS_TOUCH=%d)",
|
|
prog:read_u32(OS_TOUCH)))
|
|
replay_dump(false)
|
|
return true
|
|
end
|
|
|
|
return {
|
|
step = replay_step,
|
|
phase = function() return phase end,
|
|
dump = function(timed_out) replay_dump(timed_out) end,
|
|
}
|
|
end)()
|
|
|
|
--
|
|
-- `timed_out` matters for what the checker is allowed to say: if the payload
|
|
-- never accepted the frame, the screen shows the previous picture and calling
|
|
-- that a pixel mismatch would blame the blit for a transport failure. The
|
|
-- status file carries TIMEOUT=1 and the checker stops there.
|
|
local function dump_draw(timed_out)
|
|
local vram = read_vram()
|
|
local chars = {}
|
|
for i = 0, DRAW_FRAME - 1 do
|
|
chars[#chars + 1] = string.char(draw_bytes[i])
|
|
end
|
|
local frame = table.concat(chars)
|
|
local okv = write_file(DUMP_DIR .. "/vram-draw.bin", vram)
|
|
local okf = write_file(DUMP_DIR .. "/frame-draw.bin", frame)
|
|
local rx_ok = prog:read_u32(RX_OK)
|
|
local rx_bad = prog:read_u32(RX_BAD)
|
|
local os_touch = prog:read_u32(OS_TOUCH)
|
|
-- One frame's datagrams, consumed: the ring's replacement for Task 4's
|
|
-- "the ready flag is clear" signal.
|
|
local dgrams = prog:read_u32(RX_DGRAM)
|
|
local armed = prog:read_u32(RX_RING_ARMED)
|
|
local alive = prog:read_u32(VID_ALIVE)
|
|
local ptr = prog:read_u32(POOL_PTR)
|
|
local slota = prog:read_u32(RX_SLOT_A)
|
|
local pcv = cpu.state['PC'].value
|
|
local sent = prog:read_u16(VRAM_BASE + (VRAM_ROWS - 1) * VRAM_ROW_BITS
|
|
+ 1280)
|
|
local parse_frames = -1
|
|
local ok = okv and okf
|
|
|
|
if rx_ok_at ~= nil then
|
|
parse_frames = rx_ok_at - draw_at
|
|
end
|
|
|
|
print(string.format("VID: heartbeat %d, PC=%s (%s)", alive, hex(pcv),
|
|
in_payload(pcv) and "inside the payload" or "OUTSIDE the payload"))
|
|
print(string.format("VID: POOL_PTR=%s RX_SLOT_A=%s (an unchanged pointer means "
|
|
.. "the payload did not re-enter main)", hex(ptr), hex(slota)))
|
|
|
|
write_file(DUMP_DIR .. "/draw-status.txt", string.format(
|
|
"RX_OK=%d\nRX_BAD=%d\nOS_TOUCH=%d\nRX_DGRAM=%d\nRING_ARMED=%d\nSENTINEL=%d\n"
|
|
.. "TIMEOUT=%d\nPARSE_FRAMES=%d\n",
|
|
rx_ok, rx_bad, os_touch, dgrams, armed, sent, timed_out and 1 or 0,
|
|
parse_frames))
|
|
|
|
print(string.format("VID: frame %d: screen dumped to %s/vram-draw.bin (%d B), frame to %s/frame-draw.bin (%d B)",
|
|
frames, DUMP_DIR, #vram, DUMP_DIR, DRAW_FRAME))
|
|
print(string.format("VID: RX_OK=%d RX_BAD=%d OS_TOUCH=%d RX_DGRAM=%d SENTINEL=%04X",
|
|
rx_ok, rx_bad, os_touch, dgrams, sent))
|
|
if parse_frames >= 0 then
|
|
print(string.format("VID: receive-to-draw cost: last datagram delivered at frame %d, RX_OK first read at frame %d = %d emulated frames",
|
|
draw_at, rx_ok_at, parse_frames))
|
|
else
|
|
print("VID: receive-to-draw cost: no RX_OK observed (the frame was never accepted)")
|
|
end
|
|
|
|
if timed_out or rx_ok ~= 1 or rx_bad ~= 0 or dgrams ~= 15 or os_touch ~= 0 then
|
|
ok = false
|
|
end
|
|
print(ok and "VID: RESULT PASS" or "VID: RESULT FAIL")
|
|
machine:exit()
|
|
end
|
|
|
|
function inspect_pool()
|
|
local ptr = prog:read_u32(POOL_PTR)
|
|
local freeb = prog:read_u32(POOL_FREEB)
|
|
local freea = prog:read_u32(POOL_FREEA)
|
|
local alive = prog:read_u32(VID_ALIVE)
|
|
local pbyte = math.floor(ptr / 8)
|
|
local ok = true
|
|
|
|
print(string.format("VID: marker page %s: POOL_PTR=%s POOL_FREE(before)=%d POOL_FREE(after)=%d",
|
|
hex(MARK_PAGE), hex(ptr), freeb, freea))
|
|
print(string.format("VID: heartbeat %d (was %s) -- payload %s",
|
|
alive, alive_last and tostring(alive_last) or "unset",
|
|
(in_payload(cpu.state['PC'].value) or direct) and "still running" or "NOT RUNNING"))
|
|
if alive_last ~= nil and alive == alive_last then
|
|
ok = false
|
|
print("VID: heartbeat did not advance -> FAIL")
|
|
end
|
|
|
|
if ptr == 0 then
|
|
print(string.format("VID: pool pointer %s -> FAIL", hex(ptr)))
|
|
ok = false
|
|
elseif pbyte < 0x100000 or pbyte > 0x400000 then
|
|
print(string.format("VID: pool pointer %s (byte %s) outside 100000..400000 -> FAIL",
|
|
hex(ptr), hex(pbyte, 6)))
|
|
ok = false
|
|
else
|
|
print(string.format("VID: pool pointer in range: %s (byte %s) -> PASS",
|
|
hex(ptr), hex(pbyte, 6)))
|
|
end
|
|
|
|
-- Alignment: a 32-bit C value occupies two 16-bit words on the TMS34010,
|
|
-- so the storage needs 16-bit-word alignment. The firmware allocator
|
|
-- returns word-aligned bit pointers (observed 0x0090C8D0 = byte
|
|
-- 0x12191A, ptr % 16 == 0 but ptr % 32 == 16); a literal ptr % 32 == 0
|
|
-- would reject every correct allocation, so test the real guarantee.
|
|
if ptr ~= 0 then
|
|
if (ptr % 16) ~= 0 then
|
|
print(string.format("VID: pool pointer %s not 16-bit-word aligned -> FAIL", hex(ptr)))
|
|
ok = false
|
|
else
|
|
print(string.format("VID: pool pointer %s is 16-bit-word aligned -> PASS", hex(ptr)))
|
|
end
|
|
end
|
|
|
|
if (freeb & 1) == 1 then
|
|
-- malloc failed: the low bit of the before value is the static flag.
|
|
local before = freeb - 1
|
|
if STATIC_PTR ~= 0 and ptr == STATIC_PTR then
|
|
print(string.format("VID: static fallback at %s, flag set, heap untouched (%d -> %d bytes) -> PASS",
|
|
hex(ptr), before, freea))
|
|
else
|
|
print(string.format("VID: static fallback wrong: pointer %s, expected %s -> FAIL",
|
|
hex(ptr), hex(STATIC_PTR)))
|
|
ok = false
|
|
end
|
|
else
|
|
-- A real pool must not sit on top of the payload's own .text or .bss.
|
|
-- Both are above the poke origin, so "below the origin" covers both.
|
|
local origin_byte = math.floor(ORIGIN / 8)
|
|
if pbyte >= origin_byte then
|
|
print(string.format("VID: pool byte %s is not below the payload origin byte %s -> FAIL",
|
|
hex(pbyte, 6), hex(origin_byte, 6)))
|
|
ok = false
|
|
else
|
|
print(string.format("VID: pool byte %s is below the payload origin byte %s -> PASS",
|
|
hex(pbyte, 6), hex(origin_byte, 6)))
|
|
end
|
|
|
|
local drop = freeb - freea
|
|
local delta = drop - POOL_BYTES
|
|
if delta < 0 then delta = -delta end
|
|
if drop > 0 and delta <= 64 then
|
|
print(string.format("VID: heap free before/after differ by %d bytes (POOL_BYTES=%d, header delta=%d) -> PASS",
|
|
drop, POOL_BYTES, drop - POOL_BYTES))
|
|
else
|
|
print(string.format("VID: heap free before/after differ by %d bytes, expected ~%d -> FAIL",
|
|
drop, POOL_BYTES))
|
|
ok = false
|
|
end
|
|
end
|
|
|
|
return ok
|
|
end
|
|
|
|
local function inspect_rx()
|
|
local a = prog:read_u32(RX_SLOT_A)
|
|
local b = prog:read_u32(RX_SLOT_B)
|
|
local ok = true
|
|
|
|
if not injected or not injected_bad then
|
|
print("VID: both frames were not delivered -> FAIL")
|
|
return false
|
|
end
|
|
|
|
print(string.format("VID: slots A=%s B=%s (delta %d bits = %d bytes, expected %d)",
|
|
hex(a), hex(b), b - a, (b - a) / 8, SLOT_BYTES))
|
|
if a == 0 or (b - a) ~= SLOT_BYTES * 8 then
|
|
print("VID: slot B does not follow slot A by SLOT_BYTES -> FAIL")
|
|
ok = false
|
|
end
|
|
|
|
if readback.a and readback.b then
|
|
print("VID: both delivered frames read back byte for byte from their buffers -> PASS")
|
|
else
|
|
print(string.format("VID: delivered frame readback mismatch (A=%s B=%s) -> FAIL",
|
|
tostring(readback.a), tostring(readback.b)))
|
|
ok = false
|
|
end
|
|
|
|
local oka = prog:read_u32(RX_OK)
|
|
local bad = prog:read_u32(RX_BAD)
|
|
if oka == 1 and bad == 1 then
|
|
print(string.format("VID: RX_OK=%d RX_BAD=%d -> PASS", oka, bad))
|
|
else
|
|
print(string.format("VID: RX_OK=%d RX_BAD=%d -> FAIL (one valid frame, one corrupt)", oka, bad))
|
|
ok = false
|
|
end
|
|
|
|
local last = prog:read_u32(RX_LAST_LEN)
|
|
if last == RX_PLEN then
|
|
print(string.format("VID: RX_LAST_LEN=%d = payload_len of the accepted frame -> PASS", last))
|
|
else
|
|
print(string.format("VID: RX_LAST_LEN=%d, expected %d -> FAIL", last, RX_PLEN))
|
|
ok = false
|
|
end
|
|
|
|
local slot = prog:read_u32(RX_SLOT)
|
|
if slot == 0 then
|
|
print("VID: RX_SLOT=0 (slot A, where the valid frame went) -> PASS")
|
|
else
|
|
print(string.format("VID: RX_SLOT=%d, expected 0 (slot A) -> FAIL", slot))
|
|
ok = false
|
|
end
|
|
|
|
-- The ring is the receive path now: both frames were datagrams, and after
|
|
-- the rejected one the payload must have re-armed slot A with nothing in
|
|
-- progress.
|
|
local dg = prog:read_u32(RX_DGRAM)
|
|
if dg >= 2 then
|
|
print(string.format("VID: %d datagrams consumed (one per frame) -> PASS", dg))
|
|
else
|
|
print(string.format("VID: only %d datagrams consumed, expected 2 -> FAIL", dg))
|
|
ok = false
|
|
end
|
|
|
|
if prog:read_u32(RX_RING_ARMED) == 0 and prog:read_u32(RX_RING_ACC) == 0 then
|
|
print("VID: the ring is armed on slot A with no frame in progress -> PASS")
|
|
else
|
|
print(string.format("VID: ring armed=%d acc=%d, expected armed=0 acc=0 -> FAIL",
|
|
prog:read_u32(RX_RING_ARMED), prog:read_u32(RX_RING_ACC)))
|
|
ok = false
|
|
end
|
|
|
|
return ok
|
|
end
|
|
|
|
emu.register_frame_done(function()
|
|
frames = frames + 1
|
|
local pcv = cpu.state['PC'].value
|
|
|
|
if frames == AT and not loaded then
|
|
loaded = true
|
|
load_payload()
|
|
end
|
|
|
|
if loaded and not entered and in_payload(pcv) then
|
|
entered = true
|
|
disarm_entry()
|
|
alive0 = prog:read_u32(VID_ALIVE)
|
|
print(string.format("VID: payload entered by frame %d (PC=%s, heartbeat=%d)",
|
|
frames, hex(pcv), alive0))
|
|
end
|
|
|
|
if loaded and not entered and armed and frames == AT + 10 then
|
|
enter_direct("breakpoint at " .. hex(ENTRY_PC) .. " did not fire")
|
|
entered = true
|
|
alive0 = prog:read_u32(VID_ALIVE)
|
|
end
|
|
|
|
-- Mode rx: the valid frame first, then the same frame with one header byte
|
|
-- flipped. Both go through the ring -- one datagram each -- and the second
|
|
-- waits for RX_OK, because the payload only re-arms the other slot once it
|
|
-- has finished with the first frame.
|
|
if entered and MODE == "rx" then
|
|
if not injected and ring_ready() then
|
|
local ndg = inject_frame(rx_valid, RX_FRAME, DRAW_CHUNK)
|
|
if ndg ~= nil then
|
|
local base, pos = ring_state()
|
|
injected = true
|
|
local okb, badb = verify_chunk(base + pos * RXBUFLEN * 8, rx_valid, 0, RX_FRAME)
|
|
readback.a = okb
|
|
print(string.format("VID: frame %d: delivered the valid frame as %d datagram(s) into slot %d (rx_ok=%s, %d bad)",
|
|
frames, ndg, prog:read_u32(RX_RING_ARMED), okb and "OK" or "FAILED", badb))
|
|
end
|
|
elseif injected and not injected_bad and prog:read_u32(RX_OK) >= 1 then
|
|
local ndg = inject_frame(rx_corrupt, RX_FRAME, DRAW_CHUNK)
|
|
if ndg ~= nil then
|
|
local base, pos = ring_state()
|
|
injected_bad = true
|
|
local okb, badb = verify_chunk(base + pos * RXBUFLEN * 8, rx_corrupt, 0, RX_FRAME)
|
|
readback.b = okb
|
|
print(string.format("VID: frame %d: delivered the header-flipped frame as %d datagram(s) into slot %d (rx_ok=%s, %d bad)",
|
|
frames, ndg, prog:read_u32(RX_RING_ARMED), okb and "OK" or "FAILED", badb))
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Mode ring: the transport's structural check (see ring_step()).
|
|
if entered and MODE == "ring" then
|
|
if ring_step() then
|
|
return
|
|
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
|
|
|
|
-- Mode tiles: the 32x32 blit's pixel check (tiles.step()).
|
|
if entered and MODE == "tiles" then
|
|
if tiles.step() then
|
|
return
|
|
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
|
|
|
|
-- Mode replay: the clip make_tiles.py cached, frame by frame (replay.step()).
|
|
if entered and MODE == "replay" then
|
|
if replay.step() then
|
|
return
|
|
end
|
|
end
|
|
|
|
-- Draw mode: inject once, then wait for the payload to accept the frame.
|
|
-- Two separate observations, because they answer different questions:
|
|
--
|
|
-- * the first frame at which RX_OK reads >= 1 is the PARSE COST
|
|
-- (the last datagram completed -> first RX_OK read, in emulated frames;
|
|
-- RX_OK is published after the paint, so this is the whole
|
|
-- receive-and-draw of one frame, not the parse alone);
|
|
-- * RX_OK having moved is the payload done with the frame -- that is the
|
|
-- screen to dump.
|
|
--
|
|
-- R15: the wait is bounded, and hitting the bound is reported as a
|
|
-- timeout, never as a pixel mismatch.
|
|
if entered and MODE == "draw" and draw_at == nil then
|
|
if inject_draw() then
|
|
draw_at = frames
|
|
end
|
|
end
|
|
|
|
if MODE == "draw" and draw_at ~= nil then
|
|
if rx_ok_at == nil and prog:read_u32(RX_OK) >= 1 then
|
|
rx_ok_at = frames
|
|
end
|
|
if rx_ok_at ~= nil and frames > rx_ok_at then
|
|
dump_draw(false)
|
|
return
|
|
end
|
|
if frames >= draw_at + DRAW_WAIT then
|
|
print(string.format("VID: timed out waiting for the frame: delivered at frame %d, RX_OK=%d after %d emulated frames",
|
|
draw_at, prog:read_u32(RX_OK), DRAW_WAIT))
|
|
dump_draw(true)
|
|
return
|
|
end
|
|
end
|
|
|
|
-- Stream mode: play the sender, one frame per emulated frame whenever the
|
|
-- ring has been re-armed. The cadence is NOT the sender's 30 fps: MAME's
|
|
-- frame is not the wire (R11), and what this run has to show is that every
|
|
-- delivered frame is accepted and that the terminal reports on time, not
|
|
-- that a wall clock was kept.
|
|
if entered and MODE == "stream" and not stream_prefilled then
|
|
stream_prefilled = stream_prefill()
|
|
end
|
|
|
|
if MODE == "stream" and stream_prefilled then
|
|
if announced < STREAM_FRAMES then
|
|
inject_stream()
|
|
elseif eof_at == nil then
|
|
-- The sender has stopped. Without this the last report would be
|
|
-- the 192nd frame's; RX_EOF is how a finite run asks for the
|
|
-- final count (frame_parse.h; harness-only).
|
|
prog:write_u32(RX_EOF, 1)
|
|
eof_at = frames
|
|
print(string.format("VID: frame %d: %d frames delivered; RX_EOF set, waiting for the final status",
|
|
frames, announced))
|
|
end
|
|
capture_status()
|
|
local last = captures[#captures]
|
|
if eof_at ~= nil and last ~= nil and last.frames_rx >= STREAM_FRAMES then
|
|
-- The final report is in. Wait for the chip to hand the last
|
|
-- descriptor back before calling the transmit done: a descriptor
|
|
-- the chip never took would mean the payload armed it for nothing.
|
|
-- The model completes it in ~46 us emulated (see STREAM_DRAIN);
|
|
-- the wait is a safety net for a slow host, not a delay.
|
|
if drain_from == nil then
|
|
drain_from = frames
|
|
end
|
|
local tp = prog:read_u32(ST_TMLAST)
|
|
local tmd1 = tp ~= 0 and prog:read_u16(tp) or 0xFFFF
|
|
if (tmd1 & 0x8000) == 0 then
|
|
print(string.format("VID: frame %d: last TX descriptor completed (TMD1=%04X, OWN clear)",
|
|
frames, tmd1))
|
|
dump_stream(false)
|
|
return
|
|
end
|
|
if frames >= drain_from + STREAM_DRAIN then
|
|
print(string.format("VID: timed out %d emulated frames after the final status: last TX descriptor still has OWN set (TMD1=%04X at %s)",
|
|
STREAM_DRAIN, tmd1, hex(tp)))
|
|
dump_stream(true)
|
|
return
|
|
end
|
|
end
|
|
if (eof_at ~= nil and frames >= eof_at + STREAM_WAIT)
|
|
or frames >= STOP_AT then
|
|
print(string.format("VID: timed out waiting for the final status: %d frames announced, %d status frames, last frames_rx=%s, RX_EOF set at %s",
|
|
announced, #captures,
|
|
last and tostring(last.frames_rx) or "none",
|
|
eof_at and tostring(eof_at) or "never"))
|
|
dump_stream(true)
|
|
return
|
|
end
|
|
end
|
|
|
|
if entered and frames % 10 == 0 and frames < STOP_AT then
|
|
alive_last = prog:read_u32(VID_ALIVE)
|
|
end
|
|
|
|
if MODE == "ring" and frames >= STOP_AT then
|
|
print(string.format("VID: ring mode did not finish (phase %d) by frame %d",
|
|
ring_phase, frames))
|
|
dump_ring(true)
|
|
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 MODE == "tiles" and frames >= STOP_AT then
|
|
print(string.format("VID: tiles mode did not finish (phase %d) by frame %d",
|
|
tiles.phase(), frames))
|
|
tiles.dump(true)
|
|
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 MODE == "replay" and frames >= STOP_AT then
|
|
print(string.format("VID: replay mode did not finish (phase %d) by frame %d",
|
|
replay.phase(), frames))
|
|
replay.dump(true)
|
|
return
|
|
end
|
|
|
|
if frames >= STOP_AT and MODE ~= "stream" and MODE ~= "ring"
|
|
and MODE ~= "dict" and MODE ~= "tiles" and MODE ~= "delta"
|
|
and MODE ~= "replay"
|
|
and not (MODE == "draw" and draw_at ~= nil) then
|
|
if not entered then
|
|
print("VID: payload never entered -> FAIL")
|
|
print("VID: RESULT FAIL")
|
|
else
|
|
local ok, result = pcall(inspect_pool)
|
|
if not ok then
|
|
print("VID: ERROR " .. tostring(result))
|
|
print("VID: RESULT FAIL")
|
|
else
|
|
if MODE == "rx" then
|
|
local ok2, result2 = pcall(inspect_rx)
|
|
if not ok2 then
|
|
print("VID: ERROR " .. tostring(result2))
|
|
result = false
|
|
else
|
|
result = result and result2
|
|
end
|
|
end
|
|
print(result and "VID: RESULT PASS" or "VID: RESULT FAIL")
|
|
end
|
|
end
|
|
machine:exit()
|
|
end
|
|
end, "vt1200-vidstream")
|
|
|
|
dbg.visible_cpu = cpu
|
|
dbg.execution_state = 'run'
|