diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 42bccdc3..802a2278 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -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 |
|---|---|---|---|---|---|
@@ -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-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-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 |
---
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 68c5865c..f2e59d4b 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -2295,7 +2295,11 @@ public sealed class GameWindow : IDisposable
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
_buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28)
_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.
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
@@ -2681,6 +2685,77 @@ public sealed class GameWindow : IDisposable
}
}
+ ///
+ /// #138: re-hydrate retained server objects (doors, NPCs, chests, portals)
+ /// into a landblock that just (re)loaded. Fired by
+ /// after
+ /// AddLandblock / AddEntitiesToExistingLandblock.
+ ///
+ ///
+ /// The dungeon collapse (and Near→Far demote) drops a landblock's render
+ /// entities for FPS but keeps the parsed spawns in
+ /// (our weenie_object_table for world objects). ACE never
+ /// re-broadcasts objects it believes we still know — its per-player
+ /// KnownObjects set is not cleared on a normal teleport (verified
+ /// against references/ACE ObjectMaint; a real client keeps its
+ /// table and re-renders from it, per references/holtburger). 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.
+ ///
+ ///
+ ///
+ /// Idempotent and cheap when nothing is missing: the selection skips guids
+ /// already present in (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
+ /// de-dup scrubs the state the
+ /// collapse orphaned — the entity lingers in
+ /// after RemoveLandblock even though its render entity is gone.
+ ///
+ ///
+ 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();
+ 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(
+ _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}");
+ }
+
///
/// Door detection by server-sent name. Doors fail the generic
/// multi-frame-idle gate at line 2692 (no idle cycle), so we register
diff --git a/src/AcDream.App/Streaming/LandblockEntityRehydrator.cs b/src/AcDream.App/Streaming/LandblockEntityRehydrator.cs
new file mode 100644
index 00000000..ef473633
--- /dev/null
+++ b/src/AcDream.App/Streaming/LandblockEntityRehydrator.cs
@@ -0,0 +1,86 @@
+using System.Collections.Generic;
+
+namespace AcDream.App.Streaming;
+
+///
+/// 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; GameWindow owns the replay half (it alone can rebuild a
+/// mesh from a CreateObject).
+///
+///
+/// Why this exists (retail/ACE model). A real AC client keeps its
+/// weenie_object_table 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 KnownObjects set is never
+/// cleared on a normal teleport — ACE relies on the client retaining the
+/// table, cross-checked against references/holtburger +
+/// references/ACE/Source/ACE.Server/Physics/Common/ObjectMaint.cs).
+///
+///
+///
+/// acdream's dungeon-collapse (and Near→Far demote) drops a landblock's
+/// RENDER entities for FPS but retains the parsed spawns
+/// (GameWindow._lastSpawnByGuid — the table is only pruned by an
+/// explicit server DeleteObject 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 GameWindow can replay them, making
+/// re-delivery independent of the server.
+///
+///
+public static class LandblockEntityRehydrator
+{
+ ///
+ /// A retained spawn reduced to just the fields the selection needs.
+ /// 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 OnLiveEntitySpawnedLocked's own guard).
+ ///
+ public readonly record struct RetainedSpawn(uint Guid, uint SpawnLandblockId, bool HasWorldMesh);
+
+ ///
+ /// Canonical landblock id (low 16 bits forced to 0xFFFF) — the same
+ /// keying uses, so a
+ /// cell-resolved spawn id (0xAAAA00CC) and a streamed landblock id
+ /// (0xAAAAFFFF) compare equal when they name the same landblock.
+ ///
+ public static uint Canonicalize(uint landblockId) => (landblockId & 0xFFFF0000u) | 0xFFFFu;
+
+ ///
+ /// Select the guids whose retained spawn should be replayed for the
+ /// just-loaded .
+ ///
+ /// The landblock that just (re)loaded.
+ /// Snapshot of the retained world-object spawns.
+ ///
+ /// Server guids that already have a live render entity in
+ /// . A guid here was either never dropped or was
+ /// already re-delivered by the server, so replaying it would be redundant.
+ ///
+ ///
+ /// The local player's guid — never re-hydrated here; it is owned by the
+ /// persistent-entity rescue path (
+ /// + DrainRescued), which preserves the player's live pose/position
+ /// rather than resetting it to the spawn snapshot.
+ ///
+ public static List SelectGuidsToRehydrate(
+ uint loadedLandblockId,
+ IReadOnlyCollection retainedSpawns,
+ IReadOnlySet presentServerGuids,
+ uint playerServerGuid)
+ {
+ uint loadedCanonical = Canonicalize(loadedLandblockId);
+ var result = new List();
+ 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;
+ }
+}
diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs
index 358f8ca7..c7b139ee 100644
--- a/src/AcDream.App/Streaming/StreamingController.cs
+++ b/src/AcDream.App/Streaming/StreamingController.cs
@@ -23,6 +23,17 @@ public sealed class StreamingController
private readonly Action _applyTerrain;
private readonly Action? _removeTerrain;
private readonly Action? _clearPendingLoads;
+
+ ///
+ /// #138: fired after a landblock's entity layer (re)loads — both the full
+ /// path (initial / dungeon-exit
+ /// expand) and the path
+ /// (Far→Near). GameWindow 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.
+ ///
+ private readonly Action? _onLandblockLoaded;
private readonly GpuWorldState _state;
private StreamingRegion? _region;
@@ -87,7 +98,8 @@ public sealed class StreamingController
int nearRadius,
int farRadius,
Action? removeTerrain = null,
- Action? clearPendingLoads = null)
+ Action? clearPendingLoads = null,
+ Action? onLandblockLoaded = null)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
@@ -95,6 +107,7 @@ public sealed class StreamingController
_applyTerrain = applyTerrain;
_removeTerrain = removeTerrain;
_clearPendingLoads = clearPendingLoads;
+ _onLandblockLoaded = onLandblockLoaded;
_state = state;
NearRadius = nearRadius;
FarRadius = farRadius;
@@ -297,10 +310,15 @@ public sealed class StreamingController
case LandblockStreamResult.Loaded loaded:
_applyTerrain(loaded.Landblock, loaded.MeshData);
_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;
case LandblockStreamResult.Promoted promoted:
_applyTerrain(promoted.Landblock, promoted.MeshData);
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
+ _onLandblockLoaded?.Invoke(promoted.LandblockId);
break;
case LandblockStreamResult.Unloaded unloaded:
_state.RemoveLandblock(unloaded.LandblockId);
diff --git a/tests/AcDream.Core.Tests/Streaming/LandblockEntityRehydratorTests.cs b/tests/AcDream.Core.Tests/Streaming/LandblockEntityRehydratorTests.cs
new file mode 100644
index 00000000..db7bc597
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Streaming/LandblockEntityRehydratorTests.cs
@@ -0,0 +1,128 @@
+using System.Collections.Generic;
+using AcDream.App.Streaming;
+using Xunit;
+
+namespace AcDream.Core.Tests.Streaming;
+
+///
+/// #138 — selection logic for re-hydrating server objects when a landblock
+/// reloads after a dungeon collapse (or a Near→Far demote). See
+/// for the retail/ACE rationale.
+///
+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(),
+ 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(),
+ 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(),
+ 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 { 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(),
+ 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(),
+ 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 { chestGuid }, PlayerGuid);
+
+ Assert.Equal(new HashSet { DoorGuid, npcGuid }, new HashSet(result));
+ }
+}