Found while scoping issue #10 on feat/worldgen-sensed-tier, but this is upstream code and pre-existing — nothing to do with the sensed tier, which is why it is filed separately. Established by code reading only; see the caveat at the bottom.
Mechanism
SimpleFloorPlanPopulatorSystem.OnFloorPlanBuilt holds a GridTileEnumerator open across its whole tile loop:
var enumerator = _map.GetAllTilesEnumerator(uid, grid);
while (enumerator.MoveNext(out var tile))
{
...
Spawn(proto, coords); // <-- can be a RoomFill marker
}
GetAllTilesEnumerator is a thin wrapper over grid.Chunks.GetEnumerator(), a raw Dictionary<Vector2i, MapChunk>.Enumerator (SharedMapSystem.Grid.cs:808).
LocalStructureLoadedEvent is raised from LocalityLoaderSystem.Update, long after the grid is map-inited, so Spawn of a room marker initializes it immediately, RoomFillSystem.OnRoomFillMapInit runs synchronously, and DungeonSystem.SpawnRoom calls _maps.SetTiles — all while that enumerator is live.
Two consequences
1. Crash when the room reaches a chunk the blob never filled. Writing into existing chunks mutates MapChunk objects in place and the dictionary version is untouched, so the enumerator survives. But a new chunk goes through grid.Chunks[chunkIndex] = new MapChunk(...) (SharedMapSystem.Grid.cs:842), which bumps the version and makes the next MoveNext() throw InvalidOperationException: Collection was modified. Needs a marker within the room half-extent of a chunk boundary whose neighbour has no filled tiles.
2. Rooms can seed rooms, order-dependently. Room tiles written into a chunk the enumerator has not reached yet get populated in turn. On NFBaseWreckDebris the room marker is ungrouped at prob: 0.01 per plating/steel tile, so 25 fresh room tiles carry roughly a 1-in-5 chance of spawning another room. Whether it happens at all depends on chunk iteration order and where the write lands relative to the cursor, which makes debris population quietly non-reproducible even before any seeding is involved.
Exposure
RoomFill markers appear in the NF debris tables for all seven asteroid families (orGroup, prob: 0.0002, so roughly 4-6% of rocks) and for wrecks (ungrouped, prob: 0.01 per qualifying tile, so most wrecks and often more than one). Also used by non-worldgen dungeon generation, which may or may not hold a live enumerator the same way.
Fix
Materialize the tile list before the loop rather than walking a live enumerator. That closes the crash and makes room cascades impossible in one change. Note it is a small live behaviour change: room tiles would no longer receive populator rolls, which today they sometimes do.
Caveat
Not reproduced, and not confirmed against production. The prod Grafana credentials in .mcp.json return 401 on both the MCP and the HTTP-direct datasource proxy, so the Loki check for Collection was modified could not be run. Worth a look once those creds are refreshed before anyone spends time on a fix.
Found while scoping issue #10 on
feat/worldgen-sensed-tier, but this is upstream code and pre-existing — nothing to do with the sensed tier, which is why it is filed separately. Established by code reading only; see the caveat at the bottom.Mechanism
SimpleFloorPlanPopulatorSystem.OnFloorPlanBuiltholds aGridTileEnumeratoropen across its whole tile loop:GetAllTilesEnumeratoris a thin wrapper overgrid.Chunks.GetEnumerator(), a rawDictionary<Vector2i, MapChunk>.Enumerator(SharedMapSystem.Grid.cs:808).LocalStructureLoadedEventis raised fromLocalityLoaderSystem.Update, long after the grid is map-inited, soSpawnof a room marker initializes it immediately,RoomFillSystem.OnRoomFillMapInitruns synchronously, andDungeonSystem.SpawnRoomcalls_maps.SetTiles— all while that enumerator is live.Two consequences
1. Crash when the room reaches a chunk the blob never filled. Writing into existing chunks mutates
MapChunkobjects in place and the dictionary version is untouched, so the enumerator survives. But a new chunk goes throughgrid.Chunks[chunkIndex] = new MapChunk(...)(SharedMapSystem.Grid.cs:842), which bumps the version and makes the nextMoveNext()throwInvalidOperationException: Collection was modified. Needs a marker within the room half-extent of a chunk boundary whose neighbour has no filled tiles.2. Rooms can seed rooms, order-dependently. Room tiles written into a chunk the enumerator has not reached yet get populated in turn. On
NFBaseWreckDebristhe room marker is ungrouped atprob: 0.01per plating/steel tile, so 25 fresh room tiles carry roughly a 1-in-5 chance of spawning another room. Whether it happens at all depends on chunk iteration order and where the write lands relative to the cursor, which makes debris population quietly non-reproducible even before any seeding is involved.Exposure
RoomFillmarkers appear in the NF debris tables for all seven asteroid families (orGroup,prob: 0.0002, so roughly 4-6% of rocks) and for wrecks (ungrouped,prob: 0.01per qualifying tile, so most wrecks and often more than one). Also used by non-worldgen dungeon generation, which may or may not hold a live enumerator the same way.Fix
Materialize the tile list before the loop rather than walking a live enumerator. That closes the crash and makes room cascades impossible in one change. Note it is a small live behaviour change: room tiles would no longer receive populator rolls, which today they sometimes do.
Caveat
Not reproduced, and not confirmed against production. The prod Grafana credentials in
.mcp.jsonreturn 401 on both the MCP and the HTTP-direct datasource proxy, so the Loki check forCollection was modifiedcould not be run. Worth a look once those creds are refreshed before anyone spends time on a fix.