Skip to content

Fix bot stack overflow on a blocked safezone spawn gate - #915

Merged
sven-n merged 2 commits into
MUnique:masterfrom
facuc28:fix/bot-blocked-spawn-gate-overflow
Aug 29, 2026
Merged

sven-n merged 2 commits into
MUnique:masterfrom
facuc28:fix/bot-blocked-spawn-gate-overflow

Conversation

@facuc28

@facuc28 facuc28 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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 BotNavigator filter idea is theirs and is
kept here.

The crash is still reachable on current master

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 recently added 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 the guard. The added test reproduces the stack overflow against
master as it stands today:

   at MUnique.OpenMU.GameLogic.PlayerMapTransitions.ClientReadyAfterMapChangeAsync()
   at MUnique.OpenMU.GameLogic.Player.ClientReadyAfterMapChangeAsync()
   at MUnique.OpenMU.GameLogic.Offline.OfflineMapChangePlugIn.MapChangeAsync()
   at MUnique.OpenMU.GameLogic.PlayerMapTransitions.WarpToAsync()
   ... (repeats until the test host aborts)

The fix

Bound the recursion where it happens. PlayerMapTransitions.RecoverFromBlockedSpawnAsync
warps 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: it
samples the monster spawn points, which exclude every safezone tile by construction
(BuildSpawnPoints requires WalkMap[x,y] && !SafezoneMap[x,y]). It would drop a player who is
being 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 BotNavigator filter, with two corrections:

  1. Resolve SafezoneMap first, mirroring GetSpawnGateOfCurrentMapAsync. A bot is not
    recovered 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.
  2. Key the cache by the resolved map's id, not its number. Map numbers are not unique — the
    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.GetId falls back to Guid.Empty, and every such
    map 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. Its
loop counters are int on purpose: a gate reaching coordinate 255 would make a byte counter
wrap 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 — a
quality 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.BlockedSpawnGateRecoversWithoutRecursingAsync blocks a map's
    spawn gate, warps an offline-leveling player into it, and asserts the bot ends on a tile it can
    stand on. Against master this overflows the stack and aborts the test run.
  • BotWarpCandidateTest covers the navigator filter, including the safezone resolution and the
    id-keyed cache.
  • GameMapTerrainTests covers the new lookup: it finds a tile on a safezone-only map (where
    RandomWalkableCoordinate returns 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 null only when nothing at all is walkable.

Full MUnique.OpenMU.Tests suite passes (795/795).

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>
@facuc28
facuc28 force-pushed the fix/bot-blocked-spawn-gate-overflow branch from e0f544b to db6fabd Compare August 29, 2026 16:19

sven-n commented Aug 29, 2026

Copy link
Copy Markdown
Member

Code review

One finding:

src/GameLogic/GameMapTerrain.cs:190 — duplicate XML doc block on BuildSpawnPoints

The old XML documentation block was left in place above the new one, so the method now carries two <summary> and two <returns> tags. With documentation generation enabled this produces duplicate-tag warnings (CS1571/CS1710) — a build break under warnings-as-errors — and the stale block no longer describes the new out parameter. Removing the outdated block resolves both.

Everything else in the diff read correctly: the recursion guard in PlayerMapTransitions.RecoverFromBlockedSpawnAsync (with its flag reset in finally), the Guid.Empty cache bypass in BotNavigator.HasWalkableSpawnGate, and the loop counters in GetWalkableCoordinate.


Generated by Claude Code

The previous documentation block was left above the new one, giving the
method duplicate <summary>/<returns> tags.
@sven-n
sven-n merged commit e75ba49 into MUnique:master Aug 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants