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>
142 lines
6 KiB
C#
142 lines
6 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.Core.World;
|
|
|
|
namespace AcDream.Core.Tests.Rendering.Wb;
|
|
|
|
/// <summary>
|
|
/// Integration: verifies the pending-spawn list mechanism keeps working
|
|
/// after Task 12 wired LandblockSpawnAdapter into GpuWorldState. Server-
|
|
/// spawned entities (ServerGuid != 0) park in pending → drain on
|
|
/// AddLandblock → end up in the flat view, but they are NEVER registered
|
|
/// with the WB adapter (they're per-instance tier).
|
|
///
|
|
/// The adapter SHOULD see atlas-tier entities (ServerGuid == 0) that
|
|
/// arrived in the AddLandblock's payload directly.
|
|
/// </summary>
|
|
public sealed class PendingSpawnIntegrationTests
|
|
{
|
|
// N.5 ship amendment: WbFoundationFlag was deleted — GpuWorldState
|
|
// no longer gates adapter calls on the flag; they are unconditional
|
|
// when the adapter is non-null. No static ctor hook needed.
|
|
|
|
[Fact]
|
|
public void LiveEntity_ParkedBeforeLandblock_DrainsButIsNotRegisteredWithAdapter()
|
|
{
|
|
var captured = new CapturingAdapterMock();
|
|
var spawnAdapter = new LandblockSpawnAdapter(captured);
|
|
var state = new GpuWorldState(spawnAdapter);
|
|
|
|
// Park a live (server-spawned) entity for landblock 0x1234FFFF BEFORE
|
|
// the landblock streams in. ServerGuid != 0 makes this per-instance-tier.
|
|
var liveEntity = MakeServerSpawned(
|
|
id: 1, serverGuid: 0xCAFE0001u, gfxObjId: 0x01000099u);
|
|
// Projection placement takes the raw cell-form id; it canonicalises internally.
|
|
state.PlaceLiveEntityProjection(0x12340011u, liveEntity);
|
|
|
|
Assert.Equal(1, state.PendingLiveEntityCount);
|
|
Assert.Empty(captured.IncrementCalls); // not registered yet — landblock not loaded
|
|
|
|
// Now landblock arrives with ONE atlas-tier entity that brings its own
|
|
// GfxObj, plus the pending live entity drains into it.
|
|
var atlasEntity = MakeAtlas(id: 2, gfxObjId: 0x01000010u);
|
|
var lb = new LoadedLandblock(
|
|
LandblockId: 0x1234FFFFu,
|
|
Heightmap: new DatReaderWriter.DBObjs.LandBlock(),
|
|
Entities: new[] { atlasEntity });
|
|
state.AddLandblock(lb);
|
|
|
|
// Pending drained.
|
|
Assert.Equal(0, state.PendingLiveEntityCount);
|
|
|
|
// Flat view contains both: the atlas one from the load + the drained pending.
|
|
var allIds = state.Entities.Select(e => e.Id).ToHashSet();
|
|
Assert.Contains(1u, allIds); // pending entity
|
|
Assert.Contains(2u, allIds); // landblock entity
|
|
|
|
// Adapter only saw the atlas-tier GfxObj. The pending server-spawned
|
|
// entity's GfxObj is NOT registered (filtered by ServerGuid != 0 in
|
|
// LandblockSpawnAdapter).
|
|
Assert.Single(captured.IncrementCalls);
|
|
Assert.Contains(0x01000010ul, captured.IncrementCalls);
|
|
Assert.DoesNotContain(0x01000099ul, captured.IncrementCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public void LiveEntity_AfterLandblock_RegistersImmediatelyWithoutAdapterCall()
|
|
{
|
|
// When a CreateObject arrives for an already-loaded landblock, it goes
|
|
// straight into the flat view (not through pending). Adapter is NOT
|
|
// re-invoked because the landblock load already happened.
|
|
var captured = new CapturingAdapterMock();
|
|
var spawnAdapter = new LandblockSpawnAdapter(captured);
|
|
var state = new GpuWorldState(spawnAdapter);
|
|
|
|
var atlasEntity = MakeAtlas(id: 1, gfxObjId: 0x01000010u);
|
|
var lb = new LoadedLandblock(
|
|
LandblockId: 0x1234FFFFu,
|
|
Heightmap: new DatReaderWriter.DBObjs.LandBlock(),
|
|
Entities: new[] { atlasEntity });
|
|
state.AddLandblock(lb);
|
|
|
|
Assert.Single(captured.IncrementCalls); // atlas registered
|
|
|
|
// Now a live entity arrives — landblock is already loaded.
|
|
var liveEntity = MakeServerSpawned(id: 2, serverGuid: 0xCAFE0001u, gfxObjId: 0x01000099u);
|
|
state.PlaceLiveEntityProjection(0x12340022u, liveEntity);
|
|
|
|
// Adapter not invoked again — projection placement doesn't drive ref counts.
|
|
Assert.Single(captured.IncrementCalls);
|
|
Assert.Equal(0, state.PendingLiveEntityCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void LandblockUnload_ReleasesAtlasIds_PendingDoesNotRegress()
|
|
{
|
|
var captured = new CapturingAdapterMock();
|
|
var spawnAdapter = new LandblockSpawnAdapter(captured);
|
|
var state = new GpuWorldState(spawnAdapter);
|
|
|
|
var atlasEntity = MakeAtlas(id: 1, gfxObjId: 0x01000010u);
|
|
var lb = new LoadedLandblock(
|
|
LandblockId: 0x1234FFFFu,
|
|
Heightmap: new DatReaderWriter.DBObjs.LandBlock(),
|
|
Entities: new[] { atlasEntity });
|
|
state.AddLandblock(lb);
|
|
state.RemoveLandblock(0x1234FFFFu);
|
|
|
|
Assert.Equal(
|
|
captured.IncrementCalls.OrderBy(x => x),
|
|
captured.DecrementCalls.OrderBy(x => x));
|
|
}
|
|
|
|
// ── Test helpers ──────────────────────────────────────────────────────
|
|
|
|
private sealed class CapturingAdapterMock : IWbMeshAdapter
|
|
{
|
|
public List<ulong> IncrementCalls { get; } = new();
|
|
public List<ulong> DecrementCalls { get; } = new();
|
|
public void IncrementRefCount(ulong id) => IncrementCalls.Add(id);
|
|
public void DecrementRefCount(ulong id) => DecrementCalls.Add(id);
|
|
}
|
|
|
|
private static WorldEntity MakeAtlas(uint id, uint gfxObjId)
|
|
=> MakeEntity(id, serverGuid: 0u, gfxObjId);
|
|
|
|
private static WorldEntity MakeServerSpawned(uint id, uint serverGuid, uint gfxObjId)
|
|
=> MakeEntity(id, serverGuid, gfxObjId);
|
|
|
|
private static WorldEntity MakeEntity(uint id, uint serverGuid, uint gfxObjId)
|
|
=> new WorldEntity
|
|
{
|
|
Id = id,
|
|
ServerGuid = serverGuid,
|
|
SourceGfxObjOrSetupId = gfxObjId,
|
|
Position = Vector3.Zero,
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = new[] { new MeshRef(gfxObjId, Matrix4x4.Identity) },
|
|
};
|
|
}
|