fix(streaming): #138 — re-hydrate server objects from the retained spawn table on reload

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>
This commit is contained in:
Erik 2026-06-21 08:06:41 +02:00
parent b9445f53fe
commit bf66fb4123
5 changed files with 311 additions and 3 deletions

View file

@ -97,7 +97,7 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 3. Documented approximation (AP) — 42 rows ## 3. Documented approximation (AP) — 43 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -148,6 +148,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-46 | Health-meter gate approximation: retail shows the health meter for `IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()` (full PK/faction logic); acdream's `GameWindow.IsHealthBarTarget` uses the server PWD bits `BF_ATTACKABLE (0x10)` OR `BF_PLAYER (0x8)` | `src/AcDream.App/Rendering/GameWindow.cs` (`IsHealthBarTarget`) → `SelectedObjectController` | The PWD `BF_ATTACKABLE`/`BF_PLAYER` bits distinguish monsters + players (bar) from friendly/vendor NPCs (name-only) for the M1.5 dev loop; the pet case and the full ObjectIsAttackable PK/faction refinement (free-PK, PK-vs-PK, PKLite) are not ported | A PK/faction edge (e.g. a hostile-flagged player whose `BF_ATTACKABLE` is unset, or a pet) could show/hide the bar where retail differs — no impact on the non-PK PvE dev loop | `ClientCombatSystem::ObjectIsAttackable` acclient_2013_pseudo_c.txt:375385; `BF_ATTACKABLE` acclient.h:6437 | | AP-46 | Health-meter gate approximation: retail shows the health meter for `IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()` (full PK/faction logic); acdream's `GameWindow.IsHealthBarTarget` uses the server PWD bits `BF_ATTACKABLE (0x10)` OR `BF_PLAYER (0x8)` | `src/AcDream.App/Rendering/GameWindow.cs` (`IsHealthBarTarget`) → `SelectedObjectController` | The PWD `BF_ATTACKABLE`/`BF_PLAYER` bits distinguish monsters + players (bar) from friendly/vendor NPCs (name-only) for the M1.5 dev loop; the pet case and the full ObjectIsAttackable PK/faction refinement (free-PK, PK-vs-PK, PKLite) are not ported | A PK/faction edge (e.g. a hostile-flagged player whose `BF_ATTACKABLE` is unset, or a pet) could show/hide the bar where retail differs — no impact on the non-PK PvE dev loop | `ClientCombatSystem::ObjectIsAttackable` acclient_2013_pseudo_c.txt:375385; `BF_ATTACKABLE` acclient.h:6437 |
| AP-44 | Server-spawned weenie fixtures (lanterns, braziers, glowing items carrying `Setup.Lights`) register their lights at their SPAWN-frame world position via `RegisterOwnedLight` in `OnLiveEntitySpawnedLocked` (mirroring the dat-static path in `ApplyLoadedTerrainLocked`); the light does NOT follow the object if it later moves. Torn down on despawn/respawn by the now-unconditional `UnregisterOwner` in `RemoveLiveEntityByServerGuid`. | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawnedLocked` weenie-light block; `RemoveLiveEntityByServerGuid`) | Closes the prior gap where ONLY dat-baked statics registered lights, so server-placed lanterns cast nothing; the overwhelming majority of light-bearing weenies (wall lanterns, braziers, candelabra) are stationary, so spawn-frame placement matches retail; retail's `add_dynamic_light` re-places the light from the object frame each frame | A moving light-bearing weenie (e.g. a torch-carrying NPC) leaves its light at the spawn position instead of following — rare; the faithful fix is to re-place on `OnLivePositionUpdated` | retail object-borne lights `insert_light` 0x0054d1b0 / `add_dynamic_light` 0x0054d420 (per-frame object frame) | | AP-44 | Server-spawned weenie fixtures (lanterns, braziers, glowing items carrying `Setup.Lights`) register their lights at their SPAWN-frame world position via `RegisterOwnedLight` in `OnLiveEntitySpawnedLocked` (mirroring the dat-static path in `ApplyLoadedTerrainLocked`); the light does NOT follow the object if it later moves. Torn down on despawn/respawn by the now-unconditional `UnregisterOwner` in `RemoveLiveEntityByServerGuid`. | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawnedLocked` weenie-light block; `RemoveLiveEntityByServerGuid`) | Closes the prior gap where ONLY dat-baked statics registered lights, so server-placed lanterns cast nothing; the overwhelming majority of light-bearing weenies (wall lanterns, braziers, candelabra) are stationary, so spawn-frame placement matches retail; retail's `add_dynamic_light` re-places the light from the object frame each frame | A moving light-bearing weenie (e.g. a torch-carrying NPC) leaves its light at the spawn position instead of following — rare; the faithful fix is to re-place on `OnLivePositionUpdated` | retail object-borne lights `insert_light` 0x0054d1b0 / `add_dynamic_light` 0x0054d420 (per-frame object frame) |
| AP-47 | acdream keeps the 128 nearest-to-CAMERA point lights live (`MaxGlobalLights=128`, `BuildPointLightSnapshot`) and selects per cell CAMERA-INDEPENDENTLY (by the cell's own bounds), so a building interior stays lit at any distance within a town; retail keeps only the 40 nearest-to-PLAYER static lights (`Render::max_static_lights=0x28`, distance-sorted replace-farthest `insert_light`) and re-bakes a cell when its live light set changes, so distant interiors are baked dark and "light up" only as the player approaches and their torches enter the live 40. INTENTIONAL — acdream's always-lit interiors are the preferred behavior (no 1999-era light-budget pop-in); user-confirmed 2026-06-20. | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights=128`, `BuildPointLightSnapshot`, `SelectForObject`) | The retail pop-in is a fixed-function light-budget artifact, not an intended aesthetic; revert to retail by clamping the global set to 40 + distance-to-player sort if ever desired | Distant town interiors are lit in acdream where retail's are dark until approached — a deliberate, user-preferred divergence | `Render::max_static_lights` 0x28; `insert_light` 0x0054d1b0 (distance-sorted, replace-farthest); bake re-trigger `SetStaticLightingVertexColors` cache `burnedInStaticLights != num_static_lights` | | AP-47 | acdream keeps the 128 nearest-to-CAMERA point lights live (`MaxGlobalLights=128`, `BuildPointLightSnapshot`) and selects per cell CAMERA-INDEPENDENTLY (by the cell's own bounds), so a building interior stays lit at any distance within a town; retail keeps only the 40 nearest-to-PLAYER static lights (`Render::max_static_lights=0x28`, distance-sorted replace-farthest `insert_light`) and re-bakes a cell when its live light set changes, so distant interiors are baked dark and "light up" only as the player approaches and their torches enter the live 40. INTENTIONAL — acdream's always-lit interiors are the preferred behavior (no 1999-era light-budget pop-in); user-confirmed 2026-06-20. | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights=128`, `BuildPointLightSnapshot`, `SelectForObject`) | The retail pop-in is a fixed-function light-budget artifact, not an intended aesthetic; revert to retail by clamping the global set to 40 + distance-to-player sort if ever desired | Distant town interiors are lit in acdream where retail's are dark until approached — a deliberate, user-preferred divergence | `Render::max_static_lights` 0x28; `insert_light` 0x0054d1b0 (distance-sorted, replace-farthest); bake re-trigger `SetStaticLightingVertexColors` cache `burnedInStaticLights != num_static_lights` |
| AP-48 | On a landblock (re)load, acdream re-projects its retained server-object spawns (`GameWindow._lastSpawnByGuid` — our `weenie_object_table` for world objects, carrying position + Setup + appearance) into the render world via `LandblockEntityRehydrator`. The dungeon collapse (#133/#135) and Near→Far demote drop a landblock's RENDER entities for FPS but keep the parsed spawns; 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 table + doing its own visibility cull). Re-projecting from our own table is the retail "client keeps its weenie_object_table and re-renders from it" model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained spawn table is pruned ONLY by an explicit server `DeleteObject` (0xF747) or a spawn de-dup, never by distance/time. (#138) | `src/AcDream.App/Streaming/LandblockEntityRehydrator.cs` + `GameWindow.RehydrateServerEntitiesForLandblock` + `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Re-projection from our own table is retail-faithful AND the only reliable path (ACE won't re-send a known object); fixes the #138 "doors/NPCs/portals gone after a dungeon exit" symptom independent of any server re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) is re-hydrated at its last-known position on reload, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS`; retail client weenie_object_table re-render |
--- ---

View file

@ -2295,7 +2295,11 @@ public sealed class GameWindow : IDisposable
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu); _cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
_buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28) _buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28)
_envCellRenderer?.RemoveLandblock(id); // Phase A8 _envCellRenderer?.RemoveLandblock(id); // Phase A8
}); },
// #138: restore retained server objects when a landblock reloads
// (dungeon-exit expand or Far→Near promote). ACE won't re-send the
// objects it thinks we still know, so we re-project them ourselves.
onLandblockLoaded: RehydrateServerEntitiesForLandblock);
// A.5 T22.5: apply max-completions from resolved quality. // A.5 T22.5: apply max-completions from resolved quality.
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
@ -2681,6 +2685,77 @@ public sealed class GameWindow : IDisposable
} }
} }
/// <summary>
/// #138: re-hydrate retained server objects (doors, NPCs, chests, portals)
/// into a landblock that just (re)loaded. Fired by
/// <see cref="AcDream.App.Streaming.StreamingController"/> after
/// <c>AddLandblock</c> / <c>AddEntitiesToExistingLandblock</c>.
///
/// <para>
/// The dungeon collapse (and Near→Far demote) drops a landblock's render
/// entities for FPS but keeps the parsed spawns in <see cref="_lastSpawnByGuid"/>
/// (our <c>weenie_object_table</c> for world objects). ACE never
/// re-broadcasts objects it believes we still know — its per-player
/// <c>KnownObjects</c> set is not cleared on a normal teleport (verified
/// against <c>references/ACE</c> <c>ObjectMaint</c>; a real client keeps its
/// table and re-renders from it, per <c>references/holtburger</c>). So on
/// reload the render side would stay empty. We re-project the objects from
/// our own retained table instead — independent of any server re-send.
/// </para>
///
/// <para>
/// Idempotent and cheap when nothing is missing: the selection skips guids
/// already present in <see cref="_worldState"/> (initial login, or objects
/// ACE did re-send), the player (owned by the persistent-entity rescue
/// path), and spawns with no world mesh. The replay's own
/// <see cref="RemoveLiveEntityByServerGuid"/> de-dup scrubs the state the
/// collapse orphaned — the entity lingers in <see cref="_entitiesByServerGuid"/>
/// after <c>RemoveLandblock</c> even though its render entity is gone.
/// </para>
/// </summary>
private void RehydrateServerEntitiesForLandblock(uint loadedLandblockId)
{
if (_lastSpawnByGuid.Count == 0) return;
// Server guids that already have a live render entity. The gate keys on
// GpuWorldState (the render projection), NOT _entitiesByServerGuid, which
// still holds collapse-orphaned entries whose render entity is gone.
var present = new HashSet<uint>();
foreach (var e in _worldState.Entities)
if (e.ServerGuid != 0) present.Add(e.ServerGuid);
// Snapshot the retained spawns — the replay mutates _lastSpawnByGuid
// (remove then re-add), so we must not iterate it live.
var retained = new List<AcDream.App.Streaming.LandblockEntityRehydrator.RetainedSpawn>(
_lastSpawnByGuid.Count);
foreach (var kv in _lastSpawnByGuid)
{
var sp = kv.Value;
bool hasWorldMesh = sp.Position is not null && sp.SetupTableId is not null;
uint spawnLb = sp.Position is { } p ? p.LandblockId : 0u;
retained.Add(new AcDream.App.Streaming.LandblockEntityRehydrator.RetainedSpawn(
kv.Key, spawnLb, hasWorldMesh));
}
var guids = AcDream.App.Streaming.LandblockEntityRehydrator.SelectGuidsToRehydrate(
loadedLandblockId, retained, present, _playerServerGuid);
if (guids.Count == 0) return;
// Replay through the normal live-spawn build under the dat lock (it reads
// Setup/GfxObj/Surface dats). Each replay rebuilds the render entity into
// the now-loaded landblock via AppendLiveEntity's hot path.
lock (_datLock)
{
foreach (var guid in guids)
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
OnLiveEntitySpawnedLocked(spawn);
}
if (_options.DumpLiveSpawns)
Console.WriteLine(
$"live: re-hydrated {guids.Count} server object(s) into landblock 0x{loadedLandblockId:X8}");
}
/// <summary> /// <summary>
/// Door detection by server-sent name. Doors fail the generic /// Door detection by server-sent name. Doors fail the generic
/// multi-frame-idle gate at line 2692 (no idle cycle), so we register /// multi-frame-idle gate at line 2692 (no idle cycle), so we register

