From 53b1580abd42f70a0de10b6bc5a4260777d710fa Mon Sep 17 00:00:00 2001 From: "opencode.energia.arti.ee" Date: Sat, 31 Oct 2026 00:26:17 +0000 Subject: [PATCH] feat(stream): paced sender and terminal status frames --- harness/mame/run-vidstream.sh | 31 +- harness/mame/vidstream.lua | 348 ++++++++++++++- harness/mame/vidstream_status_check.py | 301 +++++++++++++ harness/udpstream/frame.py | 102 +++++ harness/udpstream/frame_parse.c | 17 + harness/udpstream/frame_parse.h | 43 ++ harness/udpstream/tests/test_frame.py | 100 ++++- harness/udpstream/tests/test_frame_parse.c | 22 + harness/udpstream/tests/test_vidsend.py | 385 +++++++++++++++++ harness/udpstream/vidsend.py | 481 +++++++++++++++++++++ harness/udpstream/vidstream.c | 410 +++++++++++++++++- 11 files changed, 2225 insertions(+), 15 deletions(-) create mode 100644 harness/mame/vidstream_status_check.py create mode 100644 harness/udpstream/tests/test_vidsend.py create mode 100644 harness/udpstream/vidsend.py diff --git a/harness/mame/run-vidstream.sh b/harness/mame/run-vidstream.sh index 48e7c19..1bbaac9 100755 --- a/harness/mame/run-vidstream.sh +++ b/harness/mame/run-vidstream.sh @@ -5,6 +5,7 @@ # BENCH=0 harness/mame/run-vidstream.sh # use the existing .bin # VT1200_MODE=rx harness/mame/run-vidstream.sh # alloc + the receive path # VT1200_MODE=draw harness/mame/run-vidstream.sh # alloc + rx + the blit +# VT1200_MODE=stream harness/mame/run-vidstream.sh # the status back-channel # STREAM_POOL_BYTES=0x7FFF000 harness/mame/run-vidstream.sh # force fallback # # Mode rx injects one valid frame and one corrupted frame with the debugger and @@ -15,13 +16,22 @@ # then runs harness/mame/vidstream_check.py, which compares every screen row # against its source row. The pixel verdict is the checker's, not the Lua's. # +# Mode stream plays the sender: 200 strip frames, then RX_EOF so the terminal +# reports the final count. The payload takes the LANCE over for transmit and +# builds a KIND_STATUS frame every 64 accepted frames; the Lua reads those +# frames back from the payload's own transmit buffer and this script runs +# harness/mame/vidstream_status_check.py, which decodes them with the host +# sender's wire format. There is no network in this run: the emulated LANCE has +# no interface (ruling R11), so the evidence is the buffer's bytes plus the +# completed descriptor, and the wire path belongs to Task 7. +# # The run uses VT1200_SKIP_LEVEL1=1: the payload's heap is only initialised in # the OS phase. In the diagnostics menu the free counter (0x1E8BA0) and the # free-list head (0x1E8AC0) both read 0, so the malloc success path cannot be # tested there. The skip is a documented divergence (harness/mame/README.md). # # Exit status: 0 only if vidstream.lua printed "VID: RESULT PASS" and, in draw -# mode, vidstream_check.py passed too. +# or stream mode, the checker for that mode passed too. set -eu HERE=$(cd "$(dirname "$0")" && pwd) @@ -65,6 +75,9 @@ if [ "$MODE" = "draw" ]; then rm -f "$LOGDIR/vram-draw.bin" "$LOGDIR/frame-draw.bin" \ "$LOGDIR/draw-status.txt" fi +if [ "$MODE" = "stream" ]; then + rm -f "$LOGDIR/stream-status.bin" "$LOGDIR/stream-status.txt" +fi # Absolute: the Lua runs with MAME's cwd, so a relative default only resolves # when the caller happens to be at the repo root. VID_PAYLOAD="$ROOT/harness/udpstream/build/vidstream.bin" @@ -111,4 +124,20 @@ if [ "$MODE" = "draw" ]; then exit "$STATUS" fi fi + +# The status frames are decoded by the host side's wire format, so the payload's +# encoder and frame.py cannot drift apart without failing here. +if [ "$MODE" = "stream" ]; then + set +e + python3 "$HERE/vidstream_status_check.py" \ + --bin "$LOGDIR/stream-status.bin" \ + --status "$LOGDIR/stream-status.txt" \ + --frames "${VID_STREAM_FRAMES:-200}" + STATUS=$? + set -e + if [ "$STATUS" -ne 0 ]; then + echo "run-vidstream.sh: status check failed (log $LOG)" >&2 + exit "$STATUS" + fi +fi exit 0 diff --git a/harness/mame/vidstream.lua b/harness/mame/vidstream.lua index 5e12a70..c6d7895 100644 --- a/harness/mame/vidstream.lua +++ b/harness/mame/vidstream.lua @@ -34,6 +34,20 @@ -- cannot be blessed here. No blit exists in the payload before Task 5, which is -- why this mode fails on a Task 4 tree. -- +-- Mode "stream" (Task 6) is the status frame. It plays the *sender*: 200 strip +-- frames, one per emulated frame whenever a slot is free, alternating slots. +-- 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. +-- -- Why injection and not a real Ethernet frame: harness/mame/vt1200.cpp never -- creates a device_network_interface, so the emulated LANCE is not attached to -- the host at all (ruling R11). Wiring it would be a MAME change, which @@ -47,17 +61,21 @@ -- including the RX_* cells below, is in harness/udpstream/frame_parse.h. -- -- Env: --- VT1200_MODE alloc (default), rx or draw +-- VT1200_MODE alloc (default), rx, draw or stream -- VID_AT load/entry frame (default 2400, the OS phase) -- VID_END inspection/exit frame (default VID_AT + 60) --- VID_DUMP_DIR where draw mode writes vram-draw.bin / frame-draw.bin / --- draw-status.txt (default /tmp/opencode) +-- VID_DUMP_DIR where draw and stream modes write their dumps +-- (default /tmp/opencode) -- VID_SENTINEL_CLOBBER=1 draw mode only: overwrite the VRAM canary word -- before the frame is announced, 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_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) @@ -80,7 +98,14 @@ end local MODE = os.getenv("VT1200_MODE") or "alloc" local AT = tonumber(os.getenv("VID_AT") or "2400") -local STOP_AT = tonumber(os.getenv("VID_END") or tostring(AT + 60)) +-- 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 +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" @@ -112,6 +137,15 @@ local RX_LEN_B = MARK_PAGE + 0x74 * 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 + -- Must match vidstream.c. local SLOT_BYTES = 24576 @@ -131,6 +165,26 @@ local DRAW_STRETCH = 8 local DRAW_PLEN = DRAW_ROWS * RX_ROW_BYTES local DRAW_FRAME = 16 + DRAW_PLEN +-- 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 status 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. +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). @@ -140,7 +194,7 @@ 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" then +if MODE ~= "alloc" and MODE ~= "rx" and MODE ~= "draw" and MODE ~= "stream" then print("VID ERROR: mode '" .. MODE .. "' is not implemented") machine:exit() return @@ -346,17 +400,21 @@ 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 function clear_draw_files() - if MODE ~= "draw" then +local function clear_dump_files() + 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_draw_files() +clear_dump_files() local function draw_row_byte(i) if (i // RX_ROW_BYTES) % 3 == 2 then @@ -425,6 +483,219 @@ local function write_file(path, s) return true end +-- --- Task 6: stream mode --------------------------------------------------- +-- +-- The harness plays the sender here, and the terminal's status frame is the +-- thing under test. Nothing in this section pretends to be a NIC: the frames +-- are poked into the slots the payload published, exactly as in modes rx and +-- draw, because the emulated LANCE has no network interface (R11). + +-- The 16-byte VS header for one frame number, sealed over its first 12 bytes +-- (ruling R14). The body of each slot 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 announced = 0 +local eof_at = nil +local drain_from = nil +local st_sent_seen = 0 +local bad_captures = 0 +local captures = {} + +-- Fill both slots' bodies once. Slot A holds the draw-mode pattern, slot B +-- its inverse, so a decoder that read the wrong slot (or cached one body) can +-- never pass a frame check. The headers are rewritten per frame. +local function stream_prefill() + local a = prog:read_u32(RX_SLOT_A) + local b = prog:read_u32(RX_SLOT_B) + + if a == 0 or b == 0 then + return false + end + + local fa = make_frame(1, 0, DRAW_ROWS, DRAW_STRETCH, DRAW_PLEN, draw_row_byte) + poke(a, fa, DRAW_FRAME) + local fb = make_frame(1, 0, DRAW_ROWS, DRAW_STRETCH, DRAW_PLEN, + function(i) + if draw_row_byte(i) == 0xF0 then + return 0x0F + end + return 0xFF + end) + poke(b, fb, DRAW_FRAME) + print(string.format("VID: frame %d: stream slots prefilled (A=%s, B=%s, %d B each); sender will announce %d frames", + frames, hex(a), hex(b), DRAW_FRAME, STREAM_FRAMES)) + return true +end + +-- Announce one frame in whichever slot is free. A busy slot is not +-- overwritten: the payload sets the ready flag and clears it when it is done +-- with the slot, so this is how the sender learns the terminal's pace. +local function inject_stream() + local slot = announced % 2 + local ready = prog:read_u32(slot == 0 and RX_READY_A or RX_READY_B) + + if ready ~= 0 then + return false + end + + local base = prog:read_u32(slot == 0 and RX_SLOT_A or RX_SLOT_B) + if base == 0 then + return false + end + + poke(base, header_bytes(1, announced & 0xFFFF, DRAW_ROWS, DRAW_STRETCH, + DRAW_PLEN), 16) + if slot == 0 then + prog:write_u32(RX_LEN_A, DRAW_FRAME) + prog:write_u32(RX_READY_A, 1) + else + prog:write_u32(RX_LEN_B, DRAW_FRAME) + prog:write_u32(RX_READY_B, 1) + end + 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), + } + -- 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 ~= 12 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 kind=%d payload_len=%d", + #captures, rec.frames_rx, rec.crc_err, rec.drops, rec.csr0, + rec.heap_kb, 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_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 + -- Dump the screen and the payload's record for vidstream_check.py, then end -- the run. The pass/fail for pixels belongs to the checker; here we report -- what the payload itself says (one accepted frame, no rejects, no repaint by @@ -700,11 +971,70 @@ emu.register_frame_done(function() end end + -- Stream mode: play the sender, one announced frame per emulated frame + -- whenever a slot is free. 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 + -- announced 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, like RX_READY_*). + prog:write_u32(RX_EOF, 1) + eof_at = frames + print(string.format("VID: frame %d: %d frames announced; 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 frames >= STOP_AT and not (MODE == "draw" and draw_at ~= nil) then + if frames >= STOP_AT and MODE ~= "stream" + and not (MODE == "draw" and draw_at ~= nil) then if not entered then print("VID: payload never entered -> FAIL") print("VID: RESULT FAIL") diff --git a/harness/mame/vidstream_status_check.py b/harness/mame/vidstream_status_check.py new file mode 100644 index 0000000..b34bc35 --- /dev/null +++ b/harness/mame/vidstream_status_check.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""vidstream_status_check.py -- decode the terminal's status frames. + +Task 6's host-side reading of the status path. In MAME the payload takes the +LANCE over for transmit and, every 64 accepted frames, builds a 60-byte +Ethernet frame in its own buffer and hands it to the chip +(harness/udpstream/vidstream.c, send_status()). The Lua side +(harness/mame/vidstream.lua, VT1200_MODE=stream) plays the sender: it announces +200 strip frames, then sets RX_EOF so the terminal reports the final count, and +dumps every status frame it saw in the payload's transmit buffer: + + stream-status.bin each status frame exactly as the payload built it + (60 bytes each, back to back) + stream-status.txt key=value counters from the payload's marker page + +This script is the independent decode: it uses harness/udpstream/frame.py, the +same module the host sender parses with, so the C encoder and the Python parser +cannot drift apart without a failure here. It checks: + + * the Ethernet envelope (broadcast destination, the terminal's own address, + the stream's ethertype 0x88B7); + * the VS header (magic, version, KIND_STATUS, rows/stretch zero, payload_len + 12, header CRC valid); + * the 12-byte body (frames_rx, crc_err, drops, csr0, heap_free_kb); + * the cadence and the verdict the brief asks for: at least three status + frames, the periodic ones at 64, 128, 192, and the last reporting + frames_rx == 200 with drops == 0 and crc_err == 0; + * that the transmit descriptor actually completed (TMD1's OWN bit clear). + TMD1's ERR bit is expected to be set here and is not a failure: the LANCE + has no network medium in MAME (ruling R11), so the chip reports loss of + carrier after completing the DMA. The bytes and the descriptor are the + evidence this run can produce; the wire is Task 7's. + +usage: + vidstream_status_check.py [--bin PATH] [--status PATH] [--frames 200] + +Exit status 0 only if every check passes. +""" +import argparse +import os +import struct +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__)))) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from harness.udpstream.frame import ( # noqa: E402 + BROADCAST, ETHERTYPE, HEADER_SIZE, KIND_STATUS, STATUS_SIZE, TERMINAL_MAC, + unpack_header, unpack_status) + +STATUS_BYTES = 60 # STATUS_LEN in vidstream.c +STATUS_BODY = 14 + HEADER_SIZE # Ethernet header + VS header = 30 +STATUS_EVERY = 64 # STATUS_EVERY in vidstream.c +CSR0_IDON = 0x0100 +CSR0_TXON = 0x0010 +TMD1_OWN = 0x8000 +TMD1_ERR = 0x4000 + +failures = 0 + + +def check(cond, what): + global failures + if not cond: + failures += 1 + print("FAIL: " + what) + return cond + + +def read_file(path): + try: + with open(path, "rb") as f: + return f.read() + except OSError as e: + print("vidstream_status_check: cannot read %s: %s" % (path, e)) + return None + + +def read_status(path): + st = {} + try: + with open(path, "r", encoding="ascii", errors="replace") as f: + for line in f: + if "=" in line: + k, v = line.strip().split("=", 1) + try: + st[k] = int(v, 0) + except ValueError: + st[k] = v + except OSError as e: + print("vidstream_status_check: cannot read %s: %s" % (path, e)) + return None + return st + + +def decode_one(raw, n): + """Decode one 60-byte captured frame; returns the body dict or None.""" + tag = "status frame %d" % n + if not check(len(raw) == STATUS_BYTES, + "%s is %d bytes, expected %d" + % (tag, len(raw), STATUS_BYTES)): + return None + if not check(raw[0:6] == BROADCAST, + "%s destination is %s, expected broadcast" + % (tag, raw[0:6].hex(":"))): + return None + if not check(raw[6:12] == TERMINAL_MAC, + "%s source is %s, expected the terminal's %s" + % (tag, raw[6:12].hex(":"), TERMINAL_MAC.hex(":"))): + return None + if not check(raw[12:14] == struct.pack(">H", ETHERTYPE), + "%s ethertype is %s, expected 0x%04X" + % (tag, raw[12:14].hex(), ETHERTYPE)): + return None + + try: + h = unpack_header(raw[14:14 + HEADER_SIZE]) + except ValueError as e: + check(False, "%s header does not parse: %s" % (tag, e)) + return None + if not check(h["magic"] == b"VS", "%s magic is %r" % (tag, h["magic"])): + return None + if not check(h["kind"] == KIND_STATUS, + "%s kind is %d, expected KIND_STATUS=%d" + % (tag, h["kind"], KIND_STATUS)): + return None + if not check(h["payload_len"] == STATUS_SIZE, + "%s payload_len is %d, expected %d" + % (tag, h["payload_len"], STATUS_SIZE)): + return None + if not check(h["rows"] == 0 and h["stretch"] == 0, + "%s reports geometry rows=%d stretch=%d; a status has none" + % (tag, h["rows"], h["stretch"])): + return None + if not check(h["crc_ok"], "%s header CRC is wrong" % tag): + return None + body = unpack_status(raw[STATUS_BODY:STATUS_BODY + STATUS_SIZE]) + body["frame_no"] = h["frame_no"] + return body + + +def main(): + ap = argparse.ArgumentParser() + d = os.environ.get("VID_DUMP_DIR", "/tmp/opencode") + ap.add_argument("--bin", default=os.path.join(d, "stream-status.bin")) + ap.add_argument("--status", default=os.path.join(d, "stream-status.txt")) + ap.add_argument("--frames", type=int, default=200, + help="frames the sender announced (VID_STREAM_FRAMES)") + args = ap.parse_args() + + blob = read_file(args.bin) + st = read_status(args.status) + if blob is None or st is None: + return 2 + check(len(blob) % STATUS_BYTES == 0, + "%s is %d bytes, not a whole number of %d-byte frames" + % (args.bin, len(blob), STATUS_BYTES)) + records = [blob[i:i + STATUS_BYTES] + for i in range(0, len(blob) - STATUS_BYTES + 1, STATUS_BYTES)] + print("vidstream_status_check: %d status frame(s) in %s; counters from %s" + % (len(records), args.bin, args.status)) + + # --- the run's own record -------------------------------------------- + if st.get("TIMEOUT", 0) != 0: + print("FAIL: the run timed out waiting for the final status " + "(injected=%r, status_count=%r, last frames_rx=%r)" + % (st.get("INJECTED"), st.get("STATUS_COUNT"), + st.get("LAST_FRAMES_RX"))) + print("vidstream_status_check: FAIL (timeout)") + return 1 + + check(st.get("INJECTED") == args.frames, + "INJECTED=%r, expected %d" % (st.get("INJECTED"), args.frames)) + check(st.get("RX_OK") == args.frames, + "the payload accepted RX_OK=%r frames, expected %d" + % (st.get("RX_OK"), args.frames)) + check(st.get("RX_BAD") == 0, + "RX_BAD=%r: the payload rejected a frame it should have accepted" + % (st.get("RX_BAD"),)) + check(st.get("BAD_STATUS_FRAMES", 0) == 0, + "BAD_STATUS_FRAMES=%r: the payload published something that is not " + "a VS status frame" % (st.get("BAD_STATUS_FRAMES"),)) + check(st.get("STATUS_COUNT") == len(records), + "STATUS_COUNT=%r but the dump holds %d frames" + % (st.get("STATUS_COUNT"), len(records))) + check(st.get("EOF_SET") == 1, + "RX_EOF was never set, so the final report cannot exist") + + # The LANCE is ours: IDON set. ST_INIT is CSR0 right after the INIT wait + # and BEFORE STRT, so TXON is not expected there; the transmitter is + # checked on the status body's own csr0 below. + st_init = st.get("ST_INIT", 0) + check(isinstance(st_init, int) and (st_init & CSR0_IDON) != 0, + "CSR0 after the INIT wait is %r; IDON (0x%04X) is not set, so the " + "init block was never read by the chip" % (st_init, CSR0_IDON)) + + # The last descriptor must have been given back: the chip consumed it. + # TMD1_DRAINED is read from the descriptor after the run waited for it; + # ST_TMD1 is the value the payload itself saw at send time (its wait is + # bounded, and the model completes a 60-byte frame in about 46 us: 10 + # Mbit/s is m_bandwidth = 10 << 17 bytes/s in dinetwork.cpp). + tmd1 = st.get("TMD1_DRAINED", 0) + check(isinstance(tmd1, int) and (tmd1 & TMD1_OWN) == 0, + "the last TX descriptor still has OWN set after the drain wait " + "(TMD1=%r): the chip never took the status frame" % (tmd1,)) + if isinstance(tmd1, int) and (tmd1 & TMD1_ERR): + print("note: the drained TMD1=0x%04X has ERR set -- expected: the " + "LANCE has no network medium in MAME (R11), so it reports loss " + "of carrier after completing the DMA" % tmd1) + at_send = st.get("ST_TMD1", 0) + if isinstance(at_send, int) and (at_send & TMD1_OWN): + print("note: the descriptor was still owned when the payload finished " + "its bounded wait (ST_TMD1=0x%04X); the drain check above is " + "what decides whether the chip ever took it" % at_send) + + # --- the bytes ------------------------------------------------------- + if not check(len(records) >= 3, + "%d status frames, expected at least 3 (every %d accepted " + "frames)" % (len(records), STATUS_EVERY)): + print("vidstream_status_check: FAIL") + return 1 + + bodies = [] + for i, raw in enumerate(records, 1): + body = decode_one(raw, i) + if body is None: + continue + bodies.append(body) + print(" frame %d: frame_no=%d frames_rx=%d crc_err=%d drops=%d " + "csr0=0x%04X heap_free_kb=%d" + % (i, body["frame_no"], body["frames_rx"], body["crc_err"], + body["drops"], body["csr0"], body["heap_free_kb"])) + if len(bodies) != len(records): + print("vidstream_status_check: FAIL (%d of %d frames decoded)" + % (len(bodies), len(records))) + return 1 + + # --- the cadence ----------------------------------------------------- + for i, want in enumerate((STATUS_EVERY, 2 * STATUS_EVERY, 3 * STATUS_EVERY)): + if i < len(bodies): + check(bodies[i]["frames_rx"] == want, + "status %d reports frames_rx=%d, expected %d (a report " + "every %d accepted frames)" + % (i + 1, bodies[i]["frames_rx"], want, STATUS_EVERY)) + for i in range(1, len(bodies)): + check(bodies[i]["frames_rx"] > bodies[i - 1]["frames_rx"], + "status %d reports frames_rx=%d, which does not advance past " + "the previous %d" % (i + 1, bodies[i]["frames_rx"], + bodies[i - 1]["frames_rx"])) + # After RX_EOF the terminal reports each further accepted frame, so the + # reports after the third are consecutive. That is how the final count + # (200) is reached without waiting for 256. + after = bodies[3:] + for i in range(1, len(after)): + check(after[i]["frames_rx"] == after[i - 1]["frames_rx"] + 1, + "post-EOF report %d jumps from %d to %d; the terminal reports " + "each further accepted frame" + % (len(bodies) - len(after) + i, + after[i - 1]["frames_rx"], after[i]["frames_rx"])) + + # --- the brief's verdict -------------------------------------------- + last = bodies[-1] + check(last["frames_rx"] == args.frames, + "the last status reports frames_rx=%d, expected %d" + % (last["frames_rx"], args.frames)) + check(last["drops"] == 0, + "the last status reports drops=%d, expected 0" % last["drops"]) + check(last["crc_err"] == 0, + "the last status reports crc_err=%d, expected 0" % last["crc_err"]) + # The transmitter must have been on when the reports were built: the + # payload ran STRT once, and this is the chip's own answer at report time. + check((last["csr0"] & CSR0_TXON) != 0, + "the last status reports csr0=0x%04X with TXON (0x%04X) clear, so " + "the transmitter was never started" + % (last["csr0"], CSR0_TXON)) + # The free-heap field must be a reading, not the zero padding a wrong + # offset would pick up. + check(last["heap_free_kb"] > 0, + "heap_free_kb=%d: the heap field looks like padding" + % last["heap_free_kb"]) + # The Lua's own decode of the same frame must agree with this one. + if "LAST_FRAMES_RX" in st: + check(st["LAST_FRAMES_RX"] == last["frames_rx"], + "stream-status.txt says LAST_FRAMES_RX=%r but the decoded last " + "frame says %d" % (st["LAST_FRAMES_RX"], last["frames_rx"])) + + print("vidstream_status_check: %d/%d status frames decoded, last reports " + "frames_rx=%d drops=%d crc_err=%d heap_free_kb=%d" + % (len(bodies), len(records), last["frames_rx"], last["drops"], + last["crc_err"], last["heap_free_kb"])) + if failures: + print("vidstream_status_check: FAIL (%d checks)" % failures) + return 1 + print("vidstream_status_check: PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/harness/udpstream/frame.py b/harness/udpstream/frame.py index 9bf9df0..7481895 100644 --- a/harness/udpstream/frame.py +++ b/harness/udpstream/frame.py @@ -31,6 +31,30 @@ geometry a frame was *sent* with is the geometry the terminal acts on. So a corrupted **payload** byte does not invalidate a frame here -- that is the chip's job -- while a corrupted **header** byte does. + +The status body +--------------- + +A ``KIND_STATUS`` frame is the terminal's report to the host, and it is an +ordinary frame: the same 16-byte header, the same header-only CRC, with +``rows`` and ``stretch`` zero (a status has no geometry). Its body is 12 bytes: + + offset size field + ------ ---- ------------------------------------------------------ + 0 4 frames_rx, u32 little-endian (frames fp_parse accepted) + 4 2 crc_err, u16 little-endian (frames it rejected: header CRC, + magic, version or length -- since R14 a *body* error is + the LANCE's FCS and can never appear here) + 6 2 drops, u16 little-endian (missed-packet events, CSR0 MISS) + 8 2 csr0, u16 little-endian (the chip's status word when the + report was built) + 10 2 heap_free_kb, u16 little-endian (the terminal's free heap) + +Both ends put that frame in an Ethernet frame: 6 bytes destination, 6 bytes +source, the ethertype below, then the ``VS`` frame. The terminal does not +filter on ethertype (its LANCE accepts broadcast and its own physical address), +so the value is for the host's benefit: a status frame must not be mistaken for +a probe (0x88B5, sendraw.py) or a command (0x88B6, lance_cmd.c). """ import struct @@ -38,7 +62,10 @@ import struct __all__ = [ "HEADER_SIZE", "MAGIC", "VERSION", "CRC_OFFSET", "KIND_STRIP", "KIND_STATUS", + "ETHERTYPE", "BROADCAST", "TERMINAL_MAC", "MIN_ETHERNET_PAYLOAD", + "STATUS_SIZE", "crc16", "pack_header", "seal_header", "unpack_header", + "pack_status", "unpack_status", "ethernet_frame", ] #: Total size of the fixed header every frame starts with. @@ -59,10 +86,31 @@ KIND_STRIP = 1 #: A status report travelling the other way. KIND_STATUS = 5 +#: Ethernet type the stream's frames carry. 0x88B5 is the probe frame +#: sendraw.py sends and 0x88B6 the command frame lance_cmd.c uses; the video +#: stream gets its own so a captured frame cannot be mistaken for either. +ETHERTYPE = 0x88B7 + +#: Destination that always passes the LANCE's address filter: with LADRF zero +#: the chip accepts its own physical address and broadcast, nothing else. +BROADCAST = b"\xff" * 6 + +#: The terminal's own physical address, from its Ethernet address ROM +#: (bytes 0..5 of the eight at bit 0x6800000; the last two are the checksum). +TERMINAL_MAC = b"\x08\x00\x2b\x16\x4b\x5a" + +#: Bytes of padding an Ethernet frame needs after its 14-byte header. +MIN_ETHERNET_PAYLOAD = 46 + +#: Size of the status body (see the module docstring). +STATUS_SIZE = 12 + #: Everything before the CRC field, little-endian. _PREFIX = struct.Struct("<2sBBHHBBH") #: The CRC field and the two padding bytes behind it. _TAIL = struct.Struct(" dict: "stretch": stretch, "crc_ok": crc16(bytes(buf[:CRC_OFFSET])) == stored_crc, } + + +# --- the status body and the Ethernet envelope --------------------------- + +def pack_status(frames_rx: int, crc_err: int, drops: int, csr0: int, + heap_free_kb: int) -> bytes: + """Build the 12-byte status body the terminal sends (see the docstring). + + The field widths are the terminal's: ``frames_rx`` is a 32-bit counter that + must not wrap in any run, the rest are 16-bit quantities whose limits + (65,535 missed packets, a 64 MB heap) are far above anything a VT1200 + reaches, so an out-of-range value is a bug here rather than a wrap on the + wire. ``send_status()`` in vidstream.c writes these same offsets. + """ + frames_rx = _checked("frames_rx", frames_rx, 0xFFFFFFFF) + crc_err = _checked("crc_err", crc_err, 0xFFFF) + drops = _checked("drops", drops, 0xFFFF) + csr0 = _checked("csr0", csr0, 0xFFFF) + heap_free_kb = _checked("heap_free_kb", heap_free_kb, 0xFFFF) + return _STATUS.pack(frames_rx, crc_err, drops, csr0, heap_free_kb) + + +def unpack_status(body: bytes) -> dict: + """Decode a status body, or raise ValueError if it is not 12 bytes.""" + if len(body) != STATUS_SIZE: + raise ValueError("a status body is %d bytes, got %d" + % (STATUS_SIZE, len(body))) + frames_rx, crc_err, drops, csr0, heap_free_kb = _STATUS.unpack_from(body, 0) + return { + "frames_rx": frames_rx, + "crc_err": crc_err, + "drops": drops, + "csr0": csr0, + "heap_free_kb": heap_free_kb, + } + + +def ethernet_frame(payload: bytes, src: bytes, dst: bytes = BROADCAST, + ethertype: int = ETHERTYPE, pad: bool = False) -> bytes: + """Put ``payload`` in an Ethernet frame. + + ``pad`` is deliberately off by default and is the *caller's* decision: + Ethernet needs 46 bytes after the 14-byte header, but a frame split across + datagrams may only be padded at its very end. Padding a middle chunk would + insert bytes between two chunks of one frame, shifting the body the + receiver reassembles -- so the chunking layer pads its last datagram and + nothing else. + """ + if len(src) != 6 or len(dst) != 6: + raise ValueError("MAC addresses are 6 bytes") + data = bytes(dst) + bytes(src) + struct.pack(">H", ethertype) + bytes(payload) + if pad and len(data) < 14 + MIN_ETHERNET_PAYLOAD: + data += b"\x00" * (14 + MIN_ETHERNET_PAYLOAD - len(data)) + return data diff --git a/harness/udpstream/frame_parse.c b/harness/udpstream/frame_parse.c index 982d9a5..f1ec084 100644 --- a/harness/udpstream/frame_parse.c +++ b/harness/udpstream/frame_parse.c @@ -116,3 +116,20 @@ fp_header *out; out->payload_off = FP_HEADER_SIZE; return 1; } + +/* + * unsigned int fp_crc(unsigned char *buf, unsigned int len) + * + * The same CRC over an arbitrary span, for the transmit side. The terminal + * builds the status frame's header itself (vidstream.c, send_status()) and + * seals it with this, so there is ONE CRC implementation in the payload rather + * than two that could drift apart. It is the function fp_parse() already uses + * over the 12 header bytes -- the initial value, the polynomial and the shift + * direction are stated once, in fp_crc_byte() above. + */ +unsigned int fp_crc(buf, len) +unsigned char *buf; +unsigned int len; +{ + return fp_crc_add(0xFFFF, buf, len); +} diff --git a/harness/udpstream/frame_parse.h b/harness/udpstream/frame_parse.h index 1cd0f54..7628bdf 100644 --- a/harness/udpstream/frame_parse.h +++ b/harness/udpstream/frame_parse.h @@ -68,6 +68,26 @@ * ready flag; MAME cannot hand this emulated LANCE an Ethernet frame (the * driver attaches no network interface -- ruling R11), so the debugger writes * the slot and raises the flag instead. The payload clears it. + * + * Task 6 adds one more cell of the same kind and the status record the harness + * reads back: + * + * RX_EOF +0x3C0 0x018103C0 1 = the sender stopped + * ST_SENT +0x3E0 0x018103E0 status frames transmitted + * ST_FRAMES +0x400 0x01810400 frames_rx in the last status + * ST_BUF +0x420 0x01810420 bit address of the 60-byte frame + * ST_TMD1 +0x440 0x01810440 TMD1 as the payload read it at + * the end of its own wait + * ST_INIT +0x460 0x01810460 CSR0 after the LANCE INIT wait + * ST_TMLAST +0x480 0x01810480 bit address of the last armed + * TX descriptor's TMD1 word + * + * RX_EOF is harness-only in exactly the same sense as RX_READY_x: it says "the + * sender has stopped announcing frames", which on a real wire is silence and + * carries no signal. It exists so a finite run can ask for a final status + * instead of waiting for the count to reach the next multiple of 64. The + * payload reports as soon as it sees it, and keeps reporting for each further + * accepted frame; nothing reads it in a continuous stream. * --------------------------------------------------------------------------- * * K&R C only. The 1987 toolchain in build-stream.sh compiles this header, so @@ -117,6 +137,18 @@ #define RX_READY_B MARK_CELL(0x70) #define RX_LEN_B MARK_CELL(0x74) +/* Task 6: the sender/keyframe control cell and the status record. */ +#define RX_EOF MARK_CELL(0x78) +#define ST_SENT MARK_CELL(0x7c) +#define ST_FRAMES MARK_CELL(0x80) +#define ST_BUF MARK_CELL(0x84) +#define ST_TMD1 MARK_CELL(0x88) +#define ST_INIT MARK_CELL(0x8c) +#define ST_TMLAST MARK_CELL(0x90) + +/* The status body (Task 6): frames_rx u32, then four u16, little-endian. */ +#define FP_STATUS_SIZE 12 + /* What a valid frame told us. payload_off is FP_HEADER_SIZE on success. */ typedef struct { unsigned int kind; @@ -139,4 +171,15 @@ typedef struct { */ int fp_parse(); +/* + * unsigned int fp_crc(unsigned char *buf, unsigned int len) + * + * The CCITT-FALSE CRC of len bytes (poly 0x1021, init 0xFFFF, not reflected, + * no final xor) -- the same routine fp_parse() runs over the 12 header bytes, + * exported so the terminal can seal the status header it builds (Task 6). + * One implementation, so the receive and transmit checks cannot drift apart. + * Returns the 16-bit CRC in the low half of an unsigned int. + */ +unsigned int fp_crc(); + #endif /* FRAME_PARSE_H */ diff --git a/harness/udpstream/tests/test_frame.py b/harness/udpstream/tests/test_frame.py index b63434d..6299bc6 100644 --- a/harness/udpstream/tests/test_frame.py +++ b/harness/udpstream/tests/test_frame.py @@ -10,14 +10,21 @@ purpose: a corrupted **payload** byte leaves a frame ACCEPTED (the Ethernet FCS of each datagram is the check for those bytes), while a corrupted **header** byte is REJECTED, and a truncated frame (``len < 16 + payload_len``) is rejected by the length bound that ``fp_parse`` applies before the CRC. + +The last section pins the *other* direction: the terminal's status body +(``KIND_STATUS``) and the Ethernet envelope both ends put the frame in. The +offsets there are the contract with ``send_status()`` in vidstream.c. """ import struct import pytest from harness.udpstream.frame import (CRC_OFFSET, HEADER_SIZE, KIND_STATUS, - KIND_STRIP, MAGIC, VERSION, crc16, - pack_header, seal_header, unpack_header) + KIND_STRIP, MAGIC, VERSION, STATUS_SIZE, + BROADCAST, ETHERTYPE, TERMINAL_MAC, + crc16, ethernet_frame, pack_header, + pack_status, seal_header, unpack_header, + unpack_status) def test_header_is_16_bytes_and_round_trips(): @@ -192,3 +199,92 @@ def test_out_of_range_fields_raise(kwargs): base.update(kwargs) with pytest.raises(ValueError): pack_header(**base) + + +# --- the status body that travels back (Task 6) -------------------------- + +def test_status_body_is_twelve_bytes_little_endian(): + """The terminal's status body, field by field, at the offsets the C writes. + + The C side (vidstream.c, send_status()) writes the same five fields with + byte stores, so the offsets below are the contract between the two ends. + """ + b = pack_status(frames_rx=0x12345678, crc_err=0x0102, drops=0x0304, + csr0=0x20F2, heap_free_kb=0x0506) + assert len(b) == STATUS_SIZE == 12 + assert b[0:4] == b"\x78\x56\x34\x12" # frames_rx, u32 LE + assert b[4:6] == b"\x02\x01" # crc_err, u16 LE + assert b[6:8] == b"\x04\x03" # drops, u16 LE + assert b[8:10] == b"\xF2\x20" # csr0, u16 LE + assert b[10:12] == b"\x06\x05" # heap_free_kb, u16 LE + assert unpack_status(b) == { + "frames_rx": 0x12345678, "crc_err": 0x0102, "drops": 0x0304, + "csr0": 0x20F2, "heap_free_kb": 0x0506, + } + + +@pytest.mark.parametrize("kwargs", [ + dict(frames_rx=0x100000000), dict(frames_rx=-1), + dict(crc_err=0x10000), dict(drops=-1), + dict(csr0=0x10000), dict(heap_free_kb=-1), +]) +def test_status_fields_out_of_range_raise(kwargs): + base = dict(frames_rx=1, crc_err=0, drops=0, csr0=0, heap_free_kb=0) + base.update(kwargs) + with pytest.raises(ValueError): + pack_status(**base) + + +def test_status_body_must_be_exactly_twelve_bytes(): + b = pack_status(1, 0, 0, 0, 0) + with pytest.raises(ValueError): + unpack_status(b[:-1]) + with pytest.raises(ValueError): + unpack_status(b + b"\x00") + + +def test_status_uses_the_same_header_with_zero_geometry(): + """A status frame is a normal frame: same header, same header-only CRC.""" + body = pack_status(200, 0, 0, 0x20F2, 2048) + h = pack_header(KIND_STATUS, frame_no=3, payload_len=len(body), rows=0, + stretch=0, payload=body) + d = unpack_header(h, body) + assert d["kind"] == KIND_STATUS + assert d["rows"] == 0 and d["stretch"] == 0 + assert d["crc_ok"] is True + + +def test_ethernet_envelope_is_the_streams_own_type(): + """The status frame travels in an ordinary Ethernet frame on its own type. + + 0x88B7 is the video stream's: 0x88B5 is the probe frame sendraw.py sends + and 0x88B6 the command frame lance_cmd.c uses, so a captured status frame + cannot be mistaken for either. The terminal's LANCE does not filter on + ethertype at all (it accepts broadcast and its own physical address), so + this value is for the host's benefit. + """ + body = pack_status(1, 0, 0, 0, 0) + frame = pack_header(KIND_STATUS, 1, len(body), 0, 0) + body + data = ethernet_frame(frame, src=TERMINAL_MAC, dst=BROADCAST) + assert len(data) == 14 + HEADER_SIZE + STATUS_SIZE + assert data[0:6] == b"\xff" * 6 + assert data[6:12] == TERMINAL_MAC + assert data[12:14] == struct.pack(">H", ETHERTYPE) == b"\x88\xB7" + assert data[14:] == frame + + +def test_ethernet_frame_pads_to_the_minimum_only_at_the_end(): + """A 60-byte Ethernet minimum must not be inserted between chunks. + + Padding mid-frame would put bytes the sender never meant between two + datagrams of one frame, and the receiver would see a frame whose body is + longer than payload_len and shifted -- exactly the bug this pins. + """ + short = ethernet_frame(b"VS" + bytes(10), src=TERMINAL_MAC, pad=True) + assert len(short) == 60 + assert short[-18:] == bytes(18) + long = b"x" * 1450 + assert ethernet_frame(long, src=TERMINAL_MAC, pad=True) == ( + BROADCAST + TERMINAL_MAC + b"\x88\xB7" + long) + # Off by default: the chunking layer decides, and only for the last chunk. + assert len(ethernet_frame(b"VS" + bytes(10), src=TERMINAL_MAC)) == 26 diff --git a/harness/udpstream/tests/test_frame_parse.c b/harness/udpstream/tests/test_frame_parse.c index cc05aaf..791a28e 100644 --- a/harness/udpstream/tests/test_frame_parse.c +++ b/harness/udpstream/tests/test_frame_parse.c @@ -219,6 +219,28 @@ int main(void) "bare header, one byte short, rejected"); } + /* ---- fp_crc(): the exported CRC the transmit side seals with ---- + * + * Task 6's terminal builds its own status header and must use the same + * CRC as the parser. These are the canonical check values, not values + * this code produced: CCITT-FALSE over "123456789" is 0x29B1, and the + * empty input is the initial value. The golden frame's own CRC bytes + * are the third reading, so the function and fp_parse() cannot drift. */ + { + static const unsigned char nine[] = "123456789"; + unsigned int crc; + + crc = fp_crc((unsigned char *) nine, 9); + check(crc == 0x29B1, "fp_crc(\"123456789\") == 0x29B1 (CCITT-FALSE)"); + check(fp_crc((unsigned char *) nine, 0) == 0xFFFF, + "fp_crc(empty) == 0xFFFF (the initial value)"); + + crc = fp_crc((unsigned char *) golden, FP_PREFIX_SIZE); + check(crc == (((unsigned int) golden[FP_CRC_OFF + 1] << 8) + | (unsigned int) golden[FP_CRC_OFF]), + "fp_crc over the golden prefix equals its stored crc16"); + } + printf("frame_parse: %d/%d ok\n", checks - failures, checks); return failures != 0; } diff --git a/harness/udpstream/tests/test_vidsend.py b/harness/udpstream/tests/test_vidsend.py new file mode 100644 index 0000000..703c7d1 --- /dev/null +++ b/harness/udpstream/tests/test_vidsend.py @@ -0,0 +1,385 @@ +"""Tests for the host sender: pacing, chunking, and the status back-channel. + +The plan's acceptance run says "MAME with a tap interface: run vidsend.py for +200 frames at 30 fps". That run cannot happen in this repository: the MAME +driver creates no ``device_network_interface``, so the emulated LANCE is not +attached to the host at all (ruling R11). What can be tested here is every +layer below the socket, and that is what this file does: + +* the *schedule* -- frame ``n`` leaves at ``t0 + n/fps``, measured on an + injected clock rather than by sleeping for real; +* the *wire form* -- a frame is chunked at 1450 bytes, only the last datagram is + padded to the 60-byte Ethernet minimum, and the pieces reassemble to exactly + the bytes ``vidsrc.StripSource`` produced; +* the *status* path -- a status frame is recognised in an Ethernet frame, + decoded field by field, and ``drops``/``crc_err`` decide the exit status; +* the *loop* -- a fake counterparty that reassembles datagrams, counts frames + and answers with status frames, so a 200-frame run's verdict is exercised + without a NIC or a terminal. + +No test here touches a socket, and none of them claims anything about whether a +frame would arrive on a real wire. +""" +import io +import struct + +import pytest + +from harness.udpstream import vidsend +from harness.udpstream.frame import (BROADCAST, ETHERTYPE, HEADER_SIZE, + KIND_STATUS, TERMINAL_MAC, ethernet_frame, + pack_header, pack_status, unpack_header) +from harness.udpstream.vidsrc import CHUNK, ROW_BYTES, StripSource + +#: The host's own address. Locally administered, so it cannot be mistaken for +#: the terminal's (08:00:2b:16:4b:5a, TERMINAL_MAC) in a datagram assertion. +HOST_MAC = b"\x02\x00\x00\x00\x00\x01" + + +class FakeClock: + """A monotonic clock that only moves when something sleeps on it.""" + + def __init__(self): + self.t = 0.0 + self.sleeps = [] + + def now(self): + return self.t + + def sleep(self, seconds): + self.sleeps.append(seconds) + self.t += seconds + + +class RecordingTransport: + """Stands in for the NIC: records each datagram and when it was sent.""" + + def __init__(self, clock): + self.clock = clock + self.datagrams = [] + self.times = [] + + def __call__(self, data): + self.datagrams.append(data) + self.times.append(self.clock.now()) + + +def make_sender(clock, transport, **kw): + kw.setdefault("rows", 8) + kw.setdefault("stretch", 128) + kw.setdefault("fps", 10.0) + kw.setdefault("status_wait", 0.0) + kw.setdefault("out", io.StringIO()) + kw.setdefault("err", io.StringIO()) + kw.setdefault("src", HOST_MAC) + return vidsend.Sender(transport=transport, clock=clock.now, + sleep=clock.sleep, **kw) + + +def status_frame(frames_rx=0, crc_err=0, drops=0, csr0=0x20F2, heap_free_kb=2048, + frame_no=0, body=None): + body = pack_status(frames_rx, crc_err, drops, csr0, heap_free_kb) \ + if body is None else body + hdr = pack_header(KIND_STATUS, frame_no, len(body), 0, 0, payload=body) + return ethernet_frame(hdr + body, src=TERMINAL_MAC) + + +# --- pacing --------------------------------------------------------------- + +def test_frame_n_leaves_at_t0_plus_n_over_fps(): + """The schedule, not "about 10 a second": exactly t0 + n/fps.""" + clock = FakeClock() + transport = RecordingTransport(clock) + sender = make_sender(clock, transport, fps=10.0, frames=5) + sender.run() # the exit status is the status verdict, tested below + assert transport.times == pytest.approx([0.0, 0.1, 0.2, 0.3, 0.4]) + # And the sleeps are the differences: the first frame does not sleep. + assert clock.sleeps == pytest.approx([0.1, 0.1, 0.1, 0.1]) + + +def test_pacing_does_not_accumulate_drift(): + """A slow frame is followed by a shorter sleep, not by a later schedule. + + ``t0 + n/fps`` is absolute, so the sender catches up instead of falling + behind by however long the previous frame took. + """ + clock = FakeClock() + sent_at = [] + + def transport(data): + sent_at.append(clock.now()) + clock.t += 0.03 # this frame took 30 ms to send + + sender = make_sender(clock, transport, fps=10.0, frames=4) + sender.run() # the exit status is the status verdict, tested below + assert sent_at == pytest.approx([0.0, 0.1, 0.2, 0.3]) + + +def test_fps_zero_sends_unpaced(): + clock = FakeClock() + transport = RecordingTransport(clock) + sender = make_sender(clock, transport, fps=0.0, frames=3) + sender.run() # the exit status is the status verdict, tested below + assert transport.times == [0.0, 0.0, 0.0] + assert clock.sleeps == [] + + +def test_a_csv_line_every_thirty_frames(): + clock = FakeClock() + transport = RecordingTransport(clock) + out = io.StringIO() + sender = make_sender(clock, transport, fps=0.0, frames=65, out=out) + sender.run() # the exit status is the status verdict, tested below + lines = out.getvalue().strip().splitlines() + assert lines[0] == vidsend.CSV_HEADER == "frame,elapsed_s,fps,bytes" + # 30 and 60, not a line per frame and not 0 (nothing sent yet). stdout + # carries the table only; the prose (status, verdict) goes to stderr. + assert [ln.split(",")[0] for ln in lines[1:]] == ["30", "60"] + frame_col, elapsed_col, _fps, bytes_col = lines[1].split(",") + assert int(frame_col) == 30 and float(elapsed_col) == 0.0 + assert int(bytes_col) > 0 + + +# --- the wire form -------------------------------------------------------- + +def test_only_the_last_datagram_is_short_and_none_is_padded_mid_frame(): + """128 rows = 20,480 B: 15 datagrams at 1450, the last one alone short. + + Each datagram carries the 14-byte Ethernet header in front of its chunk, + so a middle datagram is exactly CHUNK + 14 bytes; only the last one may be + padded to the minimum and here it is already well over it. + """ + clock = FakeClock() + transport = RecordingTransport(clock) + sender = make_sender(clock, transport, rows=128, stretch=8, frames=1) + sender.run() # the exit status is the status verdict, tested below + dgrams = transport.datagrams + assert len(dgrams) == 15 + assert [len(d) for d in dgrams[:-1]] == [CHUNK + 14] * 14 + assert len(dgrams[-1]) == 14 + (HEADER_SIZE + 128 * ROW_BYTES - 14 * CHUNK) + + +def test_datagrams_reassemble_to_the_frame_the_encoder_built(): + """What the receiver gets back is byte for byte vidsrc's frame.""" + clock = FakeClock() + transport = RecordingTransport(clock) + sender = make_sender(clock, transport, rows=8, stretch=128, frames=1) + sender.run() # the exit status is the status verdict, tested below + body = b"".join(d[14:] for d in transport.datagrams) + want = StripSource(8, 128).encode(0, vidsend.bars(8, 0)) + assert body == b"".join(want) + h = unpack_header(body) + assert h["crc_ok"] is True and h["kind"] == 1 and h["rows"] == 8 + + +def test_a_datagram_carries_the_ethernet_header_and_padding(): + """The smallest valid strip (8 rows x 128) is 1296 bytes: one datagram.""" + clock = FakeClock() + transport = RecordingTransport(clock) + sender = make_sender(clock, transport, rows=8, stretch=128, frames=1) + sender.run() # the exit status is the status verdict, tested below + (d,) = transport.datagrams + assert len(d) == 14 + HEADER_SIZE + 8 * ROW_BYTES + assert d[:6] == BROADCAST and d[6:12] == HOST_MAC + assert d[12:14] == struct.pack(">H", ETHERTYPE) + + +def test_a_short_last_datagram_is_padded_and_a_mid_one_is_not(): + """The padding rule, stated where it can bite: Ethernet needs 60 bytes. + + The smallest valid strip is 1296 bytes; chunked at 1255 the second (and + last) datagram carries 41 bytes of frame, so it must be padded to 60 while + the 1255-byte chunk before it must not be. Padding a middle chunk would + insert bytes into the frame body. + """ + clock = FakeClock() + transport = RecordingTransport(clock) + sender = make_sender(clock, transport, rows=8, stretch=128, frames=1, + chunk=1255) + sender.run() # the exit status is the status verdict, tested below + sizes = [len(d) for d in transport.datagrams] + assert sizes == [1255 + 14, 60] # 1296 - 1255 = 41 bytes of frame + frame = b"".join(d[14:] for d in transport.datagrams) + assert frame[:1296] == b"".join( + StripSource(8, 128).encode(0, vidsend.bars(8, 0))) + assert frame[1296:] == bytes(5) # the pad, and only at the end + + +def test_frame_numbers_wrap_at_16_bits_and_bodies_differ_per_frame(): + """frame_no is a u16 field; the pattern must move or a stream is not one.""" + assert vidsend.bars(8, 0) != vidsend.bars(8, 1) + clock = FakeClock() + transport = RecordingTransport(clock) + sender = make_sender(clock, transport, fps=0.0, frames=2) + sender.frame_offset = 0xFFFE # next two frames straddle the wrap + sender.run() # the exit status is the status verdict, tested below + nos = [unpack_header(d[14:])["frame_no"] for d in transport.datagrams] + assert nos == [0xFFFE, 0xFFFF] + + +# --- the status path ------------------------------------------------------ + +def test_a_status_frame_is_decoded_from_an_ethernet_frame(): + d = vidsend.decode_status_frame(status_frame(frames_rx=200, csr0=0x20F2, + heap_free_kb=2048)) + assert d is not None + assert d["kind"] == KIND_STATUS and d["crc_ok"] is True + assert d["frames_rx"] == 200 and d["drops"] == 0 and d["crc_err"] == 0 + assert d["csr0"] == 0x20F2 and d["heap_free_kb"] == 2048 + + +def test_a_strip_frame_is_not_mistaken_for_a_status(): + clock = FakeClock() + transport = RecordingTransport(clock) + sender = make_sender(clock, transport, fps=0.0, frames=1) + sender.run() # the exit status is the status verdict, tested below + assert vidsend.decode_status_frame(transport.datagrams[0]) is None + + +def test_a_status_frame_with_a_corrupt_header_is_flagged_not_decoded(): + bad = bytearray(status_frame(frames_rx=1)) + bad[14 + 8] ^= 0x01 # rows byte, inside the CRC prefix + d = vidsend.decode_status_frame(bytes(bad)) + assert d is not None and d["crc_ok"] is False + + +def test_a_status_in_a_foreign_ethertype_is_ignored(): + body = pack_status(1, 0, 0, 0, 0) + hdr = pack_header(KIND_STATUS, 0, len(body), 0, 0) + data = (BROADCAST + TERMINAL_MAC + struct.pack(">H", 0x88B6) + hdr + body) + assert vidsend.decode_status_frame(data) is None + + +# --- the verdict ---------------------------------------------------------- + +def run_with_statuses(*status_bytes, frames=3): + clock = FakeClock() + transport = RecordingTransport(clock) + pending = list(status_bytes) + + def recv(): + out, pending[:] = list(pending), [] + return out + + out, err = io.StringIO(), io.StringIO() + sender = vidsend.Sender(transport=transport, receive=recv, src=HOST_MAC, + clock=clock.now, sleep=clock.sleep, frames=frames, + fps=0.0, status_wait=0.0, rows=8, stretch=128, + out=out, err=err) + code = sender.run() + return sender, code, out.getvalue() + err.getvalue() + + +def test_exit_zero_only_when_drops_and_crc_err_are_clean(): + _, code, text = run_with_statuses(status_frame(frames_rx=3)) + assert code == 0 + assert "drops=0" in text and "crc_err=0" in text + + _, code, _ = run_with_statuses(status_frame(frames_rx=3, drops=1)) + assert code == 1 + + _, code, _ = run_with_statuses(status_frame(frames_rx=3, crc_err=2)) + assert code == 1 + + +def test_exit_is_non_zero_when_no_status_arrives(): + """No status means the two conditions were never observed, so no PASS.""" + _, code, text = run_with_statuses() + assert code == 1 + assert "no status" in text.lower() + + +def test_a_status_with_a_bad_header_crc_does_not_count(): + bad = bytearray(status_frame(frames_rx=3)) + bad[14 + 3] ^= 0x01 # kind byte, inside the CRC prefix + _, code, text = run_with_statuses(bytes(bad)) + assert code == 1 + assert "crc" in text.lower() + + +# --- the whole loop, with a fake counterparty ----------------------------- + +class FakeTerminal: + """Reassembles the sender's datagrams and answers with status frames. + + This is the sender's counterparty, not a VT1200 model: it counts what + arrived and every 64 accepted frames hands back the status frame the + payload would send. It exists to exercise vidsend.py's loop, not to stand + in for the terminal -- the terminal's own status emission is checked in + MAME (VT1200_MODE=stream) and decoded by vidstream_status_check.py. + """ + + def __init__(self, every=64, drops=0, crc_err=0): + self.every = every + self.drops = drops + self.crc_err = crc_err + self.buf = b"" + self.frames_rx = 0 + self.reported = [] + self.pending = [] + + def send(self, data): + if data[12:14] != struct.pack(">H", ETHERTYPE): + return + self.buf += data[14:] + if len(self.buf) < HEADER_SIZE: + return + plen = struct.unpack_from(" bytes: + """Packed rows of horizontal bars, drifting up one row per frame. + + Two row classes per frame (ink and blank), so a decoder that reused one + source row cannot produce the picture, and the pattern moves between + frames, so a frozen stream is distinguishable from a live one. + """ + out = bytearray(rows * ROW_BYTES) + for r in range(rows): + if (r + frame_no) % BAR_PERIOD < BAR_PERIOD // 2: + start = r * ROW_BYTES + out[start:start + ROW_BYTES] = b"\xff" * ROW_BYTES + return bytes(out) + + +def split(rows: int, frame_no: int) -> bytes: + """Packed rows of a split field with a vertical edge that moves per frame. + + A second pattern whose answer is known analytically: the left 640 pixels of + every row are ink, and the right side is ink only above a moving horizon. + """ + out = bytearray(rows * ROW_BYTES) + horizon = (frame_no * 3) % max(rows, 1) + for r in range(rows): + start = r * ROW_BYTES + out[start:start + ROW_BYTES // 2] = b"\xff" * (ROW_BYTES // 2) + if r < horizon: + out[start + ROW_BYTES // 2:start + ROW_BYTES] = \ + b"\xff" * (ROW_BYTES // 2) + return bytes(out) + + +PATTERNS = {"bars": bars, "split": split} + + +# --- the status back-channel --------------------------------------------- + +def decode_status_frame(data: bytes): + """Decode one received Ethernet frame, or return None. + + None means "not a status frame of ours": a different ethertype, too short, + not a ``VS`` frame, or a different kind. A status frame whose header CRC is + wrong is *returned* with ``crc_ok`` False, so the caller can count it + instead of silently treating it as noise. + """ + if len(data) < 14 + HEADER_SIZE + STATUS_SIZE: + return None + if data[12:14] != struct.pack(">H", ETHERTYPE): + return None + body = data[14:] + try: + h = unpack_header(body) + except ValueError: + return None + if h["magic"] != MAGIC or h["kind"] != KIND_STATUS: + return None + if h["payload_len"] != STATUS_SIZE: + return None + out = unpack_status(body[HEADER_SIZE:HEADER_SIZE + STATUS_SIZE]) + out["kind"] = h["kind"] + out["frame_no"] = h["frame_no"] + out["crc_ok"] = h["crc_ok"] + return out + + +# --- pacing --------------------------------------------------------------- + +class Sender: + """Send ``rows``-row strip frames at a fixed rate and collect status frames. + + Everything that would touch the world is injected, so the schedule and the + verdict can be tested without a socket: + + ``transport(data: bytes)`` one Ethernet frame, header included + ``receive()`` zero or more received Ethernet frames + ``clock()`` / ``sleep(s)`` the time base the schedule is measured on + """ + + def __init__(self, transport, src, receive=None, rows=128, stretch=8, + fps=30.0, frames=None, pattern="bars", chunk=CHUNK, + dst=BROADCAST, clock=time.monotonic, sleep=time.sleep, + report_every=REPORT_EVERY, status_wait=1.0, out=None, + err=None): + if pattern not in PATTERNS: + raise ValueError("unknown pattern %r; choose from %s" + % (pattern, ", ".join(sorted(PATTERNS)))) + if chunk < HEADER_SIZE + 1: + raise ValueError("chunk %d is too small for a frame" % chunk) + if fps < 0: + raise ValueError("fps must be >= 0 (0 = unpaced): %r" % (fps,)) + self.source = StripSource(rows, stretch) + self.pattern = pattern + self.rows = rows + self.stretch = stretch + self.fps = float(fps) + self.frames = frames + self.chunk = chunk + self.dst = dst + self.src = src + self.transport = transport + self.receive = receive + self.clock = clock + self.sleep = sleep + self.report_every = report_every + self.status_wait = status_wait + self.out = out if out is not None else sys.stdout + self.err = err if err is not None else sys.stderr + self.frame_offset = 0 + self.sent_frames = 0 + self.sent_datagrams = 0 + self.sent_bytes = 0 + self.statuses = [] + self.bad_status = 0 + self.t0 = 0.0 + + # -- output -------------------------------------------------------- + def say(self, text): + """One CSV line: stdout, because it is a table.""" + print(text, file=self.out, flush=True) + + def log(self, text): + """Prose: stderr, so it cannot be mistaken for a CSV row.""" + print(text, file=self.err, flush=True) + + # -- one frame ----------------------------------------------------- + def frame_bytes(self, n: int) -> bytes: + """The complete wire frame (header + body) for frame *n*.""" + body = PATTERNS[self.pattern](self.rows, n) + no = (self.frame_offset + n) & 0xFFFF + return b"".join(self.source.encode(no, body)) + + def datagrams(self, n: int) -> list[bytes]: + """The frame split into datagrams, only the last padded to the minimum. + + Padding matters: the 60-byte Ethernet minimum must be reached at the + *end* of a frame. Padding a middle chunk would insert bytes the sender + never meant between two chunks of one frame, and the receiver would see + a body shifted by however many pads were inserted. + """ + frame = self.frame_bytes(n) + chunks = [frame[i:i + self.chunk] + for i in range(0, len(frame), self.chunk)] + out = [] + for i, chunk in enumerate(chunks): + out.append(ethernet_frame(chunk, src=self.src, dst=self.dst, + pad=(i == len(chunks) - 1))) + return out + + def send_frame(self, n: int): + for data in self.datagrams(n): + self.transport(data) + self.sent_datagrams += 1 + self.sent_bytes += len(data) + self.sent_frames += 1 + + # -- status -------------------------------------------------------- + def poll(self): + if self.receive is None: + return + for data in (self.receive() or [])[:64]: + st = decode_status_frame(data) + if st is None: + continue + if not st["crc_ok"]: + self.bad_status += 1 + self.log("status: header CRC bad, ignored (frames_rx=%d)" + % st["frames_rx"]) + continue + self.statuses.append(st) + self.log("status %d: frame_no=%d frames_rx=%d crc_err=%d drops=%d " + "csr0=0x%04X heap_free_kb=%d" + % (len(self.statuses), st["frame_no"], st["frames_rx"], + st["crc_err"], st["drops"], st["csr0"], + st["heap_free_kb"])) + + # -- the run ------------------------------------------------------- + def run(self, judge=True) -> int: + """Send the run; ``judge`` False skips the verdict (the dry run's case).""" + self.t0 = self.clock() + if self.report_every: + self.say(CSV_HEADER) + n = 0 + try: + while self.frames is None or n < self.frames: + self.wait_for(n) + self.send_frame(n) + n += 1 + self.poll() + if self.report_every and n % self.report_every == 0: + self.report(n) + except KeyboardInterrupt: + self.log("interrupted after %d frames" % n) + if judge: + self.drain() + return self.verdict() + return 0 + + def wait_for(self, n: int): + """Sleep until ``t0 + n/fps``; the first frame does not sleep.""" + if self.fps <= 0: + return + delta = self.t0 + n / self.fps - self.clock() + if delta > 0: + self.sleep(delta) + + def report(self, n: int): + elapsed = self.clock() - self.t0 + rate = n / elapsed if elapsed > 0 else 0.0 + self.say("%d,%.3f,%.2f,%d" % (n, elapsed, rate, self.sent_bytes)) + + def drain(self, limit=None): + """Keep listening after the last frame, for a status still in flight. + + The terminal reports every 64 accepted frames, so the report for the + last few frames of a short run may never come; the wait is bounded and + ends early only when a report covers everything that was sent. + """ + if self.receive is None or self.status_wait <= 0: + return + deadline = self.clock() + (self.status_wait if limit is None else limit) + while self.clock() < deadline: + self.poll() + if (self.statuses and self.frames is not None + and self.statuses[-1]["frames_rx"] >= self.frames): + return + self.sleep(0.02) + + def verdict(self) -> int: + elapsed = self.clock() - self.t0 + self.log("sent %d frames in %d datagrams, %d bytes, %.1f fps" + % (self.sent_frames, self.sent_datagrams, self.sent_bytes, + (self.sent_frames / elapsed) if elapsed > 0 else 0.0)) + if not self.statuses: + self.log("FAIL: no status frame received (the terminal said " + "nothing, so drops and crc_err are unknown)") + return 1 + last = self.statuses[-1] + if self.bad_status: + self.log("FAIL: %d status frame(s) had a bad header CRC" + % self.bad_status) + return 1 + if last["drops"] or last["crc_err"]: + self.log("FAIL: terminal reports drops=%d crc_err=%d" + % (last["drops"], last["crc_err"])) + return 1 + lag = (self.sent_frames - last["frames_rx"] + if self.frames is not None else 0) + self.log("PASS: %d status frames, last frames_rx=%d " + "(drops=0 crc_err=0), %d frame(s) sent since that report" + % (len(self.statuses), last["frames_rx"], max(lag, 0))) + return 0 + + +# --- transports ----------------------------------------------------------- + +def parse_mac(text: str) -> bytes: + parts = text.split(":") + if len(parts) != 6: + raise argparse.ArgumentTypeError("expected aa:bb:cc:dd:ee:ff, got %r" + % text) + try: + return bytes(int(p, 16) for p in parts) + except ValueError: + raise argparse.ArgumentTypeError("not a MAC address: %r" % text) + + +def mac_of(iface: str) -> bytes: + with open("/sys/class/net/%s/address" % iface) as f: + return parse_mac(f.read().strip()) + + +class RawEthernet: + """AF_PACKET transport: send on ``iface`` and receive the stream's type. + + Two sockets, because they answer two questions: the TX socket sends a frame + the caller built (header included), the RX socket only queues frames whose + ethertype is the stream's, so the status frames arrive without the host + having to sift the interface's whole traffic. Both need CAP_NET_RAW. + """ + + def __init__(self, iface: str): + self.iface = iface + self.tx = socket.socket(socket.AF_PACKET, socket.SOCK_RAW) + self.tx.bind((iface, 0)) + self.rx = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, + socket.htons(ETHERTYPE)) + self.rx.bind((iface, 0)) + self.rx.setblocking(False) + self.src = mac_of(iface) + + def send(self, data: bytes): + self.tx.send(data) + + def recv(self): + out = [] + while True: + try: + out.append(self.rx.recv(2048)) + except BlockingIOError: + return out + if len(out) >= 64: + return out + + def close(self): + self.tx.close() + self.rx.close() + + +class CountingTransport: + """``--dry-run``: count what the schedule would send, touch no hardware.""" + + def __init__(self): + self.datagrams = 0 + self.bytes = 0 + + def __call__(self, data: bytes): + self.datagrams += 1 + self.bytes += len(data) + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--iface", help="interface to send on (needs CAP_NET_RAW)") + ap.add_argument("--rows", type=int, default=128, + help="unique source rows a frame carries (default 128; " + "128x8 fills the screen, ruling R3)") + ap.add_argument("--stretch", type=int, default=8, + help="screen rows each source row paints (default 8)") + ap.add_argument("--fps", type=float, default=30.0, + help="frames per second; 0 = as fast as possible") + ap.add_argument("--frames", type=int, default=0, + help="stop after N frames (0 = until interrupted)") + ap.add_argument("--pattern", default="bars", choices=sorted(PATTERNS), + help="test picture (default bars)") + ap.add_argument("--chunk", type=int, default=CHUNK, + help="bytes of frame per datagram (default %d)" % CHUNK) + ap.add_argument("--dst", type=parse_mac, default=BROADCAST, + help="destination MAC (default broadcast: the terminal's " + "LANCE accepts broadcast without a filter entry)") + ap.add_argument("--src", type=parse_mac, default=None, + help="source MAC (default: the interface's own)") + ap.add_argument("--report-every", type=int, default=REPORT_EVERY, + help="CSV line every N frames (default %d)" + % REPORT_EVERY) + ap.add_argument("--status-wait", type=float, default=1.0, + help="seconds to keep listening for a status after the " + "last frame (default 1.0)") + ap.add_argument("--dry-run", action="store_true", + help="run the schedule against a counter, no socket") + args = ap.parse_args(argv) + + # The geometry is vidsrc's, not this script's: `stretch` must divide the + # 1024-row screen or the encoder refuses the frame, and --rows 80 + # --stretch 12 (the plan's own example) is such a pair -- 80*12 = 960 + # rows, the other 64 scanlines unpainted (ruling R3). Report that as a + # usage error instead of a traceback. + try: + StripSource(args.rows, args.stretch) + except ValueError as e: + print("vidsend.py: bad geometry: %s\n" + " rows*stretch must be 1024 for a full screen: 128x8 is the " + "ruled default (R3); 80 rows cannot fill it." % e, + file=sys.stderr) + return 2 + + if args.dry_run: + transport = CountingTransport() + sender = Sender(transport, src=b"\x02\x00\x00\x00\x00\x01", + rows=args.rows, stretch=args.stretch, fps=args.fps, + frames=args.frames or None, pattern=args.pattern, + chunk=args.chunk, dst=args.dst, receive=None, + report_every=args.report_every, status_wait=0.0) + sender.run(judge=False) + # stdout holds the CSV table only; the verdict of a dry run is prose. + print("RESULT DRY-RUN: %d frames, %d datagrams, %d bytes counted; " + "nothing left the host and no status was received" + % (sender.sent_frames, sender.sent_datagrams, sender.sent_bytes), + file=sys.stderr) + return 0 + + if not args.iface: + ap.error("--iface is required unless --dry-run is given") + try: + net = RawEthernet(args.iface) + except PermissionError: + print("vidsend.py: %s needs CAP_NET_RAW (run as root, or use " + "--dry-run)" % args.iface, file=sys.stderr) + return 2 + except OSError as e: + print("vidsend.py: cannot open %s: %s" % (args.iface, e), file=sys.stderr) + return 2 + + src = args.src if args.src is not None else net.src + sender = Sender(net.send, src=src, receive=net.recv, rows=args.rows, + stretch=args.stretch, fps=args.fps, + frames=args.frames or None, pattern=args.pattern, + chunk=args.chunk, dst=args.dst, + report_every=args.report_every, + status_wait=args.status_wait) + try: + return sender.run() + finally: + net.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/harness/udpstream/vidstream.c b/harness/udpstream/vidstream.c index b00780d..48d2560 100644 --- a/harness/udpstream/vidstream.c +++ b/harness/udpstream/vidstream.c @@ -7,7 +7,9 @@ * fp_parse() (frame_parse.c, shared with the host unit test) and counts what * it accepted. Task 5 blits an accepted strip frame to the screen: one * PIXBLT B,XY per source row, repeated down the row `stretch` times by - * SPTCH=0 (vram_b.asm). + * SPTCH=0 (vram_b.asm). Task 6 adds the back-channel: the LANCE is taken over + * for transmit and a KIND_STATUS frame is built and sent every 64 accepted + * frames (send_status() below). * * Pool layout inside the single allocation (POOL_BYTES = 249856 = 244 KB): * @@ -15,7 +17,12 @@ * +0x06000 frame slot B 24576 bytes * +0x0C000 framebuffer 163840 bytes (scheme A, Task 5) * +0x34000 dictionary 32768 bytes - * +0x3C000 slack 4096 bytes + * +0x3C000 LANCE block 4096 bytes (Task 6: init block, TX ring, + * status frame) + * + * The LANCE block is what was slack. Its ring base is aligned at run time + * (lance_start() explains why the offset alone is not enough), and the status + * frame sits right after the 8 descriptors. * * A slot holds one complete frame -- 16-byte header and payload -- exactly as * it arrives on the wire, so the bytes fp_parse() validates are the bytes the @@ -85,6 +92,66 @@ */ #define SLOT_BYTES 24576 +/* + * Task 6: the terminal's status frame. + * + * The payload takes the LANCE over -- STOP, its own init block and TX ring, + * INIT, IDON, STRT -- and every 64 accepted frames builds a 60-byte Ethernet + * frame in its own buffer and hands it to the chip with a TX descriptor. The + * descriptor setup is lance.c's (harness/udpstream/lance.c): the ring words are + * TMD0/TMD1/TMD2/TMD3 at byte offsets 0/2/4/6, the buffer address's high byte + * lives in TMD1, and the length is (0x10000 - bytes) in TMD2. This is NOT a + * second driver: it is the same init/descriptor sequence, with one buffer + * instead of a loopback probe's. + * + * Where the pieces live: the pool's last 4 KB was slack, so the LANCE block + * goes there -- 24 bytes of init block, 64 bytes of TX ring (8 descriptors) and + * the 60-byte status frame, all aligned at run time in lance_start(). All of + * it is memory this payload owns, which is the point: the firmware's own rings + * are in low memory with 32 descriptors (see lance.c's note on the OS init + * block), and arming a slot in a ring whose position the chip owns is not + * something this payload can do reliably. + * + * The receiver is DISABLED (MODE_DRX) on purpose, and that is a gap rather + * than a design: the receive path in this task is the harness injection + * interface (RX_READY_A/B), the real transport chunks a frame into 15 + * datagrams, and a ring of one-descriptor-per-datagram that reassembles them + * does not exist yet (ruling R11 covers why MAME cannot exercise it either). + * Arming an RX ring we have no buffers for would let the chip DMA a datagram + * into address 0, so disabling the receiver is the honest state. + * + * So `drops` in the status body is wired to the chip's own counter -- CSR0's + * MISS bit, one per missed-packet event, cleared after each report -- and with + * the receiver disabled it can only be 0 here. What it must not be is + * invented: the field is the chip's, so an RX ring inherits it unchanged. + */ +#define LANCE_RAP 0x05900000 /* bit address of the register-select port */ +#define LANCE_RDP 0x05800000 /* bit address of the data port */ + +#define CSR0_INIT 0x0001 +#define CSR0_STRT 0x0002 +#define CSR0_STOP 0x0004 +#define CSR0_TDMD 0x0008 +#define CSR0_IDON 0x0100 +#define CSR0_MISS 0x1000 /* no RX descriptor: a missed-packet event */ + +#define MODE_DRX 0x0001 /* disable the receiver (see above) */ + +#define TMD1_OWN 0x8000 +#define TMD1_ENP 0x0100 +#define TMD1_STP 0x0200 + +#define L_INIT_OFF 0x3C000 /* 12-word init block (24 bytes) */ +#define L_RING_OFF 0x3C040 /* 8 TX descriptors, 8 bytes each */ +#define L_RING_LEN 8 /* TLEN code 3 = 2^3 descriptors */ + +#define STATUS_EVERY 64 /* frames per status report */ +#define STATUS_LEN 60 /* Ethernet minimum; the chip adds the FCS */ + +/* Offset of the VS header inside the Ethernet frame, and of the body. */ +#define ETH_HDR_LEN 14 +#define STAT_BODY (ETH_HDR_LEN + FP_HEADER_SIZE) + /* * Static fallback. pool_static is global so the linker map names it and the * MAME check can assert the exact address a failed malloc falls back to. @@ -118,6 +185,18 @@ static unsigned int heap_bytes() return *(unsigned int *)HEAP_FREE / 8; } +/* + * Task 6 state. The toolchain never runs .cinit, so main() clears these + * before they are used (same rule as the markers). + */ +static unsigned int lance_ok; /* 1 once the LANCE is ours */ +static unsigned int lance_txring; /* bit address of the TX ring */ +static unsigned int status_buf; /* bit address of the status frame */ +static unsigned int lance_txslot; /* next TX descriptor to arm */ +static unsigned int status_seq; /* reports sent, the status frame_no*/ +static unsigned int status_seen; /* frames_rx at the last report */ +static unsigned int drops_total; /* CSR0 MISS events, cumulative */ + /* * blit_row(src, daddr_xy, dydx) * @@ -191,6 +270,290 @@ fp_header *h; vid_eint_(); } +/* + * The LANCE, transmit only. The sequence is lance.c's, and the comment on + * each step names what makes it necessary (harness/mame/README.md rule 4: a + * step taken from another driver carries its source). + */ +static void w16(bit, v) +unsigned int bit, v; +{ + *(unsigned short *)bit = v; +} + +static unsigned int r16(bit) +unsigned int bit; +{ + return *(unsigned short *)bit; +} + +static void wb(bit, v) +unsigned int bit, v; +{ + *(unsigned char *)bit = v; +} + +/* + * The chip's register file takes a select write (RAP) then a data access + * (RDP), and needs a settle between them: am79c90.cpp's regs_w consumes the + * address register on the next access, and lance.c measured that a store + * immediately after the select lands in the wrong register. Same 30-pass + * settle as lance.c. + */ +static void lance_settle() +{ + unsigned int i; + + for (i = 0; i < 30; i++) { + } +} + +static void lance_csr_w(reg, v) +unsigned int reg, v; +{ + *(unsigned short *)LANCE_RAP = reg; + lance_settle(); + *(unsigned short *)LANCE_RDP = v; + lance_settle(); +} + +static unsigned int lance_csr_r(reg) +unsigned int reg; +{ + unsigned int v; + + *(unsigned short *)LANCE_RAP = reg; + lance_settle(); + v = *(unsigned short *)LANCE_RDP; + lance_settle(); + return v; +} + +/* + * lance_start(ptr) -- take the LANCE over for transmit. 1 on success. + * + * STOP comes first because the init block address (CSR1/CSR2/CSR3) is only + * writable while the chip is stopped -- am79c90.cpp regs_w guards each of them + * with "if (m_csr[0] & CSR0_STOP)" -- and because STOP resets the chip, which + * clears INEA: the network interrupt is off before this payload touches + * anything, so the firmware's ISR cannot act on a half-programmed ring. The + * caller also masks interrupts for the whole sequence. + * + * The init block's words are the C-LANCE's own order: MODE, PADR (three + * words), LADRF (four), RX ring address/length, TX ring address/length. A + * ring word is the address's bits 16-23 in its low byte and the length code + * (2^code descriptors) in bits 13-15; the pool sits above 64 KB, so unlike + * lance.c's low-memory rings the high byte is not zero and has to be written. + */ +static int lance_start(ptr) +unsigned int ptr; +{ + unsigned int init, ring, buf, cb, i, idon; + + /* + * Alignment is the CHIP's requirement, not a convenience. The Am7990 + * masks what it is given: INIT_ADDR_MASK 0xFFFFFE for the init block and + * RING_ADDR_MASK 0xFFFFF8 for a ring (am79c90.h), because a descriptor + * is eight bytes. The pool base is only word-aligned (0x0090C8D0 is + * byte 0x12191A), so a fixed offset does NOT give a ring the chip can + * find: the first run of this code put the ring at byte 0x15D93A, the + * chip masked that to 0x15D938 and read our TMD0 word as TMD1 -- it never + * fetched the status buffer at all (proved from VT1200_LANCE_DMA_LOG: + * no read of the buffer address, and the chip writing 0x599A into our + * TMD0 words). Round up to the alignment the chip will keep. + */ + init = (ptr + L_INIT_OFF * 8 + 0x0F) & 0xFFFFFFF0; /* 16-bit words */ + ring = (ptr + L_RING_OFF * 8 + 0x3F) & 0xFFFFFFC0; /* 8-byte */ + buf = ring + L_RING_LEN * 64; /* after the ring */ + lance_txring = ring; + status_buf = buf; + + lance_csr_w(0, CSR0_STOP); + + w16(init + 0 * 16, MODE_DRX); /* no loopback, FCS appended */ + w16(init + 1 * 16, 0x0008); /* PADR 08:00:2b:16:4b:5a */ + w16(init + 2 * 16, 0x162b); + w16(init + 3 * 16, 0x5a4b); + w16(init + 4 * 16, 0); /* LADRF: physical + broadcast */ + w16(init + 5 * 16, 0); + w16(init + 6 * 16, 0); + w16(init + 7 * 16, 0); + w16(init + 8 * 16, 0); /* RX ring: none (MODE_DRX) */ + w16(init + 9 * 16, 0); + cb = ring >> 3; /* BYTE address of the ring */ + w16(init + 10 * 16, cb & 0xFFFF); + w16(init + 11 * 16, ((cb >> 16) & 0xFF) | (3 << 13)); + + /* Every descriptor points at the one status buffer; TMD1's OWN stays + * clear until a frame is ready, and TMD2 holds the length as a two's + * complement (0x10000 - 60), lance.c's descriptor layout. */ + cb = buf >> 3; + for (i = 0; i < L_RING_LEN; i++) { + w16(ring + i * 64 + 0, cb & 0xFFFF); + w16(ring + i * 64 + 16, (cb >> 16) & 0xFF); + w16(ring + i * 64 + 32, (0x10000 - STATUS_LEN) & 0xFFFF); + w16(ring + i * 64 + 48, 0); + } + for (i = 0; i < STATUS_LEN; i++) { + wb(buf + i * 8, 0); + } + + lance_csr_w(1, (init >> 3) & 0xFFFF); + lance_csr_w(2, ((init >> 3) >> 16) & 0xFF); + lance_csr_w(3, 0); + lance_csr_w(0, CSR0_INIT); + + idon = 0; + for (i = 0; i < 20000; i++) { + idon = lance_csr_r(0); + if (idon & CSR0_IDON) { + break; + } + } + *(unsigned int *)ST_INIT = idon; + if (!(idon & CSR0_IDON)) { + return 0; + } + + /* STRT turns the transmitter on; no INEA, so no interrupt reaches the + * firmware's network ISR while this payload owns the chip. */ + lance_csr_w(0, CSR0_STRT); + for (i = 0; i < 30; i++) { + } + return 1; +} + +/* + * send_status(frames_rx) -- build and transmit one status frame. + * + * The frame is an ordinary Ethernet frame: broadcast destination (the LANCE + * accepts broadcast with an empty LADRF, and the host's capture sees it), the + * terminal's own address from its Ethernet ROM, the stream's ethertype 0x88B7 + * (frame.py ETHERTYPE), then the same 16-byte VS header every other frame + * carries, with rows and stretch zero because a status has no geometry. The + * body is 12 bytes, LITTLE-endian as frame.py's pack_status() writes it: + * frames_rx u32, crc_err u16, drops u16, csr0 u16, heap_free_kb u16. (The + * command protocol in lance_cmd.c is big-endian; this one is not, and getting + * it backwards reads 64 frames as 0x40000000 -- which is how the Task 6 + * MAME run first found it.) + * + * crc_err is RX_BAD: the frames fp_parse() rejected (header CRC, magic, + * version or length). Since ruling R14 a corrupted *body* cannot appear + * here -- the LANCE validated each datagram's FCS before the bytes were + * stored -- so this is the only CRC error the terminal can count. + * + * Returns 1 when the frame was handed to the chip, 0 when the ring slot was + * still owned and nothing was armed. Overwriting a descriptor the chip still + * owns is what every reference driver refuses to do (Linux stops the queue, + * NetBSD prints "missing buffer"), and the caller must not count a report it + * never sent: it advances status_seen only on 1. + */ +static int send_status(frames_rx) +unsigned int frames_rx; +{ + unsigned int i, crc, csr0, tmd1, off, hb, miss, bad, free_kb; + + hb = status_buf; + + /* Ethernet header. */ + for (i = 0; i < 6; i++) { + wb(hb + i * 8, 0xFF); + } + wb(hb + 6 * 8, 0x08); wb(hb + 7 * 8, 0x00); + wb(hb + 8 * 8, 0x2B); wb(hb + 9 * 8, 0x16); + wb(hb + 10 * 8, 0x4B); wb(hb + 11 * 8, 0x5A); + wb(hb + 12 * 8, 0x88); wb(hb + 13 * 8, 0xB7); + + /* The VS header, laid out exactly as frame.py's pack_header() does. */ + off = hb + ETH_HDR_LEN * 8; + wb(off + 0 * 8, FP_MAGIC0); + wb(off + 1 * 8, FP_MAGIC1); + wb(off + 2 * 8, FP_VERSION); + wb(off + 3 * 8, FP_KIND_STATUS); + wb(off + 4 * 8, status_seq & 0xFF); + wb(off + 5 * 8, (status_seq >> 8) & 0xFF); + wb(off + 6 * 8, FP_STATUS_SIZE); + wb(off + 7 * 8, 0); + wb(off + 8 * 8, 0); /* rows: none */ + wb(off + 9 * 8, 0); /* stretch: none */ + wb(off + 10 * 8, 0); /* reserved */ + wb(off + 11 * 8, 0); + crc = fp_crc((unsigned char *)off, FP_PREFIX_SIZE); + wb(off + 12 * 8, crc & 0xFF); + wb(off + 13 * 8, (crc >> 8) & 0xFF); + wb(off + 14 * 8, 0); + wb(off + 15 * 8, 0); + + /* The chip's status word, and the missed-packet count it reports. Read + * first, report what was read, then clear MISS (write-1-to-clear). */ + csr0 = lance_csr_r(0); + miss = 0; + if (csr0 & CSR0_MISS) { + drops_total = drops_total + 1; + miss = 1; + } + + bad = *(unsigned int *)RX_BAD; + free_kb = heap_bytes() >> 10; + off = hb + STAT_BODY * 8; + wb(off + 0 * 8, frames_rx & 0xFF); + wb(off + 1 * 8, (frames_rx >> 8) & 0xFF); + wb(off + 2 * 8, (frames_rx >> 16) & 0xFF); + wb(off + 3 * 8, (frames_rx >> 24) & 0xFF); + wb(off + 4 * 8, bad & 0xFF); + wb(off + 5 * 8, (bad >> 8) & 0xFF); + wb(off + 6 * 8, drops_total & 0xFF); + wb(off + 7 * 8, (drops_total >> 8) & 0xFF); + wb(off + 8 * 8, csr0 & 0xFF); + wb(off + 9 * 8, (csr0 >> 8) & 0xFF); + wb(off + 10 * 8, free_kb & 0xFF); + wb(off + 11 * 8, (free_kb >> 8) & 0xFF); + + /* Arm the next descriptor: TMD3, then the length, then ownership last, + * so the chip cannot take a descriptor whose length is still stale + * (lance_cmd.c does the same, and NetBSD's am7990_rint() says why). */ + off = lance_txring + lance_txslot * 64; + for (i = 0; i < 10000; i++) { + if (!(r16(off + 16) & TMD1_OWN)) { + break; + } + } + if (r16(off + 16) & TMD1_OWN) { + return 0; /* still the chip's: arm nothing */ + } + w16(off + 48, 0); + w16(off + 32, (0x10000 - STATUS_LEN) & 0xFFFF); + w16(off + 16, TMD1_OWN | TMD1_STP | TMD1_ENP + | ((status_buf >> 3 >> 16) & 0xFF)); + /* Where the harness must look to watch the chip finish this frame. */ + *(unsigned int *)ST_TMLAST = off + 16; + + /* One write: a bare TDMD would clear STRT (CSR0 is a command shadow; + * lance.c's comment and every reference driver agree). */ + lance_csr_w(0, CSR0_STRT | CSR0_TDMD); + + tmd1 = 0; + for (i = 0; i < 20000; i++) { + tmd1 = r16(off + 16); + if (!(tmd1 & TMD1_OWN)) { + break; + } + } + + lance_txslot = (lance_txslot + 1) & (L_RING_LEN - 1); + status_seq = status_seq + 1; + *(unsigned int *)ST_FRAMES = frames_rx; + *(unsigned int *)ST_TMD1 = tmd1; + /* ST_SENT last: a reader that sees it move reads a frame that was + * built, transmitted and recorded, never one in progress. */ + *(unsigned int *)ST_SENT = *(unsigned int *)ST_SENT + 1; + + if (miss) { + lance_csr_w(0, CSR0_MISS); + } + return 1; +} + /* * Handle one slot if the receive path has raised its ready flag. * @@ -254,7 +617,7 @@ unsigned int slot; int main() { - unsigned int before, after, ptr; + unsigned int before, after, ptr, frames, sent; *(unsigned int *)VID_ALIVE = 0; *(unsigned int *)POOL_PTR = 0; @@ -301,9 +664,50 @@ int main() *(unsigned int *)RX_SLOT_B = ptr + SLOT_BYTES * 8; *(unsigned int *)POOL_PTR = ptr; /* publish last: a complete record */ + /* Task 6: the status back-channel. The record starts empty, then the + * LANCE is taken over for transmit with interrupts masked -- the OS's + * drivers were using this chip a moment ago, and a network ISR running + * in the middle of the ring setup would see a half-written descriptor. */ + *(unsigned int *)RX_EOF = 0; + *(unsigned int *)ST_SENT = 0; + *(unsigned int *)ST_FRAMES = 0; + *(unsigned int *)ST_BUF = 0; + *(unsigned int *)ST_TMD1 = 0; + *(unsigned int *)ST_INIT = 0; + *(unsigned int *)ST_TMLAST = 0; + + lance_txslot = 0; + status_seq = 0; + status_seen = 0; + drops_total = 0; + + vid_dint_(); + lance_ok = lance_start(ptr); + vid_eint_(); + *(unsigned int *)ST_BUF = status_buf; + for (;;) { *(unsigned int *)VID_ALIVE = *(unsigned int *)VID_ALIVE + 1; rx_poll(0); rx_poll(1); + + /* Report every STATUS_EVERY accepted frames, and -- once the + * harness says the sender has stopped -- for every further + * accepted frame, so a finite run ends with a report whose + * frames_rx is the final count rather than the last multiple + * of 64 (RX_EOF is harness-only; see frame_parse.h). */ + frames = *(unsigned int *)RX_OK; + if (!lance_ok) { + continue; + } + if (frames - status_seen >= STATUS_EVERY + || (*(unsigned int *)RX_EOF != 0 && frames != status_seen)) { + vid_dint_(); + sent = send_status(frames); + vid_eint_(); + if (sent) { + status_seen = frames; + } + } } }