From 7f3950071f6dbe82e4c8b2a2d36d49d2be5f947f Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 10 May 2026 03:32:33 +0200 Subject: [PATCH 01/23] =?UTF-8?q?restore=20players[MAXPLAYERS]=20=E2=80=94?= =?UTF-8?q?=20call=20back=20the=20cooperative=20marines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/doom/am_map.c | 30 +++++------ main/doom/d_client.c | 4 +- main/doom/d_main.c | 10 ++-- main/doom/doomdef.h | 2 +- main/doom/g_game.c | 100 ++++++++++++++++++------------------ main/doom/global_data.h | 13 +++-- main/doom/hu_stuff.c | 2 +- main/doom/m_cheat.c | 60 +++++++++++----------- main/doom/m_menu.c | 8 +-- main/doom/p_enemy.c | 6 +-- main/doom/p_inter.c | 12 ++--- main/doom/p_mobj.c | 4 +- main/doom/p_setup.c | 8 +-- main/doom/p_switch.c | 4 +- main/doom/p_tick.c | 6 +-- main/doom/r_hotpath.iwram.c | 2 +- main/doom/s_sound.c | 4 +- main/doom/st_stuff.c | 2 +- main/doom/wi_stuff.c | 4 +- main/main.c | 14 +++++ sdkconfig.defaults | 16 ++++++ 21 files changed, 174 insertions(+), 137 deletions(-) create mode 100644 sdkconfig.defaults 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..3b0b825 100644 --- a/main/doom/d_client.c +++ b/main/doom/d_client.c @@ -67,7 +67,7 @@ void D_InitNetGame (void) { - _g->playeringame = true; + _g->playeringame[_g->consoleplayer] = true; } void D_BuildNewTiccmds(void) @@ -81,7 +81,7 @@ void D_BuildNewTiccmds(void) if (_g->maketic - _g->gametic > 3) break; - G_BuildTiccmd(&_g->netcmd); + G_BuildTiccmd(&_g->netcmds[_g->consoleplayer]); _g->maketic++; } } diff --git a/main/doom/d_main.c b/main/doom/d_main.c index 0513d0b..5f69d0f 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; diff --git a/main/doom/doomdef.h b/main/doom/doomdef.h index 416b268..56962c9 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 4 // 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..c2eb344 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,10 @@ static void G_DoLoadLevel (void) _g->gamestate = GS_LEVEL; - if (_g->playeringame && _g->player.playerstate == PST_DEAD) - _g->player.playerstate = PST_REBORN; + if (_g->playeringame[_g->consoleplayer] && _g->players[_g->consoleplayer].playerstate == PST_DEAD) + _g->players[_g->consoleplayer].playerstate = PST_REBORN; - memset (_g->player.frags,0,sizeof(_g->player.frags)); + memset (_g->players[_g->consoleplayer].frags,0,sizeof(_g->players[_g->consoleplayer].frags)); // initialize the msecnode_t freelist. phares 3/25/98 @@ -482,7 +482,7 @@ boolean G_Responder (event_t* ev) void G_Ticker (void) { P_MapStart(); - if(_g->playeringame && _g->player.playerstate == PST_REBORN) + if(_g->playeringame[_g->consoleplayer] && _g->players[_g->consoleplayer].playerstate == PST_REBORN) G_DoReborn (0); P_MapEnd(); @@ -492,7 +492,7 @@ boolean G_Responder (event_t* ev) switch (_g->gameaction) { case ga_loadlevel: - _g->player.playerstate = PST_REBORN; + _g->players[_g->consoleplayer].playerstate = PST_REBORN; G_DoLoadLevel (); break; case ga_newgame: @@ -524,11 +524,11 @@ 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) + if (_g->playeringame[_g->consoleplayer]) { - ticcmd_t *cmd = &_g->player.cmd; + ticcmd_t *cmd = &_g->players[_g->consoleplayer].cmd; - memcpy(cmd, &_g->netcmd, sizeof *cmd); + memcpy(cmd, &_g->netcmds[_g->consoleplayer], sizeof *cmd); if (_g->demoplayback) G_ReadDemoTiccmd (cmd); @@ -537,11 +537,11 @@ boolean G_Responder (event_t* ev) } - if (_g->playeringame) + if (_g->playeringame[_g->consoleplayer]) { - if (_g->player.cmd.buttons & BT_SPECIAL) + if (_g->players[_g->consoleplayer].cmd.buttons & BT_SPECIAL) { - switch (_g->player.cmd.buttons & BT_SPECIALMASK) + switch (_g->players[_g->consoleplayer].cmd.buttons & BT_SPECIALMASK) { case BTS_PAUSE: _g->paused ^= 1; @@ -552,14 +552,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[_g->consoleplayer].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[_g->consoleplayer].cmd.buttons & BTS_SAVEMASK)>>BTS_SAVESHIFT; _g->gameaction = ga_loadgame; _g->command_loadgame = false; break; @@ -571,7 +571,7 @@ boolean G_Responder (event_t* ev) _g->gameaction = ga_loadlevel; break; } - _g->player.cmd.buttons = 0; + _g->players[_g->consoleplayer].cmd.buttons = 0; } } } @@ -638,7 +638,7 @@ boolean G_Responder (event_t* ev) static void G_PlayerFinishLevel(int player) { - player_t *p = &_g->player; + player_t *p = &_g->players[_g->consoleplayer]; 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 +663,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[_g->consoleplayer].frags, sizeof frags); + killcount = _g->players[_g->consoleplayer].killcount; + itemcount = _g->players[_g->consoleplayer].itemcount; + secretcount = _g->players[_g->consoleplayer].secretcount; - p = &_g->player; + p = &_g->players[_g->consoleplayer]; // killough 3/10/98,3/21/98: preserve cheats across idclev { @@ -677,10 +677,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[_g->consoleplayer].frags, frags, sizeof(_g->players[_g->consoleplayer].frags)); + _g->players[_g->consoleplayer].killcount = killcount; + _g->players[_g->consoleplayer].itemcount = itemcount; + _g->players[_g->consoleplayer].secretcount = secretcount; p->usedown = p->attackdown = true; // don't do anything immediately p->playerstate = PST_LIVE; @@ -746,7 +746,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 +757,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 +827,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 +866,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 +996,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 +1040,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 +1169,7 @@ void G_InitNew(skill_t skill, int episode, int map) _g->respawnmonsters = skill == sk_nightmare; - _g->player.playerstate = PST_REBORN; + _g->players[_g->consoleplayer].playerstate = PST_REBORN; _g->usergame = true; // will be set false if a demo _g->paused = false; @@ -1478,7 +1478,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 +1486,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..d8ba474 100644 --- a/main/doom/global_data.h +++ b/main/doom/global_data.h @@ -97,10 +97,17 @@ 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; + // ****************************************************************************** // d_main.c // ****************************************************************************** @@ -183,7 +190,7 @@ skill_t gameskill; int gameepisode; int gamemap; -player_t player; +player_t players[MAXPLAYERS]; int starttime; // for comparative timing purposes @@ -221,7 +228,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/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/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..c753de0 100644 --- a/main/doom/p_mobj.c +++ b/main/doom/p_mobj.c @@ -650,10 +650,10 @@ void P_SpawnPlayer (int n, const mapthing_t* mthing) // not playing? - if (!_g->playeringame) + if (!_g->playeringame[_g->consoleplayer]) return; - p = &_g->player; + p = &_g->players[_g->consoleplayer]; if (p->playerstate == PST_REBORN) G_PlayerReborn (mthing->type-1); diff --git a/main/doom/p_setup.c b/main/doom/p_setup.c index 2bf1898..26c3a0d 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,14 +532,14 @@ 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[_g->consoleplayer].mo = NULL; P_MapStart(); P_LoadThings(lumpnum+ML_THINGS); { - if (_g->playeringame && !_g->player.mo) + if (_g->playeringame[_g->consoleplayer] && !_g->players[_g->consoleplayer].mo) I_Error("P_SetupLevel: missing player %d start\n", i+1); } 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..da4cb40 100644 --- a/main/doom/p_tick.c +++ b/main/doom/p_tick.c @@ -162,14 +162,14 @@ 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->playeringame[_g->consoleplayer]) + P_PlayerThink(&_g->players[_g->consoleplayer]); P_RunThinkers(); P_UpdateSpecials(); 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..bb9d652 100644 --- a/main/main.c +++ b/main/main.c @@ -3,11 +3,13 @@ #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" @@ -264,8 +266,20 @@ 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)); + /////////////////////////// // Main loop 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 From a890ed6777c6bc1c313741195e021fdb818b0f18 Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 10 May 2026 03:35:50 +0200 Subject: [PATCH 02/23] bring up WiFi station mode before D_DoomMain --- .gitignore | 6 ++ main/CMakeLists.txt | 2 +- main/main.c | 9 +++ main/wifi-creds.h.example | 10 +++ main/wifi.c | 127 ++++++++++++++++++++++++++++++++++++++ main/wifi.h | 21 +++++++ 6 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 main/wifi-creds.h.example create mode 100644 main/wifi.c create mode 100644 main/wifi.h diff --git a/.gitignore b/.gitignore index e9e503c..83d8107 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,9 @@ 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 diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 80f64b7..25d1b14 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,6 +1,6 @@ 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/f_finale.c" "doom/f_wipe.c" "doom/g_game.c" "doom/global_data.c" diff --git a/main/main.c b/main/main.c index bb9d652..7f41b1b 100644 --- a/main/main.c +++ b/main/main.c @@ -15,6 +15,7 @@ #include "device-info.h" #include "keypad.h" +#include "wifi.h" #include "doom/firefly-doom.h" // This disables the start menu @@ -280,6 +281,14 @@ void app_main() { 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/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 From 16e17c004a25166ec8b2c6d5e30b222b4e26ab19 Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 10 May 2026 03:45:15 +0200 Subject: [PATCH 03/23] UDP socket, d_net state machine, PC relay --- main/CMakeLists.txt | 1 + main/doom/d_client.c | 53 +++++++- main/doom/d_net.c | 276 ++++++++++++++++++++++++++++++++++++++ main/doom/d_net.h | 15 +++ main/doom/i_network.c | 137 +++++++++++++++++++ main/net-config.h | 14 ++ tools/relay.py | 301 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 792 insertions(+), 5 deletions(-) create mode 100644 main/doom/d_net.c create mode 100644 main/doom/i_network.c create mode 100644 main/net-config.h create mode 100755 tools/relay.py diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 25d1b14..070aefa 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -3,6 +3,7 @@ idf_component_register( "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/d_client.c b/main/doom/d_client.c index 3b0b825..f3a70ef 100644 --- a/main/doom/d_client.c +++ b/main/doom/d_client.c @@ -67,7 +67,15 @@ void D_InitNetGame (void) { - _g->playeringame[_g->consoleplayer] = true; + // Try to handshake with the relay. Falls back to single-player on + // timeout — D_DoomMain still runs, you just play alone. + bool ok = net_init(10000); + if (!ok) { + _g->playeringame[_g->consoleplayer] = true; + return; + } + // net_init has already populated _g->playeringame[] and the + // consoleplayer/displayplayer indices. } void D_BuildNewTiccmds(void) @@ -78,12 +86,27 @@ 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->netcmds[_g->consoleplayer]); + 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) @@ -94,10 +117,22 @@ void TryRunTics (void) // 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; + } else { + runtics = (_g->maketic) - _g->gametic; + } + if (runtics <= 0) { if (I_GetTime() - entertime > 10) @@ -112,10 +147,18 @@ 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++; diff --git a/main/doom/d_net.c b/main/doom/d_net.c new file mode 100644 index 0000000..f34da6d --- /dev/null +++ b/main/doom/d_net.c @@ -0,0 +1,276 @@ +// On-device netplay state machine: handshake with a PC relay, build and +// emit our own ticcmds each tic, ingest the relay's broadcast PKT_TICS, +// and gate the engine's gametic advance on having a full set of inputs +// for every active player. +// +// Server-side counterpart lives in tools/relay.py. + +#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 "lprintf.h" +#include "global_data.h" + +#include "d_net.h" + +// ---------------------------- 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 // we send at most 8 ahead +#define MAX_TICS_PER_PKT_TICS 8 // server resends last 8 + +// ---------------------------- module state ---------------------------------- + +static bool s_net_active = false; // true once handshake succeeded +static bool s_started = false; // true once PKT_GO received +static int s_numplayers = 1; +static unsigned s_start_tic = 0; +static int s_consoleplayer = 0; + +// localcmds[t & TIC_MASK] — what we built for tic t locally +static ticcmd_t s_localcmds[TIC_RING]; + +// netcmds_ring[p][t & TIC_MASK] — what we have for player p at tic t +static ticcmd_t s_netcmds_ring[MAXPLAYERS][TIC_RING]; +// highest tic for which s_netcmds_ring[p] is valid +static int s_have_tic[MAXPLAYERS]; + +// highest tic we've already pushed in a PKT_TICC packet +static int s_last_sent_tic = -1; + +// ---------------------------- helpers --------------------------------------- + +static byte checksum_packet(const packet_header_t *p, size_t len) { + const byte *b = (const byte *)p; + byte s = 0; + // PrBoom skips the first byte (the checksum field itself). + 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); +} + +// One-time send. Caller fills payload, we set type/checksum. +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); + if (paylen) { memcpy(buf + sizeof(*h), payload, paylen); } + packet_seal(h, sizeof(*h) + paylen); + I_SendPacket(h, sizeof(*h) + paylen); +} + +// Wait up to ms_total milliseconds for a packet of one of the wanted types. +// Returns the type received, or -1 on timeout. +static int wait_for(int wanted_type, packet_header_t *out, size_t outlen, + int ms_total) { + int waited = 0; + while (waited < ms_total) { + I_WaitForPacket(50); + size_t n = I_GetPacket(out, outlen); + if (n > 0 && packet_ok(out, n)) { + if (out->type == (byte)wanted_type) { + return out->type; + } + // unexpected type — log but keep waiting + lprintf(LO_INFO, "net: ignoring packet type=%d while waiting " + "for %d\n", out->type, wanted_type); + } + waited += 50; + } + return -1; +} + +// ---------------------------- 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 s_start_tic; } + +// Phase-4 handshake: send PKT_INIT, wait for PKT_SETUP, then wait for PKT_GO. +// Returns true on success. On failure, falls back to single-player. +bool net_init(int handshake_timeout_ms) { + I_InitNetwork(); + + for (int i = 0; i < MAXPLAYERS; i++) { s_have_tic[i] = -1; } + + // PKT_INIT carries one byte: protocol version. + byte ver = 1; + send_simple(PKT_INIT, 0, &ver, 1); + + uint8_t buf[256]; + packet_header_t *h = (packet_header_t *)buf; + + if (wait_for(PKT_SETUP, h, sizeof(buf), handshake_timeout_ms) < 0) { + lprintf(LO_INFO, "net: no PKT_SETUP within %d ms; " + "single-player mode\n", handshake_timeout_ms); + return false; + } + + struct setup_packet_s *setup = + (struct setup_packet_s *)(buf + sizeof(*h)); + s_numplayers = setup->players; + s_consoleplayer = setup->yourplayer; + if (s_numplayers < 1 || s_numplayers > MAXPLAYERS || + s_consoleplayer < 0 || s_consoleplayer >= s_numplayers) { + lprintf(LO_ERROR, "net: bad SETUP players=%d you=%d\n", + s_numplayers, s_consoleplayer); + return false; + } + lprintf(LO_INFO, "net: SETUP players=%d slot=%d\n", + s_numplayers, s_consoleplayer); + + // Mark every active slot as in-game. + for (int i = 0; i < MAXPLAYERS; i++) { + _g->playeringame[i] = (i < s_numplayers); + } + _g->consoleplayer = s_consoleplayer; + _g->displayplayer = s_consoleplayer; + + // Block until PKT_GO. No upper bound; relay sends GO when operator + // hits "go" in stdin. + if (wait_for(PKT_GO, h, sizeof(buf), 60000) < 0) { + lprintf(LO_INFO, "net: timed out waiting for PKT_GO\n"); + return false; + } + + // PKT_GO payload: uint32 starttic, uint32 rngseed (network byte order). + uint8_t *gpay = buf + sizeof(*h); + uint32_t starttic = + (gpay[0] << 24) | (gpay[1] << 16) | (gpay[2] << 8) | gpay[3]; + uint32_t rngseed = + (gpay[4] << 24) | (gpay[5] << 16) | (gpay[6] << 8) | gpay[7]; + s_start_tic = starttic; + (void)rngseed; // TODO: feed into M_Random when we hook it up + + s_net_active = true; + s_started = true; + lprintf(LO_INFO, "net: GO starttic=%u rngseed=%u\n", starttic, rngseed); + return true; +} + +// Pump any pending PKT_TICS into our ring buffers. Called at top of +// TryRunTics. Non-blocking. +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_TICS) { + // PKT_QUIT, PKT_BACKOFF — TODO; ignore for now + continue; + } + + // Payload: u8 numtics, u32 firsttic (network byte order), + // numtics * numplayers * sizeof(ticcmd_t) raw. + 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; } + } + } + } +} + +// True when we have ticcmds for every active player at tic `t`. +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; +} + +// Copy the ring-buffered ticcmd for player p at tic t into out. +void net_get_tic(int p, int t, ticcmd_t *out) { + *out = s_netcmds_ring[p][t & TIC_MASK]; +} + +// Stash our locally-built tic, then send any unsent tics to the relay. +void net_record_local_tic(int tic, const ticcmd_t *cmd) { + s_localcmds[tic & TIC_MASK] = *cmd; + // Also pre-fill our own slot so we can advance without waiting on the + // server's echo — this matches PrBoom's "loopback" optimization and + // avoids 1×RTT of latency on local input. + 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; + } + + // Payload: u8 numtics, u32 firsttic, numtics * 8B raw ticcmd + uint8_t pay[1 + 4 + MAX_TICS_PER_PKT_TICC * sizeof(ticcmd_t)]; + 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); + 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)); + s_last_sent_tic = up_to_tic; +} diff --git a/main/doom/d_net.h b/main/doom/d_net.h index fe98587..7f94eba 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,18 @@ 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); + #endif diff --git a/main/doom/i_network.c b/main/doom/i_network.c new file mode 100644 index 0000000..cf169de --- /dev/null +++ b/main/doom/i_network.c @@ -0,0 +1,137 @@ +// 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. + +#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 server endpoint (resolved once in I_InitNetwork). +static struct sockaddr_in s_server_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_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_server_addr, 0, sizeof(s_server_addr)); + s_server_addr.sin_family = AF_INET; + s_server_addr.sin_port = htons(DOOM_SERVER_PORT); + if (inet_pton(AF_INET, DOOM_SERVER_IP, &s_server_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] socket up, server=%s:%d, fd=%d\n", + DOOM_SERVER_IP, DOOM_SERVER_PORT, s_sock); +} + +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_server_addr, sizeof(s_server_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/net-config.h b/main/net-config.h new file mode 100644 index 0000000..bf55e01 --- /dev/null +++ b/main/net-config.h @@ -0,0 +1,14 @@ +#ifndef __NET_CONFIG_H__ +#define __NET_CONFIG_H__ + +// 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.10.59" +#define DOOM_SERVER_PORT 5029 + +// 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 diff --git a/tools/relay.py b/tools/relay.py new file mode 100755 index 0000000..81ab468 --- /dev/null +++ b/tools/relay.py @@ -0,0 +1,301 @@ +#!/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. The header is: + + 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) + +Run with no args to listen on 0.0.0.0:5029. Type "go" + Enter at the +console to broadcast PKT_GO once enough players have joined. Type +"quit" to terminate the game with PKT_QUIT. +""" + +import argparse +import asyncio +import os +import struct +import sys +from collections import defaultdict +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 = 4 +TICDUP_REDUNDANCY = 8 # tics resent on every PKT_TICS + +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: + # Sum of all bytes after the first (the checksum byte itself). + s = 0 + for b in buf[1:]: + s = (s + b) & 0xff + return s + + +def make_packet(pkt_type: int, tic: int, payload: bytes) -> bytes: + body = bytes([0, pkt_type, 0, 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]) + want = checksum(buf) + if cs != want: + 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 + self.players: Dict[Tuple[str, int], int] = {} # addr -> slot + self.slot_addr: Dict[int, Tuple[str, int]] = {} # slot -> addr + self.tic_buf: Dict[Tuple[int, int], bytes] = {} # (slot, tic) -> raw + self.released = -1 # highest tic released as PKT_TICS + self.started = False + self.last_ticset_payload: Optional[bytes] = None + + 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) + + # ------------- packet handlers ------------- + + def handle_init(self, addr, payload): + if self.started: + print(f"[relay] {addr} INIT after game started — ignoring", + flush=True) + return + if addr in self.players: + slot = self.players[addr] + print(f"[relay] {addr} re-INIT (slot {slot}); resending SETUP", + flush=True) + else: + if len(self.players) >= self.max_players: + print(f"[relay] {addr} INIT: lobby full", flush=True) + return + slot = len(self.players) + self.players[addr] = slot + self.slot_addr[slot] = addr + print(f"[relay] {addr} joined as slot {slot} " + f"({len(self.players)}/{self.max_players})", flush=True) + + self.send_setup(addr, slot) + + # Auto-GO once the lobby is full. Lets us run headless without an + # interactive stdin (e.g. when launched in background). + if (not self.started and len(self.players) >= self.max_players): + self.broadcast_go() + + def send_setup(self, addr, slot): + # 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=Hurt me plenty, + # 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) + pkt = make_packet(PKT_SETUP, 0, payload) + self.transport.sendto(pkt, addr) + + def broadcast_go(self): + if not self.players: + print("[relay] no players to GO", flush=True) + return + if self.started: + print("[relay] already started", flush=True) + return + rngseed = int.from_bytes(os.urandom(4), "big") + payload = struct.pack("!II", 0, rngseed) + pkt = make_packet(PKT_GO, 0, payload) + for addr in list(self.players.keys()): + self.transport.sendto(pkt, addr) + self.started = True + print(f"[relay] GO! rngseed={rngseed:#x} players={len(self.players)}", + flush=True) + + def handle_ticc(self, addr, payload): + if not self.started or addr not in self.players: + return + slot = self.players[addr] + 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 + + # Try to advance the released frontier. + 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() + + def broadcast_ticset(self): + if self.released < 0: + return + first = max(0, self.released - TICDUP_REDUNDANCY + 1) + numtics = self.released - first + 1 + nplayers = len(self.players) + 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) + for addr in list(self.players.keys()): + self.transport.sendto(pkt, addr) + self.last_ticset_payload = pkt + + def handle_retrans(self, addr, payload): + # Just rebroadcast our most recent PKT_TICS to that client. + if self.last_ticset_payload is not None: + self.transport.sendto(self.last_ticset_payload, addr) + + def handle_quit(self, addr): + if addr in self.players: + slot = self.players[addr] + print(f"[relay] {addr} (slot {slot}) quit", flush=True) + del self.slot_addr[slot] + del self.players[addr] + + def broadcast_quit(self): + pkt = make_packet(PKT_QUIT, 0, b"") + for addr in list(self.players.keys()): + self.transport.sendto(pkt, addr) + print("[relay] QUIT broadcast", flush=True) + + +async def stdin_loop(relay: Relay): + """Best-effort stdin command loop. + + On a TTY this gives interactive control; without a TTY (e.g. launched + from a non-interactive parent) asyncio refuses to attach to stdin and + we just sleep forever — the relay still works via auto-GO on lobby + full. + """ + if not sys.stdin.isatty(): + print("[relay] stdin is not a tty; commands disabled. " + "Auto-GO when lobby fills.", 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: go | quit | players | help", flush=True) + while True: + line = await reader.readline() + if not line: + break + cmd = line.decode().strip().lower() + if cmd == "go": + relay.broadcast_go() + elif cmd == "quit": + relay.broadcast_quit() + break + elif cmd == "players": + for addr, slot in relay.players.items(): + print(f" slot {slot}: {addr[0]}:{addr[1]}", flush=True) + elif cmd in ("help", ""): + print(" go — start the game (broadcast PKT_GO)\n" + " players — list connected slots\n" + " quit — stop and broadcast PKT_QUIT", 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("--players", type=int, default=4, + help=f"max players (1..{MAX_PLAYERS})") + args = ap.parse_args() + + if not (1 <= args.players <= MAX_PLAYERS): + ap.error(f"--players must be between 1 and {MAX_PLAYERS}") + + loop = asyncio.get_running_loop() + relay = Relay(args.players) + await loop.create_datagram_endpoint( + lambda: relay, local_addr=(args.bind, args.port)) + + await stdin_loop(relay) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass From 6f998d5c350a133f3537cc218cb8b64dd76e557f Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 10 May 2026 03:58:50 +0200 Subject: [PATCH 04/23] =?UTF-8?q?dropout=20+=20retransmit=20+=20relay=20se?= =?UTF-8?q?lf-reset=20=E2=80=94=20survive=20the=20imp=20ambush?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/doom/d_client.c | 31 ++++++++++++++++++++++++++----- main/doom/d_net.c | 41 ++++++++++++++++++++++++++++++++++++----- main/doom/d_net.h | 1 + tools/relay.py | 30 ++++++++++++++++++++++++------ 4 files changed, 87 insertions(+), 16 deletions(-) diff --git a/main/doom/d_client.c b/main/doom/d_client.c index f3a70ef..6d86fc4 100644 --- a/main/doom/d_client.c +++ b/main/doom/d_client.c @@ -67,15 +67,21 @@ void D_InitNetGame (void) { - // Try to handshake with the relay. Falls back to single-player on - // timeout — D_DoomMain still runs, you just play alone. - bool ok = net_init(10000); + // Try to handshake with the relay. Falls back to single-player on any + // failure — D_DoomMain still runs, you just play alone. + // + // Total worst-case wait when there's no relay reachable: + // 5 s wait for PKT_SETUP + 0 s (skipped since no SETUP) = 5 s + // When the relay is up but lobby isn't full: + // ~0 s SETUP + 30 s wait for PKT_GO = ~30 s + bool ok = net_init(5000); if (!ok) { + printf("[net] solo mode\n"); _g->playeringame[_g->consoleplayer] = true; return; } - // net_init has already populated _g->playeringame[] and the - // consoleplayer/displayplayer indices. + printf("[net] multiplayer mode: %d players, slot %d\n", + net_numplayers(), net_consoleplayer()); } void D_BuildNewTiccmds(void) @@ -113,6 +119,7 @@ void TryRunTics (void) { int runtics; int entertime = I_GetTime(); + static int last_retrans_tic = -1; // Wait for tics to run while (1) @@ -129,6 +136,14 @@ void TryRunTics (void) 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; } @@ -162,5 +177,11 @@ void TryRunTics (void) M_Ticker (); G_Ticker (); _g->gametic++; + + // One log/second to confirm lockstep is advancing in netplay. + if (net_is_active() && (_g->gametic % 35) == 0) { + printf("[net] gametic=%d maketic=%d\n", + _g->gametic, _g->maketic); + } } } diff --git a/main/doom/d_net.c b/main/doom/d_net.c index f34da6d..0dc1591 100644 --- a/main/doom/d_net.c +++ b/main/doom/d_net.c @@ -131,6 +131,8 @@ bool net_init(int handshake_timeout_ms) { uint8_t buf[256]; packet_header_t *h = (packet_header_t *)buf; + lprintf(LO_INFO, "net: PKT_INIT sent to relay; waiting up to %d ms " + "for PKT_SETUP...\n", handshake_timeout_ms); if (wait_for(PKT_SETUP, h, sizeof(buf), handshake_timeout_ms) < 0) { lprintf(LO_INFO, "net: no PKT_SETUP within %d ms; " "single-player mode\n", handshake_timeout_ms); @@ -157,10 +159,14 @@ bool net_init(int handshake_timeout_ms) { _g->consoleplayer = s_consoleplayer; _g->displayplayer = s_consoleplayer; - // Block until PKT_GO. No upper bound; relay sends GO when operator - // hits "go" in stdin. - if (wait_for(PKT_GO, h, sizeof(buf), 60000) < 0) { - lprintf(LO_INFO, "net: timed out waiting for PKT_GO\n"); + // Wait for PKT_GO. The relay auto-broadcasts GO once its lobby fills, + // so this is bounded by `--players` * (other players' boot time). 30 s + // is plenty for a couple of devices coming up together; longer than + // that means something's wrong and we should fall back to solo. + lprintf(LO_INFO, "net: waiting for PKT_GO (30 s)...\n"); + if (wait_for(PKT_GO, h, sizeof(buf), 30000) < 0) { + lprintf(LO_INFO, "net: timed out waiting for PKT_GO; " + "falling back to single-player\n"); return false; } @@ -192,8 +198,21 @@ void net_pump(void) { if (n == 0) break; if (!packet_ok(h, n)) continue; + if (h->type == PKT_QUIT) { + // Relay tore down the lobby (e.g. a peer re-INITed). Drop + // back to solo so the game keeps running locally. + lprintf(LO_INFO, "net: PKT_QUIT received; dropping to solo\n"); + s_net_active = false; + s_started = false; + // Make sure we still have at least one playeringame slot. + for (int i = 0; i < MAXPLAYERS; i++) { + _g->playeringame[i] = (i == _g->consoleplayer); + } + return; + } + if (h->type != PKT_TICS) { - // PKT_QUIT, PKT_BACKOFF — TODO; ignore for now + // PKT_BACKOFF, PKT_DOWN, etc. — ignored for now. continue; } @@ -274,3 +293,15 @@ void net_flush_unsent(int up_to_tic) { 5 + (size_t)count * sizeof(ticcmd_t)); s_last_sent_tic = up_to_tic; } + +// Ask the relay to resend the PKT_TICS containing tic `wanttic`. Used by +// TryRunTics when we've been stalled too long waiting for a slot's input. +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)); +} diff --git a/main/doom/d_net.h b/main/doom/d_net.h index 7f94eba..ce847a2 100644 --- a/main/doom/d_net.h +++ b/main/doom/d_net.h @@ -64,5 +64,6 @@ 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); #endif diff --git a/tools/relay.py b/tools/relay.py index 81ab468..d1c61d4 100755 --- a/tools/relay.py +++ b/tools/relay.py @@ -106,11 +106,30 @@ def datagram_received(self, data: bytes, addr): # ------------- packet handlers ------------- + def reset_lobby(self, reason: str): + # Drop any in-flight game state. The next handle_init will rebuild + # the lobby from scratch. We send PKT_QUIT to existing players so + # they fall back to solo / re-handshake. + if self.players: + print(f"[relay] resetting lobby ({reason}); kicking " + f"{len(self.players)} player(s)", flush=True) + self.broadcast_quit() + self.players.clear() + self.slot_addr.clear() + self.tic_buf.clear() + self.released = -1 + self.started = False + self.last_ticset_payload = None + def handle_init(self, addr, payload): - if self.started: - print(f"[relay] {addr} INIT after game started — ignoring", - flush=True) - return + # If the game is already running and a *new* address INITs, it + # almost certainly means a Pixie was reset and is trying to + # re-join. The cleanest semantic is to reset the lobby — gameplay + # is unrecoverable mid-stream when a peer's tic stream restarts at + # zero, and tearing down + rebuilding is what the operator wants. + if self.started and addr not in self.players: + self.reset_lobby(f"new INIT from {addr}") + if addr in self.players: slot = self.players[addr] print(f"[relay] {addr} re-INIT (slot {slot}); resending SETUP", @@ -127,8 +146,7 @@ def handle_init(self, addr, payload): self.send_setup(addr, slot) - # Auto-GO once the lobby is full. Lets us run headless without an - # interactive stdin (e.g. when launched in background). + # Auto-GO once the lobby is full. if (not self.started and len(self.players) >= self.max_players): self.broadcast_go() From b5a3756cd54e14c5b2c9db7fad9ff0fc965d0953 Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 10 May 2026 04:48:35 +0200 Subject: [PATCH 05/23] skip D_StartTitle in netplay so E1M1 actually loads --- main/doom/d_client.c | 13 +++++++++---- main/doom/d_main.c | 7 +++++++ main/net-config.h | 2 +- tools/relay.py | 35 +++++++++++++++++++++++++++++------ 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/main/doom/d_client.c b/main/doom/d_client.c index 6d86fc4..d01fddb 100644 --- a/main/doom/d_client.c +++ b/main/doom/d_client.c @@ -71,10 +71,11 @@ void D_InitNetGame (void) // failure — D_DoomMain still runs, you just play alone. // // Total worst-case wait when there's no relay reachable: - // 5 s wait for PKT_SETUP + 0 s (skipped since no SETUP) = 5 s - // When the relay is up but lobby isn't full: - // ~0 s SETUP + 30 s wait for PKT_GO = ~30 s - bool ok = net_init(5000); + // 25 s wait for PKT_SETUP + 0 s (skipped) = 25 s + // When the relay is up: SETUP is only sent once the lobby is full, + // so this 25 s budget covers the time for the second device to also + // boot+associate+send PKT_INIT. + bool ok = net_init(25000); if (!ok) { printf("[net] solo mode\n"); _g->playeringame[_g->consoleplayer] = true; @@ -82,6 +83,10 @@ void D_InitNetGame (void) } printf("[net] multiplayer mode: %d players, slot %d\n", net_numplayers(), net_consoleplayer()); + + // The actual G_DeferedInitNew is now done at the end of + // D_DoomMainSetup (in d_main.c) so it survives D_StartTitle's + // gameaction reset. } void D_BuildNewTiccmds(void) diff --git a/main/doom/d_main.c b/main/doom/d_main.c index 5f69d0f..b763302 100644 --- a/main/doom/d_main.c +++ b/main/doom/d_main.c @@ -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/net-config.h b/main/net-config.h index bf55e01..d44fffc 100644 --- a/main/net-config.h +++ b/main/net-config.h @@ -4,7 +4,7 @@ // 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.10.59" +#define DOOM_SERVER_IP "192.168.77.197" #define DOOM_SERVER_PORT 5029 // Local UDP port the client binds. 0 = ephemeral (recommended; the relay diff --git a/tools/relay.py b/tools/relay.py index d1c61d4..26a5abe 100755 --- a/tools/relay.py +++ b/tools/relay.py @@ -78,6 +78,10 @@ def __init__(self, max_players: int): self.started = False self.last_ticset_payload: Optional[bytes] = None + # Stats / heartbeat + self.ticc_count = 0 + self._last_stats_report = 0.0 + def connection_made(self, transport): self.transport = transport sock = transport.get_extra_info("socket") @@ -132,8 +136,7 @@ def handle_init(self, addr, payload): if addr in self.players: slot = self.players[addr] - print(f"[relay] {addr} re-INIT (slot {slot}); resending SETUP", - flush=True) + print(f"[relay] {addr} re-INIT (slot {slot})", flush=True) else: if len(self.players) >= self.max_players: print(f"[relay] {addr} INIT: lobby full", flush=True) @@ -144,10 +147,16 @@ def handle_init(self, addr, payload): print(f"[relay] {addr} joined as slot {slot} " f"({len(self.players)}/{self.max_players})", flush=True) - self.send_setup(addr, slot) - - # Auto-GO once the lobby is full. - if (not self.started and len(self.players) >= self.max_players): + # Once the lobby is full, broadcast SETUP to *all* players with + # the final player count, then GO. SETUP carries the final count + # so each client sets up playeringame[] for every active slot. + # Until the lobby fills, joiners just wait — Pixie's net_init + # blocks on wait_for(PKT_SETUP) for up to 5 s, so the operator + # has that window to bring the second device up. + if (not self.started and + len(self.players) >= self.max_players): + for a, s in self.players.items(): + self.send_setup(a, s) self.broadcast_go() def send_setup(self, addr, slot): @@ -201,6 +210,8 @@ def handle_ticc(self, addr, payload): 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. active_slots = list(self.players.values()) moved = False @@ -214,6 +225,18 @@ def handle_ticc(self, addr, payload): if moved: self.broadcast_ticset() + # Periodic heartbeat: tics/s + per-slot lag. + import time as _time + now = _time.monotonic() + if now - self._last_stats_report >= 2.0: + self._last_stats_report = now + highest = {s: max((t for (sl, t) in self.tic_buf if sl == s), + default=-1) + for s in active_slots} + print(f"[relay] released={self.released} " + f"highest={highest} ticc_pkts={self.ticc_count}", + flush=True) + def broadcast_ticset(self): if self.released < 0: return From afda728b518534f845e9f8531f39e92effbcd438 Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 10 May 2026 05:19:30 +0200 Subject: [PATCH 06/23] P_SpawnPlayer/G_PlayerReborn: use the player param, not consoleplayer --- main/doom/g_game.c | 70 +++++++++++++++++++++++++------------------- main/doom/i_system.c | 13 +++++--- main/doom/p_mobj.c | 24 ++++++++------- main/doom/p_setup.c | 19 ++++++++++-- main/doom/p_tick.c | 14 +++++++-- 5 files changed, 90 insertions(+), 50 deletions(-) diff --git a/main/doom/g_game.c b/main/doom/g_game.c index c2eb344..78430da 100644 --- a/main/doom/g_game.c +++ b/main/doom/g_game.c @@ -386,10 +386,12 @@ static void G_DoLoadLevel (void) _g->gamestate = GS_LEVEL; - if (_g->playeringame[_g->consoleplayer] && _g->players[_g->consoleplayer].playerstate == PST_DEAD) - _g->players[_g->consoleplayer].playerstate = PST_REBORN; - - memset (_g->players[_g->consoleplayer].frags,0,sizeof(_g->players[_g->consoleplayer].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 @@ -524,24 +526,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[_g->consoleplayer]) + // Copy each active player's networked input into their player_t. + for (int p = 0; p < MAXPLAYERS; p++) { - ticcmd_t *cmd = &_g->players[_g->consoleplayer].cmd; - - memcpy(cmd, &_g->netcmds[_g->consoleplayer], 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[_g->consoleplayer]) + // 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->players[_g->consoleplayer].cmd.buttons & BT_SPECIAL) + if (!_g->playeringame[p]) continue; + if (_g->players[p].cmd.buttons & BT_SPECIAL) { - switch (_g->players[_g->consoleplayer].cmd.buttons & BT_SPECIALMASK) + switch (_g->players[p].cmd.buttons & BT_SPECIALMASK) { case BTS_PAUSE: _g->paused ^= 1; @@ -552,14 +558,14 @@ boolean G_Responder (event_t* ev) break; case BTS_SAVEGAME: - _g->savegameslot = (_g->players[_g->consoleplayer].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->players[_g->consoleplayer].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 +577,7 @@ boolean G_Responder (event_t* ev) _g->gameaction = ga_loadlevel; break; } - _g->players[_g->consoleplayer].cmd.buttons = 0; + _g->players[p].cmd.buttons = 0; } } } @@ -638,7 +644,7 @@ boolean G_Responder (event_t* ev) static void G_PlayerFinishLevel(int player) { - player_t *p = &_g->players[_g->consoleplayer]; + 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 +669,12 @@ void G_PlayerReborn (int player) int itemcount; int secretcount; - memcpy (frags, _g->players[_g->consoleplayer].frags, sizeof frags); - killcount = _g->players[_g->consoleplayer].killcount; - itemcount = _g->players[_g->consoleplayer].itemcount; - secretcount = _g->players[_g->consoleplayer].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->players[_g->consoleplayer]; + p = &_g->players[player]; // killough 3/10/98,3/21/98: preserve cheats across idclev { @@ -677,10 +683,10 @@ void G_PlayerReborn (int player) p->cheats = cheats; } - memcpy(_g->players[_g->consoleplayer].frags, frags, sizeof(_g->players[_g->consoleplayer].frags)); - _g->players[_g->consoleplayer].killcount = killcount; - _g->players[_g->consoleplayer].itemcount = itemcount; - _g->players[_g->consoleplayer].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; @@ -1169,7 +1175,11 @@ void G_InitNew(skill_t skill, int episode, int map) _g->respawnmonsters = skill == sk_nightmare; - _g->players[_g->consoleplayer].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; 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/p_mobj.c b/main/doom/p_mobj.c index c753de0..c53a3c2 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[_g->consoleplayer]) + if (!_g->playeringame[n]) return; - p = &_g->players[_g->consoleplayer]; + 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. @@ -692,7 +692,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 +773,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 26c3a0d..fd37170 100644 --- a/main/doom/p_setup.c +++ b/main/doom/p_setup.c @@ -532,15 +532,28 @@ 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->players[_g->consoleplayer].mo = NULL; + _g->players[i].mo = NULL; P_MapStart(); P_LoadThings(lumpnum+ML_THINGS); { - if (_g->playeringame[_g->consoleplayer] && !_g->players[_g->consoleplayer].mo) - I_Error("P_SetupLevel: missing player %d start\n", i+1); + // The bundled doom1gba.wad only has a player-1 start spot, so any + // remaining active players need a fallback. Spawn them at slot 0's + // start (they'll telefrag onto the console player on entry, but + // both will exist in the world). + if (_g->playeringame[0] && !_g->players[0].mo) + I_Error("P_SetupLevel: missing player 1 start\n"); + + for (i = 1; i < MAXPLAYERS; i++) + { + if (_g->playeringame[i] && !_g->players[i].mo) + { + _g->playerstarts[i] = _g->playerstarts[0]; + P_SpawnPlayer(i, &_g->playerstarts[i]); + } + } } // killough 3/26/98: Spawn icon landings: diff --git a/main/doom/p_tick.c b/main/doom/p_tick.c index da4cb40..d3371a1 100644 --- a/main/doom/p_tick.c +++ b/main/doom/p_tick.c @@ -167,9 +167,17 @@ void P_Ticker (void) P_MapStart(); // not if this is an intermission screen - if(_g->gamestate==GS_LEVEL) - if (_g->playeringame[_g->consoleplayer]) - P_PlayerThink(&_g->players[_g->consoleplayer]); + 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(); From 04ac9b5fe5107446d989f860ad87691fab86f12e Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 10 May 2026 15:34:04 +0200 Subject: [PATCH 07/23] add tic-stage breadcrumbs to chase the netplay phantom --- main/doom/d_client.c | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/main/doom/d_client.c b/main/doom/d_client.c index d01fddb..0fd62be 100644 --- a/main/doom/d_client.c +++ b/main/doom/d_client.c @@ -179,8 +179,17 @@ void TryRunTics (void) } } + // Tic-stage breadcrumbs — printed via esp_rom_printf so they + // survive even if Doom main task hangs in a tight loop. + extern void esp_rom_printf(const char*, ...); + if (net_is_active()) + esp_rom_printf("[T %d M\n", _g->gametic); M_Ticker (); + if (net_is_active()) + esp_rom_printf("[T %d G\n", _g->gametic); G_Ticker (); + if (net_is_active()) + esp_rom_printf("[T %d +\n", _g->gametic); _g->gametic++; // One log/second to confirm lockstep is advancing in netplay. From d5bdfb9d10e3b53b1fb7cf53f6f28aa9c9d58c9d Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 10 May 2026 18:06:19 +0200 Subject: [PATCH 08/23] loop reborn/load-level over all marines, drop dual-mobj fallback --- main/doom/d_client.c | 19 +++++++------------ main/doom/g_game.c | 13 ++++++++++--- main/doom/p_setup.c | 17 +++++++++++------ main/net-config.h | 2 +- 4 files changed, 29 insertions(+), 22 deletions(-) diff --git a/main/doom/d_client.c b/main/doom/d_client.c index 0fd62be..6875aac 100644 --- a/main/doom/d_client.c +++ b/main/doom/d_client.c @@ -179,23 +179,18 @@ void TryRunTics (void) } } - // Tic-stage breadcrumbs — printed via esp_rom_printf so they - // survive even if Doom main task hangs in a tight loop. - extern void esp_rom_printf(const char*, ...); - if (net_is_active()) - esp_rom_printf("[T %d M\n", _g->gametic); M_Ticker (); - if (net_is_active()) - esp_rom_printf("[T %d G\n", _g->gametic); G_Ticker (); - if (net_is_active()) - esp_rom_printf("[T %d +\n", _g->gametic); _g->gametic++; - // One log/second to confirm lockstep is advancing in netplay. + // 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) { - printf("[net] gametic=%d maketic=%d\n", - _g->gametic, _g->maketic); + 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/g_game.c b/main/doom/g_game.c index 78430da..a4146c8 100644 --- a/main/doom/g_game.c +++ b/main/doom/g_game.c @@ -484,8 +484,11 @@ boolean G_Responder (event_t* ev) void G_Ticker (void) { P_MapStart(); - if(_g->playeringame[_g->consoleplayer] && _g->players[_g->consoleplayer].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 @@ -494,7 +497,11 @@ boolean G_Responder (event_t* ev) switch (_g->gameaction) { case ga_loadlevel: - _g->players[_g->consoleplayer].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: diff --git a/main/doom/p_setup.c b/main/doom/p_setup.c index fd37170..e714699 100644 --- a/main/doom/p_setup.c +++ b/main/doom/p_setup.c @@ -539,19 +539,24 @@ void P_SetupLevel(int episode, int map, int playermask, skill_t skill) P_LoadThings(lumpnum+ML_THINGS); { - // The bundled doom1gba.wad only has a player-1 start spot, so any - // remaining active players need a fallback. Spawn them at slot 0's - // start (they'll telefrag onto the console player on entry, but - // both will exist in the world). if (_g->playeringame[0] && !_g->players[0].mo) I_Error("P_SetupLevel: missing player 1 start\n"); + // For now, don't spawn fallback bodies for slots whose start is + // missing — having two players collocated at slot 0 was the + // suspected source of intermittent freezes (both mobjs in the + // same blockmap cell + telefrag/iteration weirdness). Each device + // still simulates the deterministic shared world; the absent + // remote body just means players can't see each other on screen + // until the WAD has all four player starts. The lockstep input + // sync still keeps the simulations in step. for (i = 1; i < MAXPLAYERS; i++) { if (_g->playeringame[i] && !_g->players[i].mo) { - _g->playerstarts[i] = _g->playerstarts[0]; - P_SpawnPlayer(i, &_g->playerstarts[i]); + lprintf(LO_INFO, + "P_SetupLevel: no start for player %d (this is OK; " + "slot will have no body)\n", i + 1); } } } diff --git a/main/net-config.h b/main/net-config.h index d44fffc..f0b62d3 100644 --- a/main/net-config.h +++ b/main/net-config.h @@ -4,7 +4,7 @@ // 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.197" +#define DOOM_SERVER_IP "172.20.10.5" #define DOOM_SERVER_PORT 5029 // Local UDP port the client binds. 0 = ephemeral (recommended; the relay From 017fbe7b219b17192b141c2a7b959498c3221018 Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 17 May 2026 00:42:06 +0200 Subject: [PATCH 09/23] =?UTF-8?q?dynamic=20roster=20=E2=80=94=20pixies=20j?= =?UTF-8?q?oin=20and=20leave=20the=20deathmatch=20whenever?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/doom/d_client.c | 23 ++-- main/doom/d_net.c | 266 ++++++++++++++++++++++----------------- main/doom/global_data.h | 5 + main/doom/m_random.c | 6 +- main/net-config.h | 2 +- tools/relay.py | 268 ++++++++++++++++++++++------------------ 6 files changed, 323 insertions(+), 247 deletions(-) diff --git a/main/doom/d_client.c b/main/doom/d_client.c index 6875aac..50dfd1f 100644 --- a/main/doom/d_client.c +++ b/main/doom/d_client.c @@ -67,26 +67,23 @@ void D_InitNetGame (void) { - // Try to handshake with the relay. Falls back to single-player on any - // failure — D_DoomMain still runs, you just play alone. - // - // Total worst-case wait when there's no relay reachable: - // 25 s wait for PKT_SETUP + 0 s (skipped) = 25 s - // When the relay is up: SETUP is only sent once the lobby is full, - // so this 25 s budget covers the time for the second device to also - // boot+associate+send PKT_INIT. - bool ok = net_init(25000); + // 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) { - printf("[net] solo mode\n"); + // 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()); - // The actual G_DeferedInitNew is now done at the end of - // D_DoomMainSetup (in d_main.c) so it survives D_StartTitle's - // gameaction reset. + // 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) diff --git a/main/doom/d_net.c b/main/doom/d_net.c index 0dc1591..72b05fb 100644 --- a/main/doom/d_net.c +++ b/main/doom/d_net.c @@ -3,6 +3,11 @@ // and gate the engine's gametic advance on having a full set of inputs // for every active player. // +// v2: dynamic roster. The relay broadcasts a fresh SETUP+GO whenever the +// active player set changes; every Pixie restarts at gametic=0 with the +// new roster + a new RNG seed. SETUP after the first one is a "roster +// update"; GO after the first one is "restart now". +// // Server-side counterpart lives in tools/relay.py. #include @@ -15,6 +20,7 @@ #include "i_network.h" #include "g_game.h" #include "i_system.h" +#include "i_video.h" #include "lprintf.h" #include "global_data.h" @@ -26,15 +32,13 @@ #define TIC_RING (1u << TIC_RING_BITS) #define TIC_MASK (TIC_RING - 1u) -#define MAX_TICS_PER_PKT_TICC 8 // we send at most 8 ahead -#define MAX_TICS_PER_PKT_TICS 8 // server resends last 8 +#define MAX_TICS_PER_PKT_TICC 8 // ---------------------------- module state ---------------------------------- static bool s_net_active = false; // true once handshake succeeded -static bool s_started = false; // true once PKT_GO received +static bool s_started = false; // true once first PKT_GO received static int s_numplayers = 1; -static unsigned s_start_tic = 0; static int s_consoleplayer = 0; // localcmds[t & TIC_MASK] — what we built for tic t locally @@ -42,18 +46,14 @@ static ticcmd_t s_localcmds[TIC_RING]; // netcmds_ring[p][t & TIC_MASK] — what we have for player p at tic t static ticcmd_t s_netcmds_ring[MAXPLAYERS][TIC_RING]; -// highest tic for which s_netcmds_ring[p] is valid -static int s_have_tic[MAXPLAYERS]; - -// highest tic we've already pushed in a PKT_TICC packet -static int s_last_sent_tic = -1; +static int s_have_tic[MAXPLAYERS]; // highest tic per slot +static int s_last_sent_tic = -1; // highest tic we've put in a PKT_TICC -// ---------------------------- helpers --------------------------------------- +// ---------------------------- packet helpers -------------------------------- static byte checksum_packet(const packet_header_t *p, size_t len) { const byte *b = (const byte *)p; byte s = 0; - // PrBoom skips the first byte (the checksum field itself). for (size_t i = 1; i < len; i++) { s += b[i]; } return s; } @@ -72,7 +72,6 @@ static void packet_seal(packet_header_t *p, size_t len) { p->checksum = checksum_packet(p, len); } -// One-time send. Caller fills payload, we set type/checksum. static void send_simple(enum packet_type_e type, unsigned tic, const void *payload, size_t paylen) { uint8_t buf[256]; @@ -88,105 +87,151 @@ static void send_simple(enum packet_type_e type, unsigned tic, I_SendPacket(h, sizeof(*h) + paylen); } -// Wait up to ms_total milliseconds for a packet of one of the wanted types. -// Returns the type received, or -1 on timeout. -static int wait_for(int wanted_type, packet_header_t *out, size_t outlen, - int ms_total) { +static void send_pkt_init(void) { + byte ver = 1; + send_simple(PKT_INIT, 0, &ver, 1); +} + +// ---------------------------- SETUP / GO handlers --------------------------- + +// Apply a PKT_SETUP payload to local state. Used both during the initial +// handshake and for mid-game roster updates. +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); + } + lprintf(LO_INFO, "net: SETUP players=%d slot=%d\n", np, you); + return true; +} + +// Apply a PKT_GO payload. Saves rngseed and (when called mid-game) requests +// a level restart so every Pixie converges at gametic=0. +static void apply_go(const uint8_t *payload, bool first_time) { + 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; // always 0 in v2 + + _g->net_rngseed = rngseed; + lprintf(LO_INFO, "net: GO rngseed=%u (first=%d)\n", + rngseed, (int)first_time); + + // Reset per-slot tic state. The relay's tic numbering restarts at 0 + // every GO; we follow. + 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)); + + // Reset the engine's per-tic counters to 0 so our maketic / gametic + // align with the relay's tic 0. + _g->gametic = 0; + _g->maketic = 0; + _g->lastmadetic = I_GetTime(); + _g->basetic = 0; + + s_started = true; + s_net_active = true; + + // Queue a level reload. On the next G_Ticker, ga_newgame triggers + // G_DoNewGame → G_InitNew → P_SetupLevel, which respawns every active + // player from playeringame[] and reseeds M_Random via net_rngseed. + G_DeferedInitNew(sk_medium, 1, 1); +} + +// ---------------------------- handshake ------------------------------------- + +// Wait up to ms_total ms for any packet. Returns 0 = nothing, 1 = packet +// dispatched (caller can re-check state), -1 = error. +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); - size_t n = I_GetPacket(out, outlen); - if (n > 0 && packet_ok(out, n)) { - if (out->type == (byte)wanted_type) { - return out->type; - } - // unexpected type — log but keep waiting - lprintf(LO_INFO, "net: ignoring packet type=%d while waiting " - "for %d\n", out->type, wanted_type); - } + *out_n = I_GetPacket(out, outlen); + if (*out_n > 0 && packet_ok(out, *out_n)) return 1; waited += 50; } - return -1; + return 0; } -// ---------------------------- 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 s_start_tic; } - -// Phase-4 handshake: send PKT_INIT, wait for PKT_SETUP, then wait for PKT_GO. -// Returns true on success. On failure, falls back to single-player. -bool net_init(int handshake_timeout_ms) { +// Bootstrap: send PKT_INIT every retry_ms until SETUP arrives. Then wait for +// GO. No bounded timeout — Pixie keeps trying until the relay answers. +bool net_init(int retry_ms) { I_InitNetwork(); for (int i = 0; i < MAXPLAYERS; i++) { s_have_tic[i] = -1; } - - // PKT_INIT carries one byte: protocol version. - byte ver = 1; - send_simple(PKT_INIT, 0, &ver, 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: PKT_INIT sent to relay; waiting up to %d ms " - "for PKT_SETUP...\n", handshake_timeout_ms); - if (wait_for(PKT_SETUP, h, sizeof(buf), handshake_timeout_ms) < 0) { - lprintf(LO_INFO, "net: no PKT_SETUP within %d ms; " - "single-player mode\n", handshake_timeout_ms); - return false; - } - - struct setup_packet_s *setup = - (struct setup_packet_s *)(buf + sizeof(*h)); - s_numplayers = setup->players; - s_consoleplayer = setup->yourplayer; - if (s_numplayers < 1 || s_numplayers > MAXPLAYERS || - s_consoleplayer < 0 || s_consoleplayer >= s_numplayers) { - lprintf(LO_ERROR, "net: bad SETUP players=%d you=%d\n", - s_numplayers, s_consoleplayer); - return false; - } - lprintf(LO_INFO, "net: SETUP players=%d slot=%d\n", - s_numplayers, s_consoleplayer); - - // Mark every active slot as in-game. - for (int i = 0; i < MAXPLAYERS; i++) { - _g->playeringame[i] = (i < s_numplayers); + // Phase 1: blast PKT_INIT, wait for PKT_SETUP. + 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; + } + // No SETUP within retry_ms — send another INIT and keep waiting. } - _g->consoleplayer = s_consoleplayer; - _g->displayplayer = s_consoleplayer; - - // Wait for PKT_GO. The relay auto-broadcasts GO once its lobby fills, - // so this is bounded by `--players` * (other players' boot time). 30 s - // is plenty for a couple of devices coming up together; longer than - // that means something's wrong and we should fall back to solo. - lprintf(LO_INFO, "net: waiting for PKT_GO (30 s)...\n"); - if (wait_for(PKT_GO, h, sizeof(buf), 30000) < 0) { - lprintf(LO_INFO, "net: timed out waiting for PKT_GO; " - "falling back to single-player\n"); - return false; +got_setup: + + // Phase 2: wait for PKT_GO. Relay sends it immediately on first INIT, + // so this is normally <50 ms. + 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); + return true; + } + if (h->type == PKT_SETUP) { + // The relay re-sent SETUP (another joiner came in faster + // than expected). Re-apply and keep waiting for GO. + 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; +} - // PKT_GO payload: uint32 starttic, uint32 rngseed (network byte order). - uint8_t *gpay = buf + sizeof(*h); - uint32_t starttic = - (gpay[0] << 24) | (gpay[1] << 16) | (gpay[2] << 8) | gpay[3]; - uint32_t rngseed = - (gpay[4] << 24) | (gpay[5] << 16) | (gpay[6] << 8) | gpay[7]; - s_start_tic = starttic; - (void)rngseed; // TODO: feed into M_Random when we hook it up +// ---------------------------- public API ------------------------------------ - s_net_active = true; - s_started = true; - lprintf(LO_INFO, "net: GO starttic=%u rngseed=%u\n", starttic, rngseed); - return true; -} +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; } -// Pump any pending PKT_TICS into our ring buffers. Called at top of -// TryRunTics. Non-blocking. +// Pump any pending packets. Called at top of TryRunTics. Non-blocking. void net_pump(void) { if (!s_net_active) return; @@ -198,26 +243,34 @@ void net_pump(void) { if (n == 0) break; if (!packet_ok(h, n)) continue; + // ----- roster updates / restarts ----- + 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); + // After a restart-GO, ignore the rest of this packet batch — + // they're stale ticcmds from the previous generation. + return; + } + if (h->type == PKT_QUIT) { - // Relay tore down the lobby (e.g. a peer re-INITed). Drop - // back to solo so the game keeps running locally. + // Relay kicked us (only happens on lobby-full reject or + // operator quit). Drop to solo so the device keeps showing + // Doom locally — user can power-cycle to retry. lprintf(LO_INFO, "net: PKT_QUIT received; dropping to solo\n"); s_net_active = false; s_started = false; - // Make sure we still have at least one playeringame slot. for (int i = 0; i < MAXPLAYERS; i++) { _g->playeringame[i] = (i == _g->consoleplayer); } return; } - if (h->type != PKT_TICS) { - // PKT_BACKOFF, PKT_DOWN, etc. — ignored for now. - continue; - } + if (h->type != PKT_TICS) continue; - // Payload: u8 numtics, u32 firsttic (network byte order), - // numtics * numplayers * sizeof(ticcmd_t) raw. + // ----- PKT_TICS ----- if (n < sizeof(*h) + 5) continue; uint8_t *p = buf + sizeof(*h); uint8_t numtics = p[0]; @@ -242,7 +295,6 @@ void net_pump(void) { } } -// True when we have ticcmds for every active player at tic `t`. bool net_have_all_tics(int t) { if (!s_net_active) return true; for (int p = 0; p < s_numplayers; p++) { @@ -251,17 +303,14 @@ bool net_have_all_tics(int t) { return true; } -// Copy the ring-buffered ticcmd for player p at tic t into out. void net_get_tic(int p, int t, ticcmd_t *out) { *out = s_netcmds_ring[p][t & TIC_MASK]; } -// Stash our locally-built tic, then send any unsent tics to the relay. void net_record_local_tic(int tic, const ticcmd_t *cmd) { s_localcmds[tic & TIC_MASK] = *cmd; - // Also pre-fill our own slot so we can advance without waiting on the - // server's echo — this matches PrBoom's "loopback" optimization and - // avoids 1×RTT of latency on local input. + // Pre-fill our own slot so we can advance without waiting on the + // server's echo. s_netcmds_ring[s_consoleplayer][tic & TIC_MASK] = *cmd; if (tic > s_have_tic[s_consoleplayer]) { s_have_tic[s_consoleplayer] = tic; @@ -278,7 +327,6 @@ void net_flush_unsent(int up_to_tic) { count = MAX_TICS_PER_PKT_TICC; } - // Payload: u8 numtics, u32 firsttic, numtics * 8B raw ticcmd uint8_t pay[1 + 4 + MAX_TICS_PER_PKT_TICC * sizeof(ticcmd_t)]; pay[0] = (uint8_t)count; pay[1] = (uint8_t)((first >> 24) & 0xff); @@ -294,8 +342,6 @@ void net_flush_unsent(int up_to_tic) { s_last_sent_tic = up_to_tic; } -// Ask the relay to resend the PKT_TICS containing tic `wanttic`. Used by -// TryRunTics when we've been stalled too long waiting for a slot's input. void net_request_retrans(int wanttic) { if (!s_net_active) return; uint8_t pay[4]; diff --git a/main/doom/global_data.h b/main/doom/global_data.h index d8ba474..4f64aff 100644 --- a/main/doom/global_data.h +++ b/main/doom/global_data.h @@ -108,6 +108,11 @@ int lastmadetic; 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 // ****************************************************************************** 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/net-config.h b/main/net-config.h index f0b62d3..44b8d11 100644 --- a/main/net-config.h +++ b/main/net-config.h @@ -4,7 +4,7 @@ // 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 "172.20.10.5" +#define DOOM_SERVER_IP "192.168.77.200" #define DOOM_SERVER_PORT 5029 // Local UDP port the client binds. 0 = ephemeral (recommended; the relay diff --git a/tools/relay.py b/tools/relay.py index 26a5abe..6d245b2 100755 --- a/tools/relay.py +++ b/tools/relay.py @@ -4,16 +4,21 @@ Doom simulation runs here. Wire format: see main/doom/protocol.h. Each packet is a packet_header_t -(8 bytes) plus a payload. The header is: +(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) -Run with no args to listen on 0.0.0.0:5029. Type "go" + Enter at the -console to broadcast PKT_GO once enough players have joined. Type -"quit" to terminate the game with PKT_QUIT. +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 @@ -21,7 +26,7 @@ import os import struct import sys -from collections import defaultdict +import time from typing import Dict, Tuple, Optional # Mirror of protocol.h enum @@ -34,6 +39,8 @@ TICCMD_LEN = 8 # see d_ticcmd.h MAX_PLAYERS = 4 TICDUP_REDUNDANCY = 8 # tics resent on every PKT_TICS +PLAYER_TIMEOUT_S = 5.0 # seconds of silence before kick +TIMEOUT_POLL_S = 0.25 # how often to check liveness PKT_TYPE_NAMES = { PKT_INIT: "INIT", PKT_SETUP: "SETUP", PKT_GO: "GO", @@ -44,7 +51,6 @@ def checksum(buf: bytes) -> int: - # Sum of all bytes after the first (the checksum byte itself). s = 0 for b in buf[1:]: s = (s + b) & 0xff @@ -61,8 +67,7 @@ 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]) - want = checksum(buf) - if cs != want: + if cs != checksum(buf): return None return cs, typ, tic @@ -71,14 +76,20 @@ class Relay(asyncio.DatagramProtocol): def __init__(self, max_players: int): self.max_players = max_players self.transport: Optional[asyncio.DatagramTransport] = None - self.players: Dict[Tuple[str, int], int] = {} # addr -> slot - self.slot_addr: Dict[int, Tuple[str, int]] = {} # slot -> addr - self.tic_buf: Dict[Tuple[int, int], bytes] = {} # (slot, tic) -> raw - self.released = -1 # highest tic released as PKT_TICS - self.started = False + + # 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 + + # 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 / heartbeat + # Stats self.ticc_count = 0 self._last_stats_report = 0.0 @@ -108,58 +119,54 @@ def datagram_received(self, data: bytes, addr): print(f"[relay] ignoring {PKT_TYPE_NAMES.get(typ, typ)} from {addr}", flush=True) - # ------------- packet handlers ------------- + # ------------- 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) + + # ------------- restart ------------- + + def restart_game(self, reason: str): + """Reset tic state and broadcast SETUP+GO to all current players. - def reset_lobby(self, reason: str): - # Drop any in-flight game state. The next handle_init will rebuild - # the lobby from scratch. We send PKT_QUIT to existing players so - # they fall back to solo / re-handshake. - if self.players: - print(f"[relay] resetting lobby ({reason}); kicking " - f"{len(self.players)} player(s)", flush=True) - self.broadcast_quit() - self.players.clear() - self.slot_addr.clear() + Idempotent for empty rosters: if no players are connected, just + clears state and waits. + """ self.tic_buf.clear() self.released = -1 - self.started = False self.last_ticset_payload = None + self.generation = (self.generation + 1) & 0xff + self.rngseed = int.from_bytes(os.urandom(4), "big") - def handle_init(self, addr, payload): - # If the game is already running and a *new* address INITs, it - # almost certainly means a Pixie was reset and is trying to - # re-join. The cleanest semantic is to reset the lobby — gameplay - # is unrecoverable mid-stream when a peer's tic stream restarts at - # zero, and tearing down + rebuilding is what the operator wants. - if self.started and addr not in self.players: - self.reset_lobby(f"new INIT from {addr}") + if not self.players: + print(f"[relay] reset (now idle, {reason})", flush=True) + return - if addr in self.players: - slot = self.players[addr] - print(f"[relay] {addr} re-INIT (slot {slot})", flush=True) - else: - if len(self.players) >= self.max_players: - print(f"[relay] {addr} INIT: lobby full", flush=True) - return - slot = len(self.players) - self.players[addr] = slot - self.slot_addr[slot] = addr - print(f"[relay] {addr} joined as slot {slot} " - f"({len(self.players)}/{self.max_players})", flush=True) - - # Once the lobby is full, broadcast SETUP to *all* players with - # the final player count, then GO. SETUP carries the final count - # so each client sets up playeringame[] for every active slot. - # Until the lobby fills, joiners just wait — Pixie's net_init - # blocks on wait_for(PKT_SETUP) for up to 5 s, so the operator - # has that window to bring the second device up. - if (not self.started and - len(self.players) >= self.max_players): - for a, s in self.players.items(): - self.send_setup(a, s) - self.broadcast_go() - - def send_setup(self, addr, slot): + 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)) + 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; @@ -169,35 +176,47 @@ def send_setup(self, addr, slot): # } nplayers = len(self.players) payload = bytes([ - nplayers, slot, 3, 1, 1, 0, # players, you, skill=Hurt me plenty, - # ep1, lvl1, coop + 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) - pkt = make_packet(PKT_SETUP, 0, payload) - self.transport.sendto(pkt, addr) + return make_packet(PKT_SETUP, 0, payload) - def broadcast_go(self): - if not self.players: - print("[relay] no players to GO", flush=True) - return - if self.started: - print("[relay] already started", flush=True) + # ------------- packet handlers ------------- + + def handle_init(self, addr, payload): + now = time.monotonic() + + # Same address re-INITing (e.g. Pixie rebooted from same socket). + # Free the old slot first; restart_game below covers the rest. + 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") + + 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 - rngseed = int.from_bytes(os.urandom(4), "big") - payload = struct.pack("!II", 0, rngseed) - pkt = make_packet(PKT_GO, 0, payload) - for addr in list(self.players.keys()): - self.transport.sendto(pkt, addr) - self.started = True - print(f"[relay] GO! rngseed={rngseed:#x} players={len(self.players)}", - flush=True) + + self.players[addr] = slot + self.slot_addr[slot] = addr + self.last_seen[slot] = now + 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 not self.started or addr not in self.players: + if addr not in self.players: return slot = self.players[addr] + self.last_seen[slot] = time.monotonic() + if len(payload) < 5: return numtics = payload[0] @@ -212,7 +231,8 @@ def handle_ticc(self, addr, payload): self.ticc_count += 1 - # Try to advance the released frontier. + # 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: @@ -225,24 +245,22 @@ def handle_ticc(self, addr, payload): if moved: self.broadcast_ticset() - # Periodic heartbeat: tics/s + per-slot lag. - import time as _time - now = _time.monotonic() + # Heartbeat + now = time.monotonic() if now - self._last_stats_report >= 2.0: self._last_stats_report = now highest = {s: max((t for (sl, t) in self.tic_buf if sl == s), default=-1) for s in active_slots} - print(f"[relay] released={self.released} " + print(f"[relay] gen={self.generation} released={self.released} " f"highest={highest} ticc_pkts={self.ticc_count}", flush=True) def broadcast_ticset(self): - if self.released < 0: + if self.released < 0 or not self.players: return first = max(0, self.released - TICDUP_REDUNDANCY + 1) numtics = self.released - first + 1 - nplayers = len(self.players) active_slots = sorted(self.players.values()) body = bytearray() for t in range(first, first + numtics): @@ -253,40 +271,42 @@ def broadcast_ticset(self): body += cmd payload = bytes([numtics]) + struct.pack("!I", first) + bytes(body) pkt = make_packet(PKT_TICS, self.released, payload) - for addr in list(self.players.keys()): + for addr in self.players.keys(): self.transport.sendto(pkt, addr) self.last_ticset_payload = pkt def handle_retrans(self, addr, payload): - # Just rebroadcast our most recent PKT_TICS to that client. - if self.last_ticset_payload is not None: + 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 in self.players: - slot = self.players[addr] - print(f"[relay] {addr} (slot {slot}) quit", flush=True) - del self.slot_addr[slot] - del self.players[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") - def broadcast_quit(self): - pkt = make_packet(PKT_QUIT, 0, b"") - for addr in list(self.players.keys()): - self.transport.sendto(pkt, addr) - print("[relay] QUIT broadcast", flush=True) + # ------------- 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 + ] + if stale: + for slot in stale: + self._free_slot(slot, "timeout") + self.restart_game(f"timeout (slots {stale})") async def stdin_loop(relay: Relay): - """Best-effort stdin command loop. - - On a TTY this gives interactive control; without a TTY (e.g. launched - from a non-interactive parent) asyncio refuses to attach to stdin and - we just sleep forever — the relay still works via auto-GO on lobby - full. - """ + """Operator console, when stdin is a tty.""" if not sys.stdin.isatty(): - print("[relay] stdin is not a tty; commands disabled. " - "Auto-GO when lobby fills.", flush=True) + print("[relay] stdin is not a tty; commands disabled.", flush=True) while True: await asyncio.sleep(3600) @@ -294,24 +314,25 @@ async def stdin_loop(relay: Relay): reader = asyncio.StreamReader() proto = asyncio.StreamReaderProtocol(reader) await loop.connect_read_pipe(lambda: proto, sys.stdin) - print("[relay] commands: go | quit | players | help", flush=True) + 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 == "go": - relay.broadcast_go() - elif cmd == "quit": - relay.broadcast_quit() - break + 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(" go — start the game (broadcast PKT_GO)\n" + print(" restart — bump generation, broadcast fresh SETUP+GO\n" " players — list connected slots\n" - " quit — stop and broadcast PKT_QUIT", flush=True) + " quit — kick everyone and exit", flush=True) else: print(f" unknown: {cmd!r}", flush=True) @@ -320,19 +341,22 @@ 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("--players", type=int, default=4, - help=f"max players (1..{MAX_PLAYERS})") + 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.players <= MAX_PLAYERS): - ap.error(f"--players must be between 1 and {MAX_PLAYERS}") + 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.players) + relay = Relay(args.max_players) await loop.create_datagram_endpoint( lambda: relay, local_addr=(args.bind, args.port)) - await stdin_loop(relay) + await asyncio.gather( + relay.liveness_loop(), + stdin_loop(relay), + ) if __name__ == "__main__": From 47c1ea2230f6bbf60268d41e194deb7661b30f5a Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 17 May 2026 01:25:28 +0200 Subject: [PATCH 10/23] generation byte, same-IP rejoin, MAXPLAYERS=8 --- main/doom/d_net.c | 32 +++++++++++++++++++++++++++----- main/doom/doomdef.h | 2 +- main/doom/protocol.h | 6 ++++-- tools/relay.py | 37 +++++++++++++++++++++++++++++-------- 4 files changed, 61 insertions(+), 16 deletions(-) diff --git a/main/doom/d_net.c b/main/doom/d_net.c index 72b05fb..0430194 100644 --- a/main/doom/d_net.c +++ b/main/doom/d_net.c @@ -40,6 +40,7 @@ static bool s_net_active = false; // true once handshake succeeded static bool s_started = false; // true once first PKT_GO received static int s_numplayers = 1; static int s_consoleplayer = 0; +static uint8_t s_generation = 0; // bumps every time the relay restarts // localcmds[t & TIC_MASK] — what we built for tic t locally static ticcmd_t s_localcmds[TIC_RING]; @@ -119,7 +120,7 @@ static bool apply_setup(const uint8_t *payload, size_t paylen) { // Apply a PKT_GO payload. Saves rngseed and (when called mid-game) requests // a level restart so every Pixie converges at gametic=0. -static void apply_go(const uint8_t *payload, bool first_time) { +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 = @@ -127,8 +128,9 @@ static void apply_go(const uint8_t *payload, bool first_time) { (void)starttic; // always 0 in v2 _g->net_rngseed = rngseed; - lprintf(LO_INFO, "net: GO rngseed=%u (first=%d)\n", - rngseed, (int)first_time); + s_generation = gen; + lprintf(LO_INFO, "net: GO gen=%u rngseed=%u (first=%d)\n", + gen, rngseed, (int)first_time); // Reset per-slot tic state. The relay's tic numbering restarts at 0 // every GO; we follow. @@ -208,7 +210,7 @@ bool net_init(int retry_ms) { 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); + apply_go(buf + sizeof(*h), /*first_time=*/true, h->generation); return true; } if (h->type == PKT_SETUP) { @@ -249,7 +251,7 @@ void net_pump(void) { continue; } if (h->type == PKT_GO && n >= sizeof(*h) + 8) { - apply_go(buf + sizeof(*h), /*first_time=*/false); + apply_go(buf + sizeof(*h), /*first_time=*/false, h->generation); // After a restart-GO, ignore the rest of this packet batch — // they're stale ticcmds from the previous generation. return; @@ -270,6 +272,26 @@ void net_pump(void) { if (h->type != PKT_TICS) continue; + // Generation mismatch on TICS means we missed a SETUP+GO restart. + // Reach out to the relay with a fresh INIT; its handle_init will + // trigger a new restart broadcast and apply_setup/apply_go will + // converge us. Drop this packet. + // + // Throttle: a single restart can produce a burst of stale TICS + // before the relay's tic_buf is cleared. Don't blast PKT_INIT + // more than once per second. + if (h->generation != s_generation) { + static int last_reinit_t = -100000; + int now_t = (int)I_GetTime(); + if (now_t - last_reinit_t > 35) { // 1 s + 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; + } + // ----- PKT_TICS ----- if (n < sizeof(*h) + 5) continue; uint8_t *p = buf + sizeof(*h); diff --git a/main/doom/doomdef.h b/main/doom/doomdef.h index 56962c9..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 4 +#define MAXPLAYERS 8 // phares 5/14/98: // DOOM Editor Numbers (aka doomednum in mobj_t) diff --git a/main/doom/protocol.h b/main/doom/protocol.h index c2af10d..ab41fbf 100644 --- a/main/doom/protocol.h +++ b/main/doom/protocol.h @@ -51,12 +51,14 @@ enum packet_type_e { 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/tools/relay.py b/tools/relay.py index 6d245b2..2e63143 100755 --- a/tools/relay.py +++ b/tools/relay.py @@ -37,9 +37,13 @@ HEADER_LEN = PACKET_HEADER.size # 8 TICCMD_LEN = 8 # see d_ticcmd.h -MAX_PLAYERS = 4 +MAX_PLAYERS = 8 TICDUP_REDUNDANCY = 8 # tics resent on every PKT_TICS -PLAYER_TIMEOUT_S = 5.0 # seconds of silence before kick +PLAYER_TIMEOUT_S = 15.0 # seconds of silence before kick. + # Generous: a Pixie's boot path between + # PKT_INIT and its first PKT_TICC can + # exceed 5 s on a cold start (W_Init, + # R_Init, P_Init, etc.). TIMEOUT_POLL_S = 0.25 # how often to check liveness PKT_TYPE_NAMES = { @@ -57,8 +61,10 @@ def checksum(buf: bytes) -> int: return s -def make_packet(pkt_type: int, tic: int, payload: bytes) -> bytes: - body = bytes([0, pkt_type, 0, 0]) + struct.pack("!I", tic) + payload +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:] @@ -162,7 +168,8 @@ def restart_game(self, reason: str): 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)) + 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) @@ -181,7 +188,7 @@ def _setup_packet(self, slot: int) -> bytes: ]) payload += b"\x00" * 64 # game_options payload += bytes([0]) # numwads=0 (skip wadnames) - return make_packet(PKT_SETUP, 0, payload) + return make_packet(PKT_SETUP, 0, payload, generation=self.generation) # ------------- packet handlers ------------- @@ -189,11 +196,24 @@ def handle_init(self, addr, payload): now = time.monotonic() # Same address re-INITing (e.g. Pixie rebooted from same socket). - # Free the old slot first; restart_game below covers the rest. 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: @@ -270,7 +290,8 @@ def broadcast_ticset(self): cmd = b"\x00" * TICCMD_LEN body += cmd payload = bytes([numtics]) + struct.pack("!I", first) + bytes(body) - pkt = make_packet(PKT_TICS, self.released, payload) + 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 From 479456293e1422b783a672ec1f53dde6465a001a Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 17 May 2026 01:28:34 +0200 Subject: [PATCH 11/23] relay heartbeat: per-slot contiguous frontier, not stale max --- tools/relay.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tools/relay.py b/tools/relay.py index 2e63143..f59510f 100755 --- a/tools/relay.py +++ b/tools/relay.py @@ -265,15 +265,21 @@ def handle_ticc(self, addr, payload): if moved: self.broadcast_ticset() - # Heartbeat + # 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 - highest = {s: max((t for (sl, t) in self.tic_buf if sl == s), - default=-1) - for s in active_slots} + 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"highest={highest} ticc_pkts={self.ticc_count}", + f"frontier={frontier} ticc_pkts={self.ticc_count}", flush=True) def broadcast_ticset(self): From f4e474ee65bcdcbf9b188b3d4691dbf34abea70e Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 17 May 2026 01:39:46 +0200 Subject: [PATCH 12/23] offset spawn for missing starts, grace, all-dead restart --- main/doom/d_client.c | 36 ++++++++++++++++++++++++++++++++++++ main/doom/d_net.c | 36 +++++++++++++++++++++++++++--------- main/doom/d_net.h | 1 + main/doom/p_setup.c | 34 ++++++++++++++++++++++++++-------- 4 files changed, 90 insertions(+), 17 deletions(-) diff --git a/main/doom/d_client.c b/main/doom/d_client.c index 50dfd1f..b2d870f 100644 --- a/main/doom/d_client.c +++ b/main/doom/d_client.c @@ -180,6 +180,42 @@ void TryRunTics (void) G_Ticker (); _g->gametic++; + // All-dead → restart after 10 s. Every active player is + // PST_DEAD ⇒ start a countdown; if any player flips back to + // PST_LIVE/PST_REBORN we cancel. Triggering uses PKT_INIT, + // which the relay treats as a same-IP re-join and broadcasts a + // fresh SETUP+GO to everyone — i.e. an authoritative restart + // with a new rngseed. + if (net_is_active()) { + int alive = 0; + for (int p = 0; p < MAXPLAYERS; p++) { + if (_g->playeringame[p] && + _g->players[p].playerstate != PST_DEAD) { + alive++; + } + } + static int all_dead_since = -1; + static int last_restart_request = -100000; + if (alive == 0) { + if (all_dead_since < 0) { + all_dead_since = _g->gametic; + printf("[net] all players dead; restart in 10 s\n"); + } + int dead_for = _g->gametic - all_dead_since; + if (dead_for >= 350 && // 10 s @ 35 tic/s + (int)I_GetTime() - last_restart_request > 70) { + last_restart_request = (int)I_GetTime(); + printf("[net] all-dead 10 s; requesting restart\n"); + extern void net_request_restart(void); + net_request_restart(); + } + } else { + if (all_dead_since >= 0) { + all_dead_since = -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. diff --git a/main/doom/d_net.c b/main/doom/d_net.c index 0430194..b6bd6ad 100644 --- a/main/doom/d_net.c +++ b/main/doom/d_net.c @@ -41,6 +41,7 @@ static bool s_started = false; // true once first PKT_GO received static int s_numplayers = 1; static int s_consoleplayer = 0; static uint8_t s_generation = 0; // bumps every time the relay restarts +static int s_last_go_tic = -100000; // I_GetTime() snapshot at last apply_go // localcmds[t & TIC_MASK] — what we built for tic t locally static ticcmd_t s_localcmds[TIC_RING]; @@ -148,6 +149,7 @@ static void apply_go(const uint8_t *payload, bool first_time, uint8_t gen) { s_started = true; s_net_active = true; + s_last_go_tic = (int)I_GetTime(); // Queue a level reload. On the next G_Ticker, ga_newgame triggers // G_DoNewGame → G_InitNew → P_SetupLevel, which respawns every active @@ -272,18 +274,26 @@ void net_pump(void) { if (h->type != PKT_TICS) continue; - // Generation mismatch on TICS means we missed a SETUP+GO restart. - // Reach out to the relay with a fresh INIT; its handle_init will - // trigger a new restart broadcast and apply_setup/apply_go will - // converge us. Drop this packet. + // Generation mismatch on TICS means we missed a SETUP+GO restart + // — OR we just *applied* a fresh GO and are seeing a stale TICS + // packet from the previous generation that was already in flight. + // The second case is common (the relay broadcasts SETUP+GO right + // after sending the last batch of pre-restart TICS), and a naive + // re-INIT here would kick off an oscillation: re-INIT → relay + // restart → new TICS that may also race against another in-flight + // stale TICS → re-INIT again → ... // - // Throttle: a single restart can produce a burst of stale TICS - // before the relay's tic_buf is cleared. Don't blast PKT_INIT - // more than once per second. + // Quiet the case where we just applied a GO. The relay's tic_buf + // is reset on every restart, so any stale TICS will stop arriving + // within a few RTTs. if (h->generation != s_generation) { - static int last_reinit_t = -100000; int now_t = (int)I_GetTime(); - if (now_t - last_reinit_t > 35) { // 1 s + int since_go = now_t - s_last_go_tic; + if (since_go < 70) { // 2 s of grace after a fresh GO + continue; // silently drop stale TICS + } + static int last_reinit_t = -100000; + if (now_t - last_reinit_t > 35) { // throttle 1/s last_reinit_t = now_t; lprintf(LO_INFO, "net: TICS gen=%u != ours=%u; re-INIT\n", h->generation, s_generation); @@ -364,6 +374,14 @@ void net_flush_unsent(int up_to_tic) { s_last_sent_tic = up_to_tic; } +// Ask the relay to restart the world (new rngseed, fresh roster snapshot). +// Implemented as a PKT_INIT: the relay treats it as a same-IP re-join and +// broadcasts SETUP+GO to everyone. +void net_request_restart(void) { + if (!s_net_active) return; + send_pkt_init(); +} + void net_request_retrans(int wanttic) { if (!s_net_active) return; uint8_t pay[4]; diff --git a/main/doom/d_net.h b/main/doom/d_net.h index ce847a2..12c0777 100644 --- a/main/doom/d_net.h +++ b/main/doom/d_net.h @@ -65,5 +65,6 @@ 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/p_setup.c b/main/doom/p_setup.c index e714699..a71ad0d 100644 --- a/main/doom/p_setup.c +++ b/main/doom/p_setup.c @@ -542,16 +542,34 @@ void P_SetupLevel(int episode, int map, int playermask, skill_t skill) if (_g->playeringame[0] && !_g->players[0].mo) I_Error("P_SetupLevel: missing player 1 start\n"); - // For now, don't spawn fallback bodies for slots whose start is - // missing — having two players collocated at slot 0 was the - // suspected source of intermittent freezes (both mobjs in the - // same blockmap cell + telefrag/iteration weirdness). Each device - // still simulates the deterministic shared world; the absent - // remote body just means players can't see each other on screen - // until the WAD has all four player starts. The lockstep input - // sync still keeps the simulations in step. + // 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, From 2dd5af06b133a8e39150b6a7bff1221b4cdc326d Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 17 May 2026 01:51:42 +0200 Subject: [PATCH 13/23] round-over banner + extended gen-mismatch grace --- main/doom/d_client.c | 53 +++++++++++++++++++++++++++++--------------- main/doom/d_net.c | 7 ++++-- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/main/doom/d_client.c b/main/doom/d_client.c index b2d870f..5c70c22 100644 --- a/main/doom/d_client.c +++ b/main/doom/d_client.c @@ -180,39 +180,56 @@ void TryRunTics (void) G_Ticker (); _g->gametic++; - // All-dead → restart after 10 s. Every active player is - // PST_DEAD ⇒ start a countdown; if any player flips back to - // PST_LIVE/PST_REBORN we cancel. Triggering uses PKT_INIT, - // which the relay treats as a same-IP re-join and broadcasts a - // fresh SETUP+GO to everyone — i.e. an authoritative restart - // with a new rngseed. + // 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; } } - static int all_dead_since = -1; + bool round_over = + (net_numplayers() >= 2 && alive <= 1) || + (net_numplayers() == 1 && alive == 0); + + static int countdown_start = -1; static int last_restart_request = -100000; - if (alive == 0) { - if (all_dead_since < 0) { - all_dead_since = _g->gametic; - printf("[net] all players dead; restart in 10 s\n"); + 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); } - int dead_for = _g->gametic - all_dead_since; - if (dead_for >= 350 && // 10 s @ 35 tic/s + // 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] all-dead 10 s; requesting restart\n"); + printf("[net] 10 s elapsed; requesting restart\n"); extern void net_request_restart(void); net_request_restart(); } - } else { - if (all_dead_since >= 0) { - all_dead_since = -1; - } + } else if (countdown_start >= 0) { + countdown_start = -1; } } diff --git a/main/doom/d_net.c b/main/doom/d_net.c index b6bd6ad..75b1bcf 100644 --- a/main/doom/d_net.c +++ b/main/doom/d_net.c @@ -115,6 +115,9 @@ static bool apply_setup(const uint8_t *payload, size_t paylen) { for (int i = 0; i < MAXPLAYERS; i++) { _g->playeringame[i] = (i < np); } + // SETUP marks the start of a restart sequence; the GO that follows + // bumps our generation. Suppress gen-mismatch panic in this window. + s_last_go_tic = (int)I_GetTime(); lprintf(LO_INFO, "net: SETUP players=%d slot=%d\n", np, you); return true; } @@ -289,8 +292,8 @@ void net_pump(void) { if (h->generation != s_generation) { int now_t = (int)I_GetTime(); int since_go = now_t - s_last_go_tic; - if (since_go < 70) { // 2 s of grace after a fresh GO - continue; // silently drop stale TICS + if (since_go < 175) { // 5 s of grace after SETUP or GO + continue; // silently drop stale TICS } static int last_reinit_t = -100000; if (now_t - last_reinit_t > 35) { // throttle 1/s From 1ab2255961275ebc3da31fecaa3a45e022dd703b Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 17 May 2026 01:58:02 +0200 Subject: [PATCH 14/23] docs: WiFi multiplayer setup in README --- README.md | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/README.md b/README.md index 83fff99..232d2d4 100644 --- a/README.md +++ b/README.md @@ -57,12 +57,117 @@ required. The device is played sideways (with the buttons down): ``` +Multiplayer (WiFi co-op) +------------------------ + +This branch (`feat/multiplayer`) adds lockstep co-op over WiFi for up +to 8 Pixies. Each Pixie joins a tiny Python UDP relay running on any +machine on the same network — phone, laptop, or a Raspberry Pi. +The relay itself does no game logic; it just collects each Pixie's +ticcmds and broadcasts them so every device runs the same +deterministic simulation. + +Pixies can join and leave at any time. Each roster change triggers a +clean restart at E1M1 with a fresh RNG seed. When only one player +is left alive, the round ends with a "PLAYER N WINS!" banner and the +relay restarts everyone after 10 seconds. Solo deaths trigger +"GAME OVER" with the same 10-second restart. + +### What you need + +- 1–8 Firefly Pixies (or Gremlins) flashed from this branch +- Any machine on the same WiFi running Python 3 (laptop, phone with + Termux, RPi, etc.) — this hosts the relay +- WiFi credentials that all Pixies and the relay machine can use + +### One-time setup + +1. **Pick your relay machine's IP.** On the relay machine, find its + local IP on the WiFi: + ``` + ifconfig | grep "inet " # macOS / Linux + ipconfig # Windows + ``` + +2. **Configure each Pixie's firmware.** Before building/flashing, fill + in the gitignored credentials header: + + ```bash + cp main/wifi-creds.h.example main/wifi-creds.h + $EDITOR main/wifi-creds.h # WIFI_SSID, WIFI_PASSWORD + $EDITOR main/net-config.h # DOOM_SERVER_IP = relay's IP + ``` + +3. **Build and flash each Pixie:** + ```bash + . $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. + +### Running a session + +1. **Start the relay on the host machine:** + ```bash + python3 tools/relay.py + ``` + It 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 showing tic flow per slot: + ``` + [relay] gen=3 released=5891 frontier={0: 5892, 1: 5891, 2: 5891} ... + ``` + +2. **Power on the Pixies.** Each one will WiFi-associate, send a + `PKT_INIT` to the relay, get a slot assignment, and drop straight + into E1M1. + +3. **Join late** by powering on more Pixies whenever — the relay + broadcasts a fresh `SETUP+GO` to everyone, so the existing players + restart together with the new joiner. + +4. **Leave any time** by powering off a Pixie. The relay times the + slot out after 15 seconds of silence and the survivors restart + without that player. + +### Troubleshooting + +- **Pixie shows "Waiting for server…" forever:** 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, + guest networks, and laptops can change IP between sessions. +- **Game freezes after a player dies:** the round-over detection + should kick in after 10 seconds (banner + auto-restart). If it + doesn't, the engine has hit the known intermittent stall — power- + cycle one Pixie and the relay will reissue `SETUP+GO` to the rest. +- **"Client isolation" on guest WiFi:** some networks block + client-to-client UDP. The relay still receives Pixie packets but + Pixies can't receive replies. Use a personal hotspot or a router + without isolation. +- **Two Pixies behind one NAT'd IP:** the relay distinguishes them + by source port, so it works, but the "same-IP rejoin" optimization + may falsely free the *other* Pixie's slot on a fresh INIT. Direct + network paths (laptop hotspot, regular WiFi without NAT) avoid + this entirely. + +The protocol details and on-device state machine live in +`main/doom/d_net.c` and `tools/relay.py`. Both ends carry an 8-bit +generation byte so a Pixie that misses a `SETUP+GO` restart +auto-recovers by re-sending `PKT_INIT`. + + To Do ----- - save/load - more visible menu items (bigger? different color? dim/blur the background?) - click forward twice to begin running +- multiplayer: fix the silent-stall bug (engine hangs with no I_Error + on long multi-player sessions; recovery currently requires a + power-cycle) +- multiplayer: deathmatch flag in `setup_packet_s` so the relay can + pick co-op vs DM Credits From 0cff6808ce722e0a99872bf244f760c0880b6896 Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 17 May 2026 02:19:07 +0200 Subject: [PATCH 15/23] =?UTF-8?q?P2P=20mesh=20=E2=80=94=20pixies=20form=20?= =?UTF-8?q?their=20own=20coven,=20no=20relay=20required?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/doom/d_net.c | 529 ++++++++++++++++++++++++++++++++---------- main/doom/i_network.c | 55 ++++- main/doom/protocol.h | 14 +- main/net-config.h | 24 +- 4 files changed, 481 insertions(+), 141 deletions(-) diff --git a/main/doom/d_net.c b/main/doom/d_net.c index 75b1bcf..d66de92 100644 --- a/main/doom/d_net.c +++ b/main/doom/d_net.c @@ -1,17 +1,26 @@ -// On-device netplay state machine: handshake with a PC relay, build and -// emit our own ticcmds each tic, ingest the relay's broadcast PKT_TICS, -// and gate the engine's gametic advance on having a full set of inputs -// for every active player. +// 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. // -// v2: dynamic roster. The relay broadcasts a fresh SETUP+GO whenever the -// active player set changes; every Pixie restarts at gametic=0 with the -// new roster + a new RNG seed. SETUP after the first one is a "roster -// update"; GO after the first one is "restart now". +// Two modes, picked at compile time by DOOM_NETPLAY_MESH in net-config.h: // -// Server-side counterpart lives in tools/relay.py. +// - 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" @@ -23,9 +32,16 @@ #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 @@ -34,24 +50,21 @@ #define MAX_TICS_PER_PKT_TICC 8 -// ---------------------------- module state ---------------------------------- +// ---------------------------- shared module state --------------------------- -static bool s_net_active = false; // true once handshake succeeded -static bool s_started = false; // true once first PKT_GO received +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; // bumps every time the relay restarts -static int s_last_go_tic = -100000; // I_GetTime() snapshot at last apply_go +static uint8_t s_generation = 0; +static int s_last_go_tic = -100000; -// localcmds[t & TIC_MASK] — what we built for tic t locally static ticcmd_t s_localcmds[TIC_RING]; - -// netcmds_ring[p][t & TIC_MASK] — what we have for player p at tic t static ticcmd_t s_netcmds_ring[MAXPLAYERS][TIC_RING]; -static int s_have_tic[MAXPLAYERS]; // highest tic per slot -static int s_last_sent_tic = -1; // highest tic we've put in a PKT_TICC +static int s_have_tic[MAXPLAYERS]; +static int s_last_sent_tic = -1; -// ---------------------------- packet helpers -------------------------------- +// ---------------------------- packet helpers (shared) ------------------------ static byte checksum_packet(const packet_header_t *p, size_t len) { const byte *b = (const byte *)p; @@ -84,20 +97,97 @@ static void send_simple(enum packet_type_e type, unsigned tic, } 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); } -// ---------------------------- SETUP / GO handlers --------------------------- - -// Apply a PKT_SETUP payload to local state. Used both during the initial -// handshake and for mid-game roster updates. 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; @@ -115,36 +205,28 @@ static bool apply_setup(const uint8_t *payload, size_t paylen) { for (int i = 0; i < MAXPLAYERS; i++) { _g->playeringame[i] = (i < np); } - // SETUP marks the start of a restart sequence; the GO that follows - // bumps our generation. Suppress gen-mismatch panic in this window. s_last_go_tic = (int)I_GetTime(); lprintf(LO_INFO, "net: SETUP players=%d slot=%d\n", np, you); return true; } -// Apply a PKT_GO payload. Saves rngseed and (when called mid-game) requests -// a level restart so every Pixie converges at gametic=0. 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; // always 0 in v2 + (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); - // Reset per-slot tic state. The relay's tic numbering restarts at 0 - // every GO; we follow. 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)); - // Reset the engine's per-tic counters to 0 so our maketic / gametic - // align with the relay's tic 0. _g->gametic = 0; _g->maketic = 0; _g->lastmadetic = I_GetTime(); @@ -154,16 +236,9 @@ static void apply_go(const uint8_t *payload, bool first_time, uint8_t gen) { s_net_active = true; s_last_go_tic = (int)I_GetTime(); - // Queue a level reload. On the next G_Ticker, ga_newgame triggers - // G_DoNewGame → G_InitNew → P_SetupLevel, which respawns every active - // player from playeringame[] and reseeds M_Random via net_rngseed. G_DeferedInitNew(sk_medium, 1, 1); } -// ---------------------------- handshake ------------------------------------- - -// Wait up to ms_total ms for any packet. Returns 0 = nothing, 1 = packet -// dispatched (caller can re-check state), -1 = error. static int recv_one(int ms_total, packet_header_t *out, size_t outlen, size_t *out_n) { int waited = 0; @@ -176,8 +251,6 @@ static int recv_one(int ms_total, packet_header_t *out, size_t outlen, return 0; } -// Bootstrap: send PKT_INIT every retry_ms until SETUP arrives. Then wait for -// GO. No bounded timeout — Pixie keeps trying until the relay answers. bool net_init(int retry_ms) { I_InitNetwork(); @@ -188,7 +261,6 @@ bool net_init(int retry_ms) { packet_header_t *h = (packet_header_t *)buf; size_t n; - // Phase 1: blast PKT_INIT, wait for PKT_SETUP. lprintf(LO_INFO, "net: bootstrap — waiting for relay (retry every %d ms)\n", retry_ms); while (1) { @@ -203,12 +275,9 @@ bool net_init(int retry_ms) { } wait_ms -= 50; } - // No SETUP within retry_ms — send another INIT and keep waiting. } got_setup: - // Phase 2: wait for PKT_GO. Relay sends it immediately on first INIT, - // so this is normally <50 ms. lprintf(LO_INFO, "net: SETUP received; awaiting PKT_GO\n"); int wait_ms = 15000; while (wait_ms > 0) { @@ -219,8 +288,6 @@ bool net_init(int retry_ms) { return true; } if (h->type == PKT_SETUP) { - // The relay re-sent SETUP (another joiner came in faster - // than expected). Re-apply and keep waiting for GO. apply_setup(buf + sizeof(*h), n - sizeof(*h)); } } @@ -230,15 +297,6 @@ bool net_init(int retry_ms) { return false; } -// ---------------------------- 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; } - -// Pump any pending packets. Called at top of TryRunTics. Non-blocking. void net_pump(void) { if (!s_net_active) return; @@ -250,22 +308,16 @@ void net_pump(void) { if (n == 0) break; if (!packet_ok(h, n)) continue; - // ----- roster updates / restarts ----- 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); - // After a restart-GO, ignore the rest of this packet batch — - // they're stale ticcmds from the previous generation. return; } if (h->type == PKT_QUIT) { - // Relay kicked us (only happens on lobby-full reject or - // operator quit). Drop to solo so the device keeps showing - // Doom locally — user can power-cycle to retry. lprintf(LO_INFO, "net: PKT_QUIT received; dropping to solo\n"); s_net_active = false; s_started = false; @@ -277,26 +329,14 @@ void net_pump(void) { if (h->type != PKT_TICS) continue; - // Generation mismatch on TICS means we missed a SETUP+GO restart - // — OR we just *applied* a fresh GO and are seeing a stale TICS - // packet from the previous generation that was already in flight. - // The second case is common (the relay broadcasts SETUP+GO right - // after sending the last batch of pre-restart TICS), and a naive - // re-INIT here would kick off an oscillation: re-INIT → relay - // restart → new TICS that may also race against another in-flight - // stale TICS → re-INIT again → ... - // - // Quiet the case where we just applied a GO. The relay's tic_buf - // is reset on every restart, so any stale TICS will stop arriving - // within a few RTTs. 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 of grace after SETUP or GO - continue; // silently drop stale TICS + if (since_go < 175) { // 5 s grace + continue; } static int last_reinit_t = -100000; - if (now_t - last_reinit_t > 35) { // throttle 1/s + 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); @@ -305,7 +345,6 @@ void net_pump(void) { continue; } - // ----- PKT_TICS ----- if (n < sizeof(*h) + 5) continue; uint8_t *p = buf + sizeof(*h); uint8_t numtics = p[0]; @@ -330,67 +369,309 @@ void net_pump(void) { } } -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; +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; + +// 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, bump the generation and request a level restart. +static void recompute_roster(const char *reason) { + // 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 (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. Two peers with the same roster always end up at the same + // generation, so PKT_TICC gen checks succeed across peers without + // requiring counter synchronization. Generation byte is the low 8 + // bits of the rngseed. + uint8_t seed_input[MAX_PEERS * MAC_LEN]; + 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; + } + 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\n", + reason, new_num, new_slot, s_generation, seed); + + // 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 or refresh a peer in the roster. Returns true if the roster +// composition (set of MACs) actually changed. +static bool roster_upsert(const uint8_t mac[MAC_LEN]) { + 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) { + 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; } -void net_get_tic(int p, int t, ticcmd_t *out) { - *out = s_netcmds_ring[p][t & TIC_MASK]; +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--; } -void net_record_local_tic(int tic, const ticcmd_t *cmd) { - s_localcmds[tic & TIC_MASK] = *cmd; - // Pre-fill our own slot so we can advance without waiting on the - // server's echo. - s_netcmds_ring[s_consoleplayer][tic & TIC_MASK] = *cmd; - if (tic > s_have_tic[s_consoleplayer]) { - s_have_tic[s_consoleplayer] = tic; +static void send_hello(void) { + // Payload: my_mac (6) || roster_count (1) || sorted_macs (count*6) + uint8_t pay[1 + MAC_LEN + 1 + MAX_PEERS * MAC_LEN]; + size_t off = 0; + pay[off++] = (uint8_t)1; // payload version + memcpy(pay + off, s_my_mac, MAC_LEN); off += MAC_LEN; + 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(); } -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; +// Handle a received PKT_HELLO. Adds the sender to our roster (and any +// peers it told us about) and re-runs roster computation if anything +// changed. +static void handle_hello(const uint8_t *payload, size_t paylen) { + if (paylen < 1 + MAC_LEN + 1) return; + uint8_t ver = payload[0]; + if (ver != 1) return; + const uint8_t *sender_mac = payload + 1; + int peer_count = payload[1 + MAC_LEN]; + if (paylen < 1 + MAC_LEN + 1 + (size_t)peer_count * MAC_LEN) return; + const uint8_t *peer_macs = payload + 1 + MAC_LEN + 1; + + bool changed = roster_upsert(sender_mac); + + // Also add any peers the sender knows about — gossip convergence. + 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)) changed = true; } - uint8_t pay[1 + 4 + MAX_TICS_PER_PKT_TICC * sizeof(ticcmd_t)]; - 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); - for (int i = 0; i < count; i++) { - TicToRaw(pay + 5 + i * sizeof(ticcmd_t), - &s_localcmds[(first + i) & TIC_MASK]); + if (changed) { + 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); } - send_simple(PKT_TICC, (unsigned)up_to_tic, pay, - 5 + (size_t)count * sizeof(ticcmd_t)); - s_last_sent_tic = up_to_tic; } -// Ask the relay to restart the world (new rngseed, fresh roster snapshot). -// Implemented as a PKT_INIT: the relay treats it as a same-IP re-join and -// broadcasts SETUP+GO to everyone. -void net_request_restart(void) { +// 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; + recompute_roster("boot"); + + // 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; - send_pkt_init(); + + // 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 >15 s is dropped. + 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 > 525) { // ~15 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"); + + 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 + + // 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_retrans(int wanttic) { +void net_request_restart(void) { + // In mesh mode, "request restart" just bumps our own generation and + // rebuilds local state. Every other Pixie observing the same state + // (e.g. an all-dead detection that fires deterministically across all + // peers) will do the same independently and converge. 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)); + recompute_roster("explicit restart request"); } + +#endif // DOOM_NETPLAY_MESH diff --git a/main/doom/i_network.c b/main/doom/i_network.c index cf169de..89c1f2c 100644 --- a/main/doom/i_network.c +++ b/main/doom/i_network.c @@ -6,6 +6,12 @@ // 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 @@ -27,8 +33,8 @@ struct sockaddr sentfrom; int v4socket = -1; int v6socket = -1; -// Cached server endpoint (resolved once in I_InitNetwork). -static struct sockaddr_in s_server_addr; +// 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; @@ -45,6 +51,38 @@ void I_InitNetwork(void) { 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; @@ -56,10 +94,10 @@ void I_InitNetwork(void) { } } - memset(&s_server_addr, 0, sizeof(s_server_addr)); - s_server_addr.sin_family = AF_INET; - s_server_addr.sin_port = htons(DOOM_SERVER_PORT); - if (inet_pton(AF_INET, DOOM_SERVER_IP, &s_server_addr.sin_addr) != 1) { + 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; @@ -68,8 +106,9 @@ void I_InitNetwork(void) { v4socket = s_sock; s_inited = true; - printf("[net] socket up, server=%s:%d, fd=%d\n", + 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) { @@ -91,7 +130,7 @@ 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_server_addr, sizeof(s_server_addr)); + (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; diff --git a/main/doom/protocol.h b/main/doom/protocol.h index ab41fbf..de1395b 100644 --- a/main/doom/protocol.h +++ b/main/doom/protocol.h @@ -35,19 +35,21 @@ #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 */ diff --git a/main/net-config.h b/main/net-config.h index 44b8d11..4853e16 100644 --- a/main/net-config.h +++ b/main/net-config.h @@ -1,14 +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 5029 +#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 +#define DOOM_CLIENT_PORT 0 +#endif #endif From 93a737cc1e9f8e3b00b7ee75015a3aeab6908d2c Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 17 May 2026 02:44:18 +0200 Subject: [PATCH 16/23] restart epoch fixes last-marine-standing and peer-reboot stall --- main/doom/d_net.c | 129 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 100 insertions(+), 29 deletions(-) diff --git a/main/doom/d_net.c b/main/doom/d_net.c index d66de92..60c3b45 100644 --- a/main/doom/d_net.c +++ b/main/doom/d_net.c @@ -392,6 +392,12 @@ 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; @@ -407,8 +413,11 @@ static int mac_cmp(const void *a, const void *b) { } // Recompute slot/numplayers/rngseed from the current roster. If anything -// changed, bump the generation and request a level restart. -static void recompute_roster(const char *reason) { +// 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); @@ -425,7 +434,7 @@ static void recompute_roster(const char *reason) { } int new_num = s_roster_count; - if (new_num == s_numplayers && new_slot == s_consoleplayer) { + if (!force && new_num == s_numplayers && new_slot == s_consoleplayer) { // Roster didn't change in a way that matters to us. return; } @@ -439,23 +448,30 @@ static void recompute_roster(const char *reason) { } // Generation + rngseed are deterministic functions of the sorted - // roster. Two peers with the same roster always end up at the same - // generation, so PKT_TICC gen checks succeed across peers without - // requiring counter synchronization. Generation byte is the low 8 - // bits of the rngseed. - uint8_t seed_input[MAX_PEERS * MAC_LEN]; + // 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\n", - reason, new_num, new_slot, s_generation, 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; } @@ -497,11 +513,16 @@ static void roster_remove_at(int idx) { } static void send_hello(void) { - // Payload: my_mac (6) || roster_count (1) || sorted_macs (count*6) - uint8_t pay[1 + MAC_LEN + 1 + MAX_PEERS * MAC_LEN]; + // 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)1; // payload version + 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); @@ -512,16 +533,62 @@ static void send_hello(void) { } // Handle a received PKT_HELLO. Adds the sender to our roster (and any -// peers it told us about) and re-runs roster computation if anything -// changed. +// 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 + 1) return; + if (paylen < 1 + MAC_LEN + 4 + 1) return; uint8_t ver = payload[0]; - if (ver != 1) return; + if (ver != 2) return; // ignore older firmware silently const uint8_t *sender_mac = payload + 1; - int peer_count = payload[1 + MAC_LEN]; - if (paylen < 1 + MAC_LEN + 1 + (size_t)peer_count * MAC_LEN) return; - const uint8_t *peer_macs = payload + 1 + MAC_LEN + 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); @@ -533,13 +600,13 @@ static void handle_hello(const uint8_t *payload, size_t paylen) { if (roster_upsert(m)) changed = true; } - if (changed) { + 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); + recompute_roster(mac_str, force); } } @@ -572,7 +639,8 @@ bool net_init(int retry_ms) { s_numplayers = 0; // force recompute_roster to fire s_consoleplayer = -1; - recompute_roster("boot"); + s_restart_epoch = 0; + recompute_roster("boot", false); // Initial HELLO so peers learn about us immediately. send_hello(); @@ -603,7 +671,7 @@ void net_pump(void) { changed = true; } } - if (changed) recompute_roster("peer timeout"); + if (changed) recompute_roster("peer timeout", false); uint8_t buf[1024]; packet_header_t *h = (packet_header_t *)buf; @@ -666,12 +734,15 @@ void net_pump(void) { } void net_request_restart(void) { - // In mesh mode, "request restart" just bumps our own generation and - // rebuilds local state. Every other Pixie observing the same state - // (e.g. an all-dead detection that fires deterministically across all - // peers) will do the same independently and converge. + // 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; - recompute_roster("explicit restart request"); + s_restart_epoch++; + recompute_roster("explicit restart request", true); } #endif // DOOM_NETPLAY_MESH From e478279351b6badb58fe32224f4ede6786bf4373 Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 17 May 2026 02:50:41 +0200 Subject: [PATCH 17/23] liveness on direct evidence only, 5s timeout, drop gossip ghosts --- main/doom/d_net.c | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/main/doom/d_net.c b/main/doom/d_net.c index 60c3b45..9a548bf 100644 --- a/main/doom/d_net.c +++ b/main/doom/d_net.c @@ -488,13 +488,18 @@ static void recompute_roster(const char *reason, bool force) { G_DeferedInitNew(sk_medium, 1, 1); } -// Insert or refresh a peer in the roster. Returns true if the roster -// composition (set of MACs) actually changed. -static bool roster_upsert(const uint8_t mac[MAC_LEN]) { +// 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) { - s_roster[i].last_seen_tic = now_t; + if (refresh) s_roster[i].last_seen_tic = now_t; return false; } } @@ -590,14 +595,15 @@ static void handle_hello(const uint8_t *payload, size_t paylen) { force = true; } - bool changed = roster_upsert(sender_mac); + 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)) changed = true; + if (roster_upsert(m, /*refresh=*/false)) changed = true; } if (changed || force) { @@ -658,11 +664,13 @@ void net_pump(void) { send_hello(); } - // Liveness sweep: any peer (not us) silent for >15 s is dropped. + // 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 > 525) { // ~15 s + 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], @@ -704,6 +712,13 @@ void net_pump(void) { 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). From c49f5c1c646184b43378d83ad3988f002f2ce724 Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 17 May 2026 03:02:14 +0200 Subject: [PATCH 18/23] docs: README mesh-default, parallel-flash gotcha, drop relay IP step --- README.md | 186 +++++++++++++++++++++++++++++------------------------- 1 file changed, 101 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 232d2d4..d4143a7 100644 --- a/README.md +++ b/README.md @@ -61,100 +61,115 @@ Multiplayer (WiFi co-op) ------------------------ This branch (`feat/multiplayer`) adds lockstep co-op over WiFi for up -to 8 Pixies. Each Pixie joins a tiny Python UDP relay running on any -machine on the same network — phone, laptop, or a Raspberry Pi. -The relay itself does no game logic; it just collects each Pixie's -ticcmds and broadcasts them so every device runs the same -deterministic simulation. +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. When only one player -is left alive, the round ends with a "PLAYER N WINS!" banner and the -relay restarts everyone after 10 seconds. Solo deaths trigger -"GAME OVER" with the same 10-second restart. +clean restart at E1M1 with a fresh RNG seed. 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 -- Any machine on the same WiFi running Python 3 (laptop, phone with - Termux, RPi, etc.) — this hosts the relay -- WiFi credentials that all Pixies and the relay machine can use +- 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 -1. **Pick your relay machine's IP.** On the relay machine, find its - local IP on the WiFi: - ``` - ifconfig | grep "inet " # macOS / Linux - ipconfig # Windows - ``` - -2. **Configure each Pixie's firmware.** Before building/flashing, fill - in the gitignored credentials header: - - ```bash - cp main/wifi-creds.h.example main/wifi-creds.h - $EDITOR main/wifi-creds.h # WIFI_SSID, WIFI_PASSWORD - $EDITOR main/net-config.h # DOOM_SERVER_IP = relay's IP - ``` - -3. **Build and flash each Pixie:** - ```bash - . $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. - -### Running a session - -1. **Start the relay on the host machine:** - ```bash - python3 tools/relay.py - ``` - It 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 showing tic flow per slot: - ``` - [relay] gen=3 released=5891 frontier={0: 5892, 1: 5891, 2: 5891} ... - ``` - -2. **Power on the Pixies.** Each one will WiFi-associate, send a - `PKT_INIT` to the relay, get a slot assignment, and drop straight - into E1M1. - -3. **Join late** by powering on more Pixies whenever — the relay - broadcasts a fresh `SETUP+GO` to everyone, so the existing players - restart together with the new joiner. - -4. **Leave any time** by powering off a Pixie. The relay times the - slot out after 15 seconds of silence and the survivors restart - without that player. +```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 -- **Pixie shows "Waiting for server…" forever:** 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, - guest networks, and laptops can change IP between sessions. -- **Game freezes after a player dies:** the round-over detection - should kick in after 10 seconds (banner + auto-restart). If it - doesn't, the engine has hit the known intermittent stall — power- - cycle one Pixie and the relay will reissue `SETUP+GO` to the rest. -- **"Client isolation" on guest WiFi:** some networks block - client-to-client UDP. The relay still receives Pixie packets but - Pixies can't receive replies. Use a personal hotspot or a router - without isolation. -- **Two Pixies behind one NAT'd IP:** the relay distinguishes them - by source port, so it works, but the "same-IP rejoin" optimization - may falsely free the *other* Pixie's slot on a fresh INIT. Direct - network paths (laptop hotspot, regular WiFi without NAT) avoid - this entirely. - -The protocol details and on-device state machine live in -`main/doom/d_net.c` and `tools/relay.py`. Both ends carry an 8-bit -generation byte so a Pixie that misses a `SETUP+GO` restart -auto-recovers by re-sending `PKT_INIT`. +- **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 after a player dies:** the round-over banner + + 10-second auto-restart should fire. If it doesn't, the engine has + hit the known intermittent silent-stall bug — power-cycle one + Pixie and the others will re-converge (mesh) or the relay will + reissue `SETUP+GO` (relay). + +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 @@ -164,10 +179,11 @@ To Do - more visible menu items (bigger? different color? dim/blur the background?) - click forward twice to begin running - multiplayer: fix the silent-stall bug (engine hangs with no I_Error - on long multi-player sessions; recovery currently requires a - power-cycle) + on long sessions; recovery currently requires a power-cycle of one + Pixie, after which mesh re-convergence or relay `SETUP+GO` restores + the rest) - multiplayer: deathmatch flag in `setup_packet_s` so the relay can - pick co-op vs DM + pick co-op vs DM (mesh equivalent: gametype byte in HELLO payload) Credits From bb5e71b06b13f5292011d42d3a9e1286812d4ee4 Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 17 May 2026 03:04:25 +0200 Subject: [PATCH 19/23] =?UTF-8?q?mp:=20color=20marines=20by=20slot=20?= =?UTF-8?q?=E2=80=94=20green/indigo/brown/red,=20wraps=20at=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/doom/p_mobj.c | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/main/doom/p_mobj.c b/main/doom/p_mobj.c index c53a3c2..c072492 100644 --- a/main/doom/p_mobj.c +++ b/main/doom/p_mobj.c @@ -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; From a109081eb7bc5aad018910b6e637f9b445153900 Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 17 May 2026 03:19:03 +0200 Subject: [PATCH 20/23] relay: 5s timeout for active marines, 15s grace for cold-start joiners --- tools/relay.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tools/relay.py b/tools/relay.py index f59510f..636c95f 100755 --- a/tools/relay.py +++ b/tools/relay.py @@ -39,11 +39,16 @@ TICCMD_LEN = 8 # see d_ticcmd.h MAX_PLAYERS = 8 TICDUP_REDUNDANCY = 8 # tics resent on every PKT_TICS -PLAYER_TIMEOUT_S = 15.0 # seconds of silence before kick. - # Generous: a Pixie's boot path between - # PKT_INIT and its first PKT_TICC can - # exceed 5 s on a cold start (W_Init, - # R_Init, P_Init, etc.). +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 = { @@ -87,6 +92,7 @@ def __init__(self, max_players: int): 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 @@ -141,6 +147,7 @@ def _free_slot(self, slot: int, reason: str): del self.slot_addr[slot] del self.players[addr] self.last_seen.pop(slot, None) + self.has_run.pop(slot, None) # ------------- restart ------------- @@ -226,6 +233,7 @@ def handle_init(self, addr, payload): 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) @@ -236,6 +244,7 @@ def handle_ticc(self, addr, payload): return slot = self.players[addr] self.last_seen[slot] = time.monotonic() + self.has_run[slot] = True if len(payload) < 5: return @@ -322,7 +331,8 @@ async def liveness_loop(self): now = time.monotonic() stale = [ slot for slot, t in self.last_seen.items() - if now - t > PLAYER_TIMEOUT_S + if now - t > (PLAYER_TIMEOUT_S_RUN if self.has_run.get(slot) + else PLAYER_TIMEOUT_S_INIT) ] if stale: for slot in stale: From 680a0add3da43854106f3527eb33932190f2d57a Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 17 May 2026 03:22:47 +0200 Subject: [PATCH 21/23] relay: reset has_run/last_seen on restart so survivors get fresh grace --- tools/relay.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tools/relay.py b/tools/relay.py index 636c95f..9a9a979 100755 --- a/tools/relay.py +++ b/tools/relay.py @@ -167,6 +167,15 @@ def restart_game(self, reason: str): 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) From e332efb8ec37ae8e3b49fbdb82896ccbadd44dfc Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 17 May 2026 03:26:00 +0200 Subject: [PATCH 22/23] =?UTF-8?q?relay:=20RETRANS=20counts=20as=20heartbea?= =?UTF-8?q?t=20=E2=80=94=20stalled=20survivors=20stop=20sending=20TICCs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/relay.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tools/relay.py b/tools/relay.py index 9a9a979..5e39fce 100755 --- a/tools/relay.py +++ b/tools/relay.py @@ -321,6 +321,15 @@ def broadcast_ticset(self): 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) From 9c82e7777ce781fd9155c1d02d9a2c1e1c695ea6 Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 17 May 2026 03:47:07 +0200 Subject: [PATCH 23/23] =?UTF-8?q?docs=20and=20.gitignore=20polish=20for=20?= =?UTF-8?q?PR=20=E2=80=94=20drop=20branch=20ref,=20mention=20player=20colo?= =?UTF-8?q?rs,=20cache=20dirs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++++ README.md | 39 ++++++++++++++++++++------------------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 83d8107..7d5284d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ 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 d4143a7..ef37b77 100644 --- a/README.md +++ b/README.md @@ -60,16 +60,18 @@ required. The device is played sideways (with the buttons down): Multiplayer (WiFi co-op) ------------------------ -This branch (`feat/multiplayer`) adds 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. +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. 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. +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 @@ -160,11 +162,11 @@ status line: 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 after a player dies:** the round-over banner + - 10-second auto-restart should fire. If it doesn't, the engine has - hit the known intermittent silent-stall bug — power-cycle one - Pixie and the others will re-converge (mesh) or the relay will - reissue `SETUP+GO` (relay). +- **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 @@ -178,12 +180,11 @@ To Do - save/load - more visible menu items (bigger? different color? dim/blur the background?) - click forward twice to begin running -- multiplayer: fix the silent-stall bug (engine hangs with no I_Error - on long sessions; recovery currently requires a power-cycle of one - Pixie, after which mesh re-convergence or relay `SETUP+GO` restores - the rest) -- multiplayer: deathmatch flag in `setup_packet_s` so the relay can - pick co-op vs DM (mesh equivalent: gametype byte in HELLO payload) +- 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