View file

@ -0,0 +1,86 @@
using System.Collections.Generic;
namespace AcDream.App.Streaming;
/// <summary>
/// Decides which retained server-object spawns to re-hydrate (rebuild render
/// entities for) when a landblock (re)loads. The pure selection half of the
/// #138 fix; <c>GameWindow</c> owns the replay half (it alone can rebuild a
/// mesh from a <c>CreateObject</c>).
///
/// <para>
/// <b>Why this exists (retail/ACE model).</b> A real AC client keeps its
/// <c>weenie_object_table</c> across a teleport and re-projects its rendered
/// world from that table; the server does NOT re-broadcast objects it believes
/// the client already knows (ACE's per-player <c>KnownObjects</c> set is never
/// cleared on a normal teleport — ACE relies on the client retaining the
/// table, cross-checked against <c>references/holtburger</c> +
/// <c>references/ACE/Source/ACE.Server/Physics/Common/ObjectMaint.cs</c>).
/// </para>
///
/// <para>
/// acdream's dungeon-collapse (and Near→Far demote) drops a landblock's
/// RENDER entities for FPS but retains the parsed spawns
/// (<c>GameWindow._lastSpawnByGuid</c> — the table is only pruned by an
/// explicit server <c>DeleteObject</c> or a spawn de-dup). On reload, ACE will
/// not re-send the objects it still thinks we have, so the render side stays
/// empty — the #138 symptom. This selects the retained spawns whose render
/// entity is currently absent so <c>GameWindow</c> can replay them, making
/// re-delivery independent of the server.
/// </para>
/// </summary>
public static class LandblockEntityRehydrator
{
/// <summary>
/// A retained spawn reduced to just the fields the selection needs.
/// <paramref name="HasWorldMesh"/> is true only when the spawn carries both
/// a world position AND a Setup id — i.e. it would actually build a visible
/// render entity (inventory items and setup-less spawns produce none and
/// are skipped, matching <c>OnLiveEntitySpawnedLocked</c>'s own guard).
/// </summary>
public readonly record struct RetainedSpawn(uint Guid, uint SpawnLandblockId, bool HasWorldMesh);
/// <summary>
/// Canonical landblock id (low 16 bits forced to <c>0xFFFF</c>) — the same
/// keying <see cref="GpuWorldState.AppendLiveEntity"/> uses, so a
/// cell-resolved spawn id (<c>0xAAAA00CC</c>) and a streamed landblock id
/// (<c>0xAAAAFFFF</c>) compare equal when they name the same landblock.
/// </summary>
public static uint Canonicalize(uint landblockId) => (landblockId & 0xFFFF0000u) | 0xFFFFu;
/// <summary>
/// Select the guids whose retained spawn should be replayed for the
/// just-loaded <paramref name="loadedLandblockId"/>.
/// </summary>
/// <param name="loadedLandblockId">The landblock that just (re)loaded.</param>
/// <param name="retainedSpawns">Snapshot of the retained world-object spawns.</param>
/// <param name="presentServerGuids">
/// Server guids that already have a live render entity in
/// <see cref="GpuWorldState"/>. A guid here was either never dropped or was
/// already re-delivered by the server, so replaying it would be redundant.
/// </param>
/// <param name="playerServerGuid">
/// The local player's guid — never re-hydrated here; it is owned by the
/// persistent-entity rescue path (<see cref="GpuWorldState.MarkPersistent"/>
/// + <c>DrainRescued</c>), which preserves the player's live pose/position
/// rather than resetting it to the spawn snapshot.
/// </param>
public static List<uint> SelectGuidsToRehydrate(
uint loadedLandblockId,
IReadOnlyCollection<RetainedSpawn> retainedSpawns,
IReadOnlySet<uint> presentServerGuids,
uint playerServerGuid)
{
uint loadedCanonical = Canonicalize(loadedLandblockId);
var result = new List<uint>();
foreach (var s in retainedSpawns)
{
if (!s.HasWorldMesh) continue; // no visible entity to rebuild
if (s.Guid == playerServerGuid) continue; // player: persistent-rescue path owns it
if (Canonicalize(s.SpawnLandblockId) != loadedCanonical) continue; // different landblock
if (presentServerGuids.Contains(s.Guid)) continue; // already rendered
result.Add(s.Guid);
}
return result;
}
}

