From 6c7e3ef1ab36442150042d98312e857e64df4299 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 24 Jun 2026 19:45:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(physics):=20D4+W1=20=E2=80=94=20entry-rest?= =?UTF-8?q?rictions=20register=20row=20+=20named=20PvP/missile=20dispatch?= =?UTF-8?q?=20terms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part A (D4): CObjCell::check_entry_restrictions (pc:309576) gate is omitted from FindEnvCollisions. Investigation confirmed the gate requires restriction_obj (per-cell access-lock entity id) + weenie-object-table dispatch (CanMoveInto / CanBypassMoveRestrictions) — neither exist in acdream. CellPhysics has no restriction_obj field; DatReaderWriter models no per-cell access locks. Gap is inert in all dev content (ACE starter area has no access-locked env cells). Documented as AP-50 in the retail divergence register. Part B (W1): Replace the hardcoded-false smell in BspOnlyDispatch with named internal helpers PvpExempt() and MissileIgnore() that return false with retail oracle citations (pc:276808-276841 and pc:274385 respectively). BspOnlyDispatch now folds all three terms in retail's exact predicate structure. Behavior is byte-identical in M1.5 scope (both stubs false ⇒ reduces to HAS_PHYSICS_BSP_PS check alone, same as before). A6.P7 door dispatch unchanged. Tests: 3 new guard tests in A6P7DispatchRulesTests — W1_PvpExempt_ReturnsFalseInM15Scope, W1_MissileIgnore_ReturnsFalseInM15Scope, W1_BspOnlyDispatch_DoorStateStillDispatchesBspOnly. Suite: 1590 pass / 0 fail / 2 skip (was 1587 + 3 = 1590). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 3 +- src/AcDream.Core/Physics/TransitionTypes.cs | 79 +++++++++++++++---- .../Physics/A6P7DispatchRulesTests.cs | 44 +++++++++++ 3 files changed, 110 insertions(+), 16 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 3f80c1fe..29c333ca 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -98,7 +98,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 44 rows +## 3. Documented approximation (AP) — 45 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -151,6 +151,7 @@ accepted-divergence entries (#96, #49, #50). | 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 | | AP-49 | `TeleportAnimSequencer.ComputeFadeAlpha` uses a smoothstep curve for all fade states; retail's `gmSmartBoxUI::UseTime` drives fade alpha through a 1024-entry `GetAnimLevel` lookup table whose contents are unrecovered | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`ComputeFadeAlpha`) | `GetAnimLevel` table address and contents not yet extracted via cdb; smoothstep is a perceptually-reasonable S-curve that closely approximates a typical gamma-corrected fade; the visual difference is imperceptible under normal teleport conditions | Fade timing differs subtly from retail — ramp-in or ramp-out may be slightly too fast/slow at the black-fade edges; retire by reading the `GetAnimLevel` table via cdb and replacing smoothstep with a direct 1024-entry lookup (spec §8) | `gmSmartBoxUI::UseTime` 0x004d6e30; `GetAnimLevel` 1024-entry table (address unrecovered — spec §8 cdb trace) | +| AP-50 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 | --- diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index b40dd811..e48e6ab3 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -642,9 +642,54 @@ public sealed class Transition Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1"; // ----------------------------------------------------------------------- - // A6.P7 (2026-05-25) — retail-binary dispatch rule + // A6.P7 (2026-05-25) + W1 (2026-06-24) — retail-binary dispatch rule // ----------------------------------------------------------------------- + /// + /// W1: Named predicate for the PvP-exemption term in retail's + /// CPhysicsObj::FindObjCollisions dispatch (pc:276861, + /// acclient_2013_pseudo_c.txt:276808–276841). + /// + /// + /// In retail ebp_1 is non-null when the mover is a player AND + /// the target is not impenetrable AND PK/PKLite flags indicate a + /// PvP-exempt pairing — i.e. the mover should pass through rather than + /// collide. While this is non-null the dispatch takes the cyl+sphere + /// path regardless of HAS_PHYSICS_BSP_PS, effectively making + /// PvP-exempt players transparent to BSP-only objects. + /// + /// + /// + /// M1.5 scope has no PK. Returns false always. + /// Wire through mover + target state when PK ships (M2+ / phase TBD). + /// Retail oracle: pc:276808–276841. + /// + /// + internal static bool PvpExempt() => false; // wire when PK ships (M2+) — pc:276808–276841 + + /// + /// W1: Named predicate for the OBJECTINFO::missile_ignore term + /// in retail's CPhysicsObj::FindObjCollisions dispatch + /// (pc:276861, acclient_2013_pseudo_c.txt:274385 + :276858–276861). + /// + /// + /// In retail eax_12 = OBJECTINFO::missile_ignore(ebx, this) + /// returns non-zero when either the TARGET has MISSILE_PS (0x40) + /// set, or the mover has MISSILE_PS set and the target is a + /// creature that is not the mover's designated target. When non-zero the + /// dispatch takes the cyl+sphere path regardless of + /// HAS_PHYSICS_BSP_PS — missiles pass through BSP-only objects. + /// + /// + /// + /// M1.5 scope has no missiles. Returns false always. + /// Wire through mover OBJECTINFO + target state when missiles ship (F.3). + /// Retail oracle: OBJECTINFO::missile_ignore pc:274385; + /// dispatch pc:276858–276861. + /// + /// + internal static bool MissileIgnore() => false; // wire when missiles ship (F.3) — pc:274385 + /// /// A6.P7 retail-binary dispatch predicate. Returns true when an /// entity's collision queries should go to its BSP exclusively, @@ -660,23 +705,24 @@ public sealed class Transition /// else /// // BSP-only via CPartArray::FindObjCollisions /// - /// where ebp_1 is the PvP-target-player flag (lines 276808- - /// 276841) and eax_12 is the OBJECTINFO::missile_ignore - /// result (line 274385). The flag is named + /// where ebp_1 is the PvP-target-player flag (lines 276808– + /// 276841, see ) and eax_12 is the + /// OBJECTINFO::missile_ignore result (line 274385, see + /// ). The flag is named /// HAS_PHYSICS_BSP_PS = 0x10000 in acclient.h:2833 and /// PhysicsState.HasPhysicsBSP in ACE /// (references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:24). /// /// /// - /// M1.5 scope (walking-vs-static, no PK, no missiles) treats both - /// ebp_1 and eax_12 as false. The predicate - /// reduces to (state & HAS_PHYSICS_BSP_PS) != 0. When - /// PK ships (M2+ phase) and missiles ship (F.3), wire the - /// PvP-exemption and missile_ignore checks through as additional - /// parameters following retail's - /// references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:412 - /// dispatch shape. + /// BSP-only iff: + /// HAS_PHYSICS_BSP_PS is set on the entity + /// AND the mover is not PvP-exempt for this target ( = false) + /// AND the entity is not missile-ignore exempt ( = false). + /// In M1.5 and both + /// return false, so the predicate reduces to + /// (state & HAS_PHYSICS_BSP_PS) != 0 — no behavior change + /// from before W1. /// /// /// @@ -688,10 +734,13 @@ public sealed class Transition /// PhysicsState value (as stored on /// ShadowEntry.State). /// True when retail would dispatch BSP-only — i.e. when - /// the entity has HAS_PHYSICS_BSP_PS set; cyl/sphere shapes - /// must be skipped at the per-entry dispatch site. + /// the entity has HAS_PHYSICS_BSP_PS set AND neither the PvP + /// exemption nor the missile-ignore exemption applies; cyl/sphere + /// shapes must be skipped at the per-entry dispatch site. public static bool BspOnlyDispatch(uint entityState) - => (entityState & (uint)PhysicsStateFlags.HasPhysicsBsp) != 0; + => (entityState & (uint)PhysicsStateFlags.HasPhysicsBsp) != 0 + && !PvpExempt() + && !MissileIgnore(); // ----------------------------------------------------------------------- // Public entry point diff --git a/tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs b/tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs index 2bcb4e8d..94440fb3 100644 --- a/tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs +++ b/tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs @@ -75,4 +75,48 @@ public class A6P7DispatchRulesTests { Assert.Equal(expected, Transition.BspOnlyDispatch(entityState)); } + + // ----------------------------------------------------------------------- + // W1 (2026-06-24) — named PvP/missile terms are false in M1.5 + // ----------------------------------------------------------------------- + + /// + /// W1: must return false in M1.5. + /// When PK ships (M2+) this stub will accept mover + target state; + /// the test pins the current named-false value as a guard. + /// Retail oracle: CPhysicsObj::FindObjCollisions pc:276808–276841. + /// + [Fact] + public void W1_PvpExempt_ReturnsFalseInM15Scope() + { + // PvP hasn't shipped (M1.5); the stub always returns false. + Assert.False(Transition.PvpExempt()); + } + + /// + /// W1: must return false in M1.5. + /// When missiles ship (F.3) this stub will accept mover OBJECTINFO + + /// target state; the test pins the current named-false value as a guard. + /// Retail oracle: OBJECTINFO::missile_ignore pc:274385; + /// dispatch use pc:276858–276861. + /// + [Fact] + public void W1_MissileIgnore_ReturnsFalseInM15Scope() + { + // Missiles haven't shipped (M1.5); the stub always returns false. + Assert.False(Transition.MissileIgnore()); + } + + /// + /// Guard: with both W1 terms false, a BSP-flagged entity still + /// dispatches BSP-only — A6.P7 door behavior is unchanged after W1. + /// This protects the A6.P7 cottage-door / dungeon-wall fix. + /// + [Fact] + public void W1_BspOnlyDispatch_DoorStateStillDispatchesBspOnly() + { + // Cottage door state from A6.P7 investigation: 0x10008 = STATIC | REPORT | HAS_BSP + const uint doorState = 0x00010008u; + Assert.True(Transition.BspOnlyDispatch(doorState)); + } }