Doors/NPCs/portals vanished after a portal OUT of the 0x0007 dungeon back to Holtburg. Root cause confirmed via ACE + holtburger cross-reference: the dungeon collapse drops a landblock's render entities for FPS, and ACE will NOT re-broadcast objects whose guid is still in its per-player KnownObjects set (never cleared on a normal teleport — ACE relies on the client retaining its object table and culling stale objects itself). So nothing restored them on the way back. Retail-faithful fix: a real client keeps its weenie_object_table and re-renders the world from it (holtburger keeps the table across a teleport; only suspends physics bodies). acdream's _lastSpawnByGuid (the parsed CreateObject records — position + Setup + appearance) IS that table and survives the collapse (the collapse path never calls RemoveLiveEntityByServerGuid, the only thing that prunes it). On landblock (re)load, replay OnLiveEntitySpawnedLocked for retained spawns whose render entity is absent — independent of any ACE re-send. - LandblockEntityRehydrator: pure selection (landblock match; skip already-present, the player, and mesh-less spawns), unit-tested (7). - StreamingController: onLandblockLoaded callback after AddLandblock (Loaded = dungeon-exit expand) and AddEntitiesToExistingLandblock (Promoted = Far->Near). - GameWindow.RehydrateServerEntitiesForLandblock: present-gate keys on GpuWorldState (NOT _entitiesByServerGuid, which holds collapse orphans), replay under _datLock; the replay's own RemoveLiveEntityByServerGuid de-dup scrubs the orphan state. Corrects the handoff: ClientObjectTable is inventory-only (no world position/Setup) and cannot rebuild a render entity; _lastSpawnByGuid is the world-object table. Register row AP-48 (no retail 25s visibility cull). dotnet build + 1518 Core tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
128 lines
4.7 KiB
C#
128 lines
4.7 KiB
C#
using System.Collections.Generic;
|
|
using AcDream.App.Streaming;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Streaming;
|
|
|
|
/// <summary>
|
|
/// #138 — selection logic for re-hydrating server objects when a landblock
|
|
/// reloads after a dungeon collapse (or a Near→Far demote). See
|
|
/// <see cref="LandblockEntityRehydrator"/> for the retail/ACE rationale.
|
|
/// </summary>
|
|
public class LandblockEntityRehydratorTests
|
|
{
|
|
private const uint PlayerGuid = 0x50000001u;
|
|
// A door spawned in cell 0x00070123 of dungeon landblock 0x0007; its
|
|
// cell-resolved spawn id must match the streamed canonical id 0x0007FFFF.
|
|
private const uint DungeonLb = 0x0007FFFFu;
|
|
private const uint DoorGuid = 0x7A9B4001u;
|
|
private const uint DoorSpawnId = 0x00070123u;
|
|
|
|
private static LandblockEntityRehydrator.RetainedSpawn Spawn(
|
|
uint guid, uint spawnLbId, bool hasMesh = true)
|
|
=> new(guid, spawnLbId, hasMesh);
|
|
|
|
[Fact]
|
|
public void RetainedSpawnInLoadedLandblock_NotPresent_IsSelected()
|
|
{
|
|
var result = LandblockEntityRehydrator.SelectGuidsToRehydrate(
|
|
DungeonLb,
|
|
new[] { Spawn(DoorGuid, DoorSpawnId) },
|
|
new HashSet<uint>(),
|
|
PlayerGuid);
|
|
|
|
Assert.Equal(new[] { DoorGuid }, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void CellResolvedSpawnId_MatchesCanonicalLoadedId()
|
|
{
|
|
// The streamed landblock id is canonical (0xAAAAFFFF); the spawn id is
|
|
// cell-resolved (0xAAAA00CC). They name the same landblock, so the door
|
|
// must be selected — the canonicalization is the load-bearing step.
|
|
var result = LandblockEntityRehydrator.SelectGuidsToRehydrate(
|
|
DungeonLb, // 0x0007FFFF
|
|
new[] { Spawn(DoorGuid, 0x000701A9u) }, // a different cell, same landblock
|
|
new HashSet<uint>(),
|
|
PlayerGuid);
|
|
|
|
Assert.Equal(new[] { DoorGuid }, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void SpawnInDifferentLandblock_IsNotSelected()
|
|
{
|
|
var result = LandblockEntityRehydrator.SelectGuidsToRehydrate(
|
|
DungeonLb,
|
|
new[] { Spawn(DoorGuid, 0xA9B30123u) }, // Holtburg, not the loaded dungeon
|
|
new HashSet<uint>(),
|
|
PlayerGuid);
|
|
|
|
Assert.Empty(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlreadyPresentGuid_IsNotSelected()
|
|
{
|
|
// Already rendered (server re-sent it, or it was never dropped) — a
|
|
// re-hydrate would be a redundant mesh rebuild.
|
|
var result = LandblockEntityRehydrator.SelectGuidsToRehydrate(
|
|
DungeonLb,
|
|
new[] { Spawn(DoorGuid, DoorSpawnId) },
|
|
new HashSet<uint> { DoorGuid },
|
|
PlayerGuid);
|
|
|
|
Assert.Empty(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayerGuid_IsNeverSelected()
|
|
{
|
|
// The player has a retained spawn too, but the persistent-rescue path
|
|
// owns it (preserves its live pose). Re-hydrating would reset it.
|
|
var result = LandblockEntityRehydrator.SelectGuidsToRehydrate(
|
|
DungeonLb,
|
|
new[] { Spawn(PlayerGuid, DoorSpawnId) },
|
|
new HashSet<uint>(),
|
|
PlayerGuid);
|
|
|
|
Assert.Empty(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void SpawnWithoutWorldMesh_IsNotSelected()
|
|
{
|
|
// Inventory items / setup-less spawns build no visible entity.
|
|
var result = LandblockEntityRehydrator.SelectGuidsToRehydrate(
|
|
DungeonLb,
|
|
new[] { Spawn(DoorGuid, DoorSpawnId, hasMesh: false) },
|
|
new HashSet<uint>(),
|
|
PlayerGuid);
|
|
|
|
Assert.Empty(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void MixedSet_SelectsOnlyAbsentWorldObjectsInLoadedLandblock()
|
|
{
|
|
uint npcGuid = 0x7A9B4002u; // same dungeon landblock, absent → select
|
|
uint chestGuid = 0x7A9B4003u; // same landblock but already present → skip
|
|
uint holtburgGuid = 0x7A9B4004u; // different landblock → skip
|
|
uint heldItemGuid = 0x7A9B4005u; // no world mesh → skip
|
|
|
|
var spawns = new[]
|
|
{
|
|
Spawn(DoorGuid, DoorSpawnId), // select
|
|
Spawn(npcGuid, 0x00070177u), // select (same lb, different cell)
|
|
Spawn(chestGuid, 0x00070123u), // skip (present)
|
|
Spawn(holtburgGuid, 0xA9B30100u), // skip (other lb)
|
|
Spawn(heldItemGuid, 0x00070123u, hasMesh: false), // skip (no mesh)
|
|
Spawn(PlayerGuid, 0x00070100u), // skip (player)
|
|
};
|
|
|
|
var result = LandblockEntityRehydrator.SelectGuidsToRehydrate(
|
|
DungeonLb, spawns, new HashSet<uint> { chestGuid }, PlayerGuid);
|
|
|
|
Assert.Equal(new HashSet<uint> { DoorGuid, npcGuid }, new HashSet<uint>(result));
|
|
}
|
|
}
|