View file

@ -23,6 +23,17 @@ public sealed class StreamingController
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain; private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
private readonly Action<uint>? _removeTerrain; private readonly Action<uint>? _removeTerrain;
private readonly Action? _clearPendingLoads; private readonly Action? _clearPendingLoads;
/// <summary>
/// #138: fired after a landblock's entity layer (re)loads — both the full
/// <see cref="LandblockStreamResult.Loaded"/> path (initial / dungeon-exit
/// expand) and the <see cref="LandblockStreamResult.Promoted"/> path
/// (Far→Near). <c>GameWindow</c> wires it to re-hydrate server objects whose
/// render entities were dropped by the collapse/demote but whose parsed
/// spawns it still retains. Receives the canonical landblock id. Null in
/// tests that don't exercise re-hydration.
/// </summary>
private readonly Action<uint>? _onLandblockLoaded;
private readonly GpuWorldState _state; private readonly GpuWorldState _state;
private StreamingRegion? _region; private StreamingRegion? _region;
@ -87,7 +98,8 @@ public sealed class StreamingController
int nearRadius, int nearRadius,
int farRadius, int farRadius,
Action<uint>? removeTerrain = null, Action<uint>? removeTerrain = null,
Action? clearPendingLoads = null) Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null)
{ {
_enqueueLoad = enqueueLoad; _enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload; _enqueueUnload = enqueueUnload;
@ -95,6 +107,7 @@ public sealed class StreamingController
_applyTerrain = applyTerrain; _applyTerrain = applyTerrain;
_removeTerrain = removeTerrain; _removeTerrain = removeTerrain;
_clearPendingLoads = clearPendingLoads; _clearPendingLoads = clearPendingLoads;
_onLandblockLoaded = onLandblockLoaded;
_state = state; _state = state;
NearRadius = nearRadius; NearRadius = nearRadius;
FarRadius = farRadius; FarRadius = farRadius;
@ -297,10 +310,15 @@ public sealed class StreamingController
case LandblockStreamResult.Loaded loaded: case LandblockStreamResult.Loaded loaded:
_applyTerrain(loaded.Landblock, loaded.MeshData); _applyTerrain(loaded.Landblock, loaded.MeshData);
_state.AddLandblock(loaded.Landblock); _state.AddLandblock(loaded.Landblock);
// #138: after the landblock is in _loaded (so AppendLiveEntity
// hot-paths), restore any retained server objects ACE won't
// re-send. Fired AFTER AddLandblock, never before.
_onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId);
break; break;
case LandblockStreamResult.Promoted promoted: case LandblockStreamResult.Promoted promoted:
_applyTerrain(promoted.Landblock, promoted.MeshData); _applyTerrain(promoted.Landblock, promoted.MeshData);
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities); _state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
_onLandblockLoaded?.Invoke(promoted.LandblockId);
break; break;
case LandblockStreamResult.Unloaded unloaded: case LandblockStreamResult.Unloaded unloaded:
_state.RemoveLandblock(unloaded.LandblockId); _state.RemoveLandblock(unloaded.LandblockId);

View file

@ -0,0 +1,128 @@
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));
}
}