# 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`](RIOv4_2.disasm.asm) plus the recovered memory map in [`../rio-hardware/gal/README.md`](../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 `CCK` pulses shifting a walking `1` down the cable into the board's address 74HCT164; its position picks the `ADR. SEL.` jumper (`A-0` = shift position 7, `A-1` = 6, `A-2` = 5). The last pair asserts the strobe. - **32 bytes — payload.** 16 `CCK` pulses, 2 bytes each. On reads, `PORTE` is 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.asm` renders > `$CFE0-$D003` as `.byte` — it lost sync on the `18 FF` (`STY` > extended) 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: 1. 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. 2. The press/release handler is a `JSR` with 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. - `$D144` compares each bit against the expected polarity in `$24EA`; `$D180` sets the entry's bit 0 accordingly, `$D19E` clears it. `$21C2[n]` therefore holds **lamp n's health**: 1 = OK, 0 = fault. - `$D262` sweeps all `$48` = 72 entries and calls `$D28C` for every **zero**. - `$D28C` indexes **`$DFA8`** by the same lamp number. Mask byte 0 suppresses the report; nonzero emits report type **`$03`** with the lamp index in `$2519` via `$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: - **`PORTE` is the pod-bus return path, not spare ADC.** All 8 pins carry `DI[0..7]`; the A/D is never powered up (`OPTION`/`ADCTL`/ `ADR1-4` untouched) because the pins are already busy. This is also *why there are exactly 8 pod ports* — `PORTE` is 8 bits wide. - **There is no 8× demux win.** An acquisition stores whole `PORTE` bytes, which looks like all 8 ports captured in parallel — but `SEL` varies 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`. - **`$CC53` is the keypad-1 scanner, not an encoder sweep.** RIOv4_2-ANALYSIS.md's `CheckRequest` write-up cited `$CC53` for "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`/`$C5CC` in the self-test are the two keypad scans. - **The disassembly under-reports hardware access.** Sync gaps hid a second `PORTE` read (`$CCF7`) and two `$A010` writes (`$CCEA`, `$CD0B`). Search the raw binary for `B6 10 0A` / `B7 A0 10` rather 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`](../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`](../rio-hardware/README.md). ## Cross-references - [`RIOv4_2-ANALYSIS.md`](RIOv4_2-ANALYSIS.md) — wedge fix, baud ladder, certification history - [`../rio-hardware/README.md`](../rio-hardware/README.md) — boards, schematics, pod-bus signal set - [`../rio-hardware/gal/README.md`](../rio-hardware/gal/README.md) — U7 decode and the memory map