New IO-ARCHITECTURE.md, derived from the v4.2 image, the U7 GAL decode and schematic sheets 1/3: - $A010 control-latch bit map (CCK / D_OUT / WR_SB / RD_SB / SEL0-2) - the 50-byte script format the firmware replays, and the ROM table map - full port/address population: 9 buttons boards + 2 keypads across all 8 ports; the 0x00-0x6F logical map and its 0x48-0x4F gap - the keypad engine ($CC53/$CC7E): 4-row matrix scan, row-patched RAM scripts, the $DC14 key-code table, message type $8B - lamp readback ($21C2) and the 72-byte lamp-fault mask at $DFA8 -- fault reports are type $03 with the lamp index in $2519 - scan cycle budget: 1.91 ms/pass, 17.3 ms/scan, ~58 Hz; demux is 60% - expansion: one spare buttons board, and nothing further without a protocol revision Corrections to existing docs: - $CC53 was cited as the encoder sweep; it is the keypad-1 scanner. The sweep is $C8CC-$C9A7, driven from $C0CB. - U31 is on sheet 3, not sheet 1, and is completely uncommitted: no address, data, strobe or pod-bus signal reaches it. Not an expansion hook -- populating it does nothing without new wiring. - No spare HCTL-2016 footprints exist. The decode has 3 free selects on U9, but the PCB carries exactly 5 positions and sheet 1 draws 5. More analog axes need hardware, not firmware. Also carries the previously-uncommitted standard-rate (16550) feasibility analysis and baudscan.py, plus a note that the FastRIO "FTDI-class adapter" rule really means arbitrary-rate generation: CP2102N qualifies, classic CP2102 snaps 31250 to 38400. Bench-measured 2026-07-26; on-cockpit latency check still pending. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
22 KiB
RIO I/O architecture — the pod bus, decoded
How the RIO board actually moves buttons, keypads and lamps over its eight-port "pod bus": the control-latch bit map, the 50-byte script format the firmware replays, the complete port/address population of a pod, and what it all costs in CPU cycles.
Derived 2026-07-26 from RIOv4_2.disasm.asm plus
the recovered memory map in
../rio-hardware/gal/README.md and
schematic sheet 3 (RIO_1407). Cross-checked against the physical
inventory (9 buttons boards + 2 keypads per pod, operator-confirmed).
Everything here is read out of the firmware image unless flagged INFERRED or UNVERIFIED.
The control latch at $A010
The pod bus is bit-banged by rewriting one 74HC574 latch (BND_SEL,
write-only, decoded by U7). Every bus edge is one STAA $A010. Bit
assignment recovered by diffing all 11 scripts in ROM:
| Bit | Signal | Behaviour |
|---|---|---|
| 0 | CCK |
shift clock — toggles on every write |
| 1 | D_OUT / DD_IN |
serial data into the pod's 74HCT164 |
| 2 | WR_SB |
write strobe — used only in write scripts |
| 3 | RD_SB |
read strobe — parallel-loads the pod's 74HCT165 |
| 4 | (unidentified) | static 1 in every script |
| 5-7 | SEL0-2 |
port select → 74LS138; all 8 values used |
SEL0 = bit 5, SEL1 = bit 6, SEL2 = bit 7, so a script's first byte
$N0 gives its port directly as N >> 1.
The 50-byte script format
The firmware does not bit-bang inline. It replays precomputed 50-byte scripts, one latch state per byte:
- 18 bytes — preamble. Nine
CCKpulses shifting a walking1down the cable into the board's address 74HCT164; its position picks theADR. SEL.jumper (A-0= shift position 7,A-1= 6,A-2= 5). The last pair asserts the strobe. - 32 bytes — payload. 16
CCKpulses, 2 bytes each. On reads,PORTEis sampled between pulses; on writes,WR_SB(bit 2) latches.
18 + 32 = 50, matching the table stride exactly.
The acquisition routine $CFC0
CFC0 PSHA/PSHB/PSHX/PSHY ; prologue
CFC5 LDY #$2152 ; sample destination
CFC9 LDX $2150 ; script pointer
CFCC LDAB #$12 ; 18 preamble bytes
CFCE LDAA $00,X / STAA $A010 / INX / DECB / BNE ; preamble loop
CFD8 LDAB #$10 / STAB $2036 ; 16 bits
CFDD LDAA $100A ; sample PORTE <-- bit loop top
CFE0 PSHX / STY $2031 / LDX $2031 / STAA $00,X / PULX / INY
CFED LDAB #$02
CFEF LDAA $00,X / STAA $A010 / INX / DECB / BNE ; one CCK pulse
CFF8 DEC $2036 / BNE $CFDD
CFFD NOP / PULY / PULX / PULB / PULA / RTS
Disassembler sync gap.
RIOv4_2.disasm.asmrenders$CFE0-$D003as.byte— it lost sync on the18 FF(STYextended) prebyte. The listing above is the hand decode; both branch displacements ($CFF6→$CFEF,$CFFB→$CFDD) land exactly on its instruction boundaries, which confirms it. The same gap recurs at$C380-$C38F.
PORTE ($100A) is read at exactly one site in the whole firmware
— this one. Every bit of pod input arrives here.
Script tables in ROM
| Region | Size | Contents |
|---|---|---|
$DBB0-$DC13 |
2 × 50 | keypad scripts — SEL 4, SEL 5 |
$DC14-$DC23 |
16 B | keypad key-code table (see below) |
$DC24-$DDE5 |
9 × 50 | read scripts — the 9 buttons boards |
$DDE6-$DFA7 |
9 × 50 | write scripts — lamps (WR_SB active) |
$DFA8-$DFEF |
72 B | lamp-fault mask — one byte per lamp (see below) |
$DFF0-$FFxx |
8,144 B | erased — the largest free run in the EPROM |
The blocks butt against each other with zero slack: there is no reserved tenth script anywhere in the image.
Port and address population of a pod
Scan order is $CF40, nine JSR $CFB6 calls. SEL values across the
scripts are 0,1,2,2,3,3,3,6,7, matching the $204E increment
sequence exactly ($204E = SEL + 1; it starts at 1 per $CF3E).
| Port | Board | Addr | Logical range |
|---|---|---|---|
| 0 | buttons | A-0 | 0x00-0x07 |
| 1 | buttons | A-0 | 0x08-0x0F |
| 2 | buttons ×2 | A-0, A-1 | 0x10-0x1F |
| 3 | buttons ×3 | A-0, A-1, A-2 | 0x20-0x37 |
| 4 | keypad 1 | A-0 | 0x50-0x5F |
| 5 | keypad 2 | A-0 | 0x60-0x6F |
| 6 | buttons | A-0 | 0x38-0x3F |
| 7 | buttons | A-0 | 0x40-0x47 |
All eight ports are populated. The button scan's INC $204E ×3 at
$CF8B skips ports 4-5 because those are keypads, driven from the
separate script pair at $DBB0/$DBE2 (referenced LDX #$DBB0 at
$C350).
Port→logical assignment within port 3 and the port-6/7 ordering is
INFERRED from scan order ($2037 increments monotonically, 8 per
script); the ROM does not label them.
The keypads — a second, separate engine
The keypads do not ride the button scan. They have their own scripts, their own bit-bang engine, their own RAM, and their own message type. This is why they never appear in the 72-entry button arrays.
Templates are copied ROM → RAM so rows can be patched
$C34C copies 100 bytes (LDAB #$64) from $DBB0 to $2050 —
both keypad scripts. $C369 does the same for the 9 write scripts
($DDE6 → $220A, #$09 × #$32). Scripts live in RAM because the
firmware rewrites bits inside them between replays.
| Keypad 1 | Keypad 2 | |
|---|---|---|
| Entry | $CC53 |
$CC7E |
| Port (SEL) | 4 | 5 |
$204E |
5 | 6 |
| RAM script | $2050 |
$2082 (= $2050 + $32) |
| Raw samples | $20B6 (32 B) |
$20D6 (32 B) |
| Key states | $20FC (16 B) |
$210C (16 B) |
| Key code out | $204A |
$204B |
$2082 never appears as a literal — $CC7E reaches it with
LDAB #$32 / ABX, and likewise offsets the two buffers by $20/$10.
$204E = 5 and 6 are exactly the two indices the button scan skips
with its INC $204E ×3 at $CF8B. The two engines share one index
space and partition it cleanly.
The script shape differs from a buttons board
buttons: 7 pulses data=0, 1 pulse data=1, strobe, 16 read pulses
keypad : 15 pulses data=0, 1 pulse data=1, strobe, 8 read pulses
The keypad shifts 16 bits, not 8 — the 1402 carries two chained
74HCT164s (8 bits board address + 8 bits matrix row drive, through the
74LS05 open-collector inverters), so the walking 1 has to travel
twice as far. It then reads back only 8 bits, because the single
CD74HCT165 sees 4 columns plus BRD_DT* and spares.
The scan engine $CCB2 — four rows
LDY $20F6 ; result ptr LDAB #$04 / STAB $2034 ; 4 ROWS
CCBC: LDX $20B4 ; reload script base each row
dispatch on row: $CD1E (4) / $CD4F (3) / $CD80 (2) / $CDB1 (1)
; ^ patch the row bit in the RAM script
CCE3: LDAB #$22 ; 34 bytes = 17 CCK pulses + strobe
LDAA $00,X / STAA $A010 / INX / DECB / BNE
CCF2: LDAB #$08 / STAB $2035 ; 8 columns
CCF7: LDAA $100A ; sample PORTE (the 2nd of only 2 such sites)
... store, INY, 2 latch writes = one CCK pulse ...
CD12: DEC $2035 / BNE $CCF7 ; 8 bits
CD17: DEC $2034 / BNE $CCBC ; 4 rows
The four row patchers are near-identical; $CD1E is
LDAA $08,X / ORAA #$02 / STAA $08,X (etc.) — setting and clearing
bit 1 (D_OUT) at specific script offsets, which walks the row
1 to a different shift position for each row.
Demux $CDE2 and the key-code table
Per row it does INX ×4 — discarding the first 4 sampled bits —
then takes 4. That is 4 columns × 4 rows = 16 keys, indexed by
$20FB running 0-15. The discarded bits are the 165's status half;
$CEFA picks two of them ($20B6+$03 bit 4, $20D6+$03 bit 5) into
the fault array at $24ED+9/+$0A — keypad board-detect.
$CE1F/$CE72 load LDY #$DC14, a 16-byte scan-code → legend
table occupying the gap between the keypad scripts and the button read
block. Contents are the stock hex-pad layout:
| c0 | c1 | c2 | c3 | |
|---|---|---|---|---|
| r0 | 1 | 2 | 3 | C |
| r1 | 4 | 5 | 6 | D |
| r2 | 7 | 8 | 9 | E |
| r3 | A | 0 | B | F |
$CE72 is the press path (state → $01), $CE1F the release path
(state → $00); a pressed key reads 0, being pulled low against
the 2.7K column pull-ups. Both enqueue message type $8B — not
the $88/$89 button press/release pair.
So 0x50/0x60 are host-side logical IDs, formed as a per-pad base
plus the 0-F key code; the wire carries a distinct message type and a
nibble. That is why the pads consume none of the 112-entry button
map internally. (INFERRED — the base-plus-code composition is the
reading that fits both the firmware and the observed map; PROTOCOL.md
in the RIOJoy repo is the authority.)
Cadence asymmetry
The main loop at $C0B0 is:
C0B0 JSR $C579 ; protocol service
C0B3 JSR $CF31 ; button scan (9 boards)
C0B6 JSR $C579
C0B9 JSR $CC7E ; keypad 2 ONLY
C0BC JSR $C579
C0BF JSR $C1CB
C0C2 JSR $C579
C0C5 JSR $C0EB
C0C8 JMP $C0B0
Both pads are polled every pass, but by different routes. Keypad 2
is scanned directly at $C0B9. Keypad 1 goes through $C33B
(JSR $CC53 / LDAA $204A / RTS — scan and return the key), which is
called by the A-9-E chord detector $C1CB at $C0BF; $CC53 is
also reached from the CheckRequest self-test at $C5C9. So keypad 1
feeds the test-mode entry sequence while keypad 2 feeds the normal
event stream — a functional split, not a polling gap.
I/O inventory — one pod
| Inputs | Count | Address |
|---|---|---|
| Buttons (9 × 1401 × 8) | 72 | 0x00-0x47 |
| Keypad 1 (1402 hex pad) | 16 | 0x50-0x5F |
| Keypad 2 (1402 hex pad) | 16 | 0x60-0x6F |
| Total digital controls | 104 | of a 112 map |
| Analog axes (HCTL-2016, 512 cpr) | 5 | $A020-$A03F |
Panel switches (RESET, ABORT→IRQ*) |
2 | — |
| Outputs | Count |
|---|---|
| Button lamps (9 × 8, UCN5821A) | 72 |
Global brightness PWM (BRT_CTR) |
1 |
| Display digits (ICM7228B) | 8 |
| Status LEDs | 6 |
Logical address map
| Range | Count | Contents |
|---|---|---|
0x00-0x47 |
72 | buttons — fully populated |
0x48-0x4F |
8 | gap — one unused board slot |
0x50-0x5F |
16 | keypad 1 |
0x60-0x6F |
16 | keypad 2 |
0x48 = 72 is the #$48 constant that sizes five separate arrays —
the button space and the gap boundary are the same number.
RAM layout (packed solid)
| Base | Size | Contents |
|---|---|---|
$2050 |
2 × 50 | keypad scripts, copied from $DBB0, row-patched in place |
$20B6 / $20D6 |
32 B ea | keypad raw samples (4 rows × 8 bits) |
$20FC / $210C |
16 B ea | keypad key states |
$2152 |
16 B | raw PORTE samples, bit-major |
$2162 |
$60 = 96 |
input state, logical 0x00-0x5F |
$21C2 |
$48 = 72 |
second demux path ($D144, index $2442) |
$220A |
9 × 50 = 450 | lamp write-script buffer (board × 50) |
$23CC |
2 B | script pointer used by $D331 |
$23CE |
$48 = 72 |
lamp state |
$2454 |
$48 = 72 |
lamp command |
$249C |
$48 = 72 |
(button-indexed) |
$24ED |
$14 = 20 |
fault/status flags ($CEFA) |
Arrays abut with no padding — $21C2 + 72 ends at $2209, exactly
where the lamp buffer begins.
Cycle budget (2 MHz E-clock, 0.5 µs/cycle)
Hand-counted from M68HC11 instruction timings; all register access is
extended mode (no LDX #$1000 base pointer exists in the image), so
operand enumeration is exhaustive.
| Stage | Cycles | Time |
|---|---|---|
| Preamble loop (18 × 16) | 288 | 144 µs |
| Bit loop (16 × 75) | 1,200 | 600 µs |
| Prologue/epilogue/JSR | 66 | 33 µs |
$CFC0 acquisition |
1,554 | 777 µs |
$D004 demux (8 inputs) |
~1,132 | 566 µs |
$D144 demux (8 inputs) |
~1,132 | 566 µs |
$CFB6 — one pass |
~3,829 | 1.91 ms |
| Full scan (9 passes) | ~34,500 | 17.3 ms |
Maximum scan rate ≈ 58 Hz; worst-case button latency ≈ 17 ms. Acquisition is only 40% of it (7.0 ms) — demux is 60% (10.2 ms).
Marginal costs: one more pass (+16 bits) = 1.91 ms; one more input inside an existing pass = 135 cycles = 68 µs.
Keypads, same method (estimates — the row patchers and handlers are state-dependent):
| Stage | Cycles | Time |
|---|---|---|
$CCB2 per row (patch + 34 shift + 8 read + loop) |
~1,275 | 637 µs |
$CCB2 × 4 rows |
~5,150 | 2.6 ms |
$CDE2 demux (16 keys) |
~2,800 | 1.4 ms |
| One keypad | ~7,950 | ~4.0 ms |
A main-loop pass therefore costs roughly 17.3 ms (buttons) + 4.0 ms
(keypad 2) ≈ 21 ms, plus the encoder sweep and four $C579 protocol
services — call it ~45 Hz of button/keypad latency. Keypad 1 adds its
4.0 ms only when the host asks for it.
The scan is fully interruptible — every SEI/CLI in the image is
at $C0AF and $C6E9-$C804 (message-queue critical sections), none in
$CF40-$D180. Lengthening the scan costs latency, not SCI ISR margin.
Why demux dominates
Two naive constructs, both fixable in the $DFF0 cave:
- Bit extraction shifts a byte right k times via
DECB/BEQ/LSRA/JMP— 10 cycles per position, up to 75 to reach bit 7. A mask table replaces it with ~4. - The press/release handler is a
JSRwith full register save (58 cycles) even when nothing changed.
Estimated together: demux ~1,132 → ~400 cycles/pass, full scan 17.3 → ~9.4 ms. UNVERIFIED — not implemented or benched.
Lamp readback and the fault mask
The second 8 bits of each board's 16-bit read — the $D144 path into
$21C2 — are lamp readback, not spare status. This closes the
loop the 1401's second 74HCT165 exists for.
$D144compares each bit against the expected polarity in$24EA;$D180sets the entry's bit 0 accordingly,$D19Eclears it.$21C2[n]therefore holds lamp n's health: 1 = OK, 0 = fault.$D262sweeps all$48= 72 entries and calls$D28Cfor every zero.$D28Cindexes$DFA8by the same lamp number. Mask byte 0 suppresses the report; nonzero emits report type$03with the lamp index in$2519via$C75E.
So $DFA8 is a per-lamp populated map — which of the 72 lamp
positions are physically fitted and therefore worth fault-checking. It
is 72 bytes ($DFA8-$DFEF), exactly matching the array, with the
erased cave starting at $DFF0. Suppressed entries:
| Board | Lamps | Mask |
|---|---|---|
| 0-2 | $00-$17 |
all monitored |
| 3 | $18-$1F |
$1F suppressed |
| 4-6 | $20-$37 |
all monitored |
| 7 | $38-$3F |
$3C, $3F suppressed |
| 8 | $40-$47 |
all 8 suppressed |
61 of 72 positions are monitored. The ninth board carries no fault-checked lamps at all.
This explains the persistent "board/lamp fault item" seen in both
CheckRequest snapshots (RIOv4_2-ANALYSIS.md) — it is a type-$03
report, and $2519 carries the exact lamp index, so a captured
snapshot identifies precisely which lamp is dead.
$D0BC — the routine $C0EB defers by four main-loop passes, and
skips while the test-display flag $2421 is set — walks the two other
72-entry lamp arrays ($249C, $2454) under a rotating bit mask
$24E8 that advances one position per call and reloads at $80 after
8 ($24E9). One bit-plane per tick across all lamps: most consistent
with per-lamp blink/intensity sequencing (INFERRED — the
bit-plane walk is plain, the visual effect is not proven).
The encoder bank — decode has room, the board does not
Settled 2026-07-26 from the board photo and schematic sheet 1.
Addressing. Each HCTL-2016 consumes two 74LS138 outputs, not one:
| Line | Role |
|---|---|
A[0] |
0 = data read, 1 = strobe (the OE/RST pair) |
A[2:1] |
channel within a '138 |
A[3] |
which '138 — U8 or U9, via the GAL's NOT_A3 |
A[4] |
SEL pin = high/low byte of the 16-bit count |
Both '138s take A[0:2] + PH_SEL* + E; U8's G2B is A[3].
Firmware confirms the split — $C8CC-$C9A7 reads the even (data)
addresses $A020/$A030 … $A028/$A038, while $C458-$C4C0 hits the
odd (strobe) addresses $A021-$A029.
So the ceiling is 8 channels: U8 serves OPT1-4 (Y0/Y1 … Y6/Y7), U9 serves OPT5 on Y0/Y1 and has Y2-Y7 free = 3 spare channel decodes. (An earlier estimate reached the same "3 spare" by assuming one decode line per chip; the count is right, the reasoning was not.)
But there are no footprints. The 1407 carries exactly five HCTL-2016 positions — U12-U16 = OPT1-5, headers J5/J6/J7/J3/J4 — and the schematic draws exactly five symbols, U16 being the last. Below OPT5 the PCB is bare: no silkscreen outline, no plated holes. For contrast, this board's genuinely unpopulated sites (U31, the 22V10, and R43/R44) show an obvious white outline over two rows of empty pads. The encoder bank has nothing of the kind.
The '138 outputs are pulled up by R16/R15 (PULLUP10, 2.7K);
R16's spare pin is net nc2, carrying an explicit fab instruction on
the sheet: *"*Note: Do not wire net nc2 on pcb."
Verdict: +3 axes is NOT firmware-only. The decode would accept them; the board offers nowhere to put them. It needs a daughterboard hung off U9's spare selects, or a respin.
Expansion capacity
| Resource | Now | Add | Gated by |
|---|---|---|---|
| Buttons | 72 | +8 | the 0x48-0x4F gap = one 1401 board |
| Lamps | 72 | +8 | same board |
| Analog axes | 5 | +3 (hardware) | decode has 3 spare selects but no PCB footprints |
| Keypad keys | 32 | 0 | map ends at 0x6F |
| Display digits | 8 | 0 | ICM7228B is 8-digit |
| Pod-bus slots (hardware) | 64 | +53 | no logical IDs to reach them |
The pod is built out. 104 of 112 logical inputs are in use. Past +8 buttons, expansion is protocol-bound, not hardware-bound: the bus can address 64 slots and the CPU could service ~17 at 30 Hz, but the map has no IDs left.
The single tenth buttons board is the only expansion this hardware takes without modification. The analog axes looked like the free win but are not: the decode has three spare channel selects on U9, and the board has nowhere to solder them (see the encoder-bank section above). Everything else — more buttons, more keys, more axes — needs either a protocol revision or new hardware.
Adding the tenth board
No free port — all 8 are populated, so it chains onto an existing
port's cable at a spare ADR. SEL. position (A-3+ are unused
everywhere; DD_OUT is open-collector precisely to allow sharing).
INFERRED: port 7 at A-1 is the natural spot, since 0x48-0x4F
follows port 7's 0x40-0x47 in scan order. The harness decides.
Cost: one scan pass (17.3 → 19.1 ms, 58 → 52 Hz), ~100 bytes of script
in the cave, a tenth JSR $CFB6, and five #$48 → #$50 constants
($C399, $C3A2, $C3B4, $C3BD, $D26D). The catch is RAM: the
arrays abut, so growing them in place overruns the lamp buffer at
$220A — it needs a re-layout, though SRAM is 32K with only
~$2000-$2500 in use.
Corrections to earlier claims
Recorded so they don't get re-derived wrongly:
-
PORTEis the pod-bus return path, not spare ADC. All 8 pins carryDI[0..7]; the A/D is never powered up (OPTION/ADCTL/ADR1-4untouched) because the pins are already busy. This is also why there are exactly 8 pod ports —PORTEis 8 bits wide. -
There is no 8× demux win. An acquisition stores whole
PORTEbytes, which looks like all 8 ports captured in parallel — butSELvaries per script and tracks$204E, so the 74LS138 gates the strobes per port and only the selected port returns valid data. -
Ports 4 and 5 are not empty — they are the two keypads.
-
The keypads are not daisy-chained into button shift chains; they have their own ports and their own scripts at
$DBB0/$DBE2. -
$CC53is the keypad-1 scanner, not an encoder sweep. RIOv4_2-ANALYSIS.md'sCheckRequestwrite-up cited$CC53for "sweeps all five encoder channels". The behavioural claim is right (the sweep does end on channel 4) but the address is wrong: the five channels are read at$C8CC-$C9A7(pairs$A028/$A038,$A020/$A030,$A022/$A032,$A024/$A034,$A026/$A036), driven from$C0CB.$C5C9/$C5CCin the self-test are the two keypad scans. -
The disassembly under-reports hardware access. Sync gaps hid a second
PORTEread ($CCF7) and two$A010writes ($CCEA,$CD0B). Search the raw binary forB6 10 0A/B7 A0 10rather than grepping the listing.
The main loop, fully accounted for
C0B0 JSR $C579 ; protocol service (runs between every stage)
C0B3 JSR $CF31 ; button scan — 9 boards, 17.3 ms
C0B9 JSR $CC7E ; keypad 2 scan, 4.0 ms
C0BF JSR $C1CB ; keypad 1 via $C33B + A-9-E chord detector
C0C5 JSR $C0EB ; deferred lamp bit-plane tick (every 4th pass)
C0C8 JMP $C0B0
$C1CB is the test-mode chord detector. It calls $C33B (scan
keypad 1, return its key) every pass and matches the sequence
A → 9 → E in a 3-byte buffer at $20F8; on the third key it sets
the test-display flag $2421, sends TestModeChange ($C70E), and
runs $CBA7/$C241. So keypad 1 is polled continuously — the
earlier "on demand only" reading was wrong; the demand is this
detector. That is the keypad-A-9-E test mode described in
../rio-hardware/display-board-1408.md.
The encoder sweep is not in this loop — it runs from $C0CB
($C8CC-$C9A7).
Open questions
Nothing outstanding on the I/O path. Remaining loose ends are cosmetic:
the precise visual effect of $D0BC's bit-plane walk, and the three
individually-suppressed lamp positions ($1F, $3C, $3F) — which
may record real cockpit wiring rather than anything in firmware.
Reading the schematics without a PDF renderer
Scans_018-014.pdf holds seven CCITT G4 scans (6800×4400, one per
sheet) and no text layer. With no pdftoppm/Ghostscript available, the
working route is to pull each image stream out of the PDF and rewrap it
in a minimal TIFF header (Compression=4, PhotometricInterpretation=0),
which Pillow decodes directly. Sheet order matches the table in
../rio-hardware/README.md.
Cross-references
RIOv4_2-ANALYSIS.md— wedge fix, baud ladder, certification history../rio-hardware/README.md— boards, schematics, pod-bus signal set../rio-hardware/gal/README.md— U7 decode and the memory map