fix(streaming): recover the player from the pending bucket — invisible-player / world-not-loading bug
Root cause (confirmed live via ACDREAM_PROBE_ENT): a persistent server-spawned
entity that spawns into a not-yet-loaded landblock is parked in
GpuWorldState._pendingByLandblock. RelocateEntity — called every frame to keep
the player homed to its current landblock so it draws — scanned ONLY _loaded, so
it silently no-op'd on a pending entity. The player then fell through ALL of the
recovery paths: the AddLandblock pending-drain had already run (empty) before the
cold-spawn churn re-parked the player; the server-object re-hydrate excludes the
player by design ("persistent-rescue owns it"); and RelocateEntity couldn't reach
a pending entity. Net: the player stayed stranded in pending, hidden forever.
Probe evidence (cold-spawn at 0xADAF): `[ent] APPEND guid=0x5000000A
lb=0xADAFFFFF -> PENDING(hidden)`, no later `DRAWSET PRESENT` — while the sibling
NPCs `re-hydrated 3 server object(s) into landblock 0xADAFFFFF` and drew fine.
This is the mechanism behind BOTH reported symptoms: cold-spawn "everything gone
/ invisible" AND "character disappears running out of Holtburg far enough" — both
are "player parked in pending, never recovered" (run-out crosses into a
still-streaming landblock; cold-spawn spawns into one).
Fix: RelocateEntity now removes the entity from whichever bucket it occupies
(_loaded OR _pendingByLandblock) via RemoveEntityFromAllBuckets, then re-appends
to its current landblock — promoting a stranded pending entity to drawn as soon
as its landblock is loaded. Keeps the fast-path early-return for a settled
entity (no per-frame churn).
NOT R5-V2: verified line-by-line that the voyeur/target wiring is read-only w.r.t.
position/cell/landblock/streaming — this is a pre-existing streaming bug. The
separate cold-spawn `cells=0` (outdoor spawn placed before Near-tier cells load,
no re-snap) is benign for outdoor placement (terrain-Z is used) and filed
separately; it does not block visibility, which this fix restores.
Test: GpuWorldStateTests.RelocateEntity_StrandedInPending_MovesToLoadedTarget
(red before, green after). Full suite 4007 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
fffe90b30a
commit
315af02f8a
2 changed files with 85 additions and 10 deletions
|
|
@ -255,7 +255,43 @@ public sealed class GpuWorldState
|
||||||
{
|
{
|
||||||
if (entity.ServerGuid == 0) return;
|
if (entity.ServerGuid == 0) return;
|
||||||
|
|
||||||
// Remove from current landblock (find it by scanning)
|
uint canonical = (newCanonicalLb & 0xFFFF0000u) | 0xFFFFu;
|
||||||
|
|
||||||
|
// Fast path: already drawn in the correct loaded bucket → nothing to do
|
||||||
|
// (avoids per-frame list churn for a settled, stationary entity).
|
||||||
|
if (_loaded.TryGetValue(canonical, out var target))
|
||||||
|
{
|
||||||
|
foreach (var e in target.Entities)
|
||||||
|
if (ReferenceEquals(e, entity)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the entity from wherever it currently lives — a loaded bucket
|
||||||
|
// OR a pending bucket — then re-append to its current landblock.
|
||||||
|
//
|
||||||
|
// Scanning _pendingByLandblock is the 2026-07-03 fix for the cold-spawn /
|
||||||
|
// run-out "invisible player" bug: a persistent (server-spawned) entity
|
||||||
|
// that spawned into a not-yet-loaded landblock sits in _pendingByLandblock,
|
||||||
|
// and the old code scanned ONLY _loaded — so it silently no-op'd and left
|
||||||
|
// the player stranded, hidden, even after its landblock finished loading
|
||||||
|
// (the AddLandblock pending-drain had already run empty before the churn
|
||||||
|
// re-parked the player, and the player is excluded from the server-object
|
||||||
|
// re-hydrate — so RelocateEntity was the ONLY path that could recover it,
|
||||||
|
// and it couldn't reach a pending entity). Re-appending routes the entity
|
||||||
|
// to _loaded (drawn) when its landblock is loaded, or back to pending to
|
||||||
|
// await AddLandblock otherwise.
|
||||||
|
RemoveEntityFromAllBuckets(entity);
|
||||||
|
AppendLiveEntity(canonical, entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove <paramref name="entity"/> (by reference) from whichever
|
||||||
|
/// <see cref="_loaded"/> or <see cref="_pendingByLandblock"/> bucket it
|
||||||
|
/// currently occupies. At most one bucket holds a given entity, so this
|
||||||
|
/// stops after the first hit. Called by <see cref="RelocateEntity"/> before
|
||||||
|
/// re-appending, so a stranded pending entity can be promoted.
|
||||||
|
/// </summary>
|
||||||
|
private void RemoveEntityFromAllBuckets(WorldEntity entity)
|
||||||
|
{
|
||||||
foreach (var kvp in _loaded)
|
foreach (var kvp in _loaded)
|
||||||
{
|
{
|
||||||
var entities = kvp.Value.Entities;
|
var entities = kvp.Value.Entities;
|
||||||
|
|
@ -263,23 +299,21 @@ public sealed class GpuWorldState
|
||||||
{
|
{
|
||||||
if (ReferenceEquals(entities[i], entity))
|
if (ReferenceEquals(entities[i], entity))
|
||||||
{
|
{
|
||||||
if (kvp.Key == newCanonicalLb) return; // already in the right place
|
|
||||||
|
|
||||||
// Remove from old
|
|
||||||
var newList = new List<WorldEntity>(entities.Count - 1);
|
var newList = new List<WorldEntity>(entities.Count - 1);
|
||||||
for (int j = 0; j < entities.Count; j++)
|
for (int j = 0; j < entities.Count; j++)
|
||||||
if (j != i) newList.Add(entities[j]);
|
if (j != i) newList.Add(entities[j]);
|
||||||
_loaded[kvp.Key] = new LoadedLandblock(
|
_loaded[kvp.Key] = new LoadedLandblock(
|
||||||
kvp.Value.LandblockId,
|
kvp.Value.LandblockId, kvp.Value.Heightmap, newList);
|
||||||
kvp.Value.Heightmap,
|
|
||||||
newList);
|
|
||||||
|
|
||||||
// Add to new (via AppendLiveEntity which handles pending)
|
|
||||||
AppendLiveEntity(newCanonicalLb, entity);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var kvp in _pendingByLandblock)
|
||||||
|
{
|
||||||
|
if (kvp.Value.Remove(entity))
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RemoveLandblock(uint landblockId)
|
public void RemoveLandblock(uint landblockId)
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,47 @@ public class GpuWorldStateTests
|
||||||
Assert.Equal(1, state.PendingLiveEntityCount); // 0xAAAAFFFF entry still parked
|
Assert.Equal(1, state.PendingLiveEntityCount); // 0xAAAAFFFF entry still parked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RelocateEntity_StrandedInPending_MovesToLoadedTarget()
|
||||||
|
{
|
||||||
|
// Regression: the cold-spawn / run-out "invisible player" bug
|
||||||
|
// (2026-07-03). A persistent (server-spawned) entity that spawned into a
|
||||||
|
// not-yet-loaded landblock is parked in the pending bucket. The per-frame
|
||||||
|
// RelocateEntity is supposed to keep it homed to its current landblock so
|
||||||
|
// it draws — but the old implementation scanned ONLY _loaded, so it
|
||||||
|
// silently no-op'd on a pending entity and the player stayed hidden
|
||||||
|
// forever. Confirmed live: "[ent] APPEND guid=0x5000000A -> PENDING(hidden)"
|
||||||
|
// with no later DRAWSET PRESENT, even after the landblock loaded (its
|
||||||
|
// sibling NPCs re-hydrated into it fine — the player was excluded).
|
||||||
|
var state = new GpuWorldState();
|
||||||
|
|
||||||
|
var player = new WorldEntity
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
ServerGuid = 0x5000000Au, // server-spawned + persistent
|
||||||
|
SourceGfxObjOrSetupId = 0x01000001u,
|
||||||
|
Position = System.Numerics.Vector3.Zero,
|
||||||
|
Rotation = System.Numerics.Quaternion.Identity,
|
||||||
|
MeshRefs = Array.Empty<MeshRef>(),
|
||||||
|
};
|
||||||
|
state.MarkPersistent(0x5000000Au);
|
||||||
|
|
||||||
|
// Spawned before its landblock streamed in → parked in pending, hidden.
|
||||||
|
state.AppendLiveEntity(0xADAF0011u, player);
|
||||||
|
Assert.Empty(state.Entities);
|
||||||
|
Assert.Equal(1, state.PendingLiveEntityCount);
|
||||||
|
|
||||||
|
// The player's current landblock IS loaded now (the live log shows the
|
||||||
|
// sibling NPCs re-hydrated into it). RelocateEntity — called every frame
|
||||||
|
// to keep the player homed to its current landblock — must promote the
|
||||||
|
// stranded pending entity into the loaded bucket so it draws.
|
||||||
|
state.AddLandblock(MakeStubLandblock(0xBBBBFFFFu));
|
||||||
|
state.RelocateEntity(player, 0xBBBB0011u);
|
||||||
|
|
||||||
|
Assert.Single(state.Entities); // now drawn
|
||||||
|
Assert.Equal(0, state.PendingLiveEntityCount); // no longer stranded
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RemoveLandblock_DropsPendingForThatLandblock()
|
public void RemoveLandblock_DropsPendingForThatLandblock()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue