Skip to content

feat: add BroadcastChannel JS bridge for browser-based link cable multiplayer#1

Open
bangnokia wants to merge 1 commit intomasterfrom
feat/broadcast-link-cable
Open

feat: add BroadcastChannel JS bridge for browser-based link cable multiplayer#1
bangnokia wants to merge 1 commit intomasterfrom
feat/broadcast-link-cable

Conversation

@bangnokia
Copy link
Member

BroadcastChannel JS Bridge for Browser-Based Link Cable Multiplayer

Add a lightweight JavaScript bridge that enables GBA link cable multiplayer between browser tabs using the BroadcastChannel API — no server required for same-origin local multiplayer.

What this does

Adds an Emscripten JS bridge (globalThis.__gpspLinkBridge) that lets a web frontend configure and drive gpSP's netplay serial protocols via send(dst, payload) / poll() calls, using BroadcastChannel for cross-tab communication.

Changes

libretro/libretro.c

  • Add gpsp_bridge_set_client_id() / gpsp_bridge_set_client_count() EMSCRIPTEN_KEEPALIVE exports for configuring netplay_client_id and netplay_num_clients from JavaScript
  • Add js_gpsp_bridge_has/send/poll EM_JS functions for BroadcastChannel communication via globalThis.__gpspLinkBridge
  • Add gpsp_bridge_poll_receive() that polls the JS bridge and dispatches packets to serial protocol handlers
  • Guard netpacket_start() to not clobber bridge-configured values when the JS bridge is already active
  • Bugfix: Add netpacket_poll_receive() call for SERIAL_MODE_SERIAL_POKE in retro_run() — was completely missing, causing Pokemon link cable packets to never be delivered

serial_proto.c

  • Add netpacket_poll_receive() per-tick in serialpoke_update() for low-latency packet delivery (matching the pattern already used by AW mode)
  • Bugfix: Fix CONNECTED → HANDSHAKE re-transition in both master and slave paths — reset offset/checksum, notify peer via network packet, fire IRQ on slave side (fixes "Communication error" when exiting Pokemon trade room)

Testing

  • Pokemon Fire Red: Trading works end-to-end — enter trade room, select Pokemon, trade, exit trade room cleanly without "Communication error"
  • Transport: BroadcastChannel between two browser tabs (same origin)
  • Serial mode: mul_poke (Pokemon link cable protocol)

…tiplayer

Add a lightweight JavaScript bridge that enables GBA link cable multiplayer
between browser tabs using the BroadcastChannel API - no server required
for same-origin local multiplayer.

libretro/libretro.c:
- Add gpsp JS bridge exports (gpsp_bridge_set_client_id/count)
- Add js_gpsp_bridge_has/send/poll EM_JS functions for BroadcastChannel
- Add gpsp_bridge_poll_receive() to dispatch packets from JS bridge
- Guard netpacket_start() to not clobber bridge-configured values
- Add netpacket_poll_receive() for SERIAL_MODE_SERIAL_POKE in retro_run()

serial_proto.c:
- Add netpacket_poll_receive() per-tick in serialpoke_update()
- Fix CONNECTED to HANDSHAKE re-transition: reset offset/checksum,
  notify peer, fire IRQ on slave

Tested: Pokemon Fire Red trading works end-to-end via BroadcastChannel
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds BroadcastChannel JavaScript bridge support for browser-based GBA link cable multiplayer and includes important bug fixes for the Pokemon serial protocol.

Changes:

  • Adds Emscripten JS bridge (globalThis.__gpspLinkBridge) for cross-tab multiplayer using BroadcastChannel API
  • Fixes Pokemon link cable handshake re-transition bug (CONNECTED → HANDSHAKE) by resetting state variables and notifying peers
  • Fixes missing netpacket_poll_receive() call for SERIAL_MODE_SERIAL_POKE that was preventing Pokemon packets from being delivered
  • Adds always-on AW_TRACE diagnostic logging for AW protocol debugging
  • Adds heartbeat command handling for AW protocol to improve state synchronization

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
libretro/libretro.c Adds Emscripten JS bridge functions (send/poll/has), KEEPALIVE exports for client ID/count configuration, bridge polling in netpacket_poll_receive(), guard logic to preserve bridge-configured values, and missing netpacket_poll_receive() calls in retro_run()
serial_proto.c Adds AW_TRACE macro for diagnostic logging, heartbeat_cmd field for AW protocol state sync, fixes handshake re-transition in both master/slave paths, adds netpacket_poll_receive() calls for low-latency packet delivery, clears SIOCNT busy bit, and adds diagnostic counters
bios_data.S Adds ELF .type and .size directives for BIOS symbols

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 792 to 825
const u16 ste = (flags >> 8) & 0xff; // Peer state.
const u16 cnt = flags & 0x00ff; // Number of words to follow.

