Technical architecture — OricTel
Overview
OricTel is made of two independent subsystems:
- Oric program (6502 C/ASM) — the Minitel terminal.
- Python bridge — a WebSocket ↔ TCP gateway (secondary path).
The reference setup is now LOCI + PicoWiFiModemUSB: the AT modem opens the TCP sockets to the Minitel server itself, the bridge is no longer in the path. It remains useful for servers exposed only over WebSocket.
Data flow
Minitel server (ws://3617.fr/ws)
|
| WebSocket (binary frames, raw Videotex bytes)
v
+-------------------+
| orictel_bridge.py | Python asyncio
| TCP port 3615 | Transparent bidirectional relay
+-------------------+
|
| TCP socket (raw bytes, no framing)
v
+-------------------+
| Phosphoric | Oric emulator
| ACIA backend | --serial picowifi / com: / tcp:
| AT modem 8N1 | 1200 baud (Control $18, Command $0B)
+-------------------+
|
| ACIA 6551 registers @ $0380-$0383 (LOCI base)
| Cycle-accurate timing
v
+-------------------+
| OricTel program | 6502 code (cc65)
| |
| serial_asm.s | <-- Low-level ACIA driver (polling, SEI/PLP)
| serial.c/tx.c | <-- Thin layer + non-blocking transmit queue
| at_modem.c | <-- AT commands, hang-up, carrier watch
| videotex.c | <-- Protocol state machine
| display.c | <-- HIRES rendering (1 row/pass budget)
| display_asm.s | <-- Assembly blits (cell, run, double height)
| fonts.c | <-- G0 / G1 / G2 tables
| keyboard.c | <-- Keyboard scan + Minitel mapping
| ui.c | <-- Menu helpers (host-testable)
| main.c | <-- Main loop, menus, session
+-------------------+
main.c is a menus → connection → session cycle: the session is only left through ESC
(confirmed in the status bar, without clearing the page), which hangs up (at_hangup) and goes back
to the Connection mode menu with vtx_init. The connection failure and carrier loss screens return
on ESC. ESC on the main menu exits OricTel through jmp ($FFFC) (reset vector, independent of ROM
1.0/1.1).
Reference setup (hardware)
Oric --> ACIA 6551 $0380 (LOCI cartridge)
|
| RP2040 firmware: 32-BYTE RX ring in front of the register
v
USB-CDC --> PicoWiFiModemUSB --> WiFi --> Minitel server (TCP)
The 32-byte ring is an important design element: the bare 6551 buffers a single byte, and OricTel uses no IRQ.
This buffer is what gives the software a tolerance of about 250,000 cycles (30 bytes at 1200 baud) between two
reads. Emulating this setup without --serial-buffer is therefore more pessimistic than
the real hardware.
Oric memory map
$0000-$00FB Zero Page (cc65: $00E2-$00FB)
$0100-$01FF 6502 stack
$0200-$02FF System variables
$0300-$030F VIA 6522 (mirror $0300-$03FF)
$0380-$0383 ACIA 6551 (serial, LOCI base)
$0400-$0500 Oric system area
$0501-$97FF OricTel CODE + DATA + BSS (~37 KB; BSS bounded at $9800)
$9800-$9BFF Text-row character set (copy of font_g0)
$9C00-$9FFF cc65 stack (1 KB; measured < 32 bytes in session)
$A000-$BF3F HIRES framebuffer (8000 bytes)
$BF68-$BFDF 3 text rows below HIRES = status bar
$C000-$FFFF ROM (16 KB)
ACIA 6551 registers (Oric side)
| Address | Read | Write |
|---|---|---|
$0380 | Received data | Data to send |
$0381 | Status register | Program reset |
$0382 | Command register | Command register |
$0383 | Control register | Control register |
Effective configuration (LOCI base $0380)
- Control:
$18= 1200 baud, 8N1, internal clock - Command:
$0B= DTR, RX IRQ disabled, TIC=10 (RTS low without TX IRQ)
6551 datasheet trap: TIC=01 (values $05/$07) enables the TX IRQ; TDRE
being permanently set, the ACIA kept reasserting /IRQ and the machine froze (v0.3.3/v0.3.4 regressions). The old
V23 7E1 configuration ($28/$69) no longer matches any supported setup: the
PicoWiFiModemUSB talks 8N1.
Status register bits
- Bit 3 (RDRF=
$08): received data available - Bit 4 (TDRE=
$10): transmitter ready - Bit 5 (
$20): /DCD, inverted logic — on LOCI this bit only follows the USB state and DTR, never the call state; carrier loss is detected through the modem'sNO CARRIERresponse, confirmed by 4 s of silence
Status bar (3 text rows)
In HIRES, the Oric still displays 3 text rows below the 200 graphic lines ($BF68-$BFDF). They were
hidden because their character set, which the ROM places at $9800-$9FFF, was overwritten by the C
stack. The stack is reduced to 1 KB at $9C00-$9FFF (the alternate set's area, never selected) and
display_init copies font_g0 to $9900-$9BFF. Row 0 (cyan):
C/F indicator, server, mm:ss timer, rendering mode, ESC reminder; row 1
(yellow): transient messages; row 2 (white): key help. The link indicator thus leaves row 0 of the Videotex
page, which belongs to the server.
Time base: VIA 6522 Timer 2
The session loop has no ISR and its iteration has no fixed duration. Delays (carrier-loss confirmation 4 s,
indicator back to F after 30 s) are therefore counted on T2
($0308/$0309), read on every iteration: the ROM only uses it for tape, it counts down continuously
at 1 MHz, and elapsed cycles are accumulated in 10 ms ticks. OricTel arms it itself and disables its IRQ
(IER = $20). Verified by make test-carrier: 4.0 s ± 0.1 on ROM 1.0 and 1.1.
Videotex protocol — state machine
NORMAL ──ESC($1B)──> ESC_SEQ
──US($1F)───> US_ROW
──SO($0E)───> (switch to G1)
──SI($0F)───> (switch to G0)
──$20-$7F──> (display character)
──$08-$0B──> (cursor move)
──$0C─────> (clear screen)
──$0D─────> (carriage return)
ESC_SEQ ──$40-$47──> (ink colour) -> NORMAL
──$50-$57──> (background colour) -> NORMAL
──$48/$49──> (flash on/off) -> NORMAL
──$4C-$4F──> (size) -> NORMAL
──$5B─────> CSI
──$39─────> PRO
US_ROW ──$40-$57──> US_COL (stores row)
US_COL ──$41-$68──> NORMAL (positions cursor)
CSI ──params+letter──> NORMAL (ANSI-like command)
HIRES display
- Resolution: 240×200 pixels = 40 columns × 25 rows
- Each cell: 6×8 pixels
- 3 character sets:
- G0: alphanumeric (Minitel ASCII, French accents)
- G1: semigraphic mosaics (2×3 blocks = 64 patterns)
- G2: additional characters (diacritics)
Per-cell attributes
- Ink colour (0-7)
- Background colour (0-7)
- Flash
- Video inverse
- Underline / mosaic separation
- Size (normal, double height, double width, double size)
- Conceal
Optimised rendering (“dirty” spans, measured budget)
Each modified row is marked “dirty” with the touched column range. Rendering is not paced on the video frame:
it is budgeted at one row per pass from the main loop, which calls display_render()
again as long as dirty rows remain and no byte is waiting in reception. During a pass reception is not
re-read: if the pass exceeds the RX buffer tolerance, bytes are lost.
Measured costs (make bench-render, real 6502 cycles)
| Region | Cost | In byte-times at 1200 baud |
|---|---|---|
display_render() pass (1 row) | ~151,000 | 18.8 |
| realistic hybrid row | ~79,000 | 10.1 |
| double-height row | ~134,000 | 16.7 |
| single-cell update | ~30,000 | 3.6 |
vtx_process() (40 characters) | ~105,000 | 13.2 |
Budget of one byte at 1200 baud: 8,333 cycles. Real setup tolerance: ~250,000 cycles (32-byte ring of the LOCI firmware). The benchmark also prints the SHA-256 fingerprint of the framebuffer after a canonical page: any optimisation meant to be visually neutral must leave it unchanged.
Assembly fast paths (display_asm.s)
| Routine | Role |
|---|---|
blit_cell8 | one normal-size cell (~190 cycles vs ~2,000 in C) |
blit_run | run of eligible cells; stops on any non-normal size |
blit_cell4x2 | double height: 4 source lines stretched over 8 pixel lines |
display_clear | page-wise framebuffer fill (~5 ms) |
cc65 trap: ctx->screen[row][col] generates a 16-bit multiplication by 240 on every access, and
a parameter pointer is reloaded from the software stack on every dereference. Hot loops therefore hoist a row
pointer, and videotex.c goes through a file-scope context pointer plus offset tables: 4.0× on
rendering, 1.50× on the decoder.
Reproduced from docs/ARCHITECTURE.md (authoritative, up-to-date version on GitHub).