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