Files
TeslaRel410/restoration/rio-firmware/IO-ARCHITECTURE.md
T
CydandClaude Opus 5 b615f319db RIO: decode the pod-bus I/O architecture end to end
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>
2026-07-26 22:05:29 -05:00

22 KiB
Raw Blame History

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 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/+$0Akeypad 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 $8Bnot 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/JMP10 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 portsPORTE 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.

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