diff --git a/harness/mame/PCF_VALIDATION_REPORT.md b/harness/mame/PCF_VALIDATION_REPORT.md
new file mode 100644
index 0000000..deb8c7f
--- /dev/null
+++ b/harness/mame/PCF_VALIDATION_REPORT.md
@@ -0,0 +1,274 @@
+# What the PCF loader validates on the TFTP font path — and why it cannot reach `pcfReadBitmaps`
+
+Date: 2026-10-17. Firmware `vt1200_v22.bin`, md5
+`b46a3e442ccb86134d2356bd70d22fd2` (**unmodified**). No change to
+`harness/mame/vt1200.cpp`, `build.sh`, `harness/serve.py`, or the MAME tree. No
+ROM patch and no check was disabled. Scratch and instrumentation live in
+`harness/mame/pcfwork/`.
+
+## Verdict (the short answer)
+
+**The TFTP font path does not use `pcfReadBitmaps` at all.**
+
+It loads the file through `3F96B850`, which recognises the PCF magic and calls a
+**metadata reader** (`3F97EF00`, "parser A"). Parser A handles four table types —
+PROPERTIES, ACCELERATORS, METRICS, ENCODINGS — and **silently skips the BITMAPS
+table**. The crafted `num_chars = 0x40000001` is therefore never read on this
+path, let alone multiplied. A font can pass the loader's validation (and it
+does), but passing it does not reach the vulnerable arithmetic.
+
+The "refused after 1024 bytes" observation was **not** a `pcfReadBitmaps`
+validation. For `evil-real.pcf` the parser gives up on the **METRICS** table:
+the compressed-metrics count is read little-endian as 57,088 instead of the
+intended 223, so `fosAlloc(57088*16 = 913,408)` fails. It never reaches BITMAPS
+(offset 2024); it consumed about 906 bytes, which is why the transfer looked like
+it stopped at the 1024-byte window.
+
+`pcfReadBitmaps` still exists and is still called — but only by a **different**
+parser (`3F98AC20`, "parser B") on the **X11 font-request** path, not by the
+TFTP file loader. That path was not demonstrated from a TFTP-delivered font (see
+"Remaining open question" below).
+
+## Address convention (confirmed)
+
+The TMS34010 is bit-addressed and the ROM is mapped at bit `0x3F200000`.
+Therefore **bit address = `0x3F200000` + (ROM file offset × 8)**. The earlier
+notes' `0x0F09CE` is byte `0x0F09CE` = bit `0x3F984E70`, which is exactly
+`pcfReadBitmaps`' arithmetic block. All addresses below are bit addresses.
+
+## 1. The path a TFTP-delivered font actually takes
+
+Live trace of a valid fetched font (trace script: `pcfwork/pcftrace.lua`):
+
+```
+FONTLOAD 3F96B850
+FONT-MAGIC magic=70636601
+PCF->parserA 3F96BB80 -> L3F97EF00
+PARSER-A-ENTRY 3F97EF00
+```
+
+`3F96B850` reads the first four bytes, stores them in the font object
+(`*a9(0x400)`), and compares them with `0x70636601`
+(`3F96BB20: cmpi 0x70636601,a5`). PCF goes to `L3F97EF00`; anything else falls to
+the DEC-SNF check (`0x1DF5E76`). The name comparison between the "path file"
+name and the font's own name is at `3F96C6B0`, and the success return
+(`movk 1,a8`) is at `3F96C930`.
+
+The same loader/parser pair is the only one exercised by the fetch: in a
+150-emulated-second run with a font parser A accepts, parser B and parser C were
+**never** entered.
+
+## 2. What parser A validates
+
+Parser A (`3F97EF00`) reads `nTables` and up to **eight** TOC entries
+(`label, format, size, start`, 16 bytes each). It then walks those entries in
+**TOC order**, seeks to each `start`, and dispatches on the label
+(`L3F9811B0`):
+
+| label | table | handler | reader | what is checked |
+|---|---|---|---|---|
+| 1 | PROPERTIES | `L3F97F8A0` | `L3F9770B0` | `format & 0xFFFFFF00 == 0`; `nProps`; two allocations; reads `name/type/value` then the string table; "Unkown property format", "allocation failure … (props/isstring/tmpbuf)", "short read … (tmpbuf)" |
+| 2 | ACCELERATORS | `L3F980200` | `L3F979C00` | `format & 0xFFFFFF00 == 0`; fixed 100-byte body; "Unknown accelerator format" |
+| 4 | METRICS | `L3F980E40` | `L3F9886B0` | format must be default `0` or compressed `0x100`; **count cross-checked against `pCS->nChars`** ("bad char count"); allocation `nChars*12` / `nChars*16` |
+| 32 | ENCODINGS | `L3F980A20` | `L3F97C750` | `format & 0xFFFFFF00 == 0`; first/last col/row; each index `< nChars` ("Illegal char index") |
+| 8,16,64,128 | BITMAPS, INK_METRICS, SWIDTHS, GLYPH_NAMES | — | — | **skipped** (`L3F9812B0`: `inc a9`, next entry) |
+
+The dispatch is unambiguous:
+
+```
+3f9811e0: move *a7(0x80),a8,1 ; a8 = label
+3f981200: dec a8
+3f981210: jaeq L3F97F8A0 ; 1 -> properties
+3f981230: dec a8
+3f981240: jaeq L3F980200 ; 2 -> accelerators
+3f981260: subk 2,a8
+3f981270: jreq L3F980E40 ; 4 -> metrics
+3f981280: subk 28,a8
+3f981290: jaeq L3F980A20 ; 32 -> encodings
+3f9812b0: inc a9 ... ; everything else -> next TOC entry
+```
+
+The seek before each handler (`3F96F9F0`) returns ≤ 0 on failure, which reaches
+the `Failed to read PCF format
` messages (`3F97FA40`, `3F9803A0`,
+`3F981050`, `3F980BB0`).
+
+**What is not there:** there is no reader for label 8. Parser A never reads the
+BITMAPS count, offsets or glyph data. Consequently there is no `num_chars`
+validation on this path either — the field is simply not looked at.
+
+## 3. Where `pcfReadBitmaps` actually is, and who calls it
+
+`pcfReadBitmaps` is `3F984970`; its arithmetic block is `3F984E70`:
+
+```
+3f984e70: move *a9(0x270),a11,1 ; nChars
+3f984e90: jrne L3F984EE0
+3f984ea0: move *a13(0x20),*a9(0x270),1 ; nChars == 0 -> nChars = n
+3f984f20: cmp a7,a11 ; else compare n with nChars
+3f984f30: jreq L3F985020
+3f984f80: "bad count in pcfReadBitmaps (expected %d, got %d)"
+3f985030: move *a13(0x20),a11,1 ; n
+3f985050: sll 0x2,a11 ; n*4 <-- unchecked
+3f985090: calla L3F703D90 ; fosAlloc(n*4,1)
+3f985260: (loop) read n CARD32 offsets at pBitOffsets + i*4
+```
+
+The ROM string `allocation failure in pcfReadBitmaps (offsets,%d)` is the
+`fosAlloc` failure report at `3F985150` — it names the **size argument**, it is
+not a separate validation of the offsets table. There is no check that `n`
+matches the table size, nor that `n*4` did not wrap.
+
+There is **exactly one caller** in the ROM: `3F98BB70`, inside parser B
+(`3F98AC20`). Parser B is not the TFTP loader. It is reached from the X11
+request dispatcher:
+```
+3F960640 (inside the X11 request handler that prints
+ "Invalid request code %d received.", string 3F98EA50 at 3F960750)
+ -> 3F9643E0 (font resource lookup: id -> font, then the file cache)
+ -> 3F964850 (PCF/SNF dispatch over the cached font object, magic at +0x400)
+ -> 3F98AC20 (parser B)
+ -> 3F98BB70 -> pcfReadBitmaps
+```
+
+Parser B's own TOC dispatch (`3F98C320`) handles labels 2, 4, 8 and 32, and
+calls `pcfReadBitmaps` for label 8. It *also* has the count cross-check
+(`3F984F20`/`3F984F80`), so on parser B a mismatched `num_chars` is caught by
+default.
+
+So `pcfReadBitmaps` belongs to the X11 font-request reader (font-id lookup over
+the cached font data); it is not the code that parses the file while it is being
+fetched.
+
+## 4. Live evidence
+
+All runs: `VT1200_SKIP_LEVEL1=1`, the private cfg selecting `taptun`, a private
+`-nvram_directory` seeded from a configured NVRAM (TFTP font path
+`192.168.99.1:69`), and the TFTP server serving the candidate. Trace output is
+`pcfwork/pcftrace.lua`.
+
+| candidate | result |
+|---|---|
+| `reference/fonts/baseline.pcf` (680 B) | Parser A: TOC 1,2,4 → **`A-FAIL metrics`**. The compressed-metrics count `00 01` is read little-endian as `0x0100` = 256, not 1; the metrics reader then returns 0. |
+| `pcfwork/baseline_le.pcf` (680 B; metrics count byte-swapped to `01 00`) | Parser A: TOC 1,2,4 (metrics count 1, `A-METR-C done`, `readerret=1`), then 8,16,32,64,128. No failure. |
+| `pcfwork/baseline_le_evil.pcf` (same, **BITMAPS `num_chars=0x40000001`**) | **Identical trace** to the line above: all eight TOC entries, `LOADER-A-SUCCESS` at frame 395. `BITMAPS-ENTRY`, `BITMAPS-ARITH` and `MULTIPLY` never hit; parser B and parser C never entered. The crafted count is ignored, not validated. |
+| `reference/fonts/evil-real.pcf` (25,860 B) | Parser A: TOC 1 (properties, 648 B), 2 (accelerators), 4 (metrics at 900). `fmt=0x10E` (compressed), count bytes `00 DF` read little-endian = `0xDF00` = **57,088**; `fosAlloc(57088*16 = 913,408)` returns 0 → **`A-FAIL metrics`**. BITMAPS is at offset 2024 and is never reached. ≈906 bytes consumed. |
+
+The last row is the "1024-byte" stop: the parser bailed at the METRICS
+allocation after ~906 bytes, so only the first two 512-byte TFTP blocks were
+ever consumed. It is not a validation of `num_chars`, and it is not a size limit
+of the transfer path.
+
+### Why the byte order matters (and why it is not cheating)
+
+`bdftopcf` on this host writes the compressed-metrics count big-endian
+(`00 01`); parser A copies the two bytes with a plain `fosRead` and reads them
+little-endian (`3F9898B0`, `3F9899F0: setf 16,1,0; move *a13,a7,0`). The stock
+font is therefore misread. `baseline_le.pcf` is the **same font** with that one
+2-byte field in the order the loader actually reads it. Nothing is disabled; the
+loader's own checks still run. The generator is
+`pcfwork/make_candidates.py`.
+
+## 5. Judgement
+
+**Closed, for the TFTP font path.** A font can pass the loader's validation
+(`LOADER-A-SUCCESS`), but that validation is not the one guarding the multiply:
+the loader never reads the BITMAPS table at all, so `num_chars = 0x40000001`
+cannot reach `pcfReadBitmaps`. The "1024-byte refusal" is a METRICS failure
+caused by the loader's little-endian read of a big-endian field, not a
+`pcfReadBitmaps` count/offset check.
+
+This is not "the validation happens to be good enough"; it is stronger: **the
+vulnerable function is not on this route**.
+
+### Remaining open question (stated, not papered over)
+
+`pcfReadBitmaps` is still present and still called by parser B on the X11
+font-request path (`3F960640 → 3F9643E0 → 3F964850 → 3F98AC20`). Parser B reads
+the **cached** font bytes, so in principle a font that parser A accepted and
+that the terminal then *opened* would be parsed by parser B. Two things were not
+demonstrated:
+
+1. that a TFTP-registered font is ever opened (the harness has no X11 client, and
+ in 150 emulated seconds the terminal fetched the index but never requested the
+ served font file); and
+2. whether reordering the TOC so BITMAPS precedes METRICS would leave
+ `pCS->nChars == 0` when `pcfReadBitmaps` runs, bypassing parser B's cross-check.
+ (Parser A would not object: it skips BITMAPS and reads METRICS whenever it
+ occurs.)
+
+That is a separate trigger (an X11/font-server `OpenFont` of a crafted font), not
+the "serve the font over TFTP and let the loader parse it" route this work was
+about. It is recorded here so the next session starts from the right place
+rather than from the earlier assumption that the TFTP loader reaches
+`pcfReadBitmaps`.
+
+## 6. What I refused to fake
+
+* No ROM patch; `md5sum vt1200_v22.bin` = `b46a3e442ccb86134d2356bd70d22fd2`
+ before and after.
+* No check disabled, no `harness/serve.py` edit, no invented loader behaviour.
+* I did **not** use `overflow.lua` (jumping straight into `3f984e70`) as evidence
+ of reachability. The reachability statement above comes from the real path:
+ TFTP fetch → `3F96B850` → parser A.
+* The one constructed input (`baseline_le.pcf`) is the project's valid font with
+ a 2-byte field put in the byte order the loader actually reads; the loader's
+ checks still run and still pass on their own terms.
+* `evil-real.pcf` is reported as a METRICS failure even though that makes a
+ "nicer" BITMAPS story impossible.
+
+## 7. Reproduction
+
+```sh
+cd /home/arti/code/k-space/vt1200
+python3 harness/mame/pcfwork/make_candidates.py
+
+# isolated tap (does not touch the owner's tap-mess-1000-0)
+sudo ip netns add vt1test
+sudo ip netns exec vt1test ip link set lo up
+sudo ip netns exec vt1test ip tuntap add dev tap-mess-0-0 mode tap
+sudo ip netns exec vt1test ip addr add 192.168.99.1/24 dev tap-mess-0-0
+sudo ip netns exec vt1test ip link set tap-mess-0-0 up
+
+# a configured NVRAM copy (TFTP font path 192.168.99.1:69)
+mkdir -p harness/mame/pcfwork/nvram-run/vt1200
+cp harness/mame/pcfwork/nvram-pristine.bin harness/mame/pcfwork/nvram-run/vt1200/nvram
+
+harness/mame/pcfwork/run_candidate.sh \
+ harness/mame/pcfwork/baseline_le_evil.pcf 40
+```
+
+`run_candidate.sh` starts `serve_candidate.py` (baseline for the path/index
+names, the candidate for the derived font name), runs MAME with
+`-nvram_directory pcfwork/nvram-run`, and prints the parser trace from
+`debug.log`. Switch the candidate to `reference/fonts/evil-real.pcf` for the
+25 KB case. The NVRAM copy is not committed: it is the owner's settings block
+(font path, IP) copied for the run, not part of the harness.
+
+## 8. Regression baselines (unchanged)
+
+No source file used by the driver changed, so every configuration is intact; the
+two documented change detectors were re-run on the shipped binary:
+
+```
+no env, progress.lua 200 frames : PC=3FDCE400 samples=198 (3 distinct PCs)
+VT1200_IO_PATH_STUB=1, 200 fr : PC=3FDCE400 samples=53 (75 distinct PCs)
+```
+
+`VT1200_SKIP_LEVEL1=1` and `VT1200_MOUSE=1` were used by these runs on the same
+unmodified driver; no code path they depend on was touched.
+
+## 9. New files
+
+```
+harness/mame/pcfwork/
+ pcftrace.lua parser A / parser B / pcfReadBitmaps / TFTP breakpoints
+ pcfflow.lua coarse call-graph trace of the font path
+ make_candidates.py baseline_le.pcf and baseline_le_evil.pcf
+ serve_candidate.py path/index -> baseline, font name -> candidate
+ serve_index.py a real fonts.dir + candidate (index experiments)
+ run_candidate.sh one headless real-path run, in the netns
+ run_index.sh same, with serve_index.py
+```
+
+No existing file was edited for this work except this report and the new
+`pcfwork/` files.
diff --git a/harness/mame/pcfwork/make_candidates.py b/harness/mame/pcfwork/make_candidates.py
new file mode 100644
index 0000000..dff2cee
--- /dev/null
+++ b/harness/mame/pcfwork/make_candidates.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+"""Build the two PCF candidates used in PCF_VALIDATION_REPORT.md.
+
+`baseline_le.pcf` is the project's valid 680-byte font with ONE change: the
+compressed-metrics count in the METRICS table is byte-swapped from `00 01`
+(big-endian 1, what bdftopcf emits) to `01 00`. The VT1200's font loader reads
+that INT16 raw (little-endian), so the stock file is read as 0x0100 = 256
+characters and the metrics parse runs off the end of the table. The swap makes
+the loader read the intended count of 1. It is a construction of a font the
+loader accepts, not a disabled check.
+
+`baseline_le_evil.pcf` is that font with the BITMAPS `num_chars` set to
+0x40000001 (the value overflow.lua used) -- to show the loaded path ignores it.
+
+ python3 harness/mame/pcfwork/make_candidates.py
+"""
+import os
+import struct
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
+sys.path.insert(0, os.path.join(ROOT, "tools"))
+import fontgen # noqa: E402
+
+SRC = os.path.join(ROOT, "reference", "fonts", "baseline.pcf")
+
+
+def main():
+ with open(SRC, "rb") as fh:
+ data = bytearray(fh.read())
+
+ metrics = fontgen.find_table(bytes(data), fontgen.METRICS)
+ if metrics is None:
+ raise SystemExit("baseline.pcf has no METRICS table")
+ off = metrics["offset"]
+ fmt = struct.unpack_from("parserC')
+bp('3f962cb0', 'OPEN-else-SNF')
+bp('3f9643e0', 'FN-B-ENTRY')
+bp('3f964850', 'FN-B-850')
+bp('3f964900', 'FN-B->parserB')
+bp('3f96d300', 'LOAD-A-call')
+bp('3f96b850', 'LOADER-A-ENTRY')
+bp('3f96bb80', 'LOADER-A->parserA')
+bp('3f96c990', 'CACHE-LOOKUP')
+-- Parsers.
+bp('3f97ef00', 'PARSER-A-ENTRY')
+bp('3f981690', 'PARSER-C-ENTRY')
+bp('3f98ac20', 'PARSER-B-ENTRY')
+bp('3f984970', 'pcfReadBitmaps-ENTRY')
+bp('3f985060', 'pcfReadBitmaps-MULTIPLY')
+-- Other font-file reader with SNF dispatch (3f964850's sibling at 3f96b850 is loader A).
+bp('3f964ac0', 'FN-B-SNF-check')
+
+-- pcfReadFont outer dispatch of parser B (label).
+bp('3f98c370', 'B-TOC')
+-- pcfReadFont-ish wrappers: string messages tell us which reader bails.
+bp('3f98ef08', 'ERR-msg-3f98ef08')
+bp('3f98f088', 'ERR-msg-3f98f088')
+bp('3f98f210', 'ERR-msg-3f98f210')
+bp('3f98f428', 'ADDR-3f98f428')
+
+local maxframes = tonumber(os.getenv("PCT_FRAMES") or "20000")
+emu.register_frame_done(function()
+ FRAME = FRAME + 1
+ if FRAME % 4000 == 0 then
+ print(string.format("frame %d PC=%08X", FRAME, cpu.state['PC'].value))
+ end
+ if FRAME >= maxframes then print("done"); machine:exit() end
+end, "vt1200-flow")
+dbg.visible_cpu = cpu
+dbg.execution_state = 'run'
diff --git a/harness/mame/pcfwork/pcftrace.lua b/harness/mame/pcfwork/pcftrace.lua
new file mode 100644
index 0000000..9f54c47
--- /dev/null
+++ b/harness/mame/pcfwork/pcftrace.lua
@@ -0,0 +1,91 @@
+-- pcftrace.lua -- live trace of the firmware's PCF font parser(s).
+--
+-- There are TWO PCF readers in the ROM:
+-- * parser A L3F97EF00 (reached from the font-file loader at 3f96bb80)
+-- * parser B L3F98AC20 (reached from 3f964900) -- the only caller of the
+-- pcfReadBitmaps function (L3F984970) that holds the n*4 overflow.
+-- This traces both, plus the font-file dispatch, so the run says which reader
+-- the TFTP font path actually uses and where it bails.
+
+local machine = manager.machine
+local dbg = machine.debugger
+local cpu = machine.devices[':maincpu']
+
+if not dbg then
+ print("ERROR: debugger not enabled; pass -debug")
+ machine:exit()
+ return
+end
+
+FRAME = 0
+
+-- Font-file loader / dispatch.
+dbg:command('bpset 3f96b850,,{ printf "FONTLOAD fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f96bb20,,{ printf "FONT-MAGIC fr=%d magic=%08X\\n", FRAME, a5 ; g }')
+dbg:command('bpset 3f96bb80,,{ printf "PCF->parserA fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f96c930,,{ printf "LOADER-A-SUCCESS fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f96d300,,{ printf "LOAD-A-call fr=%d\\n", FRAME ; g }')
+
+-- Parser A entry.
+dbg:command('bpset 3f97ef00,,{ printf "PARSER-A-ENTRY fr=%d\\n", FRAME ; g }')
+-- Parser A validations (labels 1/2/4/32).
+dbg:command('bpset 3f97fa40,,{ printf "A-FAIL properties fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f9803a0,,{ printf "A-FAIL accelerators fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f980bb0,,{ printf "A-FAIL encodings fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f981050,,{ printf "A-FAIL metrics fr=%d\\n", FRAME ; g }')
+-- Parser A dispatch (label in a8 right after it is loaded).
+dbg:command('bpset 3f981200,,{ printf "A-TOC fr=%d i=%d label=%d\\n", FRAME, a9, a8 ; g }')
+
+-- Parser A metrics handler internals.
+dbg:command('bpset 3f980e40,,{ printf "A-METRICS fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f980f20,,{ printf "A-METRICS seekret=%08X\\n", a8 ; g }')
+dbg:command('bpset 3f980ff0,,{ printf "A-METRICS callreader fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f981020,,{ printf "A-METRICS readerret=%08X\\n", a8 ; g }')
+dbg:command('bpset 3f9886b0,,{ printf "A-METR-ENTRY fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f9889a0,,{ printf "A-METR fmtread ret=%08X fmt=%08X\\n", a8, d@(a13+0x20) ; g }')
+dbg:command('bpset 3f988a40,,{ printf "A-METR compressed fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f988b90,,{ printf "A-METR badfmt fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f989820,,{ printf "A-METR-C-ENTRY fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f989940,,{ printf "A-METR-C countread ret=%08X count=%04X\\n", a8, d@(a13) ; g }')
+dbg:command('bpset 3f989ac0,,{ printf "A-METR-C badcount fr=%d nChars=%08X count=%04X\\n", FRAME, d@(a13+0xffc0)+0, d@(a13) ; g }')
+dbg:command('bpset 3f989c80,,{ printf "A-METR-C allocret=%08X\\n", a8 ; g }')
+dbg:command('bpset 3f989f20,,{ printf "A-METR-C byteread-fail fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f98a120,,{ printf "A-METR-C byteread-fail2 fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f98a970,,{ printf "A-METR-C done fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f988f80,,{ printf "A-METR done fr=%d\\n", FRAME ; g }')
+
+-- Parser B entry and its pcfReadBitmaps call.
+dbg:command('bpset 3f98ac20,,{ printf "PARSER-B-ENTRY fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f98bb70,,{ printf "B-CALL-BITMAPS fr=%d\\n", FRAME ; g }')
+
+-- pcfReadBitmaps internals.
+dbg:command('bpset 3f984970,,{ printf "BITMAPS-ENTRY fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f984e70,,{ printf "BITMAPS-ARITH fr=%d nChars=%08X n=%08X\\n", FRAME, d@(a9+0x270), d@(a13+0x20) ; g }')
+dbg:command('bpset 3f985060,,{ printf "MULTIPLY fr=%d n4=%08X n=%08X\\n", FRAME, a11, d@(a13+0x20) ; g }')
+dbg:command('bpset 3f9850e0,,{ printf "OFFSETS-ALLOC fr=%d result=%08X\\n", FRAME, a8 ; g }')
+dbg:command('bpset 3f984f80,,{ printf "VALIDATE bad count fr=%d nChars=%08X n=%08X\\n", FRAME, d@(a9+0x270), d@(a13+0x20) ; g }')
+dbg:command('bpset 3f985150,,{ printf "VALIDATE alloc-offsets-fail fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f9856b0,,{ printf "VALIDATE alloc-glyphs-fail fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f985860,,{ printf "VALIDATE short-read-glyphs fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f98b8c0,,{ printf "B-FAIL PCF metrics fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f98bbe0,,{ printf "B-FAIL PCF bitmaps fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f98bed0,,{ printf "B-FAIL PCF accelerators fr=%d\\n", FRAME ; g }')
+dbg:command('bpset 3f98c1b0,,{ printf "B-FAIL PCF encodings fr=%d\\n", FRAME ; g }')
+
+-- The TFTP font fetch, for the record.
+dbg:command('bpset 3f5ed230,,{ printf "TFTP-READ fr=%d\\n", FRAME ; g }')
+
+local maxframes = tonumber(os.getenv("PCT_FRAMES") or "20000")
+emu.register_frame_done(function()
+ FRAME = FRAME + 1
+ if FRAME % 2000 == 0 then
+ print(string.format("frame %d PC=%08X", FRAME, cpu.state['PC'].value))
+ end
+ if FRAME >= maxframes then
+ print("done (" .. FRAME .. " frames)")
+ machine:exit()
+ end
+end, "vt1200-pct")
+
+dbg.visible_cpu = cpu
+dbg.execution_state = 'run'
diff --git a/harness/mame/pcfwork/run_candidate.sh b/harness/mame/pcfwork/run_candidate.sh
new file mode 100755
index 0000000..186e2b7
--- /dev/null
+++ b/harness/mame/pcfwork/run_candidate.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# Run one candidate font through the terminal's own TFTP loader, with the PCF
+# parser traced. Everything happens inside the isolated netns vt1test so the
+# owner's tap is untouched.
+#
+# run_candidate.sh [seconds] [trace.lua] [extra mame args...]
+set -euo pipefail
+NS=vt1test
+ROOT=/home/arti/code/k-space/vt1200
+WORK=$ROOT/harness/mame/pcfwork
+CAND="${1:?usage: run_candidate.sh [seconds] [trace.lua]}"
+SECS="${2:-40}"
+TRACE="${3:-$WORK/pcftrace.lua}"
+shift 3 2>/dev/null || true
+
+sudo -n ip netns exec "$NS" pkill -x vt1200 2>/dev/null || true
+sudo -n ip netns exec "$NS" pkill -f serve_candidate.py 2>/dev/null || true
+sleep 0.3
+rm -rf "$WORK/nvram-run" "$WORK/debug.log"
+mkdir -p "$WORK/nvram-run/vt1200"
+cp "$WORK/nvram-pristine.bin" "$WORK/nvram-run/vt1200/nvram"
+
+sudo -n ip netns exec "$NS" python3 -u "$WORK/serve_candidate.py" "$CAND" \
+ >"$WORK/serve.log" 2>&1 &
+SRV=$!
+sleep 1.2
+
+cd "$WORK"
+timeout $((SECS+15)) sudo -n ip netns exec "$NS" env -u DISPLAY \
+ SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \
+ XDG_RUNTIME_DIR="$WORK" VT1200_SKIP_LEVEL1=1 \
+ /home/arti/code/k-space/mame0289/vt1200 vt1200 \
+ -rompath "$ROOT/harness/mame/roms" -video none -sound none -nothrottle \
+ -skip_gameinfo -cfg_directory "$WORK/cfg" \
+ -nvram_directory "$WORK/nvram-run" -networkprovider taptun \
+ -debug -debugger none -debuglog \
+ -autoboot_script "$TRACE" -autoboot_delay 0 \
+ -seconds_to_run "$SECS" >"$WORK/mame_run.log" 2>&1 || true
+
+kill $SRV 2>/dev/null || true
+sudo -n ip netns exec "$NS" pkill -f serve_candidate.py 2>/dev/null || true
+echo "=== server ==="
+cat "$WORK/serve.log"
+echo "=== parser trace ==="
+grep -E "TOC|BITMAPS|MULTIPLY|OFFSETS|VALIDATE|FAIL|TFTP-READ" "$WORK/debug.log" | head -200
diff --git a/harness/mame/pcfwork/run_index.sh b/harness/mame/pcfwork/run_index.sh
new file mode 100755
index 0000000..83646d6
--- /dev/null
+++ b/harness/mame/pcfwork/run_index.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+# Run one candidate through a proper fonts.dir index.
+# run_index.sh [seconds] [trace.lua] [font-file] [dir]
+set -euo pipefail
+NS=vt1test
+ROOT=/home/arti/code/k-space/vt1200
+WORK=$ROOT/harness/mame/pcfwork
+CAND="${1:?usage: run_index.sh [seconds] [trace.lua]}"
+XLFD="${2:?XLFD name}"
+SECS="${3:-150}"
+TRACE="${4:-$WORK/pcftrace.lua}"
+FONT_FILE="${5:-v1200a.pcf}"
+DIR="${6:-69}"
+
+sudo -n ip netns exec "$NS" pkill -x vt1200 2>/dev/null || true
+sudo -n ip netns exec "$NS" pkill -f serve_index.py 2>/dev/null || true
+sleep 0.3
+rm -rf "$WORK/nvram-run" "$WORK/debug.log"
+mkdir -p "$WORK/nvram-run/vt1200"
+cp "$WORK/nvram-pristine.bin" "$WORK/nvram-run/vt1200/nvram"
+
+sudo -n ip netns exec "$NS" python3 -u "$WORK/serve_index.py" \
+ "$CAND" "$XLFD" "$FONT_FILE" "$DIR" >"$WORK/serve.log" 2>&1 &
+SRV=$!
+sleep 1.2
+
+cd "$WORK"
+timeout $((SECS+15)) sudo -n ip netns exec "$NS" env -u DISPLAY \
+ SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \
+ XDG_RUNTIME_DIR="$WORK" VT1200_SKIP_LEVEL1=1 \
+ /home/arti/code/k-space/mame0289/vt1200 vt1200 \
+ -rompath "$ROOT/harness/mame/roms" -video none -sound none -nothrottle \
+ -skip_gameinfo -cfg_directory "$WORK/cfg" \
+ -nvram_directory "$WORK/nvram-run" -networkprovider taptun \
+ -debug -debugger none -debuglog \
+ -autoboot_script "$TRACE" -autoboot_delay 0 \
+ -seconds_to_run "$SECS" >"$WORK/mame_run.log" 2>&1 || true
+
+kill $SRV 2>/dev/null || true
+sudo -n ip netns exec "$NS" pkill -f serve_index.py 2>/dev/null || true
+echo "=== server ==="
+cat "$WORK/serve.log"
+echo "=== trace ==="
+grep -vE "Breakpoint|version|targeting|^$" "$WORK/debug.log" | head -200
diff --git a/harness/mame/pcfwork/serve_candidate.py b/harness/mame/pcfwork/serve_candidate.py
new file mode 100644
index 0000000..12e9fcb
--- /dev/null
+++ b/harness/mame/pcfwork/serve_candidate.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+"""pcfwork: serve one candidate font over TFTP on the real path.
+
+The terminal's TFTP font path in the (copied) NVR is 192.168.99.1:69, so it
+first RRQs '69' (the path object), then the font name it derives, then
+'/fonts.dir'. This serves baseline.pcf for the path/index names (so the
+terminal derives a name and asks again) and the candidate font for everything
+else -- the same shape the prior font-work used to make the terminal fetch the
+crafted font over its own loader.
+
+ python3 serve_candidate.py [bind-ip]
+"""
+import os
+import sys
+import time
+
+ROOT = "/home/arti/code/k-space/vt1200"
+sys.path.insert(0, os.path.join(ROOT, "tools"))
+import fontgen # noqa: E402
+
+cand_path = sys.argv[1]
+host = sys.argv[2] if len(sys.argv) > 2 else "192.168.99.1"
+
+with open(os.path.join(ROOT, "reference/fonts/baseline.pcf"), "rb") as fh:
+ base = fh.read()
+with open(cand_path, "rb") as fh:
+ cand = fh.read()
+print(f"path/index -> baseline.pcf ({len(base)}B); font -> {os.path.basename(cand_path)} ({len(cand)}B)",
+ flush=True)
+
+
+def resolve(name):
+ # Path object / index fetches get the baseline so the terminal derives a
+ # name and re-fetches; everything else gets the candidate.
+ if name == "69" or name.endswith("fonts.dir"):
+ return base
+ return cand
+
+
+srv = fontgen.TFTPServer((host, 69), {}, timeout=60.0, retries=10,
+ resolver=resolve).start()
+try:
+ while True:
+ time.sleep(0.5)
+ while srv.log:
+ ts, name, addr = srv.log.pop(0)
+ stamp = time.strftime("%H:%M:%S", time.localtime(ts))
+ print(f"{stamp} {addr[0]}:{addr[1]} requested {name!r}", flush=True)
+except KeyboardInterrupt:
+ pass
+finally:
+ srv.stop()
diff --git a/harness/mame/pcfwork/serve_index.py b/harness/mame/pcfwork/serve_index.py
new file mode 100644
index 0000000..1f95e92
--- /dev/null
+++ b/harness/mame/pcfwork/serve_index.py
@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+"""pcfwork: serve a proper font index + one candidate font over TFTP.
+
+Unlike serve_candidate.py (which serves the PCF bytes as the index, so the
+terminal derives a garbage name), this serves a real `fonts.dir` naming the
+candidate with its own internal XLFD, so the terminal's name check can pass and
+the font can actually be registered/opened.
+
+ python3 serve_index.py [font-file] [dir] [bind-ip]
+"""
+import os
+import sys
+import time
+
+ROOT = "/home/arti/code/k-space/vt1200"
+sys.path.insert(0, os.path.join(ROOT, "tools"))
+import fontgen # noqa: E402
+
+cand_path = sys.argv[1]
+xlfd = sys.argv[2] if len(sys.argv) > 2 else "-misc-vt1200test-medium-r-normal--8-80-75-75-c-80-iso8859-1"
+font_file = sys.argv[3] if len(sys.argv) > 3 else "v1200a.pcf"
+fontdir = sys.argv[4] if len(sys.argv) > 4 else "69"
+host = sys.argv[5] if len(sys.argv) > 5 else "192.168.99.1"
+
+with open(cand_path, "rb") as fh:
+ cand = fh.read()
+index = f"1\n{font_file} {xlfd}\n".encode()
+print(f"index={index!r}", flush=True)
+print(f"font {font_file} -> {os.path.basename(cand_path)} ({len(cand)}B)", flush=True)
+
+
+def resolve(name):
+ if name == fontdir or name == fontdir + "/":
+ return cand # the path object: the terminal reads it first
+ if name.endswith("fonts.dir"):
+ return index
+ if name == font_file or name.endswith("/" + font_file) or name.endswith(font_file):
+ return cand
+ return None # anything else: honest "file not found"
+
+
+srv = fontgen.TFTPServer((host, 69), {}, timeout=60.0, retries=10,
+ resolver=resolve).start()
+try:
+ while True:
+ time.sleep(0.5)
+ while srv.log:
+ ts, name, addr = srv.log.pop(0)
+ stamp = time.strftime("%H:%M:%S", time.localtime(ts))
+ print(f"{stamp} {addr[0]}:{addr[1]} requested {name!r}", flush=True)
+except KeyboardInterrupt:
+ pass
+finally:
+ srv.stop()