Two knobs for the 60 fps attempt, both measured on Bad Apple:
clip_from_video.py --block N averages each NxN block, thresholds the average and
draws the block flat. The point is destination words: at 60 fps the clip changes
80 tiles/frame unstylised, 54 at 8x8 and 29 at 16x16, against the ~44-tile budget
that MAME's ~3.4 us/word allows in 16.7 ms. A side effect worth knowing: with
16-pixel blocks every 32x32 tile is one of exactly 2^4 combinations, so the 256
entry dictionary represents the stylised clip with zero error (measured: 16
entries used, "reconstruction 0 bit(s)", PSNR inf, mean delta 141 B against 496 B
for the unstylised clip).
vidsend.py --keyframe-every N exposes the cadence that TileSender already had as
a constructor argument. A keyframe repaints all 1,280 tiles (~264 ms of MAME CPU
at the measured 0.22 ms/tile), so the default 60-frame cadence costs ~4.4 ms on
average per frame at 60 fps -- a tenth of the budget spent on repairs. The
13,167-frame stylised clip ships 220 keyframes at the default; 600 measured 6 in
the same window.
Measured on the stylised 60 fps cache (13,167 frames, 16 dictionary entries):
--fps 60, default cadence 643 of 805 accepted (80%), drops=8
--fps 45 484 of 520 accepted, drops=0, sender PASS
--fps 60, --keyframe-every 600 451 of 519 accepted, drops=5
So the stylised clip roughly doubles acceptance against the same 60 fps send
(40% -> 80%) but MAME still caps the clean rate around 36-45 fps.
Spec 8.3's hoist, and the 60 fps work showed it was the per-tile cost that
mattered, not the per-frame one.
Every tile_blit() call used to save 8 B registers, load B1/B3/B4/B7/B8/B9 and
write 11 I/O registers (PSIZE, CONVSP, CONVDP, PMASK, the CONTROL read-modify-
write and its read-back) -- all constants. The user's guide marks only SADDR
and DADDR changed by PIXBLT, and the TMS34010 agent's reading of it confirms
B1/B3 survive, so those two are the only ones a per-tile call has to reload.
vram_tile.asm now exports tile_init_() (the constants, once per frame, called
from paint_tiles/paint_delta) and a tile_blit_() that is setf, two pushes, two
register loads, pixblt, two pops, rets.
Measured on the real 140-tile deltas at a 60 fps send, same window, terminal's
own status frames:
before the hoist 162 accepted
after the hoist 231 accepted (+43%)
and the one-tile stream (where tile cost is nil) already runs at 60 fps with
DINT: 600 frames sent at 59.8 fps, 579 accepted (96.5%), drops=0.
Pixel verdicts unchanged: modes tiles, delta and replay all RESULT PASS with
tile_check; 155 unit tests pass.
What the remaining gap is, for the next reader: MAME's PIXBLT moves about
3.4 us per destination word (0.22 ms per 32x32 tile, measured from the accepted
rate) against the unit's measured 0.40 us/word for FILL. A 140-tile delta is
8,960 words, i.e. ~30 ms of MAME CPU against a 16.7 ms frame -- so 60 fps for
this content is bounded by the emulator's pixel engine, not by the design.
Whether the unit can do it turns on the one number nobody has measured there:
PIXBLT B,XY's per-word rate (spec 10, risk table).
Spec 11's first task: decide from the clip, not from assumptions, whether the
raw-tile scheme (no dictionary, tag + bitmap per changed tile) fits a 60 fps
frame. measure_wire.py extracts at 1280x960/60 fps with the hard threshold the
design requires, diffs each frame on the 40x30 picture grid, and totals the
payload a TILES_DELTA would carry:
4 + 3*count + 128*m
Full clip, 13,167 frames: mean 16,049 B/frame, p50 14,755, p95 33,353, max
51,523; 22.5% of frames exceed the 21 KB one-frame budget; 87% of changed tiles
are neither solid black nor white, so they carry a 128-byte bitmap (the design
assumed a third); mean 140 changed tiles/frame, max 1,173 at a cut. Link duty
at 60 fps would be 72% of the measured 1.27 MB/s.
Two consequences for the design, recorded here rather than discovered later:
the raw-tile scheme is borderline rather than comfortable, and the tile churn at
60 fps (140) is nearly the same as at 15 fps (165) -- motion between adjacent
60 fps frames is mostly sub-tile, so sending faster does not divide the wire by
four.
Spec 8.9 decides the payload may kill the resident OS; this is the first half of
that, and it is a rate fix as much as a robustness one.
With the OS still ticking, the payload's loop is preempted and the machine
accepts ~15 frames/s **whatever the payload size**: a 5-byte one-tile delta
measured the same as a 140-tile one (195 frames in 13.4 s, MAME at 100% speed,
frames_rx from the payload's own status frames). That is scheduling, not
decode -- the loop itself runs ~20k iterations/s.
VID_OWN_CPU (default 1) takes one DINT before the main loop and makes vid_eint_()
a no-op, so nothing preempts the LANCE poll. Measured on the same one-tile
stream:
OS ticking (before) 15.0 fps accepted, 400 frames in 15.4 s
DINT at startup 24.0 fps accepted, 385/400 frames, drops=0
and, paced differently, 31.5 fps with 387/400 -- the sender, not the terminal,
is now the throttle (its socket blocks once the tap's drain rate is exceeded)
Consequence stated where it is made: the OS's drivers stop being serviced, so
its console and network stack are gone for good. The payload owns the machine
once it takes the LANCE either way. The harness reaches the payload through the
debugger, not the OS, so the replay modes are unaffected -- verified: modes
tiles, delta and replay all RESULT PASS with their pixel checkers after the
change.
Set VID_OWN_CPU=0 at build time to reproduce the old burst-EINT behaviour for a
comparison run.
Two faults the 60 fps work exposed, both in the delivery tooling:
* `demo.sh --no-load` waited for the terminal to answer ping. A loaded payload
owns the LANCE and stops the OS's stack with it, so that gate can never pass
once the payload is running -- the recovery path documented for "already
jumped" deadlocked for its full 180 s. The gate now runs only when this
invocation is going to load the payload.
* `rdebug_load.py` used a 5 s socket timeout and no retry, so a readback peek
that timed out on a freshly booted OS failed a load whose pokes had already
been acknowledged. Timeout is 15 s (--timeout) and the readback retries once
before it is believed.
Tests: the retry path is pinned with a fake remote that times out on the first
peek (one timeout, then the successful pass over every chunk) and one that never
answers (it must raise rather than loop). 9 tests in harness/badapple pass.
demo.sh loaded (and jumped) the payload before it ever looked at --tiles, so a
wrong path -- the frames directory rather than the cache make_tiles.py writes --
only surfaced after the machine had been handed to a payload that never returns,
forcing a reboot to retry. It now checks dictionary.bin/stream.bin first, names
the frames-vs-cache confusion when the directory holds raw frames, and exits 2
without touching the network. The README's failure table gains that row and the
--no-load recovery for an instance that has already been jumped.
The setup section said 'a clip cache' without saying where a clip comes from,
what it costs, or how to tell the result is sane. Adds a Data preparation
section: the public sources (and why an upscale is not better), the frame
extraction (3,292 frames, 489 MB, ~12 s, LSB-first), the cache build (~2 min
47 s, four files, 6.1 MB), and the make_tiles summary to compare against --
including that a saturated 256-entry dictionary and 24 dB PSNR are expected, not
a failure.
The harness forced the VR150 monitor switch, so the firmware wrote the OS
screen at 1024 px / 128-byte rows. VT1200_VRAM_REAL then re-read that
framebuffer at the VR319 pitch, which sheared the OS and wrapped the right
quarter of every row.
Model VR319 (switch 0x60) by default -- the real unit's mode -- so the
firmware itself programs 1280x1024 with a 256-byte row pitch (CONVDP=0x14).
The renderer's pitch follows VT1200_MONITOR, and the destination span now
comes from HEBLNK..HSBLNK instead of x=0, placing the active 1280 px at
x=336 in MAME's 1696-wide raster.
Verified: clean Terminal Manager at frame 600, legible faithful diagnostics
menu and level-1 error screens, tile mode PASS with tile_check 1280/1280
tiles, tablet/mouse pixel-identical. VT1200_MONITOR=VR150 reproduces the
old geometry; the regression baseline moves 198 -> 199 (2 PCs) and is
recorded in harness/mame/README.md.
A new folder so the demo can be reproduced instead of re-derived: the delivery
and the clip packing were living in throwaway scripts under /tmp, and the only
written route to a real clip was an ffmpeg recipe that packs MSB first -- the
order that mirrors every eight pixels on a display.
harness/badapple/rdebug_load.py image -> terminal RAM over the firmware's
own remote-debug service (TCP 771), then
jump to the entry from vidstream.env
harness/badapple/clip_from_video.py video -> LSB-first 1-bpp frames for
make_tiles.py --clip
harness/badapple/demo.sh ICMP gate, load, send; one command
harness/badapple/README.md setup, run in MAME, run on real hardware,
what success looks like, failure modes
The loader records the two protocol facts that cost time to find: a 512-byte
poke chunk (1024 is mis-parsed as a following opcode: "Invalid command
0xA5A5A5A5"), and that nothing can be probed after the jump, so the image is
read back BEFORE jumping and the run is observed from the machine afterwards.
Tests cover what a wrong assumption would silently corrupt -- chunk arithmetic,
the linkage the entry point is read from, and pixel 0 landing on bit 0 -- and
the tool was exercised end to end against MAME: demo.sh loaded the payload and
the terminal reported DICT_BYTES=32768, DICT_BAD=0, RX_OK=28, TILES_DROP=0
while the sender saw frames_rx=129, drops=0.
153 tests pass (7 new).
Subagent/primary agent whose system prompt inlines docs/TMS34010-users-guide.txt
(SPVU001A, 1.75 MB, ~361k tokens) via {file:} substitution, so whole-book
questions -- cross-chapter synthesis, complete opcode lists, encoding and
timing tables -- are answered from the text in one pass.
Read-only (edit and subagent denied) and keeps read/shell, so it can render a
page with pdftoppm when the OCR text layer is mangled: section 12 prints XY as
XV and checkmarks as 'J'.
The receive loop re-armed all 16 descriptors into the other slot after every
completed frame. During a burst that re-pointed descriptors the chip had
ALREADY filled but the consumer had not read, so those frames were lost without
a trace: the LANCE DMA log shows all 23 dictionary datagrams written by the
chip, 1.132 ms apart (the MTU/bandwidth poll cadence), while the payload's own
counters accounted for 4 (DICT_BYTES=1448, dict_bad=3). Nothing was dropped by
MAME; the ring was being rewritten under the frames it held.
rx_switch()/rx_armed are gone. A descriptor is held (rx_hold) while its
datagram belongs to the frame being assembled, and rx_release() hands each one
back -- into the other slot -- only once that frame has been parsed or
abandoned. Nothing the chip delivered is ever re-pointed unread.
Measured, same zero-gap 23-datagram dictionary upload:
before dgram=4 DICT_BYTES=1448 dict_bad=3
release fix, 16 descriptors dgram=18 DICT_BYTES=24616 dict_bad=0
plus a 32-descriptor ring dgram=23 DICT_BYTES=32768 dict_bad=0
The ring is now 32 descriptors because 23 datagrams is the burst the protocol
actually defines (a whole 32 KB dictionary at MAX_BODY 1448), and 16 could not
hold it while the consumer copied. SLOT_BYTES grows to 49152 to hold the ring;
POOL_BYTES does NOT grow -- the 160 KB "framebuffer reserve" was unused slack
(the decoder paints straight from the fragments), so the extra 24,576 B per slot
comes out of it and the LANCE block keeps its fixed 0x3C000 tail.
RX_OK is now published after rx_release(), not inside the dispatch. It is the
"frame done" signal a reader polls, and it used to become visible while
descriptors were still being rewritten: ring mode caught a descriptor holding
slot B's high byte with slot A's low word. A reader that sees RX_OK move now
sees a painted picture and a consistent ring.
vidstream.lua's ring mode follows: it asserts the invariant that replaced the
flip -- the consumed descriptors move to the other slot, the untouched ones stay
-- instead of "all 16 in slot B".
Evidence: 146 unit tests pass; modes rx, draw, ring, tiles, delta and replay all
PASS with their pixel checkers (draw 1024/1024 rows, tiles and replay via
tile_check). End to end over rdebug with the STOCK sender (no pacing): the
dictionary burst completes (DICT_READY=1, DICT_BYTES=32768, dict_bad=0) and
RX_OK=100 frames paint, TILES_DROP=0.
The tile pipeline packed every row MSB first: pixel i -> bit 7 - i % 8. The
encoder and tile_check.py shared that convention, so every MAME replay check
passed while a display showed each group of eight pixels mirrored.
Evidence, from the real clip's own replay dump (vram-replay-200.bin, frame 200
of Bad Apple, 1,228,800 pixels):
decoded MSB first 1,215 mismatched <- what the pipeline produced
decoded LSB first 3,113 mismatched <- the mirrored picture, a sawtooth
and both the real unit and MAME decode LSB first: dwt/screenshot.py --real
("LSB-first bits", measured on live VRAM) and vt1200.cpp's
(vram[bit >> 4] >> (bit & 15)) & 1. A zoomed edge of that dump showed the
comb of triangles; after this change the same edge is a smooth curve.
vidsrc.Rows.pack_row now sets bit i % 8 of byte i // 8. The test that pins it
was written first and watched fail: it decodes the packed row with the
hardware's rule, which no MSB-first packer can pass. The two older tests that
asserted MSB first were rewritten -- they were asserting the bug -- and the
synthetic clip now packs through a bit-reversal table, so the committed cache
is regenerated (PSNR 29.47 -> 31.30 dB). The layout claims in tilecost.py,
vram_tile.asm and make_tiles.load_clip are corrected so the clip format states
the order instead of leaving it to whoever writes the frames.
Separately, MAME's renderer reads 128-byte rows while a payload targeting the
real unit writes 256-byte rows (vram_tile.asm DPTCH=0x800; c-hello/README.md:
"a payload writing at row*2048 bits lands on scanline row"). VT1200_VRAM_REAL=1
now draws the framebuffer at 2048 bits / 1280 px from x=0. Off by default: the
Level-1 error text is only legible at the derived 128-byte pitch, and that is
what the faithful boot needs. The branch only touches the bitmap, so the
regression baseline is unmoved (PROG_FRAMES=200 -> PC=3FDCE400, samples=198,
3 distinct PCs), and the default frame-3000 render of the Terminal Manager is
unchanged.
Verified end to end on the 3,292-frame Bad Apple clip: RX_OK=3196, TILES_DROP=0,
3292/3292 frames dumped and matched by tile_check.py, and the dumps decode to
1,379 / 4,125 mismatched pixels of 1,228,800 (quantisation) under the hardware
rule versus 3,101 / 9,793 under MSB. MAME's own frame dumps with
VT1200_VRAM_REAL=1 show the clip unsheared.
Revert note for the driver: delete the m_render_real branch and its getenv and
vt1200.cpp returns to the measured 128-byte renderer byte for byte. No second
reader reviewed that citation; it is quoted in the comment for the next one.
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.