static int aw_recv_log_count = 0;
if (aw_recv_log_count++ < 30)
AW_TRACE("[AW-RECV] from=%d st=%d cmd=%04x cnt=%d myid=%d\n",
client_id, ste, cmd, cnt, netplay_client_id);

serstate.aw.peer[client_id].timeout = 0;
serstate.aw.recv_count++;

/* When the peer changes state, discard any leftover heartbeat from
the previous state — it would confuse the local FSM. For example
a game command cached while in PACKETXG must not leak through
after the peer moves to SYNC. */
if (ste != serstate.aw.peer[client_id].state)
serstate.aw.peer[client_id].heartbeat_cmd = CMD_NONE;

serstate.aw.peer[client_id].state = ste;

/* For heartbeat packets (cnt=0) store the command so the other side
can see it in SIOMULTI on the next transfer / fake-IRQ. Each value
is consumed once by process_awpeer().
cmd==0 is a dummy placeholder used in state-notification sends
(e.g. serialaw_senddata(0, STATE_SYNC, NULL, 0)) — it must NOT
be forwarded or the game would see 0x0000 in SIOMULTI, which it
interprets as corrupt data → linking error. */
if (cnt == 0 && cmd != 0) {
serstate.aw.peer[client_id].heartbeat_cmd = cmd;
}

SRPT_DEBUG_LOG("Got packet with state %d cmd %04x and size %d.\n", ste, cmd, cnt);

if (ste == STATE_INTERSYNC) {
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing bounds check on client_id parameter. The client_id is used directly as an array index (e.g., lines 800, 807, 808, 810, 820) without validation, but the peer array only has 4 elements (indices 0-3). When using the JavaScript bridge, a malicious or buggy bridge implementation could provide a client_id >= 4, causing out-of-bounds memory access. Add a bounds check at the start of the function after validating the header: if (client_id >= 4) return;

Copilot uses AI. Check for mistakes.
Comment on lines +1405 to +1408
case SERIAL_MODE_SERIAL_AW1:
case SERIAL_MODE_SERIAL_AW2:
netpacket_poll_receive();
break;
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation in switch case statement. The cases for SERIAL_MODE_SERIAL_AW1 and SERIAL_MODE_SERIAL_AW2 use one space for indentation while other cases use two spaces. The body also uses three spaces instead of the expected indentation pattern. This should match the indentation pattern used for SERIAL_MODE_SERIAL_POKE case above.

Suggested change
case SERIAL_MODE_SERIAL_AW1:
case SERIAL_MODE_SERIAL_AW2:
netpacket_poll_receive();
break;
case SERIAL_MODE_SERIAL_AW1:
case SERIAL_MODE_SERIAL_AW2:
netpacket_poll_receive();
break;

Copilot uses AI. Check for mistakes.
EM_JS(void, js_gpsp_bridge_send, (int dst, const void *data_ptr, int len), {
const bridge = globalThis.__gpspLinkBridge;
if (!bridge || typeof bridge.send !== 'function') return;
if (!len || len <= 0) return;
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant condition check. The condition "!len || len <= 0" is redundant because "len <= 0" already covers the case when len is 0. The "!len" check is unnecessary and should be removed. The condition should be simplified to just "if (len <= 0) return;"

Suggested change
if (!len || len <= 0) return;
if (len <= 0) return;

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +38
/* Always-on lightweight state-transition trace for AW protocol debugging.
Uses printf which maps to console.log in Emscripten. */
#define AW_TRACE(...) printf(__VA_ARGS__)
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AW_TRACE macro is always enabled and will print diagnostic messages to console in production builds. While the comment indicates this is intentional for "AW protocol debugging", several trace calls are unlimited (lines 557, 589, 733, 735-736, 852) and could print frequently during gameplay, potentially impacting performance and cluttering console output. Consider either: (1) guarding AW_TRACE with a compile-time flag similar to SRPT_DEBUG_LOG, or (2) adding rate limiting to all trace calls, or (3) documenting that these will be removed/disabled before merging to main.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants