Skip to content
rgloriaPublic

About

TCP sockets for the Nintendo 64, over a SummerCart64. Real hardware, real packets.

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

n64net

TCP sockets for the Nintendo 64, over a SummerCart64.

The N64 has no network hardware. It does have a cartridge port, and a SummerCart64 in that port has a USB link to your computer. n64net turns that link into real sockets: your ROM calls connect(), send(), recv(), and a small bridge process on the host does the actual networking.

  ROM  ──►  n64net  ──►  SC64  ──►  USB  ──►  bridge.py  ──►  the internet

This is not an emulator trick. It runs on real hardware — an original N64 with a SummerCart64, or an FPGA console like the ModRetro M64 — and the packets are real packets.

N64 NETWORK CLIENT TEST
=======================

usb init     OK   SummerCart64
bridge ping  OK   2920 us round trip
tcp connect  OK   h0 -> 127.0.0.1:9999
echo         OK   14 B "HELLO FROM N64"
close        OK   clean
http get     OK   HTTP/1.1 200 OK (868 B)

That last line is an N64 making an HTTP request to a live host on the open internet, over IPv6.


What you need

Cartridge SummerCart64, firmware v2.20.2 or newer
Console Original N64, or an FPGA console (tested on ModRetro M64)
Cable USB-C data cable from the cart to your computer
Host Anything running Python 3.10+ — laptop, Raspberry Pi, mini PC
Toolchain libdragon (preview branch) — or just Docker

The host machine is your network adapter. It needs to be connected to the console by USB and to have internet itself — so a Pi on your Wi-Fi works just as well as a laptop.

Charge-only USB-C cables are the single most common setup failure. They have no data lines. The cart's LED may still light up.


Quick start

1. Install the host side

git clone https://github.com/rgloria/n64net
cd n64net/host
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt

Check the cart is visible. sc64deployer must not be running — it holds the same serial port:

.venv/bin/python -c "import sc64link; print(sc64link.find_port())"
# /dev/cu.usbserial-SC64xxxxxx

2. Build and flash the example

With libdragon installed:

export N64_INST=/opt/libdragon        # wherever you installed libdragon
cd examples/hello-net
../../tools/build.sh

Or with no toolchain at all — only Docker. The first run builds a local image (a few minutes), every run after is fast:

cd examples/hello-net
../../tools/build-docker.sh

Then flash it:

sc64deployer upload --reboot hello-net.z64

--reboot works because the example answers the cart's AUX messages — see No more power cycling.

3. Run the bridge

cd host
.venv/bin/python echo_server.py &                       # a TCP echo target
.venv/bin/python bridge.py --allow 127.0.0.1:9999 --allow example.com:80

The console runs its six checks and prints the results. That's the whole loop.


Writing a networked ROM

#include "n64net.h"

n64net_init();

int sock = n64net_connect("example.com:80", 5000);
if (sock < 0) {
    /* n64net_last_error() carries the bridge's explanation */
}

n64net_send(sock, "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n", 38);

char buf[512];
int n = n64net_recv_wait(sock, buf, sizeof(buf), 5000);

n64net_close(sock);

Hostnames are resolved on the host, so "example.com:80" works with no resolver on the console. In your game loop, call n64net_poll() once a frame — the host's writes are discarded if the console does not acknowledge them within one second.

Full API in n64/n64net.h.


What the link can actually carry

Measured on a ModRetro M64. Read this before designing anything — it is the constraint that shapes every decision.

RTT_ms  =  2.3  +  bytes / 9.56

A 2.3 ms fixed overhead per exchange, plus 19.1 bytes/ms of bandwidth shared across both directions (≈18.7 KiB/s, ≈153 kbit/s).

Cadence Frame budget Round-trip bytes
60 Hz 16.67 ms 137 B
30 Hz 33.33 ms 296 B
10 Hz 100 ms 933 B

Latency is not your problem. 2.3 ms of cart overhead is noise beside a 20–80 ms internet round trip. The tether will not be what makes your game feel laggy.

Bandwidth is your problem. 18.7 KiB/s is roughly ISDN, shared both ways.

Comfortably affordable:

  • Controller state is 4 bytes per pad — four players is 16 B, a tenth of one frame's budget
  • Rollback netplay with a ~100 B state delta per frame
  • Lobbies, matchmaking, leaderboards, presence, chat
  • Save and config sync

Out of reach:

  • Streaming audio, textures, or level data
  • Any per-frame payload past a few hundred bytes
  • Bulk downloads during play — move them to a loading screen and budget ~18 KiB/s

Design rule, straight off the measurements: batch small messages rather than sending them individually, and keep bulk transfers near 1 KiB. At 4 bytes per exchange the link runs at 3.3 KiB/s — 82% of capacity lost to fixed overhead. By 1 KiB you are within 3% of the ceiling.

Measure your own console with examples/echo-benchmark — an FPGA console emulates PI timing rather than reproducing it, so its numbers are its own. See docs/link-budget.md.


Two consoles: netplay-pong

examples/netplay-pong is two N64s playing each other:

N64 A -- SC64 -- bridge.py --\
                              relay.py
N64 B -- SC64 -- bridge.py --/

It uses delay-based lockstep, which is the model this link can afford. Only inputs cross the wire, never game state — both consoles run the same deterministic simulation over the same inputs and arrive at the same world. That is 6 bytes per frame instead of a world snapshot, against a budget of ~137 bytes round-tripped per frame.

