From 8fbde99441eb042b8c95e17ab0f8d1c9c60bc9b1 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 18:58:15 +0200 Subject: [PATCH] Revert "fix(world): AP-48 client-side visibility cull (FPS sink across portal-hops)" This reverts commit e5b2d15b630ff10ab224b9cc56d0658130425a83. --- .../retail-divergence-register.md | 3 +- src/AcDream.App/Rendering/GameWindow.cs | 64 ----------- .../World/EntityVisibilityCuller.cs | 94 --------------- .../World/EntityVisibilityCullerTests.cs | 108 ------------------ 4 files changed, 1 insertion(+), 268 deletions(-) delete mode 100644 src/AcDream.App/World/EntityVisibilityCuller.cs delete mode 100644 tests/AcDream.App.Tests/World/EntityVisibilityCullerTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 911bf16c..7a6441f6 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -61,7 +61,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 32 rows +## 2. Adaptation (AD) — 31 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -96,7 +96,6 @@ accepted-divergence entries (#96, #49, #50). | AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` | | AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path | | AD-31 | Teleport transit covered by a full-screen black FADE (`FadeOverlay` + `TeleportAnimSequencer`) instead of retail's 3D portal-tunnel swirl (2026-06-22, spec C). Opaque black holds through the tunnel states; the world ramps back in on `WorldFadeIn`. | `src/AcDream.App/Rendering/FadeOverlay.cs` + `GameWindow.cs` (TAS transit tick; `_teleportFadeAlpha = ShowTunnel ? 1 : FadeAlpha`) | The fade is a functional cover that hides the (now-fast) destination load + the post-materialization object flood; the TAS state machine + golden timing constants are retail-verbatim — only the tunnel *graphic* is approximated. Sibling to AP-49 (fade-curve). | Visual-only: the transit shows a black cover, not the animated swirl. Retire by porting the `gmSmartBoxUI` 3D tunnel render. | `gmSmartBoxUI::UseTime` 0x004d6e30 (tunnel render, unported); `TELEPORT_ANIM_*` golden constants (spec §2.1) | -| AD-32 | Client-side visibility cull (AP-48 fix, 2026-06-22): world entities >384m AND outside the 3×3 landblock neighborhood for 25s continuous are evicted (arm-on-leave/clear-on-return), so the entity tables don't accumulate every town's objects across portal-hops. Adaptations vs holtburger: the nearby test uses each entity's `ParentCellId`-derived landblock (vs holtburger's `scene` landblock_map), the cull runs at **1 Hz** client-side (vs per-world-tick), and the time base is wall-clock (`DateTime.UtcNow`) not server time. | `src/AcDream.App/World/EntityVisibilityCuller.cs` + `GameWindow.TickEntityCull` (wiring) + `RemoveLiveEntityByServerGuid` (Forget on remove) | Faithful port of holtburger's `liveness.rs` (`ACE_DESTRUCTION_TIMEOUT_SECS=25`, `CONSERVATIVE_VISIBILITY_DISTANCE_M=384`); the player + held/equipped/contained items are excluded (player by guid; inventory items never carry a world position so they never enter the culled map); a re-created object is rescued (deadline cleared on remove + on the next visible tick) | An object the server still tracks but that goes silent >25s while >384m away is evicted locally and re-materializes only on the next server CreateObject (matches holtburger); a coordinate-frame edge where a genuinely-near object reads >384m is covered by the landblock-neighborhood OR-term | holtburger `crates/holtburger-world/src/state/liveness.rs:11-12,145-189,294-308`; ACE `ObjectMaint`/`Player_Tracking` never clears KnownObjects on teleport | --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 14e923f3..8c1ac48e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -840,13 +840,6 @@ public sealed class GameWindow : IDisposable /// keys the render list; this parallel dictionary keys by server guid. /// private readonly Dictionary _entitiesByServerGuid = new(); - - // AP-48: client-side visibility cull (holtburger liveness.rs port) — prunes world - // entities the player left far behind so the tables don't accumulate every town's - // objects across portal-hops (which sank FPS). 1 Hz; the player + held/equipped items - // are excluded (see TickEntityCull). - private readonly AcDream.App.World.EntityVisibilityCuller _entityCuller = new(); - private double _cullAccum; /// /// Latest for each /// guid. Captured at the end of so @@ -3835,61 +3828,8 @@ public sealed class GameWindow : IDisposable } } - // AP-48 cull wiring (logic lives in EntityVisibilityCuller). Snapshots the world - // entities + player, asks the culler who has been >384m AND outside the 3×3 landblock - // neighborhood for 25s, and tears each down through the existing pruner. Throttled to - // 1 Hz (holtburger runs it per world tick; per-frame is wasteful). Skipped during a - // teleport hold (the player WorldEntity.Position is frozen). Held/equipped/contained - // items never enter _entitiesByServerGuid (OnLiveEntitySpawnedLocked early-returns on a - // null world position), so the cull is structurally incapable of touching them; the - // ClientObjectTable weenie row is dropped only for genuinely world-placed objects. - private void TickEntityCull(double dt) - { - _cullAccum += dt; - if (_cullAccum < 1.0) return; - _cullAccum = 0.0; - - if (_playerController is { State: AcDream.App.Input.PlayerState.PortalSpace }) return; - if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return; - - uint plb = pe.ParentCellId ?? 0u; - var player = new AcDream.App.World.EntityCullInfo( - _playerServerGuid, pe.Position, - (int)((plb >> 24) & 0xFFu), (int)((plb >> 16) & 0xFFu), plb != 0u); - - // Snapshot first (the eviction below mutates _entitiesByServerGuid). - var snapshot = new System.Collections.Generic.List( - _entitiesByServerGuid.Count); - foreach (var kv in _entitiesByServerGuid) - { - uint elb = kv.Value.ParentCellId ?? 0u; - snapshot.Add(new AcDream.App.World.EntityCullInfo( - kv.Key, kv.Value.Position, - (int)((elb >> 24) & 0xFFu), (int)((elb >> 16) & 0xFFu), elb != 0u)); - } - - double now = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - var evicted = _entityCuller.Tick(snapshot, player, now); - foreach (var guid in evicted) - { - var row = Objects.Get(guid); - RemoveLiveEntityByServerGuid(guid); - // has_nonworld_retention parity: keep the weenie row if it's held/equipped (a far - // ground item open in a container view); drop it only for world-placed objects. - if (row is null || (row.ContainerId == 0u && row.WielderId == 0u)) - Objects.Remove(guid); - } - } - private bool RemoveLiveEntityByServerGuid(uint serverGuid) { - // AP-48: clear any armed cull deadline for this guid BEFORE the early-return, so a - // guid that was culled (and is no longer in _entitiesByServerGuid) but is then - // re-created by the server starts fresh — never inherits a stale, already-expired - // deadline that would evict it on the next cull tick. Mirrors holtburger's - // clear-on-upsert/delete. - _entityCuller.Forget(serverGuid); - if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity)) return false; @@ -7644,10 +7584,6 @@ public sealed class GameWindow : IDisposable // Step 2: routed through the controller; functionally identical. _liveSessionController?.Tick(); - // AP-48: 1 Hz visibility cull. Runs after the live-session drain so the player's - // WorldEntity.Position/ParentCellId reflect this frame's UpdatePosition. - TickEntityCull(dt); - // Retail teleport transit. Runs AFTER streaming (which priority-applies the // destination landblock this frame) and the live-session drain, so a destination // that became resident this frame materializes the same frame. The TAS holds at diff --git a/src/AcDream.App/World/EntityVisibilityCuller.cs b/src/AcDream.App/World/EntityVisibilityCuller.cs deleted file mode 100644 index 201015a6..00000000 --- a/src/AcDream.App/World/EntityVisibilityCuller.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; - -namespace AcDream.App.World; - -/// Per-entity world-position + landblock snapshot fed to the culler. -public readonly record struct EntityCullInfo(uint Guid, Vector3 Pos, int LbX, int LbY, bool HasLb); - -/// -/// AP-48 fix: a client-side visibility cull that prunes world entities the player has left -/// far behind, so the entity tables don't accumulate every object from every town visited -/// (which sank FPS the more the player portal-hopped). Faithful port of holtburger's -/// liveness.rs (crates/holtburger-world/src/state/liveness.rs): an entity is -/// "conservatively visible" if it's in the 3×3 landblock neighborhood OR within 384 m of the -/// player; it is evicted only after being continuously OUT of visibility for 25 s -/// (arm-on-leave / clear-on-return). The 25 s is time-since-it-left-visibility, NOT -/// time-since-last-update. -/// -/// This class is pure: it owns only the per-guid prune deadlines and decides WHO to -/// evict; the caller (GameWindow) supplies the entity snapshots and performs the actual -/// teardown through its existing pruner. The local player and any held/equipped/contained -/// item are excluded by the caller (the player by guid; inventory items never have a world -/// position so they never appear in the snapshot). -/// -public sealed class EntityVisibilityCuller -{ - /// holtburger CONSERVATIVE_VISIBILITY_DISTANCE_M (liveness.rs:12). - public const double VisibilityDistanceM = 384.0; - - /// holtburger ACE_DESTRUCTION_TIMEOUT_SECS (liveness.rs:11). - public const double DestructionTimeoutSec = 25.0; - - private readonly Dictionary _deadline = new(); - - /// Number of entities currently armed for eviction (diagnostic). - public int ArmedCount => _deadline.Count; - - /// - /// "Conservatively visible": in the 3×3 landblock neighborhood (own + adjacent landblocks) - /// OR within of the player. The landblock test uses the - /// absolute landblock id (frame-independent), so a streaming recenter on teleport doesn't - /// false-positive a near entity whose offset position is momentarily stale. - /// - public static bool IsVisible( - Vector3 entityPos, int eLbX, int eLbY, bool entityHasLb, - Vector3 playerPos, int pLbX, int pLbY) - { - bool nearLb = entityHasLb && Math.Abs(eLbX - pLbX) <= 1 && Math.Abs(eLbY - pLbY) <= 1; - return nearLb - || Vector3.DistanceSquared(entityPos, playerPos) - <= VisibilityDistanceM * VisibilityDistanceM; - } - - /// - /// Pure per-entity state transition. Given current visibility, the entity's current - /// prune deadline (null = none armed) and the current time, returns the new deadline - /// (null = cleared) and whether to evict now. Visible → clear + keep; not-visible + - /// unarmed → arm at now+25s; not-visible + armed-and-expired → evict; not-visible + - /// armed-not-expired → keep. - /// - public static (double? newDeadline, bool evict) Step(bool visible, double? deadline, double now) - { - if (visible) return (null, false); - if (deadline is null) return (now + DestructionTimeoutSec, false); - if (deadline.Value <= now) return (null, true); - return (deadline, false); - } - - /// - /// Advance the cull over the current world entities and return the guids to evict. The - /// caller must skip the local player BEFORE calling (or rely on the per-guid - /// filter here) and perform the teardown for each returned - /// guid. Mutates only the internal deadline map; does not touch the entities. - /// - public IReadOnlyList Tick(IEnumerable entities, EntityCullInfo player, double now) - { - List? evict = null; - foreach (var e in entities) - { - if (e.Guid == player.Guid) continue; // never cull the local player - bool visible = IsVisible(e.Pos, e.LbX, e.LbY, e.HasLb, player.Pos, player.LbX, player.LbY); - double? current = _deadline.TryGetValue(e.Guid, out var d) ? d : null; - var (newDeadline, doEvict) = Step(visible, current, now); - if (newDeadline is { } nd) _deadline[e.Guid] = nd; else _deadline.Remove(e.Guid); - if (doEvict) (evict ??= new List()).Add(e.Guid); - } - return (IReadOnlyList?)evict ?? Array.Empty(); - } - - /// Drop any armed deadline for a guid (e.g. when it's explicitly deleted or - /// re-created by the server, matching holtburger's clear-on-upsert). - public void Forget(uint guid) => _deadline.Remove(guid); -} diff --git a/tests/AcDream.App.Tests/World/EntityVisibilityCullerTests.cs b/tests/AcDream.App.Tests/World/EntityVisibilityCullerTests.cs deleted file mode 100644 index dad69679..00000000 --- a/tests/AcDream.App.Tests/World/EntityVisibilityCullerTests.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System.Numerics; -using AcDream.App.World; -using Xunit; - -namespace AcDream.App.Tests.World; - -/// -/// AP-48 cull (holtburger liveness.rs port): visible = 3×3 landblock neighborhood OR ≤384 m; -/// evict only after 25 s continuously out-of-visibility (arm-on-leave / clear-on-return). -/// -public sealed class EntityVisibilityCullerTests -{ - private static readonly EntityCullInfo Player = - new(0x50000001u, new Vector3(0, 0, 0), 169, 180, true); - - // ---- IsVisible ---- - - [Fact] - public void Within384m_isVisible_byDistance_evenInAFarLandblock() - => Assert.True(EntityVisibilityCuller.IsVisible( - new Vector3(50, 0, 0), 100, 100, true, new Vector3(0, 0, 0), 169, 180)); - - [Fact] - public void AdjacentLandblock_isVisible_evenWhenFarByPosition() - => Assert.True(EntityVisibilityCuller.IsVisible( - new Vector3(5000, 0, 0), 169, 181, true, new Vector3(0, 0, 0), 169, 180)); - - [Fact] - public void FarAndNotAdjacent_isNotVisible() - => Assert.False(EntityVisibilityCuller.IsVisible( - new Vector3(5000, 0, 0), 100, 100, true, new Vector3(0, 0, 0), 169, 180)); - - // ---- Step lifecycle ---- - - [Fact] - public void Step_visible_clearsDeadlineAndKeeps() - { - var (dl, evict) = EntityVisibilityCuller.Step(visible: true, deadline: 123.0, now: 200.0); - Assert.Null(dl); - Assert.False(evict); - } - - [Fact] - public void Step_notVisible_unarmed_arms() - { - var (dl, evict) = EntityVisibilityCuller.Step(visible: false, deadline: null, now: 100.0); - Assert.Equal(100.0 + EntityVisibilityCuller.DestructionTimeoutSec, dl); - Assert.False(evict); - } - - [Fact] - public void Step_notVisible_armedNotExpired_keeps() - { - var (dl, evict) = EntityVisibilityCuller.Step(visible: false, deadline: 125.0, now: 110.0); - Assert.Equal(125.0, dl); - Assert.False(evict); - } - - [Fact] - public void Step_notVisible_armedExpired_evicts() - { - var (dl, evict) = EntityVisibilityCuller.Step(visible: false, deadline: 125.0, now: 125.0); - Assert.Null(dl); - Assert.True(evict); - } - - // ---- Tick integration ---- - - [Fact] - public void Tick_neverEvictsTheLocalPlayer() - { - var c = new EntityVisibilityCuller(); - var ents = new[] { Player }; - c.Tick(ents, Player, now: 0); - c.Tick(ents, Player, now: 1000); - Assert.Empty(c.Tick(ents, Player, now: 1_000_000)); - Assert.Equal(0, c.ArmedCount); - } - - [Fact] - public void Tick_armsThenEvicts_aFarStaleEntity_afterTimeout() - { - var c = new EntityVisibilityCuller(); - var far = new EntityCullInfo(0xABCDu, new Vector3(5000, 0, 0), 10, 10, true); - var ents = new[] { far }; - - Assert.Empty(c.Tick(ents, Player, now: 0)); // arm at 0 + 25 - Assert.Equal(1, c.ArmedCount); - Assert.Empty(c.Tick(ents, Player, now: 24)); // not expired - var evicted = c.Tick(ents, Player, now: 25); // expired → evict - Assert.Equal(new[] { 0xABCDu }, evicted); - Assert.Equal(0, c.ArmedCount); // deadline cleared on evict - } - - [Fact] - public void Tick_clearsDeadline_whenEntityReturnsToVisibility_beforeTimeout() - { - var c = new EntityVisibilityCuller(); - var far = new EntityCullInfo(0xABCDu, new Vector3(5000, 0, 0), 10, 10, true); - var near = far with { Pos = new Vector3(50, 0, 0) }; // within 384 m → visible - - Assert.Empty(c.Tick(new[] { far }, Player, now: 0)); // arm - Assert.Equal(1, c.ArmedCount); - Assert.Empty(c.Tick(new[] { near }, Player, now: 10)); // returned → clear - Assert.Equal(0, c.ArmedCount); - Assert.Empty(c.Tick(new[] { near }, Player, now: 100)); // never evicted - } -}