acdream/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs
Erik 0a5f91b6fe fix(streaming): #138 — rescue persistent entities from the pending bucket on unload
GpuWorldState.RemoveLandblock rescued persistent entities (the player)
only from the _loaded list, silently dropping one sitting in the
_pendingByLandblock bucket. The player is re-injected via AppendLiveEntity
every frame; right after a teleport its destination landblock has not
streamed in yet, so the player lands in the pending bucket — and if that
landblock is then unloaded during the streaming churn, the persistent
entry was dropped, violating the "persistent therefore survives unload"
contract. Leading candidate for the #138 "own avatar vanishes after a
couple round-trips" symptom (cumulative; needs user visual confirm).

Fix: scan the pending bucket for persistent guids and rescue them too,
so DrainRescued re-parks them at the next valid landblock. Provable
correctness fix with a deterministic test (rescue-from-pending plus a
negative for non-persistent). Core tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:07:28 +02:00

187 lines
6.8 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 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());
}
}