Local input is applied INPUT_DELAY frames in the future, giving the peer's input that long to arrive. The first few frames use neutral input on both sides, so a match self-starts with no handshake.

Determinism is the whole game. The simulation is integer-only: no floats, no uninitialised state, and the RNG advances only inside the simulation step so both consoles consume identical values in identical order. The two sides exchange a state checksum every 60 frames and print DESYNC if they ever diverge, rather than quietly drifting into different matches.

Running it

On the machine hosting the match:

python3 host/relay.py --port 7777

Then on each console's host, pointing the ROM at the relay:

cd examples/netplay-pong && ../../tools/build-docker.sh RELAY=192.168.1.50:7777
sc64deployer upload --reboot netplay-pong.z64
python3 host/bridge.py --allow 192.168.1.50:7777

Only have one console?

host/virtual_player.py joins as the second player, so you can run the whole netplay path with one console:

python3 host/relay.py &
python3 host/virtual_player.py --style sweep     # or idle, or jitter

It is an input source, not a second simulation — it does not model the ball, so it cannot chase it, and it cannot detect desync. Only two real consoles can do that.

Verified at a sustained 60 fps with a console and a virtual player, and the match recovers on its own when a peer drops and rejoins.


No more power cycling

Normally, flashing a new ROM means reaching for the power switch. sc64deployer upload --reboot avoids that, but only if the running ROM answers the cart's AUX messages. n64/sc64aux.c does:

sc64aux_init(on_halt);      /* once at startup */

while (1) {
    sc64aux_poll();         /* once a frame; does not return on Reboot */
    ...
}

Then upload --reboot halts your ROM, overwrites it, and restarts the console by itself. The hello-net example does this, and it works.

The reboot reloads IPL3 from ROM and re-enters it, declaring an NMI-style reset so IPL3 leaves RDRAM alone. If your ROM uses a CIC other than 6102/7101, call sc64aux_set_cic_seed() first.


Repository layout

n64/                  N64-side library
  n64net.[ch]           sockets over the SC64 USB channel
  sc64aux.[ch]          AUX messages + soft reboot (independent of networking)
host/                 host-side, pure Python
  sc64link.py           SC64 serial protocol
  netproto.py           bridge wire protocol + codec
  bridge.py             the bridge itself (--selftest needs no hardware)
  echo_server.py        TCP echo target for testing
  relay.py              match relay for netplay-pong
  virtual_player.py     stand-in second player (one-console setups)
  benchmark.py          measure your link
  stall_probe.py        large-transfer stall hunt
examples/
  hello-net/            six-step end-to-end test
  echo-benchmark/       echo ROM for benchmark.py
  netplay-pong/         two consoles playing each other
docs/
  protocol.md           wire protocol specification
  link-budget.md        measured performance and methodology

Test the bridge with no console attached:

cd host && .venv/bin/python bridge.py --selftest

Security

bridge.py opens TCP connections from your computer on the console's instruction. A ROM could otherwise reach anything your machine can, including your LAN — so the bridge refuses to start without an explicit allowlist:

$ bridge.py
bridge.py: error: no destinations permitted.

bridge.py opens TCP connections FROM THIS MACHINE on the console's
instruction, so it will not start without an explicit allowlist:

    bridge.py --allow example.com:80 --allow 127.0.0.1:9999

--allow HOST:PORT is repeatable, and each value is validated at startup — a missing port, a non-numeric port, or one outside 1–65535 is rejected before the bridge opens anything. Hosts are compared case-insensitively, so EXAMPLE.COM:80 cannot slip past an entry of example.com:80. IPv6 literals must be bracketed: [::1]:9999.

To lift the restriction entirely — only sensible for a ROM you wrote yourself:

bridge.py --allow-any        # logs a warning on every start

Known limitations

  • The host must be attached. This is a tether. An FPGA console's own Wi-Fi is not reachable from the N64 side of the machine, so it cannot help here.
  • Existing commercial games cannot use this. They have no networking code; a transport cannot add a capability the ROM does not have.
  • Docker on Apple Silicon / ARM. The official libdragon image is published for linux/amd64 only, so a plain docker pull fails with "no matching manifest". build-docker.sh passes --platform linux/amd64 and runs it under emulation, which works but makes the first build slow. Note also that the official image ships only the cross-toolchain — libdragon itself is not in it, which is why build-docker.sh derives its own image.
  • Occasional stalls above ~16 KiB. The host FTDI driver sometimes drops the stream mid-transfer, always on an exact FTDI packet boundary. SC64Link detects it and reopens the port automatically. Roughly 1 in 137 large transfers; irrelevant at networking payload sizes.
  • usb_write() reports phantom timeouts. libdragon's SC64_WRITE_TIMEOUT is 100 ms, so any write over ~1.9 KB returns -1 even though the data arrives intact. n64net accounts for this; if you call libdragon's USB API directly, only a 0 return is a real failure.

Credits

Built on libdragon and SummerCart64 by Polprzewodnikowy. The SC64 register map and AUX protocol come from that project's excellent documentation.

No code from SummerCart64 (GPL-3.0) or N64FlashcartMenu (AGPL-3.0) is included here — sc64aux.c was written against the published register documentation, and the IPL2 exit register contract it relies on is documented in libdragon's Unlicense boot_trampoline.S.

License

MIT — see LICENSE.

About

TCP sockets for the Nintendo 64, over a SummerCart64. Real hardware, real packets.

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages