Introduce LiveEntityRuntime as the canonical owner of each accepted server-object incarnation, stable local identity, timestamped state, parent relations, runtime components, and exactly-once teardown. Split logical registration from rebucketing so pending landblocks, equipment attachment, pickup re-entry, and GUID replacement reuse the same entity and effect owners. Keep canonical materialized and visible target/radar views distinct, preserve retail leave_world versus exit_world semantics, gate root simulation while cell-less, and track transitional pre-Create F754 owners through delete and session reset. Remove stale-spawn rehydration and make GpuWorldState spatial-only for live objects. Add lifecycle, generation, pending, unload, attachment, event-publication, local-ID, rollback, and effect-cleanup coverage; update architecture, milestones, memory, and the divergence register. Co-Authored-By: Codex <noreply@openai.com>
247 lines
9.8 KiB
C#
247 lines
9.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 PlaceLiveEntityProjection_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.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(42));
|
|
|
|
Assert.Single(state.Entities);
|
|
Assert.Equal(0u, (uint)state.PendingLiveEntityCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlaceLiveEntityProjection_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.PlaceLiveEntityProjection(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.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1));
|
|
state.PlaceLiveEntityProjection(0xA9B40022u, MakeStubEntity(2)); // same landblock, different cell
|
|
state.PlaceLiveEntityProjection(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.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1)); // pending for 0xA9B4FFFF
|
|
state.PlaceLiveEntityProjection(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 RebucketLiveEntity_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
|
|
// RebucketLiveEntity keeps 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.PlaceLiveEntityProjection(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). RebucketLiveEntity — 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.RebucketLiveEntity(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.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1));
|
|
state.PlaceLiveEntityProjection(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.PlaceLiveEntityProjection(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.PlaceLiveEntityProjection(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 rebucketing 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.PlaceLiveEntityProjection(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_RetainsNonPersistentLiveProjectionForSameIdentityReload()
|
|
{
|
|
// A normal server object (door) is not rescued with the moving player,
|
|
// but its exact projection remains pending for this landblock. Reload
|
|
// must merge that identity instead of reconstructing from CreateObject.
|
|
var state = new GpuWorldState();
|
|
var door = MakeServerEntity(id: 2, serverGuid: 0x7A9B4001u); // not marked persistent
|
|
state.PlaceLiveEntityProjection(0xA9B40011u, door);
|
|
|
|
state.RemoveLandblock(0xA9B4FFFFu);
|
|
|
|
Assert.Empty(state.DrainRescued());
|
|
Assert.Equal(1, state.PendingLiveEntityCount);
|
|
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
|
|
Assert.Same(door, Assert.Single(state.Entities));
|
|
}
|
|
|
|
[Fact]
|
|
public void RemoveLandblock_LoadedLiveProjectionMovesToPendingAndReloadsSameIdentity()
|
|
{
|
|
var state = new GpuWorldState();
|
|
var door = MakeServerEntity(id: 3, serverGuid: 0x7A9B4002u);
|
|
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
|
|
state.PlaceLiveEntityProjection(0xA9B40011u, door);
|
|
|
|
state.RemoveLandblock(0xA9B4FFFFu);
|
|
|
|
Assert.Empty(state.Entities);
|
|
Assert.Equal(1, state.PendingLiveEntityCount);
|
|
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
|
|
Assert.Same(door, Assert.Single(state.Entities));
|
|
}
|
|
}
|