#552's negotiation removed the reason to ship the wire-speed enhancement off: every non-confirming case (older core, peer explicitly set to disabled, peer that never answers, or frontend netplay -- which cannot reach the negotiation channel over SET_NETPACKET_INTERFACE at all) degrades cleanly to authentic stock timing in UARTFrameUsec(), verified against src/jerry/jlink.c's JLinkNegEligible()/JLinkNegTick() and src/jerry/uart.c's intent/effective split, not just the option text. Updates the option description (still said "off by default") and the default-value mentions in docs/netlink-design.md, docs/voice-modem.md, and docs/netlink-user-guide.md, plus a stale comment in test/tools/netlink_latency.c that asserted the option's old default. Closes #645 Co-authored-by: Claude Opus 5 <[email protected]>
17 KiB
Jaguar Voice Modem (JVM) — protocol as spoken by Ultra Vortek
Status: derived 2026-08-17 by disassembling the retail Ultra Vortek ROM
(Ultra Vortek (1995).jag, 4 MB, the only JVM title). The game's own modem
driver is the authoritative spec for what a modem must say; no BigPEmu
assets were consulted. Emulated by src/jerry/voicemodem.c (issue #481).
Hardware placement
The JVM plugs into the DSP port and talks to JERRY's asynchronous serial
UART ($F10030 ASIDATA / $F10032 ASICTRL+ASISTAT / $F10034 ASICLK) —
the same silicon JagLink and CatBox use, so the netlink transport
(docs/netlink-design.md) carries it.
How Ultra Vortek services the port (all addresses are the retail ROM):
- 68K side (driver at
$80AF96–$80BA19, runs in ROM): transmits by polling ASISTAT TBE (bit 8) and writing ASIDATA; never reads RX data registers outside an init flush. - DSP side: the audio engine's I2S interrupt handler tail
(
$F1B168–$F1B1C2, loaded to DSP RAM from ROM offset$E31C) polls ASISTAT once per I2S sample: on ERROR (bit 15) it writes CLRERR if SERIN is high; on RBF (bit 7) it reads ASIDATA and pushes the byte into a 256-byte ring in main RAM$6274–$6373, write pointer at DSP RAM$F1BA84, read pointer at$F1BA88. The 68K driver consumes replies from that ring only. There is no RX interrupt use despite RINTEN being set; JINTCTRL bit 4 is enabled but the transfer path is the DSP poll. - UART programming: ASICTRL =
$0021= ODD (bit 0) + RINTEN (bit 5). Note parity is DISABLED here — PAREN is bit 1 and is clear. An earlier version of this line read "odd parity", which is wrong: only the ODD bit is set. Per JTRM Rev 8 p.94, "when parity is disabled the value of the ODD bit is transmitted in the parity bit time", so the slot is still sent and the frame is still 11 bit times (1 start + 8 data + 1 parity slot + 1 stop). That 11 is whatUARTFrameUsec()insrc/jerry/uart.cmultiplies by, and it is correct — verified against JTRM Rev 8 p.93, which also gives the divisor assystem clock / (N+1)then /16. Wake attempts at ASICLK =$1C(~57.6 kbaud) falling back to$56(~19.2 kbaud); after wake succeeds the driver issues$FFFE(see below) and settles at ASICLK =$56— i.e. the DTE rate in use is 19200, which works out to ~576 µs per byte.
The 19200 rate is permanent, and the $86xx connect speed cannot change
it. Confirmed from two independent directions:
- The official Atari spec (
docs/atari-jaguar-1999/07 - The Jaguar Voice Modem.pdf, p.13) defines$FFFEas "Set host baud rate to 19200. Only reset ($FFFF) can change the baud rate back to 57600." The DTE link has exactly two rates and only those two commands select them. The$8100reply reports the analog modem-to-modem handshake rate and never touches the baud register. - In the retail ROM,
$F10034(ASICLK) is written exactly six times in the whole 4 MB image, every one an immediateMOVE.W #imm,$F10034with a value of only$1Cor$56, all inside the wake/reset routines ($80B10A–$80B18C,$80B1EC–$80B238,$80B358). There is no register-sourced write anywhere. The$86xxlow byte is decoded at$803C30and stored solely to RAM$57D4for the MODEM CONNECT SPEED display.
So the ~5.8 ms of wire time each way for a 10-byte pad packet is authentic and not tunable through the protocol. Reducing it is an emulator-side enhancement — see issue #498.
Note on sourcing: this decode was derived by disassembling the retail
ROM, deliberately without consulting other emulators. The official Atari JVM
manual above (present in the gitignored docs/atari-jaguar-1999/ tree)
independently corroborates the command set and both baud rates. Consult it
first for any future JVM work — it is the primary source.
Menu entry: type 911 on the numpad while the title logo is on screen
("AWESOME" sting → INITIALIZING VOICE MODEM). With no modem answering, the
game shows MODEM INITIALIZING FAILED / MAKE SURE THE MODEM IS ON.
Wire format
Console → modem: 16-bit command words, low byte first ($8A21 goes out
as $21 $8A). Every command is answered except $FFFE.
Modem → console: 3-byte messages: sync $FF ($FE also accepted), then
high byte, then low byte. Reply classes, per the driver's parsers
($80B3B0 transact, $80B452 data-receive, $80AFE2 ring scan):
| High byte | Meaning | Driver behavior |
|---|---|---|
| echo of command | command acknowledged | compared against what was sent |
$A4 |
async status event | $57F6 &= word (a bit-clear mask); if the mask goes to zero and bit 0 of the low byte is set the driver returns error $FFF3 ("lost phone connection" class); otherwise consumed silently |
$B1 |
async ring indicate | returns status $FFF4 (RING) to whatever call is in progress |
$F0 |
data byte (low byte = payload) | data-phase receive |
$F3 |
data frame end; $F301 = clean end-of-packet |
terminates a receive burst |
$86 |
connect result for $8100 (low byte = speed code, shown as MODEM CONNECT SPEED) |
proceed with connection |
$80..$8F (& $F000 == $8000) |
$8100 "not yet" |
retry (up to 255×) |
$FFFE (word) |
"nothing" reply to $6800 DTMF poll |
poll again |
Command set (as issued by Ultra Vortek)
| Word | Name (inferred) | Modem must do |
|---|---|---|
$FFFF (two raw $FF bytes) |
wake/detect | reply word $B800; sent at 57.6k, then 19.2k, then 57.6k until answered |
$FFFE |
switch line rate (to 19200) | no reply (console switches ASICLK right after) |
$0102 |
ident/ping after wake | echo |
$0501 |
begin config block | echo |
$000F $B000 $3952 $A021 $F207 $B602 $B504 $B405 $A3FC $A060 |
config parameters | echo each |
$2C80 |
originate mode (appears in the dial config table, at dial start, and again after handshake) | echo |
$2480 |
answer mode (same positions on the answer side) | echo; going off-hook to answer a pending ring |
$8C01 |
dial-tone check | echo $8C01 when line ok (anything else retried ~1000×, then NO DIALTONE) |
$8A2n |
send DTMF digit n (0–15; the dialed number, and the post-connect probe digits) | echo; deliver digit to the far side when connected |
$8C00 |
call-progress query | low byte $1x = tone/dial still in progress, $0x = idle/done |
$6800 |
poll for detected DTMF digit | $68nn = heard digit nn, word $FFFE = nothing yet |
$8000 |
go to data mode (pre-connect) | echo |
$8100 |
connect/carrier query | $86xx = connected at speed xx, $80xx = not yet (retried); after $86xx the console waits for $A4 events to clear $57F6 bits 0+1 (send $A4FC) |
The $86xx speed byte is validated (RAM $803C30, post-connect): the
game computes (xx >> 4) - 8; a negative result is declared TOO MUCH
TELEPHONE NOISE and the call is torn down. Nibbles 8..E index a
displayed-speed table: 9600, 9600, 12000, 14400, 16800, 19200, 57600.
The emulation replies $86D0 (19200, matching the programmed UART rate).
| $0002, $A3FE | post-connect config | echo |
| $9000 | hang up / abort | echo (sent repeatedly until echoed) |
| $A040, $A0A0 | audio path control (hangup path sends $9000 then $A040) | echo |
| $F0xx | data byte xx to peer (sent raw, no echo consumed) | forward to peer; do not echo |
Connection choreography (both consoles run Ultra Vortek)
Originator: wake → $FFFE → $0102 → $0501 + config($2C80) →
$8C01 dial tone → $2C80 + $8A2n digits of the phone number ($8C00
polled between digits; keypad $10 = 2 s pause) → poll $6800 for the
answerer's probe digits 0,9,8,…,1 → send its own probe digits
1,2,…,9,0 as $8A2n, polling $8C00 after each → $2C80.
Answerer: wake/config happen when the user enters the modem screen; on
RING ($B1xx) and the user choosing ANSWER PHONE: config($2480) → sends
probe digits 0,9,8,…,1 → listens via $6800 for 1,2,…,9,0 → $2480.
The mutual DTMF probe is the line-quality check ("TOO MUCH TELEPHONE
NOISE" when it fails). Speed/carrier negotiation is the $8000/$8100/
$86xx + $A4FC sequence at $80B278.
Top-level flow (RAM code; RAM address = ROM offset + $43FE)
The whole 68K driver is copied to RAM with the main program and runs
there (absolute jsr $11298-style refs prove it); the $80Bxxx
addresses in this document are the ROM image of RAM $F394..$FE17.
Dial UI path ($803A50): dial_seq ($80B6DC) -> go_online ($80B278)
-> $57DB=0 (this console is player 1). Answer path ($803ABC):
answer_seq ($80B850) -> go_online -> $57DB=1. Both converge at
$803C30: speed-nibble check, then $57DC=1 — the flag that switches
the game's input layer to modem-exchanged pad state.
What UV's netplay actually exchanges: with $57DC set, each frame
the game packs its local pad longword (plus a coin/meta bit 22) into
$579A and sends it as one 4-byte packet ($803DDC -> SendData
$80B570); the far side's packet is received into $57A0 and the two
pads are fed to the player slots ($57E4/$57E8, selected by $57DB).
$57DD throttles to one packet per received packet — classic lockstep.
Data phase (in-game)
- Sender: 4 payload bytes per exchange, each as a raw
$F0xxword ($80B570, from$579A), no replies consumed. - Receiver: expects 4 ×
$F0xxmessages then$F301end-of-packet ($80B596→$57A0). The$F301is generated by the modem (packet framing), not sent by the far console — the emulation emits it at TX-burst end. - Async
$A4with bit 0 set (e.g.$A401) during a call = line drop → LOST PHONE CONNECTION.
Why the local player sees their own move late (issue #498)
The data phase is ~10 console bytes per direction per frame (4 × $F0xx
- the
$F301marker). At the ASICLK =$56the driver settles on, one character frame is11 × 16 × 87 = 15312RISC cycles ≈ 576 µs, so a packet is ~5.8 ms of pure wire time each way — against a 16.7 ms video frame, with$57DD's strict lockstep meaning the local pad cannot be displayed until the whole exchange completes. The instinct is to blame the network; the arithmetic says the emulated cable is the choke point, andTCP_NODELAYplus the adaptive reply wait already cover the network side.
This is authentic — a real Voice Modem at 19200 baud was exactly this
slow — so it is addressed as an enhancement, not a bug fix: core
option virtualjaguar_netlink_speed (default auto since #552's
negotiation made a bad guess impossible; disabled opts back out to
authentic timing) divides the character frame time by a fixed 4x once
the two console instances negotiate that out-of-band and agree (see
docs/netlink-design.md's negotiation section) — never a magnitude the
player picks, and never applied unilaterally by one side. The knob is a
single one: UARTFrameUsec() in src/jerry/uart.c schedules both the TX
drain and the RX arrival, and it takes the untouched stock branch
whenever the option is off, no link transport is selected, or the peer
has not (yet, or ever) confirmed. It changes only when bytes appear,
never which bytes or in what order. Unlike the config-level "auto"
request, the CONFIRMED divisor is machine-affecting the moment a peer
agrees (it reschedules the event queue) and IS part of the savestate
(STATE_VERSION_TEAMTAP/v13, extended in place) — see
docs/netlink-user-guide.md.
What the emulation does (src/jerry/voicemodem.c)
A virtual modem in front of the jlink transport (virtualjaguar_uart_device = voicemodem; JagLink stays the default). Dial resolves to the existing
netlink TCP/netpacket/loopback session; no voice, no DTMF audio, no real
telephony. Inter-modem wire protocol (2-byte frames over the transport):
$01 digit DTMF, $02 - dial, $03 - answer, $04 - hangup, $05 xx
data byte, $06 - end-of-packet. Ring indications are paced ~1/second;
$A4FC is queued after each connected $86xx reply so the $57F6 wait
can never miss it. ASICLK is ignored — the UART already paces bytes; the
modem is rate-agnostic. Modem session state is host-side (like jlink's
sockets) and deliberately not serialized in savestates: loading a state
mid-call behaves as a pulled phone line.
Host-side voice chat (the JVM's actual selling point — simultaneous
voice + data) is a separate, opt-in feature that never touches this UART
protocol or the emulated machine: see docs/voice-chat-design.md
(issue #485). DTMF tone audio remains out of scope here and there —
tones never reached the TV on real hardware; functional DTMF signalling
($8A2n / $6800) is already enough for Ultra Vortek.
Troubleshooting a call that never connects
Ultra Vortek prints INITIALIZING VOICE MODEM before any reply arrives, so
that screen looks identical whether or not a modem is answering — it is not
evidence the link works. The core logs the facts instead; look for
[NETLINK] in the frontend log.
At load, one line reports what the options actually resolved to:
[NETLINK] mode=tcp_client device=voicemodem peer=127.0.0.1:42171 (address from core option)
[NETLINK] mode=tcp_server device=voicemodem port=42171
[NETLINK] built-in TCP link disabled (device=voicemodem) -- frontend netplay will carry the link if a session is running
then the link state, on edges only:
[NETLINK] tcp_client open, waiting for peer... <- no one on the other end yet
[NETLINK] link UP (tcp_client, device=voicemodem)
[NETLINK] link DOWN (tcp_client) -- peer disconnected
Reading them:
- "Network Link" is at its default (
auto) and the OSD saysVoice Modem selected but link is idle -- start netplay or pick a host— this is the single most common report and is not a bug.autoresolves to netplay-when-a-session-is-live, else idle; it never restores a previously chosen direct mode and never auto-dials a discovered peer. Either start a RetroArch netplay session, or set "Network Link" toTCP Host (listen)/TCP Client (connect)explicitly on both sides. - No
[NETLINK]lines at all — the core is too old to have the voice modem. Check the binary the frontend actually loaded, not the build tree:strings <core> | grep -c virtualjaguar_uart_devicemust be non-zero. device=jaglink— "Network Link Device" is still at its default. The raw cable cannot answer the wake handshake, so the game reports MODEM INITIALIZING FAILED. It must be set to Voice Modem on both sides.- Stuck at
waiting for peer...— the transport never paired. Check both sides agree on the port, and that exactly one is TCP Host. Intcp_clientmode, hosts running on the same LAN normally appear in the "Network Link Host" list by themselves within a couple of seconds — if the one you want isn't there, confirm it is actually intcp_servermode (discovery only beacons/listens intcp_server/tcp_client, never inauto) and that nothing on the network blocks UDP broadcast (client-isolated Wi-Fi APs are a common culprit). 'From file' selected but no address read— the "From file" preset was chosen but<system>/vj_netlink.txtis missing or empty; the address fell back to 127.0.0.1. The file format is one line, the address only, no port — e.g.192.168.1.42ormyhost.local— the port always comes from "Network Link Port".- The host you had selected quietly reverted to
127.0.0.1— a discovered host that goes offline (or stops beaconing) expires from the list after 10 s; if it was your active selection, the OSD saysSelected host <addr> dropped off the LAN -- falling back to 127.0.0.1and the option resets. Re-pick it once it reappears, or pin it withvj_netlink.txt/VJ_NETLINK_HOSTif it's on an unreliable network. - OSD says
Host is running JagLink, you are set to Voice Modem(or the reverse) — the discovered peer and your "Network Link Device" don't match. JagLink/CatBox and the Voice Modem never interoperate; set both sides to the same device. link UPon both sides but the call still fails — that is a modem-layer problem, not transport. Re-run withVJ_VM_TRACE=1for the command/reply stream, and see the choreography table above.
On-screen narration mirrors these log lines (SET_MESSAGE_EXT, 4 s,
transitions only) so a player without a log window in front of them sees
the same facts: link resolved at load, link up, link lost, "Found N Jaguar
host(s) on the LAN", the device-mismatch warning, the dropped-selection
warning above, and the idle-Voice-Modem message.
Open questions
$B1xxring payload (we send$B100); the driver only matches the high byte.- Config words (
$3952etc.) are opaque Phylon chipset parameters; echoing satisfies the driver. - The retail beta ROM and any voice-mode-specific commands beyond
$A0xxechoes are unexplored.