diff --git a/.gitignore b/.gitignore index e9e503c..7d5284d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,13 @@ dependencies.lock .iterm2-project build/** + +# Per-developer WiFi credentials (template at main/wifi-creds.h.example) +main/wifi-creds.h + +# idf.py set-target leftovers +sdkconfig.old + +# IDE / tool caches +.cache/ +.idea/ diff --git a/README.md b/README.md index 83fff99..ef37b77 100644 --- a/README.md +++ b/README.md @@ -57,12 +57,134 @@ required. The device is played sideways (with the buttons down): ``` +Multiplayer (WiFi co-op) +------------------------ + +Lockstep co-op over WiFi for up to 8 Pixies. The default build is a +**peer-to-peer mesh**: no host machine, no server, no IP to configure. +Power on the Pixies on the same WiFi and they find each other +automatically. + +Pixies can join and leave at any time. Each roster change triggers a +clean restart at E1M1 with a fresh RNG seed. Each marine is rendered +in one of four colors — green / indigo / brown / red — assigned by +slot so you can tell yourself apart from the rest of the squad. When +only one player is left alive, the round ends with a "PLAYER N WINS!" +banner and a 10-second auto-restart for everyone. Solo deaths trigger +"GAME OVER" with the same 10-second restart. + +A legacy **relay mode** is also available (Python UDP relay on a +laptop / phone / RPi) — useful for client-isolated networks where +peer-to-peer UDP is blocked, or for diagnostics (the relay exposes a +heartbeat + per-slot tic frontier). + +### What you need + +- 1–8 Firefly Pixies (or Gremlins) flashed from this branch +- A WiFi network — home WiFi, laptop hotspot, or phone hotspot. All + Pixies must associate with the same SSID. +- (Relay mode only) any machine on that WiFi running Python 3. + +### Mode select + +`main/net-config.h`: + +```c +#define DOOM_NETPLAY_MESH 1 // 1: mesh (default), 0: relay +#define DOOM_NETPLAY_PORT 5029 +``` + +You only need to edit this if you want relay mode. In relay mode also +set `DOOM_SERVER_IP` to the relay machine's IP. + +### One-time setup + +```bash +cp main/wifi-creds.h.example main/wifi-creds.h +$EDITOR main/wifi-creds.h # WIFI_SSID, WIFI_PASSWORD + +. $HOME/esp/esp-idf/export.sh +idf.py build +idf.py -p /dev/cu.usbmodemXXXX app-flash +``` + +Repeat the `app-flash` line for each Pixie's serial port — **one at a +time**, not in parallel. Concurrent `idf.py` invocations share the +same `build/` directory and race on cmake regeneration; some will +silently fail, leaving you with mixed firmware versions and a confused +mesh. + +### Running a session — mesh mode (default) + +1. **Power on the Pixies.** They WiFi-associate, broadcast a + `PKT_HELLO` every 2 s on `255.255.255.255:5029`, observe each + other's MACs, and deterministically agree on slot assignment and + RNG seed (sorted MAC list → FNV-1a hash). All Pixies drop straight + into E1M1 together. + +2. **Join late** by powering on more Pixies — within ~2 s of the new + joiner's first HELLO reaching the others, everyone restarts at + E1M1 with a fresh seed reflecting the new roster. + +3. **Leave any time** by powering off a Pixie. The others notice ≤5 s + of silence from that MAC (no HELLOs, no ticcmds) and restart with + the survivors. The departed Pixie can rejoin by powering on again. + +### Running a session — relay mode + +Set `DOOM_NETPLAY_MESH 0` and `DOOM_SERVER_IP` to the relay machine's +IP in `net-config.h`, rebuild and reflash, then: + +```bash +python3 tools/relay.py +``` + +The relay binds `0.0.0.0:5029` and sits idle until the first Pixie +joins. The output logs every join / leave / restart plus a 2 Hz +status line: + +``` +[relay] gen=3 released=5891 frontier={0: 5892, 1: 5891, 2: 5891} ... +``` + +### Troubleshooting + +- **Pixies don't see each other (mesh mode):** the network is blocking + UDP broadcasts. Guest WiFi with "client isolation" enabled is the + usual culprit. Either switch networks (personal hotspot, regular + home WiFi) or flip `DOOM_NETPLAY_MESH` to `0` and use relay mode. +- **One Pixie is out of sync after a flash run:** check that all of + them are on the same protocol version. A parallel `idf.py flash` + may have failed on one without you noticing — re-flash that one + sequentially. HELLO payload version mismatches are silently dropped, + so the symptom is "one Pixie is invisible to the others". +- **Pixie shows "Waiting for server…" forever (relay mode):** the + relay isn't reachable. Check that `DOOM_SERVER_IP` matches the + relay machine's current IP on the WiFi the Pixies actually joined. + Phone hotspots and laptops can change IP between sessions. +- **Game freezes mid-session with no obvious cause:** rare but + observed on long-running sessions — the engine appears to hang + without an `I_Error`. Power-cycle one Pixie; in mesh mode the + others will detect the reboot and re-converge, in relay mode the + relay will reissue `SETUP+GO` to everyone. + +Protocol details and on-device state machine: `main/doom/d_net.c` +(both modes, picked at compile time) and `tools/relay.py` (relay +only). Both modes carry an 8-bit generation byte in every packet +header so peers detect missed restarts and re-sync. + + To Do ----- - save/load - more visible menu items (bigger? different color? dim/blur the background?) - click forward twice to begin running +- multiplayer: track down the rare silent-stall (engine hangs with + no `I_Error` on long sessions; recoverable by power-cycling one + Pixie, then the others re-converge) +- multiplayer: deathmatch toggle (mesh: gametype byte in HELLO + payload; relay: flag in `setup_packet_s`) Credits diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 80f64b7..070aefa 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,8 +1,9 @@ idf_component_register( SRCS - "main.c" "device-info.c" "keypad.c" + "main.c" "device-info.c" "keypad.c" "wifi.c" "doom/am_map.c" "doom/d_client.c" "doom/d_items.c" "doom/d_main.c" + "doom/i_network.c" "doom/d_net.c" "doom/f_finale.c" "doom/f_wipe.c" "doom/g_game.c" "doom/global_data.c" "doom/hu_lib.c" "doom/hu_stuff.c" "doom/i_audio.c" "doom/info.c" "doom/i_system.c" "doom/i_video.c" "doom/lprintf.c" diff --git a/main/doom/am_map.c b/main/doom/am_map.c index 18066ee..95bd6bb 100644 --- a/main/doom/am_map.c +++ b/main/doom/am_map.c @@ -241,8 +241,8 @@ static void AM_initVariables(void) _g->m_h = FTOM(_g->f_h); - _g->m_x = (_g->player.mo->x >> FRACTOMAPBITS) - _g->m_w/2;//e6y - _g->m_y = (_g->player.mo->y >> FRACTOMAPBITS) - _g->m_h/2;//e6y + _g->m_x = (_g->players[_g->consoleplayer].mo->x >> FRACTOMAPBITS) - _g->m_w/2;//e6y + _g->m_y = (_g->players[_g->consoleplayer].mo->y >> FRACTOMAPBITS) - _g->m_h/2;//e6y AM_changeWindowLoc(); // inform the status bar of the change @@ -401,7 +401,7 @@ boolean AM_Responder _g->automapmode ^= am_follow; // CPhipps - put all automap mode stuff into one enum _g->f_oldloc.x = INT_MAX; // Ty 03/27/98 - externalized - _g->player.message = (_g->automapmode & am_follow) ? AMSTR_FOLLOWON : AMSTR_FOLLOWOFF; + _g->players[_g->consoleplayer].message = (_g->automapmode & am_follow) ? AMSTR_FOLLOWON : AMSTR_FOLLOWOFF; } // | else if (ch == key_map_zoomout) { @@ -511,14 +511,14 @@ static void AM_changeWindowScale(void) // static void AM_doFollowPlayer(void) { - if (_g->f_oldloc.x != _g->player.mo->x || _g->f_oldloc.y != _g->player.mo->y) + if (_g->f_oldloc.x != _g->players[_g->consoleplayer].mo->x || _g->f_oldloc.y != _g->players[_g->consoleplayer].mo->y) { - _g->m_x = FTOM(MTOF(_g->player.mo->x >> FRACTOMAPBITS)) - _g->m_w/2;//e6y - _g->m_y = FTOM(MTOF(_g->player.mo->y >> FRACTOMAPBITS)) - _g->m_h/2;//e6y + _g->m_x = FTOM(MTOF(_g->players[_g->consoleplayer].mo->x >> FRACTOMAPBITS)) - _g->m_w/2;//e6y + _g->m_y = FTOM(MTOF(_g->players[_g->consoleplayer].mo->y >> FRACTOMAPBITS)) - _g->m_h/2;//e6y _g->m_x2 = _g->m_x + _g->m_w; _g->m_y2 = _g->m_y + _g->m_h; - _g->f_oldloc.x = _g->player.mo->x; - _g->f_oldloc.y = _g->player.mo->y; + _g->f_oldloc.x = _g->players[_g->consoleplayer].mo->x; + _g->f_oldloc.y = _g->players[_g->consoleplayer].mo->y; } } @@ -837,8 +837,8 @@ static void AM_drawWalls(void) if (_g->automapmode & am_rotate) { - AM_rotate(&l.a.x, &l.a.y, ANG90-_g->player.mo->angle, _g->player.mo->x, _g->player.mo->y); - AM_rotate(&l.b.x, &l.b.y, ANG90-_g->player.mo->angle, _g->player.mo->x, _g->player.mo->y); + AM_rotate(&l.a.x, &l.a.y, ANG90-_g->players[_g->consoleplayer].mo->angle, _g->players[_g->consoleplayer].mo->x, _g->players[_g->consoleplayer].mo->y); + AM_rotate(&l.b.x, &l.b.y, ANG90-_g->players[_g->consoleplayer].mo->angle, _g->players[_g->consoleplayer].mo->x, _g->players[_g->consoleplayer].mo->y); } // if line has been seen or IDDT has been used @@ -979,7 +979,7 @@ static void AM_drawWalls(void) } } } // now draw the lines only visible because the player has computermap - else if (_g->player.powers[pw_allmap]) // computermap visible lines + else if (_g->players[_g->consoleplayer].powers[pw_allmap]) // computermap visible lines { if (!(_g->lines[i].flags & ML_DONTDRAW)) // invisible flag lines do not show { @@ -1023,7 +1023,7 @@ static void AM_drawLineCharacter int i; mline_t l; - if (_g->automapmode & am_rotate) angle -= _g->player.mo->angle - ANG90; // cph + if (_g->automapmode & am_rotate) angle -= _g->players[_g->consoleplayer].mo->angle - ANG90; // cph for (i=0;iplayer.mo->angle, + _g->players[_g->consoleplayer].mo->angle, mapcolor_sngl, //jff color - _g->player.mo->x >> FRACTOMAPBITS,//e6y - _g->player.mo->y >> FRACTOMAPBITS);//e6y + _g->players[_g->consoleplayer].mo->x >> FRACTOMAPBITS,//e6y + _g->players[_g->consoleplayer].mo->y >> FRACTOMAPBITS);//e6y } diff --git a/main/doom/d_client.c b/main/doom/d_client.c index 2d875e9..5c70c22 100644 --- a/main/doom/d_client.c +++ b/main/doom/d_client.c @@ -67,7 +67,23 @@ void D_InitNetGame (void) { - _g->playeringame = true; + // v2: always need the relay. net_init retries PKT_INIT every 2 s + // indefinitely until the server responds with SETUP+GO. There's no + // offline fallback — if you have no relay you don't play. + bool ok = net_init(2000); + if (!ok) { + // The only way net_init returns false in v2 is after receiving + // SETUP but never seeing a GO. Drop to solo so the device shows + // something rather than hang. + printf("[net] SETUP without GO — solo fallback\n"); + _g->playeringame[_g->consoleplayer] = true; + return; + } + printf("[net] multiplayer mode: %d players, slot %d\n", + net_numplayers(), net_consoleplayer()); + + // First-time G_DeferedInitNew happens in d_net.c's apply_go(). The + // d_main.c end-of-setup hook also queues it (idempotent). } void D_BuildNewTiccmds(void) @@ -78,26 +94,62 @@ void D_BuildNewTiccmds(void) while (newtics--) { I_StartTic(); - if (_g->maketic - _g->gametic > 3) + // Stay close to gametic; no need to build more than a few tics + // ahead. Multiplayer benefits from a slightly larger horizon + // (matching net_flush_unsent's MAX_TICS_PER_PKT_TICC). + int ahead = net_is_active() ? 7 : 3; + if (_g->maketic - _g->gametic > ahead) break; - G_BuildTiccmd(&_g->netcmd); + ticcmd_t cmd; + G_BuildTiccmd(&cmd); + _g->netcmds[_g->consoleplayer] = cmd; + + if (net_is_active()) { + net_record_local_tic(_g->maketic, &cmd); + } + _g->maketic++; } + + if (net_is_active()) { + net_flush_unsent(_g->maketic - 1); + } } void TryRunTics (void) { int runtics; int entertime = I_GetTime(); + static int last_retrans_tic = -1; // Wait for tics to run while (1) { + if (net_is_active()) { net_pump(); } D_BuildNewTiccmds(); - runtics = (_g->maketic) - _g->gametic; + if (net_is_active()) { + // In netplay, runtics is gated on having ALL players' inputs + // for [gametic .. min(maketic-1, max_complete_tic)]. + int complete = _g->gametic; + while (complete < _g->maketic && net_have_all_tics(complete)) { + complete++; + } + runtics = complete - _g->gametic; + + // Stalled for ~1 s on the same tic? Politely poke the relay. + // (35 tics/s × ~28 idle calls → ~800 ms before nudging.) + if (runtics <= 0 && I_GetTime() - entertime > 28 + && last_retrans_tic != _g->gametic) { + net_request_retrans(_g->gametic); + last_retrans_tic = _g->gametic; + } + } else { + runtics = (_g->maketic) - _g->gametic; + } + if (runtics <= 0) { if (I_GetTime() - entertime > 10) @@ -112,12 +164,83 @@ void TryRunTics (void) while (runtics-- > 0) { - if (_g->advancedemo) D_DoAdvanceDemo (); + // Inject every active player's ticcmd for this gametic. + if (net_is_active()) { + for (int p = 0; p < MAXPLAYERS; p++) { + if (_g->playeringame[p]) { + net_get_tic(p, _g->gametic, &_g->netcmds[p]); + } + } + } + M_Ticker (); G_Ticker (); _g->gametic++; + + // End-of-round detection. Trigger conditions: + // - multiplayer (numplayers >= 2): exactly one player left + // alive — that's the winner. + // - solo (numplayers == 1) or everyone dead at once: GAME OVER. + // In either case we display a HUD message and request a relay + // restart after 10 s. PKT_INIT to the relay → same-IP rejoin → + // fresh SETUP+GO broadcast to everyone. + if (net_is_active()) { + int alive = 0; + int winner_slot = -1; + for (int p = 0; p < MAXPLAYERS; p++) { + if (_g->playeringame[p] && + _g->players[p].playerstate != PST_DEAD) { + alive++; + winner_slot = p; + } + } + bool round_over = + (net_numplayers() >= 2 && alive <= 1) || + (net_numplayers() == 1 && alive == 0); + + static int countdown_start = -1; + static int last_restart_request = -100000; + static char banner[40]; + + if (round_over) { + if (countdown_start < 0) { + countdown_start = _g->gametic; + if (alive == 1 && net_numplayers() >= 2) { + snprintf(banner, sizeof(banner), + "PLAYER %d WINS!", winner_slot + 1); + } else { + snprintf(banner, sizeof(banner), "GAME OVER"); + } + printf("[net] %s; restart in 10 s\n", banner); + } + // Refresh each tic so Doom's HUD timer doesn't fade it + // before the restart fires. + _g->players[_g->consoleplayer].message = banner; + + int elapsed = _g->gametic - countdown_start; + if (elapsed >= 350 && // 10 s @ 35 tic/s + (int)I_GetTime() - last_restart_request > 70) { + last_restart_request = (int)I_GetTime(); + printf("[net] 10 s elapsed; requesting restart\n"); + extern void net_request_restart(void); + net_request_restart(); + } + } else if (countdown_start >= 0) { + countdown_start = -1; + } + } + + // Heartbeat + free-heap watch (1Hz). If heap is dropping over + // time we have a leak; if it's stable but we still crash the + // bug is somewhere else. + if (net_is_active() && (_g->gametic % 35) == 0) { + extern uint32_t esp_get_free_heap_size(void); + printf("[net] gametic=%d maketic=%d heap=%u\n", + _g->gametic, _g->maketic, + (unsigned)esp_get_free_heap_size()); + } } } diff --git a/main/doom/d_main.c b/main/doom/d_main.c index 0513d0b..b763302 100644 --- a/main/doom/d_main.c +++ b/main/doom/d_main.c @@ -204,7 +204,7 @@ static void D_Display (void) // Now do the drawing if (viewactive) - R_RenderPlayerView (&_g->player); + R_RenderPlayerView (&_g->players[_g->consoleplayer]); if (_g->automapmode & am_active) AM_Drawer(); @@ -262,7 +262,7 @@ static void D_DoomLoop(void) if (_g->singletics) { I_StartTic (); - G_BuildTiccmd (&_g->netcmd); + G_BuildTiccmd (&_g->netcmds[_g->consoleplayer]); if (_g->advancedemo) D_DoAdvanceDemo (); @@ -277,8 +277,8 @@ static void D_DoomLoop(void) TryRunTics (); // will run at least one tic // killough 3/16/98: change consoleplayer to displayplayer - if (_g->player.mo) // cph 2002/08/10 - S_UpdateSounds(_g->player.mo);// move positional sounds + if (_g->players[_g->consoleplayer].mo) // cph 2002/08/10 + S_UpdateSounds(_g->players[_g->consoleplayer].mo);// move positional sounds // Update display, next frame, with current state. D_Display(); @@ -443,7 +443,7 @@ const demostates[][4] = void D_DoAdvanceDemo(void) { - _g->player.playerstate = PST_LIVE; /* not reborn */ + _g->players[_g->consoleplayer].playerstate = PST_LIVE; /* not reborn */ _g->advancedemo = _g->usergame = _g->paused = false; _g->gameaction = ga_nothing; @@ -761,6 +761,13 @@ static void D_DoomMainSetup(void) G_DeferedPlayDemo(timedemo); _g->singledemo = true; // quit after one demo } + else if (net_is_active()) + { + // In netplay we skip the title/demo loop (PrBoom can't replay + // single-player demo lumps in a multi-player simulation) and go + // straight to E1M1. + G_DeferedInitNew(sk_medium, 1, 1); + } else { D_StartTitle(); // start up intro loop diff --git a/main/doom/d_net.c b/main/doom/d_net.c new file mode 100644 index 0000000..9a548bf --- /dev/null +++ b/main/doom/d_net.c @@ -0,0 +1,763 @@ +// On-device netplay state machine: build and emit local ticcmds each +// tic, ingest peers' ticcmds, gate the engine's gametic advance on +// having a full set of inputs for every active player. +// +// Two modes, picked at compile time by DOOM_NETPLAY_MESH in net-config.h: +// +// - mesh (DOOM_NETPLAY_MESH=1, default): peer-to-peer. Pixies discover +// each other by broadcasting PKT_HELLO every 2 s on +// 255.255.255.255:DOOM_NETPLAY_PORT, carrying their MAC + the sender's +// view of the roster. The slot/numplayers/rngseed are deterministically +// derived from the sorted set of observed MACs. PKT_TICC is broadcast +// too; every Pixie collects every other Pixie's tics directly. No +// server. +// +// - relay (DOOM_NETPLAY_MESH=0): centralized. Pixies unicast PKT_INIT / +// PKT_TICC to a Python relay on a host machine; the relay assigns +// slots, broadcasts SETUP+GO on roster changes, merges and resends +// PKT_TICS. Useful for debugging and for networks that block +// peer-to-peer UDP. See tools/relay.py. + +#include +#include +#include +#include + +#include "doomtype.h" +#include "d_ticcmd.h" +#include "protocol.h" // must come before i_network.h (uses packet_header_t) +#include "i_network.h" +#include "g_game.h" +#include "i_system.h" +#include "i_video.h" +#include "lprintf.h" +#include "global_data.h" +#include "../net-config.h" + +#include "d_net.h" + +#if DOOM_NETPLAY_MESH +#include "lwip/sockets.h" +#include "esp_wifi.h" +#include "esp_mac.h" +#endif + +// ---------------------------- tunable ring sizes ---------------------------- + +#define TIC_RING_BITS 6 // 64 tics ≈ 1.8 s +#define TIC_RING (1u << TIC_RING_BITS) +#define TIC_MASK (TIC_RING - 1u) + +#define MAX_TICS_PER_PKT_TICC 8 + +// ---------------------------- shared module state --------------------------- + +static bool s_net_active = false; +static bool s_started = false; +static int s_numplayers = 1; +static int s_consoleplayer = 0; +static uint8_t s_generation = 0; +static int s_last_go_tic = -100000; + +static ticcmd_t s_localcmds[TIC_RING]; +static ticcmd_t s_netcmds_ring[MAXPLAYERS][TIC_RING]; +static int s_have_tic[MAXPLAYERS]; +static int s_last_sent_tic = -1; + +// ---------------------------- packet helpers (shared) ------------------------ + +static byte checksum_packet(const packet_header_t *p, size_t len) { + const byte *b = (const byte *)p; + byte s = 0; + for (size_t i = 1; i < len; i++) { s += b[i]; } + return s; +} + +static bool packet_ok(packet_header_t *p, size_t len) { + if (len < sizeof(packet_header_t)) return false; + byte cs = p->checksum; + p->checksum = 0; + byte want = checksum_packet(p, len); + p->checksum = cs; + return cs == want; +} + +static void packet_seal(packet_header_t *p, size_t len) { + p->checksum = 0; + p->checksum = checksum_packet(p, len); +} + +static void send_simple(enum packet_type_e type, unsigned tic, + const void *payload, size_t paylen) { + uint8_t buf[256]; + if (sizeof(packet_header_t) + paylen > sizeof(buf)) { + lprintf(LO_ERROR, "net: oversized packet type=%d len=%u\n", + type, (unsigned)paylen); + return; + } + packet_header_t *h = (packet_header_t *)buf; + packet_set(h, type, tic); + h->generation = s_generation; + if (paylen) { memcpy(buf + sizeof(*h), payload, paylen); } + packet_seal(h, sizeof(*h) + paylen); + I_SendPacket(h, sizeof(*h) + paylen); +} + +// ---------------------------- shared public API ----------------------------- + +bool net_is_active(void) { return s_net_active; } +bool net_is_started(void) { return s_started; } +int net_numplayers(void) { return s_numplayers; } +int net_consoleplayer(void) { return s_consoleplayer; } +unsigned net_start_tic(void) { return 0; } + +bool net_have_all_tics(int t) { + if (!s_net_active) return true; + for (int p = 0; p < s_numplayers; p++) { + if (s_have_tic[p] < t) return false; + } + return true; +} + +void net_get_tic(int p, int t, ticcmd_t *out) { + *out = s_netcmds_ring[p][t & TIC_MASK]; +} + +void net_record_local_tic(int tic, const ticcmd_t *cmd) { + s_localcmds[tic & TIC_MASK] = *cmd; + s_netcmds_ring[s_consoleplayer][tic & TIC_MASK] = *cmd; + if (tic > s_have_tic[s_consoleplayer]) { + s_have_tic[s_consoleplayer] = tic; + } +} + +void net_flush_unsent(int up_to_tic) { + if (!s_net_active) return; + int first = s_last_sent_tic + 1; + if (up_to_tic < first) return; + int count = up_to_tic - first + 1; + if (count > MAX_TICS_PER_PKT_TICC) { + first = up_to_tic - MAX_TICS_PER_PKT_TICC + 1; + count = MAX_TICS_PER_PKT_TICC; + } + + uint8_t pay[1 + 4 + MAX_TICS_PER_PKT_TICC * sizeof(ticcmd_t) + 1]; + pay[0] = (uint8_t)count; + pay[1] = (uint8_t)((first >> 24) & 0xff); + pay[2] = (uint8_t)((first >> 16) & 0xff); + pay[3] = (uint8_t)((first >> 8) & 0xff); + pay[4] = (uint8_t)((first) & 0xff); +#if DOOM_NETPLAY_MESH + // In mesh mode, the relay isn't there to assign slots; peers identify + // each sender by their slot number. Prepend it after the count. + pay[5] = (uint8_t)s_consoleplayer; + for (int i = 0; i < count; i++) { + TicToRaw(pay + 6 + i * sizeof(ticcmd_t), + &s_localcmds[(first + i) & TIC_MASK]); + } + send_simple(PKT_TICC, (unsigned)up_to_tic, pay, + 6 + (size_t)count * sizeof(ticcmd_t)); +#else + for (int i = 0; i < count; i++) { + TicToRaw(pay + 5 + i * sizeof(ticcmd_t), + &s_localcmds[(first + i) & TIC_MASK]); + } + send_simple(PKT_TICC, (unsigned)up_to_tic, pay, + 5 + (size_t)count * sizeof(ticcmd_t)); +#endif + s_last_sent_tic = up_to_tic; +} + +void net_request_retrans(int wanttic) { + if (!s_net_active) return; + uint8_t pay[4]; + pay[0] = (uint8_t)((wanttic >> 24) & 0xff); + pay[1] = (uint8_t)((wanttic >> 16) & 0xff); + pay[2] = (uint8_t)((wanttic >> 8) & 0xff); + pay[3] = (uint8_t)((wanttic) & 0xff); + send_simple(PKT_RETRANS, (unsigned)wanttic, pay, sizeof(pay)); +} + +// ============================================================================ +// RELAY MODE +// ============================================================================ +#if !DOOM_NETPLAY_MESH + +static void send_pkt_init(void) { + byte ver = 1; + send_simple(PKT_INIT, 0, &ver, 1); +} + +static bool apply_setup(const uint8_t *payload, size_t paylen) { + if (paylen < sizeof(struct setup_packet_s) - 1) return false; + const struct setup_packet_s *setup = (const struct setup_packet_s *)payload; + int np = setup->players; + int you = setup->yourplayer; + if (np < 1 || np > MAXPLAYERS || you < 0 || you >= np) { + lprintf(LO_ERROR, "net: bad SETUP players=%d you=%d\n", np, you); + return false; + } + + s_numplayers = np; + s_consoleplayer = you; + _g->consoleplayer = you; + _g->displayplayer = you; + for (int i = 0; i < MAXPLAYERS; i++) { + _g->playeringame[i] = (i < np); + } + s_last_go_tic = (int)I_GetTime(); + lprintf(LO_INFO, "net: SETUP players=%d slot=%d\n", np, you); + return true; +} + +static void apply_go(const uint8_t *payload, bool first_time, uint8_t gen) { + uint32_t starttic = + (payload[0] << 24) | (payload[1] << 16) | (payload[2] << 8) | payload[3]; + uint32_t rngseed = + (payload[4] << 24) | (payload[5] << 16) | (payload[6] << 8) | payload[7]; + (void)starttic; + + _g->net_rngseed = rngseed; + s_generation = gen; + lprintf(LO_INFO, "net: GO gen=%u rngseed=%u (first=%d)\n", + gen, rngseed, (int)first_time); + + for (int i = 0; i < MAXPLAYERS; i++) { s_have_tic[i] = -1; } + s_last_sent_tic = -1; + memset(s_localcmds, 0, sizeof(s_localcmds)); + memset(s_netcmds_ring, 0, sizeof(s_netcmds_ring)); + + _g->gametic = 0; + _g->maketic = 0; + _g->lastmadetic = I_GetTime(); + _g->basetic = 0; + + s_started = true; + s_net_active = true; + s_last_go_tic = (int)I_GetTime(); + + G_DeferedInitNew(sk_medium, 1, 1); +} + +static int recv_one(int ms_total, packet_header_t *out, size_t outlen, + size_t *out_n) { + int waited = 0; + while (waited < ms_total) { + I_WaitForPacket(50); + *out_n = I_GetPacket(out, outlen); + if (*out_n > 0 && packet_ok(out, *out_n)) return 1; + waited += 50; + } + return 0; +} + +bool net_init(int retry_ms) { + I_InitNetwork(); + + for (int i = 0; i < MAXPLAYERS; i++) { s_have_tic[i] = -1; } + s_last_sent_tic = -1; + + uint8_t buf[256]; + packet_header_t *h = (packet_header_t *)buf; + size_t n; + + lprintf(LO_INFO, "net: bootstrap — waiting for relay (retry every %d ms)\n", + retry_ms); + while (1) { + send_pkt_init(); + int wait_ms = retry_ms; + while (wait_ms > 0) { + int r = recv_one(50, h, sizeof(buf), &n); + if (r == 1 && h->type == PKT_SETUP) { + if (apply_setup(buf + sizeof(*h), n - sizeof(*h))) { + goto got_setup; + } + } + wait_ms -= 50; + } + } +got_setup: + + lprintf(LO_INFO, "net: SETUP received; awaiting PKT_GO\n"); + int wait_ms = 15000; + while (wait_ms > 0) { + int r = recv_one(50, h, sizeof(buf), &n); + if (r == 1) { + if (h->type == PKT_GO && n >= sizeof(*h) + 8) { + apply_go(buf + sizeof(*h), /*first_time=*/true, h->generation); + return true; + } + if (h->type == PKT_SETUP) { + apply_setup(buf + sizeof(*h), n - sizeof(*h)); + } + } + wait_ms -= 50; + } + lprintf(LO_ERROR, "net: SETUP received but no GO within 15 s; giving up\n"); + return false; +} + +void net_pump(void) { + if (!s_net_active) return; + + uint8_t buf[1024]; + packet_header_t *h = (packet_header_t *)buf; + + while (1) { + size_t n = I_GetPacket(h, sizeof(buf)); + if (n == 0) break; + if (!packet_ok(h, n)) continue; + + if (h->type == PKT_SETUP) { + apply_setup(buf + sizeof(*h), n - sizeof(*h)); + continue; + } + if (h->type == PKT_GO && n >= sizeof(*h) + 8) { + apply_go(buf + sizeof(*h), /*first_time=*/false, h->generation); + return; + } + + if (h->type == PKT_QUIT) { + lprintf(LO_INFO, "net: PKT_QUIT received; dropping to solo\n"); + s_net_active = false; + s_started = false; + for (int i = 0; i < MAXPLAYERS; i++) { + _g->playeringame[i] = (i == _g->consoleplayer); + } + return; + } + + if (h->type != PKT_TICS) continue; + + if (h->generation != s_generation) { + int now_t = (int)I_GetTime(); + int since_go = now_t - s_last_go_tic; + if (since_go < 175) { // 5 s grace + continue; + } + static int last_reinit_t = -100000; + if (now_t - last_reinit_t > 35) { + last_reinit_t = now_t; + lprintf(LO_INFO, "net: TICS gen=%u != ours=%u; re-INIT\n", + h->generation, s_generation); + send_pkt_init(); + } + continue; + } + + if (n < sizeof(*h) + 5) continue; + uint8_t *p = buf + sizeof(*h); + uint8_t numtics = p[0]; + uint32_t firsttic = + (p[1] << 24) | (p[2] << 16) | (p[3] << 8) | p[4]; + uint8_t *ticbase = p + 5; + size_t need = (size_t)numtics * (size_t)s_numplayers * + sizeof(ticcmd_t); + if (n < sizeof(*h) + 5 + need) continue; + + for (uint8_t t = 0; t < numtics; t++) { + unsigned tic = firsttic + t; + for (int pl = 0; pl < s_numplayers; pl++) { + ticcmd_t cmd; + size_t off = ((size_t)t * (size_t)s_numplayers + pl) * + sizeof(ticcmd_t); + RawToTic(&cmd, ticbase + off); + s_netcmds_ring[pl][tic & TIC_MASK] = cmd; + if ((int)tic > s_have_tic[pl]) { s_have_tic[pl] = tic; } + } + } + } +} + +void net_request_restart(void) { + if (!s_net_active) return; + send_pkt_init(); +} + +// ============================================================================ +// MESH MODE +// ============================================================================ +#else // DOOM_NETPLAY_MESH + +#define MAC_LEN 6 +#define MAX_PEERS MAXPLAYERS + +typedef struct { + uint8_t mac[MAC_LEN]; + int last_seen_tic; // I_GetTime() snapshot of last HELLO or TICC +} peer_t; + +static uint8_t s_my_mac[MAC_LEN] = { 0 }; +static peer_t s_roster[MAX_PEERS] = { 0 }; // sorted by MAC ascending +static int s_roster_count = 0; +static int s_last_hello_tic = -100000; + +// Bumped on any explicit restart (round-over) or when we observe a peer +// reboot. Folded into the seed input so gen changes even when roster is +// unchanged. Propagated to peers in HELLO payload; receivers adopt the +// max so a freshly-rebooted peer (epoch=0) catches up to mesh-wide N. +static uint32_t s_restart_epoch = 0; + +// fnv1a for deterministic rngseed. +static uint32_t fnv1a(const uint8_t *data, size_t len) { + uint32_t h = 2166136261u; + for (size_t i = 0; i < len; i++) { + h ^= data[i]; + h *= 16777619u; + } + return h; +} + +static int mac_cmp(const void *a, const void *b) { + return memcmp(a, b, MAC_LEN); +} + +// Recompute slot/numplayers/rngseed from the current roster. If anything +// changed (or force is true), bump the generation and request a level +// restart. force=true is used for explicit restart requests (round-over) +// and for observed peer-reboot, where the roster looks unchanged but +// we still need everyone to reset gametic and reseed. +static void recompute_roster(const char *reason, bool force) { + // Sort roster by MAC ascending. + qsort(s_roster, s_roster_count, sizeof(peer_t), mac_cmp); + + int new_slot = -1; + for (int i = 0; i < s_roster_count; i++) { + if (memcmp(s_roster[i].mac, s_my_mac, MAC_LEN) == 0) { + new_slot = i; + break; + } + } + if (new_slot < 0) { + lprintf(LO_ERROR, "mesh: own MAC not in roster after recompute\n"); + return; + } + + int new_num = s_roster_count; + if (!force && new_num == s_numplayers && new_slot == s_consoleplayer) { + // Roster didn't change in a way that matters to us. + return; + } + + s_numplayers = new_num; + s_consoleplayer = new_slot; + _g->consoleplayer = new_slot; + _g->displayplayer = new_slot; + for (int i = 0; i < MAXPLAYERS; i++) { + _g->playeringame[i] = (i < new_num); + } + + // Generation + rngseed are deterministic functions of the sorted + // roster *and* the restart epoch. Two peers with the same (roster, + // epoch) always end up at the same generation, so PKT_TICC gen + // checks succeed across peers without requiring counter + // synchronization. The epoch byte is folded in so explicit restarts + // (which don't change the roster) still change gen. + uint8_t seed_input[MAX_PEERS * MAC_LEN + 4]; + size_t off = 0; + for (int i = 0; i < new_num; i++) { + memcpy(seed_input + off, s_roster[i].mac, MAC_LEN); + off += MAC_LEN; + } + seed_input[off++] = (uint8_t)((s_restart_epoch >> 24) & 0xff); + seed_input[off++] = (uint8_t)((s_restart_epoch >> 16) & 0xff); + seed_input[off++] = (uint8_t)((s_restart_epoch >> 8) & 0xff); + seed_input[off++] = (uint8_t)(s_restart_epoch & 0xff); + uint32_t seed = fnv1a(seed_input, off); + if (seed == 0) seed = 1; // 0 means "single-player default" in m_random.c + s_generation = (uint8_t)(seed & 0xff); + _g->net_rngseed = seed; + + lprintf(LO_INFO, + "mesh: %s — players=%d slot=%d gen=%u rngseed=%u epoch=%u\n", + reason, new_num, new_slot, s_generation, seed, + (unsigned)s_restart_epoch); + + // Reset tic state and queue a level reload. + for (int i = 0; i < MAXPLAYERS; i++) { s_have_tic[i] = -1; } + s_last_sent_tic = -1; + memset(s_localcmds, 0, sizeof(s_localcmds)); + memset(s_netcmds_ring, 0, sizeof(s_netcmds_ring)); + _g->gametic = 0; + _g->maketic = 0; + _g->lastmadetic = I_GetTime(); + _g->basetic = 0; + s_last_go_tic = (int)I_GetTime(); + + s_started = true; + G_DeferedInitNew(sk_medium, 1, 1); +} + +// Insert a peer into the roster. If they're already present, optionally +// refresh their last_seen. Refresh ONLY for direct evidence the peer is +// up (HELLO from them, TICC from them). Do NOT refresh from gossip +// (someone else's HELLO mentioning them) — otherwise a disconnected +// peer's liveness is kept alive forever by the surviving peers gossiping +// about each other's peer lists. Returns true if the roster composition +// (set of MACs) actually changed. +static bool roster_upsert(const uint8_t mac[MAC_LEN], bool refresh) { + int now_t = (int)I_GetTime(); + for (int i = 0; i < s_roster_count; i++) { + if (memcmp(s_roster[i].mac, mac, MAC_LEN) == 0) { + if (refresh) s_roster[i].last_seen_tic = now_t; + return false; + } + } + if (s_roster_count >= MAX_PEERS) return false; + memcpy(s_roster[s_roster_count].mac, mac, MAC_LEN); + s_roster[s_roster_count].last_seen_tic = now_t; + s_roster_count++; + return true; +} + +static void roster_remove_at(int idx) { + for (int i = idx; i < s_roster_count - 1; i++) { + s_roster[i] = s_roster[i + 1]; + } + s_roster_count--; +} + +static void send_hello(void) { + // Payload v2: my_mac(6) || epoch(4 BE) || roster_count(1) || + // sorted_macs(count*6) + uint8_t pay[1 + MAC_LEN + 4 + 1 + MAX_PEERS * MAC_LEN]; + size_t off = 0; + pay[off++] = (uint8_t)2; // payload version + memcpy(pay + off, s_my_mac, MAC_LEN); off += MAC_LEN; + pay[off++] = (uint8_t)((s_restart_epoch >> 24) & 0xff); + pay[off++] = (uint8_t)((s_restart_epoch >> 16) & 0xff); + pay[off++] = (uint8_t)((s_restart_epoch >> 8) & 0xff); + pay[off++] = (uint8_t)(s_restart_epoch & 0xff); + pay[off++] = (uint8_t)s_roster_count; + for (int i = 0; i < s_roster_count; i++) { + memcpy(pay + off, s_roster[i].mac, MAC_LEN); + off += MAC_LEN; + } + send_simple(PKT_HELLO, 0, pay, off); + s_last_hello_tic = (int)I_GetTime(); +} + +// Handle a received PKT_HELLO. Adds the sender to our roster (and any +// peers it told us about), adopts the higher of our and the sender's +// epoch, detects peer reboots, and re-runs roster computation if +// anything changed or a restart is forced. +static void handle_hello(const uint8_t *payload, size_t paylen) { + if (paylen < 1 + MAC_LEN + 4 + 1) return; + uint8_t ver = payload[0]; + if (ver != 2) return; // ignore older firmware silently + const uint8_t *sender_mac = payload + 1; + uint32_t sender_epoch = + ((uint32_t)payload[1 + MAC_LEN] << 24) | + ((uint32_t)payload[1 + MAC_LEN + 1] << 16) | + ((uint32_t)payload[1 + MAC_LEN + 2] << 8) | + ((uint32_t)payload[1 + MAC_LEN + 3]); + int peer_count = payload[1 + MAC_LEN + 4]; + size_t macs_off = 1 + MAC_LEN + 4 + 1; + if (paylen < macs_off + (size_t)peer_count * MAC_LEN) return; + const uint8_t *peer_macs = payload + macs_off; + + // Snapshot pre-upsert state to detect peer-reboot. If the sender is + // already in our roster but their peer list doesn't mention us, + // they wiped their state and don't yet know about us — i.e. they + // rebooted. We bump our own epoch so the next recompute produces a + // different gen, forcing the engine to reset gametic to 0 on our + // side too. The rebooted peer will catch up via epoch adoption below + // once they hear our HELLO. + bool already_known = false; + for (int i = 0; i < s_roster_count; i++) { + if (memcmp(s_roster[i].mac, sender_mac, MAC_LEN) == 0) { + already_known = true; + break; + } + } + bool sender_knows_us = false; + for (int i = 0; i < peer_count; i++) { + if (memcmp(peer_macs + i * MAC_LEN, s_my_mac, MAC_LEN) == 0) { + sender_knows_us = true; + break; + } + } + bool force = false; + if (already_known && !sender_knows_us) { + s_restart_epoch++; + force = true; + lprintf(LO_INFO, + "mesh: peer reboot detected (%02x:%02x:%02x:%02x:%02x:%02x); " + "epoch -> %u\n", + sender_mac[0], sender_mac[1], sender_mac[2], + sender_mac[3], sender_mac[4], sender_mac[5], + (unsigned)s_restart_epoch); + } + // Adopt the larger epoch. A freshly-rebooted peer (epoch=0) learns + // the current mesh epoch this way and catches up. + if (sender_epoch > s_restart_epoch) { + s_restart_epoch = sender_epoch; + force = true; + } + + bool changed = roster_upsert(sender_mac, /*refresh=*/true); + + // Also add any peers the sender knows about — gossip convergence. + // refresh=false: don't keep dead peers alive via second-hand mention. + for (int i = 0; i < peer_count; i++) { + const uint8_t *m = peer_macs + i * MAC_LEN; + // Skip our own MAC (we're always in our own roster). + if (memcmp(m, s_my_mac, MAC_LEN) == 0) continue; + if (roster_upsert(m, /*refresh=*/false)) changed = true; + } + + if (changed || force) { + char mac_str[18]; + snprintf(mac_str, sizeof(mac_str), + "%02x:%02x:%02x:%02x:%02x:%02x", + sender_mac[0], sender_mac[1], sender_mac[2], + sender_mac[3], sender_mac[4], sender_mac[5]); + recompute_roster(mac_str, force); + } +} + +// Find a peer's slot index in the sorted roster, -1 if not present. +static int slot_of(const uint8_t mac[MAC_LEN]) { + for (int i = 0; i < s_roster_count; i++) { + if (memcmp(s_roster[i].mac, mac, MAC_LEN) == 0) return i; + } + return -1; +} + +bool net_init(int retry_ms) { + (void)retry_ms; + I_InitNetwork(); + + // Read our own MAC. + if (esp_wifi_get_mac(WIFI_IF_STA, s_my_mac) != ESP_OK) { + esp_read_mac(s_my_mac, ESP_MAC_WIFI_STA); + } + lprintf(LO_INFO, "mesh: my MAC = %02x:%02x:%02x:%02x:%02x:%02x\n", + s_my_mac[0], s_my_mac[1], s_my_mac[2], + s_my_mac[3], s_my_mac[4], s_my_mac[5]); + + // Start with just ourselves in the roster — we play solo until we + // hear from a peer. + s_roster_count = 0; + memcpy(s_roster[s_roster_count].mac, s_my_mac, MAC_LEN); + s_roster[s_roster_count].last_seen_tic = (int)I_GetTime(); + s_roster_count = 1; + + s_numplayers = 0; // force recompute_roster to fire + s_consoleplayer = -1; + s_restart_epoch = 0; + recompute_roster("boot", false); + + // Initial HELLO so peers learn about us immediately. + send_hello(); + + s_net_active = true; + return true; +} + +void net_pump(void) { + if (!s_net_active) return; + + // Periodic HELLO every ~2 s. + int now_t = (int)I_GetTime(); + if (now_t - s_last_hello_tic > 70) { // 70 tics ≈ 2 s + send_hello(); + } + + // Liveness sweep: any peer (not us) silent for >5 s is dropped. + // 5 s = miss two consecutive HELLOs (which fire every 2 s) plus a + // grace tic. Direct evidence only — gossip mentions don't count. + bool changed = false; + for (int i = s_roster_count - 1; i >= 0; i--) { + if (memcmp(s_roster[i].mac, s_my_mac, MAC_LEN) == 0) continue; + if (now_t - s_roster[i].last_seen_tic > 175) { // ~5 s + lprintf(LO_INFO, "mesh: peer %02x:%02x:%02x:%02x:%02x:%02x " + "timed out\n", + s_roster[i].mac[0], s_roster[i].mac[1], s_roster[i].mac[2], + s_roster[i].mac[3], s_roster[i].mac[4], s_roster[i].mac[5]); + roster_remove_at(i); + changed = true; + } + } + if (changed) recompute_roster("peer timeout", false); + + uint8_t buf[1024]; + packet_header_t *h = (packet_header_t *)buf; + + while (1) { + size_t n = I_GetPacket(h, sizeof(buf)); + if (n == 0) break; + if (!packet_ok(h, n)) continue; + + const uint8_t *p = buf + sizeof(*h); + size_t plen = n - sizeof(*h); + + if (h->type == PKT_HELLO) { + handle_hello(p, plen); + continue; + } + + if (h->type == PKT_TICC) { + // Payload: numtics (1) || firsttic (4) || sender_slot (1) || tics + if (plen < 6) continue; + uint8_t numtics = p[0]; + uint32_t firsttic = + (p[1] << 24) | (p[2] << 16) | (p[3] << 8) | p[4]; + uint8_t sender_slot = p[5]; + const uint8_t *ticbase = p + 6; + if (plen < 6 + (size_t)numtics * sizeof(ticcmd_t)) continue; + + // Sanity: the sender_slot must agree with our local view of + // their MAC's position in the sorted roster. + if (sender_slot >= s_numplayers) continue; + if (sender_slot == s_consoleplayer) continue; // our own echo + + // Direct evidence the sender is alive — refresh their + // liveness so the 5 s timeout doesn't fire while they're + // actively in the game. + if (sender_slot < s_roster_count) { + s_roster[sender_slot].last_seen_tic = now_t; + } + + // Generation gate: ignore mismatched generations (5 s grace + // after our last restart, then drop quietly — mesh converges + // via HELLO gossip rather than via re-INIT). + if (h->generation != s_generation) { + int since_go = now_t - s_last_go_tic; + if (since_go > 175) { + // Future-generation tics: probably a peer restarted + // for a roster they observed that we haven't yet. + // The next HELLO from anyone will fix it. + } + continue; + } + + for (uint8_t ti = 0; ti < numtics; ti++) { + unsigned tic = firsttic + ti; + ticcmd_t cmd; + RawToTic(&cmd, ticbase + ti * sizeof(ticcmd_t)); + s_netcmds_ring[sender_slot][tic & TIC_MASK] = cmd; + if ((int)tic > s_have_tic[sender_slot]) { + s_have_tic[sender_slot] = tic; + } + } + continue; + } + + // PKT_INIT/SETUP/GO/TICS/etc. are unused in mesh mode — drop. + } +} + +void net_request_restart(void) { + // In mesh mode, "request restart" bumps the epoch and forces a + // recompute even if the roster hasn't changed. Every other Pixie + // observing the same lockstep state (e.g. all-dead detected at the + // same gametic) does the same independently → same epoch, same gen, + // simultaneous engine reset. Peers that miss this lockstep moment + // (rare; bug if it happens) re-sync via HELLO epoch adoption. + if (!s_net_active) return; + s_restart_epoch++; + recompute_roster("explicit restart request", true); +} + +#endif // DOOM_NETPLAY_MESH diff --git a/main/doom/d_net.h b/main/doom/d_net.h index fe98587..12c0777 100644 --- a/main/doom/d_net.h +++ b/main/doom/d_net.h @@ -36,6 +36,7 @@ #define __D_NET__ #include "d_player.h" +#include "d_ticcmd.h" #ifdef __GNUG__ @@ -50,4 +51,20 @@ void TryRunTics (void); // CPhipps - move to header file void D_InitNetGame (void); // This does the setup + +// Implemented in d_net.c — multiplayer state pump. +bool net_init(int handshake_timeout_ms); +bool net_is_active(void); +bool net_is_started(void); +int net_numplayers(void); +int net_consoleplayer(void); +unsigned net_start_tic(void); +void net_pump(void); +bool net_have_all_tics(int t); +void net_get_tic(int p, int t, ticcmd_t *out); +void net_record_local_tic(int tic, const ticcmd_t *cmd); +void net_flush_unsent(int up_to_tic); +void net_request_retrans(int wanttic); +void net_request_restart(void); + #endif diff --git a/main/doom/doomdef.h b/main/doom/doomdef.h index 416b268..531b420 100644 --- a/main/doom/doomdef.h +++ b/main/doom/doomdef.h @@ -123,7 +123,7 @@ typedef enum { // The maximum number of players, multiplayer/networking. -#define MAXPLAYERS 1 +#define MAXPLAYERS 8 // phares 5/14/98: // DOOM Editor Numbers (aka doomednum in mobj_t) diff --git a/main/doom/g_game.c b/main/doom/g_game.c index c0a585a..a4146c8 100644 --- a/main/doom/g_game.c +++ b/main/doom/g_game.c @@ -241,17 +241,17 @@ void G_BuildTiccmd(ticcmd_t* cmd) if(_g->gamekeydown[key_use] && _g->gamekeydown[key_straferight]) { - newweapon = P_WeaponCycleUp(&_g->player); + newweapon = P_WeaponCycleUp(&_g->players[_g->consoleplayer]); side -= sidemove[speed]; //Hack cancel strafe. } else if(_g->gamekeydown[key_use] && _g->gamekeydown[key_strafeleft]) { - newweapon = P_WeaponCycleDown(&_g->player); + newweapon = P_WeaponCycleDown(&_g->players[_g->consoleplayer]); side += sidemove[speed]; //Hack cancel strafe. } - else if ((_g->player.attackdown && !P_CheckAmmo(&_g->player))) - newweapon = P_SwitchWeapon(&_g->player); // phares + else if ((_g->players[_g->consoleplayer].attackdown && !P_CheckAmmo(&_g->players[_g->consoleplayer]))) + newweapon = P_SwitchWeapon(&_g->players[_g->consoleplayer]); // phares else { // phares 02/26/98: Added gamemode checks newweapon = wp_nochange; @@ -268,7 +268,7 @@ void G_BuildTiccmd(ticcmd_t* cmd) // Switch to shotgun or SSG based on preferences. { - const player_t *player = &_g->player; + const player_t *player = &_g->players[_g->consoleplayer]; // only select chainsaw from '1' if it's owned, it's // not already in use, and the player prefers it or @@ -386,10 +386,12 @@ static void G_DoLoadLevel (void) _g->gamestate = GS_LEVEL; - if (_g->playeringame && _g->player.playerstate == PST_DEAD) - _g->player.playerstate = PST_REBORN; - - memset (_g->player.frags,0,sizeof(_g->player.frags)); + for (int p = 0; p < MAXPLAYERS; p++) + { + if (_g->playeringame[p] && _g->players[p].playerstate == PST_DEAD) + _g->players[p].playerstate = PST_REBORN; + memset(_g->players[p].frags, 0, sizeof(_g->players[p].frags)); + } // initialize the msecnode_t freelist. phares 3/25/98 @@ -482,8 +484,11 @@ boolean G_Responder (event_t* ev) void G_Ticker (void) { P_MapStart(); - if(_g->playeringame && _g->player.playerstate == PST_REBORN) - G_DoReborn (0); + for (int p = 0; p < MAXPLAYERS; p++) + { + if (_g->playeringame[p] && _g->players[p].playerstate == PST_REBORN) + G_DoReborn(p); + } P_MapEnd(); // do things to change the game state @@ -492,7 +497,11 @@ boolean G_Responder (event_t* ev) switch (_g->gameaction) { case ga_loadlevel: - _g->player.playerstate = PST_REBORN; + for (int p = 0; p < MAXPLAYERS; p++) + { + if (_g->playeringame[p]) + _g->players[p].playerstate = PST_REBORN; + } G_DoLoadLevel (); break; case ga_newgame: @@ -524,24 +533,28 @@ boolean G_Responder (event_t* ev) if (_g->paused & 2 || (!_g->demoplayback && _g->menuactive)) _g->basetic++; // For revenant tracers and RNG -- we must maintain sync else { - if (_g->playeringame) + // Copy each active player's networked input into their player_t. + for (int p = 0; p < MAXPLAYERS; p++) { - ticcmd_t *cmd = &_g->player.cmd; - - memcpy(cmd, &_g->netcmd, sizeof *cmd); - - if (_g->demoplayback) - G_ReadDemoTiccmd (cmd); - if (_g->demorecording) - G_WriteDemoTiccmd (cmd); + if (!_g->playeringame[p]) continue; + ticcmd_t *cmd = &_g->players[p].cmd; + memcpy(cmd, &_g->netcmds[p], sizeof *cmd); + if (p == _g->consoleplayer) + { + if (_g->demoplayback) G_ReadDemoTiccmd(cmd); + if (_g->demorecording) G_WriteDemoTiccmd(cmd); + } } - if (_g->playeringame) + // Special button events apply to whichever player issued them + // (pause, savegame, loadgame, restart). Walk all active slots. + for (int p = 0; p < MAXPLAYERS; p++) { - if (_g->player.cmd.buttons & BT_SPECIAL) + if (!_g->playeringame[p]) continue; + if (_g->players[p].cmd.buttons & BT_SPECIAL) { - switch (_g->player.cmd.buttons & BT_SPECIALMASK) + switch (_g->players[p].cmd.buttons & BT_SPECIALMASK) { case BTS_PAUSE: _g->paused ^= 1; @@ -552,14 +565,14 @@ boolean G_Responder (event_t* ev) break; case BTS_SAVEGAME: - _g->savegameslot = (_g->player.cmd.buttons & BTS_SAVEMASK)>>BTS_SAVESHIFT; + _g->savegameslot = (_g->players[p].cmd.buttons & BTS_SAVEMASK)>>BTS_SAVESHIFT; _g->gameaction = ga_savegame; break; // CPhipps - remote loadgame request case BTS_LOADGAME: _g->savegameslot = - (_g->player.cmd.buttons & BTS_SAVEMASK)>>BTS_SAVESHIFT; + (_g->players[p].cmd.buttons & BTS_SAVEMASK)>>BTS_SAVESHIFT; _g->gameaction = ga_loadgame; _g->command_loadgame = false; break; @@ -571,7 +584,7 @@ boolean G_Responder (event_t* ev) _g->gameaction = ga_loadlevel; break; } - _g->player.cmd.buttons = 0; + _g->players[p].cmd.buttons = 0; } } } @@ -638,7 +651,7 @@ boolean G_Responder (event_t* ev) static void G_PlayerFinishLevel(int player) { - player_t *p = &_g->player; + player_t *p = &_g->players[player]; memset(p->powers, 0, sizeof p->powers); memset(p->cards, 0, sizeof p->cards); p->mo = NULL; // cph - this is allocated PU_LEVEL so it's gone @@ -663,12 +676,12 @@ void G_PlayerReborn (int player) int itemcount; int secretcount; - memcpy (frags, _g->player.frags, sizeof frags); - killcount = _g->player.killcount; - itemcount = _g->player.itemcount; - secretcount = _g->player.secretcount; + memcpy (frags, _g->players[player].frags, sizeof frags); + killcount = _g->players[player].killcount; + itemcount = _g->players[player].itemcount; + secretcount = _g->players[player].secretcount; - p = &_g->player; + p = &_g->players[player]; // killough 3/10/98,3/21/98: preserve cheats across idclev { @@ -677,10 +690,10 @@ void G_PlayerReborn (int player) p->cheats = cheats; } - memcpy(_g->player.frags, frags, sizeof(_g->player.frags)); - _g->player.killcount = killcount; - _g->player.itemcount = itemcount; - _g->player.secretcount = secretcount; + memcpy(_g->players[player].frags, frags, sizeof(_g->players[player].frags)); + _g->players[player].killcount = killcount; + _g->players[player].itemcount = itemcount; + _g->players[player].secretcount = secretcount; p->usedown = p->attackdown = true; // don't do anything immediately p->playerstate = PST_LIVE; @@ -746,7 +759,7 @@ void G_DoCompleted (void) { _g->gameaction = ga_nothing; - if (_g->playeringame) + if (_g->playeringame[_g->consoleplayer]) G_PlayerFinishLevel(0); // take away cards and stuff if (_g->automapmode & am_active) @@ -757,11 +770,11 @@ void G_DoCompleted (void) { // cph - Remove ExM8 special case, so it gets summary screen displayed case 9: - _g->player.didsecret = true; + _g->players[_g->consoleplayer].didsecret = true; break; } - _g->wminfo.didsecret = _g->player.didsecret; + _g->wminfo.didsecret = _g->players[_g->consoleplayer].didsecret; _g->wminfo.epsd = _g->gameepisode -1; _g->wminfo.last = _g->gamemap -1; @@ -827,12 +840,12 @@ void G_DoCompleted (void) _g->wminfo.pnum = 0; - _g->wminfo.plyr[0].in = _g->playeringame; - _g->wminfo.plyr[0].skills = _g->player.killcount; - _g->wminfo.plyr[0].sitems = _g->player.itemcount; - _g->wminfo.plyr[0].ssecret = _g->player.secretcount; + _g->wminfo.plyr[0].in = _g->playeringame[_g->consoleplayer]; + _g->wminfo.plyr[0].skills = _g->players[_g->consoleplayer].killcount; + _g->wminfo.plyr[0].sitems = _g->players[_g->consoleplayer].itemcount; + _g->wminfo.plyr[0].ssecret = _g->players[_g->consoleplayer].secretcount; _g->wminfo.plyr[0].stime = _g->leveltime; - memcpy (_g->wminfo.plyr[0].frags, _g->player.frags, + memcpy (_g->wminfo.plyr[0].frags, _g->players[_g->consoleplayer].frags, sizeof(_g->wminfo.plyr[0].frags)); /* cph - modified so that only whole seconds are added to the totalleveltimes @@ -866,7 +879,7 @@ void G_WorldDone (void) _g->gameaction = ga_worlddone; if (_g->secretexit) - _g->player.didsecret = true; + _g->players[_g->consoleplayer].didsecret = true; if (_g->gamemode == commercial) { @@ -996,13 +1009,13 @@ void G_DoLoadGame() G_InitNew (_g->gameskill, _g->gameepisode, _g->gamemap); _g->totalleveltimes = savedata->totalleveltimes; - memcpy(_g->player.weaponowned, savedata->weaponowned, sizeof(savedata->weaponowned)); - memcpy(_g->player.ammo, savedata->ammo, sizeof(savedata->ammo)); - memcpy(_g->player.maxammo, savedata->maxammo, sizeof(savedata->maxammo)); + memcpy(_g->players[_g->consoleplayer].weaponowned, savedata->weaponowned, sizeof(savedata->weaponowned)); + memcpy(_g->players[_g->consoleplayer].ammo, savedata->ammo, sizeof(savedata->ammo)); + memcpy(_g->players[_g->consoleplayer].maxammo, savedata->maxammo, sizeof(savedata->maxammo)); //If stored maxammo is more than no backpack ammo, player had a backpack. - if(_g->player.maxammo[am_clip] > maxammo[am_clip]) - _g->player.backpack = true; + if(_g->players[_g->consoleplayer].maxammo[am_clip] > maxammo[am_clip]) + _g->players[_g->consoleplayer].backpack = true; Z_Free(loadbuffer); } @@ -1040,15 +1053,15 @@ static void G_DoSaveGame(boolean menu) savedata->alwaysRun = _g->alwaysRun; savedata->gamma = _g->gamma; - memcpy(savedata->weaponowned, _g->player.weaponowned, sizeof(savedata->weaponowned)); - memcpy(savedata->ammo, _g->player.ammo, sizeof(savedata->ammo)); - memcpy(savedata->maxammo, _g->player.maxammo, sizeof(savedata->maxammo)); + memcpy(savedata->weaponowned, _g->players[_g->consoleplayer].weaponowned, sizeof(savedata->weaponowned)); + memcpy(savedata->ammo, _g->players[_g->consoleplayer].ammo, sizeof(savedata->ammo)); + memcpy(savedata->maxammo, _g->players[_g->consoleplayer].maxammo, sizeof(savedata->maxammo)); SaveSRAM(savebuffer, savebuffersize, 0); Z_Free(savebuffer); - _g->player.message = GGSAVED; + _g->players[_g->consoleplayer].message = GGSAVED; G_UpdateSaveGameStrings(); } @@ -1169,7 +1182,11 @@ void G_InitNew(skill_t skill, int episode, int map) _g->respawnmonsters = skill == sk_nightmare; - _g->player.playerstate = PST_REBORN; + for (int p = 0; p < MAXPLAYERS; p++) + { + if (_g->playeringame[p]) + _g->players[p].playerstate = PST_REBORN; + } _g->usergame = true; // will be set false if a demo _g->paused = false; @@ -1478,7 +1495,7 @@ static const byte* G_ReadDemoHeader(const byte *demo_p, size_t size, boolean fai if (_g->demover >= 200) { - _g->playeringame = *demo_p++; + _g->playeringame[_g->consoleplayer] = *demo_p++; demo_p += MIN_MAXPLAYERS - MAXPLAYERS; } @@ -1486,7 +1503,7 @@ static const byte* G_ReadDemoHeader(const byte *demo_p, size_t size, boolean fai G_InitNew(skill, episode, map); } - _g->player.cheats = 0; + _g->players[_g->consoleplayer].cheats = 0; demo_p+=4; //skip over players diff --git a/main/doom/global_data.h b/main/doom/global_data.h index 5120509..4f64aff 100644 --- a/main/doom/global_data.h +++ b/main/doom/global_data.h @@ -97,10 +97,22 @@ fixed_t ftom_zoommul; // how far the window zooms each tic (fb coords) // d_client.c // ****************************************************************************** -ticcmd_t netcmd; +// One netcmd per player slot. For single-player, only netcmds[0] is filled +// from G_BuildTiccmd. Multiplayer fills slots from incoming PKT_TICS packets. +ticcmd_t netcmds[MAXPLAYERS]; int maketic; int lastmadetic; +// Which player slot is local input fed into and rendered from. +// In single-player both are 0. +int consoleplayer; +int displayplayer; + +// RNG seed published by the relay in PKT_GO. M_ClearRandom() uses the low +// byte to set the initial rndindex so every Pixie starts the new level at +// the same RNG offset. Stays 0 in single-player. +uint32_t net_rngseed; + // ****************************************************************************** // d_main.c // ****************************************************************************** @@ -183,7 +195,7 @@ skill_t gameskill; int gameepisode; int gamemap; -player_t player; +player_t players[MAXPLAYERS]; int starttime; // for comparative timing purposes @@ -221,7 +233,7 @@ boolean command_loadgame; boolean usergame; // ok to save / end game boolean timingdemo; // if true, exit with report on completion -boolean playeringame; +boolean playeringame[MAXPLAYERS]; boolean demoplayback; boolean singledemo; // quit after playing a demo from cmdline boolean haswolflevels;// jff 4/18/98 wolf levels present diff --git a/main/doom/hu_stuff.c b/main/doom/hu_stuff.c index f1db20a..0e02ee5 100644 --- a/main/doom/hu_stuff.c +++ b/main/doom/hu_stuff.c @@ -476,7 +476,7 @@ void HU_Erase(void) void HU_Ticker(void) { - player_t* plr = &_g->player; // killough 3/7/98 + player_t* plr = &_g->players[_g->consoleplayer]; // killough 3/7/98 // tick down message counter if message is up if (_g->message_counter && !--_g->message_counter) diff --git a/main/doom/i_network.c b/main/doom/i_network.c new file mode 100644 index 0000000..89c1f2c --- /dev/null +++ b/main/doom/i_network.c @@ -0,0 +1,176 @@ +// PrBoom-style UDP network I/O for Doom on the ESP32-C3, against lwip's +// BSD sockets API. One non-blocking socket; all calls run from the tic +// loop (no separate task), so there's no synchronization to worry about. +// +// Wire format is whatever the caller hands to I_SendPacket — typically a +// packet_header_t followed by a payload (see doom/protocol.h). Byte order +// for the embedded ticcmd_t is handled by RawToTic / TicToRaw at the +// d_net.c layer; this file is dumb transport. +// +// Two destination modes (selected by DOOM_NETPLAY_MESH in net-config.h): +// - mesh: bind to a fixed port; I_SendPacket sends to +// 255.255.255.255:DOOM_NETPLAY_PORT (SO_BROADCAST enabled). +// - relay: ephemeral source port; I_SendPacket sends to the configured +// relay's IP:port. + +#include +#include +#include +#include + +#include "lwip/sockets.h" +#include "lwip/netdb.h" + +#include "doomtype.h" +#include "protocol.h" +#include "i_network.h" +#include "../net-config.h" + +// PrBoom externs declared in i_network.h. +size_t sentbytes = 0; +size_t recvdbytes = 0; +struct sockaddr sentfrom; +int v4socket = -1; +int v6socket = -1; + +// Cached destination endpoint (resolved once in I_InitNetwork). +static struct sockaddr_in s_dest_addr; +static int s_sock = -1; +static bool s_inited = false; + + +void I_InitNetwork(void) { + if (s_inited) { return; } + + s_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (s_sock < 0) { + printf("[net] socket() failed: %d\n", errno); + return; + } + + int flags = fcntl(s_sock, F_GETFL, 0); + fcntl(s_sock, F_SETFL, flags | O_NONBLOCK); + +#if DOOM_NETPLAY_MESH + // Mesh mode: bind to the well-known port so peers can reach us + // unsolicited, and enable broadcast sends. + { + struct sockaddr_in bindaddr = { 0 }; + bindaddr.sin_family = AF_INET; + bindaddr.sin_addr.s_addr = htonl(INADDR_ANY); + bindaddr.sin_port = htons(DOOM_NETPLAY_PORT); + int reuse = 1; + setsockopt(s_sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + if (bind(s_sock, (struct sockaddr *)&bindaddr, + sizeof(bindaddr)) < 0) { + printf("[net] bind() failed: %d\n", errno); + } + } + int bcast = 1; + if (setsockopt(s_sock, SOL_SOCKET, SO_BROADCAST, + &bcast, sizeof(bcast)) < 0) { + printf("[net] SO_BROADCAST failed: %d\n", errno); + } + memset(&s_dest_addr, 0, sizeof(s_dest_addr)); + s_dest_addr.sin_family = AF_INET; + s_dest_addr.sin_port = htons(DOOM_NETPLAY_PORT); + s_dest_addr.sin_addr.s_addr = htonl(INADDR_BROADCAST); + + v4socket = s_sock; + s_inited = true; + printf("[net] mesh socket up, bound %d, broadcast :%d, fd=%d\n", + DOOM_NETPLAY_PORT, DOOM_NETPLAY_PORT, s_sock); + +#else + // Relay mode: optionally bind to a fixed local port; send unicast. + if (DOOM_CLIENT_PORT != 0) { + struct sockaddr_in bindaddr = { 0 }; + bindaddr.sin_family = AF_INET; + bindaddr.sin_addr.s_addr = htonl(INADDR_ANY); + bindaddr.sin_port = htons(DOOM_CLIENT_PORT); + if (bind(s_sock, (struct sockaddr *)&bindaddr, + sizeof(bindaddr)) < 0) { + printf("[net] bind() failed: %d\n", errno); + } + } + + memset(&s_dest_addr, 0, sizeof(s_dest_addr)); + s_dest_addr.sin_family = AF_INET; + s_dest_addr.sin_port = htons(DOOM_SERVER_PORT); + if (inet_pton(AF_INET, DOOM_SERVER_IP, &s_dest_addr.sin_addr) != 1) { + printf("[net] inet_pton(%s) failed\n", DOOM_SERVER_IP); + close(s_sock); + s_sock = -1; + return; + } + + v4socket = s_sock; + s_inited = true; + printf("[net] relay socket up, server=%s:%d, fd=%d\n", + DOOM_SERVER_IP, DOOM_SERVER_PORT, s_sock); +#endif +} + +size_t I_GetPacket(packet_header_t *buffer, size_t buflen) { + if (s_sock < 0) { return 0; } + + socklen_t fromlen = sizeof(sentfrom); + ssize_t n = recvfrom(s_sock, buffer, buflen, MSG_DONTWAIT, + (struct sockaddr *)&sentfrom, &fromlen); + if (n <= 0) { + // EAGAIN/EWOULDBLOCK is the common case (no packet yet) — silent. + return 0; + } + + recvdbytes += (size_t)n; + return (size_t)n; +} + +void I_SendPacket(packet_header_t *packet, size_t len) { + if (s_sock < 0) { return; } + + ssize_t sent = sendto(s_sock, packet, len, 0, + (struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr)); + if (sent < 0) { + // Buffer full / unreachable / etc. — surface once, don't spam. + static int err_logged = 0; + if (!err_logged) { + printf("[net] sendto failed: errno=%d\n", errno); + err_logged = 1; + } + return; + } + sentbytes += (size_t)sent; +} + +void I_SendPacketTo(packet_header_t *packet, size_t len, UDP_CHANNEL *to) { + if (s_sock < 0 || !to) { return; } + ssize_t sent = sendto(s_sock, packet, len, 0, to, sizeof(*to)); + if (sent > 0) { sentbytes += (size_t)sent; } +} + +void I_WaitForPacket(int ms) { + if (s_sock < 0) { return; } + + fd_set readfds; + FD_ZERO(&readfds); + FD_SET(s_sock, &readfds); + + struct timeval tv; + tv.tv_sec = ms / 1000; + tv.tv_usec = (ms % 1000) * 1000; + + select(s_sock + 1, &readfds, NULL, NULL, &tv); +} + +void I_SetupSocket(int sock, int port, int family) { + // Stub — the single-socket model in I_InitNetwork covers our needs. + (void)sock; (void)port; (void)family; +} + +void I_PrintAddress(FILE *fp, UDP_CHANNEL *addr) { + char buf[INET_ADDRSTRLEN] = { 0 }; + struct sockaddr_in *sin = (struct sockaddr_in *)addr; + inet_ntop(AF_INET, &sin->sin_addr, buf, sizeof(buf)); + fprintf(fp, "%s:%d", buf, ntohs(sin->sin_port)); +} diff --git a/main/doom/i_system.c b/main/doom/i_system.c index f306c0c..deaa791 100644 --- a/main/doom/i_system.c +++ b/main/doom/i_system.c @@ -65,20 +65,25 @@ const char* I_GetVersionString(char* buf, size_t sz) #define MAX_MESSAGE_SIZE 1024 +extern void esp_rom_printf(const char *fmt, ...); + void I_Error (const char *error, ...) { char msg[MAX_MESSAGE_SIZE]; - + va_list v; va_start(v, error); - + vsprintf(msg, error, v); - + va_end(v); + // printf can be buffered or blocked under high load; esp_rom_printf + // writes straight to UART so the message is guaranteed visible + // before we spin in I_Quit. + esp_rom_printf("\n[I_Error] %s\n", msg); printf("%s", msg); - fflush( stderr ); fflush( stdout ); diff --git a/main/doom/m_cheat.c b/main/doom/m_cheat.c index 4affac3..bef086c 100644 --- a/main/doom/m_cheat.c +++ b/main/doom/m_cheat.c @@ -102,36 +102,36 @@ boolean C_Responder (event_t *ev) static void cheat_god() { - _g->player.cheats ^= CF_GODMODE; + _g->players[_g->consoleplayer].cheats ^= CF_GODMODE; - if(_g->player.cheats & CF_GODMODE) + if(_g->players[_g->consoleplayer].cheats & CF_GODMODE) { - _g->player.health = god_health; + _g->players[_g->consoleplayer].health = god_health; - _g->player.message = STSTR_DQDON; + _g->players[_g->consoleplayer].message = STSTR_DQDON; } else { - _g->player.message = STSTR_DQDOFF; + _g->players[_g->consoleplayer].message = STSTR_DQDOFF; } } static void cheat_choppers() { - _g->player.weaponowned[wp_chainsaw] = true; - _g->player.pendingweapon = wp_chainsaw; + _g->players[_g->consoleplayer].weaponowned[wp_chainsaw] = true; + _g->players[_g->consoleplayer].pendingweapon = wp_chainsaw; - P_GivePower(&_g->player, pw_invulnerability); + P_GivePower(&_g->players[_g->consoleplayer], pw_invulnerability); - _g->player.message = STSTR_CHOPPERS; + _g->players[_g->consoleplayer].message = STSTR_CHOPPERS; } static void cheat_idkfa() { int i; - player_t* plyr = &_g->player; + player_t* plyr = &_g->players[_g->consoleplayer]; if (!plyr->backpack) { @@ -163,7 +163,7 @@ static void cheat_idkfa() static void cheat_ammo() { int i; - player_t* plyr = &_g->player; + player_t* plyr = &_g->players[_g->consoleplayer]; if (!plyr->backpack) { @@ -191,42 +191,42 @@ static void cheat_ammo() static void cheat_noclip() { - _g->player.cheats ^= CF_NOCLIP; + _g->players[_g->consoleplayer].cheats ^= CF_NOCLIP; - if(_g->player.cheats & CF_NOCLIP) + if(_g->players[_g->consoleplayer].cheats & CF_NOCLIP) { - _g->player.message = STSTR_NCON; + _g->players[_g->consoleplayer].message = STSTR_NCON; } else { - _g->player.message = STSTR_NCOFF; + _g->players[_g->consoleplayer].message = STSTR_NCOFF; } } static void cheat_invincibility() { - P_GivePower(&_g->player, pw_invulnerability); + P_GivePower(&_g->players[_g->consoleplayer], pw_invulnerability); } static void cheat_beserk() { - P_GivePower(&_g->player, pw_strength); + P_GivePower(&_g->players[_g->consoleplayer], pw_strength); } static void cheat_invisibility() { - P_GivePower(&_g->player, pw_invisibility); + P_GivePower(&_g->players[_g->consoleplayer], pw_invisibility); } static void cheat_map() { - P_GivePower(&_g->player, pw_allmap); + P_GivePower(&_g->players[_g->consoleplayer], pw_allmap); } static void cheat_goggles() { - P_GivePower(&_g->player, pw_infrared); + P_GivePower(&_g->players[_g->consoleplayer], pw_infrared); } static void cheat_exit() @@ -236,22 +236,22 @@ static void cheat_exit() static void cheat_rockets() { - _g->player.cheats ^= CF_ENEMY_ROCKETS; + _g->players[_g->consoleplayer].cheats ^= CF_ENEMY_ROCKETS; - if(_g->player.cheats & CF_ENEMY_ROCKETS) + if(_g->players[_g->consoleplayer].cheats & CF_ENEMY_ROCKETS) { - _g->player.health = god_health; + _g->players[_g->consoleplayer].health = god_health; - _g->player.weaponowned[wp_missile] = true; - _g->player.ammo[am_misl] = _g->player.maxammo[am_misl]; + _g->players[_g->consoleplayer].weaponowned[wp_missile] = true; + _g->players[_g->consoleplayer].ammo[am_misl] = _g->players[_g->consoleplayer].maxammo[am_misl]; - _g->player.pendingweapon = wp_missile; + _g->players[_g->consoleplayer].pendingweapon = wp_missile; - _g->player.message = STSTR_ROCKETON; + _g->players[_g->consoleplayer].message = STSTR_ROCKETON; } else { - _g->player.message = STSTR_ROCKETOFF; + _g->players[_g->consoleplayer].message = STSTR_ROCKETOFF; } } @@ -260,9 +260,9 @@ static void cheat_fps() _g->fps_show = !_g->fps_show; if(_g->fps_show) { - _g->player.message = STSTR_FPSON; + _g->players[_g->consoleplayer].message = STSTR_FPSON; }else { - _g->player.message = STSTR_FPSOFF; + _g->players[_g->consoleplayer].message = STSTR_FPSOFF; } } \ No newline at end of file diff --git a/main/doom/m_menu.c b/main/doom/m_menu.c index d3e9010..010ed56 100644 --- a/main/doom/m_menu.c +++ b/main/doom/m_menu.c @@ -747,9 +747,9 @@ void M_ChangeMessages(int choice) _g->showMessages = 1 - _g->showMessages; if (!_g->showMessages) - _g->player.message = MSGOFF; // Ty 03/27/98 - externalized + _g->players[_g->consoleplayer].message = MSGOFF; // Ty 03/27/98 - externalized else - _g->player.message = MSGON ; // Ty 03/27/98 - externalized + _g->players[_g->consoleplayer].message = MSGON ; // Ty 03/27/98 - externalized _g->message_dontfuckwithme = true; @@ -764,9 +764,9 @@ void M_ChangeAlwaysRun(int choice) _g->alwaysRun = 1 - _g->alwaysRun; if (!_g->alwaysRun) - _g->player.message = RUNOFF; // Ty 03/27/98 - externalized + _g->players[_g->consoleplayer].message = RUNOFF; // Ty 03/27/98 - externalized else - _g->player.message = RUNON ; // Ty 03/27/98 - externalized + _g->players[_g->consoleplayer].message = RUNON ; // Ty 03/27/98 - externalized G_SaveSettings(); } diff --git a/main/doom/m_random.c b/main/doom/m_random.c index 5a6ce97..3ea0a45 100644 --- a/main/doom/m_random.c +++ b/main/doom/m_random.c @@ -94,5 +94,9 @@ int M_Random (void) void M_ClearRandom (void) { - _g->rndindex = _g->prndindex = 0; + // In netplay the relay supplies an rngseed in PKT_GO (stored in + // _g->net_rngseed) so every Pixie's RNG starts at the same offset. + // In single-player net_rngseed stays 0 and behavior matches stock. + uint8_t seed = (uint8_t)(_g->net_rngseed & 0xff); + _g->rndindex = _g->prndindex = seed; } diff --git a/main/doom/p_enemy.c b/main/doom/p_enemy.c index 6a7b253..a4a71d5 100644 --- a/main/doom/p_enemy.c +++ b/main/doom/p_enemy.c @@ -658,9 +658,9 @@ static boolean P_LookForPlayers(mobj_t *actor, boolean allaround) { player_t *player; - if(_g->playeringame) + if(_g->playeringame[_g->consoleplayer]) { - player = &_g->player; + player = &_g->players[_g->consoleplayer]; if (player->health <= 0) return false; // dead @@ -1829,7 +1829,7 @@ void A_BossDeath(mobj_t *mo) } - if (!(_g->playeringame && _g->player.health > 0)) + if (!(_g->playeringame[_g->consoleplayer] && _g->players[_g->consoleplayer].health > 0)) return; // no one left alive, so do not end game // scan the remaining thinkers to see diff --git a/main/doom/p_inter.c b/main/doom/p_inter.c index 8e0e1ee..66c61bd 100644 --- a/main/doom/p_inter.c +++ b/main/doom/p_inter.c @@ -579,7 +579,7 @@ void P_TouchSpecialThing(mobj_t *special, mobj_t *toucher) /* cph 20028/10 - for old-school DM addicts, allow old behavior * where only consoleplayer's pickup sounds are heard */ // displayplayer, not consoleplayer, for viewing multiplayer demos - if (player == &_g->player) + if (player == &_g->players[_g->consoleplayer]) S_StartSound (player->mo, sound | PICKUP_SOUND); // killough 4/25/98 } @@ -610,14 +610,14 @@ static void P_KillMobj(mobj_t *source, mobj_t *target) if (target->flags & MF_COUNTKILL) source->player->killcount++; if (target->player) - source->player->frags[target->player-&_g->player]++; + source->player->frags[target->player-&_g->players[_g->consoleplayer]]++; } else if (target->flags & MF_COUNTKILL) { /* Add to kills tally */ // count all monster deaths, // even those caused by other monsters - _g->player.killcount++; + _g->players[_g->consoleplayer].killcount++; } @@ -625,13 +625,13 @@ static void P_KillMobj(mobj_t *source, mobj_t *target) { // count environment kills against you if (!source) - target->player->frags[target->player-&_g->player]++; + target->player->frags[target->player-&_g->players[_g->consoleplayer]]++; target->flags &= ~MF_SOLID; target->player->playerstate = PST_DEAD; P_DropWeapon (target->player); - if (target->player == &_g->player && (_g->automapmode & am_active)) + if (target->player == &_g->players[_g->consoleplayer] && (_g->automapmode & am_active)) AM_Stop(); // don't die in auto map; switch view prior to dying } @@ -649,7 +649,7 @@ static void P_KillMobj(mobj_t *source, mobj_t *target) // This determines the kind of object spawned // during the death frame of a thing. - if( (_g->player.cheats & CF_ENEMY_ROCKETS) && (target->type >= MT_POSSESSED) && (target->type <= MT_KEEN) ) + if( (_g->players[_g->consoleplayer].cheats & CF_ENEMY_ROCKETS) && (target->type >= MT_POSSESSED) && (target->type <= MT_KEEN) ) { item = MT_MISC27; //Everyone drops a rocket launcher. } diff --git a/main/doom/p_mobj.c b/main/doom/p_mobj.c index cdeb66a..c072492 100644 --- a/main/doom/p_mobj.c +++ b/main/doom/p_mobj.c @@ -650,13 +650,13 @@ void P_SpawnPlayer (int n, const mapthing_t* mthing) // not playing? - if (!_g->playeringame) + if (!_g->playeringame[n]) return; - p = &_g->player; + p = &_g->players[n]; if (p->playerstate == PST_REBORN) - G_PlayerReborn (mthing->type-1); + G_PlayerReborn (n); /* cph 2001/08/14 - use the options field of memorised player starts to * indicate whether the start really exists in the level. @@ -669,7 +669,16 @@ void P_SpawnPlayer (int n, const mapthing_t* mthing) z = ONFLOORZ; mobj = P_SpawnMobj (x,y,z, MT_PLAYER); - // set color translations for player sprites + // Color translation for player sprites. The translation table has 3 + // alternate palettes (indigo, brown, red); the default (no bits set) + // is green. We key on the slot index so the offset-spawn fallback + // for player2..N — which clones playerstart[0] — still gets distinct + // colors. With 8 players and a 2-bit field, slots 4..7 wrap and + // share colors with 0..3. + { + unsigned int trans = ((unsigned int)(n & 3)) << MF_TRANSSHIFT; + mobj->flags = (mobj->flags & ~MF_TRANSLATION) | trans; + } mobj->angle = ANG45 * (mthing->angle/45); mobj->player = p; @@ -692,7 +701,8 @@ void P_SpawnPlayer (int n, const mapthing_t* mthing) P_SetupPsprites (p); - if (mthing->type-1 == 0) + // Status bar / HUD belong to the local view only. + if (n == _g->consoleplayer) { ST_Start(); // wake up the status bar HU_Start(); // wake up the heads up text @@ -772,13 +782,16 @@ void P_SpawnMapThing (const mapthing_t* mthing) } // check for players specially - - //Only care about start spot for player 1. - if(mthing->type == 1) + // DoomEd start-spot types 1..4 map to player slots 0..3. + if (mthing->type >= 1 && mthing->type <= MAXPLAYERS) { - _g->playerstarts[0] = *mthing; - _g->playerstarts[0].options = 1; - P_SpawnPlayer (0, &_g->playerstarts[0]); + int slot = mthing->type - 1; + _g->playerstarts[slot] = *mthing; + _g->playerstarts[slot].options = 1; + if (_g->playeringame[slot]) + { + P_SpawnPlayer(slot, &_g->playerstarts[slot]); + } return; } diff --git a/main/doom/p_setup.c b/main/doom/p_setup.c index 2bf1898..a71ad0d 100644 --- a/main/doom/p_setup.c +++ b/main/doom/p_setup.c @@ -469,10 +469,10 @@ void P_SetupLevel(int episode, int map, int playermask, skill_t skill) _g->wminfo.partime = 180; for (i=0; iplayer.killcount = _g->player.secretcount = _g->player.itemcount = 0; + _g->players[_g->consoleplayer].killcount = _g->players[_g->consoleplayer].secretcount = _g->players[_g->consoleplayer].itemcount = 0; // Initial height of PointOfView will be set by player think. - _g->player.viewz = 1; + _g->players[_g->consoleplayer].viewz = 1; // Make sure all sounds are stopped before Z_FreeTags. S_Start(); @@ -532,15 +532,51 @@ void P_SetupLevel(int episode, int map, int playermask, skill_t skill) memset(_g->playerstarts,0,sizeof(_g->playerstarts)); for (i = 0; i < MAXPLAYERS; i++) - _g->player.mo = NULL; + _g->players[i].mo = NULL; P_MapStart(); P_LoadThings(lumpnum+ML_THINGS); { - if (_g->playeringame && !_g->player.mo) - I_Error("P_SetupLevel: missing player %d start\n", i+1); + if (_g->playeringame[0] && !_g->players[0].mo) + I_Error("P_SetupLevel: missing player 1 start\n"); + + // The doom1gba.wad we bundle has only a player-1 start spot, so + // for slots 2..N we fabricate a start by offsetting from slot 0's + // position. Without these, players[i].mo is NULL — which is fine + // for the deterministic simulation, but on the Pixie whose + // consoleplayer matches that slot, viewplayer->mo is also NULL + // and the renderer has no camera anchor (looks "frozen"). + // + // The offsets are deterministic per-slot so every device computes + // identical mobj positions and stays in lockstep. 32 fixed-point + // units per side ≈ half a player width; far enough that we don't + // telefrag, close enough they're all visibly in the same room. + static const int dx[8] = { 0, 32, -32, 0, 32, -32, 32, -32 }; + static const int dy[8] = { 0, 0, 0, 32, 32, 32, -32, -32 }; + for (i = 1; i < MAXPLAYERS; i++) + { + if (_g->playeringame[i] && !_g->players[i].mo) + { + lprintf(LO_INFO, + "P_SetupLevel: synthesizing start for player %d " + "at offset (%d,%d) from slot 0\n", + i + 1, dx[i], dy[i]); + _g->playerstarts[i] = _g->playerstarts[0]; + _g->playerstarts[i].x = _g->playerstarts[0].x + dx[i]; + _g->playerstarts[i].y = _g->playerstarts[0].y + dy[i]; + _g->playerstarts[i].options = 1; + P_SpawnPlayer(i, &_g->playerstarts[i]); + continue; + } + if (_g->playeringame[i] && !_g->players[i].mo) + { + lprintf(LO_INFO, + "P_SetupLevel: no start for player %d (this is OK; " + "slot will have no body)\n", i + 1); + } + } } // killough 3/26/98: Spawn icon landings: diff --git a/main/doom/p_switch.c b/main/doom/p_switch.c index 0e6567f..230b9c0 100644 --- a/main/doom/p_switch.c +++ b/main/doom/p_switch.c @@ -1172,7 +1172,7 @@ static boolean PTR_CanUseTraverse(intercept_t *in) const line_t *line = in->d.line; // Compute which side the player is on - //player_t *pl = &players[consoleplayer]; + //player_t *pl = &players[_g->consoleplayer]; player_t *pl = _g->plyr; int side = P_PointOnLineSide(pl->mo->x, pl->mo->y, line); @@ -1193,7 +1193,7 @@ boolean ffx_doomCanUse() { if (_g->gamestate != GS_LEVEL) { return false; } - player_t *player = _g->plyr; //&players[consoleplayer]; + player_t *player = _g->plyr; //&players[_g->consoleplayer]; if (player == NULL) { return false; } //player->cheats |= CF_GODMODE; diff --git a/main/doom/p_tick.c b/main/doom/p_tick.c index 71a28fe..d3371a1 100644 --- a/main/doom/p_tick.c +++ b/main/doom/p_tick.c @@ -162,14 +162,22 @@ void P_Ticker (void) * All of this complicated mess is used to preserve demo sync. */ - if (_g->paused || (_g->menuactive && !_g->demoplayback && _g->player.viewz != 1)) + if (_g->paused || (_g->menuactive && !_g->demoplayback && _g->players[_g->consoleplayer].viewz != 1)) return; P_MapStart(); // not if this is an intermission screen - if(_g->gamestate==GS_LEVEL) - if (_g->playeringame) - P_PlayerThink(&_g->player); + if (_g->gamestate == GS_LEVEL) + { + for (int p = 0; p < MAXPLAYERS; p++) + { + // Skip players whose mobj never spawned — happens when the + // current map lacks a start spot for that slot (e.g. the + // doom1gba.wad we bundle has only player-1 starts). + if (_g->playeringame[p] && _g->players[p].mo) + P_PlayerThink(&_g->players[p]); + } + } P_RunThinkers(); P_UpdateSpecials(); diff --git a/main/doom/protocol.h b/main/doom/protocol.h index c2af10d..de1395b 100644 --- a/main/doom/protocol.h +++ b/main/doom/protocol.h @@ -35,28 +35,32 @@ #include "m_swap.h" enum packet_type_e { - PKT_INIT, // initial packet to server - PKT_SETUP, // game information packet - PKT_GO, // game has started - PKT_TICC, // tics from client - PKT_TICS, // tics from server + PKT_INIT, // initial packet to server (relay mode) + PKT_SETUP, // game information packet (relay mode) + PKT_GO, // game has started (relay mode) + PKT_TICC, // tics from client (both modes) + PKT_TICS, // tics from server (relay mode) PKT_RETRANS, // Request for retransmission - PKT_EXTRA, // Extra info packet + PKT_EXTRA, // Extra info packet — repurposed as PKT_HELLO in mesh mode PKT_QUIT, // Player quit game PKT_DOWN, // Server downed PKT_WAD, // Wad file request PKT_BACKOFF, // Request for client back-off }; +#define PKT_HELLO PKT_EXTRA + typedef struct { byte checksum; // Simple checksum of the entire packet byte type; /* Type of packet */ - byte reserved[2]; /* Was random in prboom <=2.2.4, now 0 */ + byte generation; // Restart counter; relay bumps every SETUP+GO. Clients + // with a stale generation know they missed a restart. + byte reserved; /* Was random in prboom <=2.2.4, now 0 */ unsigned tic; // Timestamp } PACKEDATTR packet_header_t; static inline void packet_set(packet_header_t* p, enum packet_type_e t, unsigned long tic) -{ p->tic = doom_htonl(tic); p->type = t; p->reserved[0] = 0; p->reserved[1] = 0; } +{ p->tic = doom_htonl(tic); p->type = t; p->generation = 0; p->reserved = 0; } #ifndef GAME_OPTIONS_SIZE // From g_game.h diff --git a/main/doom/r_hotpath.iwram.c b/main/doom/r_hotpath.iwram.c index f1cc8e6..851dc0e 100644 --- a/main/doom/r_hotpath.iwram.c +++ b/main/doom/r_hotpath.iwram.c @@ -3009,7 +3009,7 @@ boolean P_SetMobjState(mobj_t* mobj, statenum_t state) // Call action functions when the state is set if(st->action) { - if(!(_g->player.cheats & CF_ENEMY_ROCKETS)) + if(!(_g->players[_g->consoleplayer].cheats & CF_ENEMY_ROCKETS)) { st->action(mobj); } diff --git a/main/doom/s_sound.c b/main/doom/s_sound.c index af5331e..dcc83fa 100644 --- a/main/doom/s_sound.c +++ b/main/doom/s_sound.c @@ -215,12 +215,12 @@ void S_StartSoundAtVolume(mobj_t *origin, int sfx_id, int volume) // Check to see if it is audible, modify the params // killough 3/7/98, 4/25/98: code rearranged slightly - if (!origin || origin == _g->player.mo) + if (!origin || origin == _g->players[_g->consoleplayer].mo) { volume *= 8; } else - if (!S_AdjustSoundParams(_g->player.mo, origin, &volume, &sep)) + if (!S_AdjustSoundParams(_g->players[_g->consoleplayer].mo, origin, &volume, &sep)) return; // kill old sound diff --git a/main/doom/st_stuff.c b/main/doom/st_stuff.c index c080c1f..909ff6c 100644 --- a/main/doom/st_stuff.c +++ b/main/doom/st_stuff.c @@ -550,7 +550,7 @@ static void ST_initData(void) { int i; - _g->plyr = &_g->player; // killough 3/7/98 + _g->plyr = &_g->players[_g->consoleplayer]; // killough 3/7/98 _g->st_statusbaron = true; diff --git a/main/doom/wi_stuff.c b/main/doom/wi_stuff.c index 9f225e9..e83d43c 100644 --- a/main/doom/wi_stuff.c +++ b/main/doom/wi_stuff.c @@ -953,9 +953,9 @@ void WI_drawStats(void) void WI_checkForAccelerate(void) { int i; - player_t *player = &_g->player; + player_t *player = &_g->players[_g->consoleplayer]; - if (_g->playeringame) + if (_g->playeringame[_g->consoleplayer]) { if (player->cmd.buttons & BT_ATTACK) { diff --git a/main/main.c b/main/main.c index 9416124..7f41b1b 100644 --- a/main/main.c +++ b/main/main.c @@ -3,16 +3,19 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "esp_heap_caps.h" #include "firefly-display.h" #include "doom/doomdef.h" #include "doom/d_main.h" +#include "doom/d_player.h" #include "doom/global_data.h" #include "doom/i_video.h" #include "device-info.h" #include "keypad.h" +#include "wifi.h" #include "doom/firefly-doom.h" // This disables the start menu @@ -264,8 +267,28 @@ void app_main() { while(!ffx_display_renderFragment(display)); Z_Init(); + + printf("MP-PROBE: sizeof(player_t)=%u sizeof(globals_t)=%u " + "free_heap=%u largest_block=%u MAXPLAYERS=%d\n", + (unsigned)sizeof(player_t), + (unsigned)sizeof(globals_t), + (unsigned)heap_caps_get_free_size(MALLOC_CAP_8BIT), + (unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_8BIT), + MAXPLAYERS); + InitGlobals(); + printf("MP-PROBE: after InitGlobals: free_heap=%u\n", + (unsigned)heap_caps_get_free_size(MALLOC_CAP_8BIT)); + + // WiFi up before D_DoomMain. Best-effort: 15 s timeout, fall through to + // single-player if it doesn't connect (multiplayer init will retry). + printf("MP-PROBE: bringing up WiFi...\n"); + bool wifi_ok = wifi_connect(15000); + printf("MP-PROBE: WiFi %s; free_heap=%u\n", + wifi_ok ? "OK" : "FAIL", + (unsigned)heap_caps_get_free_size(MALLOC_CAP_8BIT)); + /////////////////////////// // Main loop diff --git a/main/net-config.h b/main/net-config.h new file mode 100644 index 0000000..4853e16 --- /dev/null +++ b/main/net-config.h @@ -0,0 +1,32 @@ +#ifndef __NET_CONFIG_H__ +#define __NET_CONFIG_H__ + +// Pick mesh (peer-to-peer, no Python server) or relay (centralized +// tools/relay.py on a host). +// +// Mesh mode: every Pixie broadcasts PKT_HELLO + PKT_TICC to +// 255.255.255.255:DOOM_NETPLAY_PORT. Roster + slot + rngseed are +// computed deterministically from observed MACs. No external process. +// Works on most home networks and laptop hotspots; fails on +// client-isolated guest WiFi. +// +// Relay mode: every Pixie unicasts to DOOM_SERVER_IP and the Python +// relay merges and rebroadcasts. Useful for debugging (the relay's +// heartbeat + operator console) and for networks that block +// peer-to-peer UDP. +#define DOOM_NETPLAY_MESH 1 +#define DOOM_NETPLAY_PORT 5029 + +#if !DOOM_NETPLAY_MESH +// PC relay address. The Doom client unicasts PKT_TICC here and listens for +// PKT_TICS on its source port. For laptop-as-AP, this is the laptop's IP on +// its hotspot subnet (typically 192.168.x.1). +#define DOOM_SERVER_IP "192.168.77.200" +#define DOOM_SERVER_PORT DOOM_NETPLAY_PORT + +// Local UDP port the client binds. 0 = ephemeral (recommended; the relay +// learns the client's source port from the first PKT_INIT). +#define DOOM_CLIENT_PORT 0 +#endif + +#endif diff --git a/main/wifi-creds.h.example b/main/wifi-creds.h.example new file mode 100644 index 0000000..ed8c239 --- /dev/null +++ b/main/wifi-creds.h.example @@ -0,0 +1,10 @@ +// Copy this file to wifi-creds.h and fill in your network credentials. +// wifi-creds.h is gitignored. + +#ifndef __WIFI_CREDS_H__ +#define __WIFI_CREDS_H__ + +#define WIFI_SSID "your-ssid" +#define WIFI_PASSWORD "your-password" + +#endif diff --git a/main/wifi.c b/main/wifi.c new file mode 100644 index 0000000..cc3f7a2 --- /dev/null +++ b/main/wifi.c @@ -0,0 +1,127 @@ +#include "wifi.h" + +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include "freertos/task.h" + +#include "esp_event.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_wifi.h" +#include "nvs_flash.h" + +#include "wifi-creds.h" + +static const char *TAG = "wifi"; + +#define WIFI_CONNECTED_BIT BIT0 +#define WIFI_FAIL_BIT BIT1 +#define MAX_RETRY 5 + +static EventGroupHandle_t s_eventGroup = NULL; +static bool s_started = false; +static bool s_connected = false; +static int s_retry = 0; + +static void onEvent(void *arg, esp_event_base_t base, int32_t id, void *data) { + if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) { + esp_wifi_connect(); + } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { + s_connected = false; + if (s_retry < MAX_RETRY) { + s_retry++; + ESP_LOGI(TAG, "retry %d/%d", s_retry, MAX_RETRY); + esp_wifi_connect(); + } else { + xEventGroupSetBits(s_eventGroup, WIFI_FAIL_BIT); + } + } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t *event = (ip_event_got_ip_t *)data; + ESP_LOGI(TAG, "got ip:" IPSTR, IP2STR(&event->ip_info.ip)); + s_retry = 0; + s_connected = true; + xEventGroupSetBits(s_eventGroup, WIFI_CONNECTED_BIT); + } +} + +static esp_err_t initOnce(void) { + if (s_started) { return ESP_OK; } + + // NVS may already be initialized by hollows; tolerate that. + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + nvs_flash_erase(); + err = nvs_flash_init(); + } + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { return err; } + + // esp_netif_init / event_loop are idempotent enough; ESP_ERR_INVALID_STATE + // on second-init is fine. + err = esp_netif_init(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { return err; } + + err = esp_event_loop_create_default(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { return err; } + + s_eventGroup = xEventGroupCreate(); + if (!s_eventGroup) { return ESP_ERR_NO_MEM; } + + esp_netif_create_default_wifi_sta(); + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + err = esp_wifi_init(&cfg); + if (err != ESP_OK) { return err; } + + esp_event_handler_instance_t hWifi, hIp; + esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, + &onEvent, NULL, &hWifi); + esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, + &onEvent, NULL, &hIp); + + wifi_config_t wcfg = { 0 }; + strncpy((char *)wcfg.sta.ssid, WIFI_SSID, sizeof(wcfg.sta.ssid) - 1); + strncpy((char *)wcfg.sta.password, WIFI_PASSWORD, sizeof(wcfg.sta.password) - 1); + wcfg.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + + esp_wifi_set_mode(WIFI_MODE_STA); + esp_wifi_set_config(WIFI_IF_STA, &wcfg); + esp_wifi_start(); + + s_started = true; + return ESP_OK; +} + +bool wifi_connect(uint32_t timeoutMs) { + if (s_connected) { + ESP_LOGI(TAG, "wifi_connect: already connected"); + return true; + } + + ESP_LOGI(TAG, "wifi_connect: initOnce"); + esp_err_t ie = initOnce(); + if (ie != ESP_OK) { + ESP_LOGE(TAG, "initOnce: %s", esp_err_to_name(ie)); + return false; + } + + s_retry = 0; + xEventGroupClearBits(s_eventGroup, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT); + // The STA_START event fires connect on its own; don't double-call. + // (Calling esp_wifi_connect() while a connect is already in-flight + // returns ESP_ERR_WIFI_CONN and is harmless, but it spams logs.) + + ESP_LOGI(TAG, "wifi_connect: waiting up to %lu ms", (unsigned long)timeoutMs); + EventBits_t bits = xEventGroupWaitBits(s_eventGroup, + WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE, + timeoutMs / portTICK_PERIOD_MS); + + bool ok = (bits & WIFI_CONNECTED_BIT) != 0; + ESP_LOGI(TAG, "wifi_connect: %s", ok ? "OK" : "FAIL/TIMEOUT"); + return ok; +} + +bool wifi_isConnected(void) { + return s_connected; +} diff --git a/main/wifi.h b/main/wifi.h new file mode 100644 index 0000000..18367b6 --- /dev/null +++ b/main/wifi.h @@ -0,0 +1,21 @@ +#ifndef __WIFI_H__ +#define __WIFI_H__ + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include + +// Block until associated + got IP, or timeoutMs elapses. Returns true on success. +// Idempotent: calling repeatedly after a successful connect is a no-op. +bool wifi_connect(uint32_t timeoutMs); + +bool wifi_isConnected(void); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/sdkconfig.defaults b/sdkconfig.defaults new file mode 100644 index 0000000..9747036 --- /dev/null +++ b/sdkconfig.defaults @@ -0,0 +1,16 @@ +# Pin the build-relevant choices for firefly-doom so `idf.py set-target` +# regeneration doesn't drop them. Originally these were checked-in via +# sdkconfig itself (generated against a newer ESP-IDF), but kconfig defaults +# get rewritten between IDF versions. + +# 16 MB flash to fit the WAD + factory app +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_ESPTOOLPY_FLASHSIZE="16MB" + +# Use the project's partitions.csv (factory=7MB, attest, nvs) +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" + +# WiFi station mode (used for multiplayer) +CONFIG_ESP_WIFI_ENABLED=y diff --git a/tools/relay.py b/tools/relay.py new file mode 100755 index 0000000..5e39fce --- /dev/null +++ b/tools/relay.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +""" +Tiny UDP relay for firefly-doom multiplayer. Pure ticcmd broadcast — no +Doom simulation runs here. + +Wire format: see main/doom/protocol.h. Each packet is a packet_header_t +(8 bytes) plus a payload: + + byte checksum (sum of all bytes after this byte) + byte type (PKT_INIT/SETUP/GO/TICC/TICS/...) + byte reserved[2] + uint32_be tic (timestamp; we don't really use it) + +Dynamic-roster behavior (v2): + - The relay can sit idle with zero players. + - Any PKT_INIT from a new address assigns the lowest free slot 0..3 and + triggers a restart: every active client receives SETUP+GO with a fresh + rngseed, so they all return to E1M1 tic 0 with the new roster. + - Clean leave (PKT_QUIT) or 5 s of silence frees the slot and restarts the + remaining players. + - With one Pixie connected, it just plays alone (lockstep with itself). +""" + +import argparse +import asyncio +import os +import struct +import sys +import time +from typing import Dict, Tuple, Optional + +# Mirror of protocol.h enum +PKT_INIT, PKT_SETUP, PKT_GO, PKT_TICC, PKT_TICS, PKT_RETRANS, \ + PKT_EXTRA, PKT_QUIT, PKT_DOWN, PKT_WAD, PKT_BACKOFF = range(11) + +PACKET_HEADER = struct.Struct("!BBBB I") # checksum, type, r0, r1, tic +HEADER_LEN = PACKET_HEADER.size # 8 + +TICCMD_LEN = 8 # see d_ticcmd.h +MAX_PLAYERS = 8 +TICDUP_REDUNDANCY = 8 # tics resent on every PKT_TICS +PLAYER_TIMEOUT_S_INIT = 15.0 # silence-before-kick for a player + # who hasn't sent their first TICC + # yet — Pixie's W_Init/R_Init/ + # P_SetupLevel can exceed 5 s. +PLAYER_TIMEOUT_S_RUN = 5.0 # silence-before-kick once they're + # streaming TICCs at 35 Hz. Anything + # >5 s of silence from an actively- + # playing client means they're gone, + # and survivors are stalled in + # lockstep waiting on them. +TIMEOUT_POLL_S = 0.25 # how often to check liveness + +PKT_TYPE_NAMES = { + PKT_INIT: "INIT", PKT_SETUP: "SETUP", PKT_GO: "GO", + PKT_TICC: "TICC", PKT_TICS: "TICS", PKT_RETRANS: "RETRANS", + PKT_QUIT: "QUIT", PKT_DOWN: "DOWN", PKT_WAD: "WAD", + PKT_BACKOFF: "BACKOFF", +} + + +def checksum(buf: bytes) -> int: + s = 0 + for b in buf[1:]: + s = (s + b) & 0xff + return s + + +def make_packet(pkt_type: int, tic: int, payload: bytes, + generation: int = 0) -> bytes: + body = bytes([0, pkt_type, generation & 0xff, 0]) \ + + struct.pack("!I", tic) + payload + cs = checksum(body) + return bytes([cs]) + body[1:] + + +def parse_header(buf: bytes) -> Optional[Tuple[int, int, int]]: + if len(buf) < HEADER_LEN: + return None + cs, typ, _, _, tic = PACKET_HEADER.unpack(buf[:HEADER_LEN]) + if cs != checksum(buf): + return None + return cs, typ, tic + + +class Relay(asyncio.DatagramProtocol): + def __init__(self, max_players: int): + self.max_players = max_players + self.transport: Optional[asyncio.DatagramTransport] = None + + # Roster + self.players: Dict[Tuple[str, int], int] = {} # addr -> slot + self.slot_addr: Dict[int, Tuple[str, int]] = {} # slot -> addr + self.last_seen: Dict[int, float] = {} # slot -> monotonic + self.has_run: Dict[int, bool] = {} # slot -> received TICC? + + # Tic exchange — cleared on every restart_game() + self.tic_buf: Dict[Tuple[int, int], bytes] = {} # (slot,tic) -> raw + self.released = -1 + self.generation = 0 + self.rngseed = 0 + self.last_ticset_payload: Optional[bytes] = None + + # Stats + self.ticc_count = 0 + self._last_stats_report = 0.0 + + def connection_made(self, transport): + self.transport = transport + sock = transport.get_extra_info("socket") + print(f"[relay] listening on {sock.getsockname()}", flush=True) + + def datagram_received(self, data: bytes, addr): + hdr = parse_header(data) + if hdr is None: + print(f"[relay] bad checksum from {addr} ({len(data)} bytes)", + flush=True) + return + _, typ, _ = hdr + payload = data[HEADER_LEN:] + + if typ == PKT_INIT: + self.handle_init(addr, payload) + elif typ == PKT_TICC: + self.handle_ticc(addr, payload) + elif typ == PKT_RETRANS: + self.handle_retrans(addr, payload) + elif typ == PKT_QUIT: + self.handle_quit(addr) + else: + print(f"[relay] ignoring {PKT_TYPE_NAMES.get(typ, typ)} from {addr}", + flush=True) + + # ------------- slot management ------------- + + def _lowest_free_slot(self) -> Optional[int]: + for s in range(self.max_players): + if s not in self.slot_addr: + return s + return None + + def _free_slot(self, slot: int, reason: str): + addr = self.slot_addr.get(slot) + if addr is not None: + print(f"[relay] slot {slot} ({addr[0]}:{addr[1]}) freed — {reason}", + flush=True) + del self.slot_addr[slot] + del self.players[addr] + self.last_seen.pop(slot, None) + self.has_run.pop(slot, None) + + # ------------- restart ------------- + + def restart_game(self, reason: str): + """Reset tic state and broadcast SETUP+GO to all current players. + + Idempotent for empty rosters: if no players are connected, just + clears state and waits. + """ + self.tic_buf.clear() + self.released = -1 + self.last_ticset_payload = None + self.generation = (self.generation + 1) & 0xff + self.rngseed = int.from_bytes(os.urandom(4), "big") + + if not self.players: + print(f"[relay] reset (now idle, {reason})", flush=True) + return + + # Every player about to re-init goes back through P_SetupLevel, + # which can take several seconds before their first TICC. Reset + # has_run + last_seen so the cold-start 15 s grace applies again, + # otherwise the 5 s active-play timer kicks them mid-reload. + now = time.monotonic() + for slot in self.players.values(): + self.has_run[slot] = False + self.last_seen[slot] = now + + print(f"[relay] restart #{self.generation} ({reason}); " + f"players={len(self.players)} rngseed={self.rngseed:#x}", + flush=True) + + # SETUP is per-player (each gets its own slot number) + for addr, slot in self.players.items(): + self.transport.sendto(self._setup_packet(slot), addr) + # GO is the same for everyone + go = make_packet(PKT_GO, 0, struct.pack("!II", 0, self.rngseed), + generation=self.generation) + for addr in self.players.keys(): + self.transport.sendto(go, addr) + + def _setup_packet(self, slot: int) -> bytes: + # struct setup_packet_s { + # byte players, yourplayer, skill, episode, level, deathmatch, + # complevel, ticdup, extratic; + # byte game_options[GAME_OPTIONS_SIZE]; # 64 + # byte numwads; + # byte wadnames[1]; # variable; we send 0 + # } + nplayers = len(self.players) + payload = bytes([ + nplayers, slot, 3, 1, 1, 0, # players, you, skill, ep1, lvl1, coop + 0, 1, 0, # complevel=0, ticdup=1, extratic=0 + ]) + payload += b"\x00" * 64 # game_options + payload += bytes([0]) # numwads=0 (skip wadnames) + return make_packet(PKT_SETUP, 0, payload, generation=self.generation) + + # ------------- packet handlers ------------- + + def handle_init(self, addr, payload): + now = time.monotonic() + + # Same address re-INITing (e.g. Pixie rebooted from same socket). + if addr in self.players: + old_slot = self.players[addr] + print(f"[relay] {addr} re-INIT (was slot {old_slot})", flush=True) + self._free_slot(old_slot, "re-INIT") + else: + # Different source port from the same IP almost always means a + # Pixie crashed + rebooted (ESP32-C3 picks a new ephemeral port + # after reset). Free any slot still bound to that IP so we + # don't orphan it for the full timeout window. + same_ip = [ + a for a in self.players.keys() if a[0] == addr[0] + ] + for a in same_ip: + old_slot = self.players[a] + print(f"[relay] {addr} new INIT from same IP as " + f"{a[0]}:{a[1]} (slot {old_slot}); freeing old", + flush=True) + self._free_slot(old_slot, "same-IP rejoin") + + slot = self._lowest_free_slot() + if slot is None: + print(f"[relay] {addr} INIT: lobby full ({self.max_players})", + flush=True) + # Kick this one client; do not disturb the others. + self.transport.sendto(make_packet(PKT_QUIT, 0, b""), addr) + return + + self.players[addr] = slot + self.slot_addr[slot] = addr + self.last_seen[slot] = now + self.has_run[slot] = False + print(f"[relay] {addr} joined as slot {slot} " + f"({len(self.players)}/{self.max_players})", flush=True) + + self.restart_game(f"slot {slot} joined") + + def handle_ticc(self, addr, payload): + if addr not in self.players: + return + slot = self.players[addr] + self.last_seen[slot] = time.monotonic() + self.has_run[slot] = True + + if len(payload) < 5: + return + numtics = payload[0] + firsttic = struct.unpack("!I", payload[1:5])[0] + body = payload[5:] + if len(body) < numtics * TICCMD_LEN: + return + for i in range(numtics): + tic = firsttic + i + cmd = body[i*TICCMD_LEN:(i+1)*TICCMD_LEN] + self.tic_buf[(slot, tic)] = cmd + + self.ticc_count += 1 + + # Try to advance the released frontier. With a dynamic roster, the + # gate is "all *currently* active slots have this tic". + active_slots = list(self.players.values()) + moved = False + while True: + t = self.released + 1 + if all((s, t) in self.tic_buf for s in active_slots): + self.released = t + moved = True + else: + break + if moved: + self.broadcast_ticset() + + # Heartbeat. Reports each slot's *contiguous* frontier from the + # current generation (the highest tic T such that every tic 0..T + # is present in tic_buf). This is what actually gates `released`, + # and avoids confusing stale-tic-from-previous-generation reads. + now = time.monotonic() + if now - self._last_stats_report >= 2.0: + self._last_stats_report = now + frontier = {} + for s in active_slots: + t = self.released + 1 + while (s, t) in self.tic_buf: + t += 1 + frontier[s] = t - 1 + print(f"[relay] gen={self.generation} released={self.released} " + f"frontier={frontier} ticc_pkts={self.ticc_count}", + flush=True) + + def broadcast_ticset(self): + if self.released < 0 or not self.players: + return + first = max(0, self.released - TICDUP_REDUNDANCY + 1) + numtics = self.released - first + 1 + active_slots = sorted(self.players.values()) + body = bytearray() + for t in range(first, first + numtics): + for s in active_slots: + cmd = self.tic_buf.get((s, t)) + if cmd is None: + cmd = b"\x00" * TICCMD_LEN + body += cmd + payload = bytes([numtics]) + struct.pack("!I", first) + bytes(body) + pkt = make_packet(PKT_TICS, self.released, payload, + generation=self.generation) + for addr in self.players.keys(): + self.transport.sendto(pkt, addr) + self.last_ticset_payload = pkt + + def handle_retrans(self, addr, payload): + # A RETRANS request is direct evidence the client is alive — they + # send it when stalled in lockstep waiting for tics they're missing. + # Without refreshing last_seen here, survivors of a peer-disconnect + # cascade get timed out themselves: once the engine stalls, it caps + # maketic at gametic+7 and stops emitting TICCs, leaving only + # RETRANS as the heartbeat. + if addr in self.players: + slot = self.players[addr] + self.last_seen[slot] = time.monotonic() + if self.last_ticset_payload is not None and addr in self.players: + self.transport.sendto(self.last_ticset_payload, addr) + + def handle_quit(self, addr): + if addr not in self.players: + return + slot = self.players[addr] + self._free_slot(slot, "PKT_QUIT") + self.restart_game(f"slot {slot} quit") + + # ------------- liveness watchdog ------------- + + async def liveness_loop(self): + """Periodically free slots that haven't sent a PKT_TICC recently.""" + while True: + await asyncio.sleep(TIMEOUT_POLL_S) + now = time.monotonic() + stale = [ + slot for slot, t in self.last_seen.items() + if now - t > (PLAYER_TIMEOUT_S_RUN if self.has_run.get(slot) + else PLAYER_TIMEOUT_S_INIT) + ] + if stale: + for slot in stale: + self._free_slot(slot, "timeout") + self.restart_game(f"timeout (slots {stale})") + + +async def stdin_loop(relay: Relay): + """Operator console, when stdin is a tty.""" + if not sys.stdin.isatty(): + print("[relay] stdin is not a tty; commands disabled.", flush=True) + while True: + await asyncio.sleep(3600) + + loop = asyncio.get_event_loop() + reader = asyncio.StreamReader() + proto = asyncio.StreamReaderProtocol(reader) + await loop.connect_read_pipe(lambda: proto, sys.stdin) + print("[relay] commands: restart | players | quit | help", flush=True) + while True: + line = await reader.readline() + if not line: + break + cmd = line.decode().strip().lower() + if cmd == "restart": + relay.restart_game("operator") + elif cmd == "players": + for addr, slot in relay.players.items(): + print(f" slot {slot}: {addr[0]}:{addr[1]}", flush=True) + elif cmd == "quit": + for addr in list(relay.players.keys()): + relay.transport.sendto(make_packet(PKT_QUIT, 0, b""), addr) + break + elif cmd in ("help", ""): + print(" restart — bump generation, broadcast fresh SETUP+GO\n" + " players — list connected slots\n" + " quit — kick everyone and exit", flush=True) + else: + print(f" unknown: {cmd!r}", flush=True) + + +async def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--bind", default="0.0.0.0", help="listen address") + ap.add_argument("--port", type=int, default=5029, help="listen port") + ap.add_argument("--max-players", type=int, default=MAX_PLAYERS, + help=f"hard limit on simultaneous players (1..{MAX_PLAYERS})") + args = ap.parse_args() + + if not (1 <= args.max_players <= MAX_PLAYERS): + ap.error(f"--max-players must be between 1 and {MAX_PLAYERS}") + + loop = asyncio.get_running_loop() + relay = Relay(args.max_players) + await loop.create_datagram_endpoint( + lambda: relay, local_addr=(args.bind, args.port)) + + await asyncio.gather( + relay.liveness_loop(), + stdin_loop(relay), + ) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass