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)); + } +}