Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,11 @@ asks for each chip's file once. See [README-SNES.md](README-SNES.md).
the host gets a short room code (like `K7Q-M4X`) to read out or copy, and the other player types it.
The two browsers meet through NESER's meeting service, liaison (`wss://liaison.rmstdope.eu/ws`), which
only passes the connection setup between them and never sees the game; they then connect directly,
so both computers must be on the same network (or VPN). liaison accepts pages served from
each browser first asking outpost, NESER's own STUN server (`stun:outpost.rmstdope.eu:3478`), for its
public address, so players in different homes can play together and each can see the other's internet
address. Networks that pick a new public port for every destination (common on mobile, hotspot and
work networks) cannot be connected this way; after about 10 seconds both players are told so. With
outpost down, only computers on the same network (or VPN) connect. liaison accepts pages served from
https://neser.rmstdope.eu and from `localhost`/`127.0.0.1` only, so a self-hosted copy reached by another
address (a LAN IP, say) cannot start a network game. The two must run the same NESER build, and
a connection slower than 120 ms round trip is refused. Once connected the host presses **Start game**:
Expand Down
2 changes: 1 addition & 1 deletion architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@ The web frontend is bundled with **Vite** (config at `vite.config.ts`, root: `we
| `web/src/save-state/` | Save state persistence using IndexedDB (`save_state_storage.ts`, `save_state_controller.ts`, `save_state_context.ts`), offered where the console table says so: NES, Game Boy and SNES games (`save_state_support.ts`), and the Load button's preview of the saved state, a picture of its frame and when it was saved, kept in the IndexedDB database `neser-save-previews` (`save_preview.ts`). |
| `web/src/debugger/` | Browser-based debugger panels — disassembly, OAM viewer, watch expressions, PPU viewer layout/scroll — docked beside the picture, and whether the top bar shows its button (`debugger_dock.ts`, kept in `localStorage` under `neser.debuggerButton` and set from the settings panel's Developer tab; F5 opens it either way). |
| `web/src/shortcuts/` | Keyboard shortcut actions, and which key targets they leave alone (`keyboard_target.ts`: editable fields and open dialogs). |
| `web/src/netplay/` | Connecting two browsers for a network game (nr-ayk5.3) and sending the host's game (nr-ayk5.4): `game_package.ts` (the ROM, any SNES firmware and the host's save state as one `NSRG` byte string, read back as untrusted: anything that does not describe itself exactly is refused, never thrown over; `crc32`, since `crypto.subtle` is missing on a plain-http page), `build_id.ts` (the NESER version and short commit Vite stamps in as `__NESER_VERSION__`/`__NESER_COMMIT__`; two browsers connect only on the same build), `room_code.ts` (the six-character room codes from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, shown as `ABC-DEF`, and tidying what is typed into one), `liaison.ts` (nr-z4nw: the only `WebSocket` user, a client for the liaison meeting service at `wss://liaison.rmstdope.eu/ws`, github.com/rmstdope/liaison: `create`, `join`, `signal`, and its answers read back as untrusted; liaison's TURN relay is not used), `response_time.ts` (Great under 50 ms, Playable to 120 ms, Too slow above), `peer_link.ts` (the only `RTCPeerConnection` user: one data channel from a non-trickle offer and answer with no STUN/TURN, so the two must reach each other directly) and `session.ts` (the host's and joiner's steps as a DOM-free state machine over injectable signaling and links: the host opens a liaison room and shows its code, the joiner joins by it, and the offer and answer cross as `signal {offer}` / `signal {answer}`, after which each side leaves liaison; a room that ran out, or whose joiner left before the swap, is replaced by itself; the joiner keeps one liaison connection across its tries so liaison's wrong-code limit holds; a 15 s connect limit from the joiner being in the room and five-ping check that sends `ready` or `refuse`, live round trips once a second, `bye`; then sending the game: `game` announces it, 16 KiB binary chunks led by the transfer id follow, the joiner acknowledges each with `got` so at most 256 KiB is in flight, checks what came through an injected `checkGame` and answers `arrived` or `broken`; `stop` is either side's Cancel, ten silent seconds are a broken send, `arrived` carrying how long the joiner's test load took; then the host confirms it still runs the game (`confirmStart`) and says `start`, on which the joiner closes its own game and sets the received one up, while the host waits half the round trip measured before the send plus that time, so both counts begin together). `peer_link.ts` carries bytes as well as text. `app.ts` test-loads a received game on a scratch core before the joiner's own game is touched, and never keeps one (no library entry, picture or save state; it goes when the joiner leaves the ended game for the library). Playing together (nr-ayk5.5): `start` carries the input delay the host chose from its response time (`inputDelayFor`, 2-6 frames); lockstep packets ride the same link as bytes led by transfer id 0, beside the play messages `resume` and `reset`; `end` says the other player ended it, where `bye` says they left. `play.ts` is the DOM-free play controller each side owns over `WasmLockstep`, an injected core, link, clock and count: every local controller's buttons drive this side's player; a frame runs only once both pads are known, on ports 1 and 2; pause (with which window paused it) and reset travel in the pad's bits 12-15 so both games act after the same frame, and once both are stopped there, resuming and resetting are messages; it reports waiting (held half a second) and catching up (behind half a second, running two frames a tick). Resyncing (nr-ayk5.6): after every 60th frame each side hands the rules a CRC of its whole save state; a side whose rules find the two differ puts a resync command in its next pad, so both stop after the same frame; the host then sends its save state (`state`, then 16 KiB pieces led by transfer id `0xFFFFFFFF`, unacknowledged), the joiner loads it and says `resynced`, and both count "Resynced"; a failed load, a state that does not add up, or 15 s without getting back in step ends playing together on both sides with `unsynced`. Keeping full speed (nr-ayk5.7): `speed.ts` measures, per animation frame, how many frames a second this computer manages (what a frame costs is the wait until the next animation frame), and wants 95 % of it after three seconds below 90 % of the speed played at (a second whose frames took under 250 ms was mostly held and counts for neither), or full speed again after three at 97 %; `play.ts` sends what it wants as `speed`, both games run at the slower of the two sides' (the page's frame limiter follows `PlayView.speed`, and the sound is then never pitched down), and the first slowdown of a play together sets `slowNote` once, while playing, for the note worded for each side; a `speed` at or above full speed, or not a whole number of at least 1, asks nothing. Small clock differences need nothing more: lockstep evens them out one frame at a time, which the sound's lead absorbs. |
| `web/src/netplay/` | Connecting two browsers for a network game (nr-ayk5.3) and sending the host's game (nr-ayk5.4): `game_package.ts` (the ROM, any SNES firmware and the host's save state as one `NSRG` byte string, read back as untrusted: anything that does not describe itself exactly is refused, never thrown over; `crc32`, since `crypto.subtle` is missing on a plain-http page), `build_id.ts` (the NESER version and short commit Vite stamps in as `__NESER_VERSION__`/`__NESER_COMMIT__`; two browsers connect only on the same build), `room_code.ts` (the six-character room codes from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, shown as `ABC-DEF`, and tidying what is typed into one), `liaison.ts` (nr-z4nw: the only `WebSocket` user, a client for the liaison meeting service at `wss://liaison.rmstdope.eu/ws`, github.com/rmstdope/liaison: `create`, `join`, `signal`, and its answers read back as untrusted; liaison's TURN relay is not used), `response_time.ts` (Great under 50 ms, Playable to 120 ms, Too slow above), `peer_link.ts` (the only `RTCPeerConnection` user: one data channel from a non-trickle offer and answer; each browser asks outpost, NESER's own STUN server `stun:outpost.rmstdope.eu:3478`, for its public address (nr-ri8n), gathering for at most 3 s, and no TURN relay is used, so the two must reach each other directly) and `session.ts` (the host's and joiner's steps as a DOM-free state machine over injectable signaling and links: the host opens a liaison room and shows its code, the joiner joins by it, and the offer and answer cross as `signal {offer}` / `signal {answer}`, after which each side leaves liaison; a room that ran out, or whose joiner left before the swap, is replaced by itself; the joiner keeps one liaison connection across its tries so liaison's wrong-code limit holds; a 10 s connect limit from the joiner being in the room (nr-ri8n) and five-ping check that sends `ready` or `refuse`, live round trips once a second, `bye`; then sending the game: `game` announces it, 16 KiB binary chunks led by the transfer id follow, the joiner acknowledges each with `got` so at most 256 KiB is in flight, checks what came through an injected `checkGame` and answers `arrived` or `broken`; `stop` is either side's Cancel, ten silent seconds are a broken send, `arrived` carrying how long the joiner's test load took; then the host confirms it still runs the game (`confirmStart`) and says `start`, on which the joiner closes its own game and sets the received one up, while the host waits half the round trip measured before the send plus that time, so both counts begin together). `peer_link.ts` carries bytes as well as text. `app.ts` test-loads a received game on a scratch core before the joiner's own game is touched, and never keeps one (no library entry, picture or save state; it goes when the joiner leaves the ended game for the library). Playing together (nr-ayk5.5): `start` carries the input delay the host chose from its response time (`inputDelayFor`, 2-6 frames); lockstep packets ride the same link as bytes led by transfer id 0, beside the play messages `resume` and `reset`; `end` says the other player ended it, where `bye` says they left. `play.ts` is the DOM-free play controller each side owns over `WasmLockstep`, an injected core, link, clock and count: every local controller's buttons drive this side's player; a frame runs only once both pads are known, on ports 1 and 2; pause (with which window paused it) and reset travel in the pad's bits 12-15 so both games act after the same frame, and once both are stopped there, resuming and resetting are messages; it reports waiting (held half a second) and catching up (behind half a second, running two frames a tick). Resyncing (nr-ayk5.6): after every 60th frame each side hands the rules a CRC of its whole save state; a side whose rules find the two differ puts a resync command in its next pad, so both stop after the same frame; the host then sends its save state (`state`, then 16 KiB pieces led by transfer id `0xFFFFFFFF`, unacknowledged), the joiner loads it and says `resynced`, and both count "Resynced"; a failed load, a state that does not add up, or 15 s without getting back in step ends playing together on both sides with `unsynced`. Keeping full speed (nr-ayk5.7): `speed.ts` measures, per animation frame, how many frames a second this computer manages (what a frame costs is the wait until the next animation frame), and wants 95 % of it after three seconds below 90 % of the speed played at (a second whose frames took under 250 ms was mostly held and counts for neither), or full speed again after three at 97 %; `play.ts` sends what it wants as `speed`, both games run at the slower of the two sides' (the page's frame limiter follows `PlayView.speed`, and the sound is then never pitched down), and the first slowdown of a play together sets `slowNote` once, while playing, for the note worded for each side; a `speed` at or above full speed, or not a whole number of at least 1, asks nothing. Small clock differences need nothing more: lockstep evens them out one frame at a time, which the sound's lead absorbs. |
| `web/src/ui/` | Toast overlays (`toast_overlay.ts`), gamepad init toast, the control bar's volume (`volume.ts`, key `neser.volume`) and game title (`game_title.ts`) helpers, the settings panel (`settings_panel.ts`), the short lists Filter, Palette, Colours and Reset open (`choice_menu.ts`), the Play together window and its top-bar status chip (`play_together.ts`, drawing `netplay/session.ts`'s view one step at a time, Start game and the send's progress included), the 3-2-1 counts before and while playing together (`countdown.ts`), the note once both games run slower because one computer can't keep full speed (`slow_note.ts`, nr-ayk5.7: five seconds, once per play together, gone as soon as the game stops), the paused, waiting, catching-up, resyncing and ended screens over the shared game and the "End playing together?" question (`play_screen.ts`), the Keys and controls panel H and the top bar's ? open (`help_panel.ts`: the shortcut rows and a controller drawn for the running console's keys), where an error shows and its library title (`errors.ts`: a failed pick in the library, a stopped game over the picture), and the library (`library.ts`: grouping, search, system filter, and the release-news wave). |
| `web/integration/` | Playwright-based end-to-end integration tests for the web frontend. |

Expand Down
9 changes: 5 additions & 4 deletions web/integration/specs/play-together.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import {
waitForRunningState
} from "../helpers/lifecycle.helpers";

// Two browser contexts on one runner connect over WebRTC with no STUN server, as two browsers on
// one network do. Chromium hides its local addresses behind multicast-DNS names by default; the
// runner may not resolve those, so this spec has the browser offer its addresses as they are.
// What is under test is the page's connecting, not the operating system's mDNS.
// Two browser contexts on one runner connect over WebRTC by their own addresses, as two browsers on
// one network do (the STUN address from outpost, nr-ri8n, is offered too, but not needed here).
// Chromium hides its local addresses behind multicast-DNS names by default; the runner may not
// resolve those, so this spec has the browser offer its addresses as they are. What is under test
// is the page's connecting, not the operating system's mDNS.
test.use({
launchOptions: {
...config.use?.launchOptions,
Expand Down
6 changes: 6 additions & 0 deletions web/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -2537,6 +2537,12 @@ body.handheld:not(.handheld-portrait).touch-running .screen-wrap canvas {
font-size: 12px;
}

.netplay-body p.netplay-address {
margin: 8px 0 0;
font-size: 13px;
color: var(--steel-hi);
}

.netplay-pill {
display: inline-flex;
margin: 6px 0;
Expand Down
33 changes: 32 additions & 1 deletion web/src/netplay/peer_link.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { wrapChannel } from "./peer_link";
import { webRtcLinks, wrapChannel } from "./peer_link";

/** Just enough of a data channel and its connection for the link wrapper. */
function fakeChannel() {
Expand Down Expand Up @@ -37,3 +37,34 @@ describe("the link to the other browser", () => {
expect(channel.send).toHaveBeenNthCalledWith(2, "hi");
});
});

describe("connecting across the internet", () => {
it("asks outpost, NESER's own STUN server, for each browser's public address", async () => {
const configs: RTCConfiguration[] = [];
class FakeConnection extends EventTarget {
iceGatheringState = "complete";
localDescription: { sdp: string } | null = null;
constructor(config: RTCConfiguration) {
super();
configs.push(config);
}
createDataChannel() {
return new EventTarget();
}
async setLocalDescription() {
this.localDescription = { sdp: "local" };
}
async setRemoteDescription() {}
close() {}
}
vi.stubGlobal("RTCPeerConnection", FakeConnection);
try {
(await webRtcLinks.startHost()).cancel();
(await webRtcLinks.startJoin("offer")).cancel();
} finally {
vi.unstubAllGlobals();
}
const outpost = { iceServers: [{ urls: "stun:outpost.rmstdope.eu:3478" }] };
expect(configs).toEqual([outpost, outpost]);
});
});
21 changes: 14 additions & 7 deletions web/src/netplay/peer_link.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
/**
* The direct connection between two browsers (nr-ayk5.3): one WebRTC data channel, set up from
* an offer and an answer the two browsers swap through liaison (nr-z4nw). No STUN or TURN server
* is asked (liaison's relay is not used either), so
* only the browsers' own addresses are offered and the two must reach each other directly. This
* is the only file that touches `RTCPeerConnection`; the session drives it through `PeerLinks`.
* an offer and an answer the two browsers swap through liaison (nr-z4nw). Each browser asks
* outpost, NESER's own STUN server, for the public address its router gives it (nr-ri8n), so two
* players in different homes can reach each other directly; no TURN relay is used (liaison's is
* left for nr-1wbx), so a pair whose networks block a direct connection cannot connect. This is
* the only file that touches `RTCPeerConnection`; the session drives it through `PeerLinks`.
*/

/** outpost answers STUN only; it never carries game data. Without it, same-network play still works. */
export const ICE_SERVERS: RTCIceServer[] = [{ urls: "stun:outpost.rmstdope.eu:3478" }];

/**
* An open channel to the other browser, carrying text (the session's messages) and bytes (a game
* being sent, nr-ayk5.4). `onClosed` is told once, however the channel ends.
Expand Down Expand Up @@ -36,7 +40,10 @@ export interface PeerLinks {
startJoin(hostSdp: string): Promise<JoinStart>;
}

/** The offer and answer each carry every address at once, so they wait for the browser to find them. */
/**
* The offer and answer each carry every address at once, so they wait for the browser to find them;
* with outpost out of reach, the wait ends here and only the browser's own addresses are offered.
*/
const GATHER_LIMIT_MS = 3000;
/** How long a closing channel may take to send what is queued before the connection is ended anyway. */
const CLOSE_LIMIT_MS = 2000;
Expand Down Expand Up @@ -105,7 +112,7 @@ function opened(pc: RTCPeerConnection, channel: Promise<RTCDataChannel>): Promis
}

async function startHost(): Promise<HostStart> {
const pc = new RTCPeerConnection({ iceServers: [] });
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
const channel = pc.createDataChannel("neser");
try {
await pc.setLocalDescription();
Expand All @@ -125,7 +132,7 @@ async function startHost(): Promise<HostStart> {
}

async function startJoin(hostSdp: string): Promise<JoinStart> {
const pc = new RTCPeerConnection({ iceServers: [] });
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
const channel = new Promise<RTCDataChannel>((resolve) => {
pc.addEventListener("datachannel", (event) => resolve(event.channel), { once: true });
});
Expand Down
Loading
Loading