diff --git a/harness/mame/run-vidstream.sh b/harness/mame/run-vidstream.sh index 9dcef20..5e7d218 100755 --- a/harness/mame/run-vidstream.sh +++ b/harness/mame/run-vidstream.sh @@ -3,9 +3,12 @@ # # harness/mame/run-vidstream.sh # build, then mode alloc # BENCH=0 harness/mame/run-vidstream.sh # use the existing .bin -# VT1200_MODE=rx harness/mame/run-vidstream.sh # Task 4's mode +# VT1200_MODE=rx harness/mame/run-vidstream.sh # alloc + the receive path # 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 +# asserts RX_OK == 1 && RX_BAD == 1 (see vidstream.lua and the task-4 report). +# # 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 diff --git a/harness/mame/vidstream.lua b/harness/mame/vidstream.lua index 6f9e0ea..4ca1531 100644 --- a/harness/mame/vidstream.lua +++ b/harness/mame/vidstream.lua @@ -1,4 +1,5 @@ --- vidstream.lua -- load the streaming payload and check its startup allocation. +-- vidstream.lua -- load the streaming payload and check its startup allocation +-- and its receive path. -- -- MECHANISM: debugger entry, not a delivery mechanism. The firmware is run -- with VT1200_SKIP_LEVEL1=1 (a documented divergence) to the OS, because the @@ -17,13 +18,27 @@ -- * on a failed malloc, POOL_PTR is the linked static buffer and the low bit -- of POOL_FREE (before) marks the fallback, i.e. the payload keeps running. -- +-- Mode "rx" (Task 4) is "alloc" plus the receive path: it writes a VALID frame +-- into slot A and the same frame with one payload byte flipped into slot B, +-- raises each slot's ready flag (the role the LANCE receive interrupt plays on +-- the real transport), and asserts RX_OK == 1 && RX_BAD == 1, that RX_LAST_LEN +-- is the accepted frame's payload_len, that RX_SLOT names slot A, and that the +-- payload cleared both ready flags. +-- +-- 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 +-- AGENTS.md rule 4 requires to be quoted and independently read. The DMA path +-- is proven in Task 7, on the unit. +-- -- Marker page: bit 0x01810000. A marker named 0x018100NN in the plan is a -- BYTE offset into that page (0x01810024 is four bits after 0x01810020 -- -- inside the same 32-bit cell -- so the literal reading cannot work). The C --- side uses the same rule: bit = 0x01810000 + NN * 8. +-- side uses the same rule: bit = 0x01810000 + NN * 8. The full table, +-- including the RX_* cells below, is in harness/udpstream/frame_parse.h. -- -- Env: --- VT1200_MODE alloc (default; rx/draw arrive in later tasks) +-- VT1200_MODE alloc (default) or rx -- VID_AT load/entry frame (default 2400, the OS phase) -- VID_END inspection/exit frame (default VID_AT + 60) -- VID_PAYLOAD raw .text image (default harness/udpstream/build/vidstream.bin) @@ -65,12 +80,97 @@ local POOL_FREEB = MARK_PAGE + 0x24 * 8 local POOL_FREEA = MARK_PAGE + 0x28 * 8 local VID_ALIVE = MARK_PAGE + 0x2c * 8 -if MODE ~= "alloc" then - print("VID ERROR: mode '" .. MODE .. "' is not implemented in Task 3") +-- Task 4: receive counters and the injection interface (frame_parse.h). +local RX_OK = MARK_PAGE + 0x30 * 8 +local RX_BAD = MARK_PAGE + 0x34 * 8 +local RX_LAST_LEN = MARK_PAGE + 0x38 * 8 +local RX_SLOT = MARK_PAGE + 0x3c * 8 +local RX_SLOT_A = MARK_PAGE + 0x60 * 8 +local RX_SLOT_B = MARK_PAGE + 0x64 * 8 +local RX_READY_A = MARK_PAGE + 0x68 * 8 +local RX_LEN_A = MARK_PAGE + 0x6c * 8 +local RX_READY_B = MARK_PAGE + 0x70 * 8 +local RX_LEN_B = MARK_PAGE + 0x74 * 8 + +-- Must match vidstream.c. +local SLOT_BYTES = 24576 + +-- The injected frame: 8 rows of 1280 pixels, 1 bpp MSB-first (160 bytes a +-- row), each row repeated 8 times. 1280 B of payload -- the same geometry +-- vidsrc.py's ROW_BYTES produces, so Task 5 can blit it unchanged. +local RX_ROWS = 8 +local RX_STRETCH = 8 +local RX_ROW_BYTES = 160 +local RX_PLEN = RX_ROWS * RX_ROW_BYTES +local RX_FRAME = 16 + RX_PLEN + +if MODE ~= "alloc" and MODE ~= "rx" then + print("VID ERROR: mode '" .. MODE .. "' is not implemented") machine:exit() return end +-- CCITT-FALSE (polynomial 0x1021, init 0xFFFF, not reflected, no final xor). +-- frame.py, frame_parse.c and this loop are three independent readings of the +-- same spec; the harness signs its own frames rather than reusing a value the +-- payload computed, so the C parser accepting them is a real check. +local function crc_add(crc, bytes, first, count) + for i = first, first + count - 1 do + crc = (crc ~ (bytes[i] << 8)) & 0xFFFF + for _ = 1, 8 do + if (crc & 0x8000) ~= 0 then + crc = ((crc << 1) ~ 0x1021) & 0xFFFF + else + crc = (crc << 1) & 0xFFFF + end + end + end + return crc +end + +-- A complete frame, header and payload, laid out as frame.py writes it. +local function make_frame(kind, frame_no, rows, stretch, plen) + local b = {} + b[0] = 0x56 -- 'V' + b[1] = 0x53 -- 'S' + b[2] = 0x01 -- version + b[3] = kind + b[4] = frame_no & 0xFF + b[5] = (frame_no >> 8) & 0xFF + b[6] = plen & 0xFF + b[7] = (plen >> 8) & 0xFF + b[8] = rows + b[9] = stretch + b[10] = 0 + b[11] = 0 + for i = 0, plen - 1 do + b[16 + i] = (i * 7 + 3) & 0xFF + end + local crc = crc_add(0xFFFF, b, 0, 12) + crc = crc_add(crc, b, 16, plen) + b[12] = crc & 0xFF + b[13] = (crc >> 8) & 0xFF + b[14] = 0 + b[15] = 0 + return b +end + +local function poke(base, bytes, n) + for i = 0, n - 1 do + prog:write_u8(base + i * 8, bytes[i]) + end +end + +local function verify(base, bytes, n) + local bad = 0 + for i = 0, n - 1 do + if prog:read_u8(base + i * 8) ~= bytes[i] then + bad = bad + 1 + end + end + return bad == 0, bad +end + local payload = nil do local f, err = io.open(PATH, "rb") @@ -91,6 +191,9 @@ local armed = false local direct = false local alive0 = nil local alive_last = nil +local injected = false +local readback_a = false +local readback_b = false local function load_payload() local n = math.floor(#payload / 2) @@ -127,7 +230,38 @@ local function enter_direct(reason) frames, reason, hex(ENTRY_PTR))) end -local function inspect() +-- Fill slot A with a valid frame and slot B with the same frame carrying one +-- flipped payload byte, then raise both ready flags. The payload has already +-- published the slot addresses by the time this runs; a zero there means the +-- malloc has not finished and the caller retries next frame. +local function inject() + 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, 0x0021, RX_ROWS, RX_STRETCH, RX_PLEN) + poke(a, fa, RX_FRAME) + readback_a = verify(a, fa, RX_FRAME) + + local fb = make_frame(1, 0x0022, RX_ROWS, RX_STRETCH, RX_PLEN) + fb[16 + 100] = fb[16 + 100] ~ 0x01 -- payload byte flipped, CRC left alone + poke(b, fb, RX_FRAME) + readback_b = verify(b, fb, RX_FRAME) + + prog:write_u32(RX_LEN_A, RX_FRAME) + prog:write_u32(RX_LEN_B, RX_FRAME) + prog:write_u32(RX_READY_A, 1) + prog:write_u32(RX_READY_B, 1) + + print(string.format("VID: frame %d: injected slot A (%s, %d B, valid) and slot B (%s, %d B, one payload byte flipped)", + frames, hex(a), RX_FRAME, hex(b), RX_FRAME)) + return true +end + +local function inspect_pool() local ptr = prog:read_u32(POOL_PTR) local freeb = prog:read_u32(POOL_FREEB) local freea = prog:read_u32(POOL_FREEA) @@ -208,7 +342,69 @@ local function inspect() end end - print(ok and "VID: RESULT PASS" or "VID: RESULT FAIL") + return ok +end + +local function inspect_rx() + local a = prog:read_u32(RX_SLOT_A) + local b = prog:read_u32(RX_SLOT_B) + local rxa = prog:read_u32(RX_READY_A) + local rxb = prog:read_u32(RX_READY_B) + local ok = true + + if not injected then + print("VID: no frame was ever injected -> FAIL") + return false + end + + print(string.format("VID: slots A=%s B=%s (delta %d bits = %d bytes, expected %d)", + hex(a), hex(b), b - a, (b - a) / 8, SLOT_BYTES)) + if a == 0 or (b - a) ~= SLOT_BYTES * 8 then + print("VID: slot B does not follow slot A by SLOT_BYTES -> FAIL") + ok = false + end + + if readback_a and readback_b then + print("VID: both injected frames read back byte for byte -> PASS") + else + print(string.format("VID: injected frame readback mismatch (A=%s B=%s) -> FAIL", + tostring(readback_a), tostring(readback_b))) + ok = false + end + + local oka = prog:read_u32(RX_OK) + local bad = prog:read_u32(RX_BAD) + if oka == 1 and bad == 1 then + print(string.format("VID: RX_OK=%d RX_BAD=%d -> PASS", oka, bad)) + else + print(string.format("VID: RX_OK=%d RX_BAD=%d -> FAIL (one valid frame, one corrupt)", oka, bad)) + ok = false + end + + local last = prog:read_u32(RX_LAST_LEN) + if last == RX_PLEN then + print(string.format("VID: RX_LAST_LEN=%d = payload_len of the accepted frame -> PASS", last)) + else + print(string.format("VID: RX_LAST_LEN=%d, expected %d -> FAIL", last, RX_PLEN)) + ok = false + end + + local slot = prog:read_u32(RX_SLOT) + if slot == 0 then + print("VID: RX_SLOT=0 (slot A, where the valid frame went) -> PASS") + else + print(string.format("VID: RX_SLOT=%d, expected 0 (slot A) -> FAIL", slot)) + ok = false + end + + if rxa == 0 and rxb == 0 then + print("VID: the payload cleared both ready flags -> PASS") + else + print(string.format("VID: ready flags A=%d B=%d, both must be cleared -> FAIL", rxa, rxb)) + ok = false + end + + return ok end emu.register_frame_done(function() @@ -234,6 +430,15 @@ emu.register_frame_done(function() alive0 = prog:read_u32(VID_ALIVE) end + -- Inject as soon as the payload has entered AND published the slots: the + -- slot addresses are written straight after the malloc, so a non-zero + -- value there means the receive loop is about to run. + if entered and MODE == "rx" and not injected then + if prog:read_u32(RX_SLOT_A) ~= 0 and prog:read_u32(RX_SLOT_B) ~= 0 then + injected = inject() + end + end + if entered and frames % 10 == 0 and frames < STOP_AT then alive_last = prog:read_u32(VID_ALIVE) end @@ -243,10 +448,21 @@ emu.register_frame_done(function() print("VID: payload never entered -> FAIL") print("VID: RESULT FAIL") else - local ok2, err = pcall(inspect) - if not ok2 then - print("VID: ERROR " .. tostring(err)) + local ok, result = pcall(inspect_pool) + if not ok then + print("VID: ERROR " .. tostring(result)) print("VID: RESULT FAIL") + else + if MODE == "rx" then + local ok2, result2 = pcall(inspect_rx) + if not ok2 then + print("VID: ERROR " .. tostring(result2)) + result = false + else + result = result and result2 + end + end + print(result and "VID: RESULT PASS" or "VID: RESULT FAIL") end end machine:exit() diff --git a/harness/udpstream/build-stream.sh b/harness/udpstream/build-stream.sh index 3313fd1..eb8837d 100755 --- a/harness/udpstream/build-stream.sh +++ b/harness/udpstream/build-stream.sh @@ -39,6 +39,13 @@ fi SRC=${STREAM_SRC:-$DIR/vidstream.c} cp "$SRC" "$WORK/VIDSTRM.C" cp "$DIR/romapi.h" "$WORK/ROMAPI.H" +# The frame parser is a second translation unit: vidstream.c calls it and the +# host unit test (tests/test_frame_parse.c) compiles the same file. The 1987 +# preprocessor cannot open a name with an 11-character base (Task 3 lost a +# build to VIDSTREAM.C the same way), so the work copies use 8.3 names and the +# C sources include "frmpsr.h" -- a symlink to frame_parse.h in the repo. +cp "$DIR/frame_parse.c" "$WORK/FRMPSR.C" +cp "$DIR/frame_parse.h" "$WORK/FRMPSR.H" cp "$DIR/entry.asm" "$WORK/ENTRY.ASM" cp "$DIR/vram.asm" "$WORK/VRAM.ASM" @@ -63,6 +70,7 @@ printf '#define POOL_BYTES %s\n' "$POOL_BYTES" > "$WORK/POOLCFG.H" printf '%s\n' '-m VIDSTRM.map' printf '%s\n' '-e _start' printf '%s\n' 'VIDSTRM.obj' + printf '%s\n' 'FRMPSR.obj' printf '%s\n' 'ENTRY.obj' printf '%s\n' 'VRAM.obj' [ -z "$VRAMB_OBJ" ] || printf '%s\n' "$VRAMB_OBJ" @@ -85,37 +93,52 @@ sed -i 's/$/\r/' "$WORK/VIDSTRM.CMD" rm -f "$WORK"/VIDSTRM.OBJ "$WORK"/VIDSTRM.OUT "$WORK"/VIDSTRM.LST \ "$WORK"/VIDSTRM.MAP "$WORK"/VIDSTRM.ASM "$WORK"/VIDSTRM.CPP \ "$WORK"/VIDSTRM.IF "$WORK"/ENTRY.OBJ "$WORK"/ENTRY.LST \ + "$WORK"/FRMPSR.OBJ "$WORK"/FRMPSR.LST "$WORK"/FRMPSR.ASM \ + "$WORK"/FRMPSR.CPP "$WORK"/FRMPSR.IF \ "$WORK"/VRAM.OBJ "$WORK"/VRAM.LST "$WORK"/VRAMB.OBJ "$WORK"/VRAMB.LST +# The toolchain runs from a batch file, not from one -c per command. Once the +# build grew to a second translation unit the -c list stopped completing: +# FRMPSR.OBJ and ENTRY.OBJ were written, VRAM.OBJ and VIDSTRM.OUT never were, +# and DOSBox then sat at the prompt until the 240 s timeout (reproduced twice, +# with nothing in dosbox.log -- the DOS tools write to the screen, not stdout). +# DOSBox feeds each -c line through the keyboard buffer, which is the likeliest +# cause; a .BAT has no such limit, completes the same steps in ~2 s, and makes +# the step list one readable block. CRLF, because DOS. +{ + printf 'gspcpp VIDSTRM\r\n' + printf 'gspcc VIDSTRM\r\n' + printf 'gspcg VIDSTRM\r\n' + printf 'gspa VIDSTRM VIDSTRM.obj VIDSTRM.lst -l\r\n' + printf 'gspcpp FRMPSR\r\n' + printf 'gspcc FRMPSR\r\n' + printf 'gspcg FRMPSR\r\n' + printf 'gspa FRMPSR FRMPSR.obj FRMPSR.lst -l\r\n' + printf 'gspa ENTRY ENTRY.obj ENTRY.lst -l\r\n' + printf 'gspa VRAM VRAM.obj VRAM.lst -l\r\n' + [ -z "$VRAMB_OBJ" ] || printf 'gspa VRAMB VRAMB.obj VRAMB.lst -l\r\n' + printf 'gsplnk VIDSTRM.cmd\r\n' + printf 'exit\r\n' +} > "$WORK/BUILD.BAT" + run_dosbox() { SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \ timeout 240 dosbox -conf "$DOSBOX_CONF" -noprimaryconf -nolocalconf \ -c "mount c $WORK" -c "c:" \ - -c "gspcpp VIDSTRM" \ - -c "gspcc VIDSTRM" \ - -c "gspcg VIDSTRM" \ - -c "gspa VIDSTRM VIDSTRM.obj VIDSTRM.lst -l" \ - -c "gspa ENTRY ENTRY.obj ENTRY.lst -l" \ - -c "gspa VRAM VRAM.obj VRAM.lst -l" \ - "$@" \ - -c "gsplnk VIDSTRM.cmd" \ + -c "BUILD.BAT" \ -c "exit" -exit > "$WORK/dosbox.log" 2>&1 } -if [ -n "$VRAMB_OBJ" ]; then - run_dosbox -c "gspa VRAMB VRAMB.obj VRAMB.lst -l" -else - run_dosbox -fi +run_dosbox if [ ! -f "$WORK/VIDSTRM.OUT" ]; then echo "build-stream.sh: GSPLNK produced no VIDSTRM.OUT" >&2 sed 's/\r$//' "$WORK/VIDSTRM.MAP" 2>/dev/null | tail -20 >&2 || true - for f in VIDSTRM.LST ENTRY.LST VRAM.LST $VRAMB_LST; do + for f in VIDSTRM.LST FRMPSR.LST ENTRY.LST VRAM.LST $VRAMB_LST; do sed 's/\r$//' "$WORK/$f" 2>/dev/null | grep -i 'error' | grep -vi 'no errors' >&2 || true done exit 1 fi -for f in VIDSTRM.LST ENTRY.LST VRAM.LST $VRAMB_LST; do +for f in VIDSTRM.LST FRMPSR.LST ENTRY.LST VRAM.LST $VRAMB_LST; do if sed 's/\r$//' "$WORK/$f" | grep -i 'error' | grep -qvi 'no errors'; then echo "build-stream.sh: $f diagnostics:" >&2 sed 's/\r$//' "$WORK/$f" | grep -in 'error' | grep -vi 'no errors' >&2 @@ -126,6 +149,7 @@ done cp "$WORK/VIDSTRM.OUT" "$OUT/vidstream.coff" sed 's/\r$//' "$WORK/VIDSTRM.MAP" > "$OUT/vidstream.map" sed 's/\r$//' "$WORK/VIDSTRM.LST" > "$OUT/vidstream.lst" +sed 's/\r$//' "$WORK/FRMPSR.LST" > "$OUT/frmpsr.lst" sed 's/\r$//' "$WORK/ENTRY.LST" > "$OUT/entry.lst" sed 's/\r$//' "$WORK/VRAM.LST" > "$OUT/vram.lst" if [ -n "$VRAMB_LST" ]; then diff --git a/harness/udpstream/frame_parse.c b/harness/udpstream/frame_parse.c new file mode 100644 index 0000000..df07933 --- /dev/null +++ b/harness/udpstream/frame_parse.c @@ -0,0 +1,111 @@ +/* + * frame_parse.c -- receive-side frame check for the VT1200 video stream. + * + * fp_parse() answers one question: is the block of bytes the transport just + * delivered a complete, uncorrupted frame of the format frame.py writes? It + * checks, in this order, the length bound, the magic and version, and finally + * the CRC -- the CRC is the expensive part, so a truncated or foreign frame is + * rejected before it is computed. + * + * The CRC is CCITT-FALSE (polynomial 0x1021, initial value 0xFFFF, no input or + * output reflection, no final xor), the same one frame.py implements with a + * 256-entry table. It covers header bytes 0..11 followed by the payload; the + * stored crc16 at offset 12 is never part of its own input. + * + * This file is compiled twice: by the 1987 TI C compiler inside the payload + * build (build-stream.sh stages it as FRMPSR.C) and by the host cc for + * tests/test_frame_parse.c. So it is K&R C -- no prototypes in definitions, + * no declarations after statements, no '//', no 'const' -- and it must not + * assume a 32-bit int beyond the fields it masks. + */ +#include "frmpsr.h" + +/* + * One byte through the CRC, bit by bit. frame.py builds a table from the + * identical loop; that is the whole reason this is written the long way, so + * the two can be compared line for line. + */ +static unsigned int fp_crc_byte(crc, byte) +unsigned int crc; +unsigned int byte; +{ + int i; + + crc = (crc ^ ((byte & 0xFF) << 8)) & 0xFFFF; + for (i = 0; i < 8; i++) { + if (crc & 0x8000) { + crc = ((crc << 1) ^ 0x1021) & 0xFFFF; + } else { + crc = (crc << 1) & 0xFFFF; + } + } + return crc; +} + +/* Fold len bytes at buf into crc. */ +static unsigned int fp_crc_add(crc, buf, len) +unsigned int crc; +unsigned char *buf; +unsigned int len; +{ + unsigned int i; + + for (i = 0; i < len; i++) { + crc = fp_crc_byte(crc, buf[i]); + } + return crc; +} + +/* + * int fp_parse(unsigned char *buf, unsigned int len, fp_header *out) + * + * See frame_parse.h for the contract. A NULL buffer or header is a rejection + * rather than a crash: the payload calls this with a pointer it published + * itself, and a zero there means "no slot yet". + */ +int fp_parse(buf, len, out) +unsigned char *buf; +unsigned int len; +fp_header *out; +{ + unsigned int plen, stored, crc; + + if (buf == 0 || out == 0) { + return 0; + } + if (len < FP_HEADER_SIZE) { + return 0; + } + if (buf[0] != FP_MAGIC0 || buf[1] != FP_MAGIC1) { + return 0; + } + if (buf[2] != FP_VERSION) { + return 0; + } + + plen = (unsigned int) buf[6] | ((unsigned int) buf[7] << 8); + /* + * Subtracting rather than adding: plen can be 0xFFFF, and + * FP_HEADER_SIZE + plen would still fit a 32-bit unsigned int but not a + * 16-bit one. len is known to be at least FP_HEADER_SIZE here. + */ + if (len - FP_HEADER_SIZE < plen) { + return 0; + } + + stored = ((unsigned int) buf[FP_CRC_OFF + 1] << 8) + | (unsigned int) buf[FP_CRC_OFF]; + crc = fp_crc_add(0xFFFF, buf, FP_PREFIX_SIZE); + crc = fp_crc_add(crc, buf + FP_HEADER_SIZE, plen); + if (crc != stored) { + return 0; + } + + out->kind = (unsigned int) buf[3]; + out->frame_no = (unsigned int) buf[4] | ((unsigned int) buf[5] << 8); + out->payload_len = plen; + out->rows = (unsigned int) buf[8]; + out->stretch = (unsigned int) buf[9]; + out->payload_off = FP_HEADER_SIZE; + return 1; +} diff --git a/harness/udpstream/frame_parse.h b/harness/udpstream/frame_parse.h new file mode 100644 index 0000000..663a389 --- /dev/null +++ b/harness/udpstream/frame_parse.h @@ -0,0 +1,129 @@ +/* + * frame_parse.h -- the wire format and the receive-side frame check. + * + * This is the one place the wire format is written down in C; the host sender + * writes it down in harness/udpstream/frame.py. The two must agree byte for + * byte, and harness/udpstream/tests/test_frame_parse.c pins the agreement with + * a frame generated by frame.py (a golden vector, not a value this code + * produced itself). + * + * offset size field + * ------ ---- ------------------------------------------------------ + * 0 2 magic, ASCII 'V','S' + * 2 1 version, 0x01 + * 3 1 kind (KIND_STRIP = 1, KIND_STATUS = 5) + * 4 2 frame_no, u16 little-endian + * 6 2 payload_len, u16 little-endian + * 8 1 rows + * 9 1 stretch + * 10 2 reserved, zero + * 12 2 crc16, u16 little-endian + * 14 2 padding, zero + * + * The CRC is CCITT-FALSE (polynomial 0x1021, initial value 0xFFFF, no input or + * output reflection, no final xor) over header bytes 0..11 followed by the + * payload -- never the CRC field itself. + * + * --------------------------------------------------------------------------- + * Marker page: bit 0x01810000, byte 0x00302000. A cell below is one 32-bit + * word. Cells are 0x20 BITS apart (4 bytes), which is the rule Task 3 + * established: "marker named 0x018100NN" means bit 0x01810000 + NN*8, because + * the TMS34010 is bit-addressed and four bits after a word would still be + * inside the same word. The plan numbers the page from 0x01810100, so both + * offsets are given. + * + * cell page offset bit address meaning + * ------------ ----------- ----------- -------------------------------- + * POOL_PTR +0x00 0x01810100 bit pointer to the 244 KB pool + * POOL_FREEB +0x20 0x01810120 heap free bytes before malloc + * POOL_FREEA +0x40 0x01810140 heap free bytes after malloc + * VID_ALIVE +0x60 0x01810160 heartbeat while the loop runs + * RX_OK +0x80 0x01810180 frames accepted by fp_parse + * RX_BAD +0xA0 0x018101A0 frames rejected by fp_parse + * RX_LAST_LEN +0xC0 0x018101C0 payload_len of the last accepted + * RX_SLOT +0xE0 0x018101E0 slot of the last accepted (0=A) + * + * Debugger injection interface (Task 4; the harness plays the receive path): + * + * RX_SLOT_A +0x200 0x01810300 bit address of slot A + * RX_SLOT_B +0x220 0x01810320 bit address of slot B + * RX_READY_A +0x240 0x01810340 1 = slot A holds RX_LEN_A bytes + * RX_LEN_A +0x260 0x01810360 bytes in slot A + * RX_READY_B +0x280 0x01810380 1 = slot B holds RX_LEN_B bytes + * RX_LEN_B +0x2A0 0x018103A0 bytes in slot B + * + * The RX_READY_x and RX_LEN_x cells are NOT part of the wire protocol. In the real + * transport the LANCE receive interrupt fills a slot and raises that slot's + * 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. + * --------------------------------------------------------------------------- + * + * K&R C only. The 1987 toolchain in build-stream.sh compiles this header, so + * there are no ANSI prototypes, no '//' comments and no 'const' (romapi.h is + * written the same way): the parameter lists live in the comments and in the + * definitions in frame_parse.c. + * + * 8.3 names. The 1987 preprocessor cannot open an 11-character base name + * (Task 3 hit this with VIDSTREAM.C), so this file is also reachable as + * frmpsr.h -- a symlink to it -- and build-stream.sh stages it as FRMPSR.H. + * Host compiles may use either name. + */ +#ifndef FRAME_PARSE_H +#define FRAME_PARSE_H + +#define FP_HEADER_SIZE 16 /* bytes of fixed header before the payload */ +#define FP_PREFIX_SIZE 12 /* header bytes the CRC covers, before payload */ +#define FP_CRC_OFF 12 /* offset of the stored crc16, low byte first */ + +#define FP_MAGIC0 0x56 /* 'V' */ +#define FP_MAGIC1 0x53 /* 'S' */ +#define FP_VERSION 1 + +#define FP_KIND_STRIP 1 +#define FP_KIND_STATUS 5 + +/* Marker page cells; see the table above. */ +#define MARK_PAGE 0x01810000 +#define MARK_CELL(n) (MARK_PAGE + (n) * 8) + +#define POOL_PTR MARK_CELL(0x20) +#define POOL_FREEB MARK_CELL(0x24) +#define POOL_FREEA MARK_CELL(0x28) +#define VID_ALIVE MARK_CELL(0x2c) + +#define RX_OK MARK_CELL(0x30) +#define RX_BAD MARK_CELL(0x34) +#define RX_LAST_LEN MARK_CELL(0x38) +#define RX_SLOT MARK_CELL(0x3c) + +#define RX_SLOT_A MARK_CELL(0x60) +#define RX_SLOT_B MARK_CELL(0x64) +#define RX_READY_A MARK_CELL(0x68) +#define RX_LEN_A MARK_CELL(0x6c) +#define RX_READY_B MARK_CELL(0x70) +#define RX_LEN_B MARK_CELL(0x74) + +/* What a valid frame told us. payload_off is FP_HEADER_SIZE on success. */ +typedef struct { + unsigned int kind; + unsigned int frame_no; + unsigned int rows; + unsigned int stretch; + unsigned int payload_len; + unsigned int payload_off; +} fp_header; + +/* + * int fp_parse(unsigned char *buf, unsigned int len, fp_header *out) + * + * 1 and *out filled when buf[0..len) is a well-formed frame, 0 otherwise. + * A frame is well formed when len is at least FP_HEADER_SIZE, the magic and + * version match, len is at least FP_HEADER_SIZE + payload_len, and the stored + * crc16 equals the CCITT-FALSE CRC over buf[0..11] and the payload. Bytes + * beyond the frame are ignored: payload_len is authoritative (the transport + * hands us exactly what arrived, and a tail is not part of the frame). + */ +int fp_parse(); + +#endif /* FRAME_PARSE_H */ diff --git a/harness/udpstream/frmpsr.h b/harness/udpstream/frmpsr.h new file mode 120000 index 0000000..dc9472e --- /dev/null +++ b/harness/udpstream/frmpsr.h @@ -0,0 +1 @@ +frame_parse.h \ No newline at end of file diff --git a/harness/udpstream/tests/test_frame_parse.c b/harness/udpstream/tests/test_frame_parse.c new file mode 100644 index 0000000..2c7cd62 --- /dev/null +++ b/harness/udpstream/tests/test_frame_parse.c @@ -0,0 +1,167 @@ +/* + * test_frame_parse.c -- host unit test for the receive-side frame check. + * + * cc -std=c89 -Wall -o /tmp/tfp harness/udpstream/tests/test_frame_parse.c \ + * harness/udpstream/frame_parse.c && /tmp/tfp + * + * The frame under test is a GOLDEN VECTOR, not something this test or + * frame_parse.c computed: the bytes in `golden` came from frame.py, the + * sender's implementation of the same spec. That is what makes the test a + * cross-check of the C parser rather than a tautology: if the C CRC, the + * field offsets or the coverage (header bytes 0..11 plus the payload, never + * the CRC field) were wrong, the golden frame would be rejected. + * + * Regenerate the vector with: + * + * cd harness/udpstream && python3 -c ' + * import frame + * pl = bytes((i*7+3) & 0xFF for i in range(80)) + * d = frame.pack_header(1, 0x1234, len(pl), 1, 8, payload=pl) + pl + * print(len(d)); print(", ".join("0x%02X" % b for b in d))' + * + * (kind 1 = KIND_STRIP, frame_no 0x1234, rows 1, stretch 8, payload_len 80.) + */ + +#include +#include + +#include "../frame_parse.h" + +static int checks; +static int failures; + +static void check(int cond, const char *what) +{ + checks++; + if (!cond) { + failures++; + printf("FAIL: %s\n", what); + } +} + +static const unsigned char golden[] = { + 0x56, 0x53, 0x01, 0x01, 0x34, 0x12, 0x50, 0x00, + 0x01, 0x08, 0x00, 0x00, 0xB3, 0x3C, 0x00, 0x00, + 0x03, 0x0A, 0x11, 0x18, 0x1F, 0x26, 0x2D, 0x34, + 0x3B, 0x42, 0x49, 0x50, 0x57, 0x5E, 0x65, 0x6C, + 0x73, 0x7A, 0x81, 0x88, 0x8F, 0x96, 0x9D, 0xA4, + 0xAB, 0xB2, 0xB9, 0xC0, 0xC7, 0xCE, 0xD5, 0xDC, + 0xE3, 0xEA, 0xF1, 0xF8, 0xFF, 0x06, 0x0D, 0x14, + 0x1B, 0x22, 0x29, 0x30, 0x37, 0x3E, 0x45, 0x4C, + 0x53, 0x5A, 0x61, 0x68, 0x6F, 0x76, 0x7D, 0x84, + 0x8B, 0x92, 0x99, 0xA0, 0xA7, 0xAE, 0xB5, 0xBC, + 0xC3, 0xCA, 0xD1, 0xD8, 0xDF, 0xE6, 0xED, 0xF4, + 0xFB, 0x02, 0x09, 0x10, 0x17, 0x1E, 0x25, 0x2C +}; + +#define GOLDEN_LEN ((unsigned int) sizeof golden) +#define GOLDEN_PLEN 80 + +static unsigned char buf[256]; +static fp_header h; + +/* Copy the golden frame into the mutable buffer and return its length. */ +static unsigned int load(void) +{ + memcpy(buf, golden, sizeof golden); + return GOLDEN_LEN; +} + +int main(void) +{ + unsigned int len; + int rc; + + /* ---- the well-formed frame is accepted, with every field read ---- */ + len = load(); + memset(&h, 0, sizeof h); + rc = fp_parse(buf, len, &h); + check(rc == 1, "golden frame accepted"); + check(h.kind == FP_KIND_STRIP, "kind == KIND_STRIP"); + check(h.frame_no == 0x1234, "frame_no == 0x1234 (little-endian)"); + check(h.rows == 1, "rows == 1"); + check(h.stretch == 8, "stretch == 8"); + check(h.payload_len == GOLDEN_PLEN, "payload_len == 80"); + check(h.payload_off == FP_HEADER_SIZE, "payload_off == 16"); + + /* ---- a flipped payload byte fails the CRC ---- */ + len = load(); + buf[FP_HEADER_SIZE + 40] = (unsigned char) (buf[FP_HEADER_SIZE + 40] ^ 0x01); + check(fp_parse(buf, len, &h) == 0, "flipped payload byte rejected"); + + /* ---- a flipped header byte inside the CRC prefix fails too ---- */ + len = load(); + buf[8] = (unsigned char) (buf[8] + 1); + check(fp_parse(buf, len, &h) == 0, "flipped header byte rejected"); + + /* ---- short buffers: one byte short of the frame, and below the header ---- */ + len = load(); + check(fp_parse(buf, GOLDEN_LEN - 1, &h) == 0, + "truncated payload (one byte short) rejected"); + check(fp_parse(buf, FP_HEADER_SIZE - 1, &h) == 0, + "buffer shorter than the header rejected"); + check(fp_parse(buf, 0, &h) == 0, "empty buffer rejected"); + + /* ---- wrong magic ---- */ + len = load(); + buf[0] = 'X'; + check(fp_parse(buf, len, &h) == 0, "bad magic byte 0 rejected"); + len = load(); + buf[1] = 'X'; + check(fp_parse(buf, len, &h) == 0, "bad magic byte 1 rejected"); + + /* ---- wrong version (0 and 2 both differ from 1) ---- */ + len = load(); + buf[2] = 0x00; + check(fp_parse(buf, len, &h) == 0, "version 0 rejected"); + len = load(); + buf[2] = 0x02; + check(fp_parse(buf, len, &h) == 0, "version 2 rejected"); + + /* ---- wrong stored CRC, either byte ---- */ + len = load(); + buf[FP_CRC_OFF] = (unsigned char) (buf[FP_CRC_OFF] + 1); + check(fp_parse(buf, len, &h) == 0, "corrupt crc low byte rejected"); + len = load(); + buf[FP_CRC_OFF + 1] = (unsigned char) (buf[FP_CRC_OFF + 1] + 1); + check(fp_parse(buf, len, &h) == 0, "corrupt crc high byte rejected"); + + /* ---- a header that understates payload_len cannot hide bad bytes ---- */ + len = load(); + buf[6] = 0x40; /* payload_len 0x40 instead of 0x50 */ + check(fp_parse(buf, len, &h) == 0, + "payload_len shorter than the sealed payload rejected"); + + /* ---- payload_len is authoritative: a tail in the slot is not the frame ---- */ + len = load(); + buf[GOLDEN_LEN] = 0xFF; + check(fp_parse(buf, GOLDEN_LEN + 1, &h) == 1, + "trailing byte past the frame ignored"); + + /* ---- a bare header (payload_len 0) is a valid frame ... + * + * 56 53 01 01 09 00 00 00 00 00 00 00 AA D1 00 00 -- frame.py's + * pack_header(1, 9, 0, 0, 0, payload=b""), whose CRC covers the 12 + * prefix bytes only. It pins the empty-payload case. + */ + { + static const unsigned char bare[] = { + 0x56, 0x53, 0x01, 0x01, 0x09, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xAA, 0xD1, 0x00, 0x00 + }; + + memcpy(buf, bare, sizeof bare); + memset(&h, 0, sizeof h); + rc = fp_parse(buf, (unsigned int) sizeof bare, &h); + check(rc == 1, "bare header (payload_len 0) accepted"); + check(h.frame_no == 9, "bare header frame_no == 9"); + check(h.payload_len == 0, "bare header payload_len == 0"); + + /* and one byte missing is not a header */ + check(fp_parse(buf, (unsigned int) sizeof bare - 1, &h) == 0, + "bare header, one byte short, rejected"); + } + + printf("frame_parse: %d/%d ok\n", checks - failures, checks); + return failures != 0; +} diff --git a/harness/udpstream/vidstream.c b/harness/udpstream/vidstream.c index 8f11bba..0c56792 100644 --- a/harness/udpstream/vidstream.c +++ b/harness/udpstream/vidstream.c @@ -1,38 +1,55 @@ /* - * vidstream.c -- terminal side of the VT1200 video stream (startup skeleton). + * vidstream.c -- terminal side of the VT1200 video stream. * - * Task 3 only: claim the working set with ONE malloc at startup and publish - * where it landed. The receive ring (Task 4) and the blit loop (Task 5) build - * on the pointer this file leaves behind. + * Task 3 claimed the working set with ONE malloc at startup and published + * where it landed. Task 4 adds the receive side: two 24 KB frame slots at the + * head of that pool, and a poll loop that hands each completed slot to + * fp_parse() (frame_parse.c, shared with the host unit test) and counts what + * it accepted. Task 5 replaces the accept path's placeholder with the blit. * - * Marker layout. The design plan names markers as 0x01810020, 0x01810024, - * 0x01810030, ... Those cannot be read literally as bit addresses: the - * TMS34010 is bit-addressed, so 0x01810024 is FOUR BITS after 0x01810020, - * inside the same 32-bit cell. They are byte offsets into the marker page - * (bit 0x01810000, byte 0x00302000), which is the only reading under which - * the plan's 4-byte spacing holds. So a named marker 0x018100NN lives at + * Pool layout inside the single allocation (POOL_BYTES = 249856 = 244 KB): * - * bit 0x01810000 + NN * 8 + * +0x00000 frame slot A 24576 bytes + * +0x06000 frame slot B 24576 bytes + * +0x0C000 framebuffer 163840 bytes (scheme A, Task 5) + * +0x34000 dictionary 32768 bytes + * +0x3C000 slack 4096 bytes * - * (POOL_PTR = 0x01810100, POOL_FREE before/after = 0x01810120/0x01810140). - * vidstream.lua uses the same rule. See the task-3 report. + * 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 + * blit will read. Neither slot is ever split across a ring wrap: the two ring + * entries' descriptors are re-armed per slot at frame start. + * + * Marker layout: see the table in frame_parse.h. Markers are 32-bit cells on + * the byte-aligned page at bit 0x01810000, 0x20 bits (4 bytes) apart. The + * design plan names them 0x01810020, 0x01810024, ... but the TMS34010 is + * bit-addressed, so 0x01810024 is four BITS after 0x01810020 -- inside the + * same cell. The plan's numbers are byte offsets into the page; the rule is + * + * marker named 0x018100NN -> bit 0x01810000 + NN * 8 + * + * (POOL_PTR = 0x01810100, ...). vidstream.lua uses the same rule. * * K&R C only: no prototypes, no '//', declarations before statements. */ #include "romapi.h" #include "poolcfg.h" /* POOL_BYTES, written by build-stream.sh */ - -#define MARK_PAGE 0x01810000 /* bit address of the marker page */ -#define POOL_PTR (MARK_PAGE + 0x20 * 8) /* u32 bit pointer to the pool */ -#define POOL_FREEB (MARK_PAGE + 0x24 * 8) /* u32 heap free bytes, before */ -#define POOL_FREEA (MARK_PAGE + 0x28 * 8) /* u32 heap free bytes, after */ -#define VID_ALIVE (MARK_PAGE + 0x2c * 8) /* u32 heartbeat while spinning */ +#include "frmpsr.h" /* frame_parse.h under an 8.3 name; see that file */ #define HEAP_FREE 0x1E8BA0 /* firmware free-byte counter, IN BITS */ #define MARK 0x01A00000 /* .text must end before this bit address */ #define STATIC_BYTES 249856 /* the designed working set, 244 KB */ +/* + * The two frame slots. SLOT_BYTES is the plan's 24 KB: a 128-row strip frame + * (128 x 160 B + 16 B header = 20,496 B = 15 packets) fits, and so does any + * smaller strip. Slot addresses are bit addresses, because that is what a + * pointer holds in this toolchain (see harness/c-hello/README.md: "pointers + * are bit addresses"). + */ +#define SLOT_BYTES 24576 + /* * 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. @@ -60,6 +77,68 @@ static unsigned int heap_bytes() return *(unsigned int *)HEAP_FREE / 8; } +/* + * Handle one slot if the receive path has raised its ready flag. + * + * In the real transport the LANCE receive interrupt fills the slot and sets + * the flag; MAME cannot hand this emulated LANCE an Ethernet frame (the + * driver attaches no network interface -- ruling R11), so in the harness + * vidstream.lua writes the slot and sets the flag instead. The payload does + * not care which of the two did it. + * + * `len` is the number of bytes the transport actually delivered. fp_parse() + * rejects a frame whose payload_len wants more than that, which is the rule + * that makes payload_len plus the received length authoritative rather than a + * descriptor field. + */ +static void rx_poll(slot) +unsigned int slot; +{ + unsigned int readyp, lenp, basep, base, len, ready; + fp_header h; + int ok; + + if (slot == 0) { + readyp = RX_READY_A; + lenp = RX_LEN_A; + basep = RX_SLOT_A; + } else { + readyp = RX_READY_B; + lenp = RX_LEN_B; + basep = RX_SLOT_B; + } + + ready = *(unsigned int *)readyp; + if (ready == 0) { + return; + } + + base = *(unsigned int *)basep; + len = *(unsigned int *)lenp; + + ok = 0; + if (base != 0) { + ok = fp_parse((unsigned char *)base, len, &h); + } + + if (ok) { + *(unsigned int *)RX_OK = *(unsigned int *)RX_OK + 1; + *(unsigned int *)RX_LAST_LEN = h.payload_len; + *(unsigned int *)RX_SLOT = slot; + /* Task 5 blits the accepted frame from here: h.payload_off + * bytes into the slot, h.rows rows of 160 bytes (1280 pixels, + * 1 bpp, MSB first), h.stretch scanlines each. Nothing is + * drawn for a rejected frame, so a corrupt frame can never + * reach VRAM. */ + } else { + *(unsigned int *)RX_BAD = *(unsigned int *)RX_BAD + 1; + } + + /* Release the slot last: the checks read the counters, and a slot + * freed before its counter moved would look like a dropped frame. */ + *(unsigned int *)readyp = 0; +} + int main() { unsigned int before, after, ptr; @@ -69,6 +148,20 @@ int main() *(unsigned int *)POOL_FREEB = 0; *(unsigned int *)POOL_FREEA = 0; + /* Receive record: counters and the two slots are cleared before anything + * is published, so a reader never sees a stale ready flag or a slot + * address that has no allocation behind it. */ + *(unsigned int *)RX_OK = 0; + *(unsigned int *)RX_BAD = 0; + *(unsigned int *)RX_LAST_LEN = 0; + *(unsigned int *)RX_SLOT = 0; + *(unsigned int *)RX_SLOT_A = 0; + *(unsigned int *)RX_SLOT_B = 0; + *(unsigned int *)RX_READY_A = 0; + *(unsigned int *)RX_LEN_A = 0; + *(unsigned int *)RX_READY_B = 0; + *(unsigned int *)RX_LEN_B = 0; + before = heap_bytes(); ptr = malloc_(POOL_BYTES); /* malloc_ takes BYTES, returns BITS */ after = heap_bytes(); @@ -82,9 +175,16 @@ int main() *(unsigned int *)POOL_FREEB = before; } *(unsigned int *)POOL_FREEA = after; - *(unsigned int *)POOL_PTR = ptr; /* publish last: readers see a complete record */ + + /* Slot B follows slot A in the same pool. Both are published before + * POOL_PTR so the harness can find them from either one. */ + *(unsigned int *)RX_SLOT_A = ptr; + *(unsigned int *)RX_SLOT_B = ptr + SLOT_BYTES * 8; + *(unsigned int *)POOL_PTR = ptr; /* publish last: a complete record */ for (;;) { *(unsigned int *)VID_ALIVE = *(unsigned int *)VID_ALIVE + 1; + rx_poll(0); + rx_poll(1); } }