using System; using AcDream.App.Streaming; using AcDream.Core.World; using DatReaderWriter.DBObjs; using Xunit; namespace AcDream.Core.Tests.Streaming; /// /// Covers '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. /// public class GpuWorldStateTests { private static LoadedLandblock MakeStubLandblock(uint canonicalId) => new(canonicalId, new LandBlock(), Array.Empty()); private static WorldEntity MakeStubEntity(uint id) => new() { Id = id, SourceGfxObjOrSetupId = 0x01000001u, Position = System.Numerics.Vector3.Zero, Rotation = System.Numerics.Quaternion.Identity, MeshRefs = Array.Empty(), }; [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(), }; 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(), }; [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()); } }