The payload assembles KIND_DICT_CHUNK frames into a 32 KB dictionary at
POOL_PTR + 2 * SLOT_BYTES and learns the tile kinds:
* frame_parse.h gains FP_KIND_TILES_FULL (2), FP_KIND_TILES_DELTA (3) and
FP_KIND_DICT_CHUNK (4), exactly frame.py's KINDS table, plus the marker
cells DICT_READY/DICT_BYTES/DICT_BAD and TILES_DROP. A host test parses
the header and compares it with KINDS so the two cannot drift apart.
* dict_chunk() enforces an ascending, contiguous offset (no gap, overlap,
duplicate or reorder; a mid-entry offset only as a continuation), a chunk
that does not overrun the last entry, and no chunk after DICT_READY. A
refused chunk leaves the prefix alone. Readiness is exactly 32768 bytes
(256 whole entries), so a short upload never becomes ready.
* The dispatcher routes STRIP to the blit, DICT_CHUNK to the assembler, and
refuses TILES_FULL/TILES_DELTA without painting, counting them in
TILES_DROP (Task 4 adds the tile blit).
* A compile-time assertion refuses a POOL_BYTES that cannot hold the
dictionary; the 1987 preprocessor does not fold macro arithmetic in #if,
so it is a defined array whose negative length fails the build.
* VT1200_MODE=dict injects both tile kinds before the dictionary, 23
contiguous chunks with a duplicate offset, a gap and an overrunning tail
mixed in, and reads the stored 32 KB back byte for byte.
Ruling R14: the frame CRC now covers header bytes 0..11 only. A
payload-wide CRC cost the terminal ~139 instructions per payload byte
(the parser, not the wire or the blitter, was the frame-rate ceiling),
while every datagram already carries an Ethernet FCS the LANCE checks
and the length bound catches truncation. frame.py, fp_parse and the
harness's own frame signer follow the new rule; a corrupted payload
byte is now ACCEPTED and a corrupted header byte rejected, stated
explicitly in both test suites, and the C golden vectors were
regenerated from frame.py.
Measured after: RX_READY_A write -> first RX_OK read is 1 emulated
frame for a 20,480-byte frame (84 before), which is the frame hook's
resolution.
Ruling R15: draw mode deletes its dump files before the run (wrapper
and Lua) so a failed run cannot be judged on the previous screen; a
wait that hits its cap reports "timed out waiting for the frame" and
the checker reports it as a timeout, not as pixel mismatches; the
checker asserts the VRAM canary word is 0xA5C3, so a payload that
stopped arming it fails instead of passing quietly.
rx mode now corrupts a header byte in slot B, because a payload flip is
accepted by design under R14.
One PIXBLT B,XY per source row paints the whole screen: DYDX names
`stretch` rows and SPTCH = 0 makes each of them read the same packed
source row, so the vertical stretch costs no extra CPU work.
vram_b.asm carries the primitive (PSIZE=1, CONVSP/CONVDP=0x14,
PMASK=0, OFFSET=0x04000000, DPTCH=0x800, raster op 0) plus DINT/EINT
thunks; vidstream.c masks interrupts for the burst (spec 8.9), refuses
geometry the screen cannot hold, and counts an OS repaint through a
sentinel word in the unused right edge of the last screen row
(OS_TOUCH, 0x01810220).
MAME mode draw injects a 128-row frame (stretch 8 = 1024 screen rows,
ruling R3) and dumps the screen; vidstream_check.py compares all 1024
rows against the bytes actually delivered. RED: 0/1024 rows matched.
GREEN: 1024/1024, RX_OK=1, OS_TOUCH=0.
Fixes a latent harness bug found on the way: `bpclear <address>` takes
a breakpoint INDEX, so the idle-loop breakpoint was never cleared; once
a burst masked interrupts long enough, its stale action fired again and
re-entered main (a second malloc, counters back to 0).
vram_blit_ copies a 1-bpp rectangle from RAM to VRAM. The manual has no L,L
variant, so the destination is XY and the hardware forms the address:
B0=SADDR B1=SPTCH B2=DADDR(XY) B3=DPTCH=0x800 B4=OFFSET=0x04000000
B7=DYDX PSIZE=1 PMASK=0 CONVSP=CONVDP=0x14 CONTROL raster-op=0
SPTCH is a parameter because 0 makes every row read the same source address,
which is how one expanded row can be stamped eight times down the screen.
The .bss region in the linker command grows from 0x40000 to 0x200000 address
units (32 KB -> 256 KB) so a 1280x1024 1-bpp frame buffer (163840 B) fits; the
previous .coff had been built from sources that were not yet committed.
bench-sweep.sh gains its executable bit.
Measured on the unit: byte stores to DRAM 13.92 us, to VRAM 13.93 us -- the
same, so the display arbiter is not involved. In MAME 16-bit stores halve the
time exactly (863 ms vs 1588 ms per frame), i.e. the cost is per store
instruction, not per byte.
Against the graphics engine (FILL L 0.40 us/word, PIXBLT L,XY 0.62 us/word)
the CPU is 25-35x slower per word. So frames must never be built with CPU
stores: the LANCE can chain descriptors so a 160 KB frame lands contiguously
in a pre-armed buffer with no CPU copy, and one PIXBLT L,XY puts it on screen.
Budget: full frame ~5 fps (129 ms wire + 60 ms blit), 640x512 ~21 fps,
320x240 ~80 fps. Report section 75; cmd 10 (PIXBLT from RAM) and cmd 11
(16-bit store control) are the benchmarks.
cmd 9's first version wrote to 0x01A10000 (bits), chosen as "above MARK, below
.bss". On the unit that was not usable RAM: no reply in 120 s and the machine
stopped answering ping and rdebug -- one power cycle.
The target is now `static unsigned char drambuf[512]`, placed by the linker
inside .bss, which the payload already relies on for its labels and strings.
Loop and store count are unchanged, so the per-store figure stays comparable.
Report 74.5 records it.
cmd 8 alternates 0xFFFF/0x0000 per frame so the rate is visible: 300 frames
in 9.775 s on the unit, 32.6 ms each, 30.69 fps sustained.
cmd 9 is the same byte-store loop aimed at a DRAM buffer, to measure the CPU's
store rate without the VRAM arbiter and size a frame-expansion stage.
vtcmd.py --dram selects it.
Report 74.3 records the independent Ghidra pass over the ROM: the firmware
sets CONVSP/CONVDP=0x14 (pitch 0x800 bits = 256 B), PSIZE=1, PMASK=0 and
OFFSET=0x04000000 for XY addressing -- the same values vram.asm uses -- and
fills with FILL_L chunked at 0x7FC0 pixels because DYDX is 15 bits per axis.
There is no VBLT and no wait-state/arbitration register in the ROM, so the
12 us/byte CPU store cost is inherent to this board, not a setting.
Byte stores to VRAM measure 2284 ms per 1280x1024 frame on the real unit
(0.44 fps) -- about a scanline period per byte, i.e. almost no access through
the arbiter. vram.asm now drives the GSP's array engine instead: FILL L with
DADDR/DPTCH/DYDX/COLOR1 (B2/B3/B7/B9), PSIZE=1, PMASK=0 and PPOP=0, per the
TMS34010 User's Guide's implied-operand table, with CONTROL read-modify-written
so CD/RM/RR survive.
Measured: 32.7 ms/frame, 30.6 fps on the unit; 27.1 ms in MAME (74x over byte
stores there, 70x on hardware). 2,508,224 destination words/s = 0.40 us/word.
Consequence for video: a full uncompressed frame is wire-limited at 8 fps
(163840 B at 1.27 MB/s); a small source expanded on the device is limited by
the ~33 ms transfer, i.e. ~30 fps full screen if the expansion uses PIXBLT.
build-lance.sh assembles vram.asm and links VRAM.obj; MARK moved to 0x01A00000
because .text had come within 146 bytes of it.
Loading the COFF over rdebug pokes thousands of words one at a time, so how
long the payload takes to appear varies by tens of seconds -- a fixed sleep is
either wasteful or too short. The payload now broadcasts three frames the
moment its ring is armed: ethertype 0x88B6, 'K' 'S', opcode 0xFF, magic
"LANC". `vtcmd.py wait` listens for it; measured at 8.7 s in MAME.
Two details found by measurement:
* The announce must use TX slot 0, because the chip transmits only from its
ring position -- arming slot 7 while the position is 0 never sends
anything (the first attempt did exactly that and was silently lost).
* After the announce the reply cursor advances to slot 1 to match the chip's
new position, so replies do not collide with the announce descriptor.
txfull then stays 0 across consecutive replies; before this it read 1.
test-osboot.sh ties the hand-back together with no sleep: listen, load, wait
for the announce, stats, osboot, wait for :771. Report sections 73.3/73.4.
osboot at 17:11:21, rdebug back at 17:12:01 (40 s), ping OK, and the VRAM
screenshot shows the Terminal Manager with a live display, the right MAC/IP/
LAT name and the clean-boot banner.
Same machine, same payload as the two failed reset-vector attempts: reboot
0/2 (POST, never returns), osboot 1/1 (OS cold start, 40 s). The display
returns before the network -- the operator could see the screen at 32 s while
port 771 was still closed.
Recorded as 1 of 1, not as reliable: section 72.3 already lists three
conclusions drawn from single observations, so a second run is needed before
this is the standard hand-back.
The reset vector is the wrong entry: it starts POST, which re-programs the
board window from a state a payload has already changed. The firmware has a
second entry, 0x3F5788C0, where the boot gate at 0x3F2042D0 branches when it
skips level 1. It begins with DINT, CSR0=STOP on the LANCE, and a board-window
re-program -- exactly what a takeover breaks.
MAME, from the LANCE access log: the payload's last access and the OS's first
are 130 us apart, then pc=3F578940 writes CSR0=0x0004 (STOP) and pc=3FDDCAE0
(the OS driver) re-initialises the chip, after which the OS keeps servicing it.
cmd 6 / vtcmd.py osboot. Unit run pending; the report section is 73.
The operator confirms attempt 1's recovery at 88 s was a manual power cycle,
not the payload. That makes the record 0 confirmed successes out of 2, and
the first reading of attempt 1 -- "black and hung, needs a power cycle" --
was right after all.
Three corrections are now recorded in section 72.3, because they are one
mistake made three times: "hung" from a single sample, then "works" from the
same sample after it recovered, then "works" again when that recovery was
attributed to the payload. The recovery had an external cause, which one
observation cannot tell apart from the payload working.
The Ghidra finding stands: the firmware has no software reboot, so there is
no supported entry to jump to. Untested hypothesis for the hang remains
that the reset path leaves the LANCE busy-mastering; CSR0_STOP before the
jump would be the single-variable test.
vtcmd.py reboot requires --force and now says plainly that it has never
worked and that a power cycle should be expected.
Attempt 1 (16:58:45) came back: port 771 reopened at 88 s, the unit pinged,
and a VRAM screenshot showed the Terminal Manager. Attempt 2 (17:04:15,
after a power cycle) never came back -- no ping, no rdebug at 130 s, monitor
asleep, operator reports it stuck.
The previous commit recorded attempt 1 as "it works, no power cycle needed".
That was a single sample and attempt 2 falsified it. Section 72.3 now records
both runs as a table, the 1-of-2 rate, and the two corrections made on the way
(first "hung", then "works").
Untested hypothesis: the reset path does not reset the LANCE, and the payload
hands over with bus-master DMA enabled; an in-flight transfer or pending
interrupt may leave the board-window handshake at 0xC00000B0/0xC0000100
waiting forever. The single-variable test would be CSR0_STOP before the jump.
vtcmd.py reboot requires --force again and states the measured rate.
Issued while the payload owned the LANCE: the reply arrived, control went to
0x3F200040, the screen went black -- and the OS was back 88 s later. Port 771
reopened on its own, the unit answered ping, and a VRAM screenshot shows the
Terminal Manager with the right MAC/IP/LAT name and the clean-boot
"Ethernet Port Error - 24" banner. A taken-over unit can therefore hand
itself back without a power cycle.
An earlier reading of the same run reported "black and hung, needs a power
cycle". That was a single observation taken inside the ~90 s POST window.
The black screen is the reset path reprogramming the board/video window
(0xC00000B0, 0xC0000100) and not re-enabling the display until POST ends.
vtcmd.py reboot now says how long it takes and warns not to power cycle
during it, instead of refusing to run.
lance_cmd.c is a payload that drives the LANCE with its own code (no ROM
calls) and answers a command protocol on ethertype 0x88B6, so a unit whose
chip we have taken over can still be inspected:
read (peek), stats, poke, jump, reboot
The reply path was dead for a while and the cause was not the chip: the RX
ring had been grown to 16 entries (0x3020..0x309F) while TXR stayed at
0x3060, so all eight TX descriptors lay inside the RX ring. The chip's RX
write-back set OWN in what we believed were TX descriptors, and every reply
was discarded by transmit_poll() as "owned but not start of packet"
(r 00309A 8000 / w 00309A 0000 in the DMA log). TX ring moved to 0x30C0,
with a compile-time guard so the rings cannot overlap again.
Receive path aligned with the three drivers available locally (4.4BSD
pmax if_le.c, NetBSD am7990.c, Linux 7990.c):
* STP *and* ENP required on one descriptor, else the frame is counted as
chained and dropped rather than parsed from the middle of a buffer;
* the 4-byte FCS the chip writes into the buffer is subtracted, as all
three do (rmd3 - 4);
* re-arm restores RMD2 and clears RMD3, ownership written last;
* TX kick is TDMD|INEA (no reference driver re-writes STRT);
* a TX slot still owned is skipped instead of clobbered.
RX buffers are 1526 B so a full 1514-byte MTU frame fits one descriptor;
1500 B would have chained it.
vtcmd.py drives all of it; bench-sweep.sh sweeps size x rate. Measured on
the real VT1200 (10 Mbit half-duplex hub): 856-858 fps of 1514-byte frames
at 99.9-100% delivery, i.e. wire limited, and 1500 fps of 60-byte frames at
99.8-99.9%. NETWORK_REPORT.md sections 68-72 record the evidence, including
the 64-descriptor build that died with an illegal opcode on the unit and was
reverted.
Panel read off the unit after loading lance_bcast.coff with the host sending
0x88B5 broadcasts: TXD=5, RXF=5, ET=0x88B5, CSR0=0x0073 (STRT|TXON|RXON|INEA)
and REST=0x0073. First exchange with a real peer using only our own driver
code.
Two display bugs found by reading it: the glyph table had no 'X' (so TXD/RXF
printed as 'T D'/'R F') and the three MAC words were drawn on top of each
other. Both fixed: X added, MAC on three rows, REST moved to y=878.
lance_bcast.c drives the chip entirely on its own -- init block, PADR/LADRF,
eight TX and eight RX descriptors, INIT/IDON/STRT, TDMD, completion polling --
and test-bcast.sh runs it in MAME and reports:
TXD=6 RXF=5 ET=0x88B5 source MAC 0xC6FC ok=1 PASS
The tap capture separates the directions: six 60-byte 0x88b5 broadcasts from
08:00:2b:16:4b:5a (our payload) and 49 from the host. RXF counts host frames
(early stop at five) and the recorded source MAC is the host's.
Fixes behind it: per-frame TX descriptor (the chip advances its own ring
position), cfg-net needed for a tap to exist at all, pkill -x instead of -f,
and two bit-vs-byte addressing bugs.
Normal (promiscuous) mode instead of loopback: programs its own init block,
PADR and LADRF, arms eight RX descriptors, transmits six 60-byte broadcast
frames (ethertype 0x88B5) through its own TX ring with TDMD kicks, then counts
received frames and records the last source MAC and ethertype.
Everything is the payload's own code -- CSR accesses, init block, rings,
descriptors, DMA results. Panel: TXD (transmitted), RXF (received), ET (last
ethertype), MAC (last source), CSR0, REST.
The terminal shows 'Ethernet Port Error - 24' after a clean boot while its port
works, so it is the firmware's own Ethernet self-test result (which also fails
in MAME per LANCE_LOOPBACK_REPORT), not damage from the payload.
Consequences: the banner is not evidence about the takeover, and the 'needs a
power cycle' claim now rests only on the runs that crashed; for the REST=0x0073
build the network was never checked after the run.
They were drawn at y=850, overlapping the REST text row (846-867), which is
what shows as a stray box over REST in the unit photo. The text panel already
reports OK as YES/NO.
Photo images/20261030_155855.JPG shows CSR0/CSR1=0x07B3, TMD1=0x8300,
RMD1=0x0300, MCNT=0x001C, OK=YES (loopback reproduced with the text build) and
REST=0x0073 = INIT|STRT|TXON|RXON|INEA, i.e. the chip is running again after
the payload's 0x7F41 -> 0x0042 hand-back.
The OS however shows 'Ethernet Port Error - 24' and stays off the network, so
the recovery failure is in the firmware's driver state, not in the LANCE. A
takeover run therefore still costs a power cycle.
The text panel grew .text past MARK and the payload overwrote its own code
with cell() writes (illegal opcode on the unit). The build now parses
build/lance.map and aborts if the end of .text reaches MARK, printing the
remaining gap.
.text went from 0x018C6140 to 0x018CACD0, so the marker cells at 0x018C8000
landed inside the payload's own code; cell() then overwrote instructions and
the CPU faulted with an illegal opcode before drawing anything.
MARK moved to 0x018D0000 (~2.6 KB past the end of .text).