Fix bot stack overflow on a blocked safezone spawn gate - #915
Conversation
A player placed on a non-walkable tile is recovered by WarpToSafezoneAsync, which re-enters ClientReadyAfterMapChangeAsync. A player with a game client re-enters later and on a fresh stack, with its F3 12 packet - but a connection-less player (an OfflinePlayer, i.e. a bot) gets it inline from OfflineMapChangePlugIn. Since PlaceAtGateAsync rolls the position within the gate once and never retries, a safezone spawn gate without a single walkable tile recurses until the stack overflows, taking the game server process down. The repeated-packet guard at the top of ClientReadyAfterMapChangeAsync does not stop this: WarpToAsync sets CurrentMap to null before raising the map change, so the nested call walks straight past it. PlayerMapTransitions.RecoverFromBlockedSpawnAsync now warps at most once per re-entry: a nested attempt places the player on a walkable tile of the map it already stands on. That bounds the recursion on every path into it - the bot navigator, escape and home gates, death respawn, portals, mini-games and GM warps alike. It returns whether it warped, so the outer client-ready frame keeps its existing behaviour of not adding the summon a second time. A player with a game client re-enters on a later, fresh stack with the flag already reset, so it is not bounded by this - it can still be warped back and forth between two blocked gates, exactly as before, which is a stuck client rather than a dead server. The tile it falls back to comes from the new GameMapTerrain.GetAnyWalkableCoordinate, which prefers the safezone. RandomWalkableCoordinate would have been wrong here: it samples the monster spawn points, which exclude every safezone tile by construction, so it would drop a player who is being recovered into a hunting ground - and return nothing at all on a map which is only safezone, stranding the player on the very tile it was meant to rescue it from. On top of that, the bot navigator no longer offers a bot a map it cannot stand in, so it does not pick one and get bounced straight back out. This is the idea of MUnique#844, with two corrections: the check resolves SafezoneMap first, mirroring GetSpawnGateOfCurrentMapAsync, because dungeons and event maps (Icarus, Karutan 2, the Chaos Castles, ...) are recovered to a different map than the one being entered; and the cache is keyed by the resolved map's id rather than its number, which is not unique - the Devil Squares all share number 9. The verdict is not cached when the map has no id of its own, because GetId falls back to Guid.Empty and every such map would share one entry. The gate scan lives on GameMapTerrain, shared by both. Its loop counters are ints on purpose: a gate reaching coordinate 255 would make a byte counter wrap around and loop forever. No map in the seeded Season 6 configuration has a blocked safezone gate today (the tightest is Doppelgaenger 4 at 39 of 64 tiles), so this is reachable only through custom maps or edited terrain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e0f544b to
db6fabd
Compare
Code reviewOne finding:
The old XML documentation block was left in place above the new one, so the method now carries two Everything else in the diff read correctly: the recursion guard in Generated by Claude Code |
The previous documentation block was left above the new one, giving the method duplicate <summary>/<returns> tags.
This is the same crash #844 reports, fixed at its source and with two corrections to that PR's
approach. Credit to @therpr for finding it — the
BotNavigatorfilter idea is theirs and iskept here.
The crash is still reachable on current master
A player placed on a non-walkable tile is recovered by
WarpToSafezoneAsync, which re-entersClientReadyAfterMapChangeAsync. A player with a game client re-enters later and on a freshstack, with its F3 12 packet — but a connection-less player (an
OfflinePlayer, i.e. a bot)gets it inline from
OfflineMapChangePlugIn. SincePlaceAtGateAsyncrolls the position withinthe gate once and never retries, a safezone spawn gate without a single walkable tile recurses
until the stack overflows, taking the game server process down.
The repeated-packet guard recently added at the top of
ClientReadyAfterMapChangeAsyncdoes notstop this:
WarpToAsyncsetsCurrentMapto null before raising the map change, so the nestedcall walks straight past the guard. The added test reproduces the stack overflow against
masteras it stands today:The fix
Bound the recursion where it happens.
PlayerMapTransitions.RecoverFromBlockedSpawnAsyncwarps at most once per re-entry; a nested attempt places the player on a walkable tile of the map
it already stands on instead. This covers every path into the recursion — the bot navigator,
escape and home gates, death respawn, portals, mini-games and GM warps alike — rather than only
the one the bots happen to take. It returns whether it warped, so the client-ready frame keeps
its existing behaviour of not adding the summon a second time.
To be precise about the scope: this bounds the inline re-entry, which is the one that kills the
process. A player with a game client re-enters on a later, fresh stack with the flag already
reset, so it can still be warped back and forth between two blocked gates — exactly as today.
That is a stuck client rather than a dead server, and fixing it is a separate change.
The tile the nested attempt falls back to comes from a new
GameMapTerrain.AnyWalkableCoordinate,which prefers the safezone. It is computed in the terrain scan that already builds the spawn
points, so it costs nothing at lookup time. The obvious candidate,
RandomWalkableCoordinate, is wrong here: itsamples the monster spawn points, which exclude every safezone tile by construction
(
BuildSpawnPointsrequiresWalkMap[x,y] && !SafezoneMap[x,y]). It would drop a player who isbeing recovered into a hunting ground, and return nothing at all on a map that is only safezone —
stranding the player on the very tile it was meant to rescue it from.
Keep bots out of maps they cannot stand in, so a bot does not pick one and get bounced
straight back out. This is #844's
BotNavigatorfilter, with two corrections:SafezoneMapfirst, mirroringGetSpawnGateOfCurrentMapAsync. A bot is notrecovered to the destination map's own gate but to its safezone map's, and many maps
(dungeons and event maps — Icarus, Karutan 2, the Chaos Castles) point somewhere else
entirely. Checking the destination itself both rejects safe maps and misses the ones that
actually strand a bot.
Devil Squares all share number 9 — and a server can host several game configurations whose
maps carry the same numbers over different terrain. The verdict is not cached at all when the
map has no id of its own:
ObjectExtensions.GetIdfalls back toGuid.Empty, and every suchmap would otherwise share a single entry, so one blocked map would reject all of them.
The gate scan itself lives on
GameMapTerrain.GetWalkableCoordinate, shared by both halves. Itsloop counters are
inton purpose: a gate reaching coordinate 255 would make a byte counterwrap around and loop forever.
Only maps whose spawn gate has zero walkable tiles are excluded. A gate with a few walkable
tiles can still, under
PlaceAtGateAsync's single random roll, miss and recover to town — aquality glitch, not a crash, accepted by design.
Scope
No map in the seeded Season 6 configuration has a blocked safezone gate today (the tightest is
Doppelgaenger 4 at 39 of 64 tiles), so this is reachable only through custom maps or edited
terrain. It is a process-killer when it is reached.
Tests
ClientReadyAfterMapChangeTests.BlockedSpawnGateRecoversWithoutRecursingAsyncblocks a map'sspawn gate, warps an offline-leveling player into it, and asserts the bot ends on a tile it can
stand on. Against
masterthis overflows the stack and aborts the test run.BotWarpCandidateTestcovers the navigator filter, including the safezone resolution and theid-keyed cache.
GameMapTerrainTestscovers the new lookup: it finds a tile on a safezone-only map (whereRandomWalkableCoordinatereturns nothing — the test asserts that as its precondition),prefers a safezone tile over an ordinary walkable one that comes earlier in scan order, and
returns
nullonly when nothing at all is walkable.Full
MUnique.OpenMU.Testssuite passes (795/795).