acdream/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs
Erik 8a5d77f7f4 feat(net): port retail physics spawn and event timestamps
Parse the complete PhysicsDesc plus F754/F755 packets, correct every PhysicsState bit, and gate all nine retail update channels with generation-safe immutable snapshots. Preserve ForcePosition, teleport, placement, velocity, parent, pickup, delete, and same-generation CreateObject ordering from the named client.

Separate accepted logical lifecycle notifications from retained UI qualities, make GUID replacement and session reset clear every projection exactly once, and add packet, wraparound, malformed-input, parent FIFO, canonical-position, reconnect, and GUID-reuse conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-14 00:22:17 +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
// the retained inbound spawn snapshot, 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());
}
}