acdream/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs
Erik 315af02f8a 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>
2026-07-03 22:03:44 +02:00

228 lines
8.9 KiB
C#

using System;
using AcDream.App.Streaming;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using Xunit;
namespace AcDream.Core.Tests.Streaming;
/// <summary>
/// Covers <see cref="GpuWorldState"/>'s pending-spawn behavior — the path
/// that survives the race where the server delivers a CreateObject for a
/// landblock that hasn't been streamed in yet. This was the bug that
/// silently dropped 40+ NPCs on the first frame after live login during
/// Phase A.1 visual verification.
/// </summary>
public class GpuWorldStateTests
{
private static LoadedLandblock MakeStubLandblock(uint canonicalId)
=> new(canonicalId, new LandBlock(), Array.Empty<WorldEntity>());
private static WorldEntity MakeStubEntity(uint id)
=> new()
{
Id = id,
SourceGfxObjOrSetupId = 0x01000001u,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
[Fact]
public void AppendLiveEntity_LandblockAlreadyLoaded_AppendsImmediately()
{
var state = new GpuWorldState();
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
// Server sends a spawn at landblock 0xA9B40011 — same landblock,
// cell index 0x0011. Canonicalize drops the cell index, lookup
// succeeds, entity lands in the loaded landblock immediately.
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(42));
Assert.Single(state.Entities);
Assert.Equal(0u, (uint)state.PendingLiveEntityCount);
}
[Fact]
public void AppendLiveEntity_LandblockNotLoaded_ParksInPending()
{
var state = new GpuWorldState();
// No landblock loaded — the spawn must survive instead of being
// silently dropped (the bug from Phase A.1's first live run).
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(42));
Assert.Empty(state.Entities); // not visible yet
Assert.Equal(1, state.PendingLiveEntityCount);
}
[Fact]
public void AddLandblock_DrainsPendingEntriesForThatLandblock()
{
var state = new GpuWorldState();
// Three spawns arrive before the landblock loads.
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1));
state.AppendLiveEntity(0xA9B40022u, MakeStubEntity(2)); // same landblock, different cell
state.AppendLiveEntity(0xA9B40033u, MakeStubEntity(3));
Assert.Equal(3, state.PendingLiveEntityCount);
Assert.Empty(state.Entities);
// Now the landblock streams in.
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
// The three pending entities are now visible, and the pending
// bucket for that landblock is empty.
Assert.Equal(3, state.Entities.Count);
Assert.Equal(0, state.PendingLiveEntityCount);
}
[Fact]
public void AddLandblock_DoesNotDrainPendingForADifferentLandblock()
{
var state = new GpuWorldState();
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1)); // pending for 0xA9B4FFFF
state.AppendLiveEntity(0xAAAA0022u, MakeStubEntity(2)); // pending for 0xAAAAFFFF
// Loading 0xA9B4FFFF only drains its own bucket.
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
Assert.Single(state.Entities);
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]
public void RemoveLandblock_DropsPendingForThatLandblock()
{
var state = new GpuWorldState();
// Spawns for landblock 0xA9B4FFFF arrive while it's pending.
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1));
state.AppendLiveEntity(0xA9B40022u, MakeStubEntity(2));
Assert.Equal(2, state.PendingLiveEntityCount);
// Player moves away — the streamer says "this landblock is no
// longer in the visible window, drop it." The pending entries
// for it are dropped too because they came along with that
// landblock and are no longer relevant.
state.RemoveLandblock(0xA9B4FFFFu);
Assert.Equal(0, state.PendingLiveEntityCount);
Assert.Empty(state.Entities);
}
[Fact]
public void RemoveLandblock_LoadedThenRemoved_DropsItsEntities()
{
var state = new GpuWorldState();
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1));
Assert.Single(state.Entities);
state.RemoveLandblock(0xA9B4FFFFu);
Assert.Empty(state.Entities);
}
[Fact]
public void IsLoaded_ReturnsTrueForLoaded_FalseForPendingOnly()
{
var state = new GpuWorldState();
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1));
Assert.False(state.IsLoaded(0xA9B4FFFFu)); // pending doesn't count
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
Assert.True(state.IsLoaded(0xA9B4FFFFu));
}
private static WorldEntity MakeServerEntity(uint id, uint serverGuid)
=> new()
{
Id = id,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0x01000001u,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
[Fact]
public void RemoveLandblock_RescuesPersistentEntity_FromPendingBucket()
{
// #138 (secondary): the player is re-injected via AppendLiveEntity every
// frame; right after a teleport its landblock hasn't streamed in, so it
// lands in the pending bucket. If that landblock is then unloaded before
// it finishes loading, the persistent entity must still be rescued —
// otherwise the avatar vanishes after a couple round-trips.
var state = new GpuWorldState();
const uint playerGuid = 0x50000001u;
state.MarkPersistent(playerGuid);
var player = MakeServerEntity(id: 1, serverGuid: playerGuid);
state.AppendLiveEntity(0xA9B40011u, player); // landblock not loaded → pending
Assert.Equal(1, state.PendingLiveEntityCount);
state.RemoveLandblock(0xA9B4FFFFu); // unloaded while still pending
Assert.Contains(player, state.DrainRescued()); // rescued, not dropped
}
[Fact]
public void RemoveLandblock_DoesNotRescue_NonPersistentPendingEntity()
{
// A normal server object (door) in the pending bucket is NOT persistent;
// it must still be dropped (and is recoverable via #138 re-hydrate from
// _lastSpawnByGuid, not via the rescue path).
var state = new GpuWorldState();
var door = MakeServerEntity(id: 2, serverGuid: 0x7A9B4001u); // not marked persistent
state.AppendLiveEntity(0xA9B40011u, door);
state.RemoveLandblock(0xA9B4FFFFu);
Assert.Empty(state.DrainRescued());
}
}