From cbf54d9d37367d29a62c85bf4dcc7eb389015065 Mon Sep 17 00:00:00 2001 From: Mohammad Syuhada Date: Sat, 13 Jun 2026 22:48:13 +0800 Subject: [PATCH 1/2] feat: netplay support --- workspace/all/common/api.h | 13 +- workspace/all/common/generic_wifi.c | 93 + .../patches/gpsp/001-rfu_disconnect_fix.patch | 100 + .../patches/gpsp/002-rfu_queue_size.patch | 80 + workspace/all/minarch/ma_core.c | 11 + workspace/all/minarch/ma_environment.c | 10 + workspace/all/minarch/ma_frontend_opts.h | 3 + workspace/all/minarch/ma_input.c | 40 +- workspace/all/minarch/ma_input.h | 3 + workspace/all/minarch/ma_internal.h | 15 + workspace/all/minarch/ma_menu.c | 48 +- workspace/all/minarch/ma_options.c | 10 +- workspace/all/minarch/ma_video.c | 7 + workspace/all/minarch/makefile | 13 +- workspace/all/minarch/minarch.c | 128 +- workspace/all/minarch/minarch.h | 66 + workspace/all/netplay/gbalink.c | 1672 ++++++++++ workspace/all/netplay/gbalink.h | 139 + workspace/all/netplay/gblink.c | 637 ++++ workspace/all/netplay/gblink.h | 115 + workspace/all/netplay/keyboard.c | 278 ++ workspace/all/netplay/keyboard.h | 18 + workspace/all/netplay/netplay.c | 1143 +++++++ workspace/all/netplay/netplay.h | 150 + workspace/all/netplay/netplay_helper.c | 2886 +++++++++++++++++ workspace/all/netplay/netplay_helper.h | 348 ++ workspace/all/netplay/network_common.c | 292 ++ workspace/all/netplay/network_common.h | 182 ++ workspace/all/netplay/wifi_direct.c | 397 +++ workspace/all/netplay/wifi_direct.h | 115 + workspace/tg5040/cores/makefile | 5 + workspace/tg5040/cores/patches/gambatte.patch | 3 +- workspace/tg5050/cores/makefile | 5 + workspace/tg5050/cores/patches/gambatte.patch | 3 +- 34 files changed, 9002 insertions(+), 26 deletions(-) create mode 100644 workspace/all/cores/patches/gpsp/001-rfu_disconnect_fix.patch create mode 100644 workspace/all/cores/patches/gpsp/002-rfu_queue_size.patch create mode 100644 workspace/all/minarch/minarch.h create mode 100644 workspace/all/netplay/gbalink.c create mode 100644 workspace/all/netplay/gbalink.h create mode 100644 workspace/all/netplay/gblink.c create mode 100644 workspace/all/netplay/gblink.h create mode 100644 workspace/all/netplay/keyboard.c create mode 100644 workspace/all/netplay/keyboard.h create mode 100644 workspace/all/netplay/netplay.c create mode 100644 workspace/all/netplay/netplay.h create mode 100644 workspace/all/netplay/netplay_helper.c create mode 100644 workspace/all/netplay/netplay_helper.h create mode 100644 workspace/all/netplay/network_common.c create mode 100644 workspace/all/netplay/network_common.h create mode 100644 workspace/all/netplay/wifi_direct.c create mode 100644 workspace/all/netplay/wifi_direct.h diff --git a/workspace/all/common/api.h b/workspace/all/common/api.h index da8e14534..443b79c47 100644 --- a/workspace/all/common/api.h +++ b/workspace/all/common/api.h @@ -792,12 +792,20 @@ int PLAT_wifiConnection(struct WIFI_connection *connection_info); bool PLAT_wifiHasCredentials(char *ssid, WifiSecurityType sec); // forgets the credentials for this SSID, if saved void PLAT_wifiForget(char *ssid, WifiSecurityType sec); +// forgets every saved network whose SSID starts with prefix; returns the count removed +int PLAT_wifiForgetPrefix(const char *prefix); +// re-enables all configured networks (undoes a prior select_network exclusivity) +void PLAT_wifiEnableAll(void); // attempt to connect to this SSID, using, stored credentials. // \sa PLAT_wifiHasCredentials void PLAT_wifiConnect(char *ssid, WifiSecurityType sec); -// attempt to connect to this SSID with password given. +// attempt to connect to this SSID with password given. // If successful, stores credentials with wpa_supplicant. void PLAT_wifiConnectPass(const char *ssid, WifiSecurityType sec, const char* pass); +// exclusively select an already-configured SSID (select_network: enables it and +// disables all other configured networks for this session, so a higher-priority +// saved network can't win the association). Used by netplay hotspot join. +void PLAT_wifiSelectOnly(const char *ssid); // disconnect from any active network void PLAT_wifiDisconnect(); // enable wifi diagnostic logging @@ -814,8 +822,11 @@ void PLAT_wifiDiagnosticsEnable(bool on); #define WIFI_connectionInfo PLAT_wifiConnection #define WIFI_isKnown PLAT_wifiHasCredentials #define WIFI_forget PLAT_wifiForget +#define WIFI_forgetPrefix PLAT_wifiForgetPrefix +#define WIFI_enableAll PLAT_wifiEnableAll #define WIFI_connect PLAT_wifiConnect #define WIFI_connectPass PLAT_wifiConnectPass +#define WIFI_selectOnly PLAT_wifiSelectOnly #define WIFI_disconnect PLAT_wifiDisconnect #define WIFI_diagnosticsEnabled PLAT_wifiDiagnosticsEnabled #define WIFI_diagnosticsEnable PLAT_wifiDiagnosticsEnable diff --git a/workspace/all/common/generic_wifi.c b/workspace/all/common/generic_wifi.c index ccf653040..f60d97461 100644 --- a/workspace/all/common/generic_wifi.c +++ b/workspace/all/common/generic_wifi.c @@ -479,6 +479,76 @@ void PLAT_wifiForget(char *ssid, WifiSecurityType sec) } } +// Forget (remove) every configured network whose SSID starts with `prefix`. +// Used to purge accumulated netplay hotspot entries: each join adds and saves a +// uniquely-named hotspot network (e.g. NextUI-AB12), and sessions that end without +// a clean teardown (failed join, crash, power-off) never remove theirs, so the +// wpa_supplicant config grows without bound. Returns the number removed. +int PLAT_wifiForgetPrefix(const char *prefix) +{ + if (!CFG_getWifi()) { + LOG_error("PLAT_wifiForgetPrefix: wifi is currently disabled.\n"); + return 0; + } + if (!prefix || !prefix[0]) return 0; + + char list_results[8192]; + char cmd[128]; + snprintf(cmd, sizeof(cmd), "%s list_networks 2>/dev/null", WPA_CLI_CMD); + if (wifi_run_cmd(cmd, list_results, sizeof(list_results)) != 0) { + wifilog("PLAT_wifiForgetPrefix: failed to get network list\n"); + return 0; + } + + size_t prefix_len = strlen(prefix); + int ids[128]; + int n_ids = 0; + + // Skip the header line ("network id / ssid / bssid / flags"). + const char *current = strchr(list_results, '\n'); + current = current ? current + 1 : NULL; + + char line[256]; + while (current && *current && n_ids < (int)(sizeof(ids) / sizeof(ids[0]))) { + const char *next = strchr(current, '\n'); + size_t len = next ? (size_t)(next - current) : strlen(current); + if (len >= sizeof(line)) len = sizeof(line) - 1; + memcpy(line, current, len); + line[len] = '\0'; + + char *saveptr = NULL; + char *token_id = strtok_r(line, "\t", &saveptr); + char *token_ssid = strtok_r(NULL, "\t", &saveptr); + + if (token_id && token_ssid && strncmp(token_ssid, prefix, prefix_len) == 0) { + ids[n_ids++] = atoi(token_id); + } + + current = next ? next + 1 : NULL; + } + + if (n_ids == 0) return 0; + + // remove_network does not renumber the remaining networks, so removing by the + // ids we collected up front is safe. + for (int i = 0; i < n_ids; i++) { + snprintf(cmd, sizeof(cmd), "%s remove_network %d 2>/dev/null", WPA_CLI_CMD, ids[i]); + system(cmd); + } + system(WPA_CLI_CMD " save_config 2>/dev/null"); + wifilog("PLAT_wifiForgetPrefix: removed %d network(s) matching '%s'\n", n_ids, prefix); + return n_ids; +} + +// Re-enable all configured networks. select_network (used for netplay hotspot join) +// disables every network except the chosen one; this restores the others so the +// saved config isn't left with home/other networks stuck disabled. Caller persists. +void PLAT_wifiEnableAll(void) +{ + if (!CFG_getWifi()) return; + system(WPA_CLI_CMD " enable_network all 2>/dev/null"); +} + void PLAT_wifiConnect(char *ssid, WifiSecurityType sec) { PLAT_wifiConnectPass(ssid, sec, NULL); @@ -585,6 +655,29 @@ void PLAT_wifiConnectPass(const char *ssid, WifiSecurityType sec, const char* pa LOG_error("PLAT_wifiConnectPass: connection timeout after 5 seconds\n"); } +void PLAT_wifiSelectOnly(const char *ssid) +{ + if (!CFG_getWifi()) { + LOG_error("PLAT_wifiSelectOnly: wifi is currently disabled.\n"); + return; + } + if (!ssid || !ssid[0]) return; + + int network_id = wifi_find_network_id(ssid); + if (network_id < 0) { + wifilog("PLAT_wifiSelectOnly: network %s not found\n", ssid); + return; + } + + // select_network enables this network and disables all others, then triggers + // (re)association. Intentionally NOT followed by save_config: the disable of the + // other networks is a runtime-only state, restored when the wifi stack restarts. + char cmd[128]; + snprintf(cmd, sizeof(cmd), "%s select_network %d 2>/dev/null", WPA_CLI_CMD, network_id); + system(cmd); + wifilog("PLAT_wifiSelectOnly: selected network %s (id=%d)\n", ssid, network_id); +} + void PLAT_wifiDisconnect() { PLAT_wifiConnectPass(NULL, SECURITY_WPA2_PSK, NULL); diff --git a/workspace/all/cores/patches/gpsp/001-rfu_disconnect_fix.patch b/workspace/all/cores/patches/gpsp/001-rfu_disconnect_fix.patch new file mode 100644 index 000000000..5473173de --- /dev/null +++ b/workspace/all/cores/patches/gpsp/001-rfu_disconnect_fix.patch @@ -0,0 +1,100 @@ +diff --git a/libretro/libretro.c b/libretro/libretro.c +index 5f02c09..80969b3 100644 +--- a/libretro/libretro.c ++++ b/libretro/libretro.c +@@ -512,7 +512,13 @@ static bool netpacket_connected(uint16_t client_id) { + return true; + } + ++// Forward declaration for RFU disconnect ++void rfu_net_disconnect(void); ++ + static void netpacket_disconnected(uint16_t client_id) { ++ // Force RFU state to IDLE so the game receives disconnect notification ++ // This fixes CLIENT getting stuck when HOST disconnects during gameplay ++ rfu_net_disconnect(); + netplay_num_clients--; + } + +diff --git a/rfu.c b/rfu.c +index d5add44..2b7a8c0 100644 +--- a/rfu.c ++++ b/rfu.c +@@ -119,6 +119,9 @@ static u32 rfu_prev_data; + static u32 rfu_comstate, rfu_cnt, rfu_state; + static u32 rfu_buf[255]; + static u8 rfu_cmd, rfu_plen; ++// Flag to force disconnect response on next command ++static bool rfu_disconnect_pending = false; ++ + static u32 rfu_timeout_cycles, rfu_resp_timeout; + static u8 rfu_timeout, rfu_rtx_max; + +@@ -242,6 +245,9 @@ void rfu_reset() { + // Clear all the received broadcasts. + memset(&rfu_peer_bcst, 0, sizeof(rfu_peer_bcst)); + ++ // Clear any pending disconnect from previous session ++ rfu_disconnect_pending = false; ++ + // Re-seed random gen. cpu_ticks is determined by the GBA program and + // is therefore replay-reproducible; time(NULL) is not, and would break + // record/replay or netplay rollback if used here. The seed only needs +@@ -250,6 +256,24 @@ void rfu_reset() { + rand_seed(cpu_ticks); + } + ++// Called when the network link is lost (netpacket disconnect callback). ++// Clears connection state and sets disconnect pending flag. ++// The next RFU command will be forced to WAIT, triggering disconnect response. ++void rfu_net_disconnect(void) { ++ RFU_DEBUG_LOG("RFU net disconnect! state=%d comstate=%d\n", rfu_state, rfu_comstate); ++ ++ // Clear all connection data based on current role ++ if (rfu_state == RFU_STATE_HOST) { ++ memset(&rfu_host.clients, 0, sizeof(rfu_host.clients)); ++ } else if (rfu_state == RFU_STATE_CLIENT) { ++ memset(&rfu_client, 0, sizeof(rfu_client)); ++ } ++ ++ // Force IDLE state and mark disconnect pending ++ rfu_state = RFU_STATE_IDLE; ++ rfu_disconnect_pending = true; ++} ++ + static u16 new_devid() { + while (1) { + /* Mix in cpu_ticks (deterministic) rather than time(NULL) so the +@@ -598,9 +622,17 @@ u32 rfu_transfer(u32 sent_value) { + case RFU_COMSTATE_WAITCMD: + // Wait for a new command, verify its header. + if ((sent_value >> 16) == 0x9966) { +- rfu_plen = (u8)(sent_value >> 8); +- rfu_cmd = (u8)(sent_value); +- rfu_cnt = 0; ++ // Check for pending disconnect - force WAIT command to trigger disconnect response ++ if (rfu_disconnect_pending && rfu_state == RFU_STATE_IDLE) { ++ rfu_cmd = RFU_CMD_WAIT; ++ rfu_plen = 0; ++ rfu_cnt = 0; ++ rfu_disconnect_pending = false; ++ } else { ++ rfu_plen = (u8)(sent_value >> 8); ++ rfu_cmd = (u8)(sent_value); ++ rfu_cnt = 0; ++ } + if (!rfu_plen) { + // Returns error code or response length + s32 ret = rfu_process_command(); +diff --git a/serial.h b/serial.h +index 9f8c3d0..ce471b9 100644 +--- a/serial.h ++++ b/serial.h +@@ -43,6 +43,7 @@ void serial_set_irq_cycles(u32 v); + void rfu_reset(void); + bool rfu_update(unsigned cycles); + u32 rfu_transfer(u32 value); ++void rfu_net_disconnect(void); + void rfu_frame_update(void); + void rfu_net_receive(const void* buf, size_t len, uint16_t client_id); + diff --git a/workspace/all/cores/patches/gpsp/002-rfu_queue_size.patch b/workspace/all/cores/patches/gpsp/002-rfu_queue_size.patch new file mode 100644 index 000000000..e63874c8a --- /dev/null +++ b/workspace/all/cores/patches/gpsp/002-rfu_queue_size.patch @@ -0,0 +1,80 @@ +diff --git forkSrcPrefix/rfu.c forkDstPrefix/rfu.c +index 31a515c..a5e0b8f 100644 +--- forkSrcPrefix/rfu.c ++++ forkDstPrefix/rfu.c +@@ -19,6 +19,10 @@ + + #include "common.h" + ++// Packet queue size for buffering network packets ++// Increased from 4 to handle TCP batching that delivers packets faster than game reads ++#define RFU_PKT_QUEUE_SIZE 16 ++ + // Debug print logic: + #ifdef RFU_DEBUG + #define RFU_DEBUG_LOG(...) printf(__VA_ARGS__) +@@ -138,7 +142,7 @@ static struct { + struct { + u32 datalen; // Byte count of data waiting to be polled. + u8 data[16]; // Data received from client. +- } pkts[4]; ++ } pkts[RFU_PKT_QUEUE_SIZE]; + } clients[4]; // Connected clients IDs (zero means empty slot). + } rfu_host; + +@@ -146,11 +150,11 @@ static struct { + u16 devid; // Device ID assigned to the client (by the host?) + u16 clnum; // Client number (0 to 3) + u16 host_id; // Client ID for the host device. +- // Store host recevied packets (up to 8!) ++ // Store host received packets + struct { + u16 hblen; // Bytes received from the host. + u8 hdata[128]; // Data received from the host (accumulated). +- } pkts[4]; ++ } pkts[RFU_PKT_QUEUE_SIZE]; + } rfu_client; + + typedef struct { +@@ -498,8 +502,8 @@ static s32 rfu_process_command() { + rfu_buf[0] |= dlen << (8 + i * 5); + // Discard front packet + memmove(&rfu_host.clients[i].pkts[0], &rfu_host.clients[i].pkts[1], +- 3 * sizeof(rfu_host.clients[i].pkts[0])); +- rfu_host.clients[i].pkts[3].datalen = 0; ++ (RFU_PKT_QUEUE_SIZE - 1) * sizeof(rfu_host.clients[i].pkts[0])); ++ rfu_host.clients[i].pkts[RFU_PKT_QUEUE_SIZE - 1].datalen = 0; + } + } + // Copy data into words into the RFU buffer. +@@ -516,8 +520,8 @@ static s32 rfu_process_command() { + rfu_buf[cnt++] = leupack32(&rfu_client.pkts[0].hdata[j*4]); + + // Move to the next packet +- memmove(&rfu_client.pkts[0], &rfu_client.pkts[1], sizeof(rfu_client.pkts[0]) * 3); +- rfu_client.pkts[3].hblen = 0; ++ memmove(&rfu_client.pkts[0], &rfu_client.pkts[1], sizeof(rfu_client.pkts[0]) * (RFU_PKT_QUEUE_SIZE - 1)); ++ rfu_client.pkts[RFU_PKT_QUEUE_SIZE - 1].hblen = 0; + return cnt; + } + break; +@@ -810,8 +814,8 @@ void rfu_net_receive(const void* buf, size_t len, uint16_t client_id) { + // ACK the reception (so they know we are alive!) + rfu_net_send_cmd(client_id, NET_RFU_CLIENT_ACK, + rfu_client.devid | (rfu_client.clnum << 16)); +- // Receive data from the host. Queue que packet if possible +- for (i = 0; i < 4; i++) { ++ // Receive data from the host. Queue packet if possible ++ for (i = 0; i < RFU_PKT_QUEUE_SIZE; i++) { + if (!rfu_client.pkts[i].hblen) { + memcpy(&rfu_client.pkts[i].hdata, payl, blen); + rfu_client.pkts[i].hblen = blen; +@@ -835,7 +839,7 @@ void rfu_net_receive(const void* buf, size_t len, uint16_t client_id) { + // Validate the slot with device ID + if (rfu_host.clients[clid].devid == cdevid) { + rfu_host.clients[clid].clttl = 0; // Account for packet reception +- for (i = 0; i < 4; i++) { ++ for (i = 0; i < RFU_PKT_QUEUE_SIZE; i++) { + if (!rfu_host.clients[clid].pkts[i].datalen) { + memcpy(rfu_host.clients[clid].pkts[i].data, payl, blen); + rfu_host.clients[clid].pkts[i].datalen = blen; diff --git a/workspace/all/minarch/ma_core.c b/workspace/all/minarch/ma_core.c index 771c8fb10..64ae96199 100644 --- a/workspace/all/minarch/ma_core.c +++ b/workspace/all/minarch/ma_core.c @@ -9,6 +9,7 @@ #include "ma_input.h" #include "ma_cheats.h" #include "ma_core.h" +#include "netplay_helper.h" // CoreLinkSupport / checkCoreLinkSupport void Core_getName(char* in_name, char* out_name) { @@ -130,6 +131,11 @@ int Core_updateAVInfo(void) { void Core_load(void) { LOG_info("Core_load\n"); + + core.has_netpacket = false; + core.has_gblink = false; + core.show_netplay = false; + struct retro_game_info game_info; game_info.path = game.tmp_path[0]?game.tmp_path:game.path; game_info.data = game.data; @@ -137,6 +143,11 @@ void Core_load(void) { LOG_info("game path: %s (%i)\n", game_info.path, game.size); core.load_game(&game_info); + CoreLinkSupport link_support = checkCoreLinkSupport(core.name); + core.show_netplay = link_support.show_netplay; + core.has_netpacket = link_support.has_netpacket; + core.has_gblink = link_support.has_gblink; + if (Cheats_load()) Core_applyCheats(&cheatcodes); diff --git a/workspace/all/minarch/ma_environment.c b/workspace/all/minarch/ma_environment.c index 3f4da0452..e26530c6c 100644 --- a/workspace/all/minarch/ma_environment.c +++ b/workspace/all/minarch/ma_environment.c @@ -5,6 +5,7 @@ #include "ma_input.h" #include "ra_integration.h" #include "ma_environment.h" +#include "gbalink.h" static bool set_rumble_state(unsigned port, enum retro_rumble_effect effect, uint16_t strength) { // TODO: handle other args? not sure I can @@ -403,6 +404,15 @@ bool environment_callback(unsigned cmd, void *data) { // copied from picoarch in return true; } + case RETRO_ENVIRONMENT_SET_NETPACKET_INTERFACE: { + const struct retro_netpacket_callback *cb = + (const struct retro_netpacket_callback *)data; + if (cb) { + core.has_netpacket = true; + GBALink_setCoreCallbacks(cb); + } + return true; + } default: // LOG_debug("Unsupported environment cmd: %u\n", cmd); return false; diff --git a/workspace/all/minarch/ma_frontend_opts.h b/workspace/all/minarch/ma_frontend_opts.h index e31c49878..3ee78c282 100644 --- a/workspace/all/minarch/ma_frontend_opts.h +++ b/workspace/all/minarch/ma_frontend_opts.h @@ -9,11 +9,14 @@ typedef struct MenuList MenuList; typedef struct MenuItem MenuItem; +#ifndef MENU_CALLBACK_CODES_DEFINED +#define MENU_CALLBACK_CODES_DEFINED enum { MENU_CALLBACK_NOP, MENU_CALLBACK_EXIT, MENU_CALLBACK_NEXT_ITEM, }; +#endif typedef int (*MenuList_callback_t)(MenuList* list, int i); diff --git a/workspace/all/minarch/ma_input.c b/workspace/all/minarch/ma_input.c index 3233b214d..45972d5a1 100644 --- a/workspace/all/minarch/ma_input.c +++ b/workspace/all/minarch/ma_input.c @@ -1,5 +1,6 @@ #include "ma_internal.h" #include "ma_input.h" +#include "netplay_helper.h" // Netplay_*/Multiplayer_*/NETPLAY_* used in input callbacks #include @@ -14,6 +15,9 @@ int setFastForward(int enable) { static uint32_t buttons = 0; // RETRO_DEVICE_ID_JOYPAD_* buttons static int ignore_menu = 0; + +// Expose the current local button bitmask to minarch.c's netplay input sync. +uint32_t Input_getButtons(void) { return buttons; } void input_poll_callback(void) { PAD_poll(); @@ -30,6 +34,7 @@ void input_poll_callback(void) { if (PAD_isPressed(BTN_MENU) && PAD_isPressed(BTN_SELECT)) { ignore_menu = 1; newScreenshot = 1; + Netplay_quitAll(); quit = 1; Menu_saveState(); putFile(GAME_SWITCHER_PERSIST_PATH, game.path + strlen(SDCARD_PATH)); @@ -51,6 +56,11 @@ void input_poll_callback(void) { int btn = 1 << mapping->local; if (btn==BTN_NONE) continue; // not bound if (!mapping->mod || PAD_isPressed(BTN_MENU)) { + // Skip FF/rewind for multiplayer + if (i==SHORTCUT_TOGGLE_FF || i==SHORTCUT_HOLD_FF || + i==SHORTCUT_HOLD_REWIND || i==SHORTCUT_TOGGLE_REWIND) { + if (Multiplayer_isActive()) continue; + } if (i==SHORTCUT_TOGGLE_FF) { if (PAD_justPressed(btn)) { toggled_ff_on = setFastForward(!fast_forward); @@ -149,11 +159,13 @@ void input_poll_callback(void) { break; case SHORTCUT_RESET_GAME: core.reset(); break; case SHORTCUT_SAVE_QUIT: + Netplay_quitAll(); newScreenshot = 1; quit = 1; Menu_saveState(); break; case SHORTCUT_GAMESWITCHER: + Netplay_quitAll(); newScreenshot = 1; quit = 1; Menu_saveState(); @@ -218,18 +230,24 @@ void input_poll_callback(void) { } int16_t input_state_callback(unsigned port, unsigned device, unsigned index, unsigned id) { - if (port==0 && device==RETRO_DEVICE_JOYPAD && index==0) { - if (id == RETRO_DEVICE_ID_JOYPAD_MASK) return buttons; - return (buttons >> id) & 1; + uint32_t player_buttons = Netplay_getPlayerButtons(port, buttons); + + // Digital joypad inputs + if (device == RETRO_DEVICE_JOYPAD && index == 0) { + if (id == RETRO_DEVICE_ID_JOYPAD_MASK) return player_buttons; + return (player_buttons >> id) & 1; } - else if (port==0 && device==RETRO_DEVICE_ANALOG) { - if (index==RETRO_DEVICE_INDEX_ANALOG_LEFT) { - if (id==RETRO_DEVICE_ID_ANALOG_X) return pad.laxis.x; - else if (id==RETRO_DEVICE_ID_ANALOG_Y) return pad.laxis.y; - } - else if (index==RETRO_DEVICE_INDEX_ANALOG_RIGHT) { - if (id==RETRO_DEVICE_ID_ANALOG_X) return pad.raxis.x; - else if (id==RETRO_DEVICE_ID_ANALOG_Y) return pad.raxis.y; + // Analog inputs (local only - no netplay analog support) + else if (port == 0 && device == RETRO_DEVICE_ANALOG) { + if (!Netplay_isActive() || Netplay_getMode() == NETPLAY_HOST) { + if (index == RETRO_DEVICE_INDEX_ANALOG_LEFT) { + if (id == RETRO_DEVICE_ID_ANALOG_X) return pad.laxis.x; + else if (id == RETRO_DEVICE_ID_ANALOG_Y) return pad.laxis.y; + } + else if (index == RETRO_DEVICE_INDEX_ANALOG_RIGHT) { + if (id == RETRO_DEVICE_ID_ANALOG_X) return pad.raxis.x; + else if (id == RETRO_DEVICE_ID_ANALOG_Y) return pad.raxis.y; + } } } return 0; diff --git a/workspace/all/minarch/ma_input.h b/workspace/all/minarch/ma_input.h index 1fc520010..558d3e500 100644 --- a/workspace/all/minarch/ma_input.h +++ b/workspace/all/minarch/ma_input.h @@ -7,5 +7,8 @@ void input_poll_callback(void); int16_t input_state_callback(unsigned port, unsigned device, unsigned index, unsigned id); void Input_init(const struct retro_input_descriptor *vars); +// Current local RETRO_DEVICE_ID_JOYPAD_* button bitmask (for netplay input sync). +uint32_t Input_getButtons(void); + // Menu functions (defined in ma_menu.c, see ma_menu.h) #include "ma_menu.h" diff --git a/workspace/all/minarch/ma_internal.h b/workspace/all/minarch/ma_internal.h index 28ffcaa57..41a40a91a 100644 --- a/workspace/all/minarch/ma_internal.h +++ b/workspace/all/minarch/ma_internal.h @@ -56,6 +56,10 @@ struct Core { size_t (*get_memory_size)(unsigned id); retro_core_options_update_display_callback_t update_visibility_callback; + + bool has_netpacket; // Netpacket interface (for GBA Link support) + bool show_netplay; // Whether to show netplay menu (false for cores that don't support it like mGBA) + bool has_gblink; // GB Link support (gambatte core) }; struct Game { @@ -128,6 +132,17 @@ extern int rewind_cfg_compress; extern int rewind_cfg_lz4_acceleration; extern int rewind_init_ready; +// Core option batching (defined in ma_options.c, used by minarch.c netplay accessors). +// While in batch mode, OptionList_setOptionValue defers config.core.changed so a +// run of related option writes triggers a single core re-read. +extern int option_batch_mode; +extern int option_batch_changed; + +// Suppress video output for one forced core frame (defined in ma_video.c). +// Used by minarch_forceCoreOptionUpdate() to run a frame purely to trigger +// the core's check_variables() without flashing a frame on screen. +extern int skip_video_output; + #include "ma_rewind.h" /* ----------------------------------------------------------------------- diff --git a/workspace/all/minarch/ma_menu.c b/workspace/all/minarch/ma_menu.c index 5a5c130d0..b5f9de646 100644 --- a/workspace/all/minarch/ma_menu.c +++ b/workspace/all/minarch/ma_menu.c @@ -22,6 +22,7 @@ #include "ma_video.h" #include "ma_frontend_opts.h" #include "ma_menu.h" +#include "netplay_helper.h" // netplay menu hooks + minarch.h accessor prototypes /////////////////////////////// @@ -113,7 +114,7 @@ void MSG_quit(void) { /////////////////////////////////////// -#define MENU_ITEM_COUNT 5 +#define MENU_ITEM_COUNT 6 #define MENU_SLOT_COUNT 8 enum { @@ -121,6 +122,7 @@ enum { ITEM_SAVE, ITEM_LOAD, ITEM_OPTS, + ITEM_NETPLAY, ITEM_QUIT, }; @@ -162,10 +164,15 @@ static struct { [ITEM_SAVE] = "Save", [ITEM_LOAD] = "Load", [ITEM_OPTS] = "Options", + [ITEM_NETPLAY] = "Netplay", [ITEM_QUIT] = "Quit", } }; +// Accessor for external modules (netplay) that need the paused-menu backdrop. +// Lives here because `menu` is file-static; prototype is in minarch.h. +SDL_Surface* minarch_getMenuBitmap(void) { return menu.bitmap; } + void Menu_init(void) { menu.overlay = SDL_CreateRGBSurfaceWithFormat(SDL_SWSURFACE, DEVICE_WIDTH,DEVICE_HEIGHT, @@ -1646,6 +1653,9 @@ void Menu_screenshot(void) { } } void Menu_saveState(void) { + // Block save states during multiplayer - causes connection breaks + if (Multiplayer_isActive()) { return;} + // LOG_info("Menu_saveState\n"); Menu_updateState(); @@ -1685,6 +1695,9 @@ void Menu_saveState(void) { } } void Menu_loadState(void) { + // Block load states during multiplayer - causes connection breaks + if (Multiplayer_isActive()) { return; } + Menu_updateState(); if (menu.save_exists) { @@ -1792,15 +1805,26 @@ void Menu_loop(void) { uint32_t now = SDL_GetTicks(); PAD_poll(); - + + if (Netplay_isConnected()) { + Netplay_pollWhilePaused(); + } + int mp_active = Multiplayer_isActive(); + if (PAD_justPressed(BTN_UP)) { - selected -= 1; - if (selected<0) selected += MENU_ITEM_COUNT; + do { + selected -= 1; + if (selected<0) selected += MENU_ITEM_COUNT; + } while ((!core.show_netplay && selected == ITEM_NETPLAY) || + (mp_active && (selected == ITEM_SAVE || selected == ITEM_LOAD))); dirty = 1; } else if (PAD_justPressed(BTN_DOWN)) { - selected += 1; - if (selected>=MENU_ITEM_COUNT) selected -= MENU_ITEM_COUNT; + do { + selected += 1; + if (selected>=MENU_ITEM_COUNT) selected -= MENU_ITEM_COUNT; + } while ((!core.show_netplay && selected == ITEM_NETPLAY) || + (mp_active && (selected == ITEM_SAVE || selected == ITEM_LOAD))); dirty = 1; } else if (PAD_justPressed(BTN_LEFT)) { @@ -1888,7 +1912,19 @@ void Menu_loop(void) { } } break; + case ITEM_NETPLAY: + { + LinkType link_type = core.has_netpacket ? LINK_TYPE_GBALINK : + core.has_gblink ? LINK_TYPE_GBLINK : LINK_TYPE_NETPLAY; + if (Netplay_menu_link(link_type)) { + status = STATUS_CONT; + show_menu = 0; + } + dirty = 1; + } + break; case ITEM_QUIT: + Netplay_quitAll(); status = STATUS_QUIT; show_menu = 0; quit = 1; // TODO: tmp? diff --git a/workspace/all/minarch/ma_options.c b/workspace/all/minarch/ma_options.c index 82bc5ce64..50c23f60f 100644 --- a/workspace/all/minarch/ma_options.c +++ b/workspace/all/minarch/ma_options.c @@ -5,6 +5,13 @@ #include #include +// Core option batching (toggled via minarch_beginOptionsBatch/endOptionsBatch). +// While batching, OptionList_setOptionValue records that something changed but +// defers config.core.changed until the batch ends, so a burst of related option +// writes triggers a single core variable re-read instead of one per write. +int option_batch_mode = 0; +int option_batch_changed = 0; + int Option_getValueIndex(Option* item, const char* value) { if (!value || !item || !item->values) return 0; int i = 0; @@ -312,7 +319,8 @@ void OptionList_setOptionValue(OptionList* list, const char* key, const char* va Option* item = OptionList_getOption(list, key); if (item) { Option_setValue(item, value); - list->changed = 1; + if (option_batch_mode) option_batch_changed = 1; + else list->changed = 1; // LOG_info("\tSET %s (%s) TO %s (%s)\n", item->name, item->key, item->labels[item->value], item->values[item->value]); // if (list->on_set) list->on_set(list, key); diff --git a/workspace/all/minarch/ma_video.c b/workspace/all/minarch/ma_video.c index edee20f21..c009409a8 100644 --- a/workspace/all/minarch/ma_video.c +++ b/workspace/all/minarch/ma_video.c @@ -5,6 +5,10 @@ #include "scaler.h" #include "ma_video.h" +// When set, video_refresh_callback drops the frame. minarch_forceCoreOptionUpdate() +// uses this to run one core frame purely to trigger check_variables() without flashing. +int skip_video_output = 0; + static const char* bitmap_font[] = { ['0'] = @@ -950,6 +954,9 @@ void video_refresh_callback(const void* data, unsigned width, unsigned height, s // Early exit if quitting to avoid rendering stale frames if (quit) return; + // Suppress output for forced option-update frames (minarch_forceCoreOptionUpdate) + if (skip_video_output) return; + // Allocate RGBA buffer if needed if (!rgbaData || rgbaDataSize != width * height) { if (rgbaData) free(rgbaData); diff --git a/workspace/all/minarch/makefile b/workspace/all/minarch/makefile index ae10f2529..8add6caf7 100644 --- a/workspace/all/minarch/makefile +++ b/workspace/all/minarch/makefile @@ -21,11 +21,11 @@ SDL?=SDL TARGET = minarch PRODUCT= build/$(PLATFORM)/$(TARGET).elf -INCDIR = -I. -I./libretro-common/include/ -I../common/ -I../../$(PLATFORM)/platform/ +INCDIR = -I. -I./libretro-common/include/ -I../common/ -I../../$(PLATFORM)/platform/ -I../netplay/ SOURCE = $(TARGET).c ma_cheats.c ma_rewind.c ma_audio.c ma_input.c \ ma_options.c ma_frontend_opts.c ma_saves.c ma_video.c ma_core.c ma_game.c ma_environment.c ma_config.c ma_menu.c ma_runframe.c \ ../common/scaler.c ../common/utils.c ../common/config.c ../common/api.c \ - ../common/notification.c ../../$(PLATFORM)/platform/platform.c + ../common/notification.c ../../$(PLATFORM)/platform/platform.c ../netplay/netplay.c ../netplay/gbalink.c ../netplay/gblink.c ../netplay/network_common.c ../netplay/netplay_helper.c ../netplay/keyboard.c # RA support ifneq (,$(filter $(PLATFORM),tg5040 tg5050 my355 desktop)) @@ -33,6 +33,15 @@ ifneq (,$(filter $(PLATFORM),tg5040 tg5050 my355 desktop)) SOURCE += ../common/http.c ../common/ra_badges.c ../common/ra_offline.c ../common/ra_sync.c ../common/ra_event_queue.c ra_integration.c chd_reader.c endif +# Netplay WiFi (hotspot/AP) support. +# wifi_direct.c provides AP hosting and delegates client ops to the platform +# WiFi stack (common/generic_wifi.c -> PLAT_wifi*), so it only builds on +# platforms that ship that stack. +ifneq (,$(filter $(PLATFORM),tg5040 tg5050)) +SOURCE += ../netplay/wifi_direct.c +CFLAGS += -DHAS_WIFIMG +endif + CC = $(CROSS_COMPILE)gcc CFLAGS += $(OPT) CFLAGS += $(INCDIR) -DPLATFORM=\"$(PLATFORM)\" -std=gnu99 diff --git a/workspace/all/minarch/minarch.c b/workspace/all/minarch/minarch.c index b3e156eda..8aaa92217 100644 --- a/workspace/all/minarch/minarch.c +++ b/workspace/all/minarch/minarch.c @@ -3,6 +3,12 @@ #include +#include "scaler.h" +#include "minarch.h" +#include "netplay.h" +#include "gbalink.h" +#include "gblink.h" +#include "netplay_helper.h" #include "notification.h" #include "ra_integration.h" @@ -257,8 +263,26 @@ int main(int argc , char* argv[]) { while (!quit) { GFX_startFrame(); - run_frame(); - + // Netplay: synchronize inputs BEFORE running the core. If we're still waiting + // on the peer this frame, poll input (so menu/quit stay responsive) and skip it. + if (!Netplay_update((uint16_t)Input_getButtons(), core.serialize_size, core.serialize, core.unserialize)) { + input_poll_callback(); + continue; + } + + GBALink_update(); + GBALink_pollAndDeliverPackets(); + GBLink_pollConnectionState(); // GB Link: detect connect/disconnect from the socket table + + if (Multiplayer_isActive()) { + core.run(); // link/netplay drives timing; rewind & FF are disabled + } else { + run_frame(); + } + if (Netplay_isActive()) { + Netplay_postFrame(); + } + // Process RetroAchievements for this frame RA_doFrame(); @@ -312,13 +336,29 @@ int main(int argc , char* argv[]) { } if (show_menu) { + if (Netplay_isConnected()) { + Netplay_pause(); + } PWR_updateFrequency(PWR_UPDATE_FREQ,1); Menu_loop(); // Process RA async operations while menu is shown RA_idle(); + if (Netplay_isPaused()) { + Netplay_resume(); + } PWR_updateFrequency(PWR_UPDATE_FREQ_INGAME,0); has_pending_opt_change = config.core.changed; chooseSyncRef(); + + // Clear FF/rewind state if multiplayer is now active + if (Multiplayer_isActive()) { + fast_forward = setFastForward(0); + ff_toggled = 0; + ff_hold_active = 0; + rewind_toggle = 0; + rewind_pressed = 0; + rewinding = 0; + } } Audio_checkAndResetIfNeeded(); @@ -344,10 +384,14 @@ int main(int argc , char* argv[]) { PLAT_clearTurbo(); Menu_quit(); + QuitSettings(); finish: - Perf_setCPUMonitorEnabled(0); + + Netplay_quitAll(); + + Perf_setCPUMonitorEnabled(0); // Unload game and shutdown RetroAchievements before Notification_quit — // RA background threads (sync, badge downloads) may call notification @@ -376,3 +420,81 @@ int main(int argc , char* argv[]) { Menu_waitScreenshot(); return EXIT_SUCCESS; } + +////////////////////////////////////////////////////////////////////////////// +// Accessor functions for external modules +////////////////////////////////////////////////////////////////////////////// + +// Screen/display accessors +SDL_Surface* minarch_getScreen(void) { return screen; } +int minarch_getDeviceWidth(void) { return DEVICE_WIDTH; } +int minarch_getDeviceHeight(void) { return DEVICE_HEIGHT; } +// minarch_getMenuBitmap() is defined in ma_menu.c, which owns the menu state. + +// Game state accessors +const char* minarch_getCoreTag(void) { return core.tag; } +const char* minarch_getGameName(void) { return game.name; } +void* minarch_getGameData(void) { return game.data; } +size_t minarch_getGameSize(void) { return game.size; } + +// Core option accessors +char* minarch_getCoreOptionValue(const char* key) { + return OptionList_getOptionValue(&config.core, key); +} +void minarch_setCoreOptionValue(const char* key, const char* value) { + OptionList_setOptionValue(&config.core, key, value); +} + +// Sleep state accessors +void minarch_beforeSleep(void) { Menu_beforeSleep(); } +void minarch_afterSleep(void) { Menu_afterSleep(); } + +// Platform accessors +void minarch_hdmimon(void) { hdmimon(); } + +// Menu accessors +int minarch_menuMessage(char* message, char** pairs) { return Menu_message(message, pairs); } + +// Save current config to file (used before core reset to preserve option changes) +void minarch_saveConfig(void) { Config_write(CONFIG_WRITE_ALL); } + +////////////////////////////////////////////////////////////////////////////// +// Utility/API functions for external modules +////////////////////////////////////////////////////////////////////////////// + +void minarch_beginOptionsBatch(void) { + option_batch_mode = 1; + option_batch_changed = 0; +} +void minarch_endOptionsBatch(void) { + option_batch_mode = 0; + if (option_batch_changed) { + config.core.changed = 1; + option_batch_changed = 0; + } +} + +// Force core to process option changes immediately (used by gblink.c and netplay_helper.c) +// Runs one frame with video output suppressed to trigger check_variables() +void minarch_forceCoreOptionUpdate(void) { + skip_video_output = 1; + core.run(); + skip_video_output = 0; +} + + +// Reload the game to reinitialize core state (e.g., for gpSP serial mode changes) +// Unloads and reloads the ROM so the core re-reads options during load_game() +void minarch_reloadGame(void) { + SRAM_write(); + core.unload_game(); + + struct retro_game_info game_info; + game_info.path = game.tmp_path[0] ? game.tmp_path : game.path; + game_info.data = game.data; + game_info.size = game.size; + core.load_game(&game_info); + + SRAM_read(); + Core_updateAVInfo(); +} diff --git a/workspace/all/minarch/minarch.h b/workspace/all/minarch/minarch.h new file mode 100644 index 000000000..50ec3a7c0 --- /dev/null +++ b/workspace/all/minarch/minarch.h @@ -0,0 +1,66 @@ +/* + * NextUI Minarch Header + * Shared types and accessor/API functions for minarch menu system + * Use these to avoid symbol conflicts with cores and struct layout mismatches with LTO + */ + +#ifndef MINARCH_H +#define MINARCH_H + +#include +#include + +// Menu callback result codes +// Guarded so it can coexist with the identical enum in ma_frontend_opts.h +// (minarch.c and ma_menu.c pull in both headers). +#ifndef MENU_CALLBACK_CODES_DEFINED +#define MENU_CALLBACK_CODES_DEFINED +enum { + MENU_CALLBACK_NOP = 0, + MENU_CALLBACK_EXIT = 1, + MENU_CALLBACK_NEXT_ITEM = 2, +}; +#endif + +// Screen/display accessors +SDL_Surface* minarch_getScreen(void); +int minarch_getDeviceWidth(void); +int minarch_getDeviceHeight(void); +SDL_Surface* minarch_getMenuBitmap(void); + +// Game state accessors +const char* minarch_getCoreTag(void); +const char* minarch_getGameName(void); +void* minarch_getGameData(void); +size_t minarch_getGameSize(void); + +// Core option accessors +char* minarch_getCoreOptionValue(const char* key); +void minarch_setCoreOptionValue(const char* key, const char* value); + +// Batch mode for setting multiple core options atomically +void minarch_beginOptionsBatch(void); +void minarch_endOptionsBatch(void); + +// Force core to process option changes immediately +// Runs one frame with video output suppressed to trigger check_variables() +void minarch_forceCoreOptionUpdate(void); + +// Save current config to file +void minarch_saveConfig(void); + +// Reload game to apply option changes (e.g., gpSP serial mode) +// Unloads and reloads ROM so core re-reads options during load_game() +void minarch_reloadGame(void); + +// Sleep state accessors +void minarch_beforeSleep(void); +void minarch_afterSleep(void); + +// Platform accessors +void minarch_hdmimon(void); + +// Menu accessors +int minarch_menuMessage(char* message, char** pairs); + +#endif /* MINARCH_H */ diff --git a/workspace/all/netplay/gbalink.c b/workspace/all/netplay/gbalink.c new file mode 100644 index 000000000..bda4e4ea5 --- /dev/null +++ b/workspace/all/netplay/gbalink.c @@ -0,0 +1,1672 @@ +/* + * NextUI GBA Link Module + * Implements GBA Wireless Adapter (RFU) emulation over WiFi + * + * This module provides a transport layer for the libretro netpacket interface, + * allowing gpSP to use its built-in Wireless Adapter (RFU) emulation over TCP. + * + * gpSP has complete RFU support (rfu.c - 937 lines) that handles the complex + * wireless adapter protocol used by Pokemon games for trading and battles. + * + * Unlike netplay (input synchronization), GBA Link: + * - Uses gpSP's native RFU timing and protocol + * - Each device runs its own save file and game state + * - Only wireless adapter packets are exchanged (not inputs) + * + * Supported features via gpSP: + * - Pokemon trading (FireRed/LeafGreen/Ruby/Sapphire/Emerald) + * - Pokemon battles (Union Room) + */ + +#define _GNU_SOURCE // For strcasestr + +#include "gbalink.h" +#include "minarch.h" +#include "netplay_helper.h" +#include "network_common.h" +#include "defines.h" // Must come before api.h for BTN_ID_COUNT +#include "api.h" +#ifdef HAS_WIFIMG +#include "wifi_direct.h" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Protocol constants +#define GL_PROTOCOL_MAGIC 0x47424C4B // "GBLK" +#define GL_DISCOVERY_QUERY 0x47424451 // "GBDQ" - GBA Link Discovery Query +#define GL_DISCOVERY_RESP 0x47424452 // "GBDR" - GBA Link Discovery Response + +// Discovery broadcast interval +#define DISCOVERY_BROADCAST_INTERVAL_US 500000 // 500ms + +// Network commands +enum { + CMD_SIO_DATA = 0x01, // SIO packet data from core + CMD_PING = 0x02, + CMD_PONG = 0x03, + CMD_DISCONNECT = 0x04, + CMD_READY = 0x05, // Signal ready for SIO exchange + CMD_HEARTBEAT = 0x06, // Keepalive during idle periods +}; + +// Heartbeat interval - RFU protocol requires host to send data so clients can respond +// 500ms interval keeps connection alive without excessive overhead +// (100ms was too aggressive and could overwhelm slow receivers) +#define HEARTBEAT_INTERVAL_MS 500 + +// Connection timeout - disconnect if no packets received for this long +// 60 seconds provides headroom for: +// - WiFi latency spikes and packet loss +// - Game auto-saves (Pokemon saves when entering Union Room, etc.) +// - Long RFU protocol pauses during room transitions +// Real disconnections are still detected via TCP errors and heartbeat failures +#define GBALINK_CONNECTION_TIMEOUT_MS 60000 + +// Packet header for TCP communication +typedef struct __attribute__((packed)) { + uint8_t cmd; + uint16_t size; + uint16_t client_id; // Source client ID +} PacketHeader; + +// Receive buffer for incoming packets +// GBA wireless packets vary in size: trades ~32 bytes, battles ~200 bytes max +// 2048 bytes is sufficient headroom while reducing memory usage +#define RECV_BUFFER_SIZE 2048 +typedef struct { + uint8_t data[RECV_BUFFER_SIZE]; + size_t len; + uint16_t client_id; +} ReceivedPacket; + + +// Pending packet queue - needs enough slots to handle burst traffic during +// trade/battle setup. 32 slots with 2KB buffers = 64KB (reduced from 256KB) +#define MAX_PENDING_PACKETS 32 + +// Main GBA Link state +static struct { + GBALinkMode mode; + GBALinkState state; + + // Sockets + int tcp_fd; // Main TCP connection + int listen_fd; // Server listen socket + int udp_fd; // Discovery UDP broadcast socket (for sending) + int udp_listen_fd; // Discovery UDP listen socket (for receiving queries) + + // Connection info + char local_ip[16]; + char remote_ip[16]; + uint16_t port; + + // Hotspot mode + GBALinkConnMethod conn_method; + bool using_hotspot; // True if hotspot was started for this session + bool connected_to_hotspot; // True if client connected to hotspot WiFi + + // Game info + char game_name[GBALINK_MAX_GAME_NAME]; + uint32_t game_crc; + + // Netpacket interface (from core) + bool core_registered; + uint16_t local_client_id; + retro_netpacket_send_t core_send_fn; // Stored but we provide our own to core + retro_netpacket_poll_receive_t core_poll_fn; + + // Receive buffer for delivering to core + ReceivedPacket pending_packets[MAX_PENDING_PACKETS]; + int pending_count; + int pending_read_idx; + int pending_write_idx; + + // Discovery + GBALinkHostInfo discovered_hosts[GBALINK_MAX_HOSTS]; + int num_hosts; + bool discovery_active; + + // Threading + pthread_t listen_thread; + pthread_mutex_t mutex; + volatile bool running; + + // Status + char status_msg[128]; + + // Core support flag + bool has_netpacket_support; + + // Streaming receive buffer for handling partial TCP reads + // Uses read/write indices to avoid memmove on every packet + uint8_t stream_buf[RECV_BUFFER_SIZE + sizeof(PacketHeader)]; + size_t stream_buf_read_idx; // Where to read next packet from + size_t stream_buf_write_idx; // Where to write incoming data + + // Heartbeat/keepalive tracking - critical for RFU protocol + // The host must send data (even dummy) so clients can respond + struct timeval last_packet_sent; + struct timeval last_packet_received; + + // Deferred connection notification (listen thread sets, main thread processes) + // Required because core callbacks must be called from main thread + volatile bool pending_host_connected; + + // Initialization flag + bool initialized; + + // Core netpacket callbacks (set by minarch when core registers) + struct retro_netpacket_callback core_callbacks; + bool has_core_callbacks; + + // Netpacket bridging state + bool netpacket_active; + uint16_t remote_client_id; // Cached: 1 if we're host, 0 if we're client + + // Link mode synchronization (host's gpsp_serial value sent to client) + char link_mode[32]; + + // Pending reload state (when client's link mode differs from host's) + bool needs_reload; + char pending_link_mode[32]; // Host's mode (what to change to) + char client_link_mode[32]; // Client's current mode + + // Performance optimization: reduce getsockopt frequency + int error_check_counter; + + // Cached frame time to avoid multiple gettimeofday() calls per frame + struct timeval frame_time; + bool frame_time_valid; + + // Deferred disconnect notification (set by recv_packet, processed after mutex release) + volatile bool pending_disconnect_notify; +} gl = {0}; + +// Forward declarations +static bool send_packet(uint8_t cmd, const void* data, uint16_t size, uint16_t client_id); +static bool recv_packet(PacketHeader* hdr, void* data, uint16_t max_size, int timeout_ms); +static void* listen_thread_func(void* arg); +static void GBALink_sendHeartbeatIfNeeded(const struct timeval* now); + +////////////////////////////////////////////////////////////////////////////// +// Performance Optimization Helpers +////////////////////////////////////////////////////////////////////////////// + +// Cache frame time - call once at start of frame to avoid multiple gettimeofday() syscalls +static void cache_frame_time(void) { + gettimeofday(&gl.frame_time, NULL); + gl.frame_time_valid = true; +} + +// Get cached frame time (falls back to fresh call if not cached) +static const struct timeval* get_frame_time(void) { + if (!gl.frame_time_valid) { + cache_frame_time(); + } + return &gl.frame_time; +} + +// Invalidate frame time cache (call at end of frame) +static void invalidate_frame_time(void) { + gl.frame_time_valid = false; +} + +// Compact stream buffer if needed - consolidates fragmented buffer space +// Only compacts when read_idx is past halfway point AND we need more space +// This reduces memmove frequency significantly during burst traffic +static void compact_stream_buffer_if_needed(size_t min_space_needed) { + size_t available = gl.stream_buf_write_idx - gl.stream_buf_read_idx; + size_t space_at_end = sizeof(gl.stream_buf) - gl.stream_buf_write_idx; + + // Only compact if: + // 1. We need more space than available at end + // 2. Read index is past halfway point (worth the memmove cost) + // 3. There's actually data to move + if (space_at_end < min_space_needed && + gl.stream_buf_read_idx > sizeof(gl.stream_buf) / 2 && + available > 0) { + memmove(gl.stream_buf, gl.stream_buf + gl.stream_buf_read_idx, available); + gl.stream_buf_read_idx = 0; + gl.stream_buf_write_idx = available; + } +} + +////////////////////////////////////////////////////////////////////////////// +// Initialization +////////////////////////////////////////////////////////////////////////////// + +void GBALink_init(void) { + if (gl.initialized) return; + + // Preserve core callbacks - they may have been set before init + // (core registers callbacks in retro_init, before GBALink session starts) + struct retro_netpacket_callback saved_callbacks = gl.core_callbacks; + bool saved_has_callbacks = gl.has_core_callbacks; + bool saved_has_netpacket = gl.has_netpacket_support; + + memset(&gl, 0, sizeof(gl)); + + // Restore core callbacks + gl.core_callbacks = saved_callbacks; + gl.has_core_callbacks = saved_has_callbacks; + gl.has_netpacket_support = saved_has_netpacket; + + gl.mode = GBALINK_OFF; + gl.state = GBALINK_STATE_IDLE; + gl.tcp_fd = -1; + gl.listen_fd = -1; + gl.udp_fd = -1; + gl.udp_listen_fd = -1; + gl.port = GBALINK_DEFAULT_PORT; + pthread_mutex_init(&gl.mutex, NULL); + NET_getLocalIP(gl.local_ip, sizeof(gl.local_ip)); + snprintf(gl.status_msg, sizeof(gl.status_msg), "GBA Link ready"); + gl.initialized = true; +} + +void GBALink_quit(void) { + if (!gl.initialized) return; + + // Capture hotspot state before cleanup (under mutex for our state) + pthread_mutex_lock(&gl.mutex); + bool was_host = (gl.mode == GBALINK_HOST); + bool needs_hotspot_cleanup = gl.using_hotspot; + pthread_mutex_unlock(&gl.mutex); + + // Read external global separately (it has its own sync in netplay_helper) + bool client_connected_hotspot = gbalink_connected_to_hotspot; + needs_hotspot_cleanup = needs_hotspot_cleanup || client_connected_hotspot; + + GBALink_disconnect(); + GBALink_stopHostFast(); + GBALink_stopDiscovery(); + + // Handle hotspot cleanup asynchronously + if (needs_hotspot_cleanup) { + stopHotspotAndRestoreWiFiAsync(was_host); + gbalink_connected_to_hotspot = 0; + } + + pthread_mutex_destroy(&gl.mutex); + gl.initialized = false; +} + +bool GBALink_checkCoreSupport(const char* core_name) { + // Only gpSP supports Wireless Adapter/RFU via netpacket interface + // core_name is derived from the .so filename (e.g., "gpsp" from "gpsp_libretro.so") + bool supported = strcasecmp(core_name, "gpsp") == 0; + gl.has_netpacket_support = supported; + return supported; +} + +// Set the link mode to synchronize with client +// Called before hosting to capture the current gpsp_serial value +void GBALink_setLinkMode(const char* mode) { + if (mode) { + strncpy(gl.link_mode, mode, sizeof(gl.link_mode) - 1); + gl.link_mode[sizeof(gl.link_mode) - 1] = '\0'; + } else { + gl.link_mode[0] = '\0'; + } +} + +// Get the current link mode (for debugging) +const char* GBALink_getLinkMode(void) { + return gl.link_mode[0] ? gl.link_mode : NULL; +} + +// Get pending link mode (host's mode to change to) after GBALINK_CONNECT_NEEDS_RELOAD +const char* GBALink_getPendingLinkMode(void) { + return gl.needs_reload && gl.pending_link_mode[0] ? gl.pending_link_mode : NULL; +} + +// Get client's current link mode (what it was before host connection) +const char* GBALink_getClientLinkMode(void) { + return gl.needs_reload && gl.client_link_mode[0] ? gl.client_link_mode : NULL; +} + +// Clear pending reload state (called when user cancels) +void GBALink_clearPendingReload(void) { + gl.needs_reload = false; + gl.pending_link_mode[0] = '\0'; + gl.client_link_mode[0] = '\0'; +} + +// Apply pending link mode to config (called when user confirms before reload) +// Note: gpsp ignores runtime option changes, so we just set the option here +// and the caller must reload the game for gpsp to pick it up +void GBALink_applyPendingLinkMode(void) { + if (gl.needs_reload && gl.pending_link_mode[0]) { + minarch_setCoreOptionValue("gpsp_serial", gl.pending_link_mode); + GBALink_clearPendingReload(); + } +} + +////////////////////////////////////////////////////////////////////////////// +// Helper Functions +////////////////////////////////////////////////////////////////////////////// + +// GBALink-specific TCP configuration: +// - 32KB buffers: smaller for faster congestion feedback on WiFi +// (large buffers hide congestion = bufferbloat, causing delayed failure detection) +// GBA packets are small (16-104 bytes), so 32KB handles bursts without masking issues +// - 1ms recv timeout for RFU sub-frame timing +// - Keepalive enabled for dead connection detection +static const NET_TCPConfig GBALINK_TCP_CONFIG = { + .buffer_size = 32768, // 32KB - smaller for faster WiFi congestion feedback + .recv_timeout_us = 1000, // 1ms timeout for RFU timing + .enable_keepalive = true +}; + +////////////////////////////////////////////////////////////////////////////// +// Host Mode +////////////////////////////////////////////////////////////////////////////// + +int GBALink_startHost(const char* game_name, uint32_t game_crc, const char* hotspot_ip, const char* link_mode) { + LOG_info("GBALink: HOST startHost() game=%s hotspot=%s link_mode=%s has_callbacks=%d\n", + game_name, hotspot_ip ? hotspot_ip : "NULL", link_mode ? link_mode : "NULL", + gl.has_core_callbacks); + GBALink_init(); // Lazy init + if (gl.mode != GBALINK_OFF) { + LOG_info("GBALink: HOST already in mode %d, aborting\n", gl.mode); + return -1; + } + + // Set link mode for client synchronization (must be after init to avoid being cleared) + GBALink_setLinkMode(link_mode); + + // Set up IP based on mode + if (hotspot_ip) { + gl.using_hotspot = true; + gl.conn_method = GBALINK_CONN_HOTSPOT; + strncpy(gl.local_ip, hotspot_ip, sizeof(gl.local_ip) - 1); + gl.local_ip[sizeof(gl.local_ip) - 1] = '\0'; + } + + // Create TCP listen socket using shared utility + gl.listen_fd = NET_createListenSocket(gl.port, gl.status_msg, sizeof(gl.status_msg)); + if (gl.listen_fd < 0) { + if (hotspot_ip) { + gl.using_hotspot = false; + } + return -1; + } + + // Create UDP socket for discovery broadcasts + gl.udp_fd = NET_createBroadcastSocket(); + if (gl.udp_fd < 0) { + close(gl.listen_fd); + gl.listen_fd = -1; + if (hotspot_ip) { + gl.using_hotspot = false; + } + snprintf(gl.status_msg, sizeof(gl.status_msg), "Failed to create broadcast socket"); + return -1; + } + + // Create UDP socket for receiving discovery queries (especially for hotspot mode) + gl.udp_listen_fd = NET_createDiscoveryListenSocket(GBALINK_DISCOVERY_PORT); + if (gl.udp_listen_fd < 0) { + LOG_warn("GBALink: Could not create UDP query listener (non-fatal)\n"); + // Non-fatal - broadcasts still work on regular WiFi + } + + strncpy(gl.game_name, game_name, GBALINK_MAX_GAME_NAME - 1); + gl.game_crc = game_crc; + + // Start listen thread + gl.running = true; + pthread_create(&gl.listen_thread, NULL, listen_thread_func, NULL); + + gl.mode = GBALINK_HOST; + gl.state = GBALINK_STATE_WAITING; + gl.local_client_id = 0; // Host is always client 0 + + snprintf(gl.status_msg, sizeof(gl.status_msg), "Hosting on %s:%d", gl.local_ip, gl.port); + LOG_info("GBALink: HOST listening on %s:%d has_callbacks=%d\n", gl.local_ip, gl.port, gl.has_core_callbacks); + return 0; +} + +// Internal helper - stops host with optional hotspot cleanup +static int GBALink_stopHostInternal(bool skip_hotspot_cleanup) { + if (gl.mode != GBALINK_HOST) return -1; + + gl.running = false; + + // Close listen socket to unblock accept() in listen thread + if (gl.listen_fd >= 0) { + close(gl.listen_fd); + gl.listen_fd = -1; + } + + if (gl.listen_thread) { + // Thread will exit via gl.running check - no pthread_cancel needed + // (pthread_cancel can leave mutex/fd in undefined state) + pthread_join(gl.listen_thread, NULL); + gl.listen_thread = 0; + } + + if (gl.udp_fd >= 0) { + close(gl.udp_fd); + gl.udp_fd = -1; + } + + if (gl.udp_listen_fd >= 0) { + close(gl.udp_listen_fd); + gl.udp_listen_fd = -1; + } + + GBALink_disconnect(); + + // Stop hotspot if it was started + if (gl.using_hotspot) { + if (!skip_hotspot_cleanup) { +#ifdef HAS_WIFIMG + WIFI_direct_stopHotspot(); +#endif + } + gl.using_hotspot = false; + // Clear local IP since hotspot is stopping + // Hotspot stop will restore previous WiFi connection, + // and the IP will be refreshed when needed + strncpy(gl.local_ip, "0.0.0.0", sizeof(gl.local_ip) - 1); + } + gl.mode = GBALINK_OFF; + gl.state = GBALINK_STATE_IDLE; + snprintf(gl.status_msg, sizeof(gl.status_msg), "GBA Link ready"); + return 0; +} + +int GBALink_stopHost(void) { + return GBALink_stopHostInternal(false); +} + +int GBALink_stopHostFast(void) { + return GBALink_stopHostInternal(true); +} + +// Restart UDP broadcast when going back to waiting state +// Called when client disconnects but host wants to accept new clients +static void GBALink_restartBroadcast(void) { + if (gl.udp_fd >= 0) return; // Already running + if (gl.mode != GBALINK_HOST) return; // Only for host + + gl.udp_fd = NET_createBroadcastSocket(); + if (gl.udp_fd < 0) { + snprintf(gl.status_msg, sizeof(gl.status_msg), "Failed to restart broadcast"); + } +} + +static void* listen_thread_func(void* arg) { + (void)arg; + + // Use shared broadcast timer for rate limiting + NET_BroadcastTimer broadcast_timer; + NET_initBroadcastTimer(&broadcast_timer, DISCOVERY_BROADCAST_INTERVAL_US); + + while (gl.running && gl.listen_fd >= 0) { + // Rate-limited discovery broadcast using shared timer + if (gl.udp_fd >= 0 && gl.state == GBALINK_STATE_WAITING) { + if (NET_shouldBroadcast(&broadcast_timer)) { + NET_sendDiscoveryBroadcast(gl.udp_fd, GL_DISCOVERY_RESP, GBALINK_PROTOCOL_VERSION, + gl.game_crc, gl.port, GBALINK_DISCOVERY_PORT, + gl.game_name, gl.link_mode); + } + } + + // Handle incoming UDP discovery queries (for hotspot mode where broadcasts may not work) + // Protect UDP socket access with mutex to prevent race with socket closure + pthread_mutex_lock(&gl.mutex); + int udp_fd = gl.udp_listen_fd; + bool in_waiting = (gl.state == GBALINK_STATE_WAITING); + pthread_mutex_unlock(&gl.mutex); + + if (udp_fd >= 0 && in_waiting) { + NET_DiscoveryPacket query_pkt; + struct sockaddr_in sender; + socklen_t sender_len = sizeof(sender); + ssize_t recv_len = recvfrom(udp_fd, &query_pkt, sizeof(query_pkt), MSG_DONTWAIT, + (struct sockaddr*)&sender, &sender_len); + if (recv_len >= (ssize_t)sizeof(query_pkt) && ntohl(query_pkt.magic) == GL_DISCOVERY_QUERY) { + // Respond directly to the sender with our info + pthread_mutex_lock(&gl.mutex); + NET_DiscoveryPacket resp_pkt = {0}; + resp_pkt.magic = htonl(GL_DISCOVERY_RESP); + resp_pkt.protocol_version = htonl(GBALINK_PROTOCOL_VERSION); + resp_pkt.game_crc = htonl(gl.game_crc); + resp_pkt.port = htons(gl.port); + strncpy(resp_pkt.game_name, gl.game_name, NET_MAX_GAME_NAME - 1); + strncpy(resp_pkt.link_mode, gl.link_mode, NET_MAX_LINK_MODE - 1); + int send_fd = gl.udp_listen_fd; // Re-check under mutex + pthread_mutex_unlock(&gl.mutex); + if (send_fd >= 0) { + sendto(send_fd, &resp_pkt, sizeof(resp_pkt), 0, + (struct sockaddr*)&sender, sender_len); + } + } + } + + // Check for incoming connection + if (gl.state == GBALINK_STATE_WAITING && gl.listen_fd >= 0) { + fd_set fds; + FD_ZERO(&fds); + FD_SET(gl.listen_fd, &fds); + + struct timeval tv = {.tv_sec = 0, .tv_usec = 100000}; // 100ms timeout + int sel_result = select(gl.listen_fd + 1, &fds, NULL, NULL, &tv); + if (sel_result < 0 || !gl.running) break; // Socket closed or stopping + if (sel_result > 0) { + struct sockaddr_in client_addr; + socklen_t len = sizeof(client_addr); + + int fd = accept(gl.listen_fd, (struct sockaddr*)&client_addr, &len); + if (fd >= 0) { + char client_ip[16]; + inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip)); + LOG_info("GBALink: HOST accept() got connection from %s\n", client_ip); + + pthread_mutex_lock(&gl.mutex); + + if (gl.state != GBALINK_STATE_WAITING) { + LOG_info("GBALink: HOST rejecting - not in WAITING state\n"); + close(fd); + pthread_mutex_unlock(&gl.mutex); + continue; + } + + // Configure TCP socket using GBALink-specific settings + NET_configureTCPSocket(fd, &GBALINK_TCP_CONFIG); + + gl.tcp_fd = fd; + inet_ntop(AF_INET, &client_addr.sin_addr, gl.remote_ip, sizeof(gl.remote_ip)); + + gl.state = GBALINK_STATE_CONNECTED; + gl.pending_count = 0; + gl.pending_read_idx = 0; + gl.pending_write_idx = 0; + gl.stream_buf_read_idx = 0; + gl.stream_buf_write_idx = 0; + struct timeval now; + gettimeofday(&now, NULL); + gl.last_packet_sent = now; + gl.last_packet_received = now; + + snprintf(gl.status_msg, sizeof(gl.status_msg), "Client connected: %s", gl.remote_ip); + LOG_info("GBALink: HOST waiting for client READY signal...\n"); + + // Wait for client's READY signal + bool client_ready = false; + for (int attempts = 0; attempts < 100 && gl.running; attempts++) { // 5 second timeout + PacketHeader hdr; + uint8_t data[64]; + bool got_packet = recv_packet(&hdr, data, sizeof(data), 50); + if (got_packet && hdr.cmd == CMD_READY) { + client_ready = true; + break; + } + } + + LOG_info("GBALink: HOST client_ready=%d\n", client_ready); + if (!client_ready) { + LOG_error("GBALink: HOST timeout waiting for client READY\n"); + // Send DISCONNECT so client knows we rejected them + send_packet(CMD_DISCONNECT, NULL, 0, 0); + close(gl.tcp_fd); + gl.tcp_fd = -1; + gl.state = GBALINK_STATE_WAITING; + pthread_mutex_unlock(&gl.mutex); + continue; + } + + // Send READY back to client with link mode for synchronization + // Link mode is sent so client can match host's gpsp_serial setting + uint16_t mode_len = gl.link_mode[0] ? (uint16_t)(strlen(gl.link_mode) + 1) : 0; + send_packet(CMD_READY, gl.link_mode, mode_len, 0); + + // Verify state hasn't changed during handshake + // (main thread could have disconnected during mutex release in send_packet) + if (gl.tcp_fd < 0 || gl.state != GBALINK_STATE_CONNECTED) { + // Connection was interrupted during handshake + pthread_mutex_unlock(&gl.mutex); + continue; + } + + // Set flag for main thread to process (core callbacks must run on main thread) + // Memory barrier ensures all state writes are visible before flag is set + __sync_synchronize(); + gl.pending_host_connected = true; + LOG_info("GBALink: HOST handshake complete, pending_host_connected=true\n"); + + // Close UDP sockets - no longer needed after connection + if (gl.udp_fd >= 0) { + close(gl.udp_fd); + gl.udp_fd = -1; + } + if (gl.udp_listen_fd >= 0) { + close(gl.udp_listen_fd); + gl.udp_listen_fd = -1; + } + + pthread_mutex_unlock(&gl.mutex); + } + } + } else { + usleep(50000); // 50ms + } + } + + return NULL; +} + +////////////////////////////////////////////////////////////////////////////// +// Client Mode +////////////////////////////////////////////////////////////////////////////// + +int GBALink_connectToHost(const char* ip, uint16_t port) { + LOG_info("GBALink: CLIENT connectToHost(%s:%d) called\n", ip, port); + GBALink_init(); // Lazy init + if (gl.mode != GBALINK_OFF) { + LOG_info("GBALink: CLIENT already in mode %d, aborting\n", gl.mode); + return -1; + } + + // Refresh local IP - important when client just connected to hotspot WiFi + // This ensures we have the correct IP from the hotspot's DHCP server + NET_getLocalIP(gl.local_ip, sizeof(gl.local_ip)); + LOG_info("GBALink: CLIENT local_ip=%s\n", gl.local_ip); + + gl.tcp_fd = socket(AF_INET, SOCK_STREAM, 0); + if (gl.tcp_fd < 0) { + LOG_info("GBALink: CLIENT socket() failed errno=%d\n", errno); + snprintf(gl.status_msg, sizeof(gl.status_msg), "Socket creation failed"); + return -1; + } + + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + + if (inet_pton(AF_INET, ip, &addr.sin_addr) <= 0) { + close(gl.tcp_fd); + gl.tcp_fd = -1; + snprintf(gl.status_msg, sizeof(gl.status_msg), "Invalid IP address"); + return -1; + } + + gl.state = GBALINK_STATE_CONNECTING; + snprintf(gl.status_msg, sizeof(gl.status_msg), "Connecting to %s:%d...", ip, port); + + // Connect with timeout + struct timeval tv = {.tv_sec = 5, .tv_usec = 0}; + setsockopt(gl.tcp_fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + + if (connect(gl.tcp_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { + LOG_info("GBALink: CLIENT connect() failed errno=%d\n", errno); + close(gl.tcp_fd); + gl.tcp_fd = -1; + gl.state = GBALINK_STATE_ERROR; + snprintf(gl.status_msg, sizeof(gl.status_msg), "Connection failed"); + return -1; + } + + LOG_info("GBALink: CLIENT TCP connected to %s:%d\n", ip, port); + + // Configure TCP socket using GBALink-specific settings + NET_configureTCPSocket(gl.tcp_fd, &GBALINK_TCP_CONFIG); + + strncpy(gl.remote_ip, ip, sizeof(gl.remote_ip) - 1); + gl.port = port; + gl.mode = GBALINK_CLIENT; + gl.state = GBALINK_STATE_CONNECTED; + gl.local_client_id = 1; // Client is always client 1 + + gl.pending_count = 0; + gl.pending_read_idx = 0; + gl.pending_write_idx = 0; + gl.stream_buf_read_idx = 0; + gl.stream_buf_write_idx = 0; + struct timeval now; + gettimeofday(&now, NULL); + gl.last_packet_sent = now; + gl.last_packet_received = now; + + snprintf(gl.status_msg, sizeof(gl.status_msg), "Connected to %s", ip); + + // Send READY signal to host and wait for host's READY + pthread_mutex_lock(&gl.mutex); + send_packet(CMD_READY, NULL, 0, gl.local_client_id); + pthread_mutex_unlock(&gl.mutex); + + // Set socket receive timeout for handshake (5 seconds) + // This helps detect dead connections during handshake + struct timeval handshake_timeout = {.tv_sec = 5, .tv_usec = 0}; + setsockopt(gl.tcp_fd, SOL_SOCKET, SO_RCVTIMEO, &handshake_timeout, sizeof(handshake_timeout)); + + // Wait for host's READY signal (with timeout - 5 seconds = 100 x 50ms) + bool host_ready = false; + bool needs_reload = false; + for (int attempts = 0; attempts < 100; attempts++) { + PacketHeader hdr; + uint8_t data[64]; + pthread_mutex_lock(&gl.mutex); + bool got_packet = recv_packet(&hdr, data, sizeof(data), 50); + pthread_mutex_unlock(&gl.mutex); + + if (got_packet) { + if (hdr.cmd == CMD_READY) { + // Extract link mode from payload and check if it differs from client + if (hdr.size > 0 && hdr.size < sizeof(data)) { + data[hdr.size] = '\0'; // Ensure null-terminated + const char* host_link_mode = (const char*)data; + if (host_link_mode[0]) { + // Get client's current link mode + const char* client_mode = minarch_getCoreOptionValue("gpsp_serial"); + + // Check if modes differ (need reload for gpsp to pick up new mode) + if (!client_mode || strcmp(client_mode, host_link_mode) != 0) { + // Store the modes for UI confirmation + strncpy(gl.pending_link_mode, host_link_mode, sizeof(gl.pending_link_mode) - 1); + gl.pending_link_mode[sizeof(gl.pending_link_mode) - 1] = '\0'; + strncpy(gl.client_link_mode, client_mode ? client_mode : "auto", + sizeof(gl.client_link_mode) - 1); + gl.client_link_mode[sizeof(gl.client_link_mode) - 1] = '\0'; + gl.needs_reload = true; + needs_reload = true; + } + } + } + host_ready = true; + break; + } else if (hdr.cmd == CMD_DISCONNECT) { + // Host rejected us during handshake + LOG_error("GBALink: Host sent DISCONNECT during handshake\n"); + close(gl.tcp_fd); + gl.tcp_fd = -1; + gl.mode = GBALINK_OFF; + gl.state = GBALINK_STATE_ERROR; + snprintf(gl.status_msg, sizeof(gl.status_msg), "Host rejected connection"); + return GBALINK_CONNECT_ERROR; + } + } + } + + if (!host_ready) { + LOG_error("GBALink: CLIENT timeout waiting for host READY\n"); + close(gl.tcp_fd); + gl.tcp_fd = -1; + gl.mode = GBALINK_OFF; + gl.state = GBALINK_STATE_ERROR; + snprintf(gl.status_msg, sizeof(gl.status_msg), "Host not responding"); + return GBALINK_CONNECT_ERROR; + } + + // Restore normal timeout after successful handshake + struct timeval normal_timeout = {.tv_sec = 0, .tv_usec = GBALINK_TCP_CONFIG.recv_timeout_us}; + setsockopt(gl.tcp_fd, SOL_SOCKET, SO_RCVTIMEO, &normal_timeout, sizeof(normal_timeout)); + + // If link modes differ, return special code so UI can confirm with user + // Don't start netpacket session yet - wait for user confirmation and reload + if (needs_reload) { + return GBALINK_CONNECT_NEEDS_RELOAD; + } + + // Now both sides are ready - notify minarch to start netpacket session + GBALink_notifyConnected(0); + + return GBALINK_CONNECT_OK; +} + +void GBALink_disconnect(void) { + GBALinkMode prev_mode = gl.mode; + + // Notify minarch to stop netpacket session first + GBALink_notifyDisconnected(); + + pthread_mutex_lock(&gl.mutex); + if (gl.tcp_fd >= 0) { + send_packet(CMD_DISCONNECT, NULL, 0, 0); + // send_packet re-acquires mutex, so we still hold it here + close(gl.tcp_fd); + gl.tcp_fd = -1; + } + + // Always clear core_registered to prevent timeout checks + gl.core_registered = false; + + if (prev_mode == GBALINK_CLIENT) { + gl.mode = GBALINK_OFF; + gl.state = GBALINK_STATE_DISCONNECTED; + snprintf(gl.status_msg, sizeof(gl.status_msg), "Disconnected"); + // Clear local IP since client is disconnecting from hotspot network + // The actual WiFi disconnection happens separately, but we clear here + // so the UI shows no IP while disconnected + strncpy(gl.local_ip, "0.0.0.0", sizeof(gl.local_ip) - 1); + gl.connected_to_hotspot = false; + } else if (prev_mode == GBALINK_HOST) { + gl.state = GBALINK_STATE_WAITING; + snprintf(gl.status_msg, sizeof(gl.status_msg), "Client left, waiting on %s:%d", gl.local_ip, gl.port); + } else { + gl.mode = GBALINK_OFF; + gl.state = GBALINK_STATE_DISCONNECTED; + snprintf(gl.status_msg, sizeof(gl.status_msg), "Disconnected"); + } + + gl.pending_count = 0; + gl.stream_buf_read_idx = 0; + gl.stream_buf_write_idx = 0; + pthread_mutex_unlock(&gl.mutex); +} + +////////////////////////////////////////////////////////////////////////////// +// Discovery +////////////////////////////////////////////////////////////////////////////// + +int GBALink_startDiscovery(void) { + if (gl.discovery_active) return 0; + + gl.udp_fd = NET_createDiscoveryListenSocket(GBALINK_DISCOVERY_PORT); + if (gl.udp_fd < 0) return -1; + + gl.num_hosts = 0; + gl.discovery_active = true; + return 0; +} + +void GBALink_stopDiscovery(void) { + if (!gl.discovery_active) return; + + if (gl.udp_fd >= 0 && gl.mode == GBALINK_OFF) { + close(gl.udp_fd); + gl.udp_fd = -1; + } + + gl.discovery_active = false; +} + +int GBALink_getDiscoveredHosts(GBALinkHostInfo* hosts, int max_hosts) { + if (!gl.discovery_active || gl.udp_fd < 0) return 0; + + // Poll for discovery responses using shared function + // GBALinkHostInfo and NET_HostInfo have identical layouts + NET_receiveDiscoveryResponses(gl.udp_fd, GL_DISCOVERY_RESP, + (NET_HostInfo*)gl.discovered_hosts, &gl.num_hosts, + GBALINK_MAX_HOSTS); + + int count = (gl.num_hosts < max_hosts) ? gl.num_hosts : max_hosts; + memcpy(hosts, gl.discovered_hosts, count * sizeof(GBALinkHostInfo)); + return count; +} + +int GBALink_queryHostLinkMode(const char* host_ip, char* link_mode_out, size_t size) { + if (!host_ip || !link_mode_out || size < 2) return -1; + link_mode_out[0] = '\0'; + + // Create UDP socket for query + int query_fd = socket(AF_INET, SOCK_DGRAM, 0); + if (query_fd < 0) { + return -1; + } + + // Set send and receive timeouts to prevent blocking indefinitely + struct timeval tv = {.tv_sec = 0, .tv_usec = 500000}; // 500ms timeout + setsockopt(query_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(query_fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + + // Prepare query packet + NET_DiscoveryPacket query_pkt = {0}; + query_pkt.magic = htonl(GL_DISCOVERY_QUERY); + query_pkt.protocol_version = htonl(GBALINK_PROTOCOL_VERSION); + + // Send to host + struct sockaddr_in host_addr = {0}; + host_addr.sin_family = AF_INET; + host_addr.sin_port = htons(GBALINK_DISCOVERY_PORT); + if (inet_pton(AF_INET, host_ip, &host_addr.sin_addr) <= 0) { + close(query_fd); + return -1; // Invalid IP address + } + + // Try up to 3 times with 500ms timeout each + for (int attempt = 0; attempt < 3; attempt++) { + sendto(query_fd, &query_pkt, sizeof(query_pkt), 0, + (struct sockaddr*)&host_addr, sizeof(host_addr)); + + // Wait for response + NET_DiscoveryPacket resp_pkt; + struct sockaddr_in sender; + socklen_t sender_len = sizeof(sender); + ssize_t recv_len = recvfrom(query_fd, &resp_pkt, sizeof(resp_pkt), 0, + (struct sockaddr*)&sender, &sender_len); + + if (recv_len >= (ssize_t)sizeof(resp_pkt) && ntohl(resp_pkt.magic) == GL_DISCOVERY_RESP) { + // Got response - extract link_mode + strncpy(link_mode_out, resp_pkt.link_mode, size - 1); + link_mode_out[size - 1] = '\0'; + close(query_fd); + return 0; + } + } + + close(query_fd); + return -1; +} + +////////////////////////////////////////////////////////////////////////////// +// Netpacket Interface (called by frontend when core registers) +////////////////////////////////////////////////////////////////////////////// + +void GBALink_onNetpacketStart(uint16_t client_id, + retro_netpacket_send_t send_fn, + retro_netpacket_poll_receive_t poll_receive_fn) { + pthread_mutex_lock(&gl.mutex); + gl.core_registered = true; + gl.local_client_id = client_id; + gl.core_send_fn = send_fn; + gl.core_poll_fn = poll_receive_fn; + // Reset packet timestamps to start fresh timeout window after handshake + struct timeval now; + gettimeofday(&now, NULL); + gl.last_packet_sent = now; + gl.last_packet_received = now; + pthread_mutex_unlock(&gl.mutex); +} + +void GBALink_onNetpacketStop(void) { + pthread_mutex_lock(&gl.mutex); + gl.core_registered = false; + gl.core_send_fn = NULL; + gl.core_poll_fn = NULL; + pthread_mutex_unlock(&gl.mutex); +} + +void GBALink_onNetpacketPoll(void) { + // Called by core each frame - check for incoming packets + GBALink_pollReceive(); +} + +////////////////////////////////////////////////////////////////////////////// +// Packet Sending (called by core via netpacket send function) +////////////////////////////////////////////////////////////////////////////// + +void GBALink_sendPacket(int flags, const void* buf, size_t len, uint16_t client_id) { + if (!GBALink_isConnected()) return; + + // Handle empty flush request (flush only, no data) + // We use TCP_NODELAY so packets are already flushed immediately + if (!buf || len == 0) return; + + // Send to remote via TCP + pthread_mutex_lock(&gl.mutex); + bool sent_ok = send_packet(CMD_SIO_DATA, buf, (uint16_t)len, client_id); + if (!sent_ok) { + LOG_warn("GBALink: SIO_DATA send failed, disconnecting\n"); + pthread_mutex_unlock(&gl.mutex); + GBALink_disconnect(); + return; + } + // Update last_packet_sent to prevent unnecessary heartbeats during active communication + gettimeofday(&gl.last_packet_sent, NULL); + pthread_mutex_unlock(&gl.mutex); + + // FLUSH_HINT: Since we use TCP_NODELAY and send immediately, + // packets are already flushed. No additional action needed. + (void)flags; +} + +// Limit packets per poll to prevent frame stalls during high traffic +// 64 packets allows same-frame delivery of packet bursts during trade/battle +// This prevents Pokemon Union Room trade failures caused by buffering packets until next frame +#define MAX_PACKETS_PER_POLL 64 + +// Send heartbeat packet if idle for too long (host only) +// Critical for RFU protocol: "the host must send data (even dummy) so clients can respond" +// Without this, clients timeout fatally (unrecoverable "communication error") +// Takes cached frame time to avoid extra gettimeofday() syscalls +static void GBALink_sendHeartbeatIfNeeded(const struct timeval* now) { + // Only host sends heartbeats - clients respond to host packets + if (gl.mode != GBALINK_HOST || !GBALink_isConnected()) return; + + long elapsed_ms = (now->tv_sec - gl.last_packet_sent.tv_sec) * 1000 + + (now->tv_usec - gl.last_packet_sent.tv_usec) / 1000; + + if (elapsed_ms >= HEARTBEAT_INTERVAL_MS) { + pthread_mutex_lock(&gl.mutex); + bool sent_ok = send_packet(CMD_HEARTBEAT, NULL, 0, 0); + if (sent_ok) { + gl.last_packet_sent = *now; + } else { + // Heartbeat send failed - connection is dead + GBALink_disconnect(); + return; + } + pthread_mutex_unlock(&gl.mutex); + } +} + +void GBALink_pollReceive(void) { + if (!GBALink_isConnected()) return; + + // Cache frame time once at start - avoids multiple gettimeofday() syscalls + cache_frame_time(); + + // Send heartbeat if needed (host only, keeps clients alive) + GBALink_sendHeartbeatIfNeeded(get_frame_time()); + + pthread_mutex_lock(&gl.mutex); + + // Check for incoming packets with short timeout + PacketHeader hdr; + uint8_t data[RECV_BUFFER_SIZE]; + // RECV_BUFFER_SIZE is 2048, well within uint16_t range + uint16_t max_recv = RECV_BUFFER_SIZE; + int packets_this_poll = 0; + + while (packets_this_poll < MAX_PACKETS_PER_POLL && recv_packet(&hdr, data, max_recv, 0)) { + if (hdr.cmd == CMD_SIO_DATA) { + // Queue packet for delivery to core + // Note: hdr.size is validated by recv_packet to be <= RECV_BUFFER_SIZE + if (gl.pending_count < MAX_PENDING_PACKETS && hdr.size <= RECV_BUFFER_SIZE) { + ReceivedPacket* pkt = &gl.pending_packets[gl.pending_write_idx]; + memcpy(pkt->data, data, hdr.size); + pkt->len = hdr.size; + pkt->client_id = hdr.client_id; + gl.pending_write_idx = (gl.pending_write_idx + 1) % MAX_PENDING_PACKETS; + gl.pending_count++; + } + packets_this_poll++; + } else if (hdr.cmd == CMD_HEARTBEAT) { + // Heartbeat received - timestamp already updated in recv_packet + } else if (hdr.cmd == CMD_DISCONNECT) { + // Remote sent explicit disconnect command + GBALinkMode prev_mode = gl.mode; + close(gl.tcp_fd); + gl.tcp_fd = -1; + + if (prev_mode == GBALINK_CLIENT) { + // Client fully disconnects + gl.mode = GBALINK_OFF; + gl.state = GBALINK_STATE_DISCONNECTED; + gl.core_registered = false; // Ensure this is cleared + strncpy(gl.local_ip, "0.0.0.0", sizeof(gl.local_ip) - 1); + gl.connected_to_hotspot = false; + snprintf(gl.status_msg, sizeof(gl.status_msg), "Host disconnected"); + // Notify minarch that connection is lost + pthread_mutex_unlock(&gl.mutex); + GBALink_notifyDisconnected(); + pthread_mutex_lock(&gl.mutex); + // Verify state wasn't corrupted during callback + if (gl.mode != GBALINK_OFF || gl.state != GBALINK_STATE_DISCONNECTED) { + // Force correct state + gl.mode = GBALINK_OFF; + gl.state = GBALINK_STATE_DISCONNECTED; + } + } else if (prev_mode == GBALINK_HOST) { + // Host goes back to waiting and restarts broadcast + gl.state = GBALINK_STATE_WAITING; + gl.core_registered = false; // Core session is over + // Notify minarch before restarting broadcast + pthread_mutex_unlock(&gl.mutex); + GBALink_notifyDisconnected(); + pthread_mutex_lock(&gl.mutex); + GBALink_restartBroadcast(); + snprintf(gl.status_msg, sizeof(gl.status_msg), "Client left, waiting on %s:%d", gl.local_ip, gl.port); + } + break; + } + } + + // Check for deferred disconnect notification (set by recv_packet) + bool need_disconnect_notify = gl.pending_disconnect_notify; + gl.pending_disconnect_notify = false; + + pthread_mutex_unlock(&gl.mutex); + + // Process deferred notification after mutex release to avoid holding lock during callbacks + if (need_disconnect_notify) { + GBALink_notifyDisconnected(); + } +} + +////////////////////////////////////////////////////////////////////////////// +// Status Functions +////////////////////////////////////////////////////////////////////////////// + +GBALinkMode GBALink_getMode(void) { + if (!gl.initialized) return GBALINK_OFF; + pthread_mutex_lock(&gl.mutex); + GBALinkMode mode = gl.mode; + pthread_mutex_unlock(&gl.mutex); + return mode; +} + +GBALinkState GBALink_getState(void) { + if (!gl.initialized) return GBALINK_STATE_IDLE; + pthread_mutex_lock(&gl.mutex); + GBALinkState state = gl.state; + pthread_mutex_unlock(&gl.mutex); + return state; +} + +bool GBALink_isConnected(void) { + if (!gl.initialized) return false; + pthread_mutex_lock(&gl.mutex); + bool connected = gl.tcp_fd >= 0 && gl.state == GBALINK_STATE_CONNECTED; + pthread_mutex_unlock(&gl.mutex); + return connected; +} + +const char* GBALink_getStatusMessage(void) { return gl.status_msg; } + +void GBALink_getStatusMessageSafe(char* buf, size_t buf_size) { + if (!gl.initialized) { + strncpy(buf, "Not initialized", buf_size - 1); + buf[buf_size - 1] = '\0'; + return; + } + pthread_mutex_lock(&gl.mutex); + strncpy(buf, gl.status_msg, buf_size - 1); + buf[buf_size - 1] = '\0'; + pthread_mutex_unlock(&gl.mutex); +} + +// Thread-safe version that copies IP to caller's buffer +void GBALink_getLocalIPSafe(char* buf, size_t buf_size) { + if (!gl.initialized) { + strncpy(buf, "0.0.0.0", buf_size - 1); + buf[buf_size - 1] = '\0'; + return; + } + pthread_mutex_lock(&gl.mutex); + strncpy(buf, gl.local_ip, buf_size - 1); + buf[buf_size - 1] = '\0'; + pthread_mutex_unlock(&gl.mutex); +} + +// Note: Returns pointer to internal buffer - use GBALink_getLocalIPSafe for thread safety +const char* GBALink_getLocalIP(void) { + // Refresh IP if not in an active session (to avoid returning stale hotspot IP) + if (gl.mode == GBALINK_OFF) { + NET_getLocalIP(gl.local_ip, sizeof(gl.local_ip)); + } + return gl.local_ip; +} + +bool GBALink_isUsingHotspot(void) { + if (!gl.initialized) return false; + pthread_mutex_lock(&gl.mutex); + bool using_hotspot = gl.using_hotspot; + pthread_mutex_unlock(&gl.mutex); + return using_hotspot; +} + +bool GBALink_hasNetworkConnection(void) { + NET_getLocalIP(gl.local_ip, sizeof(gl.local_ip)); + return NET_hasConnection(); +} + +void GBALink_update(void) { + if (!gl.initialized) return; + pthread_mutex_lock(&gl.mutex); + + // Process pending host connection notification (must run on main thread) + // The listen thread sets this flag, we process it here to ensure + // core callbacks are called from the main thread + if (gl.pending_host_connected) { + LOG_info("GBALink: HOST update() processing pending_host_connected\n"); + // Memory barrier ensures we see all state from listen thread + __sync_synchronize(); + gl.pending_host_connected = false; + pthread_mutex_unlock(&gl.mutex); + GBALink_notifyConnected(1); // We are host + pthread_mutex_lock(&gl.mutex); + } + + // Check for connection errors via socket error state + // Optimization: only check every 10 frames to reduce syscall overhead + // TCP errors are still caught quickly by recv/send operations + if (gl.tcp_fd >= 0 && ++gl.error_check_counter >= 10) { + gl.error_check_counter = 0; + int fd = gl.tcp_fd; // Cache fd under mutex + pthread_mutex_unlock(&gl.mutex); + + int error = 0; + socklen_t len = sizeof(error); + if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &len) < 0 || error != 0) { + GBALink_disconnect(); + return; + } + pthread_mutex_lock(&gl.mutex); + } + + // Check for connection timeout - disconnect if no packets for too long + // This detects dead connections that TCP keepalive may miss + // Only check AFTER handshake is complete (core_registered is set by GBALink_notifyConnected) + if (gl.tcp_fd >= 0 && gl.state == GBALINK_STATE_CONNECTED && gl.core_registered) { + struct timeval last_recv = gl.last_packet_received; + pthread_mutex_unlock(&gl.mutex); + + // Use cached frame time if available, otherwise get fresh time + const struct timeval* now = get_frame_time(); + + long elapsed_ms = (now->tv_sec - last_recv.tv_sec) * 1000 + + (now->tv_usec - last_recv.tv_usec) / 1000; + + if (elapsed_ms > GBALINK_CONNECTION_TIMEOUT_MS) { + GBALink_disconnect(); + return; + } + } else { + pthread_mutex_unlock(&gl.mutex); + } +} + +bool GBALink_getPendingPacket(void** buf, size_t* len, uint16_t* client_id) { + pthread_mutex_lock(&gl.mutex); + if (gl.pending_count == 0) { + pthread_mutex_unlock(&gl.mutex); + return false; + } + ReceivedPacket* pkt = &gl.pending_packets[gl.pending_read_idx]; + *buf = pkt->data; + *len = pkt->len; + if (client_id) *client_id = pkt->client_id; + pthread_mutex_unlock(&gl.mutex); + return true; +} + +void GBALink_consumePendingPacket(void) { + pthread_mutex_lock(&gl.mutex); + if (gl.pending_count > 0) { + gl.pending_read_idx = (gl.pending_read_idx + 1) % MAX_PENDING_PACKETS; + gl.pending_count--; + } + pthread_mutex_unlock(&gl.mutex); +} + +// Atomic get-and-consume: reduces mutex cycles in hot path (single lock instead of two) +bool GBALink_popPendingPacket(void** buf, size_t* len, uint16_t* client_id) { + pthread_mutex_lock(&gl.mutex); + if (gl.pending_count == 0) { + pthread_mutex_unlock(&gl.mutex); + return false; + } + ReceivedPacket* pkt = &gl.pending_packets[gl.pending_read_idx]; + *buf = pkt->data; + *len = pkt->len; + if (client_id) *client_id = pkt->client_id; + // Consume immediately + gl.pending_read_idx = (gl.pending_read_idx + 1) % MAX_PENDING_PACKETS; + gl.pending_count--; + pthread_mutex_unlock(&gl.mutex); + return true; +} + +////////////////////////////////////////////////////////////////////////////// +// Network Helper Functions +////////////////////////////////////////////////////////////////////////////// + +// Forward declaration for drain function +static void drain_receive_buffer(void); + +// Send all bytes with retry logic for reliability +// Retries up to 500ms total to handle WiFi latency and buffer pressure +// Critical: RFU protocol breaks if packets are dropped - must deliver all packets +// Returns false only on real errors or extended blocking +// NOTE: This function does NOT hold the mutex - caller must NOT hold mutex either +static bool send_all(int fd, const void* buf, size_t len) { + const uint8_t* p = buf; + int total_wait_us = 0; + const int max_wait_us = 2000000; // 2 seconds - needs to be long enough to survive TCP deadlocks + + while (len > 0) { + ssize_t sent = send(fd, p, len, MSG_NOSIGNAL | MSG_DONTWAIT); + if (sent > 0) { + p += sent; + len -= sent; + total_wait_us = 0; // Reset wait time on successful send + } else if (sent < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + // Socket buffer full - wait briefly and retry + if (total_wait_us >= max_wait_us) { + return false; + } + + // CRITICAL: While waiting for send buffer to clear, drain our receive buffer + // This prevents deadlock where both sides are waiting to send but neither is receiving + // Safe to call without mutex since socket ops are thread-safe + drain_receive_buffer(); + + usleep(1000); // 1ms + total_wait_us += 1000; + } else { + // Real error (connection closed, broken pipe, etc.) + return false; + } + } else { + // sent == 0 should not happen with TCP + return false; + } + } + return true; +} + +// Drain receive buffer without holding mutex - used during send retries to prevent deadlock +// This reads from TCP socket to clear the kernel's receive buffer, allowing remote to send more +static void drain_receive_buffer(void) { + int fd; + pthread_mutex_lock(&gl.mutex); + fd = gl.tcp_fd; + + // Compact buffer if needed to make space for draining (uses optimized helper) + compact_stream_buffer_if_needed(1024); + size_t space = sizeof(gl.stream_buf) - gl.stream_buf_write_idx; + pthread_mutex_unlock(&gl.mutex); + + if (fd < 0 || space == 0) return; + + // Check if data is available + fd_set fds; + FD_ZERO(&fds); + FD_SET(fd, &fds); + struct timeval tv = {0, 0}; // Non-blocking + + if (select(fd + 1, &fds, NULL, NULL, &tv) > 0) { + // Read data into stream buffer (under mutex) + pthread_mutex_lock(&gl.mutex); + if (gl.tcp_fd >= 0) { + space = sizeof(gl.stream_buf) - gl.stream_buf_write_idx; + if (space > 0) { + ssize_t ret = recv(gl.tcp_fd, gl.stream_buf + gl.stream_buf_write_idx, space, MSG_DONTWAIT); + if (ret > 0) { + gl.stream_buf_write_idx += ret; + } + } + } + pthread_mutex_unlock(&gl.mutex); + } +} + +// Send packet - NOTE: caller must hold mutex for shared state, but we release during I/O +static bool send_packet(uint8_t cmd, const void* data, uint16_t size, uint16_t client_id) { + if (gl.tcp_fd < 0) return false; + + // Get fd before releasing mutex + int fd = gl.tcp_fd; + + PacketHeader hdr = { + .cmd = cmd, + .size = htons(size), + .client_id = htons(client_id) + }; + + // Release mutex during actual I/O to allow receive processing + pthread_mutex_unlock(&gl.mutex); + + bool ok = send_all(fd, &hdr, sizeof(hdr)); + if (ok && size > 0 && data) { + ok = send_all(fd, data, size); + } + + // Re-acquire mutex before returning + pthread_mutex_lock(&gl.mutex); + + // Validate fd is still valid (another thread could have disconnected) + if (gl.tcp_fd < 0 || gl.tcp_fd != fd) { + return false; + } + + if (!ok) { + return false; + } + + return true; +} + +static bool recv_packet(PacketHeader* hdr, void* data, uint16_t max_size, int timeout_ms) { + if (gl.tcp_fd < 0) return false; + + // Calculate available data in buffer + size_t available = gl.stream_buf_write_idx - gl.stream_buf_read_idx; + + // Try to read more data into our stream buffer (non-blocking) + fd_set fds; + FD_ZERO(&fds); + FD_SET(gl.tcp_fd, &fds); + + struct timeval tv = { + .tv_sec = timeout_ms / 1000, + .tv_usec = (timeout_ms % 1000) * 1000 + }; + + // Only try to recv if there's data available (non-blocking check) + if (select(gl.tcp_fd + 1, &fds, NULL, NULL, &tv) > 0) { + // Compact buffer if needed (optimized: only when read_idx past halfway) + compact_stream_buffer_if_needed(1024); + size_t space_at_end = sizeof(gl.stream_buf) - gl.stream_buf_write_idx; + + if (space_at_end > 0) { + ssize_t ret = recv(gl.tcp_fd, gl.stream_buf + gl.stream_buf_write_idx, space_at_end, MSG_DONTWAIT); + if (ret == 0) { + // Connection closed by remote + GBALinkMode prev_mode = gl.mode; + close(gl.tcp_fd); + gl.tcp_fd = -1; + gl.core_registered = false; // Prevent timeout check from firing + + if (prev_mode == GBALINK_CLIENT) { + // Client fully disconnects + gl.mode = GBALINK_OFF; + gl.state = GBALINK_STATE_DISCONNECTED; + strncpy(gl.local_ip, "0.0.0.0", sizeof(gl.local_ip) - 1); + gl.connected_to_hotspot = false; + snprintf(gl.status_msg, sizeof(gl.status_msg), "Host disconnected"); + gl.pending_disconnect_notify = true; // Defer notification until mutex released + } else if (prev_mode == GBALINK_HOST) { + // Host goes back to waiting and restarts broadcast + gl.state = GBALINK_STATE_WAITING; + snprintf(gl.status_msg, sizeof(gl.status_msg), "Client left, waiting on %s:%d", gl.local_ip, gl.port); + gl.pending_disconnect_notify = true; // Defer notification until mutex released + GBALink_restartBroadcast(); + } + return false; + } + if (ret < 0) { + if (errno == ECONNRESET || errno == EPIPE || errno == ENOTCONN) { + GBALinkMode prev_mode = gl.mode; + close(gl.tcp_fd); + gl.tcp_fd = -1; + gl.core_registered = false; // Prevent timeout check from firing + + if (prev_mode == GBALINK_CLIENT) { + // Client fully disconnects + gl.mode = GBALINK_OFF; + gl.state = GBALINK_STATE_DISCONNECTED; + strncpy(gl.local_ip, "0.0.0.0", sizeof(gl.local_ip) - 1); + gl.connected_to_hotspot = false; + snprintf(gl.status_msg, sizeof(gl.status_msg), "Connection lost"); + gl.pending_disconnect_notify = true; // Defer notification until mutex released + } else if (prev_mode == GBALINK_HOST) { + // Host goes back to waiting and restarts broadcast + gl.state = GBALINK_STATE_WAITING; + snprintf(gl.status_msg, sizeof(gl.status_msg), "Client left, waiting on %s:%d", gl.local_ip, gl.port); + gl.pending_disconnect_notify = true; // Defer notification until mutex released + GBALink_restartBroadcast(); + } + return false; + } + // EAGAIN/EWOULDBLOCK is ok, just no data right now + } else { + gl.stream_buf_write_idx += ret; + available += ret; + } + } + } + + // Check if we have a complete header + if (available < sizeof(PacketHeader)) { + return false; // Need more data + } + + // Parse header from buffer + PacketHeader* buf_hdr = (PacketHeader*)(gl.stream_buf + gl.stream_buf_read_idx); + hdr->cmd = buf_hdr->cmd; + hdr->size = ntohs(buf_hdr->size); + hdr->client_id = ntohs(buf_hdr->client_id); + + // Validate size - explicit bounds check for safety + // Check both against max_size (caller's buffer) and RECV_BUFFER_SIZE (our buffer) + if (hdr->size > max_size || hdr->size > RECV_BUFFER_SIZE) { + // Invalid packet size - protocol error, reset buffer + gl.stream_buf_read_idx = 0; + gl.stream_buf_write_idx = 0; + return false; + } + + // Check if we have complete packet (header + payload) + size_t total_size = sizeof(PacketHeader) + hdr->size; + if (available < total_size) { + return false; // Need more data + } + + // Copy payload to output (bounds already validated above) + if (hdr->size > 0 && data) { + memcpy(data, gl.stream_buf + gl.stream_buf_read_idx + sizeof(PacketHeader), hdr->size); + } + + // Advance read index instead of memmove - O(1) instead of O(n) + gl.stream_buf_read_idx += total_size; + + // If buffer is now empty, reset indices to avoid accumulating offset + if (gl.stream_buf_read_idx == gl.stream_buf_write_idx) { + gl.stream_buf_read_idx = 0; + gl.stream_buf_write_idx = 0; + } + + // Update last packet received timestamp - use cached time if available + const struct timeval* now = get_frame_time(); + gl.last_packet_received = *now; + + return true; +} + +////////////////////////////////////////////////////////////////////////////// +// Core Netpacket Bridging +////////////////////////////////////////////////////////////////////////////// + +// Maximum packets to deliver per frame - matches minarch's constant +#define GBALINK_MAX_PACKETS_PER_FRAME 64 + +// Set core netpacket callbacks (called by minarch when core registers) +void GBALink_setCoreCallbacks(const struct retro_netpacket_callback* callbacks) { + if (callbacks) { + gl.core_callbacks = *callbacks; + gl.has_core_callbacks = true; + gl.has_netpacket_support = true; + LOG_info("GBALink: Core registered netpacket callbacks (start=%p receive=%p)\n", + (void*)callbacks->start, (void*)callbacks->receive); + } else { + memset(&gl.core_callbacks, 0, sizeof(gl.core_callbacks)); + gl.has_core_callbacks = false; + gl.has_netpacket_support = false; + LOG_info("GBALink: Core unregistered netpacket callbacks\n"); + } +} + +// Send function provided to core - bridges to gbalink network +static void gbalink_netpacket_send(int flags, const void* buf, size_t len, uint16_t client_id) { + if (gl.netpacket_active) { + GBALink_sendPacket(flags, buf, len, client_id); + } +} + +// Poll receive function provided to core +static void gbalink_netpacket_poll_receive(void) { + if (!gl.netpacket_active) return; + GBALink_pollReceive(); +} + +// Start netpacket session - called when gbalink connects +void GBALink_notifyConnected(int is_host) { + if (!gl.has_core_callbacks || gl.netpacket_active) { + return; + } + + // Call core's start callback with our bridge functions + if (gl.core_callbacks.start) { + uint16_t client_id = is_host ? 0 : 1; // 0 = host, 1 = client + gl.local_client_id = client_id; + gl.remote_client_id = is_host ? 1 : 0; + gl.core_callbacks.start(client_id, gbalink_netpacket_send, gbalink_netpacket_poll_receive); + gl.netpacket_active = true; + + // Register for timeout detection + GBALink_onNetpacketStart(client_id, NULL, NULL); + } + + // Notify core that remote player connected + if (gl.core_callbacks.connected) { + gl.core_callbacks.connected(gl.remote_client_id); + } +} + +// Stop netpacket session - called when gbalink disconnects +void GBALink_notifyDisconnected(void) { + if (!gl.netpacket_active) return; + + // Notify core that remote player disconnected + if (gl.core_callbacks.disconnected) { + gl.core_callbacks.disconnected(gl.remote_client_id); + } + + // Call core's stop callback + if (gl.core_callbacks.stop) { + gl.core_callbacks.stop(); + } + + // Unregister from timeout tracking + GBALink_onNetpacketStop(); + + gl.netpacket_active = false; +} + +// Check if netpacket bridging is active +bool GBALink_isNetpacketActive(void) { + return gl.netpacket_active; +} + +// Poll network and deliver packets to core (call each frame before core.run()) +void GBALink_pollAndDeliverPackets(void) { + if (!gl.netpacket_active) return; + + // Poll for incoming TCP data + GBALink_pollReceive(); + + // Deliver pending packets to core + // Use atomic pop to reduce mutex cycles (single lock instead of get+consume) + void* pkt_buf; + size_t pkt_len; + + int packets_delivered = 0; + while (packets_delivered < GBALINK_MAX_PACKETS_PER_FRAME && + GBALink_popPendingPacket(&pkt_buf, &pkt_len, NULL)) { + // In direct 2-player TCP, any received packet is from the remote peer + if (gl.core_callbacks.receive) { + gl.core_callbacks.receive(pkt_buf, pkt_len, gl.remote_client_id); + } + packets_delivered++; + } +} diff --git a/workspace/all/netplay/gbalink.h b/workspace/all/netplay/gbalink.h new file mode 100644 index 000000000..378749e3f --- /dev/null +++ b/workspace/all/netplay/gbalink.h @@ -0,0 +1,139 @@ +/* + * NextUI GBA Link Module + * Implements GBA Wireless Adapter (RFU) emulation over WiFi using libretro netpacket interface + * + * This module bridges gpSP's built-in RFU emulation with network transport, + * enabling Pokemon trading, battles (Union Room), and other wireless features. + * + * This is separate from netplay (input sync) - it provides wireless adapter + * communication for GBA games via gpSP's complete RFU implementation. + */ + +#ifndef GBALINK_H +#define GBALINK_H + +#include +#include +#include +#include "libretro-common/include/libretro.h" + +#define GBALINK_DEFAULT_PORT 55437 +#define GBALINK_DISCOVERY_PORT 55438 +#define GBALINK_MAGIC "GBLK" +#define GBALINK_PROTOCOL_VERSION 1 +#define GBALINK_MAX_GAME_NAME 64 +#define GBALINK_MAX_HOSTS 8 + +typedef enum { + GBALINK_OFF = 0, + GBALINK_HOST, + GBALINK_CLIENT +} GBALinkMode; + +typedef enum { + GBALINK_CONN_WIFI = 0, // Use existing WiFi network + GBALINK_CONN_HOTSPOT // Create/connect to hotspot +} GBALinkConnMethod; + +typedef enum { + GBALINK_STATE_IDLE = 0, + GBALINK_STATE_WAITING, // Host waiting for client + GBALINK_STATE_CONNECTING, // Client connecting to host + GBALINK_STATE_CONNECTED, // Connected and ready for SIO packets + GBALINK_STATE_DISCONNECTED, + GBALINK_STATE_ERROR +} GBALinkState; + +// Return codes for GBALink_connectToHost +#define GBALINK_CONNECT_OK 0 // Connected successfully +#define GBALINK_CONNECT_ERROR -1 // Connection failed +#define GBALINK_CONNECT_NEEDS_RELOAD 1 // Connected but link mode differs, needs game reload + +typedef struct { + char game_name[GBALINK_MAX_GAME_NAME]; + char host_ip[16]; + uint16_t port; + uint32_t game_crc; + char link_mode[32]; // Host's link mode for compatibility check (e.g., "mul_poke", "rfu") +} GBALinkHostInfo; + +// Initialize/cleanup +void GBALink_init(void); +void GBALink_quit(void); + +// Check if a core supports GBA Link (RFU/Wireless Adapter) +// core_name is derived from the .so filename (e.g., "gpsp" from "gpsp_libretro.so") +// Returns true if supported (gpsp), also sets internal support flag +bool GBALink_checkCoreSupport(const char* core_name); + +// Link mode synchronization - host captures mode, client receives and applies it +// Called before hosting to capture the current gpsp_serial value +void GBALink_setLinkMode(const char* mode); +const char* GBALink_getLinkMode(void); + +// Pending link mode after GBALINK_CONNECT_NEEDS_RELOAD +// Client's current mode and host's mode that differs +const char* GBALink_getPendingLinkMode(void); // Returns host's mode (what to change to) +const char* GBALink_getClientLinkMode(void); // Returns client's current mode +void GBALink_clearPendingReload(void); // Clear pending reload state +void GBALink_applyPendingLinkMode(void); // Apply pending mode to config + +// Connection management +// If hotspot_ip is NULL, uses WiFi mode. Otherwise, uses hotspot mode with given IP. +// link_mode is the gpsp_serial value to sync with client (can be NULL) +int GBALink_startHost(const char* game_name, uint32_t game_crc, const char* hotspot_ip, const char* link_mode); +int GBALink_stopHost(void); +int GBALink_stopHostFast(void); +int GBALink_connectToHost(const char* ip, uint16_t port); +void GBALink_disconnect(void); + +// Hotspot mode +bool GBALink_isUsingHotspot(void); + +// Status queries +GBALinkMode GBALink_getMode(void); +GBALinkState GBALink_getState(void); +bool GBALink_isConnected(void); +const char* GBALink_getStatusMessage(void); +void GBALink_getStatusMessageSafe(char* buf, size_t buf_size); +const char* GBALink_getLocalIP(void); +bool GBALink_hasNetworkConnection(void); + +// Host discovery (for client) +int GBALink_startDiscovery(void); +void GBALink_stopDiscovery(void); +int GBALink_getDiscoveredHosts(GBALinkHostInfo* hosts, int max_hosts); + +// Direct link mode query (for hotspot mode where broadcasts may not work) +// Sends UDP query directly to host_ip and waits for response +// Returns 0 on success, -1 on failure/timeout +int GBALink_queryHostLinkMode(const char* host_ip, char* link_mode_out, size_t size); + +// Netpacket interface callbacks for core +// These are called when the core registers its netpacket interface +void GBALink_onNetpacketStart(uint16_t client_id, + retro_netpacket_send_t send_fn, + retro_netpacket_poll_receive_t poll_receive_fn); +void GBALink_onNetpacketStop(void); +void GBALink_onNetpacketPoll(void); + +// Called by frontend to provide send/poll functions to core +// These wrap the network transport layer +void GBALink_sendPacket(int flags, const void* buf, size_t len, uint16_t client_id); +void GBALink_pollReceive(void); + +// Update function (call periodically for connection handling) +void GBALink_update(void); + +// Set core netpacket callbacks (called by minarch when core registers RETRO_ENVIRONMENT_SET_NETPACKET_INTERFACE) +void GBALink_setCoreCallbacks(const struct retro_netpacket_callback* callbacks); + +// Connection state change notifications (called internally by gbalink) +void GBALink_notifyConnected(int is_host); +void GBALink_notifyDisconnected(void); + +// Netpacket bridging +bool GBALink_isNetpacketActive(void); +void GBALink_pollAndDeliverPackets(void); // Call each frame before core.run() + +#endif /* GBALINK_H */ diff --git a/workspace/all/netplay/gblink.c b/workspace/all/netplay/gblink.c new file mode 100644 index 000000000..3874239af --- /dev/null +++ b/workspace/all/netplay/gblink.c @@ -0,0 +1,637 @@ +/* + * NextUI GB Link Module + * Implements GB/GBC Link Cable emulation over WiFi via gambatte core options + * + * This module manages gambatte's built-in network serial (HAVE_NETWORK=1) + * by setting core options programmatically. Unlike GBA Link which handles + * TCP directly, gambatte manages its own TCP connection - we just configure + * the core options and provide UDP discovery. + * + * Supported features: + * - Pokemon trading (Red/Blue/Yellow/Gold/Silver/Crystal) + * - Tetris 2-player + * - Other link cable games + */ + +#define _GNU_SOURCE // For strcasestr + +#include "gblink.h" +#include "netplay_helper.h" +#include "network_common.h" +#include "defines.h" +#include "api.h" +#ifdef HAS_WIFIMG +#include "wifi_direct.h" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Protocol constants for UDP discovery +#define GL_DISCOVERY_MAGIC 0x47424C43 // "GBLC" +#define GL_DISCOVERY_RESP 0x47424C52 // "GBLR" - GB Link Discovery Response + +// Discovery broadcast interval +#define DISCOVERY_BROADCAST_INTERVAL_US 500000 // 500ms + +// Main GB Link state +static struct { + GBLinkMode mode; + GBLinkState state; + + // UDP sockets (separate to avoid race conditions) + int udp_fd; // UDP socket for discovery broadcasts + int discovery_fd; // For client discovery + + // Connection info + char local_ip[16]; + char remote_ip[16]; + uint16_t port; + + // Hotspot mode + bool using_hotspot; + + // Game info + char game_name[GBLINK_MAX_GAME_NAME]; + uint32_t game_crc; + + // Discovery + GBLinkHostInfo discovered_hosts[GBLINK_MAX_HOSTS]; + int num_hosts; + bool discovery_active; + + // Host broadcast thread + pthread_t broadcast_thread; + volatile bool broadcast_thread_active; // Track if thread was created (portable) + pthread_mutex_t mutex; + volatile bool running; + + // Status message + char status_msg[128]; + + // Core support flag (true when gambatte is loaded) + bool has_gambatte_support; + + // Initialization flag (to prevent use-after-quit crashes) + bool initialized; + + // Quitting flag (to skip core option setting during quit) + bool quitting; +} gl = {0}; + +// Forward declarations +static void* broadcast_thread_func(void* arg); +static void GBLink_disconnect(void); + +////////////////////////////////////////////////////////////////////////////// +// Initialization +////////////////////////////////////////////////////////////////////////////// + +void GBLink_init(void) { + if (gl.initialized) { + return; // Already initialized + } + + memset(&gl, 0, sizeof(gl)); + gl.mode = GBLINK_OFF; + gl.state = GBLINK_STATE_IDLE; + gl.udp_fd = -1; + gl.discovery_fd = -1; + gl.port = GBLINK_DEFAULT_PORT; + + // Use recursive mutex to prevent deadlock if functions re-acquire lock + pthread_mutexattr_t attr; + pthread_mutexattr_init(&attr); + pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE); + pthread_mutex_init(&gl.mutex, &attr); + pthread_mutexattr_destroy(&attr); + + NET_getLocalIP(gl.local_ip, sizeof(gl.local_ip)); + snprintf(gl.status_msg, sizeof(gl.status_msg), "GB Link ready"); + + gl.initialized = true; +} + +void GBLink_quit(void) { + if (!gl.initialized) { + return; // Already quit or never initialized + } + + // Capture hotspot state before cleanup + bool was_host = (gl.mode == GBLINK_HOST); + bool needs_hotspot_cleanup = gl.using_hotspot || gblink_connected_to_hotspot; + + // Set quitting flag to prevent core option changes during shutdown + // (the core may already be in an invalid state) + gl.quitting = true; + + // Stop all link activity using fast version + GBLink_stopAllFast(); + GBLink_stopDiscovery(); + + // Handle hotspot cleanup asynchronously + if (needs_hotspot_cleanup) { + stopHotspotAndRestoreWiFiAsync(was_host); + gblink_connected_to_hotspot = 0; + } + + gl.initialized = false; // Mark as quit BEFORE destroying mutex + pthread_mutex_destroy(&gl.mutex); +} + +bool GBLink_checkCoreSupport(const char* core_name) { + // Gambatte supports GB Link via core options (HAVE_NETWORK=1) + // core_name is derived from the .so filename (e.g., "gambatte" from "gambatte_libretro.so") + bool supported = strcasecmp(core_name, "gambatte") == 0; + gl.has_gambatte_support = supported; + return supported; +} + +////////////////////////////////////////////////////////////////////////////// +// Core Option Management +////////////////////////////////////////////////////////////////////////////// + +// Set the port option for gambatte +static void GBLink_setCorePort(uint16_t port) { + char port_str[8]; + snprintf(port_str, sizeof(port_str), "%d", port); + minarch_setCoreOptionValue("gambatte_gb_link_network_port", port_str); +} + +// Set gambatte_gb_link_mode to "Network Server" +void GBLink_setCoreOptionsForHost(void) { + minarch_beginOptionsBatch(); + GBLink_setCorePort(gl.port); + minarch_setCoreOptionValue("gambatte_gb_link_mode", "Network Server"); + minarch_endOptionsBatch(); + // Force gambatte to process options and start TCP server immediately + minarch_forceCoreOptionUpdate(); +} + +// Set gambatte_gb_link_mode to "Network Client" and configure IP digit options +void GBLink_setCoreOptionsForClient(const char* ip) { + minarch_beginOptionsBatch(); + + GBLink_setCorePort(gl.port); + minarch_setCoreOptionValue("gambatte_gb_link_mode", "Network Client"); + + // Convert IP address to 12 digits for gambatte's options + // IP like "192.168.1.100" becomes digits: 1,9,2,1,6,8,0,0,1,1,0,0 + // Gambatte expects the IP formatted as 12 individual digit options + char digits[13] = {0}; // 12 digits + null + int d = 0; + + // Extract digits from IP (skip dots, pad each octet to 3 digits) + char ip_copy[16]; + strncpy(ip_copy, ip, sizeof(ip_copy) - 1); + ip_copy[sizeof(ip_copy) - 1] = '\0'; + + char* token = strtok(ip_copy, "."); + while (token && d < 12) { + int octet = atoi(token); + // Validate octet range + if (octet < 0 || octet > 255) { + LOG_warn("GBLink: Invalid IP octet: %d\n", octet); + minarch_endOptionsBatch(); // End batch even on error + return; + } + // Format as 3 digits with leading zeros + digits[d++] = '0' + (octet / 100); + digits[d++] = '0' + ((octet / 10) % 10); + digits[d++] = '0' + (octet % 10); + token = strtok(NULL, "."); + } + + // Pad remaining with zeros if needed + while (d < 12) { + digits[d++] = '0'; + } + + // Set each IP digit option + for (int i = 0; i < 12; i++) { + char key[64]; + char val[2] = {digits[i], '\0'}; + snprintf(key, sizeof(key), "gambatte_gb_link_network_server_ip_%d", i + 1); + minarch_setCoreOptionValue(key, val); + } + + minarch_endOptionsBatch(); + minarch_forceCoreOptionUpdate(); +} + +// Set gambatte_gb_link_mode to "Not Connected" and reset IP digits +void GBLink_setCoreOptionsDisconnect(void) { + // Skip if we're quitting - the core may be in an invalid state + // and setting options could cause a segfault + if (gl.quitting) { + return; + } + + minarch_beginOptionsBatch(); + + minarch_setCoreOptionValue("gambatte_gb_link_mode", "Not Connected"); + + // Reset IP digit options to default (0) + for (int i = 0; i < 12; i++) { + char key[64]; + snprintf(key, sizeof(key), "gambatte_gb_link_network_server_ip_%d", i + 1); + minarch_setCoreOptionValue(key, "0"); + } + + minarch_endOptionsBatch(); +} + +////////////////////////////////////////////////////////////////////////////// +// Host Mode +////////////////////////////////////////////////////////////////////////////// + +int GBLink_startHost(const char* game_name, uint32_t game_crc, const char* hotspot_ip) { + GBLink_init(); // Lazy init + if (gl.mode != GBLINK_OFF) { + return -1; + } + + // Set up IP based on mode + if (hotspot_ip) { + gl.using_hotspot = true; + strncpy(gl.local_ip, hotspot_ip, sizeof(gl.local_ip) - 1); + gl.local_ip[sizeof(gl.local_ip) - 1] = '\0'; + } else { + // WiFi mode - refresh local IP in case it changed since init + NET_getLocalIP(gl.local_ip, sizeof(gl.local_ip)); + } + + // Create UDP socket for discovery broadcasts + gl.udp_fd = NET_createBroadcastSocket(); + if (gl.udp_fd < 0) { + if (hotspot_ip) { + gl.using_hotspot = false; + } + snprintf(gl.status_msg, sizeof(gl.status_msg), "Failed to create broadcast socket"); + return -1; + } + + strncpy(gl.game_name, game_name, GBLINK_MAX_GAME_NAME - 1); + gl.game_crc = game_crc; + + // Start broadcast thread for discovery + gl.running = true; + pthread_create(&gl.broadcast_thread, NULL, broadcast_thread_func, NULL); + gl.broadcast_thread_active = true; + + gl.mode = GBLINK_HOST; + gl.state = GBLINK_STATE_WAITING; + + // Set gambatte core options to start TCP server + GBLink_setCoreOptionsForHost(); + + snprintf(gl.status_msg, sizeof(gl.status_msg), "Hosting on %s:%d", gl.local_ip, gl.port); + return 0; +} + +// Internal helper - stops host with optional hotspot cleanup +static int GBLink_stopHostInternal(bool skip_hotspot_cleanup) { + if (gl.mode != GBLINK_HOST) return -1; + + // Stop broadcast thread and close UDP socket + GBLink_stopBroadcast(); + + // Stop hotspot if it was started + if (gl.using_hotspot) { + if (!skip_hotspot_cleanup) { +#ifdef HAS_WIFIMG + WIFI_direct_stopHotspot(); + WIFI_direct_restorePreviousConnection(); +#endif + } + gl.using_hotspot = false; + } + + // Reset core options and state + GBLink_disconnect(); + return 0; +} + +int GBLink_stopHost(void) { + return GBLink_stopHostInternal(false); +} + +int GBLink_stopHostFast(void) { + return GBLink_stopHostInternal(true); +} + +void GBLink_stopBroadcast(void) { + // Stop broadcast thread (but keep host session active) + gl.running = false; + if (gl.broadcast_thread_active) { + pthread_join(gl.broadcast_thread, NULL); + gl.broadcast_thread_active = false; + } + + // Close UDP socket - no longer needed after connection + if (gl.udp_fd >= 0) { + close(gl.udp_fd); + gl.udp_fd = -1; + } +} + +// Restart UDP broadcast when going back to waiting state +// Called when client disconnects but host wants to accept new clients +static void GBLink_restartBroadcast(void) { + if (gl.broadcast_thread_active) return; // Already running + if (gl.mode != GBLINK_HOST) return; // Only for host + + // Create UDP socket for discovery broadcasts + gl.udp_fd = NET_createBroadcastSocket(); + if (gl.udp_fd < 0) { + snprintf(gl.status_msg, sizeof(gl.status_msg), "Failed to restart broadcast"); + return; + } + + // Start broadcast thread + gl.running = true; + pthread_create(&gl.broadcast_thread, NULL, broadcast_thread_func, NULL); + gl.broadcast_thread_active = true; +} + +// Broadcast thread - sends discovery packets for clients to find +static void* broadcast_thread_func(void* arg) { + (void)arg; + + NET_BroadcastTimer broadcast_timer; + NET_initBroadcastTimer(&broadcast_timer, DISCOVERY_BROADCAST_INTERVAL_US); + + while (gl.running && gl.udp_fd >= 0) { + if (gl.state == GBLINK_STATE_WAITING || gl.state == GBLINK_STATE_CONNECTED) { + if (NET_shouldBroadcast(&broadcast_timer)) { + NET_sendDiscoveryBroadcast(gl.udp_fd, GL_DISCOVERY_RESP, GBLINK_PROTOCOL_VERSION, + gl.game_crc, gl.port, GBLINK_DISCOVERY_PORT, + gl.game_name, NULL); // GBLink doesn't use link_mode + } + } + usleep(100000); // 100ms sleep + } + + return NULL; +} + +////////////////////////////////////////////////////////////////////////////// +// Client Mode +////////////////////////////////////////////////////////////////////////////// + +int GBLink_connectToHost(const char* ip, uint16_t port) { + GBLink_init(); // Lazy init + if (gl.mode != GBLINK_OFF) { + return -1; + } + + // Refresh local IP - important when client just connected to hotspot WiFi + NET_getLocalIP(gl.local_ip, sizeof(gl.local_ip)); + + strncpy(gl.remote_ip, ip, sizeof(gl.remote_ip) - 1); + gl.port = port; + + // Set mode BEFORE setCoreOptionsForClient so log messages during + // minarch_forceCoreOptionUpdate() are processed correctly + gl.mode = GBLINK_CLIENT; + gl.state = GBLINK_STATE_CONNECTING; + + // Set gambatte core options for client mode (this calls minarch_forceCoreOptionUpdate) + GBLink_setCoreOptionsForClient(ip); + + // If connection succeeded during the core.run(), state will be CONNECTED + // Otherwise, it stays CONNECTING and we assume gambatte will connect on resume + if (gl.state != GBLINK_STATE_CONNECTED) { + gl.state = GBLINK_STATE_CONNECTED; // Assume success - gambatte handles TCP + } + + snprintf(gl.status_msg, sizeof(gl.status_msg), "Connected to %s", ip); + return 0; +} + +int GBLink_stopClient(void) { + if (gl.mode != GBLINK_CLIENT) return -1; + + GBLink_disconnect(); + return 0; +} + +////////////////////////////////////////////////////////////////////////////// +// Internal disconnect (resets core options and state) +////////////////////////////////////////////////////////////////////////////// + +static void GBLink_disconnect(void) { + if (!gl.initialized) { + return; // Already quit - mutex is destroyed + } + + pthread_mutex_lock(&gl.mutex); + + // Reset core options and force gambatte to process them + GBLink_setCoreOptionsDisconnect(); + if (!gl.quitting) { + minarch_forceCoreOptionUpdate(); + } + + // Reset state + gl.mode = GBLINK_OFF; + gl.state = GBLINK_STATE_DISCONNECTED; + strncpy(gl.local_ip, "0.0.0.0", sizeof(gl.local_ip) - 1); + gl.local_ip[sizeof(gl.local_ip) - 1] = '\0'; + snprintf(gl.status_msg, sizeof(gl.status_msg), "Disconnected"); + + pthread_mutex_unlock(&gl.mutex); +} + +void GBLink_stopAll(void) { + if (gl.mode == GBLINK_OFF) return; + + if (gl.mode == GBLINK_HOST) { + GBLink_stopHost(); + } else if (gl.mode == GBLINK_CLIENT) { + GBLink_stopClient(); + } +} + +void GBLink_stopAllFast(void) { + if (gl.mode == GBLINK_OFF) return; + + if (gl.mode == GBLINK_HOST) { + GBLink_stopHostFast(); + } else if (gl.mode == GBLINK_CLIENT) { + GBLink_stopClient(); + } +} + +////////////////////////////////////////////////////////////////////////////// +// Discovery (for clients) +////////////////////////////////////////////////////////////////////////////// + +int GBLink_startDiscovery(void) { + if (gl.discovery_active) return 0; + + gl.discovery_fd = NET_createDiscoveryListenSocket(GBLINK_DISCOVERY_PORT); + if (gl.discovery_fd < 0) return -1; + + gl.num_hosts = 0; + gl.discovery_active = true; + return 0; +} + +void GBLink_stopDiscovery(void) { + if (!gl.discovery_active) return; + + if (gl.discovery_fd >= 0) { + close(gl.discovery_fd); + gl.discovery_fd = -1; + } + + gl.discovery_active = false; +} + +int GBLink_getDiscoveredHosts(GBLinkHostInfo* hosts, int max_hosts) { + if (!gl.discovery_active || gl.discovery_fd < 0) return 0; + + // Poll for discovery responses using shared function + // GBLinkHostInfo and NET_HostInfo have identical layouts + NET_receiveDiscoveryResponses(gl.discovery_fd, GL_DISCOVERY_RESP, + (NET_HostInfo*)gl.discovered_hosts, &gl.num_hosts, + GBLINK_MAX_HOSTS); + + int count = (gl.num_hosts < max_hosts) ? gl.num_hosts : max_hosts; + memcpy(hosts, gl.discovered_hosts, count * sizeof(GBLinkHostInfo)); + return count; +} + +////////////////////////////////////////////////////////////////////////////// +// Status Functions +////////////////////////////////////////////////////////////////////////////// + +GBLinkMode GBLink_getMode(void) { return gl.mode; } + +GBLinkState GBLink_getState(void) { + if (!gl.initialized) return GBLINK_STATE_IDLE; + GBLink_pollConnectionState(); // refresh from the socket table (throttled, self-locking) + pthread_mutex_lock(&gl.mutex); + GBLinkState state = gl.state; + pthread_mutex_unlock(&gl.mutex); + return state; +} + +bool GBLink_isConnected(void) { + if (!gl.initialized) return false; + GBLink_pollConnectionState(); // refresh from the socket table (throttled, self-locking) + pthread_mutex_lock(&gl.mutex); + bool connected = (gl.state == GBLINK_STATE_CONNECTED); + pthread_mutex_unlock(&gl.mutex); + return connected; +} + +const char* GBLink_getStatusMessage(void) { return gl.status_msg; } + +const char* GBLink_getLocalIP(void) { + // Refresh IP if not in an active session (to avoid returning stale hotspot IP) + if (gl.mode == GBLINK_OFF) { + NET_getLocalIP(gl.local_ip, sizeof(gl.local_ip)); + } + return gl.local_ip; +} + +bool GBLink_isUsingHotspot(void) { return gl.using_hotspot; } + +bool GBLink_hasNetworkConnection(void) { + NET_getLocalIP(gl.local_ip, sizeof(gl.local_ip)); + return NET_hasConnection(); +} + +void GBLink_notifyConnectionFromCore(bool connected) { + if (!gl.initialized) return; + + pthread_mutex_lock(&gl.mutex); + + if (connected) { + // Only update if we're in a mode that expects connection + if (gl.mode == GBLINK_HOST && gl.state == GBLINK_STATE_WAITING) { + gl.state = GBLINK_STATE_CONNECTED; + snprintf(gl.status_msg, sizeof(gl.status_msg), "Client connected"); + } else if (gl.mode == GBLINK_CLIENT && gl.state != GBLINK_STATE_CONNECTED) { + gl.state = GBLINK_STATE_CONNECTED; + snprintf(gl.status_msg, sizeof(gl.status_msg), "Connected to host"); + } + } else { + // Connection lost + if (gl.state == GBLINK_STATE_CONNECTED) { + if (gl.mode == GBLINK_HOST) { + // Host goes back to waiting and restarts broadcast + gl.state = GBLINK_STATE_WAITING; + GBLink_restartBroadcast(); + snprintf(gl.status_msg, sizeof(gl.status_msg), "Client left, waiting on %s:%d", gl.local_ip, gl.port); + } else { + // Client fully disconnects + gl.state = GBLINK_STATE_DISCONNECTED; + snprintf(gl.status_msg, sizeof(gl.status_msg), "Connection lost"); + } + } + } + + pthread_mutex_unlock(&gl.mutex); +} + +////////////////////////////////////////////////////////////////////////////// +// Connection State Polling +////////////////////////////////////////////////////////////////////////////// + +// gambatte owns the GB Link TCP socket (we only set its mode/port via core +// options), so we observe the connection by inspecting the kernel socket table +// for an ESTABLISHED connection on `port` instead of scraping core log output. +static bool gblink_tcp_established_on_port(uint16_t port) { + const char* files[] = { "/proc/net/tcp", "/proc/net/tcp6" }; + for (int f = 0; f < 2; f++) { + FILE* fp = fopen(files[f], "r"); + if (!fp) continue; + char line[512]; + if (fgets(line, sizeof(line), fp)) { // skip header row + unsigned local_port, rem_port, st; + while (fgets(line, sizeof(line), fp)) { + // Columns: sl local_addr:PORT rem_addr:PORT st ... (addr/port in hex) + if (sscanf(line, "%*d: %*[0-9A-Fa-f]:%x %*[0-9A-Fa-f]:%x %x", + &local_port, &rem_port, &st) == 3) { + // st 0x01 == TCP_ESTABLISHED. Host: local port == our port; + // client: remote port == our port. LISTEN/TIME_WAIT won't match. + if (st == 0x01 && (local_port == port || rem_port == port)) { + fclose(fp); + return true; + } + } + } + } + fclose(fp); + } + return false; +} + +// Poll gambatte's link socket and feed the state machine. Throttled (~0.5s) so +// it is cheap to call every frame and from the menu/wait loops. Drives the same +// GBLink_notifyConnectionFromCore() transitions the old log scraper did. +void GBLink_pollConnectionState(void) { + if (!gl.initialized || gl.mode == GBLINK_OFF) return; + + static uint64_t last_us = 0; + struct timeval tv; + gettimeofday(&tv, NULL); + uint64_t now = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; + if (last_us && (now - last_us) < 500000ULL) return; // ~0.5s throttle + last_us = now; + + GBLink_notifyConnectionFromCore(gblink_tcp_established_on_port(gl.port)); +} diff --git a/workspace/all/netplay/gblink.h b/workspace/all/netplay/gblink.h new file mode 100644 index 000000000..6d52aaa85 --- /dev/null +++ b/workspace/all/netplay/gblink.h @@ -0,0 +1,115 @@ +/* + * NextUI GB Link Module + * Implements GB/GBC Link Cable emulation over WiFi via gambatte core options + * + * This module manages gambatte's built-in network serial (HAVE_NETWORK=1) + * by setting core options programmatically. Gambatte handles the actual + * TCP connection internally - this module provides: + * - UDP host discovery (similar to GBA Link) + * - Core option management for link mode and IP configuration + * - Consistent UI with GBA Link menus + * + * Unlike GBA Link (which uses netpacket callbacks), GB Link: + * - Gambatte manages TCP connection internally (port 56400) + * - We set gambatte_gb_link_mode and IP digit options + * - Each device runs its own save file + */ + +#ifndef GBLINK_H +#define GBLINK_H + +#include +#include + +#define GBLINK_DEFAULT_PORT 56400 +#define GBLINK_DISCOVERY_PORT 56421 +#define GBLINK_MAGIC "GBLC" +#define GBLINK_PROTOCOL_VERSION 1 +#define GBLINK_MAX_GAME_NAME 64 +#define GBLINK_MAX_HOSTS 8 + +typedef enum { + GBLINK_OFF = 0, + GBLINK_HOST, + GBLINK_CLIENT +} GBLinkMode; + +typedef enum { + GBLINK_STATE_IDLE = 0, + GBLINK_STATE_WAITING, // Host waiting for client + GBLINK_STATE_CONNECTING, // Client connecting to host + GBLINK_STATE_CONNECTED, // Connected + GBLINK_STATE_DISCONNECTED, + GBLINK_STATE_ERROR +} GBLinkState; + +typedef struct { + char game_name[GBLINK_MAX_GAME_NAME]; + char host_ip[16]; + uint16_t port; + uint32_t game_crc; +} GBLinkHostInfo; + +// Initialize/cleanup +void GBLink_init(void); +void GBLink_quit(void); + +// Check if a core supports GB Link (link cable via gambatte) +// core_name is derived from the .so filename (e.g., "gambatte" from "gambatte_libretro.so") +// Returns true if supported (gambatte), also sets internal support flag +bool GBLink_checkCoreSupport(const char* core_name); + +// Host mode (sets gambatte_gb_link_mode = "Network Server") +// If hotspot_ip is NULL, uses WiFi mode. Otherwise, uses hotspot mode with given IP. +int GBLink_startHost(const char* game_name, uint32_t game_crc, const char* hotspot_ip); +int GBLink_stopHost(void); +int GBLink_stopHostFast(void); +void GBLink_stopBroadcast(void); // Stop UDP broadcast but keep host session active + +// Client mode (sets gambatte_gb_link_mode = "Network Client" + IP options) +int GBLink_connectToHost(const char* ip, uint16_t port); +int GBLink_stopClient(void); + +// Stop all GB Link activity +// Use this for clean shutdown before quit +void GBLink_stopAll(void); +void GBLink_stopAllFast(void); + +// Status queries +GBLinkMode GBLink_getMode(void); +GBLinkState GBLink_getState(void); +bool GBLink_isConnected(void); +const char* GBLink_getStatusMessage(void); +const char* GBLink_getLocalIP(void); +bool GBLink_hasNetworkConnection(void); + +// Hotspot mode +bool GBLink_isUsingHotspot(void); + +// Host discovery (for client) +int GBLink_startDiscovery(void); +void GBLink_stopDiscovery(void); +int GBLink_getDiscoveredHosts(GBLinkHostInfo* hosts, int max_hosts); + +// Core option management - called by minarch to configure gambatte +// Sets gambatte_gb_link_mode to "Network Server" +void GBLink_setCoreOptionsForHost(void); +// Sets gambatte_gb_link_mode to "Network Client" and IP digit options +void GBLink_setCoreOptionsForClient(const char* ip); +// Sets gambatte_gb_link_mode to "Not Connected" +void GBLink_setCoreOptionsDisconnect(void); + +// Updates connection state. Called internally by the poll below; exposed so the +// state machine can be driven from one place. +void GBLink_notifyConnectionFromCore(bool connected); + +// Observe gambatte's link socket (via the kernel socket table) and update the +// connection state. Throttled, so it is safe to call every frame and from the +// menu/wait loops. Called lazily from GBLink_getState()/GBLink_isConnected() and +// once per frame from minarch's main loop for in-game disconnect detection. +void GBLink_pollConnectionState(void); + +// Minarch accessor and utility functions +#include "minarch.h" + +#endif /* GBLINK_H */ diff --git a/workspace/all/netplay/keyboard.c b/workspace/all/netplay/keyboard.c new file mode 100644 index 000000000..c73e48c2b --- /dev/null +++ b/workspace/all/netplay/keyboard.c @@ -0,0 +1,278 @@ +/* + * On-Screen Keyboard for minarch + * Ported from settings/keyboardprompt.cpp (MIT License) + * Original: https://github.com/josegonzalez/minui-keyboard + */ + +#include +#include +#include + +#include "keyboard.h" +#include "defines.h" +#include "api.h" + +// Minarch accessor and utility functions +#include "minarch.h" +#define screen minarch_getScreen() + +// External globals from minarch.c +extern struct GFX_Fonts font; + +#define KB_ROWS 5 +#define KB_COLS 14 +#define KB_MAX_INPUT 128 + +// Keyboard layouts +static const char* kb_layout_lower[KB_ROWS][KB_COLS] = { + {"`", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=", NULL}, + {"q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "[", "]", "\\", NULL}, + {"a", "s", "d", "f", "g", "h", "j", "k", "l", ";", "'", NULL, NULL, NULL}, + {"z", "x", "c", "v", "b", "n", "m", ",", ".", "/", NULL, NULL, NULL, NULL}, + {"SHIFT", "SPACE", "DONE", NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL} +}; + +static const char* kb_layout_upper[KB_ROWS][KB_COLS] = { + {"~", "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "_", "+", NULL}, + {"Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "{", "}", "|", NULL}, + {"A", "S", "D", "F", "G", "H", "J", "K", "L", ":", "\"", NULL, NULL, NULL}, + {"Z", "X", "C", "V", "B", "N", "M", "<", ">", "?", NULL, NULL, NULL, NULL}, + {"SHIFT", "SPACE", "DONE", NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL} +}; + +// Count non-null keys in a row +static int kb_row_length(const char* layout[KB_ROWS][KB_COLS], int row) { + int len = 0; + for (int i = 0; i < KB_COLS; i++) { + if (layout[row][i] != NULL) len++; + } + return len; +} + +// Draw the on-screen keyboard +static void kb_draw(const char* title, const char* input_text, int cur_row, int cur_col, int shift) { + const char* (*layout)[KB_COLS] = shift ? kb_layout_upper : kb_layout_lower; + + GFX_clear(screen); + + int center_x = screen->w / 2; + int center_y = screen->h / 2; + SDL_Surface* text; + int text_w; + + // Keyboard dimensions (smaller with padding) + int key_size = SCALE1(18); + int key_spacing = SCALE1(3); + int special_key_w = SCALE1(50); + int special_spacing = SCALE1(58); + + // Calculate total keyboard height to center it + int kb_height = (KB_ROWS * key_size) + ((KB_ROWS - 1) * key_spacing); + int title_h = SCALE1(30); + int input_h = SCALE1(24); + int gap = SCALE1(12); + int total_h = title_h + gap + input_h + gap + kb_height; + + int content_start_y = center_y - total_h / 2; + + // Title + text = TTF_RenderUTF8_Blended(font.medium, title, COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, content_start_y}); + SDL_FreeSurface(text); + + // Input field background + int input_y = content_start_y + title_h + gap; + int input_w = screen->w - SCALE1(100); // More padding on sides + SDL_Rect input_bg = {center_x - input_w/2, input_y, input_w, input_h}; + SDL_FillRect(screen, &input_bg, SDL_MapRGB(screen->format, 40, 40, 40)); + + // Input text (show actual characters so user can verify) + int len = strlen(input_text); + + if (len > 0) { + text = TTF_RenderUTF8_Blended(font.small, input_text, COLOR_WHITE); + text_w = text->w; + // Clamp to fit in input field + if (text_w > input_bg.w - SCALE1(10)) { + SDL_Rect src = {text_w - (input_bg.w - SCALE1(10)), 0, input_bg.w - SCALE1(10), text->h}; + SDL_BlitSurface(text, &src, screen, &(SDL_Rect){input_bg.x + SCALE1(5), input_y + SCALE1(3)}); + } else { + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, input_y + SCALE1(3)}); + } + SDL_FreeSurface(text); + } + + // Keyboard + int kb_start_y = input_y + input_h + gap; + + for (int row = 0; row < KB_ROWS; row++) { + int row_len = kb_row_length(layout, row); + int row_width; + + if (row == KB_ROWS - 1) { + // Special row with SHIFT, SPACE, DONE + row_width = (3 * special_key_w) + (2 * key_spacing); + } else { + row_width = (row_len * key_size) + ((row_len - 1) * key_spacing); + } + + int start_x = center_x - row_width / 2; + + for (int col = 0; col < KB_COLS; col++) { + const char* key = layout[row][col]; + if (key == NULL) continue; + + bool selected = (row == cur_row && col == cur_col); + int key_w = key_size; + + // Special keys are wider + if (strcmp(key, "SHIFT") == 0 || strcmp(key, "SPACE") == 0 || strcmp(key, "DONE") == 0) { + key_w = special_key_w; + } + + int key_x; + if (row == KB_ROWS - 1) { + // Position special keys + if (col == 0) key_x = start_x; + else if (col == 1) key_x = start_x + special_key_w + key_spacing; + else key_x = start_x + 2 * (special_key_w + key_spacing); + } else { + key_x = start_x + col * (key_size + key_spacing); + } + + int key_y = kb_start_y + row * (key_size + key_spacing); + + // Key background + SDL_Rect key_rect = {key_x, key_y, key_w, key_size}; + Uint32 bg_color = selected ? + SDL_MapRGB(screen->format, 255, 255, 255) : + SDL_MapRGB(screen->format, 60, 60, 60); + SDL_FillRect(screen, &key_rect, bg_color); + + // Key text - use tiny font for special keys, small for regular keys + SDL_Color text_color = selected ? COLOR_BLACK : COLOR_WHITE; + bool is_special = (strcmp(key, "SHIFT") == 0 || strcmp(key, "SPACE") == 0 || strcmp(key, "DONE") == 0); + text = TTF_RenderUTF8_Blended(is_special ? font.tiny : font.small, key, text_color); + int tx = key_x + (key_w - text->w) / 2; + int ty = key_y + (key_size - text->h) / 2; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){tx, ty}); + SDL_FreeSurface(text); + } + } + + // Button hints + GFX_blitButtonGroup((char*[]){ "B","DELETE", "Y","CANCEL", "A","TYPE", NULL }, 1, screen, 1); + + GFX_flip(screen); +} + +char* Keyboard_show(const char* title) { + char input[KB_MAX_INPUT + 1] = {0}; + int input_len = 0; + int cur_row = 0; + int cur_col = 0; + int shift = 0; // 0 = lowercase, 1 = uppercase + int dirty = 1; + + const char* (*layout)[KB_COLS]; + + while (1) { + layout = shift ? kb_layout_upper : kb_layout_lower; + + GFX_startFrame(); + PAD_poll(); + + // Cancel + if (PAD_justPressed(BTN_Y) || PAD_justPressed(BTN_MENU)) { + return NULL; + } + + // Navigation + if (PAD_justRepeated(BTN_UP)) { + cur_row--; + if (cur_row < 0) cur_row = KB_ROWS - 1; + // Clamp column to row length + int row_len = kb_row_length(layout, cur_row); + if (cur_col >= row_len) cur_col = row_len - 1; + dirty = 1; + } + else if (PAD_justRepeated(BTN_DOWN)) { + cur_row++; + if (cur_row >= KB_ROWS) cur_row = 0; + int row_len = kb_row_length(layout, cur_row); + if (cur_col >= row_len) cur_col = row_len - 1; + dirty = 1; + } + else if (PAD_justRepeated(BTN_LEFT)) { + cur_col--; + if (cur_col < 0) { + int row_len = kb_row_length(layout, cur_row); + cur_col = row_len - 1; + } + dirty = 1; + } + else if (PAD_justRepeated(BTN_RIGHT)) { + cur_col++; + int row_len = kb_row_length(layout, cur_row); + if (cur_col >= row_len) cur_col = 0; + dirty = 1; + } + // Delete character + else if (PAD_justPressed(BTN_B)) { + if (input_len > 0) { + input[--input_len] = '\0'; + dirty = 1; + } + } + // Type character / action + else if (PAD_justPressed(BTN_A)) { + const char* key = layout[cur_row][cur_col]; + if (key != NULL) { + if (strcmp(key, "SHIFT") == 0) { + shift = !shift; + dirty = 1; + } + else if (strcmp(key, "SPACE") == 0) { + if (input_len < KB_MAX_INPUT) { + input[input_len++] = ' '; + input[input_len] = '\0'; + dirty = 1; + } + } + else if (strcmp(key, "DONE") == 0) { + // Return the input + if (input_len > 0) { + char* result = malloc(input_len + 1); + if (result) { + strcpy(result, input); + } + return result; + } + return NULL; // Empty input = cancel + } + else { + // Regular character + if (input_len < KB_MAX_INPUT) { + input[input_len++] = key[0]; + input[input_len] = '\0'; + dirty = 1; + } + } + } + } + + PWR_update(&dirty, NULL, minarch_beforeSleep, minarch_afterSleep); + + if (dirty) { + kb_draw(title, input, cur_row, cur_col, shift); + dirty = 0; + } + + minarch_hdmimon(); + } +} + +char* Keyboard_getPassword(void) { + return Keyboard_show("Enter WiFi Password"); +} diff --git a/workspace/all/netplay/keyboard.h b/workspace/all/netplay/keyboard.h new file mode 100644 index 000000000..64a73922e --- /dev/null +++ b/workspace/all/netplay/keyboard.h @@ -0,0 +1,18 @@ +/* + * On-Screen Keyboard for minarch + * Ported from settings/keyboardprompt.cpp + */ + +#ifndef KEYBOARD_H +#define KEYBOARD_H + +// Show on-screen keyboard and return entered text +// title: Title to display above the keyboard +// Returns malloc'd string with input, or NULL if cancelled +// Caller must free() the returned string +char* Keyboard_show(const char* title); + +// Convenience function for WiFi password entry +char* Keyboard_getPassword(void); + +#endif // KEYBOARD_H diff --git a/workspace/all/netplay/netplay.c b/workspace/all/netplay/netplay.c new file mode 100644 index 000000000..edffd5105 --- /dev/null +++ b/workspace/all/netplay/netplay.c @@ -0,0 +1,1143 @@ +/* + * NextUI Netplay Module + * Simplified implementation based on RetroArch netplay concepts + * + * Key design: + * - Lockstep synchronization: both devices must have same inputs before advancing + * - Frame buffer: circular buffer storing input history + * - Host = Player 1, Client = Player 2 (always) + * - Both devices run identical emulation with identical inputs + */ + +#define _GNU_SOURCE // For strcasestr + +#include "netplay.h" +#include "netplay_helper.h" // For stopHotspotAndRestoreWiFiAsync, netplay_connected_to_hotspot +#include "network_common.h" +#include "defines.h" // Must come before api.h for BTN_ID_COUNT +#include "api.h" +#ifdef HAS_WIFIMG +#include "wifi_direct.h" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Protocol constants (internal) +#define NP_PROTOCOL_MAGIC 0x4E585550 // "NXUP" - NextUI Protocol +#define NP_DISCOVERY_QUERY 0x4E584451 // "NXDQ" - NextUI Discovery Query +#define NP_DISCOVERY_RESP 0x4E584452 // "NXDR" - NextUI Discovery Response + +// Optimization: Discovery broadcast interval (microseconds) +#define DISCOVERY_BROADCAST_INTERVAL_US 500000 // 500ms + +// Network commands +enum { + CMD_INPUT = 0x01, // Input data for a frame + CMD_STATE_REQ = 0x02, // Request state transfer + CMD_STATE_HDR = 0x03, // State header (size) + CMD_STATE_DATA = 0x04, // State data chunk + CMD_STATE_ACK = 0x05, // State received OK + CMD_PING = 0x06, + CMD_PONG = 0x07, + CMD_DISCONNECT = 0x08, + CMD_READY = 0x09, // Ready to play + CMD_PAUSE = 0x0A, // Player paused (menu opened) + CMD_RESUME = 0x0B, // Player resumed (menu closed) + CMD_KEEPALIVE = 0x0C, // Keepalive during stall to prevent timeout +}; + +// Frame input entry +typedef struct { + uint32_t frame; + uint16_t p1_input; // Host input (always Player 1) + uint16_t p2_input; // Client input (always Player 2) + bool have_p1; + bool have_p2; +} FrameInput; + +// Packet header +typedef struct __attribute__((packed)) { + uint8_t cmd; + uint32_t frame; + uint16_t size; +} PacketHeader; + +// Input packet +typedef struct __attribute__((packed)) { + uint16_t input; +} InputPacket; + +// Main netplay state +static struct { + NetplayMode mode; + NetplayState state; + + // Sockets + int tcp_fd; // Main TCP connection + int listen_fd; // Server listen socket + int udp_fd; // Discovery UDP socket + + // Connection info + char local_ip[16]; + char remote_ip[16]; + uint16_t port; + + // Game info + char game_name[NETPLAY_MAX_GAME_NAME]; + uint32_t game_crc; + + // Frame synchronization + uint32_t self_frame; // Our current frame + uint32_t run_frame; // Frame we're executing + uint32_t other_frame; // Last frame with complete input + + // Circular frame buffer + FrameInput frame_buffer[NETPLAY_FRAME_BUFFER_SIZE]; + + // Local input for current frame + uint16_t local_input; + + // State sync flags + bool needs_state_sync; + bool state_sync_complete; + + // Discovery + NetplayHostInfo discovered_hosts[NETPLAY_MAX_HOSTS]; + int num_hosts; + bool discovery_active; + + // Threading + pthread_t listen_thread; + pthread_mutex_t mutex; + volatile bool running; + + // Status + char status_msg[128]; + int stall_frames; + + // Optimization: Cached audio silence state (updated per frame) + volatile bool audio_should_silence; + + // Hotspot mode + bool using_hotspot; + + // Pause state (for menu) + bool local_paused; // We have paused (menu open) + bool remote_paused; // Remote player has paused + + // Initialization flag + bool initialized; + +} np = {0}; + +// Forward declarations +static bool send_packet(uint8_t cmd, uint32_t frame, const void* data, uint16_t size); +static bool recv_packet(PacketHeader* hdr, void* data, uint16_t max_size, int timeout_ms); +static void* listen_thread_func(void* arg); +static FrameInput* get_frame_slot(uint32_t frame); +static void init_frame_buffer(void); + +////////////////////////////////////////////////////////////////////////////// +// Initialization +////////////////////////////////////////////////////////////////////////////// + +void Netplay_init(void) { + if (np.initialized) return; + + memset(&np, 0, sizeof(np)); + np.mode = NETPLAY_OFF; + np.state = NETPLAY_STATE_IDLE; + np.tcp_fd = -1; + np.listen_fd = -1; + np.udp_fd = -1; + np.port = NETPLAY_DEFAULT_PORT; + pthread_mutex_init(&np.mutex, NULL); + NET_getLocalIP(np.local_ip, sizeof(np.local_ip)); + snprintf(np.status_msg, sizeof(np.status_msg), "Netplay ready"); + np.initialized = true; +} + +void Netplay_quit(void) { + if (!np.initialized) return; + + // Capture hotspot state before cleanup + bool was_host = (np.mode == NETPLAY_HOST); + bool needs_hotspot_cleanup = np.using_hotspot || netplay_connected_to_hotspot; + + Netplay_disconnect(); + Netplay_stopHostFast(); + Netplay_stopDiscovery(); + + // Handle hotspot cleanup asynchronously + if (needs_hotspot_cleanup) { + stopHotspotAndRestoreWiFiAsync(was_host); + netplay_connected_to_hotspot = 0; + } + + pthread_mutex_destroy(&np.mutex); + np.initialized = false; +} + +bool Netplay_checkCoreSupport(const char* core_name) { + // These cores have been tested and work with frame-synchronized netplay + // core_name is derived from the .so filename (e.g., "fbneo" from "fbneo_libretro.so") + if (strcasecmp(core_name, "fbneo") == 0 || + strcasecmp(core_name, "fceumm") == 0 || + strcasecmp(core_name, "snes9x") == 0 || + strcasecmp(core_name, "mednafen_supafaust") == 0 || + strcasecmp(core_name, "picodrive") == 0 || + strcasecmp(core_name, "pcsx_rearmed") == 0) { + return true; + } + return false; +} + +////////////////////////////////////////////////////////////////////////////// +// Helper Functions (extracted for code reuse) +////////////////////////////////////////////////////////////////////////////// + +static FrameInput* get_frame_slot(uint32_t frame) { + return &np.frame_buffer[frame & NETPLAY_FRAME_MASK]; +} + +static void init_frame_slot(uint32_t frame) { + FrameInput* slot = get_frame_slot(frame); + slot->frame = frame; + slot->p1_input = 0; + slot->p2_input = 0; + slot->have_p1 = false; + slot->have_p2 = false; +} + +// Optimization: Extracted duplicate frame buffer initialization +static void init_frame_buffer(void) { + for (int i = 0; i < NETPLAY_FRAME_BUFFER_SIZE; i++) { + init_frame_slot(i); + } +} + +////////////////////////////////////////////////////////////////////////////// +// Host Mode +////////////////////////////////////////////////////////////////////////////// + +int Netplay_startHost(const char* game_name, uint32_t game_crc, const char* hotspot_ip) { + Netplay_init(); // Lazy init + if (np.mode != NETPLAY_OFF) { + return -1; + } + + // Set up IP based on mode + if (hotspot_ip) { + np.using_hotspot = true; + strncpy(np.local_ip, hotspot_ip, sizeof(np.local_ip) - 1); + np.local_ip[sizeof(np.local_ip) - 1] = '\0'; + } + + // Create TCP listen socket using shared utility + np.listen_fd = NET_createListenSocket(np.port, np.status_msg, sizeof(np.status_msg)); + if (np.listen_fd < 0) { + if (hotspot_ip) { + np.using_hotspot = false; + } + return -1; + } + + // Create UDP socket for discovery broadcasts + np.udp_fd = NET_createBroadcastSocket(); + if (np.udp_fd < 0) { + close(np.listen_fd); + np.listen_fd = -1; + if (hotspot_ip) { + np.using_hotspot = false; + } + snprintf(np.status_msg, sizeof(np.status_msg), "Failed to create broadcast socket"); + return -1; + } + + strncpy(np.game_name, game_name, NETPLAY_MAX_GAME_NAME - 1); + np.game_crc = game_crc; + + // Start listen thread + np.running = true; + pthread_create(&np.listen_thread, NULL, listen_thread_func, NULL); + + np.mode = NETPLAY_HOST; + np.state = NETPLAY_STATE_WAITING; + np.needs_state_sync = true; + + snprintf(np.status_msg, sizeof(np.status_msg), "Hosting on %s:%d", np.local_ip, np.port); + return 0; +} + +void Netplay_stopBroadcast(void) { + // Close UDP socket - no longer needed after connection + if (np.udp_fd >= 0) { + close(np.udp_fd); + np.udp_fd = -1; + } +} + +// Restart UDP broadcast when going back to waiting state +// Called when client disconnects but host wants to accept new clients +static void Netplay_restartBroadcast(void) { + if (np.udp_fd >= 0) return; // Already running + if (np.mode != NETPLAY_HOST) return; // Only for host + + np.udp_fd = NET_createBroadcastSocket(); + if (np.udp_fd < 0) { + snprintf(np.status_msg, sizeof(np.status_msg), "Failed to restart broadcast"); + } +} + +// Internal helper - stops host with optional hotspot cleanup +static int Netplay_stopHostInternal(bool skip_hotspot_cleanup) { + if (np.mode != NETPLAY_HOST) return -1; + + np.running = false; + + // Wake up listen thread by closing listen socket (causes select to return) + // This is safer than pthread_cancel which may leave resources inconsistent + if (np.listen_fd >= 0) { + shutdown(np.listen_fd, SHUT_RDWR); + } + + if (np.listen_thread) { + pthread_join(np.listen_thread, NULL); + np.listen_thread = 0; + } + + if (np.listen_fd >= 0) { + close(np.listen_fd); + np.listen_fd = -1; + } + + Netplay_stopBroadcast(); + Netplay_disconnect(); + + // Stop hotspot if it was started + if (np.using_hotspot) { + if (!skip_hotspot_cleanup) { +#ifdef HAS_WIFIMG + WIFI_direct_stopHotspot(); +#endif + } + np.using_hotspot = false; + } + + np.mode = NETPLAY_OFF; + np.state = NETPLAY_STATE_IDLE; + snprintf(np.status_msg, sizeof(np.status_msg), "Netplay ready"); + return 0; +} + +int Netplay_stopHost(void) { + return Netplay_stopHostInternal(false); +} + +int Netplay_stopHostFast(void) { + return Netplay_stopHostInternal(true); +} + +static void* listen_thread_func(void* arg) { + (void)arg; + + // Use shared broadcast timer for rate limiting + NET_BroadcastTimer broadcast_timer; + NET_initBroadcastTimer(&broadcast_timer, DISCOVERY_BROADCAST_INTERVAL_US); + + while (np.running && np.listen_fd >= 0) { + // Check state under mutex protection to avoid race conditions + pthread_mutex_lock(&np.mutex); + bool is_waiting = (np.state == NETPLAY_STATE_WAITING); + int udp_fd = np.udp_fd; + pthread_mutex_unlock(&np.mutex); + + // Rate-limited discovery broadcast using shared timer + if (udp_fd >= 0 && is_waiting) { + if (NET_shouldBroadcast(&broadcast_timer)) { + NET_sendDiscoveryBroadcast(udp_fd, NP_DISCOVERY_RESP, NETPLAY_PROTOCOL_VERSION, + np.game_crc, np.port, NETPLAY_DISCOVERY_PORT, + np.game_name, NULL); // Netplay doesn't use link_mode + } + } + + // Check for incoming connection (only accept when waiting) + if (is_waiting) { + fd_set fds; + FD_ZERO(&fds); + FD_SET(np.listen_fd, &fds); + + struct timeval tv = {.tv_sec = 0, .tv_usec = 100000}; // 100ms timeout + if (select(np.listen_fd + 1, &fds, NULL, NULL, &tv) > 0) { + struct sockaddr_in client_addr; + socklen_t len = sizeof(client_addr); + + int fd = accept(np.listen_fd, (struct sockaddr*)&client_addr, &len); + if (fd >= 0) { + pthread_mutex_lock(&np.mutex); + + // Double-check we're still waiting (state could have changed) + if (np.state != NETPLAY_STATE_WAITING) { + close(fd); + pthread_mutex_unlock(&np.mutex); + continue; + } + + // Configure TCP socket using shared utility (default: 64KB buffers) + NET_configureTCPSocket(fd, NULL); + + np.tcp_fd = fd; + inet_ntop(AF_INET, &client_addr.sin_addr, np.remote_ip, sizeof(np.remote_ip)); + + np.state = NETPLAY_STATE_SYNCING; + np.needs_state_sync = true; + np.self_frame = 0; + np.run_frame = 0; + np.other_frame = 0; + + init_frame_buffer(); + + snprintf(np.status_msg, sizeof(np.status_msg), "Client connected: %s", np.remote_ip); + pthread_mutex_unlock(&np.mutex); + } + } + } else { + // Not waiting, just sleep briefly to avoid busy loop + usleep(50000); // 50ms (reduced from 100ms) + } + } + + return NULL; +} + +////////////////////////////////////////////////////////////////////////////// +// Client Mode +////////////////////////////////////////////////////////////////////////////// + +int Netplay_connectToHost(const char* ip, uint16_t port) { + Netplay_init(); // Lazy init + if (np.mode != NETPLAY_OFF) { + return -1; + } + + np.tcp_fd = socket(AF_INET, SOCK_STREAM, 0); + if (np.tcp_fd < 0) { + snprintf(np.status_msg, sizeof(np.status_msg), "Socket creation failed"); + return -1; + } + + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + + if (inet_pton(AF_INET, ip, &addr.sin_addr) <= 0) { + close(np.tcp_fd); + np.tcp_fd = -1; + snprintf(np.status_msg, sizeof(np.status_msg), "Invalid IP address"); + return -1; + } + + np.state = NETPLAY_STATE_CONNECTING; + snprintf(np.status_msg, sizeof(np.status_msg), "Connecting to %s:%d...", ip, port); + + // Connect with timeout + struct timeval tv = {.tv_sec = 5, .tv_usec = 0}; + setsockopt(np.tcp_fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + + if (connect(np.tcp_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { + close(np.tcp_fd); + np.tcp_fd = -1; + np.state = NETPLAY_STATE_ERROR; + snprintf(np.status_msg, sizeof(np.status_msg), "Connection failed"); + return -1; + } + + // Configure TCP socket using shared utility (default: 64KB buffers) + NET_configureTCPSocket(np.tcp_fd, NULL); + + strncpy(np.remote_ip, ip, sizeof(np.remote_ip) - 1); + np.port = port; + np.mode = NETPLAY_CLIENT; + np.state = NETPLAY_STATE_SYNCING; + np.needs_state_sync = true; + + np.self_frame = 0; + np.run_frame = 0; + np.other_frame = 0; + + init_frame_buffer(); + + snprintf(np.status_msg, sizeof(np.status_msg), "Connected to %s", ip); + return 0; +} + +void Netplay_disconnect(void) { + if (np.tcp_fd >= 0) { + send_packet(CMD_DISCONNECT, 0, NULL, 0); + close(np.tcp_fd); + np.tcp_fd = -1; + } + + // Update cached audio state + np.audio_should_silence = false; + + // Reset pause state + np.local_paused = false; + np.remote_paused = false; + + if (np.mode == NETPLAY_CLIENT) { + np.mode = NETPLAY_OFF; + np.state = NETPLAY_STATE_DISCONNECTED; + snprintf(np.status_msg, sizeof(np.status_msg), "Disconnected"); + } else if (np.mode == NETPLAY_HOST) { + // Host stays in host mode, reset to waiting for new client + np.state = NETPLAY_STATE_WAITING; + np.needs_state_sync = true; + np.stall_frames = 0; + snprintf(np.status_msg, sizeof(np.status_msg), "Client left, waiting on %s:%d", np.local_ip, np.port); + } else { + np.state = NETPLAY_STATE_DISCONNECTED; + snprintf(np.status_msg, sizeof(np.status_msg), "Disconnected"); + } +} + +////////////////////////////////////////////////////////////////////////////// +// Discovery +////////////////////////////////////////////////////////////////////////////// + +int Netplay_startDiscovery(void) { + if (np.discovery_active) return 0; + + np.udp_fd = NET_createDiscoveryListenSocket(NETPLAY_DISCOVERY_PORT); + if (np.udp_fd < 0) { + snprintf(np.status_msg, sizeof(np.status_msg), "Failed to start discovery"); + return -1; + } + + np.num_hosts = 0; + np.discovery_active = true; + return 0; +} + +void Netplay_stopDiscovery(void) { + if (!np.discovery_active) return; + + if (np.udp_fd >= 0 && np.mode == NETPLAY_OFF) { + close(np.udp_fd); + np.udp_fd = -1; + } + + np.discovery_active = false; +} + +int Netplay_getDiscoveredHosts(NetplayHostInfo* hosts, int max_hosts) { + if (!np.discovery_active || np.udp_fd < 0) return 0; + + // Poll for discovery responses using shared function + // NetplayHostInfo and NET_HostInfo have identical layouts + NET_receiveDiscoveryResponses(np.udp_fd, NP_DISCOVERY_RESP, + (NET_HostInfo*)np.discovered_hosts, &np.num_hosts, + NETPLAY_MAX_HOSTS); + + int count = (np.num_hosts < max_hosts) ? np.num_hosts : max_hosts; + memcpy(hosts, np.discovered_hosts, count * sizeof(NetplayHostInfo)); + return count; +} + +////////////////////////////////////////////////////////////////////////////// +// Frame Synchronization (Core Netplay Logic) +////////////////////////////////////////////////////////////////////////////// + +bool Netplay_preFrame(void) { + pthread_mutex_lock(&np.mutex); + + // Check connection under mutex to avoid TOCTOU race + if (np.tcp_fd < 0 || + (np.state != NETPLAY_STATE_SYNCING && + np.state != NETPLAY_STATE_PLAYING && + np.state != NETPLAY_STATE_STALLED && + np.state != NETPLAY_STATE_PAUSED)) { + pthread_mutex_unlock(&np.mutex); + return true; + } + + // Get slot for execution (current run frame) + FrameInput* run_slot = get_frame_slot(np.run_frame); + + // Get slot for input sending (ahead by latency frames) + FrameInput* input_slot = get_frame_slot(np.self_frame); + if (input_slot->frame != np.self_frame) { + init_frame_slot(np.self_frame); + input_slot->frame = np.self_frame; + } + + // Store and send our input for the FUTURE frame (np.self_frame) + if (np.mode == NETPLAY_HOST) { + if (!input_slot->have_p1) { + input_slot->p1_input = np.local_input; + input_slot->have_p1 = true; + InputPacket pkt = { .input = htons(np.local_input) }; + send_packet(CMD_INPUT, np.self_frame, &pkt, sizeof(pkt)); + } + } else { + if (!input_slot->have_p2) { + input_slot->p2_input = np.local_input; + input_slot->have_p2 = true; + InputPacket pkt = { .input = htons(np.local_input) }; + send_packet(CMD_INPUT, np.self_frame, &pkt, sizeof(pkt)); + } + } + + // Try to receive remote input - always process available packets + int timeout_ms = 16; // ~1 frame at 60fps + int max_attempts = 10; // ~160ms total + int attempts = 0; + + while (attempts < max_attempts) { + // Check if we already have both inputs for the run frame + run_slot = get_frame_slot(np.run_frame); + if (run_slot->have_p1 && run_slot->have_p2) { + break; // Got both inputs, proceed + } + + // Release lock during blocking network operation + pthread_mutex_unlock(&np.mutex); + + // Try to receive remote input + PacketHeader hdr; + InputPacket remote_pkt; + bool received = recv_packet(&hdr, &remote_pkt, sizeof(remote_pkt), timeout_ms); + + // Re-acquire lock for frame buffer access + pthread_mutex_lock(&np.mutex); + + // Check if disconnected during recv + if (np.state == NETPLAY_STATE_DISCONNECTED) { + np.audio_should_silence = false; + pthread_mutex_unlock(&np.mutex); + return false; + } + + if (received) { + if (hdr.cmd == CMD_INPUT) { + FrameInput* remote_slot = get_frame_slot(hdr.frame); + uint16_t remote_input = ntohs(remote_pkt.input); + + // Store remote input in appropriate slot + if (np.mode == NETPLAY_HOST) { + remote_slot->p2_input = remote_input; + remote_slot->have_p2 = true; + } else { + remote_slot->p1_input = remote_input; + remote_slot->have_p1 = true; + } + } else if (hdr.cmd == CMD_DISCONNECT) { + // Close TCP connection + close(np.tcp_fd); + np.tcp_fd = -1; + np.audio_should_silence = false; + + // For host, go back to waiting and restart broadcast + if (np.mode == NETPLAY_HOST) { + np.state = NETPLAY_STATE_WAITING; + np.needs_state_sync = true; + np.stall_frames = 0; + Netplay_restartBroadcast(); + snprintf(np.status_msg, sizeof(np.status_msg), "Client left, waiting on %s:%d", np.local_ip, np.port); + } else { + np.state = NETPLAY_STATE_DISCONNECTED; + snprintf(np.status_msg, sizeof(np.status_msg), "Host disconnected"); + } + pthread_mutex_unlock(&np.mutex); + return false; + } else if (hdr.cmd == CMD_PAUSE) { + np.remote_paused = true; + np.state = NETPLAY_STATE_PAUSED; + snprintf(np.status_msg, sizeof(np.status_msg), "Remote player paused"); + } else if (hdr.cmd == CMD_RESUME) { + np.remote_paused = false; + if (!np.local_paused) { + np.state = NETPLAY_STATE_PLAYING; + snprintf(np.status_msg, sizeof(np.status_msg), "Netplay active"); + } + } else if (hdr.cmd == CMD_KEEPALIVE) { + // Keepalive received - connection is alive, reset stall counter + // This prevents timeout during legitimate delays (save operations, etc.) + } + } + attempts++; + } + + // Final check - do we have both inputs for run_frame? + run_slot = get_frame_slot(np.run_frame); + if (!run_slot->have_p1 || !run_slot->have_p2) { + np.stall_frames++; + + // Send keepalive during stall to prevent remote from timing out + if (np.stall_frames % NETPLAY_KEEPALIVE_INTERVAL_FRAMES == 0) { + send_packet(CMD_KEEPALIVE, np.self_frame, NULL, 0); + } + + // Skip timeout when either player is paused (menu open) + if (!np.local_paused && !np.remote_paused) { + if (np.stall_frames > NETPLAY_STALL_TIMEOUT_FRAMES) { + snprintf(np.status_msg, sizeof(np.status_msg), "Connection timeout"); + np.state = NETPLAY_STATE_DISCONNECTED; + np.audio_should_silence = false; + pthread_mutex_unlock(&np.mutex); + return false; + } else if (np.stall_frames > NETPLAY_STALL_WARNING_FRAMES) { + // Show countdown warning to user + int remaining = (NETPLAY_STALL_TIMEOUT_FRAMES - np.stall_frames) / 60; + snprintf(np.status_msg, sizeof(np.status_msg), "Waiting... (%ds)", remaining); + } + } + np.state = NETPLAY_STATE_STALLED; + np.audio_should_silence = true; + pthread_mutex_unlock(&np.mutex); + return false; + } + + np.stall_frames = 0; + np.audio_should_silence = false; + np.state = NETPLAY_STATE_PLAYING; + pthread_mutex_unlock(&np.mutex); + return true; +} + +uint16_t Netplay_getInputState(unsigned port) { + if (!Netplay_isConnected()) return 0; + + pthread_mutex_lock(&np.mutex); + FrameInput* slot = get_frame_slot(np.run_frame); + uint16_t input = (port == 0) ? slot->p1_input : slot->p2_input; + pthread_mutex_unlock(&np.mutex); + + return input; +} + +uint32_t Netplay_getPlayerButtons(unsigned port, uint32_t local_buttons) { + // When netplay active, inputs come from the synchronized frame buffer + // Host = Player 1, Client = Player 2 (always) + // Both devices see identical inputs for same frame + if (np.mode != NETPLAY_OFF && Netplay_isConnected()) { + return Netplay_getInputState(port); + } + // Local play - only P1 has input + return (port == 0) ? local_buttons : 0; +} + +void Netplay_setLocalInput(uint16_t input) { + np.local_input = input; +} + +void Netplay_postFrame(void) { + if (!Netplay_isConnected()) return; + + pthread_mutex_lock(&np.mutex); + np.run_frame++; + np.self_frame++; + pthread_mutex_unlock(&np.mutex); +} + +bool Netplay_shouldStall(void) { + return np.state == NETPLAY_STATE_STALLED; +} + +// Optimization: Uses cached value instead of checking state each call +bool Netplay_shouldSilenceAudio(void) { + return np.audio_should_silence; +} + +////////////////////////////////////////////////////////////////////////////// +// State Synchronization +////////////////////////////////////////////////////////////////////////////// + +int Netplay_sendState(const void* data, size_t size) { + if (!Netplay_isConnected() || !data || size == 0) return -1; + + // Send state header + uint32_t state_size = (uint32_t)size; + state_size = htonl(state_size); + if (!send_packet(CMD_STATE_HDR, 0, &state_size, sizeof(state_size))) { + return -1; + } + + // Send state data in chunks + const uint8_t* ptr = (const uint8_t*)data; + size_t remaining = size; + + while (remaining > 0) { + size_t chunk = (remaining > 4096) ? 4096 : remaining; + ssize_t sent = send(np.tcp_fd, ptr, chunk, MSG_NOSIGNAL); + if (sent <= 0) { + return -1; + } + ptr += sent; + remaining -= sent; + } + + // Wait for client ACK + PacketHeader hdr; + if (!recv_packet(&hdr, NULL, 0, 10000) || hdr.cmd != CMD_STATE_ACK) { + return -1; + } + + // Send READY signal + if (!send_packet(CMD_READY, 0, NULL, 0)) { + return -1; + } + + return 0; +} + +int Netplay_receiveState(void* data, size_t size) { + if (!Netplay_isConnected() || !data || size == 0) return -1; + + // Receive state header + PacketHeader hdr; + uint32_t state_size; + + if (!recv_packet(&hdr, &state_size, sizeof(state_size), 10000) || + hdr.cmd != CMD_STATE_HDR) { + return -1; + } + + state_size = ntohl(state_size); + + if (state_size != size) { + snprintf(np.status_msg, sizeof(np.status_msg), + "State size mismatch: %u vs %zu", state_size, size); + return -1; + } + + // Receive state data + uint8_t* ptr = (uint8_t*)data; + size_t remaining = size; + + while (remaining > 0) { + ssize_t received = recv(np.tcp_fd, ptr, remaining, 0); + if (received <= 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + usleep(1000); + continue; + } + return -1; + } + ptr += received; + remaining -= received; + } + + // Send ACK to host + if (!send_packet(CMD_STATE_ACK, 0, NULL, 0)) { + return -1; + } + + // Wait for READY signal from host + if (!recv_packet(&hdr, NULL, 0, 10000) || hdr.cmd != CMD_READY) { + return -1; + } + + return 0; +} + +bool Netplay_needsStateSync(void) { + return np.needs_state_sync && np.state == NETPLAY_STATE_SYNCING; +} + +void Netplay_completeStateSync(void) { + pthread_mutex_lock(&np.mutex); + np.needs_state_sync = false; + np.state_sync_complete = true; + np.state = NETPLAY_STATE_PLAYING; + + // Pre-fill latency buffer frames with neutral input + for (int i = 0; i < NETPLAY_INPUT_LATENCY_FRAMES; i++) { + FrameInput* slot = get_frame_slot(i); + slot->frame = i; + slot->p1_input = 0; + slot->p2_input = 0; + slot->have_p1 = true; + slot->have_p2 = true; + } + + np.run_frame = 0; + np.self_frame = NETPLAY_INPUT_LATENCY_FRAMES; + np.stall_frames = 0; + np.audio_should_silence = false; + + snprintf(np.status_msg, sizeof(np.status_msg), "Netplay active"); + pthread_mutex_unlock(&np.mutex); +} + +////////////////////////////////////////////////////////////////////////////// +// Status Functions +////////////////////////////////////////////////////////////////////////////// + +NetplayMode Netplay_getMode(void) { return np.mode; } +NetplayState Netplay_getState(void) { return np.state; } +bool Netplay_isUsingHotspot(void) { return np.using_hotspot; } + +bool Netplay_isConnected(void) { + return np.tcp_fd >= 0 && + (np.state == NETPLAY_STATE_SYNCING || + np.state == NETPLAY_STATE_PLAYING || + np.state == NETPLAY_STATE_STALLED || + np.state == NETPLAY_STATE_PAUSED); +} + +bool Netplay_isActive(void) { + return np.state == NETPLAY_STATE_PLAYING; +} + +const char* Netplay_getStatusMessage(void) { return np.status_msg; } + +const char* Netplay_getLocalIP(void) { + // Refresh IP if not in an active session (to avoid returning stale hotspot IP) + if (np.mode == NETPLAY_OFF) { + NET_getLocalIP(np.local_ip, sizeof(np.local_ip)); + } + return np.local_ip; +} + +bool Netplay_hasNetworkConnection(void) { + NET_getLocalIP(np.local_ip, sizeof(np.local_ip)); + return NET_hasConnection(); +} + +////////////////////////////////////////////////////////////////////////////// +// Pause/Resume for Menu +////////////////////////////////////////////////////////////////////////////// + +void Netplay_pause(void) { + if (!Netplay_isConnected()) return; + + pthread_mutex_lock(&np.mutex); + np.local_paused = true; + send_packet(CMD_PAUSE, 0, NULL, 0); + np.state = NETPLAY_STATE_PAUSED; + snprintf(np.status_msg, sizeof(np.status_msg), "Paused"); + pthread_mutex_unlock(&np.mutex); +} + +void Netplay_resume(void) { + if (!Netplay_isConnected()) return; + + pthread_mutex_lock(&np.mutex); + np.local_paused = false; + send_packet(CMD_RESUME, 0, NULL, 0); + + // Only resume to PLAYING if remote is also not paused + if (!np.remote_paused) { + np.state = NETPLAY_STATE_PLAYING; + np.stall_frames = 0; + snprintf(np.status_msg, sizeof(np.status_msg), "Netplay active"); + } else { + snprintf(np.status_msg, sizeof(np.status_msg), "Waiting for remote..."); + } + pthread_mutex_unlock(&np.mutex); +} + +void Netplay_pollWhilePaused(void) { + if (!Netplay_isConnected()) return; + + // Just check if connection is still alive, don't consume any packets + // Input packets will be processed by Netplay_preFrame() when we resume + + int error = 0; + socklen_t len = sizeof(error); + if (getsockopt(np.tcp_fd, SOL_SOCKET, SO_ERROR, &error, &len) < 0 || error != 0) { + pthread_mutex_lock(&np.mutex); + np.state = NETPLAY_STATE_DISCONNECTED; + snprintf(np.status_msg, sizeof(np.status_msg), "Connection lost"); + close(np.tcp_fd); + np.tcp_fd = -1; + pthread_mutex_unlock(&np.mutex); + } +} + +////////////////////////////////////////////////////////////////////////////// +// Main Loop Update +////////////////////////////////////////////////////////////////////////////// + +int Netplay_update(uint16_t local_input, + Netplay_SerializeSizeFn serialize_size_fn, + Netplay_SerializeFn serialize_fn, + Netplay_UnserializeFn unserialize_fn) { + // Handle state sync when connection is established + if (Netplay_needsStateSync()) { + if (!serialize_size_fn || !serialize_fn || !unserialize_fn) { + Netplay_disconnect(); + return 1; // Run frame normally after disconnect + } + + size_t state_size = serialize_size_fn(); + bool sync_success = false; + + if (state_size > 0) { + void* state_data = malloc(state_size); + if (state_data) { + if (np.mode == NETPLAY_HOST) { + // Host sends current state to client + if (serialize_fn(state_data, state_size)) { + if (Netplay_sendState(state_data, state_size) == 0) { + sync_success = true; + } + } + } else { + // Client receives state from host + if (Netplay_receiveState(state_data, state_size) == 0) { + if (unserialize_fn(state_data, state_size)) { + sync_success = true; + } + } + } + free(state_data); + } + } + + if (sync_success) { + Netplay_completeStateSync(); + } else { + Netplay_disconnect(); + } + return 0; // Skip this frame + } + + // Frame synchronization (when playing or recovering from stall) + if (Netplay_isActive() || Netplay_shouldStall()) { + Netplay_setLocalInput(local_input); + + if (!Netplay_preFrame()) { + // Check if we got disconnected + if (np.state == NETPLAY_STATE_DISCONNECTED) { + Netplay_disconnect(); // Clean up netplay state + return 1; // Continue to run the game normally + } + // Stalled - don't run this frame + return 0; + } + } + + return 1; // Run frame +} + +bool Netplay_isPaused(void) { + return np.local_paused || np.remote_paused; +} + +////////////////////////////////////////////////////////////////////////////// +// Network Helper Functions +////////////////////////////////////////////////////////////////////////////// + +static bool send_packet(uint8_t cmd, uint32_t frame, const void* data, uint16_t size) { + if (np.tcp_fd < 0) return false; + + PacketHeader hdr = { + .cmd = cmd, + .frame = htonl(frame), + .size = htons(size) + }; + + if (send(np.tcp_fd, &hdr, sizeof(hdr), MSG_NOSIGNAL) != sizeof(hdr)) { + return false; + } + + if (size > 0 && data) { + if (send(np.tcp_fd, data, size, MSG_NOSIGNAL) != size) { + return false; + } + } + + return true; +} + +// Helper to handle disconnect within recv_packet (called with mutex NOT held) +static void handle_recv_disconnect(void) { + pthread_mutex_lock(&np.mutex); + + // Close socket under mutex protection + if (np.tcp_fd >= 0) { + close(np.tcp_fd); + np.tcp_fd = -1; + } + + // For host, go back to waiting and restart broadcast + if (np.mode == NETPLAY_HOST) { + np.state = NETPLAY_STATE_WAITING; + np.needs_state_sync = true; + np.stall_frames = 0; + snprintf(np.status_msg, sizeof(np.status_msg), "Client left, waiting on %s:%d", np.local_ip, np.port); + pthread_mutex_unlock(&np.mutex); + Netplay_restartBroadcast(); // Can be called without mutex + } else { + np.state = NETPLAY_STATE_DISCONNECTED; + snprintf(np.status_msg, sizeof(np.status_msg), "Remote disconnected"); + pthread_mutex_unlock(&np.mutex); + } +} + +static bool recv_packet(PacketHeader* hdr, void* data, uint16_t max_size, int timeout_ms) { + if (np.tcp_fd < 0) return false; + + fd_set fds; + FD_ZERO(&fds); + FD_SET(np.tcp_fd, &fds); + + struct timeval tv = { + .tv_sec = timeout_ms / 1000, + .tv_usec = (timeout_ms % 1000) * 1000 + }; + + if (select(np.tcp_fd + 1, &fds, NULL, NULL, &tv) <= 0) { + return false; // Timeout or error + } + + ssize_t ret = recv(np.tcp_fd, hdr, sizeof(*hdr), 0); + if (ret == 0) { + // Connection closed by remote end + handle_recv_disconnect(); + return false; + } + if (ret < 0 || ret != sizeof(*hdr)) { + // Error or partial read + if (errno == ECONNRESET || errno == EPIPE || errno == ENOTCONN) { + handle_recv_disconnect(); + } + return false; + } + + hdr->frame = ntohl(hdr->frame); + hdr->size = ntohs(hdr->size); + + // Validate packet size to prevent malformed packet issues + if (hdr->size > 4096) { + return false; // Reject suspiciously large packets + } + + if (hdr->size > 0 && data && hdr->size <= max_size) { + ret = recv(np.tcp_fd, data, hdr->size, 0); + if (ret == 0) { + handle_recv_disconnect(); + return false; + } + if (ret != hdr->size) { + return false; + } + } + + return true; +} diff --git a/workspace/all/netplay/netplay.h b/workspace/all/netplay/netplay.h new file mode 100644 index 000000000..2978444e5 --- /dev/null +++ b/workspace/all/netplay/netplay.h @@ -0,0 +1,150 @@ +/* + * NextUI Netplay Module + * Based on RetroArch netplay architecture + * Implements frame-synchronized multiplayer over WiFi + */ + +#ifndef NETPLAY_H +#define NETPLAY_H + +#include +#include +#include +#include "network_common.h" + +#define NETPLAY_DEFAULT_PORT 55435 +#define NETPLAY_DISCOVERY_PORT 55436 +#define NETPLAY_MAGIC "NXNP" +#define NETPLAY_PROTOCOL_VERSION 2 +#define NETPLAY_MAX_GAME_NAME 64 +#define NETPLAY_MAX_HOSTS 8 + +// Frame buffer for rollback (power of 2 for efficient wraparound) +#define NETPLAY_FRAME_BUFFER_SIZE 64 +#define NETPLAY_FRAME_MASK (NETPLAY_FRAME_BUFFER_SIZE - 1) + +// Stall/timeout constants - extended for reliability on lossy networks +// Pokemon games can pause 2+ seconds during save operations +#define NETPLAY_STALL_TIMEOUT_FRAMES 180 // 3 seconds at 60fps (was 30 frames/500ms) +#define NETPLAY_STALL_WARNING_FRAMES 60 // 1 second warning before disconnect +#define NETPLAY_KEEPALIVE_INTERVAL_FRAMES 30 // Send keepalive every 500ms during stall + +// Hotspot SSID prefix - use unified prefix for all link types +#define NETPLAY_HOTSPOT_SSID_PREFIX LINK_HOTSPOT_SSID_PREFIX + +typedef enum { + NETPLAY_CONN_WIFI = 0, + NETPLAY_CONN_HOTSPOT +} NetplayConnMethod; + +// Input latency frames (to hide network jitter) +#define NETPLAY_INPUT_LATENCY_FRAMES 2 + +typedef enum { + NETPLAY_OFF = 0, + NETPLAY_HOST, + NETPLAY_CLIENT +} NetplayMode; + +typedef enum { + NETPLAY_STATE_IDLE = 0, + NETPLAY_STATE_WAITING, // Host waiting for client + NETPLAY_STATE_CONNECTING, // Client connecting to host + NETPLAY_STATE_SYNCING, // Exchanging initial state + NETPLAY_STATE_PLAYING, // Active gameplay + NETPLAY_STATE_STALLED, // Waiting for remote input + NETPLAY_STATE_PAUSED, // Local or remote player has paused (menu open) + NETPLAY_STATE_DISCONNECTED, + NETPLAY_STATE_ERROR +} NetplayState; + +typedef struct { + char game_name[NETPLAY_MAX_GAME_NAME]; + char host_ip[16]; + uint16_t port; + uint32_t game_crc; +} NetplayHostInfo; + +// Initialize/cleanup +void Netplay_init(void); +void Netplay_quit(void); + +// Check if a core supports netplay (frame-sync) +// core_name is derived from the .so filename (e.g., "fbneo" from "fbneo_libretro.so") +// Returns true if supported +bool Netplay_checkCoreSupport(const char* core_name); + +// Connection management +// If hotspot_ip is NULL, uses WiFi mode. Otherwise, uses hotspot mode with given IP. +int Netplay_startHost(const char* game_name, uint32_t game_crc, const char* hotspot_ip); +int Netplay_stopHost(void); +int Netplay_stopHostFast(void); +void Netplay_stopBroadcast(void); // Stop UDP broadcast but keep session active +int Netplay_connectToHost(const char* ip, uint16_t port); +void Netplay_disconnect(void); + +// Hotspot mode +bool Netplay_isUsingHotspot(void); + +// Status queries +NetplayMode Netplay_getMode(void); +NetplayState Netplay_getState(void); +bool Netplay_isConnected(void); +bool Netplay_isActive(void); +const char* Netplay_getStatusMessage(void); +const char* Netplay_getLocalIP(void); +bool Netplay_hasNetworkConnection(void); + +// Host discovery (for client) +int Netplay_startDiscovery(void); +void Netplay_stopDiscovery(void); +int Netplay_getDiscoveredHosts(NetplayHostInfo* hosts, int max_hosts); + +// Frame synchronization (RetroArch-style) +// Called at the start of each frame - handles network polling and sync +bool Netplay_preFrame(void); + +// Get inputs for a specific player (called by input_state_callback) +uint16_t Netplay_getInputState(unsigned port); + +// Get player buttons with netplay handling +// Returns synchronized netplay input if connected, otherwise local_buttons for port 0 +uint32_t Netplay_getPlayerButtons(unsigned port, uint32_t local_buttons); + +// Set local player's input for current frame +void Netplay_setLocalInput(uint16_t input); + +// Called at end of each frame - sends data, advances frame counter +void Netplay_postFrame(void); + +// Check if we should skip this frame (stalled waiting for input) +bool Netplay_shouldStall(void); + +// Audio control - returns true if audio should be silenced (during stall) +bool Netplay_shouldSilenceAudio(void); + +// State synchronization +int Netplay_sendState(const void* data, size_t size); +int Netplay_receiveState(void* data, size_t size); +bool Netplay_needsStateSync(void); +void Netplay_completeStateSync(void); + +// Pause/resume for menu (keeps connection alive) +void Netplay_pause(void); // Called when entering menu +void Netplay_resume(void); // Called when exiting menu +void Netplay_pollWhilePaused(void); // Call periodically during menu to maintain connection +bool Netplay_isPaused(void); // Check if paused + +// Main loop update - handles state sync and frame synchronization +// Returns: 1 = run frame, 0 = skip frame (stalled/syncing) +// Callbacks are for core serialization (can be NULL if netplay not active) +typedef size_t (*Netplay_SerializeSizeFn)(void); +typedef bool (*Netplay_SerializeFn)(void* data, size_t size); +typedef bool (*Netplay_UnserializeFn)(const void* data, size_t size); + +int Netplay_update(uint16_t local_input, + Netplay_SerializeSizeFn serialize_size_fn, + Netplay_SerializeFn serialize_fn, + Netplay_UnserializeFn unserialize_fn); + +#endif /* NETPLAY_H */ diff --git a/workspace/all/netplay/netplay_helper.c b/workspace/all/netplay/netplay_helper.c new file mode 100644 index 000000000..09d92f44b --- /dev/null +++ b/workspace/all/netplay/netplay_helper.c @@ -0,0 +1,2886 @@ +/* + * NextUI Netplay Helper Module Implementation + * Extracted UI helpers and orchestration functions for netplay menus + */ + +#include "netplay_helper.h" +#include "defines.h" +#include "api.h" +#include "network_common.h" +#ifdef HAS_WIFIMG +#include "wifi_direct.h" +#endif +#include +#include +#include +#include +#include +#include + +////////////////////////////////////////////////////////////////////////////// +// Minarch accessor functions and types +////////////////////////////////////////////////////////////////////////////// + +// Minarch accessor and utility functions +#include "minarch.h" + +// Convenience macros for accessor functions +#define screen minarch_getScreen() +#define DEVICE_WIDTH minarch_getDeviceWidth() +#define DEVICE_HEIGHT minarch_getDeviceHeight() + +// Create a fake menu struct with just bitmap for compatibility +static struct { SDL_Surface* bitmap; } _menu_accessor; +#define menu (_menu_accessor.bitmap = minarch_getMenuBitmap(), _menu_accessor) + +// String utility functions (defined in utils.c) +extern int exactMatch(char* str1, char* str2); +extern int containsString(char* haystack, char* needle); + +////////////////////////////////////////////////////////////////////////////// +// Host Discovery State Variables +////////////////////////////////////////////////////////////////////////////// + +static NetplayHostInfo netplay_hosts[NETPLAY_MAX_HOSTS]; +static int netplay_host_count = 0; +static int netplay_selected_host = 0; +int netplay_force_resume = 0; +int netplay_connected_to_hotspot = 0; + +static GBALinkHostInfo gbalink_hosts[GBALINK_MAX_HOSTS]; +static int gbalink_host_count = 0; +int gbalink_connected_to_hotspot = 0; +int gbalink_force_resume = 0; + +static GBLinkHostInfo gblink_hosts[GBLINK_MAX_HOSTS]; +static int gblink_host_count = 0; +int gblink_connected_to_hotspot = 0; +int gblink_force_resume = 0; + +// Store the hotspot SSID client connected to (shared - only one game runs at a time) +char connected_hotspot_ssid[33] = {0}; + +////////////////////////////////////////////////////////////////////////////// +// WiFi/Network Helpers +////////////////////////////////////////////////////////////////////////////// + +#include "keyboard.h" + +// Launch on-screen keyboard for password input +static char* launchKeyboard(void) { + return Keyboard_getPassword(); +} + +// Get signal strength indicator string based on RSSI +// RSSI: typically -30 (excellent) to -90 (poor) +static const char* getSignalStrengthIndicator(int rssi) { + if (rssi >= -50) return "[####]"; // Excellent + else if (rssi >= -60) return "[### ]"; // Good + else if (rssi >= -70) return "[## ]"; // Fair + else if (rssi >= -80) return "[# ]"; // Weak + else return "[ ]"; // Very weak +} + +// Help dialog entries +typedef struct { + const char* symbol; + const char* description; +} WiFiHelpEntry; + +static const WiFiHelpEntry wifi_help_entries[] = { + {"[C]", "Currently connected"}, + {"[*]", "Saved (auto-connect)"}, + {"[L]", "Locked (needs password)"}, + {"[####]", "Excellent signal"}, + {"[### ]", "Good signal"}, + {"[## ]", "Fair signal"}, + {"[# ]", "Weak signal"}, + {NULL, NULL} +}; + +// Render WiFi help dialog overlay +static void renderWiFiHelpDialog(void) { + int hw = screen->w; + int hh = screen->h; + + // Count entries + int entry_count = 0; + while (wifi_help_entries[entry_count].symbol != NULL) { + entry_count++; + } + + // Dialog dimensions + int line_height = SCALE1(22); + int box_w = SCALE1(260); + int box_h = SCALE1(70) + (entry_count * line_height); + int box_x = (hw - box_w) / 2; + int box_y = (hh - box_h) / 2; + + // Dark overlay around dialog + SDL_Rect overlay = {0, 0, hw, hh}; + SDL_FillRect(screen, &overlay, SDL_MapRGB(screen->format, 0, 0, 0)); + + // Box background + SDL_Rect box = {box_x, box_y, box_w, box_h}; + SDL_FillRect(screen, &box, SDL_MapRGB(screen->format, 32, 32, 32)); + + // Box border + SDL_Rect border_top = {box_x, box_y, box_w, SCALE1(2)}; + SDL_Rect border_bot = {box_x, box_y + box_h - SCALE1(2), box_w, SCALE1(2)}; + SDL_Rect border_left = {box_x, box_y, SCALE1(2), box_h}; + SDL_Rect border_right = {box_x + box_w - SCALE1(2), box_y, SCALE1(2), box_h}; + SDL_FillRect(screen, &border_top, SDL_MapRGB(screen->format, 255, 255, 255)); + SDL_FillRect(screen, &border_bot, SDL_MapRGB(screen->format, 255, 255, 255)); + SDL_FillRect(screen, &border_left, SDL_MapRGB(screen->format, 255, 255, 255)); + SDL_FillRect(screen, &border_right, SDL_MapRGB(screen->format, 255, 255, 255)); + + int left_margin = box_x + SCALE1(20); + int right_col = box_x + SCALE1(80); + + // Title + SDL_Surface* title_surf = TTF_RenderUTF8_Blended(font.medium, "WiFi Symbols", COLOR_WHITE); + if (title_surf) { + SDL_BlitSurface(title_surf, NULL, screen, &(SDL_Rect){left_margin, box_y + SCALE1(12)}); + SDL_FreeSurface(title_surf); + } + + // Entries + int y_offset = box_y + SCALE1(42); + for (int i = 0; i < entry_count; i++) { + // Symbol + SDL_Surface* sym_surf = TTF_RenderUTF8_Blended(font.small, wifi_help_entries[i].symbol, COLOR_WHITE); + if (sym_surf) { + SDL_BlitSurface(sym_surf, NULL, screen, &(SDL_Rect){left_margin, y_offset}); + SDL_FreeSurface(sym_surf); + } + + // Description + SDL_Surface* desc_surf = TTF_RenderUTF8_Blended(font.small, wifi_help_entries[i].description, COLOR_GRAY); + if (desc_surf) { + SDL_BlitSurface(desc_surf, NULL, screen, &(SDL_Rect){right_col, y_offset}); + SDL_FreeSurface(desc_surf); + } + + y_offset += line_height; + } + + // Hint at bottom + SDL_Surface* hint_surf = TTF_RenderUTF8_Blended(font.tiny, "Press any button to close", COLOR_GRAY); + if (hint_surf) { + int hint_x = box_x + (box_w - hint_surf->w) / 2; + SDL_BlitSurface(hint_surf, NULL, screen, &(SDL_Rect){hint_x, box_y + box_h - SCALE1(18)}); + SDL_FreeSurface(hint_surf); + } + + GFX_flip(screen); +} + +// Show WiFi help dialog - blocks until user presses any button +static void showWiFiHelpDialog(void) { + renderWiFiHelpDialog(); + + // Wait for any button press + while (1) { + GFX_startFrame(); + PAD_poll(); + + if (PAD_justPressed(BTN_A) || PAD_justPressed(BTN_B) || + PAD_justPressed(BTN_MENU) || PAD_justPressed(BTN_UP) || + PAD_justPressed(BTN_DOWN) || PAD_justPressed(BTN_LEFT) || + PAD_justPressed(BTN_RIGHT)) { + break; + } + + PWR_update(NULL, NULL, minarch_beforeSleep, minarch_afterSleep); + minarch_hdmimon(); + } +} + +// Render WiFi network selection list +// connected_ssid: SSID of currently connected network (or NULL if not connected) +#ifdef HAS_WIFIMG +static void renderWiFiNetworkList(WIFI_direct_network_t* networks, int count, int selected, const char* connected_ssid) { + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + + SDL_Surface* text; + int text_w; + int center_x = screen->w / 2; + + // Title + int title_y = SCALE1(60); + text = TTF_RenderUTF8_Blended(font.large, "Select WiFi Network", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, title_y}); + SDL_FreeSurface(text); + + // Instruction + int instruction_y = title_y + SCALE1(22); + text = TTF_RenderUTF8_Blended(font.small, "Choose a network to use", COLOR_GRAY); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, instruction_y}); + SDL_FreeSurface(text); + + int list_start_y = instruction_y + SCALE1(35); // Extra space for up arrow indicator + int max_visible = 3; // Limited to 3 to prevent overlap with button hints + + // Show "Scanning..." message if no networks found yet + if (count <= 0) { + int scanning_y = list_start_y + SCALE1(PILL_SIZE * 2); + text = TTF_RenderUTF8_Blended(font.medium, "Scanning for networks...", COLOR_GRAY); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, scanning_y}); + SDL_FreeSurface(text); + + GFX_blitButtonGroup((char*[]){ "B","BACK", NULL }, 0, screen, 1); + GFX_flip(screen); + return; + } + + // Calculate visible range for scrolling + int start_idx = 0; + if (count > max_visible) { + // Center selection in the visible area when possible + start_idx = selected - max_visible / 2; + if (start_idx < 0) start_idx = 0; + if (start_idx + max_visible > count) start_idx = count - max_visible; + } + int visible_count = (count > max_visible) ? max_visible : count; + + // Network list with pills + for (int j = 0; j < visible_count; j++) { + int idx = start_idx + j; + + // Build network label: [status] ssid [signal] + // Status: [C] = Connected, [*] = Saved credentials, [L] = Locked, [ ] = Open + char label[128]; + bool is_connected; + bool has_creds; + bool is_secured; + const char* signal; + const char* ssid; + + WIFI_direct_network_t* net = &networks[idx]; + ssid = net->ssid; + is_connected = (connected_ssid && strcmp(ssid, connected_ssid) == 0); + has_creds = net->has_saved_creds; + is_secured = net->is_secured; + signal = getSignalStrengthIndicator(net->rssi); + + const char* status; + if (is_connected) { + status = "[C]"; // Currently connected + } else if (has_creds) { + status = "[*]"; // Has saved credentials + } else if (is_secured) { + status = "[L]"; // Locked (needs password) + } else { + status = " "; // Open network + } + + snprintf(label, sizeof(label), "%s %s %s", status, ssid, signal); + + SDL_Color text_color = COLOR_WHITE; + if (idx == selected) { + text_color = uintToColour(THEME_COLOR5_255); + int ow; + TTF_SizeUTF8(font.medium, label, &ow, NULL); + ow += SCALE1(BUTTON_PADDING * 2); + // Cap max width + int max_pill_w = DEVICE_WIDTH - SCALE1(PADDING * 4); + if (ow > max_pill_w) ow = max_pill_w; + GFX_blitPillDark(ASSET_WHITE_PILL, screen, &(SDL_Rect){ + center_x - ow/2, + list_start_y + j * SCALE1(PILL_SIZE), + ow, + SCALE1(PILL_SIZE) + }); + } + + text = TTF_RenderUTF8_Blended(font.medium, label, text_color); + text_w = text->w; + // Cap display width + int max_text_w = DEVICE_WIDTH - SCALE1(PADDING * 4); + if (text_w > max_text_w) { + SDL_Rect src_rect = {0, 0, max_text_w, text->h}; + SDL_BlitSurface(text, &src_rect, screen, &(SDL_Rect){center_x - max_text_w/2, list_start_y + j * SCALE1(PILL_SIZE) + SCALE1(4)}); + } else { + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, list_start_y + j * SCALE1(PILL_SIZE) + SCALE1(4)}); + } + SDL_FreeSurface(text); + } + + // Scroll indicators if needed + if (count > max_visible) { + if (start_idx > 0) { + // Show up arrow indicator - more networks above + char up_hint[32]; + snprintf(up_hint, sizeof(up_hint), "▲ %d more", start_idx); + text = TTF_RenderUTF8_Blended(font.tiny, up_hint, COLOR_GRAY); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, list_start_y - SCALE1(15)}); + SDL_FreeSurface(text); + } + if (start_idx + max_visible < count) { + // Show down arrow indicator - more networks below + int remaining = count - (start_idx + max_visible); + char down_hint[32]; + snprintf(down_hint, sizeof(down_hint), "▼ %d more", remaining); + text = TTF_RenderUTF8_Blended(font.tiny, down_hint, COLOR_GRAY); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, list_start_y + visible_count * SCALE1(PILL_SIZE) - SCALE1(2)}); + SDL_FreeSurface(text); + } + } + + GFX_blitButtonGroup((char*[]){ "MENU","HELP", NULL }, 0, screen, 0); // Left aligned + GFX_blitButtonGroup((char*[]){ "B","BACK", "A","SELECT", NULL }, 1, screen, 1); // Right aligned + GFX_flip(screen); +} +#endif // HAS_WIFIMG + +// Show WiFi network selection UI +// Returns true if user successfully connects (or confirms current connection), false if cancelled/failed +static bool showWiFiNetworkSelection(void) { +#ifdef HAS_WIFIMG + // Use WIFI_direct functions for more reliable WiFi operations + + // Ensure WiFi hardware is ready + if (!WIFI_direct_ensureReady()) { + minarch_menuMessage("Failed to initialize WiFi.\n\nPlease try again.", + (char*[]){ "A","OKAY", NULL }); + return false; + } + + // Check if already connected to a WiFi network + char connected_ssid_buf[WIFI_DIRECT_SSID_MAX] = {0}; + const char* connected_ssid = NULL; + + if (WIFI_direct_isConnected()) { + if (WIFI_direct_getCurrentSSID(connected_ssid_buf, sizeof(connected_ssid_buf)) == 0) { + connected_ssid = connected_ssid_buf; + } + } + + // Network list - continuously updated + WIFI_direct_network_t networks[16]; + int count = 0; + int selected = 0; + int dirty = 1; + bool first_selection_done = false; + + // Scan timing - separate trigger from reading to avoid blocking UI + uint32_t last_scan_trigger_time = 0; + uint32_t scan_trigger_interval_ms = 4000; // Trigger new scan every 4 seconds + uint32_t scan_read_delay_ms = 1500; // Wait 1.5s after trigger before reading + bool scan_pending = false; + + // Overall timeout to prevent hanging + uint32_t start_time = SDL_GetTicks(); + uint32_t max_duration_ms = 120000; // 120 seconds (2 minutes) max on this screen + + // Trigger initial scan immediately (non-blocking) + WIFI_direct_triggerScan(); + last_scan_trigger_time = SDL_GetTicks(); + scan_pending = true; + + while (1) { + uint32_t now = SDL_GetTicks(); + + // Check for overall timeout + if (now - start_time > max_duration_ms) { + minarch_menuMessage("WiFi selection timed out.\n\nPlease try again.", + (char*[]){ "A","OKAY", NULL }); + return false; + } + + // Read scan results after delay (non-blocking read of cached results) + if (scan_pending && (now - last_scan_trigger_time >= scan_read_delay_ms)) { + scan_pending = false; + + int new_count = WIFI_direct_scanNetworks(networks, 16); + + if (new_count != count || new_count > 0) { + count = new_count; + dirty = 1; + + // Auto-select best network on first successful scan + if (count > 0 && !first_selection_done) { + first_selection_done = true; + + // Find the best network to pre-select + int preselect_idx = -1; + int best_saved_idx = -1; + int best_rssi = -999; + + for (int i = 0; i < count; i++) { + if (connected_ssid && strcmp(networks[i].ssid, connected_ssid) == 0) { + preselect_idx = i; + } + if (networks[i].has_saved_creds && networks[i].rssi > best_rssi) { + best_rssi = networks[i].rssi; + best_saved_idx = i; + } + } + + if (preselect_idx >= 0) { + selected = preselect_idx; + } else if (best_saved_idx >= 0) { + selected = best_saved_idx; + } else { + selected = 0; + } + } + + // Keep selection in bounds + if (selected >= count && count > 0) { + selected = count - 1; + } + } + } + + // Trigger periodic rescan (non-blocking) + if (!scan_pending && (now - last_scan_trigger_time >= scan_trigger_interval_ms)) { + WIFI_direct_triggerScan(); + last_scan_trigger_time = now; + scan_pending = true; + } + + GFX_startFrame(); + PAD_poll(); + + if (PAD_justPressed(BTN_B)) { + return false; // Cancel + } + + // Show help dialog when MENU is pressed + if (PAD_justPressed(BTN_MENU)) { + showWiFiHelpDialog(); + dirty = 1; // Redraw after dialog closes + } + + // Navigation (only if we have networks) + if (count > 0) { + if (PAD_justRepeated(BTN_UP)) { + selected--; + if (selected < 0) selected = count - 1; + dirty = 1; + } + else if (PAD_justRepeated(BTN_DOWN)) { + selected++; + if (selected >= count) selected = 0; + dirty = 1; + } + else if (PAD_justPressed(BTN_A)) { + // Selected network + WIFI_direct_network_t* net = &networks[selected]; + + // Check if user selected the already-connected network + if (connected_ssid && strcmp(net->ssid, connected_ssid) == 0) { + // Already connected to this network + // Verify we have an IP, request DHCP if needed + showOverlayMessage("Verifying connection..."); + + // Check if we already have an IP + char ip[16] = {0}; + WIFI_direct_getIP(ip, sizeof(ip)); + + if (ip[0] == '\0' || strcmp(ip, "0.0.0.0") == 0) { + // No IP yet, request DHCP and wait + system("udhcpc -i wlan0 -q -t 5 >/dev/null 2>&1"); + + // Wait for IP with timeout + for (int i = 0; i < 10; i++) { + SDL_Delay(500); + WIFI_direct_getIP(ip, sizeof(ip)); + if (ip[0] != '\0' && strcmp(ip, "0.0.0.0") != 0) { + break; + } + } + } + + return true; + } + + if (net->has_saved_creds || !net->is_secured) { + // Connect with saved credentials or open network + showOverlayMessage("Connecting..."); + int ret = WIFI_direct_connect(net->ssid, NULL); // NULL = use saved creds + + if (ret == 0) { + // Wait for DHCP to assign IP + showOverlayMessage("Getting IP address..."); + char ip[16] = {0}; + bool got_ip = false; + + // First check if we already have IP + WIFI_direct_getIP(ip, sizeof(ip)); + if (ip[0] != '\0' && strcmp(ip, "0.0.0.0") != 0) { + got_ip = true; + } else { + // Request DHCP + system("udhcpc -i wlan0 -q -t 5 >/dev/null 2>&1"); + // Wait for IP with timeout + for (int i = 0; i < 20; i++) { // 10 second timeout + SDL_Delay(500); + WIFI_direct_getIP(ip, sizeof(ip)); + if (ip[0] != '\0' && strcmp(ip, "0.0.0.0") != 0) { + got_ip = true; + break; + } + } + } + + if (got_ip) { + return true; + } + minarch_menuMessage("Connected but no IP.\n\nPlease try again.", + (char*[]){ "A","OKAY", NULL }); + } else { + minarch_menuMessage("Connection failed.\n\nPlease check the network\nand try again.", + (char*[]){ "A","OKAY", NULL }); + } + dirty = 1; + // Force rescan + WIFI_direct_triggerScan(); + last_scan_trigger_time = SDL_GetTicks(); + scan_pending = true; + } else { + // Need password - launch keyboard + char* password = launchKeyboard(); + if (password) { + showOverlayMessage("Connecting..."); + int ret = WIFI_direct_connect(net->ssid, password); + free(password); + + if (ret == 0) { + // Wait for DHCP to assign IP + showOverlayMessage("Getting IP address..."); + char ip[16] = {0}; + bool got_ip = false; + + // Request DHCP + system("udhcpc -i wlan0 -q -t 5 >/dev/null 2>&1"); + // Wait for IP with timeout + for (int i = 0; i < 20; i++) { // 10 second timeout + SDL_Delay(500); + WIFI_direct_getIP(ip, sizeof(ip)); + if (ip[0] != '\0' && strcmp(ip, "0.0.0.0") != 0) { + got_ip = true; + break; + } + } + + if (got_ip) { + return true; + } + minarch_menuMessage("Connected but no IP.\n\nPlease try again.", + (char*[]){ "A","OKAY", NULL }); + } else { + minarch_menuMessage("Connection failed.\n\nIncorrect password or\nnetwork unavailable.", + (char*[]){ "A","OKAY", NULL }); + } + } + dirty = 1; + // Force rescan + WIFI_direct_triggerScan(); + last_scan_trigger_time = SDL_GetTicks(); + scan_pending = true; + } + } + } + + PWR_update(&dirty, NULL, minarch_beforeSleep, minarch_afterSleep); + + if (dirty) { + renderWiFiNetworkList(networks, count, selected, connected_ssid); + dirty = 0; + } + + minarch_hdmimon(); + } + +#else + // WiFi network selection not available on this platform + minarch_menuMessage("WiFi not available\non this platform.", + (char*[]){ "A","OKAY", NULL }); + return false; +#endif +} + +bool ensureWifiEnabled(void) { +#ifdef HAS_WIFIMG + // Check if WiFi is already ready (avoid showing message if already enabled) + if (WIFI_direct_isConnected()) { + return true; // Already connected, no need to enable + } + + // Check if wpa_supplicant is running (quick check without full ensureReady) + int ret = system("pidof wpa_supplicant > /dev/null 2>&1"); + if (ret == 0) { + // wpa_supplicant running, WiFi is likely ready + return true; + } + + // Show enabling message only when we actually need to enable WiFi + GFX_setMode(MODE_MAIN); + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + { + SDL_Surface* text = TTF_RenderUTF8_Blended(font.medium, "Enabling WiFi...", COLOR_WHITE); + int text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){screen->w/2 - text_w/2, screen->h/2}); + SDL_FreeSurface(text); + } + GFX_flip(screen); + + // Use WIFI_direct to ensure WiFi is ready + bool ready = WIFI_direct_ensureReady(); + + GFX_setMode(MODE_MENU); + + if (!ready) { + minarch_menuMessage("Failed to enable WiFi.\nPlease try again.", (char*[]){ "A","OKAY", NULL }); + return false; + } + + return true; +#else + minarch_menuMessage("WiFi not available\non this platform.", (char*[]){ "A","OKAY", NULL }); + return false; +#endif +} + +bool ensureNetworkConnected(LinkType type, const char* action) { + (void)action; // Currently unused after adding WiFi selection + + // Always show WiFi selection so user can confirm or change network + // If already connected, that network will be pre-selected + // User can either confirm current connection or switch to another + if (!showWiFiNetworkSelection()) { + return false; // User cancelled + } + + // Verify connection after user selection + bool connected = false; + switch (type) { + case LINK_TYPE_NETPLAY: connected = Netplay_hasNetworkConnection(); break; + case LINK_TYPE_GBALINK: connected = GBALink_hasNetworkConnection(); break; + case LINK_TYPE_GBLINK: connected = GBLink_hasNetworkConnection(); break; + } + return connected; +} + +// Structure for async hotspot stop + WiFi restore +typedef struct { + bool stop_hotspot; // true if host (need to call WIFI_direct_stopHotspot) + char hotspot_ssid[33]; +} HotspotStopArgs; + +static void* hotspot_stop_thread(void* arg) { + HotspotStopArgs* args = (HotspotStopArgs*)arg; + +#ifdef HAS_WIFIMG + if (args->stop_hotspot) { + WIFI_direct_stopHotspot(); + } + + // Forget every saved hotspot network (this session's plus any left behind by + // earlier sessions that didn't tear down cleanly), and re-enable other networks + // so wpa_supplicant.conf doesn't accumulate stale NextUI-* entries over time. + WIFI_direct_forgetAllHotspots(); + + // Restore previous WiFi connection + WIFI_direct_restorePreviousConnection(); +#endif + + free(args); + return NULL; +} + +void stopHotspotAndRestoreWiFiAsync(bool is_host) { + HotspotStopArgs* args = malloc(sizeof(HotspotStopArgs)); + if (!args) { + LOG_error("stopHotspotAndRestoreWiFiAsync: failed to allocate args\n"); + return; + } + + args->stop_hotspot = is_host; + + // Copy and clear the connected hotspot SSID + strncpy(args->hotspot_ssid, connected_hotspot_ssid, sizeof(args->hotspot_ssid) - 1); + args->hotspot_ssid[sizeof(args->hotspot_ssid) - 1] = '\0'; + connected_hotspot_ssid[0] = '\0'; + + // Spawn detached thread + pthread_t thread; + pthread_attr_t attr; + pthread_attr_init(&attr); + pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); + + if (pthread_create(&thread, &attr, hotspot_stop_thread, args) != 0) { + LOG_error("stopHotspotAndRestoreWiFiAsync: failed to create thread\n"); + free(args); + } + + pthread_attr_destroy(&attr); +} + +////////////////////////////////////////////////////////////////////////////// +// UI Helpers +////////////////////////////////////////////////////////////////////////////// + +void showOverlayMessage(const char* msg) { + GFX_setMode(MODE_MAIN); + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + SDL_Surface* text = TTF_RenderUTF8_Blended(font.medium, msg, COLOR_WHITE); + int text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){screen->w/2 - text_w/2, screen->h/2}); + SDL_FreeSurface(text); + GFX_flip(screen); + GFX_setMode(MODE_MENU); +} + +void showConnectedSuccess(uint32_t timeout_ms) { + uint32_t start_time = SDL_GetTicks(); + GFX_setMode(MODE_MAIN); + while (SDL_GetTicks() - start_time < timeout_ms) { + GFX_startFrame(); + PAD_poll(); + if (PAD_justPressed(BTN_A)) break; + + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + + SDL_Surface* text; + int text_w; + int center_x = screen->w / 2; + int center_y = screen->h / 2; + + text = TTF_RenderUTF8_Blended(font.large, "Connected!", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, center_y - SCALE1(20)}); + SDL_FreeSurface(text); + + text = TTF_RenderUTF8_Blended(font.medium, "Starting game...", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, center_y + SCALE1(20)}); + SDL_FreeSurface(text); + + GFX_flip(screen); + minarch_hdmimon(); + } + GFX_setMode(MODE_MENU); +} + +int Menu_selectConnectionMode(const char* title) { + int selected = 0; + int dirty = 1; + const char* modes[] = { "Hotspot", "WiFi" }; + int mode_count = 2; + + while (1) { + GFX_startFrame(); + PAD_poll(); + + if (PAD_justPressed(BTN_B)) { + return -1; // Cancelled + } + + if (PAD_justRepeated(BTN_UP)) { + selected--; + if (selected < 0) selected = mode_count - 1; + dirty = 1; + } + else if (PAD_justRepeated(BTN_DOWN)) { + selected++; + if (selected >= mode_count) selected = 0; + dirty = 1; + } + else if (PAD_justPressed(BTN_A)) { + return selected; // 0 = Hotspot, 1 = WiFi + } + + PWR_update(&dirty, NULL, minarch_beforeSleep, minarch_afterSleep); + + if (dirty) { + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + + SDL_Surface* text; + int text_w; + int center_x = screen->w / 2; + + // Title + int title_y = SCALE1(60); + text = TTF_RenderUTF8_Blended(font.large, title, COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, title_y}); + SDL_FreeSurface(text); + + // Instruction + int instruction_y = title_y + SCALE1(30); + text = TTF_RenderUTF8_Blended(font.medium, "Select connection mode:", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, instruction_y}); + SDL_FreeSurface(text); + + // Subtitle hint + int subtitle_y = instruction_y + SCALE1(20); + text = TTF_RenderUTF8_Blended(font.small, "Use hotspot for better gameplay", COLOR_GRAY); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, subtitle_y}); + SDL_FreeSurface(text); + + // Mode list with pills + int list_start_y = subtitle_y + SCALE1(25); + for (int j = 0; j < mode_count; j++) { + SDL_Color text_color = COLOR_WHITE; + if (j == selected) { + text_color = uintToColour(THEME_COLOR5_255); + int ow; + TTF_SizeUTF8(font.large, modes[j], &ow, NULL); + ow += SCALE1(BUTTON_PADDING * 2); + GFX_blitPillDark(ASSET_WHITE_PILL, screen, &(SDL_Rect){ + center_x - ow/2, + list_start_y + j * SCALE1(PILL_SIZE), + ow, + SCALE1(PILL_SIZE) + }); + } + + text = TTF_RenderUTF8_Blended(font.large, modes[j], text_color); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, list_start_y + j * SCALE1(PILL_SIZE) + SCALE1(4)}); + SDL_FreeSurface(text); + } + + GFX_blitButtonGroup((char*[]){ "B","BACK", "A","SELECT", NULL }, 1, screen, 1); + GFX_flip(screen); + dirty = 0; + } + + minarch_hdmimon(); + } +} + +////////////////////////////////////////////////////////////////////////////// +// Host Discovery State Accessors +////////////////////////////////////////////////////////////////////////////// + +const char* getHostGameName(LinkType type, int index) { + switch (type) { + case LINK_TYPE_NETPLAY: return netplay_hosts[index].game_name; + case LINK_TYPE_GBALINK: return gbalink_hosts[index].game_name; + case LINK_TYPE_GBLINK: return gblink_hosts[index].game_name; + } + return ""; +} + +const char* getHostIP(LinkType type, int index) { + switch (type) { + case LINK_TYPE_NETPLAY: return netplay_hosts[index].host_ip; + case LINK_TYPE_GBALINK: return gbalink_hosts[index].host_ip; + case LINK_TYPE_GBLINK: return gblink_hosts[index].host_ip; + } + return ""; +} + +int getHostPort(LinkType type, int index) { + switch (type) { + case LINK_TYPE_NETPLAY: return netplay_hosts[index].port; + case LINK_TYPE_GBALINK: return gbalink_hosts[index].port; + case LINK_TYPE_GBLINK: return gblink_hosts[index].port; + } + return 0; +} + +const char* getHostLinkMode(LinkType type, int index) { + switch (type) { + case LINK_TYPE_GBALINK: return gbalink_hosts[index].link_mode; + default: return ""; // Only GBALink uses link_mode + } +} + +int getHostCount(LinkType type) { + switch (type) { + case LINK_TYPE_NETPLAY: return netplay_host_count; + case LINK_TYPE_GBALINK: return gbalink_host_count; + case LINK_TYPE_GBLINK: return gblink_host_count; + } + return 0; +} + +void setHostCount(LinkType type, int count) { + switch (type) { + case LINK_TYPE_NETPLAY: netplay_host_count = count; break; + case LINK_TYPE_GBALINK: gbalink_host_count = count; break; + case LINK_TYPE_GBLINK: gblink_host_count = count; break; + } +} + +int isLinkConnected(LinkType type) { + switch (type) { + case LINK_TYPE_NETPLAY: return Netplay_getMode() != NETPLAY_OFF; + case LINK_TYPE_GBALINK: return GBALink_getMode() != GBALINK_OFF; + case LINK_TYPE_GBLINK: return GBLink_getMode() != GBLINK_OFF; + } + return 0; +} + +int* getForceResumeFlag(LinkType type) { + switch (type) { + case LINK_TYPE_NETPLAY: return &netplay_force_resume; + case LINK_TYPE_GBALINK: return &gbalink_force_resume; + case LINK_TYPE_GBLINK: return &gblink_force_resume; + } + return NULL; +} + +int Multiplayer_isActive(void) { + return GBALink_isConnected() || GBLink_isConnected() || Netplay_isConnected(); +} + +CoreLinkSupport checkCoreLinkSupport(const char* core_name) { + CoreLinkSupport support = {false, false, false}; + + if (Netplay_checkCoreSupport(core_name)) { + support.show_netplay = true; + } + if (GBALink_checkCoreSupport(core_name)) { + support.has_netpacket = true; + support.show_netplay = true; + } + if (GBLink_checkCoreSupport(core_name)) { + support.has_gblink = true; + support.show_netplay = true; + } + + return support; +} + +////////////////////////////////////////////////////////////////////////////// +// Rendering Helpers +////////////////////////////////////////////////////////////////////////////// + +void showSearchingScreen(void) { + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + SDL_Surface* text = TTF_RenderUTF8_Blended(font.medium, "Searching for hosts...", COLOR_WHITE); + int text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){screen->w/2 - text_w/2, screen->h/2}); + SDL_FreeSurface(text); + GFX_blitButtonGroup((char*[]){ "B","CANCEL", NULL }, 0, screen, 1); + GFX_flip(screen); +} + +void showConnectingScreen(const char* host_ip) { + char msg[256]; + snprintf(msg, sizeof(msg), "Connecting to %s...", host_ip); + GFX_setMode(MODE_MAIN); + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + SDL_Surface* text = TTF_RenderUTF8_Blended(font.medium, msg, COLOR_WHITE); + int text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){screen->w/2 - text_w/2, screen->h/2}); + SDL_FreeSurface(text); + GFX_flip(screen); + GFX_setMode(MODE_MENU); +} + +void renderHostSelectionList(LinkType type, int selected, int host_count) { + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + + SDL_Surface* text; + int text_w; + int center_x = screen->w / 2; + + // Title + int title_y = SCALE1(60); + text = TTF_RenderUTF8_Blended(font.large, "Select Host", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, title_y}); + SDL_FreeSurface(text); + + // Host list with pills + int list_start_y = title_y + SCALE1(40); + for (int j = 0; j < host_count; j++) { + // Format: "Game Name (IP)" + char host_label[128]; + snprintf(host_label, sizeof(host_label), "%s (%s)", + getHostGameName(type, j), getHostIP(type, j)); + + SDL_Color text_color = COLOR_WHITE; + if (j == selected) { + text_color = uintToColour(THEME_COLOR5_255); + int ow; + TTF_SizeUTF8(font.medium, host_label, &ow, NULL); + ow += SCALE1(BUTTON_PADDING * 2); + GFX_blitPillDark(ASSET_WHITE_PILL, screen, &(SDL_Rect){ + center_x - ow/2, + list_start_y + j * SCALE1(PILL_SIZE), + ow, + SCALE1(PILL_SIZE) + }); + } + + text = TTF_RenderUTF8_Blended(font.medium, host_label, text_color); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, list_start_y + j * SCALE1(PILL_SIZE) + SCALE1(4)}); + SDL_FreeSurface(text); + } + + GFX_blitButtonGroup((char*[]){ "B","BACK", "A","SELECT", NULL }, 1, screen, 1); + GFX_flip(screen); +} + +void renderHotspotWaitingScreen(const char* code) { + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + + SDL_Surface* text; + int text_w, text_h; + int center_x = screen->w / 2; + int center_y = screen->h / 2; + + // Large code in pill (centered, prominent) + TTF_SizeUTF8(font.large, code, &text_w, &text_h); + int pill_w = text_w + SCALE1(BUTTON_PADDING * 2); + int pill_y = center_y - text_h - SCALE1(4); + GFX_blitPillDark(ASSET_WHITE_PILL, screen, &(SDL_Rect){ + center_x - pill_w/2, + pill_y, + pill_w, + SCALE1(PILL_SIZE) + }); + text = TTF_RenderUTF8_Blended(font.large, code, uintToColour(THEME_COLOR5_255)); + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, pill_y + SCALE1(4)}); + SDL_FreeSurface(text); + + // Medium instruction + text = TTF_RenderUTF8_Blended(font.medium, "Select this code on the other device", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, center_y + SCALE1(5)}); + SDL_FreeSurface(text); + + // Small status + text = TTF_RenderUTF8_Blended(font.small, "Waiting for connection...", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, center_y + SCALE1(28)}); + SDL_FreeSurface(text); +} + +void renderWiFiWaitingScreen(const char* ip) { + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + + SDL_Surface* text; + int text_w, text_h; + int center_x = screen->w / 2; + int center_y = screen->h / 2; + + // Large IP (centered, prominent) + text = TTF_RenderUTF8_Blended(font.large, ip, COLOR_WHITE); + text_w = text->w; + text_h = text->h; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, center_y - text_h}); + SDL_FreeSurface(text); + + // Medium instruction + text = TTF_RenderUTF8_Blended(font.medium, "Waiting for player to join...", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, center_y + SCALE1(5)}); + SDL_FreeSurface(text); + + // Small hint + text = TTF_RenderUTF8_Blended(font.small, "Other device must be on the same WiFi network", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, center_y + SCALE1(28)}); + SDL_FreeSurface(text); +} + +void showConnectionSuccessScreen(void) { + uint32_t start_time = SDL_GetTicks(); + GFX_setMode(MODE_MAIN); + while (SDL_GetTicks() - start_time < 3000) { + GFX_startFrame(); + PAD_poll(); + if (PAD_justPressed(BTN_A)) break; + + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + + SDL_Surface* text; + int text_w; + int center_x = screen->w / 2; + int center_y = screen->h / 2; + + text = TTF_RenderUTF8_Blended(font.large, "Connected!", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, center_y - SCALE1(20)}); + SDL_FreeSurface(text); + + text = TTF_RenderUTF8_Blended(font.medium, "Starting game...", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, center_y + SCALE1(20)}); + SDL_FreeSurface(text); + + GFX_flip(screen); + minarch_hdmimon(); + } + GFX_setMode(MODE_MENU); +} + +////////////////////////////////////////////////////////////////////////////// +// Utility Functions +////////////////////////////////////////////////////////////////////////////// + +uint32_t calculateGameCRC(void) { + uint32_t crc = 0; + void* game_data = minarch_getGameData(); + size_t game_size = minarch_getGameSize(); + if (game_data && game_size > 0) { + const uint8_t* data = (const uint8_t*)game_data; + for (size_t j = 0; j < game_size && j < 1024; j++) { + crc = (crc << 1) ^ data[j]; + } + } + return crc; +} + +void getGameName(char* buf, size_t buf_size) { + const char* game_name = minarch_getGameName(); + if (game_name[0]) { + strncpy(buf, game_name, buf_size - 1); + buf[buf_size - 1] = '\0'; + // Remove extension if present + char* dot = strrchr(buf, '.'); + if (dot) { + *dot = '\0'; + } + } else { + strncpy(buf, "Unknown Game", buf_size - 1); + buf[buf_size - 1] = '\0'; + } +} + +void showTransitionMessage(const char* message) { + GFX_setMode(MODE_MAIN); + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + { + SDL_Surface* text = TTF_RenderUTF8_Blended(font.medium, message, COLOR_WHITE); + int text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){screen->w/2 - text_w/2, screen->h/2}); + SDL_FreeSurface(text); + } + GFX_flip(screen); +} + +void showTimedConfirmation(const char* message, int duration_ms) { + uint32_t start_time = SDL_GetTicks(); + GFX_setMode(MODE_MAIN); + while (SDL_GetTicks() - start_time < (uint32_t)duration_ms) { + GFX_startFrame(); + PAD_poll(); + if (PAD_justPressed(BTN_A) || PAD_justPressed(BTN_B)) break; + + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + + SDL_Surface* text; + int text_w; + int center_x = screen->w / 2; + int center_y = screen->h / 2; + + text = TTF_RenderUTF8_Blended(font.large, message, COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, center_y}); + SDL_FreeSurface(text); + + GFX_flip(screen); + minarch_hdmimon(); + } + GFX_setMode(MODE_MENU); +} + +// Show a confirmation dialog that requires A to confirm or B to cancel +// Returns true if user pressed A, false if user pressed B +static bool showConfirmDialog(const char* title, const char* message) { + GFX_setMode(MODE_MAIN); + while (1) { + GFX_startFrame(); + PAD_poll(); + + if (PAD_justPressed(BTN_A)) { + GFX_setMode(MODE_MENU); + return true; + } + if (PAD_justPressed(BTN_B)) { + GFX_setMode(MODE_MENU); + return false; + } + + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + + SDL_Surface* text; + int text_w; + int center_x = screen->w / 2; + int y = screen->h / 3; + + // Title + text = TTF_RenderUTF8_Blended(font.large, title, COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, y}); + SDL_FreeSurface(text); + y += 70; + + // Message (can contain newlines - render line by line) + char msg_copy[512]; + strncpy(msg_copy, message, sizeof(msg_copy) - 1); + msg_copy[sizeof(msg_copy) - 1] = '\0'; + char* line = strtok(msg_copy, "\n"); + while (line) { + text = TTF_RenderUTF8_Blended(font.medium, line, COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, y}); + SDL_FreeSurface(text); + y += 40; + line = strtok(NULL, "\n"); + } + + // Button hints at bottom + y = screen->h - 60; + GFX_blitButtonGroup((char*[]){ "B","CANCEL", "A","CONTINUE", NULL }, 0, screen, 1); + + GFX_flip(screen); + minarch_hdmimon(); + } +} + +// Show link mode restart dialog with specific layout +// is_host: true for host changing mode, false for client syncing to host +// Returns true if user pressed A (confirm), false if user pressed B (cancel) +static bool showLinkModeRestartDialog(const char* mode_name, bool is_host) { + GFX_setMode(MODE_MAIN); + while (1) { + GFX_startFrame(); + PAD_poll(); + + if (PAD_justPressed(BTN_A)) { + GFX_setMode(MODE_MENU); + return true; + } + if (PAD_justPressed(BTN_B)) { + GFX_setMode(MODE_MENU); + return false; + } + + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + + SDL_Surface* text; + int text_w; + int center_x = screen->w / 2; + int y = SCALE1(60); + + text = TTF_RenderUTF8_Blended(font.large, "Restart Required", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, y}); + SDL_FreeSurface(text); + y += SCALE1(30); + + // Main message - different for host vs client + if (is_host) { + text = TTF_RenderUTF8_Blended(font.medium, "Changing connectivity mode to", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, y}); + SDL_FreeSurface(text); + y += SCALE1(20); + + char mode_msg[128]; + snprintf(mode_msg, sizeof(mode_msg), "%s", mode_name); + text = TTF_RenderUTF8_Blended(font.medium, mode_msg, COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, y}); + SDL_FreeSurface(text); + y += SCALE1(20); + + text = TTF_RenderUTF8_Blended(font.medium, "requires a restart for", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, y}); + SDL_FreeSurface(text); + y += SCALE1(20); + + text = TTF_RenderUTF8_Blended(font.medium, "the changes to take effect.", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, y}); + SDL_FreeSurface(text); + y += SCALE1(20); + + text = TTF_RenderUTF8_Blended(font.medium, "Please rehost after restarting to connect.", COLOR_GRAY); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, y}); + SDL_FreeSurface(text); + } else { + text = TTF_RenderUTF8_Blended(font.medium, "Your connectivity mode doesn't match the host.", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, y}); + SDL_FreeSurface(text); + y += SCALE1(20); + + text = TTF_RenderUTF8_Blended(font.medium, "A restart is needed to sync settings.", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, y}); + SDL_FreeSurface(text); + y += SCALE1(20); + + text = TTF_RenderUTF8_Blended(font.medium, "Please rejoin after restarting to connect.", COLOR_GRAY); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, y}); + SDL_FreeSurface(text); + } + + // Button hints at bottom + GFX_blitButtonGroup((char*[]){ "B","CANCEL", "A","RESTART", NULL }, 0, screen, 1); + + GFX_flip(screen); + minarch_hdmimon(); + } +} + +void showLinkStatusMessage( + const char* title, + const char* mode_str, + const char* conn_str, + const char* state_str, + const char* code, + const char* local_ip, + const char* status_msg +) { + char msg[512]; + + if (code) { + // Hotspot host mode - show code + snprintf(msg, sizeof(msg), "%s\n\nMode: %s (%s)\nState: %s\nCode: %s\nIP: %s\n\n%s", + title, mode_str, conn_str, state_str, code, local_ip, status_msg); + } else if (conn_str[0]) { + // Connected with connection type + snprintf(msg, sizeof(msg), "%s\n\nMode: %s (%s)\nState: %s\nLocal IP: %s\n\n%s", + title, mode_str, conn_str, state_str, local_ip, status_msg); + } else { + // Not connected + snprintf(msg, sizeof(msg), "%s\n\nMode: %s\nState: %s\nLocal IP: %s\n\n%s", + title, mode_str, state_str, local_ip, status_msg); + } + + minarch_menuMessage(msg, (char*[]){ "A","OKAY", NULL }); +} + +void renderLinkMenuUI( + const char* title, + char** items, + int item_count, + int selected, + const char* (*getHint)(void) +) { + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.25f, 1, 0); + GFX_blitHardwareGroup(screen, 0); + + // Title + SDL_Surface* text; + text = TTF_RenderUTF8_Blended(font.large, title, uintToColour(THEME_COLOR6_255)); + int title_w = text->w + SCALE1(BUTTON_PADDING * 2); + GFX_blitPillLight(ASSET_WHITE_PILL, screen, &(SDL_Rect){ + SCALE1(PADDING), + SCALE1(PADDING), + title_w, + SCALE1(PILL_SIZE) + }); + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){ + SCALE1(PADDING + BUTTON_PADDING), + SCALE1(PADDING + 4) + }); + SDL_FreeSurface(text); + + // Button hints + GFX_blitButtonGroup((char*[]){ "B","BACK", "A","OKAY", NULL }, 1, screen, 1); + + // Menu items - centered vertically, shifted up by one pill size + int oy = (((DEVICE_HEIGHT / FIXED_SCALE) - PADDING * 2) - (item_count * PILL_SIZE)) / 2 - PILL_SIZE; + for (int i = 0; i < item_count; i++) { + char* item = items[i]; + SDL_Color text_color = COLOR_WHITE; + + if (i == selected) { + text_color = uintToColour(THEME_COLOR5_255); + + int ow; + TTF_SizeUTF8(font.large, item, &ow, NULL); + ow += SCALE1(BUTTON_PADDING * 2); + + // Selected pill background + GFX_blitPillDark(ASSET_WHITE_PILL, screen, &(SDL_Rect){ + SCALE1(PADDING), + SCALE1(oy + PADDING + (i * PILL_SIZE)), + ow, + SCALE1(PILL_SIZE) + }); + } + + // Text + text = TTF_RenderUTF8_Blended(font.large, item, text_color); + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){ + SCALE1(PADDING + BUTTON_PADDING), + SCALE1(oy + PADDING + (i * PILL_SIZE) + 4) + }); + SDL_FreeSurface(text); + } + + // Render contextual hint below menu items (left-aligned, multi-line support) + if (getHint) { + const char* hint = getHint(); + if (hint) { + int leading = SCALE1(14); + int y = SCALE1(oy + PADDING + (item_count * PILL_SIZE) + PILL_SIZE / 2); + char line[256]; + const char* start = hint; + const char* end; + + // Render each line left-aligned (with BUTTON_PADDING to align with text inside pills) + while (*start) { + end = strchr(start, '\n'); + int len = end ? (end - start) : (int)strlen(start); + if (len > 0 && len < (int)sizeof(line)) { + strncpy(line, start, len); + line[len] = '\0'; + + SDL_Surface* hint_text = TTF_RenderUTF8_Blended(font.tiny, line, COLOR_WHITE); + if (hint_text) { + SDL_Rect dst = { SCALE1(PADDING + BUTTON_PADDING), y, hint_text->w, hint_text->h }; + SDL_BlitSurface(hint_text, NULL, screen, &dst); + SDL_FreeSurface(hint_text); + } + } + y += leading; + if (!end) break; + start = end + 1; + } + } + } + + GFX_flip(screen); +} + +////////////////////////////////////////////////////////////////////////////// +// Auto-Configuration Helpers +////////////////////////////////////////////////////////////////////////////// + +// Convert gpsp_serial mode code to human-readable name +static const char* getGBALinkModeName(const char* mode) { + if (!mode) return "Unknown"; + if (strcmp(mode, "auto") == 0) return "Automatic"; + if (strcmp(mode, "disabled") == 0) return "Disabled"; + if (strcmp(mode, "rfu") == 0) return "GBA Wireless Adapter"; + if (strcmp(mode, "mul_poke") == 0) return "Pokemon Gen3 Link Cable"; + if (strcmp(mode, "mul_aw1") == 0) return "Advance Wars 1"; + if (strcmp(mode, "mul_aw2") == 0) return "Advance Wars 2"; + return mode; // Return original if unknown +} + +// Auto-configure gpSP serial mode for GBA multiplayer +// Ensures a working mode is set since "auto" and "disable" don't support multiplayer +// Force gpSP's GBA link/serial mode to "auto" so the core auto-detects the correct +// adapter from the ROM (game code at 0xAC -> gba_over.h: RFU / Pokemon cable / Adv Wars). +// Returns true if it had to change the option and trigger a game reload; the caller must +// then bail with MENU_CALLBACK_EXIT, since gpSP only re-reads the serial mode on game load. +static bool forceGBALinkAutoNeedsReload(void) { + // Only for GBA core (gpSP) + if (!exactMatch((char*)minarch_getCoreTag(), "GBA")) return false; + + const char* current = minarch_getCoreOptionValue("gpsp_serial"); + if (current && strcmp(current, "auto") == 0) { + // Already auto-detecting (resolved at the last game load) - nothing to do. + return false; + } + + minarch_setCoreOptionValue("gpsp_serial", "auto"); + minarch_saveConfig(); + minarch_reloadGame(); // deferred; re-runs load_gamepak -> auto-detect + gbalink_force_resume = 1; // close menus and resume into the reloaded game + return true; +} + +////////////////////////////////////////////////////////////////////////////// +// Orchestration Functions +////////////////////////////////////////////////////////////////////////////// + +int hostGame_common(LinkType type, void* list, int i) { + (void)list; (void)i; + + // Check if already in a session + bool already_connected = false; + const char* session_name = "Netplay"; + switch (type) { + case LINK_TYPE_NETPLAY: already_connected = Netplay_getMode() != NETPLAY_OFF; break; + case LINK_TYPE_GBALINK: already_connected = GBALink_getMode() != GBALINK_OFF; break; + case LINK_TYPE_GBLINK: already_connected = GBLink_getMode() != GBLINK_OFF; break; + } + + if (already_connected) { + char msg[128]; + snprintf(msg, sizeof(msg), "Already in %s session.\nDisconnect first.", session_name); + minarch_menuMessage(msg, (char*[]){ "A","OKAY", NULL }); + return MENU_CALLBACK_NOP; + } + + // Auto-enable WiFi if needed + if (!ensureWifiEnabled()) { + return MENU_CALLBACK_NOP; + } + + // Auto-configure link cable mode for GBA games (gpSP auto-detects via gba_over.h) + if (type == LINK_TYPE_GBALINK) { + if (forceGBALinkAutoNeedsReload()) return MENU_CALLBACK_EXIT; + } + + // Show mode selection using shared pill-style UI + int selected = Menu_selectConnectionMode("Host Game"); + if (selected < 0) { + return MENU_CALLBACK_NOP; // Cancelled + } + + // Get game name from current ROM + char game_name[64]; + getGameName(game_name, sizeof(game_name)); + + uint32_t crc = calculateGameCRC(); + + // Execute selected mode + if (selected == 0) { + return hostGameHotspot_common(type, game_name, crc); + } else { + return hostGameWiFi_common(type, game_name, crc); + } +} + +int hostGameHotspot_common(LinkType type, const char* game_name, uint32_t crc) { +#ifndef HAS_WIFIMG + minarch_menuMessage("WiFi not available\non this platform.", (char*[]){ "A","OKAY", NULL }); + return MENU_CALLBACK_NOP; +#endif + + // Show initial message + GFX_setMode(MODE_MAIN); + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + { + SDL_Surface* text = TTF_RenderUTF8_Blended(font.medium, "Starting hotspot...", COLOR_WHITE); + int text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){screen->w/2 - text_w/2, screen->h/2}); + SDL_FreeSurface(text); + } + GFX_flip(screen); + GFX_setMode(MODE_MENU); + + // Generate SSID with random 4-character code + char ssid[33]; + struct timeval tv; + gettimeofday(&tv, NULL); + NET_HotspotConfig hotspot_cfg = { + .prefix = LINK_HOTSPOT_SSID_PREFIX, + .seed = (unsigned int)(tv.tv_usec ^ tv.tv_sec ^ crc) + }; + NET_generateHotspotSSID(ssid, sizeof(ssid), &hotspot_cfg); + + const char* pass = WIFI_direct_getHotspotPassword(); + + // Purge stale hotspot networks while wpa_supplicant is still up (startHotspot + // kills it to hand wlan0 to hostapd). Clears entries this device accumulated + // from past client joins. + WIFI_direct_forgetAllHotspots(); + + if (WIFI_direct_startHotspot(ssid, pass) != 0) { + minarch_menuMessage("Failed to start hotspot.\nCheck device capabilities.", (char*[]){ "A","OKAY", NULL }); + return MENU_CALLBACK_NOP; + } + + // Get hotspot IP + const char* hotspot_ip = WIFI_direct_getHotspotIP(); + + // Start host with hotspot IP + int start_result = -1; + switch (type) { + case LINK_TYPE_NETPLAY: start_result = Netplay_startHost(game_name, crc, hotspot_ip); break; + case LINK_TYPE_GBALINK: { + // Get current link mode to sync with client + const char* link_mode = minarch_getCoreOptionValue("gpsp_serial"); + start_result = GBALink_startHost(game_name, crc, hotspot_ip, link_mode); + break; + } + case LINK_TYPE_GBLINK: start_result = GBLink_startHost(game_name, crc, hotspot_ip); break; + } + + if (start_result != 0) { + WIFI_direct_stopHotspot(); + minarch_menuMessage("Failed to start host.\nCheck device capabilities.", (char*[]){ "A","OKAY", NULL }); + return MENU_CALLBACK_NOP; + } + + // Extract code from SSID for display + size_t prefix_len = strlen(LINK_HOTSPOT_SSID_PREFIX); + const char* code = (strlen(ssid) > prefix_len) ? ssid + prefix_len : "????"; + + // Wait loop + int dirty = 1; + int connected = 0; + int cancelled = 0; + + GFX_setMode(MODE_MAIN); + while (1) { + GFX_startFrame(); + PAD_poll(); + + // Check for cancellation + if (PAD_justPressed(BTN_B)) { + cancelled = 1; + break; + } + + // For GBLink: run a few core frames so gambatte can accept/process the + // TCP connection; GBLink_isConnected() then polls the kernel socket table. + if (type == LINK_TYPE_GBLINK) { + for (int i = 0; i < 5; i++) { + minarch_forceCoreOptionUpdate(); + if (GBLink_isConnected()) { + connected = 1; + break; + } + } + if (connected) break; + } + + // Check connection state for other types + switch (type) { + case LINK_TYPE_NETPLAY: + if (Netplay_getState() == NETPLAY_STATE_SYNCING || Netplay_isConnected()) { + connected = 1; + } + break; + case LINK_TYPE_GBALINK: + if (GBALink_getState() == GBALINK_STATE_CONNECTED) { + connected = 1; + } + break; + default: + break; + } + if (connected) break; + + // For GBLink: skip the render delay to poll more frequently + if (type == LINK_TYPE_GBLINK) { + dirty = 1; // Always redraw to keep loop fast + } + + PWR_update(&dirty, NULL, minarch_beforeSleep, minarch_afterSleep); + + if (dirty) { + renderHotspotWaitingScreen(code); + GFX_blitButtonGroup((char*[]){ "B","CANCEL", NULL }, 0, screen, 1); + GFX_flip(screen); + dirty = 0; + } + + minarch_hdmimon(); + } + + // Handle cancellation + if (cancelled) { + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + SDL_Surface* text = TTF_RenderUTF8_Blended(font.medium, "Cancelling...", COLOR_WHITE); + int text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){screen->w/2 - text_w/2, screen->h/2}); + SDL_FreeSurface(text); + GFX_flip(screen); + GFX_setMode(MODE_MENU); + + // Stop host + switch (type) { + case LINK_TYPE_NETPLAY: Netplay_stopHost(); break; + case LINK_TYPE_GBALINK: GBALink_stopHost(); break; + case LINK_TYPE_GBLINK: GBLink_stopHost(); break; + } + return MENU_CALLBACK_NOP; + } + + // Handle connection success - show confirmation screen + if (connected) { + uint32_t start_time = SDL_GetTicks(); + while (SDL_GetTicks() - start_time < 3000) { + GFX_startFrame(); + PAD_poll(); + if (PAD_justPressed(BTN_A)) break; + + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + + SDL_Surface* text; + int text_w; + int center_x = screen->w / 2; + int center_y = screen->h / 2; + + text = TTF_RenderUTF8_Blended(font.large, "Connected!", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, center_y - SCALE1(20)}); + SDL_FreeSurface(text); + + text = TTF_RenderUTF8_Blended(font.medium, "Starting game...", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, center_y + SCALE1(20)}); + SDL_FreeSurface(text); + + GFX_flip(screen); + minarch_hdmimon(); + } + GFX_setMode(MODE_MENU); + + // Stop UDP broadcast - no longer needed after connection + switch (type) { + case LINK_TYPE_NETPLAY: Netplay_stopBroadcast(); break; + case LINK_TYPE_GBLINK: GBLink_stopBroadcast(); break; + default: break; + } + + // Set force resume flag + switch (type) { + case LINK_TYPE_NETPLAY: netplay_force_resume = 1; break; + case LINK_TYPE_GBALINK: gbalink_force_resume = 1; break; + case LINK_TYPE_GBLINK: gblink_force_resume = 1; break; + } + return MENU_CALLBACK_EXIT; + } + + GFX_setMode(MODE_MENU); + return MENU_CALLBACK_NOP; +} + +int hostGameWiFi_common(LinkType type, const char* game_name, uint32_t crc) { + // Ensure wlan1 is down in case device previously hosted via hotspot + system("ip link set wlan1 down 2>/dev/null"); + + // Check for network connectivity (required for WiFi mode) + if (!ensureNetworkConnected(type, "hosting")) { + return MENU_CALLBACK_NOP; + } + + // Show initial message + GFX_setMode(MODE_MAIN); + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + { + SDL_Surface* text = TTF_RenderUTF8_Blended(font.medium, "Starting host...", COLOR_WHITE); + int text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){screen->w/2 - text_w/2, screen->h/2}); + SDL_FreeSurface(text); + } + GFX_flip(screen); + GFX_setMode(MODE_MENU); + + // Start host based on type + int start_result = -1; + switch (type) { + case LINK_TYPE_NETPLAY: start_result = Netplay_startHost(game_name, crc, NULL); break; + case LINK_TYPE_GBALINK: { + // Get current link mode to sync with client + const char* link_mode = minarch_getCoreOptionValue("gpsp_serial"); + start_result = GBALink_startHost(game_name, crc, NULL, link_mode); + break; + } + case LINK_TYPE_GBLINK: start_result = GBLink_startHost(game_name, crc, NULL); break; + } + + if (start_result != 0) { + minarch_menuMessage("Failed to start host.\nCheck WiFi connection.", (char*[]){ "A","OKAY", NULL }); + return MENU_CALLBACK_NOP; + } + + // Get IP based on type + const char* ip = NULL; + switch (type) { + case LINK_TYPE_NETPLAY: ip = Netplay_getLocalIP(); break; + case LINK_TYPE_GBALINK: ip = GBALink_getLocalIP(); break; + case LINK_TYPE_GBLINK: ip = GBLink_getLocalIP(); break; + } + + // Wait loop + int dirty = 1; + int connected = 0; + int cancelled = 0; + + GFX_setMode(MODE_MAIN); + while (1) { + GFX_startFrame(); + PAD_poll(); + + // Check for cancellation + if (PAD_justPressed(BTN_B)) { + cancelled = 1; + break; + } + + // For GBLink: run a few core frames so gambatte can accept/process the + // TCP connection; GBLink_isConnected() then polls the kernel socket table. + if (type == LINK_TYPE_GBLINK) { + for (int i = 0; i < 5; i++) { + minarch_forceCoreOptionUpdate(); + if (GBLink_isConnected()) { + connected = 1; + break; + } + } + if (connected) break; + } + + // Check connection state for other types + switch (type) { + case LINK_TYPE_NETPLAY: + if (Netplay_getState() == NETPLAY_STATE_SYNCING || Netplay_isConnected()) { + connected = 1; + } + break; + case LINK_TYPE_GBALINK: + if (GBALink_getState() == GBALINK_STATE_CONNECTED) { + connected = 1; + } + break; + default: + break; + } + if (connected) break; + + // For GBLink: skip the render delay to poll more frequently + if (type == LINK_TYPE_GBLINK) { + dirty = 1; // Always redraw to keep loop fast + } + + PWR_update(&dirty, NULL, minarch_beforeSleep, minarch_afterSleep); + + if (dirty) { + renderWiFiWaitingScreen(ip); + GFX_blitButtonGroup((char*[]){ "B","CANCEL", NULL }, 0, screen, 1); + GFX_flip(screen); + dirty = 0; + } + + minarch_hdmimon(); + } + + // Handle cancellation + if (cancelled) { + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + SDL_Surface* text = TTF_RenderUTF8_Blended(font.medium, "Cancelling...", COLOR_WHITE); + int text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){screen->w/2 - text_w/2, screen->h/2}); + SDL_FreeSurface(text); + GFX_flip(screen); + GFX_setMode(MODE_MENU); + + // Stop host + switch (type) { + case LINK_TYPE_NETPLAY: Netplay_stopHost(); break; + case LINK_TYPE_GBALINK: GBALink_stopHost(); break; + case LINK_TYPE_GBLINK: GBLink_stopHost(); break; + } + return MENU_CALLBACK_NOP; + } + + // Handle connection success - show confirmation screen + if (connected) { + showConnectionSuccessScreen(); + + // Stop UDP broadcast - no longer needed after connection + switch (type) { + case LINK_TYPE_NETPLAY: Netplay_stopBroadcast(); break; + case LINK_TYPE_GBLINK: GBLink_stopBroadcast(); break; + default: break; + } + + // Set force resume flag + switch (type) { + case LINK_TYPE_NETPLAY: netplay_force_resume = 1; break; + case LINK_TYPE_GBALINK: gbalink_force_resume = 1; break; + case LINK_TYPE_GBLINK: gblink_force_resume = 1; break; + } + return MENU_CALLBACK_EXIT; + } + + GFX_setMode(MODE_MENU); + return MENU_CALLBACK_NOP; +} + +int joinGameWiFi_common(LinkType type) { + // Ensure wlan1 is down in case device previously hosted via hotspot + system("ip link set wlan1 down 2>/dev/null"); + + // Check for network connectivity (required for WiFi mode) + if (!ensureNetworkConnected(type, "joining")) { + return MENU_CALLBACK_NOP; + } + + // Start discovery based on type + int start_result = -1; + switch (type) { + case LINK_TYPE_NETPLAY: start_result = Netplay_startDiscovery(); break; + case LINK_TYPE_GBALINK: start_result = GBALink_startDiscovery(); break; + case LINK_TYPE_GBLINK: start_result = GBLink_startDiscovery(); break; + } + if (start_result != 0) { + minarch_menuMessage("Failed to start discovery.\nCheck WiFi connection.", (char*[]){ "A","OKAY", NULL }); + return MENU_CALLBACK_NOP; + } + + int dirty = 1; + int cancelled = 0; + uint32_t last_poll = SDL_GetTicks(); + setHostCount(type, 0); + + // Show scanning message with dark overlay + GFX_setMode(MODE_MAIN); + showSearchingScreen(); + + // Auto-refresh discovery loop - wait for hosts + while (1) { + GFX_startFrame(); + PAD_poll(); + + // Check for cancellation + if (PAD_justPressed(BTN_B)) { + cancelled = 1; + break; + } + + // Poll for hosts periodically (every 500ms) + uint32_t now = SDL_GetTicks(); + if (now - last_poll >= 500) { + last_poll = now; + int new_count = 0; + switch (type) { + case LINK_TYPE_NETPLAY: new_count = Netplay_getDiscoveredHosts(netplay_hosts, NETPLAY_MAX_HOSTS); break; + case LINK_TYPE_GBALINK: new_count = GBALink_getDiscoveredHosts(gbalink_hosts, GBALINK_MAX_HOSTS); break; + case LINK_TYPE_GBLINK: new_count = GBLink_getDiscoveredHosts(gblink_hosts, GBLINK_MAX_HOSTS); break; + } + if (new_count != getHostCount(type)) { + setHostCount(type, new_count); + dirty = 1; + } + + // Found hosts - exit loop + if (getHostCount(type) > 0) { + break; + } + } + + PWR_update(&dirty, NULL, minarch_beforeSleep, minarch_afterSleep); + + // Keep showing scanning message + if (dirty) { + showSearchingScreen(); + dirty = 0; + } + + minarch_hdmimon(); + } + GFX_setMode(MODE_MENU); + + if (cancelled || getHostCount(type) == 0) { + // Stop discovery before returning + switch (type) { + case LINK_TYPE_NETPLAY: Netplay_stopDiscovery(); break; + case LINK_TYPE_GBALINK: GBALink_stopDiscovery(); break; + case LINK_TYPE_GBLINK: GBLink_stopDiscovery(); break; + } + if (!cancelled) { + minarch_menuMessage("No hosts found.\n\nMake sure:\n1. Both devices on same WiFi\n2. Host started first", + (char*[]){ "A","OKAY", NULL }); + } + return MENU_CALLBACK_NOP; + } + + // Show host selection with pills (continue polling for new hosts) + int selected = 0; + dirty = 1; + last_poll = SDL_GetTicks(); + + while (1) { + GFX_startFrame(); + PAD_poll(); + + if (PAD_justPressed(BTN_B)) { + // Stop discovery before returning + switch (type) { + case LINK_TYPE_NETPLAY: Netplay_stopDiscovery(); break; + case LINK_TYPE_GBALINK: GBALink_stopDiscovery(); break; + case LINK_TYPE_GBLINK: GBLink_stopDiscovery(); break; + } + return MENU_CALLBACK_NOP; + } + + if (PAD_justRepeated(BTN_UP)) { + selected--; + if (selected < 0) selected = getHostCount(type) - 1; + dirty = 1; + } + else if (PAD_justRepeated(BTN_DOWN)) { + selected++; + if (selected >= getHostCount(type)) selected = 0; + dirty = 1; + } + else if (PAD_justPressed(BTN_A)) { + // Host selected - proceed to connect + break; + } + + // Continue polling for new hosts in the background + uint32_t now = SDL_GetTicks(); + if (now - last_poll >= 500) { + last_poll = now; + int new_count = 0; + switch (type) { + case LINK_TYPE_NETPLAY: new_count = Netplay_getDiscoveredHosts(netplay_hosts, NETPLAY_MAX_HOSTS); break; + case LINK_TYPE_GBALINK: new_count = GBALink_getDiscoveredHosts(gbalink_hosts, GBALINK_MAX_HOSTS); break; + case LINK_TYPE_GBLINK: new_count = GBLink_getDiscoveredHosts(gblink_hosts, GBLINK_MAX_HOSTS); break; + } + if (new_count != getHostCount(type)) { + setHostCount(type, new_count); + if (selected >= getHostCount(type)) selected = getHostCount(type) - 1; + dirty = 1; + } + } + + PWR_update(&dirty, NULL, minarch_beforeSleep, minarch_afterSleep); + + if (dirty) { + renderHostSelectionList(type, selected, getHostCount(type)); + dirty = 0; + } + + minarch_hdmimon(); + } + + // Stop discovery AFTER selection is made + switch (type) { + case LINK_TYPE_NETPLAY: Netplay_stopDiscovery(); break; + case LINK_TYPE_GBALINK: GBALink_stopDiscovery(); break; + case LINK_TYPE_GBLINK: GBLink_stopDiscovery(); break; + } + + // For GBALink, check link mode compatibility BEFORE establishing TCP connection + // This allows showing the sync dialog without making a network connection + if (type == LINK_TYPE_GBALINK) { + const char* host_mode = getHostLinkMode(type, selected); + const char* client_mode = minarch_getCoreOptionValue("gpsp_serial"); + + // Check if modes differ (need reload for gpsp to pick up new mode) + if (host_mode && host_mode[0] && (!client_mode || strcmp(client_mode, host_mode) != 0)) { + if (showLinkModeRestartDialog(getGBALinkModeName(host_mode), false)) { + // User confirmed - apply mode, save config, reload (no TCP connection was made) + minarch_setCoreOptionValue("gpsp_serial", host_mode); + minarch_saveConfig(); + minarch_reloadGame(); // Deferred to avoid segfault + gbalink_force_resume = 1; + return MENU_CALLBACK_EXIT; + } else { + // User cancelled - return without connecting + return MENU_CALLBACK_NOP; + } + } + } + + // Show connecting screen + const char* host_ip = getHostIP(type, selected); + int host_port = getHostPort(type, selected); + showConnectingScreen(host_ip); + + // Connect to selected host (modes already match for GBALink) + int connect_result = -1; + switch (type) { + case LINK_TYPE_NETPLAY: connect_result = Netplay_connectToHost(host_ip, host_port); break; + case LINK_TYPE_GBALINK: connect_result = GBALink_connectToHost(host_ip, host_port); break; + case LINK_TYPE_GBLINK: connect_result = GBLink_connectToHost(host_ip, host_port); break; + } + + if (connect_result == GBALINK_CONNECT_ERROR) { + minarch_menuMessage("Connection failed.", (char*[]){ "A","OKAY", NULL }); + return MENU_CALLBACK_NOP; + } + + // Handle GBALink needing reload for link mode sync (fallback for protocol changes) + // This path is kept as a safety fallback in case host doesn't broadcast link_mode + if (type == LINK_TYPE_GBALINK && connect_result == GBALINK_CONNECT_NEEDS_RELOAD) { + const char* host_mode = GBALink_getPendingLinkMode(); + + if (showLinkModeRestartDialog(getGBALinkModeName(host_mode), false)) { + // User confirmed - apply mode, save config, disconnect, reload, return to game + GBALink_applyPendingLinkMode(); + minarch_saveConfig(); + GBALink_disconnect(); + minarch_reloadGame(); // Deferred to avoid segfault + // Set force_resume to close all menus + gbalink_force_resume = 1; + return MENU_CALLBACK_EXIT; + } else { + // User cancelled - clear state and disconnect + GBALink_clearPendingReload(); + GBALink_disconnect(); + return MENU_CALLBACK_NOP; + } + } + + // Show success screen + showConnectionSuccessScreen(); + + // Set force resume flag + switch (type) { + case LINK_TYPE_NETPLAY: netplay_force_resume = 1; break; + case LINK_TYPE_GBALINK: gbalink_force_resume = 1; break; + case LINK_TYPE_GBLINK: gblink_force_resume = 1; break; + } + return MENU_CALLBACK_EXIT; +} + +int joinGame_Hotspot_common(LinkType type) { + // Note: ensureWifiEnabled() already called by joinGame_common() + + // Link-type specific setup + const char* display_name = "Netplay"; + int* connected_to_hotspot_flag = NULL; + int* force_resume_flag = NULL; + int default_port = 0; + + switch (type) { + case LINK_TYPE_NETPLAY: + connected_to_hotspot_flag = &netplay_connected_to_hotspot; + force_resume_flag = &netplay_force_resume; + default_port = NETPLAY_DEFAULT_PORT; + break; + case LINK_TYPE_GBALINK: + connected_to_hotspot_flag = &gbalink_connected_to_hotspot; + force_resume_flag = &gbalink_force_resume; + default_port = GBALINK_DEFAULT_PORT; + break; + case LINK_TYPE_GBLINK: + connected_to_hotspot_flag = &gblink_connected_to_hotspot; + force_resume_flag = &gblink_force_resume; + default_port = GBLINK_DEFAULT_PORT; + break; + } + + *connected_to_hotspot_flag = 0; + + // Purge stale hotspot networks left by previous sessions before joining a new + // one. Done up front (not only on teardown) so a backlog from aborted joins + // can't grow unbounded or let wpa_supplicant prefer an old saved hotspot. + WIFI_direct_forgetAllHotspots(); + + // Show scanning message + char scan_msg[64]; + snprintf(scan_msg, sizeof(scan_msg), "Scanning for %s hosts...", display_name); + showOverlayMessage(scan_msg); + + // Scan for hotspots (all types use unified prefix) + char hotspots[8][33]; + memset(hotspots, 0, sizeof(hotspots)); // Zero-initialize to prevent garbage + + // Use wifi_direct for more reliable scanning (bypasses wifi_daemon) + int hotspot_count = WIFI_direct_scanForHotspots(LINK_HOTSPOT_SSID_PREFIX, hotspots, 8); + + if (hotspot_count == 0) { + char no_host_msg[128]; + snprintf(no_host_msg, sizeof(no_host_msg), + "No %s host found.\n\nMake sure the host has\nstarted %s first.", + display_name, type == LINK_TYPE_NETPLAY ? "hosting" : "a link session"); + minarch_menuMessage(no_host_msg, (char*[]){ "A","OKAY", NULL }); + return MENU_CALLBACK_NOP; + } + + // Show list of available hosts for user to select + char selected_ssid[33]; + int selected = 0; + int show_selection = 1; + int dirty = 1; + size_t prefix_len = strlen(LINK_HOTSPOT_SSID_PREFIX); + + while (show_selection) { + GFX_startFrame(); + PAD_poll(); + + if (PAD_justPressed(BTN_B)) { + return MENU_CALLBACK_NOP; // Cancel + } + + if (PAD_justRepeated(BTN_UP)) { + selected--; + if (selected < 0) selected = hotspot_count - 1; + dirty = 1; + } + else if (PAD_justRepeated(BTN_DOWN)) { + selected++; + if (selected >= hotspot_count) selected = 0; + dirty = 1; + } + else if (PAD_justPressed(BTN_A)) { + strncpy(selected_ssid, hotspots[selected], sizeof(selected_ssid) - 1); + selected_ssid[32] = '\0'; + show_selection = 0; + } + + PWR_update(&dirty, NULL, minarch_beforeSleep, minarch_afterSleep); + + if (dirty) { + GFX_clear(screen); + GFX_drawOnLayer(menu.bitmap, 0, 0, DEVICE_WIDTH, DEVICE_HEIGHT, 0.15f, 1, 0); + + SDL_Surface* text; + int text_w; + int center_x = screen->w / 2; + + // Calculate vertical layout + int title_y = SCALE1(60); + int instruction_y = title_y + SCALE1(30); + int list_start_y = instruction_y + SCALE1(35); + + // Large title "Join Game" + text = TTF_RenderUTF8_Blended(font.large, "Join Game", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, title_y}); + SDL_FreeSurface(text); + + // Medium instruction + text = TTF_RenderUTF8_Blended(font.medium, "Select code displayed on the host device", COLOR_WHITE); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, instruction_y}); + SDL_FreeSurface(text); + + // Render code list with pills + for (int j = 0; j < hotspot_count; j++) { + const char* code = (strlen(hotspots[j]) > prefix_len) ? hotspots[j] + prefix_len : "????"; + const char* display_code = code[0] ? code : "????"; + + SDL_Color text_color = COLOR_WHITE; + if (j == selected) { + text_color = uintToColour(THEME_COLOR5_255); + int ow; + TTF_SizeUTF8(font.large, display_code, &ow, NULL); + ow += SCALE1(BUTTON_PADDING * 2); + GFX_blitPillDark(ASSET_WHITE_PILL, screen, &(SDL_Rect){ + center_x - ow/2, + list_start_y + j * SCALE1(PILL_SIZE), + ow, + SCALE1(PILL_SIZE) + }); + } + + text = TTF_RenderUTF8_Blended(font.large, display_code, text_color); + text_w = text->w; + SDL_BlitSurface(text, NULL, screen, &(SDL_Rect){center_x - text_w/2, list_start_y + j * SCALE1(PILL_SIZE) + SCALE1(4)}); + SDL_FreeSurface(text); + } + + GFX_blitButtonGroup((char*[]){ "B","BACK", "A","SELECT", NULL }, 1, screen, 1); + GFX_flip(screen); + dirty = 0; + } + + minarch_hdmimon(); + } + + // Connect to selected hotspot + const char* selected_code = (strlen(selected_ssid) > prefix_len) ? selected_ssid + prefix_len : "????"; + char connect_msg[64]; + snprintf(connect_msg, sizeof(connect_msg), "Connecting to %s...", selected_code[0] ? selected_code : "????"); + showOverlayMessage(connect_msg); + + // Save current connection before switching to hotspot (so we can restore later) + WIFI_direct_saveCurrentConnection(); + + // IMPORTANT: Ensure wlan1 is completely down before joining another hotspot + // This fixes an issue where a device that previously hosted still has wlan1 + // up with 10.0.0.1, causing routing conflicts when trying to join a new hotspot + system("killall hostapd 2>/dev/null"); + system("killall udhcpd 2>/dev/null"); + system("ip addr flush dev wlan1 2>/dev/null"); + system("ip link set wlan1 down 2>/dev/null"); + + // Disconnect from current WiFi first to ensure clean switch to hotspot + WIFI_direct_disconnect(); + // Flush any stale network state (old routes, IP addresses) + system("ip addr flush dev wlan0 2>/dev/null"); + system("ip route flush dev wlan0 2>/dev/null"); + SDL_Delay(1000); // Wait for cleanup to complete + + // Use wifi_direct for more reliable connection (bypasses wifi_daemon) + const char* hotspot_pass = WIFI_direct_getHotspotPassword(); + int wifi_connect_result = WIFI_direct_connect(selected_ssid, hotspot_pass); + + if (wifi_connect_result != 0) { + WIFI_direct_restorePreviousConnection(); // Restore WiFi so next scan works + minarch_menuMessage("Failed to connect to host.", (char*[]){ "A","OKAY", NULL }); + return MENU_CALLBACK_NOP; + } + + *connected_to_hotspot_flag = 1; + + // Store the hotspot SSID for cleanup + strncpy(connected_hotspot_ssid, selected_ssid, 32); + connected_hotspot_ssid[32] = '\0'; + + // GBLink needs IP refresh after connecting to hotspot + if (type == LINK_TYPE_GBLINK) { + SDL_Delay(500); + GBLink_hasNetworkConnection(); // Refreshes IP as side effect + } + + // On hotspot network, host is always at fixed IP + const char* host_ip = WIFI_direct_getHotspotIP(); + + // For GBALink, query host's link_mode directly BEFORE TCP connection + // This allows showing the sync dialog without making a TCP connection + if (type == LINK_TYPE_GBALINK) { + showOverlayMessage("Checking compatibility..."); + + // Direct UDP query to known host IP (more reliable than broadcasts on hotspot) + char host_mode[32] = {0}; + int query_result = GBALink_queryHostLinkMode(host_ip, host_mode, sizeof(host_mode)); + + if (query_result == 0 && host_mode[0]) { + const char* client_mode = minarch_getCoreOptionValue("gpsp_serial"); + + // Check if modes differ + if (!client_mode || strcmp(client_mode, host_mode) != 0) { + if (showLinkModeRestartDialog(getGBALinkModeName(host_mode), false)) { + // User confirmed - apply mode, save config, disconnect from hotspot, reload + minarch_setCoreOptionValue("gpsp_serial", host_mode); + minarch_saveConfig(); + WIFI_direct_restorePreviousConnection(); + *connected_to_hotspot_flag = 0; + minarch_reloadGame(); // Deferred to avoid segfault + gbalink_force_resume = 1; + return MENU_CALLBACK_EXIT; + } else { + // User cancelled - disconnect from hotspot + WIFI_direct_restorePreviousConnection(); + *connected_to_hotspot_flag = 0; + return MENU_CALLBACK_NOP; + } + } + } + } + + // Verify client has valid IP before attempting TCP connect + char client_ip[16] = {0}; + WIFI_direct_getIP(client_ip, sizeof(client_ip)); + if (client_ip[0] == '\0' || strcmp(client_ip, "0.0.0.0") == 0) { + showOverlayMessage("Waiting for network..."); + // Wait up to 10 seconds for DHCP to assign IP + for (int i = 0; i < 20; i++) { + SDL_Delay(500); + WIFI_direct_getIP(client_ip, sizeof(client_ip)); + if (client_ip[0] != '\0' && strcmp(client_ip, "0.0.0.0") != 0) { + break; + } + } + if (client_ip[0] == '\0' || strcmp(client_ip, "0.0.0.0") == 0) { + minarch_menuMessage("Failed to get IP address.\n\nPlease try again.", + (char*[]){ "A","OKAY", NULL }); + WIFI_direct_restorePreviousConnection(); + *connected_to_hotspot_flag = 0; + return MENU_CALLBACK_NOP; + } + } + + showOverlayMessage("Establishing link..."); + + // Network warmup: ping host to populate ARP cache and verify connectivity + // This helps with intermittent connection failures on hotspot + { + char ping_cmd[64]; + snprintf(ping_cmd, sizeof(ping_cmd), "ping -c 1 -W 2 %s >/dev/null 2>&1", host_ip); + int ping_result = system(ping_cmd); + if (ping_result != 0) { + // First ping failed, wait and retry - ARP may need time + SDL_Delay(500); + system(ping_cmd); + } + // Even if ping fails, still try TCP - the ping may be blocked but TCP allowed + } + + // Connect to host with retry logic (modes already match for GBALink if we got here) + int connect_result = -1; + for (int attempt = 0; attempt < 3; attempt++) { + if (attempt > 0) { + char retry_msg[64]; + snprintf(retry_msg, sizeof(retry_msg), "Retrying connection... (%d/3)", attempt + 1); + showOverlayMessage(retry_msg); + SDL_Delay(1500); // Increased delay before retry for network stabilization + } + + switch (type) { + case LINK_TYPE_NETPLAY: + connect_result = Netplay_connectToHost(host_ip, default_port); + break; + case LINK_TYPE_GBALINK: + connect_result = GBALink_connectToHost(host_ip, default_port); + break; + case LINK_TYPE_GBLINK: + connect_result = GBLink_connectToHost(host_ip, default_port); + break; + } + + // Success or needs reload - stop retrying + if (connect_result == 0 || connect_result == GBALINK_CONNECT_NEEDS_RELOAD) { + break; + } + } + + if (connect_result == GBALINK_CONNECT_ERROR || (connect_result != 0 && connect_result != GBALINK_CONNECT_NEEDS_RELOAD)) { + minarch_menuMessage("Failed to connect to host.\n\nConnection timed out.", (char*[]){ "A","OKAY", NULL }); + WIFI_direct_restorePreviousConnection(); + *connected_to_hotspot_flag = 0; + return MENU_CALLBACK_NOP; + } + + // Handle GBALink needing reload for link mode sync (fallback for protocol changes) + // This path is kept as a safety fallback in case UDP discovery didn't get link_mode + if (type == LINK_TYPE_GBALINK && connect_result == GBALINK_CONNECT_NEEDS_RELOAD) { + const char* host_mode = GBALink_getPendingLinkMode(); + + if (showLinkModeRestartDialog(getGBALinkModeName(host_mode), false)) { + // User confirmed - apply mode, save config, disconnect, reload, return to game + GBALink_applyPendingLinkMode(); + minarch_saveConfig(); + GBALink_disconnect(); + minarch_reloadGame(); // Deferred to avoid segfault + // Set force_resume to close all menus + gbalink_force_resume = 1; + return MENU_CALLBACK_EXIT; + } else { + // User cancelled - clear state and disconnect + GBALink_clearPendingReload(); + GBALink_disconnect(); + WIFI_direct_restorePreviousConnection(); + *connected_to_hotspot_flag = 0; + return MENU_CALLBACK_NOP; + } + } + + // Show success + showConnectedSuccess(type == LINK_TYPE_GBLINK ? 2000 : 3000); + + *force_resume_flag = 1; + return MENU_CALLBACK_EXIT; +} + +int joinGame_common(LinkType type, void* list, int i) { + (void)list; (void)i; + + // Check if already connected + bool connected = false; + const char* session_name = "Netplay"; + switch (type) { + case LINK_TYPE_NETPLAY: connected = Netplay_getMode() != NETPLAY_OFF; break; + case LINK_TYPE_GBALINK: connected = GBALink_getMode() != GBALINK_OFF; break; + case LINK_TYPE_GBLINK: connected = GBLink_getMode() != GBLINK_OFF; break; + } + + if (connected) { + char msg[128]; + snprintf(msg, sizeof(msg), "Already in %s session.\nDisconnect first.", session_name); + minarch_menuMessage(msg, (char*[]){ "A","OKAY", NULL }); + return MENU_CALLBACK_NOP; + } + + if (!ensureWifiEnabled()) { + return MENU_CALLBACK_NOP; + } + + // Auto-configure link cable mode for GBA games (gpSP auto-detects via gba_over.h) + if (type == LINK_TYPE_GBALINK) { + if (forceGBALinkAutoNeedsReload()) return MENU_CALLBACK_EXIT; + } + + int selected = Menu_selectConnectionMode("Join Game"); + if (selected < 0) return MENU_CALLBACK_NOP; + + if (selected == 0) { + return joinGame_Hotspot_common(type); + } + + // WiFi mode + return joinGameWiFi_common(type); +} + +int disconnect_common(LinkType type, void* list, int i) { + (void)list; (void)i; + + // Check if not connected + bool disconnected = false; + const char* session_name = "Netplay"; + switch (type) { + case LINK_TYPE_NETPLAY: disconnected = Netplay_getMode() == NETPLAY_OFF; break; + case LINK_TYPE_GBALINK: disconnected = GBALink_getMode() == GBALINK_OFF; break; + case LINK_TYPE_GBLINK: disconnected = GBLink_getMode() == GBLINK_OFF; break; + } + + if (disconnected) { + char msg[128]; + snprintf(msg, sizeof(msg), "Not in a %s session.", session_name); + minarch_menuMessage(msg, (char*[]){ "A","OKAY", NULL }); + return MENU_CALLBACK_NOP; + } + + showTransitionMessage("Disconnecting..."); + + // Capture hotspot state, disconnect, and stop host in one switch + bool was_host = false; + bool needs_hotspot_cleanup = false; + + switch (type) { + case LINK_TYPE_NETPLAY: + was_host = (Netplay_getMode() == NETPLAY_HOST); + needs_hotspot_cleanup = Netplay_isUsingHotspot() || netplay_connected_to_hotspot; + Netplay_disconnect(); + if (was_host) Netplay_stopHostFast(); + netplay_connected_to_hotspot = 0; + break; + case LINK_TYPE_GBALINK: + was_host = (GBALink_getMode() == GBALINK_HOST); + needs_hotspot_cleanup = GBALink_isUsingHotspot() || gbalink_connected_to_hotspot; + GBALink_disconnect(); + if (was_host) GBALink_stopHostFast(); + gbalink_connected_to_hotspot = 0; + break; + case LINK_TYPE_GBLINK: + was_host = (GBLink_getMode() == GBLINK_HOST); + needs_hotspot_cleanup = GBLink_isUsingHotspot() || gblink_connected_to_hotspot; + GBLink_stopAllFast(); + gblink_connected_to_hotspot = 0; + break; + } + + // Do hotspot cleanup asynchronously to avoid 5-10 second delay + if (needs_hotspot_cleanup) { + stopHotspotAndRestoreWiFiAsync(was_host); + } +#ifdef HAS_WIFIMG + else { + // WiFi-mode session (no hotspot to tear down): a WIFI_direct_connect that + // switched networks may have left other saved networks disabled via + // select_network. Re-enable them now so they're usable again immediately + // instead of only after the next reboot/WiFi toggle. + WIFI_enableAll(); + } +#endif + + showTimedConfirmation("Disconnected", 1500); + return MENU_CALLBACK_NOP; +} + +////////////////////////////////////////////////////////////////////////////// +// Status & Menu Functions +////////////////////////////////////////////////////////////////////////////// + +int status_common(LinkType type) { + const char* mode_str = "Off"; + const char* state_str = "Idle"; + const char* conn_str = ""; + const char* code = NULL; + const char* local_ip = NULL; + const char* status_msg = NULL; + + // Type-specific variables + int mode_off = 0; + int mode_host = 0; + int is_using_hotspot = 0; + int connected_to_hotspot = 0; + + switch (type) { + case LINK_TYPE_NETPLAY: + mode_off = (Netplay_getMode() == NETPLAY_OFF); + mode_host = (Netplay_getMode() == NETPLAY_HOST); + is_using_hotspot = Netplay_isUsingHotspot(); + connected_to_hotspot = netplay_connected_to_hotspot; + local_ip = Netplay_getLocalIP(); + status_msg = Netplay_getStatusMessage(); + + switch (Netplay_getMode()) { + case NETPLAY_HOST: mode_str = "Host"; break; + case NETPLAY_CLIENT: mode_str = "Client"; break; + default: mode_str = "Off"; break; + } + switch (Netplay_getState()) { + case NETPLAY_STATE_WAITING: state_str = "Waiting for player"; break; + case NETPLAY_STATE_CONNECTING: state_str = "Connecting"; break; + case NETPLAY_STATE_SYNCING: state_str = "Connected"; break; + case NETPLAY_STATE_PLAYING: state_str = "Playing"; break; + case NETPLAY_STATE_STALLED: state_str = "Playing (stalled)"; break; + case NETPLAY_STATE_DISCONNECTED: state_str = "Disconnected"; break; + case NETPLAY_STATE_ERROR: state_str = "Error"; break; + default: state_str = "Idle"; break; + } + break; + + case LINK_TYPE_GBALINK: + mode_off = (GBALink_getMode() == GBALINK_OFF); + mode_host = (GBALink_getMode() == GBALINK_HOST); + is_using_hotspot = GBALink_isUsingHotspot(); + connected_to_hotspot = gbalink_connected_to_hotspot; + local_ip = GBALink_getLocalIP(); + status_msg = GBALink_getStatusMessage(); + + switch (GBALink_getMode()) { + case GBALINK_HOST: mode_str = "Host"; break; + case GBALINK_CLIENT: mode_str = "Client"; break; + default: mode_str = "Off"; break; + } + switch (GBALink_getState()) { + case GBALINK_STATE_WAITING: state_str = "Waiting for link"; break; + case GBALINK_STATE_CONNECTING: state_str = "Connecting"; break; + case GBALINK_STATE_CONNECTED: state_str = "Connected"; break; + case GBALINK_STATE_DISCONNECTED: state_str = "Disconnected"; break; + case GBALINK_STATE_ERROR: state_str = "Error"; break; + default: state_str = "Idle"; break; + } + break; + + case LINK_TYPE_GBLINK: + mode_off = (GBLink_getMode() == GBLINK_OFF); + mode_host = (GBLink_getMode() == GBLINK_HOST); + is_using_hotspot = GBLink_isUsingHotspot(); + connected_to_hotspot = gblink_connected_to_hotspot; + local_ip = GBLink_getLocalIP(); + status_msg = GBLink_getStatusMessage(); + + switch (GBLink_getMode()) { + case GBLINK_HOST: mode_str = "Host"; break; + case GBLINK_CLIENT: mode_str = "Client"; break; + default: mode_str = "Off"; break; + } + switch (GBLink_getState()) { + case GBLINK_STATE_WAITING: state_str = "Waiting for link"; break; + case GBLINK_STATE_CONNECTING: state_str = "Connecting"; break; + case GBLINK_STATE_CONNECTED: state_str = "Connected"; break; + case GBLINK_STATE_DISCONNECTED: state_str = "Disconnected"; break; + case GBLINK_STATE_ERROR: state_str = "Error"; break; + default: state_str = "Idle"; break; + } + break; + } + + // Connection type string + if (!mode_off) { + if (is_using_hotspot || connected_to_hotspot) { + conn_str = "Hotspot"; + } else { + conn_str = "WiFi"; + } + } + + // Get hotspot code if hosting with hotspot + if (is_using_hotspot && mode_host) { + const char* ssid = NULL; +#ifdef HAS_WIFIMG + ssid = WIFI_direct_getHotspotSSID(); +#endif + size_t ssid_len = ssid ? strlen(ssid) : 0; + size_t prefix_len = strlen(LINK_HOTSPOT_SSID_PREFIX); + code = (ssid && ssid_len > prefix_len) ? ssid + prefix_len : "????"; + } + + showLinkStatusMessage("Netplay Status", mode_str, conn_str, state_str, code, local_ip, status_msg); + return MENU_CALLBACK_NOP; +} + +// Menu option handlers +int OptionNetplay_hostGame(void* list, int i) { + return hostGame_common(LINK_TYPE_NETPLAY, list, i); +} + +int OptionNetplay_joinGame(void* list, int i) { + return joinGame_common(LINK_TYPE_NETPLAY, list, i); +} + +int OptionNetplay_disconnect(void* list, int i) { + return disconnect_common(LINK_TYPE_NETPLAY, list, i); +} + +int OptionNetplay_status(void* list, int i) { + (void)list; (void)i; + return status_common(LINK_TYPE_NETPLAY); +} + +int OptionGBALink_hostGame(void* list, int i) { + return hostGame_common(LINK_TYPE_GBALINK, list, i); +} + +int OptionGBALink_joinGame(void* list, int i) { + return joinGame_common(LINK_TYPE_GBALINK, list, i); +} + +int OptionGBALink_disconnect(void* list, int i) { + return disconnect_common(LINK_TYPE_GBALINK, list, i); +} + +int OptionGBALink_status(void* list, int i) { + (void)list; (void)i; + return status_common(LINK_TYPE_GBALINK); +} + +int OptionGBLink_hostGame(void* list, int i) { + return hostGame_common(LINK_TYPE_GBLINK, list, i); +} + +int OptionGBLink_joinGame(void* list, int i) { + return joinGame_common(LINK_TYPE_GBLINK, list, i); +} + +int OptionGBLink_disconnect(void* list, int i) { + return disconnect_common(LINK_TYPE_GBLINK, list, i); +} + +int OptionGBLink_status(void* list, int i) { + (void)list; (void)i; + return status_common(LINK_TYPE_GBLINK); +} + +const char* getNetplayMenuHint(void) { + // GBA link adapter mode is now auto-detected by gpSP (gpsp_serial="auto"), + // so there is no manual setting to hint about. + return NULL; +} + +const char* (*getLinkMenuHint(LinkType type))(void) { + switch (type) { + case LINK_TYPE_NETPLAY: return getNetplayMenuHint; + case LINK_TYPE_GBALINK: return getNetplayMenuHint; + case LINK_TYPE_GBLINK: return NULL; + } + return NULL; +} + +// Internal callback types for menu +typedef int (*NetplayMenuCallback)(void*, int); + +typedef struct { + NetplayMenuCallback host; + NetplayMenuCallback join; + NetplayMenuCallback disconnect; + NetplayMenuCallback status; +} NetplayLinkCallbacks; + +static NetplayLinkCallbacks getNetplayLinkCallbacks(LinkType type) { + switch (type) { + case LINK_TYPE_NETPLAY: + return (NetplayLinkCallbacks){OptionNetplay_hostGame, OptionNetplay_joinGame, + OptionNetplay_disconnect, OptionNetplay_status}; + case LINK_TYPE_GBALINK: + return (NetplayLinkCallbacks){OptionGBALink_hostGame, OptionGBALink_joinGame, + OptionGBALink_disconnect, OptionGBALink_status}; + case LINK_TYPE_GBLINK: + return (NetplayLinkCallbacks){OptionGBLink_hostGame, OptionGBLink_joinGame, + OptionGBLink_disconnect, OptionGBLink_status}; + } + return (NetplayLinkCallbacks){NULL, NULL, NULL, NULL}; +} + +int Netplay_menu_link(LinkType type) { + int* force_resume = getForceResumeFlag(type); + *force_resume = 0; + + NetplayLinkCallbacks callbacks = getNetplayLinkCallbacks(type); + const char* (*getHint)(void) = getLinkMenuHint(type); + + int dirty = 1; + int show_menu = 1; + int selected = 0; + + while (show_menu) { + int is_connected = isLinkConnected(type); + + char* items[5]; + NetplayMenuCallback item_callbacks[5]; + int item_count = 0; + + if (!is_connected) { + items[item_count] = "Host Game"; + item_callbacks[item_count] = callbacks.host; + item_count++; + items[item_count] = "Join Game"; + item_callbacks[item_count] = callbacks.join; + item_count++; + } else { + items[item_count] = "Disconnect"; + item_callbacks[item_count] = callbacks.disconnect; + item_count++; + } + items[item_count] = "Status"; + item_callbacks[item_count] = callbacks.status; + item_count++; + + if (selected >= item_count) selected = item_count - 1; + + GFX_startFrame(); + PAD_poll(); + + if (PAD_justRepeated(BTN_UP)) { + selected--; + if (selected < 0) selected = item_count - 1; + dirty = 1; + } + else if (PAD_justRepeated(BTN_DOWN)) { + selected++; + if (selected >= item_count) selected = 0; + dirty = 1; + } + else if (PAD_justPressed(BTN_B)) { + show_menu = 0; + } + else if (PAD_justPressed(BTN_A)) { + int result = item_callbacks[selected](NULL, selected); + if (result == MENU_CALLBACK_EXIT || *force_resume) { + show_menu = 0; + } + dirty = 1; + } + + if (*force_resume) { + show_menu = 0; + } + + PWR_update(&dirty, NULL, minarch_beforeSleep, minarch_afterSleep); + + if (dirty) { + renderLinkMenuUI("Netplay", items, item_count, selected, getHint); + dirty = 0; + } + + minarch_hdmimon(); + } + + return *force_resume; +} + +////////////////////////////////////////////////////////////////////////////// +// Link Cleanup +////////////////////////////////////////////////////////////////////////////// + +void Netplay_quitAll(void) { + GBLink_quit(); + GBALink_quit(); + Netplay_quit(); +} diff --git a/workspace/all/netplay/netplay_helper.h b/workspace/all/netplay/netplay_helper.h new file mode 100644 index 000000000..6ed6a4bc2 --- /dev/null +++ b/workspace/all/netplay/netplay_helper.h @@ -0,0 +1,348 @@ +/* + * NextUI Netplay Helper Module + * Extracted UI helpers and orchestration functions for netplay menus + * + * This module contains the UI rendering and orchestration code that was + * previously in minarch.c. It's been extracted to keep minarch.c focused + * on core emulator functionality. + */ + +#ifndef NETPLAY_HELPER_H +#define NETPLAY_HELPER_H + +#include +#include +#include +#include "minarch.h" +#include "netplay.h" +#include "gbalink.h" +#include "gblink.h" + +// Link type enum for unified handling of all link types +typedef enum { + LINK_TYPE_NETPLAY, + LINK_TYPE_GBALINK, + LINK_TYPE_GBLINK +} LinkType; + +// Result of checking core link support capabilities +typedef struct { + bool show_netplay; // true if any link type is supported + bool has_netpacket; // true if GBALink (netpacket interface) is supported + bool has_gblink; // true if GBLink (gambatte network) is supported +} CoreLinkSupport; + +////////////////////////////////////////////////////////////////////////////// +// State Variables (defined in netplay_helper.c, used by minarch.c) +////////////////////////////////////////////////////////////////////////////// + +// Force resume flags - set when connection succeeds to auto-close menus +extern int netplay_force_resume; +extern int gbalink_force_resume; +extern int gblink_force_resume; + +// Track if client connected to hotspot (for WiFi restoration on disconnect) +extern int netplay_connected_to_hotspot; +extern int gbalink_connected_to_hotspot; +extern int gblink_connected_to_hotspot; + +// Store the hotspot SSID client connected to (shared - only one game runs at a time) +extern char connected_hotspot_ssid[33]; + +// Non-blocking async hotspot stop + WiFi restoration +// Use this when host disconnects via menu to avoid 5-10 second delays +// is_host: true if we were hosting (need to stop hotspot), false if client (just restore WiFi) +void stopHotspotAndRestoreWiFiAsync(bool is_host); + +////////////////////////////////////////////////////////////////////////////// +// WiFi/Network Helpers +////////////////////////////////////////////////////////////////////////////// + +/** + * Ensure WiFi is enabled before netplay/link operations + * Shows UI during WiFi enable process + * @return true if WiFi is enabled and ready, false if user cancelled or timeout + */ +bool ensureWifiEnabled(void); + +/** + * Check network connectivity for WiFi mode + * Shows error message if not connected + * @param type Link type for error message context + * @param action Action string for error message (e.g., "hosting", "joining") + * @return true if connected, false if not (shows error message) + */ +bool ensureNetworkConnected(LinkType type, const char* action); + +////////////////////////////////////////////////////////////////////////////// +// UI Helpers +////////////////////////////////////////////////////////////////////////////// + +/** + * Show a centered message over darkened game background + * @param msg Message to display + */ +void showOverlayMessage(const char* msg); + +/** + * Show "Connected!" success screen with timeout + * @param timeout_ms How long to show the screen (can be dismissed with A) + */ +void showConnectedSuccess(uint32_t timeout_ms); + +/** + * Show mode selection UI for WiFi/Hotspot choice + * @param title Title to display (e.g., "Host Game", "Join Game") + * @return 0 = Hotspot, 1 = WiFi, -1 = Cancelled. + */ +int Menu_selectConnectionMode(const char* title); + +////////////////////////////////////////////////////////////////////////////// +// Host Discovery State Accessors +// These provide uniform access to the type-specific host arrays +////////////////////////////////////////////////////////////////////////////// + +const char* getHostGameName(LinkType type, int index); +const char* getHostIP(LinkType type, int index); +int getHostPort(LinkType type, int index); +int getHostCount(LinkType type); +void setHostCount(LinkType type, int count); +int isLinkConnected(LinkType type); +int* getForceResumeFlag(LinkType type); + +/** + * Check if any multiplayer session is active (Netplay, GBALink, or GBLink) + * @return 1 if any link type is connected, 0 otherwise + */ +int Multiplayer_isActive(void); + +/** + * Check which link types a core supports + * @param core_name Core name (e.g., "gpsp", "gambatte", "fbneo") + * @return CoreLinkSupport struct with support flags + */ +CoreLinkSupport checkCoreLinkSupport(const char* core_name); + +////////////////////////////////////////////////////////////////////////////// +// Rendering Helpers +////////////////////////////////////////////////////////////////////////////// + +/** + * Show "Searching for hosts..." screen + */ +void showSearchingScreen(void); + +/** + * Show "Connecting to {ip}..." screen + * @param host_ip IP address being connected to + */ +void showConnectingScreen(const char* host_ip); + +/** + * Render host selection list with pills (standardized UI) + * @param type Link type for host list + * @param selected Currently selected index + * @param host_count Number of hosts to display + */ +void renderHostSelectionList(LinkType type, int selected, int host_count); + +////////////////////////////////////////////////////////////////////////////// +// Orchestration Functions +////////////////////////////////////////////////////////////////////////////// + +/** + * Common host game implementation for all link types + * Handles mode selection, WiFi enable, and delegates to WiFi/Hotspot specific functions + * @param type Link type + * @param list Menu list (unused, for callback signature) + * @param i Menu item index (unused) + * @return MENU_CALLBACK_NOP or MENU_CALLBACK_EXIT + */ +int hostGame_common(LinkType type, void* list, int i); + +/** + * Common WiFi host implementation for all link types + * @param type Link type + * @param game_name Game name for discovery + * @param crc Game CRC for verification + * @return MENU_CALLBACK_NOP or MENU_CALLBACK_EXIT + */ +int hostGameWiFi_common(LinkType type, const char* game_name, uint32_t crc); + +/** + * Common hotspot host implementation for all link types + * @param type Link type + * @param game_name Game name for discovery + * @param crc Game CRC for verification + * @return MENU_CALLBACK_NOP or MENU_CALLBACK_EXIT + */ +int hostGameHotspot_common(LinkType type, const char* game_name, uint32_t crc); + +/** + * Common join game implementation for all link types + * Handles mode selection and delegates to WiFi/Hotspot specific functions + * @param type Link type + * @param list Menu list (unused, for callback signature) + * @param i Menu item index (unused) + * @return MENU_CALLBACK_NOP or MENU_CALLBACK_EXIT + */ +int joinGame_common(LinkType type, void* list, int i); + +/** + * Common WiFi join implementation for all link types + * Handles discovery and host selection + * @param type Link type + * @return MENU_CALLBACK_NOP or MENU_CALLBACK_EXIT + */ +int joinGameWiFi_common(LinkType type); + +/** + * Unified hotspot join implementation for all link types + * @param type Link type + * @return MENU_CALLBACK_NOP or MENU_CALLBACK_EXIT + */ +int joinGame_Hotspot_common(LinkType type); + +/** + * Common disconnect implementation for all link types + * @param type Link type + * @param list Menu list (unused, for callback signature) + * @param i Menu item index (unused) + * @return MENU_CALLBACK_NOP + */ +int disconnect_common(LinkType type, void* list, int i); + +////////////////////////////////////////////////////////////////////////////// +// Utility Functions +////////////////////////////////////////////////////////////////////////////// + +/** + * Calculate simple CRC from game data + * @return CRC32-like checksum + */ +uint32_t calculateGameCRC(void); + +/** + * Get game name from current ROM (without extension) + * @param buf Buffer to receive game name + * @param buf_size Size of buffer + */ +void getGameName(char* buf, size_t buf_size); + +/** + * Show a transition message (e.g., "Disconnecting...", "Connecting...") + * @param message Message to display + */ +void showTransitionMessage(const char* message); + +/** + * Show a timed confirmation message that can be dismissed with A/B + * @param message Message to display + * @param duration_ms Duration to show (can be dismissed early) + */ +void showTimedConfirmation(const char* message, int duration_ms); + +/** + * Format and display link status message + */ +void showLinkStatusMessage( + const char* title, + const char* mode_str, + const char* conn_str, + const char* state_str, + const char* code, // NULL if not hotspot host + const char* local_ip, + const char* status_msg +); + +/** + * Render link menu UI + * @param title Menu title + * @param items Array of menu item labels + * @param item_count Number of items + * @param selected Currently selected index + * @param getHint Optional hint function, can be NULL + */ +void renderLinkMenuUI( + const char* title, + char** items, + int item_count, + int selected, + const char* (*getHint)(void) +); + +////////////////////////////////////////////////////////////////////////////// +// Status & Menu Functions +////////////////////////////////////////////////////////////////////////////// + +/** + * Unified status display for all link types + * @param type Link type + * @return MENU_CALLBACK_NOP + */ +int status_common(LinkType type); + +// Menu option handlers for each link type +int OptionNetplay_hostGame(void* list, int i); +int OptionNetplay_joinGame(void* list, int i); +int OptionNetplay_disconnect(void* list, int i); +int OptionNetplay_status(void* list, int i); + +int OptionGBALink_hostGame(void* list, int i); +int OptionGBALink_joinGame(void* list, int i); +int OptionGBALink_disconnect(void* list, int i); +int OptionGBALink_status(void* list, int i); + +int OptionGBLink_hostGame(void* list, int i); +int OptionGBLink_joinGame(void* list, int i); +int OptionGBLink_disconnect(void* list, int i); +int OptionGBLink_status(void* list, int i); + +/** + * Get contextual hints for netplay menus (GBA-specific hints) + * @return Hint string or NULL if no hint applicable + */ +const char* getNetplayMenuHint(void); + +/** + * Get the hint function for a given link type + * @param type Link type + * @return Hint function pointer or NULL + */ +const char* (*getLinkMenuHint(LinkType type))(void); + +/** + * Main netplay/link menu handler + * @param type Link type to show menu for + * @return 1 if should resume game immediately (force_resume), 0 otherwise + */ +int Netplay_menu_link(LinkType type); + +////////////////////////////////////////////////////////////////////////////// +// Hotspot Waiting Screen Helpers +////////////////////////////////////////////////////////////////////////////// + +/** + * Render hotspot host waiting screen with code + * @param code 4-character hotspot code + */ +void renderHotspotWaitingScreen(const char* code); + +/** + * Render WiFi host waiting screen with IP address + * @param ip Local IP address + */ +void renderWiFiWaitingScreen(const char* ip); + +/** + * Show connection success screen (3-second countdown, skippable with A) + */ +void showConnectionSuccessScreen(void); + +/** + * Clean up all link sessions (GBLink, GBALink, Netplay) + * Call before quit to ensure clean shutdown + */ +void Netplay_quitAll(void); + +#endif /* NETPLAY_HELPER_H */ diff --git a/workspace/all/netplay/network_common.c b/workspace/all/netplay/network_common.c new file mode 100644 index 000000000..4bd75070d --- /dev/null +++ b/workspace/all/netplay/network_common.c @@ -0,0 +1,292 @@ +/* + * NextUI Network Common Module + * Shared networking utilities for netplay and gbalink + */ + +#include "network_common.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Default TCP configuration +static const NET_TCPConfig DEFAULT_TCP_CONFIG = { + .buffer_size = 65536, // 64KB + .recv_timeout_us = 0, // No timeout + .enable_keepalive = false +}; + +// Character set for SSID generation (excludes confusing chars: 0/O, 1/I) +static const char* SSID_CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +static const int SSID_CHARSET_LEN = 32; + +////////////////////////////////////////////////////////////////////////////// +// IP Address Utilities +////////////////////////////////////////////////////////////////////////////// + +void NET_getLocalIP(char* ip_out, size_t ip_size) { + if (!ip_out || ip_size < 16) return; + + strncpy(ip_out, "0.0.0.0", ip_size - 1); + ip_out[ip_size - 1] = '\0'; + + struct ifaddrs* ifaddr; + if (getifaddrs(&ifaddr) == -1) return; + + for (struct ifaddrs* ifa = ifaddr; ifa != NULL; ifa = ifa->ifa_next) { + if (ifa->ifa_addr == NULL || ifa->ifa_addr->sa_family != AF_INET) continue; + if (strcmp(ifa->ifa_name, "lo") == 0) continue; + + struct sockaddr_in* addr = (struct sockaddr_in*)ifa->ifa_addr; + inet_ntop(AF_INET, &addr->sin_addr, ip_out, ip_size); + + // Prefer wlan interfaces + if (strncmp(ifa->ifa_name, "wlan", 4) == 0) break; + } + + freeifaddrs(ifaddr); +} + +bool NET_hasConnection(void) { + char ip[16]; + NET_getLocalIP(ip, sizeof(ip)); + return strcmp(ip, "0.0.0.0") != 0; +} + +////////////////////////////////////////////////////////////////////////////// +// TCP Socket Configuration +////////////////////////////////////////////////////////////////////////////// + +void NET_configureTCPSocket(int fd, const NET_TCPConfig* config) { + if (fd < 0) return; + + const NET_TCPConfig* cfg = config ? config : &DEFAULT_TCP_CONFIG; + + // Enable TCP_NODELAY to disable Nagle's algorithm + int opt = 1; + setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt)); + + // Set socket buffer sizes + if (cfg->buffer_size > 0) { + setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &cfg->buffer_size, sizeof(cfg->buffer_size)); + setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &cfg->buffer_size, sizeof(cfg->buffer_size)); + } + + // Set receive timeout if specified + if (cfg->recv_timeout_us > 0) { + struct timeval tv = { + .tv_sec = cfg->recv_timeout_us / 1000000, + .tv_usec = cfg->recv_timeout_us % 1000000 + }; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + } + + // Enable keepalive if requested + if (cfg->enable_keepalive) { + int keepalive = 1; + setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive)); + } +} + +////////////////////////////////////////////////////////////////////////////// +// Server Socket Creation +////////////////////////////////////////////////////////////////////////////// + +int NET_createListenSocket(uint16_t port, char* error_msg, size_t error_size) { + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + if (error_msg && error_size > 0) { + snprintf(error_msg, error_size, "Socket creation failed"); + } + return -1; + } + + int opt = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = INADDR_ANY; + addr.sin_port = htons(port); + + if (bind(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { + close(fd); + if (error_msg && error_size > 0) { + snprintf(error_msg, error_size, "Bind failed on port %d", port); + } + return -1; + } + + if (listen(fd, 1) < 0) { + close(fd); + if (error_msg && error_size > 0) { + snprintf(error_msg, error_size, "Listen failed"); + } + return -1; + } + + return fd; +} + +int NET_createBroadcastSocket(void) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd >= 0) { + int broadcast = 1; + setsockopt(fd, SOL_SOCKET, SO_BROADCAST, &broadcast, sizeof(broadcast)); + } + return fd; +} + +int NET_createDiscoveryListenSocket(uint16_t port) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return -1; + + int opt = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = INADDR_ANY; + addr.sin_port = htons(port); + + if (bind(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { + close(fd); + return -1; + } + + // Set non-blocking + int flags = fcntl(fd, F_GETFL, 0); + fcntl(fd, F_SETFL, flags | O_NONBLOCK); + + return fd; +} + +////////////////////////////////////////////////////////////////////////////// +// Hotspot Utilities +////////////////////////////////////////////////////////////////////////////// + +void NET_generateHotspotSSID(char* ssid_out, size_t ssid_size, const NET_HotspotConfig* config) { + if (!ssid_out || ssid_size < 16 || !config || !config->prefix) return; + + // Seed random with provided seed + srand(config->seed); + + // Generate 4-character random code + char code[5]; + for (int i = 0; i < 4; i++) { + code[i] = SSID_CHARSET[rand() % SSID_CHARSET_LEN]; + } + code[4] = '\0'; + + snprintf(ssid_out, ssid_size, "%s%s", config->prefix, code); +} + +////////////////////////////////////////////////////////////////////////////// +// Broadcast Timer +////////////////////////////////////////////////////////////////////////////// + +void NET_initBroadcastTimer(NET_BroadcastTimer* timer, int interval_us) { + if (!timer) return; + timer->last_broadcast.tv_sec = 0; + timer->last_broadcast.tv_usec = 0; + timer->interval_us = interval_us; +} + +bool NET_shouldBroadcast(NET_BroadcastTimer* timer) { + if (!timer) return false; + + struct timeval now; + gettimeofday(&now, NULL); + + long elapsed_us = (now.tv_sec - timer->last_broadcast.tv_sec) * 1000000 + + (now.tv_usec - timer->last_broadcast.tv_usec); + + if (elapsed_us >= timer->interval_us) { + timer->last_broadcast = now; + return true; + } + + return false; +} + +////////////////////////////////////////////////////////////////////////////// +// Discovery Utilities +////////////////////////////////////////////////////////////////////////////// + +void NET_sendDiscoveryBroadcast(int udp_fd, uint32_t magic, uint32_t protocol_version, + uint32_t game_crc, uint16_t tcp_port, + uint16_t discovery_port, const char* game_name, + const char* link_mode) { + if (udp_fd < 0) return; + + NET_DiscoveryPacket pkt = {0}; + pkt.magic = htonl(magic); + pkt.protocol_version = htonl(protocol_version); + pkt.game_crc = htonl(game_crc); + pkt.port = htons(tcp_port); + if (game_name) { + strncpy(pkt.game_name, game_name, NET_MAX_GAME_NAME - 1); + } + if (link_mode) { + strncpy(pkt.link_mode, link_mode, NET_MAX_LINK_MODE - 1); + } + + struct sockaddr_in bcast = {0}; + bcast.sin_family = AF_INET; + bcast.sin_addr.s_addr = INADDR_BROADCAST; + bcast.sin_port = htons(discovery_port); + + sendto(udp_fd, &pkt, sizeof(pkt), 0, + (struct sockaddr*)&bcast, sizeof(bcast)); +} + +int NET_receiveDiscoveryResponses(int udp_fd, uint32_t expected_magic, + NET_HostInfo* hosts, int* current_count, + int max_hosts) { + if (udp_fd < 0 || !hosts || !current_count) return 0; + + NET_DiscoveryPacket pkt; + struct sockaddr_in sender; + socklen_t sender_len = sizeof(sender); + + while (recvfrom(udp_fd, &pkt, sizeof(pkt), MSG_DONTWAIT, + (struct sockaddr*)&sender, &sender_len) == sizeof(pkt)) { + if (ntohl(pkt.magic) != expected_magic) continue; + + char ip[16]; + inet_ntop(AF_INET, &sender.sin_addr, ip, sizeof(ip)); + + // Check for duplicates + bool found = false; + for (int i = 0; i < *current_count; i++) { + if (strcmp(hosts[i].host_ip, ip) == 0) { + found = true; + break; + } + } + + if (!found && *current_count < max_hosts) { + NET_HostInfo* h = &hosts[*current_count]; + strncpy(h->game_name, pkt.game_name, NET_MAX_GAME_NAME - 1); + h->game_name[NET_MAX_GAME_NAME - 1] = '\0'; + strncpy(h->host_ip, ip, sizeof(h->host_ip) - 1); + h->host_ip[sizeof(h->host_ip) - 1] = '\0'; + h->port = ntohs(pkt.port); + h->game_crc = ntohl(pkt.game_crc); + strncpy(h->link_mode, pkt.link_mode, NET_MAX_LINK_MODE - 1); + h->link_mode[NET_MAX_LINK_MODE - 1] = '\0'; + (*current_count)++; + } + + sender_len = sizeof(sender); // Reset for next iteration + } + + return *current_count; +} diff --git a/workspace/all/netplay/network_common.h b/workspace/all/netplay/network_common.h new file mode 100644 index 000000000..0f31d163e --- /dev/null +++ b/workspace/all/netplay/network_common.h @@ -0,0 +1,182 @@ +/* + * NextUI Network Common Module + * Shared networking utilities for netplay and gbalink + */ + +#ifndef NETWORK_COMMON_H +#define NETWORK_COMMON_H + +#include +#include +#include +#include + +// Unified SSID prefix for all link hotspots (Netplay, GBALink, GBLink) +#define LINK_HOTSPOT_SSID_PREFIX "NextUI-" + +// Configuration for TCP socket setup +typedef struct { + int buffer_size; // SO_SNDBUF/SO_RCVBUF size (bytes) + int recv_timeout_us; // SO_RCVTIMEO in microseconds (0 = none) + bool enable_keepalive; // SO_KEEPALIVE +} NET_TCPConfig; + +// Configuration for hotspot SSID generation +typedef struct { + const char* prefix; // Use LINK_HOTSPOT_SSID_PREFIX for all link types + uint32_t seed; // Random seed (typically game_crc ^ time) +} NET_HotspotConfig; + +// Rate-limited broadcast timer +typedef struct { + struct timeval last_broadcast; + int interval_us; +} NET_BroadcastTimer; + +// Maximum game name length for discovery +#define NET_MAX_GAME_NAME 64 +#define NET_MAX_DISCOVERED_HOSTS 8 + +// Maximum link mode length for discovery +#define NET_MAX_LINK_MODE 32 + +// Generic discovery packet (wire format) +typedef struct __attribute__((packed)) { + uint32_t magic; + uint32_t protocol_version; + uint32_t game_crc; + uint16_t port; + char game_name[NET_MAX_GAME_NAME]; + char link_mode[NET_MAX_LINK_MODE]; // Link mode for compatibility check (e.g., "mul_poke", "rfu") +} NET_DiscoveryPacket; + +// Generic host info (for discovered hosts list) +typedef struct { + char game_name[NET_MAX_GAME_NAME]; + char host_ip[16]; + uint16_t port; + uint32_t game_crc; + char link_mode[NET_MAX_LINK_MODE]; // Host's link mode for compatibility check +} NET_HostInfo; + +////////////////////////////////////////////////////////////////////////////// +// IP Address Utilities +////////////////////////////////////////////////////////////////////////////// + +/** + * Get the local IP address (preferring wlan interfaces) + * @param ip_out Buffer to receive IP string + * @param ip_size Size of ip_out buffer (should be at least 16 bytes) + */ +void NET_getLocalIP(char* ip_out, size_t ip_size); + +/** + * Check if device has a valid network connection + * @return true if connected (has non-0.0.0.0 IP), false otherwise + */ +bool NET_hasConnection(void); + +////////////////////////////////////////////////////////////////////////////// +// TCP Socket Configuration +////////////////////////////////////////////////////////////////////////////// + +/** + * Configure TCP socket with common options (TCP_NODELAY, buffer sizes, etc.) + * @param fd Socket file descriptor + * @param config Configuration options (NULL for default: 64KB buffers, no timeout) + */ +void NET_configureTCPSocket(int fd, const NET_TCPConfig* config); + +////////////////////////////////////////////////////////////////////////////// +// Server Socket Creation +////////////////////////////////////////////////////////////////////////////// + +/** + * Create a listening TCP socket bound to a port + * @param port Port number to bind to + * @param error_msg Buffer to receive error message on failure (can be NULL) + * @param error_size Size of error_msg buffer + * @return Socket fd on success, -1 on failure + */ +int NET_createListenSocket(uint16_t port, char* error_msg, size_t error_size); + +/** + * Create a UDP socket for broadcasting + * @return Socket fd on success, -1 on failure + */ +int NET_createBroadcastSocket(void); + +/** + * Create a non-blocking UDP socket for discovery listening + * Binds to INADDR_ANY on the specified port with SO_REUSEADDR + * @param port Port to bind to for receiving discovery broadcasts + * @return Socket fd on success, -1 on failure + */ +int NET_createDiscoveryListenSocket(uint16_t port); + +////////////////////////////////////////////////////////////////////////////// +// Hotspot Utilities +////////////////////////////////////////////////////////////////////////////// + +/** + * Generate a hotspot SSID with random suffix + * Format: "{prefix}XXXX" where XXXX is a random 4-character code + * @param ssid_out Buffer to receive SSID string + * @param ssid_size Size of ssid_out buffer (should be at least 33 bytes) + * @param config Hotspot configuration (prefix and random seed) + */ +void NET_generateHotspotSSID(char* ssid_out, size_t ssid_size, const NET_HotspotConfig* config); + +////////////////////////////////////////////////////////////////////////////// +// Broadcast Timer +////////////////////////////////////////////////////////////////////////////// + +/** + * Initialize a broadcast timer for rate-limiting + * @param timer Timer to initialize + * @param interval_us Minimum interval between broadcasts in microseconds + */ +void NET_initBroadcastTimer(NET_BroadcastTimer* timer, int interval_us); + +/** + * Check if enough time has passed to broadcast again + * Updates the timer's last_broadcast time if returning true + * @param timer Timer to check + * @return true if should broadcast, false if too soon + */ +bool NET_shouldBroadcast(NET_BroadcastTimer* timer); + +////////////////////////////////////////////////////////////////////////////// +// Discovery Utilities +////////////////////////////////////////////////////////////////////////////// + +/** + * Send a discovery broadcast packet + * @param udp_fd UDP socket file descriptor + * @param magic Protocol magic number (host byte order - will be converted) + * @param protocol_version Protocol version + * @param game_crc Game CRC for matching + * @param tcp_port TCP port to advertise + * @param discovery_port UDP port to broadcast to + * @param game_name Game name string + * @param link_mode Link mode string (e.g., "mul_poke", "rfu") - can be NULL + */ +void NET_sendDiscoveryBroadcast(int udp_fd, uint32_t magic, uint32_t protocol_version, + uint32_t game_crc, uint16_t tcp_port, + uint16_t discovery_port, const char* game_name, + const char* link_mode); + +/** + * Receive and deduplicate discovery responses + * @param udp_fd UDP socket file descriptor + * @param expected_magic Expected magic number (host byte order) + * @param hosts Array to store discovered hosts + * @param current_count Pointer to current count (updated in place) + * @param max_hosts Maximum hosts to store + * @return Updated host count + */ +int NET_receiveDiscoveryResponses(int udp_fd, uint32_t expected_magic, + NET_HostInfo* hosts, int* current_count, + int max_hosts); + +#endif /* NETWORK_COMMON_H */ diff --git a/workspace/all/netplay/wifi_direct.c b/workspace/all/netplay/wifi_direct.c new file mode 100644 index 000000000..e9344d6b6 --- /dev/null +++ b/workspace/all/netplay/wifi_direct.c @@ -0,0 +1,397 @@ +/* + * WiFi Direct - netplay WiFi helper + * + * Client-side operations (scan / connect / disconnect / IP / save+restore) + * delegate to the platform WiFi stack (common/generic_wifi.c, exposed via the + * WIFI_* / PLAT_wifi* API in api.h) so netplay does not ship a second wpa_cli + * client implementation. + * + * Only the hotspot/AP side is implemented here: the platform stack has no + * Access Point support, and netplay *hosting* needs one (hostapd + udhcpd on + * wlan0, handing the interface between wpa_supplicant and hostapd by role). + */ + +#include "wifi_direct.h" +#include "defines.h" +#include "api.h" // WIFI_*/PLAT_wifi* client API, WIFI_network/WIFI_connection, LOG_* + +#include +#include +#include +#include + +// Upper bound on networks pulled from a single platform scan (stack buffer). +#define WD_SCAN_MAX 64 + +// Static state for hotspot +static bool hotspot_active = false; +static char hotspot_ssid[WIFI_DIRECT_SSID_MAX] = {0}; +static char hotspot_previous_ssid[128] = {0}; + +// Helper to sleep in milliseconds +static void wifi_sleep_ms(int ms) { + usleep(ms * 1000); +} + +// Request a DHCP lease on wlan0 and wait (briefly) for a usable IP. Needed when +// joining a host's hotspot, whose udhcpd serves the 10.0.0.x range -- the platform +// connect path associates but does not run a DHCP client itself. +static bool wifi_acquire_dhcp(void) { + // Drop any stale IP AND routes first. Leaving the old address makes us mistake + // it for the new lease; leaving the old routes (e.g. "default via 192.168.1.1" + // from home wifi) means we can't actually reach the host at 10.0.0.1 even once + // udhcpc assigns 10.0.0.x. udhcpc then installs the correct address + routes. + system("ip addr flush dev wlan0 2>/dev/null"); + system("ip route flush dev wlan0 2>/dev/null"); + system("killall udhcpc 2>/dev/null"); + wifi_sleep_ms(500); // let the freshly-associated link settle before DHCP + + // Run udhcpc SYNCHRONOUSLY (foreground), not backgrounded: a "udhcpc ... &" + // launched via system() did not reliably obtain/apply a lease, but running it in + // the foreground does. -n = exit if no lease, -q = quit once obtained. The first + // DISCOVER right after association is often dropped, so retry a few times. + for (int attempt = 0; attempt < 3; attempt++) { + if (system("udhcpc -i wlan0 -n -q -t 6 2>/dev/null") == 0) { + char ip[32]; + if (WIFI_direct_getIP(ip, sizeof(ip)) == 0 && ip[0] && strcmp(ip, "0.0.0.0") != 0) + return true; + } + wifi_sleep_ms(300); + } + return false; +} + +////////////////////////////////// +// WiFi Client Functions (wlan0) - thin wrappers over the platform WiFi stack +////////////////////////////////// + +bool WIFI_direct_ensureReady(void) { + // Bring the platform WiFi stack up if it is currently disabled. wifi_init.sh + // (run by PLAT_wifiEnable) brings up wlan0 and starts wpa_supplicant. + if (!WIFI_enabled()) { + WIFI_enable(true); + wifi_sleep_ms(1000); // give the supplicant a moment to come up + } + return WIFI_enabled(); +} + +void WIFI_direct_triggerScan(void) { + // No-op: PLAT_wifiScan() triggers, waits, and reads results in one blocking call. +} + +int WIFI_direct_scanNetworks(WIFI_direct_network_t* networks, int max_count) { + if (!networks || max_count <= 0) return 0; + + struct WIFI_network found[WD_SCAN_MAX]; + int want = max_count < WD_SCAN_MAX ? max_count : WD_SCAN_MAX; + int n = WIFI_scan(found, want); + if (n < 0) return 0; + + int count = 0; + for (int i = 0; i < n && count < max_count; i++) { + if (found[i].ssid[0] == '\0') continue; + strncpy(networks[count].ssid, found[i].ssid, WIFI_DIRECT_SSID_MAX - 1); + networks[count].ssid[WIFI_DIRECT_SSID_MAX - 1] = '\0'; + networks[count].rssi = found[i].rssi; + networks[count].is_secured = (found[i].security != SECURITY_NONE); + networks[count].has_saved_creds = WIFI_isKnown(found[i].ssid, found[i].security); + count++; + } + return count; +} + +int WIFI_direct_getCurrentSSID(char* ssid_out, size_t ssid_size) { + if (!ssid_out || ssid_size < 1) return -1; + ssid_out[0] = '\0'; + + struct WIFI_connection info; + if (WIFI_connectionInfo(&info) != 0 || !info.valid || info.ssid[0] == '\0') return -1; + strncpy(ssid_out, info.ssid, ssid_size - 1); + ssid_out[ssid_size - 1] = '\0'; + return 0; +} + +bool WIFI_direct_isConnected(void) { + return WIFI_connected(); +} + +void WIFI_direct_saveCurrentConnection(void) { + struct WIFI_connection info; + if (WIFI_connectionInfo(&info) == 0 && info.valid && info.ssid[0] != '\0') { + strncpy(hotspot_previous_ssid, info.ssid, sizeof(hotspot_previous_ssid) - 1); + hotspot_previous_ssid[sizeof(hotspot_previous_ssid) - 1] = '\0'; + } +} + +int WIFI_direct_connect(const char* ssid, const char* pass) { + if (!ssid) return -1; + + bool has_pass = (pass && pass[0] != '\0'); + WIFI_connectPass(ssid, has_pass ? SECURITY_WPA2_PSK : SECURITY_NONE, has_pass ? pass : NULL); + + // WIFI_connectPass uses enable_network, which leaves other saved networks (e.g. + // home wifi) enabled. When joining a host hotspot the home network is usually + // higher priority, so wpa_supplicant associates to it instead -- we then sit on + // the wrong subnet and can't reach the host at 10.0.0.1. Force an exclusive + // selection of the requested SSID, then confirm we actually landed on it: + // WIFI_connected() alone only checks wpa_state, not which network we joined. + WIFI_selectOnly(ssid); + + bool on_target = false; + char current[128]; + for (int i = 0; i < 20; i++) { // up to ~10s for the (re)association to settle + if (WIFI_connected() && + WIFI_direct_getCurrentSSID(current, sizeof(current)) == 0 && + strcmp(current, ssid) == 0) { + on_target = true; + break; + } + wifi_sleep_ms(500); + } + + if (!on_target) { + LOG_error("WIFI_direct_connect: failed to associate to '%s'\n", ssid); + return -1; + } + + // Associated to the right AP; pull a DHCP lease from the host's udhcpd (best effort). + if (!wifi_acquire_dhcp()) { + LOG_error("WIFI_direct_connect: DHCP timeout - no IP assigned\n"); + } + return 0; +} + +void WIFI_direct_disconnect(void) { + WIFI_disconnect(); +} + +void WIFI_direct_forget(const char* ssid) { + if (!ssid || !ssid[0]) return; + WIFI_forget((char*)ssid, SECURITY_WPA2_PSK); +} + +// Remove ALL saved netplay hotspot networks, not just the current session's. Each +// join adds a uniquely-named hotspot network; sessions that don't end cleanly leave +// theirs behind, so without a prefix sweep wpa_supplicant.conf accumulates dozens of +// stale NextUI-*/GBLink-*/GBALink-* entries. Also re-enables any networks a prior +// select_network() left disabled so the saved config stays clean. Returns count removed. +int WIFI_direct_forgetAllHotspots(void) { + WIFI_enableAll(); + int removed = 0; + removed += WIFI_forgetPrefix(LINK_HOTSPOT_SSID_PREFIX); // current: "NextUI-" + removed += WIFI_forgetPrefix("GBLink-"); // legacy + removed += WIFI_forgetPrefix("GBALink-"); // legacy + return removed; +} + +int WIFI_direct_scanForHotspots(const char* prefix, char ssids_out[][WIFI_DIRECT_SSID_MAX], int max_count) { + if (!prefix || !ssids_out || max_count <= 0) return 0; + + size_t prefix_len = strlen(prefix); + struct WIFI_network found[WD_SCAN_MAX]; + int count = 0; + + // A couple of passes since hotspots can take a moment to appear. + for (int retry = 0; retry < 3 && count == 0; retry++) { + int n = WIFI_scan(found, WD_SCAN_MAX); + if (n < 0) continue; + for (int i = 0; i < n && count < max_count; i++) { + if (strncmp(found[i].ssid, prefix, prefix_len) == 0) { + strncpy(ssids_out[count], found[i].ssid, WIFI_DIRECT_SSID_MAX - 1); + ssids_out[count][WIFI_DIRECT_SSID_MAX - 1] = '\0'; + count++; + } + } + } + return count; +} + +int WIFI_direct_getIP(char* ip_out, size_t ip_size) { + if (!ip_out || ip_size < 16) return -1; + ip_out[0] = '\0'; + + struct WIFI_connection info; + if (WIFI_connectionInfo(&info) != 0 || !info.valid || info.ip[0] == '\0') return -1; + strncpy(ip_out, info.ip, ip_size - 1); + ip_out[ip_size - 1] = '\0'; + return 0; +} + +bool WIFI_direct_restorePreviousConnection(void) { + if (hotspot_previous_ssid[0] == '\0') return false; + + // Reconnect using the credentials wpa_supplicant already has for this SSID. + WIFI_connect(hotspot_previous_ssid, SECURITY_WPA2_PSK); + + for (int i = 0; i < 20; i++) { // up to ~10s + wifi_sleep_ms(500); + if (WIFI_connected()) { + char current[128]; + if (WIFI_direct_getCurrentSSID(current, sizeof(current)) == 0 && + strcmp(current, hotspot_previous_ssid) == 0) { + wifi_acquire_dhcp(); + hotspot_previous_ssid[0] = '\0'; + return true; + } + } + } + + hotspot_previous_ssid[0] = '\0'; + return false; +} + +////////////////////////////////// +// Hotspot Functions (wlan0 AP Mode) - no platform equivalent, kept here +////////////////////////////////// + +// The AP runs on wlan0 directly. A device is only ever host OR client in a +// session, so the host hands wlan0 from the client stack (wpa_supplicant) to +// hostapd, then hands it back on stop. This works on single-radio devices +// (tg5050, only wlan0) and on tg5040 (wlan0 + a boot-created wlan1 AP vif that +// must be removed first, since the radio allows only one AP interface). +int WIFI_direct_startHotspot(const char* ssid, const char* password) { + if (hotspot_active) { + return 0; + } + + // Save the current client connection (while wpa_supplicant is still up) so we + // can restore it after hosting. + WIFI_direct_saveCurrentConnection(); + + // Clean up any leftover hotspot processes from a previous session. + system("killall hostapd 2>/dev/null"); + system("killall udhcpd 2>/dev/null"); + + // Free the single AP slot: drop any pre-existing wlan1 AP vif (tg5040). No-op + // where it doesn't exist (tg5050). + system("iw dev wlan1 del 2>/dev/null"); + + // Hand wlan0 to hostapd: stop the client stack so hostapd can take exclusive + // nl80211 control of the interface. + system("killall wpa_supplicant 2>/dev/null"); + system("killall udhcpc 2>/dev/null"); + wifi_sleep_ms(300); + + // Reset wlan0 and bring it up for AP mode. + system("ip addr flush dev wlan0 2>/dev/null"); + system("ip link set wlan0 down 2>/dev/null"); + wifi_sleep_ms(100); + system("ip link set wlan0 up 2>/dev/null"); + wifi_sleep_ms(200); + + // Create hostapd config + FILE* f = fopen("/tmp/gbalink_hostapd.conf", "w"); + if (!f) { + LOG_error("WIFI_direct_startHotspot: failed to create hostapd config\n"); + WIFI_enable(true); // restore the client stack + return -1; + } + fprintf(f, "interface=wlan0\n"); + fprintf(f, "driver=nl80211\n"); + fprintf(f, "ssid=%s\n", ssid); + fprintf(f, "channel=6\n"); + fprintf(f, "hw_mode=g\n"); + fprintf(f, "auth_algs=1\n"); + fprintf(f, "wpa=2\n"); + fprintf(f, "wpa_passphrase=%s\n", password); + fprintf(f, "wpa_key_mgmt=WPA-PSK\n"); + fprintf(f, "rsn_pairwise=CCMP\n"); + fclose(f); + + // Create udhcpd config + f = fopen("/tmp/gbalink_udhcpd.conf", "w"); + if (!f) { + LOG_error("WIFI_direct_startHotspot: failed to create udhcpd config\n"); + WIFI_enable(true); + return -1; + } + fprintf(f, "start 10.0.0.10\n"); + fprintf(f, "end 10.0.0.50\n"); + fprintf(f, "interface wlan0\n"); + fprintf(f, "pidfile /tmp/gbalink_udhcpd.pid\n"); + fprintf(f, "lease_file /tmp/gbalink_udhcpd.leases\n"); + fprintf(f, "option subnet 255.255.255.0\n"); + fprintf(f, "option router 10.0.0.1\n"); + fclose(f); + + // Start hostapd (switches wlan0 into AP mode). + int ret = system("hostapd -B /tmp/gbalink_hostapd.conf"); + if (ret != 0) { + LOG_error("WIFI_direct_startHotspot: failed to start hostapd\n"); + system("ip link set wlan0 down 2>/dev/null"); + WIFI_enable(true); + return -1; + } + + // Assign the AP IP on wlan0. + system("ip addr add 10.0.0.1/24 dev wlan0 2>/dev/null"); + + // Start DHCP server + ret = system("udhcpd /tmp/gbalink_udhcpd.conf"); + if (ret != 0) { + LOG_error("WIFI_direct_startHotspot: failed to start udhcpd\n"); + system("killall hostapd 2>/dev/null"); + system("ip addr flush dev wlan0 2>/dev/null"); + system("ip link set wlan0 down 2>/dev/null"); + WIFI_enable(true); + return -1; + } + + strncpy(hotspot_ssid, ssid, sizeof(hotspot_ssid) - 1); + hotspot_ssid[sizeof(hotspot_ssid) - 1] = '\0'; + hotspot_active = true; + return 0; +} + +int WIFI_direct_stopHotspot(void) { + if (!hotspot_active) { + return 0; + } + + // Stop the AP and DHCP server. + system("killall hostapd 2>/dev/null"); + wifi_sleep_ms(200); // wait for hostapd to release wlan0 + system("kill $(cat /tmp/gbalink_udhcpd.pid 2>/dev/null) 2>/dev/null"); + system("killall udhcpd 2>/dev/null"); + + // Tear down the AP addressing on wlan0. + system("ip addr flush dev wlan0 2>/dev/null"); + system("ip link set wlan0 down 2>/dev/null"); + + // Cleanup temp files + system("rm -f /tmp/gbalink_*.conf /tmp/gbalink_*.pid /tmp/gbalink_*.leases 2>/dev/null"); + + hotspot_active = false; + hotspot_ssid[0] = '\0'; + // NOTE: Don't clear hotspot_previous_ssid here - it's needed by + // WIFI_direct_restorePreviousConnection() which is called later + + // Hand wlan0 back to the client stack: WIFI_enable(true) runs the platform's + // wifi_init.sh start, which restarts wpa_supplicant (+ dhcp) and reconnects to + // known networks. WIFI_direct_restorePreviousConnection() (called by the netplay + // flow afterwards) re-selects the saved network. + WIFI_enable(true); + wifi_sleep_ms(500); + + return 0; +} + +bool WIFI_direct_isHotspotActive(void) { + return hotspot_active; +} + +const char* WIFI_direct_getHotspotIP(void) { + return WIFI_DIRECT_HOTSPOT_IP; +} + +const char* WIFI_direct_getHotspotSSID(void) { + return hotspot_ssid; +} + +const char* WIFI_direct_getHotspotSSIDPrefix(void) { + return LINK_HOTSPOT_SSID_PREFIX; +} + +const char* WIFI_direct_getHotspotPassword(void) { + return WIFI_DIRECT_HOTSPOT_PASS; +} diff --git a/workspace/all/netplay/wifi_direct.h b/workspace/all/netplay/wifi_direct.h new file mode 100644 index 000000000..3bde6cbc9 --- /dev/null +++ b/workspace/all/netplay/wifi_direct.h @@ -0,0 +1,115 @@ +/* + * WiFi Direct - wpa_cli based WiFi operations for netplay + * Uses wpa_cli directly, bypassing wifi_daemon for more reliable operation + */ + +#ifndef WIFI_DIRECT_H +#define WIFI_DIRECT_H + +#include +#include +#include + +// Maximum SSID length +#define WIFI_DIRECT_SSID_MAX 33 + +// Unified SSID prefix for all link hotspots (shared with minarch via network_common.h) +#ifndef LINK_HOTSPOT_SSID_PREFIX +#define LINK_HOTSPOT_SSID_PREFIX "NextUI-" +#endif + +// Hotspot configuration +#define WIFI_DIRECT_HOTSPOT_IP "10.0.0.1" +#define WIFI_DIRECT_HOTSPOT_PASS "nextui123" + +// Network info structure for scan results +typedef struct { + char ssid[WIFI_DIRECT_SSID_MAX]; + int rssi; // Signal strength (negative dBm, e.g., -50 is strong, -90 is weak) + bool is_secured; // true if WPA/WPA2/WEP + bool has_saved_creds; // true if we have saved credentials for this network +} WIFI_direct_network_t; + +////////////////////////////////// +// WiFi Client Functions (wlan0) +////////////////////////////////// + +// Ensure WiFi hardware and wpa_supplicant are ready +// Returns true if WiFi is ready for operations +bool WIFI_direct_ensureReady(void); + +// Trigger a WiFi scan (non-blocking, just starts the scan) +// Call this, then wait ~1.5 seconds before calling WIFI_direct_scanNetworks +void WIFI_direct_triggerScan(void); + +// Scan for all available WiFi networks (reads cached results from last trigger) +// Returns number of networks found +int WIFI_direct_scanNetworks(WIFI_direct_network_t* networks, int max_count); + +// Get current connected SSID +// Returns 0 on success with SSID in ssid_out, -1 if not connected +int WIFI_direct_getCurrentSSID(char* ssid_out, size_t ssid_size); + +// Check if WiFi is connected using wpa_cli +bool WIFI_direct_isConnected(void); + +// Connect to a WiFi network using wpa_cli +// pass can be NULL for open networks +// Returns 0 on success, -1 on failure +int WIFI_direct_connect(const char* ssid, const char* pass); + +// Disconnect from current network +void WIFI_direct_disconnect(void); + +// Forget (remove) a saved network by SSID +void WIFI_direct_forget(const char* ssid); + +// Forget ALL saved netplay hotspot networks (current + legacy prefixes) and +// re-enable other saved networks. Purges stale entries left by aborted sessions. +// Returns the number of networks removed. +int WIFI_direct_forgetAllHotspots(void); + +// Scan for hotspots matching a prefix +// Returns number of hotspots found +// ssids_out should be an array of char[WIFI_DIRECT_SSID_MAX] +int WIFI_direct_scanForHotspots(const char* prefix, char ssids_out[][WIFI_DIRECT_SSID_MAX], int max_count); + +// Get IP address of wlan0 +// Returns 0 on success, -1 on failure +int WIFI_direct_getIP(char* ip_out, size_t ip_size); + +// Save current WiFi connection info for later restoration +void WIFI_direct_saveCurrentConnection(void); + +// Restore previous WiFi connection after hotspot/link session +// Returns true on success +bool WIFI_direct_restorePreviousConnection(void); + +////////////////////////////////// +// Hotspot Functions (wlan0 AP Mode) +////////////////////////////////// + +// Start a WiFi hotspot with given SSID and password +// Returns 0 on success, -1 on failure +int WIFI_direct_startHotspot(const char* ssid, const char* password); + +// Stop the WiFi hotspot +// Returns 0 on success +int WIFI_direct_stopHotspot(void); + +// Check if hotspot is currently active +bool WIFI_direct_isHotspotActive(void); + +// Get the hotspot's IP address (always 10.0.0.1) +const char* WIFI_direct_getHotspotIP(void); + +// Get the current hotspot SSID +const char* WIFI_direct_getHotspotSSID(void); + +// Get the hotspot SSID prefix (LINK_HOTSPOT_SSID_PREFIX) +const char* WIFI_direct_getHotspotSSIDPrefix(void); + +// Get the hotspot password +const char* WIFI_direct_getHotspotPassword(void); + +#endif // WIFI_DIRECT_H diff --git a/workspace/tg5040/cores/makefile b/workspace/tg5040/cores/makefile index a224ff179..a49e2b3ad 100755 --- a/workspace/tg5040/cores/makefile +++ b/workspace/tg5040/cores/makefile @@ -65,6 +65,11 @@ fceumm_REPO = https://github.com/libretro/libretro-fceumm gambatte_REPO = https://github.com/libretro/gambatte-libretro +# Pinned: upstream gpsp rfu.c drifts (rfu_reset/new_devid reworked) and breaks +# the all/cores/patches/gpsp/* netplay patches. When bumping this hash, regen +# 001-rfu_disconnect_fix.patch against the new source in the same change. +gpsp_HASH = 2db57b11a437c4432ab69823bdcd951181de6213 + mednafen_supafaust_REPO = https://github.com/libretro/supafaust pcsx_rearmed_MAKEFILE = Makefile.libretro diff --git a/workspace/tg5040/cores/patches/gambatte.patch b/workspace/tg5040/cores/patches/gambatte.patch index 86e70f657..db1008d0a 100644 --- a/workspace/tg5040/cores/patches/gambatte.patch +++ b/workspace/tg5040/cores/patches/gambatte.patch @@ -2,7 +2,7 @@ diff --git forkSrcPrefix/Makefile.libretro forkDstPrefix/Makefile.libretro index ffaba21e7a88f90786d818ab550b68acd05738bf..2095b2f843eaf6be9abec24ce3da69b551da86a6 100644 --- forkSrcPrefix/Makefile.libretro +++ forkDstPrefix/Makefile.libretro -@@ -381,6 +381,19 @@ else ifeq ($(platform), gcw0) +@@ -381,6 +381,20 @@ else ifeq ($(platform), gcw0) CFLAGS += -fomit-frame-pointer -ffast-math -march=mips32 -mtune=mips32r2 -mhard-float CXXFLAGS += $(CFLAGS) @@ -18,6 +18,7 @@ index ffaba21e7a88f90786d818ab550b68acd05738bf..2095b2f843eaf6be9abec24ce3da69b5 + CFLAGS += -mtune=cortex-a53 -mcpu=cortex-a53 -march=armv8-a + CFLAGS += -fomit-frame-pointer -ffast-math -fPIC -flto + CXXFLAGS += $(CFLAGS) ++ HAVE_NETWORK = 1 + # RETROFW else ifeq ($(platform), retrofw) diff --git a/workspace/tg5050/cores/makefile b/workspace/tg5050/cores/makefile index a224ff179..a49e2b3ad 100755 --- a/workspace/tg5050/cores/makefile +++ b/workspace/tg5050/cores/makefile @@ -65,6 +65,11 @@ fceumm_REPO = https://github.com/libretro/libretro-fceumm gambatte_REPO = https://github.com/libretro/gambatte-libretro +# Pinned: upstream gpsp rfu.c drifts (rfu_reset/new_devid reworked) and breaks +# the all/cores/patches/gpsp/* netplay patches. When bumping this hash, regen +# 001-rfu_disconnect_fix.patch against the new source in the same change. +gpsp_HASH = 2db57b11a437c4432ab69823bdcd951181de6213 + mednafen_supafaust_REPO = https://github.com/libretro/supafaust pcsx_rearmed_MAKEFILE = Makefile.libretro diff --git a/workspace/tg5050/cores/patches/gambatte.patch b/workspace/tg5050/cores/patches/gambatte.patch index 8a07891d7..b267eaacd 100644 --- a/workspace/tg5050/cores/patches/gambatte.patch +++ b/workspace/tg5050/cores/patches/gambatte.patch @@ -2,7 +2,7 @@ diff --git forkSrcPrefix/Makefile.libretro forkDstPrefix/Makefile.libretro index ffaba21e7a88f90786d818ab550b68acd05738bf..2095b2f843eaf6be9abec24ce3da69b551da86a6 100644 --- forkSrcPrefix/Makefile.libretro +++ forkDstPrefix/Makefile.libretro -@@ -381,6 +381,19 @@ else ifeq ($(platform), gcw0) +@@ -381,6 +381,20 @@ else ifeq ($(platform), gcw0) CFLAGS += -fomit-frame-pointer -ffast-math -march=mips32 -mtune=mips32r2 -mhard-float CXXFLAGS += $(CFLAGS) @@ -18,6 +18,7 @@ index ffaba21e7a88f90786d818ab550b68acd05738bf..2095b2f843eaf6be9abec24ce3da69b5 + CFLAGS += -mcpu=cortex-a55 + CFLAGS += -fomit-frame-pointer -ffast-math -fPIC -flto + CXXFLAGS += $(CFLAGS) ++ HAVE_NETWORK = 1 + # RETROFW else ifeq ($(platform), retrofw) From fba86a64c3be555783a065fed4177435b66c9871 Mon Sep 17 00:00:00 2001 From: Mohammad Syuhada Date: Sun, 14 Jun 2026 06:39:30 +0800 Subject: [PATCH 2/2] fix: guard netplay hotspot code for desktop build --- workspace/all/netplay/netplay_helper.c | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/workspace/all/netplay/netplay_helper.c b/workspace/all/netplay/netplay_helper.c index 09d92f44b..6b167f78f 100644 --- a/workspace/all/netplay/netplay_helper.c +++ b/workspace/all/netplay/netplay_helper.c @@ -1570,9 +1570,10 @@ int hostGame_common(LinkType type, void* list, int i) { int hostGameHotspot_common(LinkType type, const char* game_name, uint32_t crc) { #ifndef HAS_WIFIMG + (void)type; (void)game_name; (void)crc; minarch_menuMessage("WiFi not available\non this platform.", (char*[]){ "A","OKAY", NULL }); return MENU_CALLBACK_NOP; -#endif +#else // Show initial message GFX_setMode(MODE_MAIN); @@ -1767,6 +1768,7 @@ int hostGameHotspot_common(LinkType type, const char* game_name, uint32_t crc) { GFX_setMode(MODE_MENU); return MENU_CALLBACK_NOP; +#endif // HAS_WIFIMG } int hostGameWiFi_common(LinkType type, const char* game_name, uint32_t crc) { @@ -2155,6 +2157,11 @@ int joinGameWiFi_common(LinkType type) { } int joinGame_Hotspot_common(LinkType type) { +#ifndef HAS_WIFIMG + (void)type; + minarch_menuMessage("WiFi not available\non this platform.", (char*[]){ "A","OKAY", NULL }); + return MENU_CALLBACK_NOP; +#else // Note: ensureWifiEnabled() already called by joinGame_common() // Link-type specific setup @@ -2484,6 +2491,7 @@ int joinGame_Hotspot_common(LinkType type) { *force_resume_flag = 1; return MENU_CALLBACK_EXIT; +#endif // HAS_WIFIMG } int joinGame_common(LinkType type, void* list, int i) {