diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 5a49675d..3a047c5f 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -116,7 +116,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 3. Documented approximation (AP) — 93 active rows (AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-127 filed same slice for the two minor unmodeled bonus properties; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
+## 3. Documented approximation (AP) — 93 active rows (AP-71 retired 2026-07-30 at Campaign P Slice P4 — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-129 filed same slice for the narrower remaining gap — `CanMoveInto`'s owner/guest-list decode is unmodeled, so a genuinely restricted cell fails closed for everyone, not just intruders; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-127 filed same slice for the two minor unmodeled bonus properties; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
@@ -180,7 +180,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| ~~AP-66~~ | **RETIRED 2026-07-13 — authored paperdoll empty-slot presentation.** The earlier “no silhouettes” conclusion inspected the ItemList elements' own media but missed `UIElement_ItemList::InternalCreateItem`, which clones a distinct `UIElement_UIItem` catalog prototype for each location. All 21 supported jewelry, weapon, ammo, shield, clothing, cloak, trinket, and armor lists now resolve their exact `ItemSlot_Empty` surface from live DAT; `PostInit` confirms non-armor lists remain visible while the nine armor lists toggle with Slots. | `src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs`; `ItemListCellTemplate.cs`; `PaperdollController.cs` | — | — | `gmPaperDollUI::GetLocationInfoFromElementID @ 0x004A37F0`; `PostInit @ 0x004A5360`; `UIElement_ItemList::InternalCreateItem @ 0x004E3570`; `LayoutDesc 0x21000037` |
| AP-68 | 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-69 | acdream preserves one accepted active record across rebucketing and ports retail's 25-second leave-visibility destruction lifecycle. Spatially resident records cancel expiry; otherwise the ACE compatibility boundary uses holtburger's conservative 384-unit distance envelope and retains attached/container/wielder/parent-owned objects. Expiry uses the exact generation-safe active teardown, then retains only a cold `EntitySpawn` because ACE can keep the GUID in `KnownObjects` and omit CreateObject on revisit; explicit F747/new generation/session reset removes it. DIVERGENCE: retail can delete the complete object under its visibility protocol; the fallback does not yet derive visibility from retail/ACE ObjCell PVS (`SeenOutside` plus `VisibleCells`), and trade/container preview retention has no separate lifecycle flag. | `src/AcDream.App/World/LiveEntityRuntime.cs`; `src/AcDream.App/World/LiveEntityLivenessController.cs`; `src/AcDream.App/World/DormantLiveEntityStore.cs`; `LiveEntityHydrationController.OnPrune` | Prevents stale portal destinations from accumulating animation/effect/render owners while still allowing doors, signs, portals, and other ACE-known objects to rematerialize when the server does not resend them | Dormant data-only snapshots can grow with every unique ACE destination until F747 or session reset; a nonresident object outside 384 units that remains visible through an unusual long EnvCell PVS could expire early; a future preview-only object with no parent/container ownership could also expire. Replace the compatibility predicate when exact ObjCell PVS and preview lifetimes are available | `CPhysicsObj::prepare_to_leave_visibility` 0x00511F40; `CPhysicsObj::prepare_to_enter_world` 0x00511FA0; `CObjectMaint::AddObjectToBeDestroyed` 0x00508F70; `CObjectMaint::UseTime` 0x005089B0; ACE `KnownObjects`; `docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md` |
-| AP-71 | **`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 |
+| ~~AP-71~~ | **RETIRED 2026-07-30 (Campaign P Slice P4) — the `check_entry_restrictions` gate is now ported at the head of the indoor branch of `Transition.FindEnvCollisions`.** `ObjectInfo.CheckEntryRestrictions` (`src/AcDream.Core/Physics/TransitionTypes.cs`) reproduces retail's exact order: NPCs/props bypass, a mover with `CanBypassMoveRestrictions` (new PWD-bitfield decode, `BF_ADMIN 0x100000` AND `BF_IMMUNE_CELL_RESTRICTIONS 0x400000`, `acclient.h:6452-6454`) bypasses, an ordinary cell (`RestrictionObj == 0`) is a no-op. `CellPhysics.RestrictionObj` is now wired from the DAT-baked `EnvCell.RestrictionObj` field (§4.3's old open question — RESOLVED via `references/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs:32,66-67` and an independent reflection probe of `Chorizite.DatReaderWriter` 2.1.7's own `EnvCell.RestrictionObj` field: it is a plain per-cell DAT field gated by `EnvCellFlags.HasRestrictionObj (0x8)`, NOT a live wire override; the BN pseudo-C's "count for an array alloc" reading at the same `UnPack` offset was the mis-attributed field-name collision `feedback_bn_decomp_field_names` warned about). Wired in BOTH the dev/graph-fixture path (`PhysicsDataCache.CacheCellStruct`) and the production/prepared path (`CachePreparedCellStruct`) — the latter already receives a live parsed `envCell` for `Position`/`EnvironmentId`, so no bake-format change was needed. See AP-129 for the narrower remaining gap this leaves. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ObjectInfo.CheckEntryRestrictions`, `Transition.FindEnvCollisions`); `src/AcDream.Core/Physics/PhysicsDataCache.cs` (`CellPhysics.RestrictionObj`) | — | — | `CObjCell::check_entry_restrictions` pc:308873-308912 (0x0052b6d0); `CEnvCell::find_env_collisions` pc:309573-309597; `ACCWeenieObject::CanBypassMoveRestrictions` 0x0058c500; `ACCWeenieObject::CanMoveInto` 0x0058da40; `references/ACE/Source/ACE.Server/Physics/Common/ObjCell.cs:286-333` |
+| AP-129 | **`check_entry_restrictions`'s `CanMoveInto` (house owner IID + guest/ban list) is unmodeled** — retail resolves the cell's `restriction_obj` to a live weenie and asks `CanMoveInto(mover)` (owner IID match, else a `RestrictionDB` guest/ban list check). acdream has no restriction-weenie resolution and no `RestrictionDB`/house-guest wire model (`HouseData 0x0225`, `HouseUpdateRestrictions 0x0248`, and the `House_*` guest-list opcode family are enumerated in `GameEventType.cs` but none are parsed into any acdream data structure). `ObjectInfo.CheckEntryRestrictions` therefore fails CLOSED (`Collided`) for ANY cell whose DAT-baked `RestrictionObj` is nonzero and whose mover cannot bypass — this is retail's own fallback when the restriction object can't be resolved, so it is not a fabricated behavior, but it means a legitimate house owner/guest would ALSO be blocked, not just intruders. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ObjectInfo.CheckEntryRestrictions`) | Zero access-restricted EnvCells exist in the ACE starter-area test content the project runs against day to day (this is DAT-authored, per-cell data — not synthesized), so the fail-closed default is inert for ordinary dungeon/outdoor testing; it only fires if a player-built house (or other content with `EnvCellFlags.HasRestrictionObj` set) is visited. Outdoor `CLandCell` restriction (`LandblockInfo.RestrictionTables`, a separate per-landblock packed hash table — `references/ACE/Source/ACE.Server/Physics/Common/Landblock.cs:508-520`, `ACE.DatLoader.FileTypes.LandblockInfo.cs:43`) is a distinct, unparsed DAT structure and is explicitly NOT ported by this row's gate (the gate only reads `CellPhysics.RestrictionObj`, the indoor/EnvCell field). | Any house-barrier EnvCell visited today denies entry to everyone including its owner, until `RestrictionDB`/`CanMoveInto` wire parsing lands; an outdoor restricted cell (if that content ever exists) is not gated at all. | `ACCWeenieObject::CanMoveInto` 0x0058da40; `references/ACE/Source/ACE.Server/Physics/Common/ObjCell.cs:301-325`; `references/ACE/Source/ACE.Server/Network/Structure/RestrictionDB.cs` |
| AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 |
| AP-74 | **UseDone WeenieError text comes from a hardcoded subset map, not the portal String tables** — retail resolves the 0x01C7 UseDone error code through the client String tables into the canonical line ("You are not trained in healing!"); acdream's `WeenieErrorText.For` hardcodes the handful of codes the current use/heal flows produce (0x001D/0x04EB/0x04FC/0x04FE, texts phrased after the ACE enum names) with a generic code-carrying fallback. | `src/AcDream.Core.Net/Messages/WeenieErrorText.cs` | Every refusal is now visible; only unmapped wording deviates, and those lines retain the raw code. Retire by porting the String-table lookup (#202). | An unmapped WeenieError shows a generic line instead of retail's exact sentence | retail String-table error lookup; ACE `WeenieError.cs` values |
| AP-73 | **Character raises mutate optimistically, contrary to retail's server-authoritative flow** — after sending RaiseAttribute/RaiseVital/RaiseSkill/TrainSkill, `CharacterSheetProvider.ApplyLocalRaise` immediately bumps ranks and debits XP/credits. Named retail permits one request in flight, ghosts the clicked button, and waits for an authoritative quality-change element message before changing displayed state (**#199**). | `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` | ACE usually accepts client-affordable raises, so its later property echoes conceal the incorrect prediction; Wave 8 removes local mutation and owns one awaiting request | A rejected/reordered raise can display invented state until a later full refresh, and repeated clicks can create multiple speculative spends | `gmAttributeUI`/`gmSkillUI` raise and quality-change paths, pinned in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
diff --git a/src/AcDream.Core/Physics/EntityCollisionFlags.cs b/src/AcDream.Core/Physics/EntityCollisionFlags.cs
index eceb61f3..79f0a4d9 100644
--- a/src/AcDream.Core/Physics/EntityCollisionFlags.cs
+++ b/src/AcDream.Core/Physics/EntityCollisionFlags.cs
@@ -49,6 +49,19 @@ public enum EntityCollisionFlags : byte
/// requires this distinction for ethereal targets.
///
HasWeenie = 0x20,
+ ///
+ /// AP-71 (Campaign P Slice P4, 2026-07-30): set when BOTH
+ /// BF_ADMIN (0x100000) and BF_IMMUNE_CELL_RESTRICTIONS
+ /// (0x400000) are set in pwd._bitfield — the exact two-bit AND
+ /// retail's ACCWeenieObject::CanBypassMoveRestrictions
+ /// (0x0058c500) evaluates to let a mover through an access-restricted
+ /// (house-barrier) cell without an owner/guest-list check. Bit names
+ /// confirmed at acclient.h:6452-6454
+ /// (PublicWeenieDesc::BitfieldIndex); BF_ADMIN is
+ /// independently decoded the same way in
+ /// .
+ ///
+ CanBypassMoveRestrictions = 0x40,
}
/// Helpers to convert raw retail bitfields into .
@@ -72,6 +85,10 @@ public static class EntityCollisionFlagsExt
if ((bitfield & 0x20u) != 0) flags |= EntityCollisionFlags.IsPK;
if ((bitfield & 0x200000u) != 0) flags |= EntityCollisionFlags.IsImpenetrable;
if ((bitfield & 0x2000000u) != 0) flags |= EntityCollisionFlags.IsPKLite;
+ // AP-71: BF_ADMIN (0x100000) AND BF_IMMUNE_CELL_RESTRICTIONS (0x400000),
+ // matching CanBypassMoveRestrictions' own AND (not OR) of the two bits.
+ if ((bitfield & 0x100000u) != 0 && (bitfield & 0x400000u) != 0)
+ flags |= EntityCollisionFlags.CanBypassMoveRestrictions;
return flags;
}
@@ -95,6 +112,10 @@ public static class EntityCollisionFlagsExt
if ((flags & EntityCollisionFlags.IsPK) != 0) state |= ObjectInfoState.IsPK;
if ((flags & EntityCollisionFlags.IsPKLite) != 0) state |= ObjectInfoState.IsPKLite;
if ((flags & EntityCollisionFlags.IsImpenetrable) != 0) state |= ObjectInfoState.IsImpenetrable;
+ // AP-71 (Campaign P Slice P4): the mover's own house-restriction bypass,
+ // consumed by ObjectInfo.CheckEntryRestrictions.
+ if ((flags & EntityCollisionFlags.CanBypassMoveRestrictions) != 0)
+ state |= ObjectInfoState.CanBypassMoveRestrictions;
return state;
}
diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs
index 91e60ade..b7a520b2 100644
--- a/src/AcDream.Core/Physics/PhysicsDataCache.cs
+++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs
@@ -477,6 +477,13 @@ public sealed class PhysicsDataCache
// #107: retail CEnvCell.seen_outside — consumed by AdjustPosition's
// indoor not-found fallback (acclient :280037).
SeenOutside = envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside),
+ // AP-71 (Campaign P Slice P4, 2026-07-30): retail CObjCell::
+ // restriction_obj — a DAT-baked uint32 gated by EnvCellFlags.
+ // HasRestrictionObj (0x8), confirmed via ACE.DatLoader.FileTypes.
+ // EnvCell.cs:66-67 and Chorizite.DatReaderWriter's own EnvCell
+ // field of the same name (reflection-confirmed 2026-07-30). Zero
+ // for every ordinary (non-house-barrier) cell.
+ RestrictionObj = envCell.RestrictionObj,
};
_cellStruct[envCellId] = cellPhysics;
@@ -654,6 +661,14 @@ public sealed class PhysicsDataCache
VisibleCellIds = new HashSet(
preparedTopology.VisibleCellIds),
SeenOutside = preparedTopology.SeenOutside,
+ // AP-71 (Campaign P Slice P4, 2026-07-30): read straight from the
+ // locally-parsed live envCell, same as SeenOutside historically
+ // was before the bake pipeline existed — RestrictionObj is NOT
+ // (and does not need to be) part of the baked FlatEnvCellTopology
+ // payload; the caller (LandblockPhysicsPublisher.PublishCell)
+ // already has a real parsed DatReaderWriter.DBObjs.EnvCell in
+ // hand for Position/EnvironmentId, so this is free.
+ RestrictionObj = envCell.RestrictionObj,
});
}
@@ -1062,4 +1077,19 @@ public sealed class CellPhysics
/// contain it AND this flag is set (retail acclient :280037-280046).
///
public bool SeenOutside { get; init; }
+
+ ///
+ /// AP-71 (Campaign P Slice P4, 2026-07-30): retail CObjCell::
+ /// restriction_obj — the DAT-baked object IID (gated by
+ /// EnvCellFlags.HasRestrictionObj) that CObjCell::
+ /// check_entry_restrictions (0x0052b6d0) resolves and consults
+ /// before letting a player into this cell. Zero (the default) for
+ /// every ordinary cell — only house-barrier EnvCells set it. Consumed
+ /// by via
+ /// . Resolving the value into
+ /// a live restriction weenie (for the owner/guest-list
+ /// CanMoveInto check) is NOT modeled — see the AP-71 register
+ /// history for the narrower unfed-input row.
+ ///
+ public uint RestrictionObj { get; init; }
}
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 0dd9030e..e4386898 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -42,6 +42,15 @@ public enum ObjectInfoState : uint
/// retail's OBJECTINFO::state bits (acclient.h:6190-6194).
IsPK = 0x800,
IsPKLite = 0x1000,
+ ///
+ /// AP-71 (Campaign P Slice P4, 2026-07-30): the mover's own PWD-bitfield
+ /// BF_ADMIN & BF_IMMUNE_CELL_RESTRICTIONS AND (see
+ /// EntityCollisionFlags.CanBypassMoveRestrictions), consumed by
+ /// exactly like
+ /// / flow through the same
+ /// moverFlags OR-in.
+ ///
+ CanBypassMoveRestrictions = 0x2000,
}
///
@@ -97,6 +106,7 @@ public sealed class ObjectInfo
public bool EdgeSlide => (State & ObjectInfoState.EdgeSlide) != 0;
public bool PathClipped => (State & ObjectInfoState.PathClipped) != 0;
public bool FreeRotate => (State & ObjectInfoState.FreeRotate) != 0;
+ public bool CanBypassMoveRestrictions => (State & ObjectInfoState.CanBypassMoveRestrictions) != 0;
///
/// Verbatim decision tree from retail OBJECTINFO::missile_ignore
@@ -124,6 +134,59 @@ public sealed class ObjectInfo
&& (targetFlags & EntityCollisionFlags.IsCreature) != 0;
}
+ ///
+ /// AP-71 (Campaign P Slice P4, 2026-07-30). Retail CObjCell::
+ /// check_entry_restrictions (named-retail pc:308873-308912,
+ /// 0x0052b6d0) — called FIRST by both CEnvCell::find_env_collisions
+ /// (pc:309576) and CLandCell::find_env_collisions (pc:317075),
+ /// before any BSP/terrain collision work. Ported here for the indoor
+ /// (EnvCell) call site only; the outdoor CLandCell equivalent
+ /// reads a completely separate DAT structure
+ /// (LandblockInfo.RestrictionTables, a per-landblock packed hash
+ /// table — confirmed via references/ACE/Source/ACE.Server/Physics/
+ /// Common/Landblock.cs:508-520 and ACE.DatLoader.FileTypes.
+ /// LandblockInfo.cs:43) that acdream does not parse; that scope gap
+ /// is filed separately (see the AP-71 register history), not ported here.
+ ///
+ ///
+ /// Gate order, verbatim from the decompile except for the "mover is
+ /// null" branch: acdream's is never invoked on
+ /// a null mover (unlike retail's t->object_info.object
+ /// pointer, which is defensively null-checked but never actually null
+ /// for a live transition — CPhysicsObj::transition 0x00512dc0
+ /// always seeds object_info.object = this), so that branch and
+ /// the parallel moverWeenie == 0 check have no acdream analog and
+ /// are omitted. What remains: NPCs/props (not )
+ /// bypass entirely (retail's state & 0x100 test); a mover
+ /// whose own PWD bitfield grants
+ /// bypasses; an ordinary cell ( ==
+ /// 0 — the overwhelmingly common case, DAT-gated by
+ /// EnvCellFlags.HasRestrictionObj) is a no-op.
+ ///
+ ///
+ ///
+ /// Otherwise (a restriction IS authored on this cell and the mover
+ /// cannot bypass): retail resolves the live restriction weenie
+ /// (CPhysicsObj::GetObjectA) and asks CanMoveInto (house
+ /// owner IID + guest/ban list, ACCWeenieObject::CanMoveInto
+ /// 0x0058da40). acdream has no restriction-weenie resolution or
+ /// guest-list wire model yet (see the AP-71 register history for the
+ /// narrower unfed-input row this leaves), so it cannot correctly answer
+ /// CanMoveInto — it fails CLOSED (), exactly matching retail's own
+ /// fallback when the restriction object can't be resolved (pc:704-716
+ /// falls through to COLLIDED_TS).
+ ///
+ ///
+ public TransitionState CheckEntryRestrictions(uint cellRestrictionObj)
+ {
+ if (!IsPlayer) return TransitionState.OK; // NPCs/props bypass entirely
+ if (CanBypassMoveRestrictions) return TransitionState.OK;
+ if (cellRestrictionObj == 0) return TransitionState.OK; // ordinary cell — no-op
+
+ return TransitionState.Collided;
+ }
+
///
/// Return the Z threshold for a walkable surface appropriate to the
/// current movement context.
@@ -2965,6 +3028,20 @@ public sealed class Transition
if (cellLow >= 0x0100 && engine.DataCache is not null)
{
var cellPhysics = engine.DataCache.GetCellStruct(sp.CheckCellId);
+
+ // AP-71 (Campaign P Slice P4, 2026-07-30): retail CEnvCell::
+ // find_env_collisions (pc:309576) calls check_entry_restrictions
+ // as its FIRST statement — before any HasPhysics/BSP dispatch.
+ // cellPhysics is null-safe: an uncached/BSP-less cell has no
+ // authored restriction data either.
+ var restrictionState = ObjectInfo.CheckEntryRestrictions(cellPhysics?.RestrictionObj ?? 0);
+ if (restrictionState != TransitionState.OK)
+ {
+ if ((ObjectInfo.State & ObjectInfoState.Contact) == 0)
+ ci.CollidedWithEnvironment = true;
+ return restrictionState;
+ }
+
if (cellPhysics is not null &&
CollisionTraversal.HasPhysics(engine.DataCache!, cellPhysics))
{
diff --git a/tests/AcDream.Core.Tests/Physics/Ap71EntryRestrictionGateTests.cs b/tests/AcDream.Core.Tests/Physics/Ap71EntryRestrictionGateTests.cs
new file mode 100644
index 00000000..72893e1d
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Ap71EntryRestrictionGateTests.cs
@@ -0,0 +1,249 @@
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Physics;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Conformance tests for AP-71 (Campaign P Slice P4, 2026-07-30) — retail
+/// CObjCell::check_entry_restrictions (named-retail pc:308873-308912,
+/// 0x0052b6d0), ported as and
+/// wired at the head of the indoor branch of
+/// Transition.FindEnvCollisions (src/AcDream.Core/Physics/
+/// TransitionTypes.cs).
+///
+///
+/// Retail gate order: NPCs/props (not a player) bypass entirely; a mover
+/// whose PWD bitfield grants CanBypassMoveRestrictions (BF_ADMIN &
+/// BF_IMMUNE_CELL_RESTRICTIONS) bypasses; an ordinary cell (no
+/// restriction_obj authored) is a no-op; otherwise retail resolves
+/// the restriction weenie and asks CanMoveInto — acdream has no
+/// owner/guest-list model for that yet, so it fails CLOSED (Collided),
+/// matching retail's own fallback when the restriction object can't be
+/// resolved (pc:704-716).
+///
+///
+public sealed class Ap71EntryRestrictionGateTests
+{
+ private const uint RestrictionObjGuid = 0x80001234u;
+
+ [Fact]
+ public void OrdinaryCell_NoRestrictionObj_IsNoOp_ForPlayer()
+ {
+ var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer };
+
+ Assert.Equal(TransitionState.OK, mover.CheckEntryRestrictions(cellRestrictionObj: 0));
+ }
+
+ [Fact]
+ public void OrdinaryCell_NoRestrictionObj_IsNoOp_ForNonPlayer()
+ {
+ // NPCs/props aren't players; confirms the gate is a genuine no-op for
+ // the overwhelmingly common (non-house) content regardless of mover kind.
+ var mover = new ObjectInfo { State = ObjectInfoState.None };
+
+ Assert.Equal(TransitionState.OK, mover.CheckEntryRestrictions(cellRestrictionObj: 0));
+ }
+
+ [Fact]
+ public void RestrictedCell_NonPlayerMover_Bypasses()
+ {
+ // Retail's (state & 0x100) == 0 -> OK early return: NPCs/props never
+ // gated by house restrictions regardless of the cell's restriction_obj.
+ var mover = new ObjectInfo { State = ObjectInfoState.None };
+
+ Assert.Equal(
+ TransitionState.OK,
+ mover.CheckEntryRestrictions(RestrictionObjGuid));
+ }
+
+ [Fact]
+ public void RestrictedCell_PlayerMover_CannotBypass_FailsClosed()
+ {
+ // A restricted cell, a player mover, no CanBypassMoveRestrictions bit:
+ // acdream cannot resolve CanMoveInto (no guest-list model) -> Collided,
+ // matching retail's own fallback when the restriction weenie can't be
+ // resolved (pc:704-716 fallthrough to COLLIDED_TS).
+ var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer };
+
+ Assert.Equal(
+ TransitionState.Collided,
+ mover.CheckEntryRestrictions(RestrictionObjGuid));
+ }
+
+ [Fact]
+ public void RestrictedCell_PlayerMover_CanBypassMoveRestrictions_PassesThrough()
+ {
+ // BF_ADMIN & BF_IMMUNE_CELL_RESTRICTIONS both set on the mover's own
+ // PWD bitfield (via EntityCollisionFlagsExt.ToMoverState) -> OK.
+ var mover = new ObjectInfo
+ {
+ State = ObjectInfoState.IsPlayer | ObjectInfoState.CanBypassMoveRestrictions,
+ };
+
+ Assert.Equal(
+ TransitionState.OK,
+ mover.CheckEntryRestrictions(RestrictionObjGuid));
+ }
+
+ // ── PWD-bitfield decode (mirrors the existing EntityCollisionFlagsTests
+ // pattern for IsPK/IsPKLite/IsImpenetrable) ─────────────────────────────
+
+ [Fact]
+ public void FromPwdBitfield_OnlyAdminBit_DoesNotGrantBypass()
+ {
+ // BF_ADMIN alone (0x100000) is not sufficient — retail ANDs both bits.
+ var flags = EntityCollisionFlagsExt.FromPwdBitfield(0x100000u);
+ Assert.False(flags.HasFlag(EntityCollisionFlags.CanBypassMoveRestrictions));
+ }
+
+ [Fact]
+ public void FromPwdBitfield_OnlyImmuneCellRestrictionsBit_DoesNotGrantBypass()
+ {
+ // BF_IMMUNE_CELL_RESTRICTIONS alone (0x400000) is not sufficient either.
+ var flags = EntityCollisionFlagsExt.FromPwdBitfield(0x400000u);
+ Assert.False(flags.HasFlag(EntityCollisionFlags.CanBypassMoveRestrictions));
+ }
+
+ [Fact]
+ public void FromPwdBitfield_BothAdminAndImmuneCellRestrictionsBits_GrantsBypass()
+ {
+ uint bitfield = 0x100000u | 0x400000u;
+ var flags = EntityCollisionFlagsExt.FromPwdBitfield(bitfield);
+ Assert.True(flags.HasFlag(EntityCollisionFlags.CanBypassMoveRestrictions));
+ }
+
+ [Fact]
+ public void ToMoverState_CanBypassMoveRestrictions_TranslatesIndependently()
+ {
+ Assert.Equal(
+ ObjectInfoState.CanBypassMoveRestrictions,
+ EntityCollisionFlags.CanBypassMoveRestrictions.ToMoverState());
+ }
+
+ [Fact]
+ public void FromPwdBitfield_ThenToMoverState_EndToEnd_AdminBypassesRestriction()
+ {
+ // Full pipeline: a wire bitfield with BF_PLAYER | BF_ADMIN |
+ // BF_IMMUNE_CELL_RESTRICTIONS decodes through FromPwdBitfield ->
+ // ToMoverState -> ORs into moverFlags -> ObjectInfo.State ->
+ // CheckEntryRestrictions passes through a restricted cell.
+ uint bitfield = 0x8u | 0x100000u | 0x400000u;
+ ObjectInfoState moverState =
+ ObjectInfoState.IsPlayer | EntityCollisionFlagsExt.FromPwdBitfield(bitfield).ToMoverState();
+
+ var mover = new ObjectInfo { State = moverState };
+
+ Assert.Equal(
+ TransitionState.OK,
+ mover.CheckEntryRestrictions(RestrictionObjGuid));
+ }
+
+ // ── CellPhysics.RestrictionObj plumbing (ordinary-cell no-op proof) ────
+
+ [Fact]
+ public void CellPhysics_DefaultRestrictionObj_IsZero()
+ {
+ // Default-constructed CellPhysics (the shape every existing test
+ // fixture builds) carries RestrictionObj == 0 — the exact "ordinary
+ // cell" no-op input CheckEntryRestrictions expects. Proves the new
+ // field cannot silently flip any existing fixture's behavior.
+ var cellPhysics = new CellPhysics
+ {
+ WorldTransform = System.Numerics.Matrix4x4.Identity,
+ InverseWorldTransform = System.Numerics.Matrix4x4.Identity,
+ Resolved = new System.Collections.Generic.Dictionary(),
+ };
+
+ Assert.Equal(0u, cellPhysics.RestrictionObj);
+ }
+
+ // ── End-to-end: the gate wired into Transition.FindEnvCollisions ──────
+ // A single indoor cell with an EMPTY physics BSP (no walls of its own) —
+ // the ONLY thing that can stop the sphere here is the entry-restriction
+ // gate. Proves the wiring at the top of FindEnvCollisions's indoor
+ // branch, not just the pure CheckEntryRestrictions logic above.
+
+ private const uint CellId = 0xA9B40157u;
+
+ private static CellPhysics MakeIndoorCell(uint restrictionObj) => new()
+ {
+ BSP = new PhysicsBSPTree
+ {
+ Root = new PhysicsBSPNode
+ {
+ Type = BSPNodeType.Leaf,
+ BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 10f },
+ },
+ },
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Resolved = new Dictionary(),
+ CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf } },
+ RestrictionObj = restrictionObj,
+ };
+
+ private static PhysicsEngine MakeEngine(CellPhysics cellPhysics)
+ {
+ var engine = new PhysicsEngine();
+ engine.DataCache = new PhysicsDataCache();
+ engine.DataCache.RegisterCellStructForTest(CellId, cellPhysics);
+ return engine;
+ }
+
+ [Fact]
+ public void EndToEnd_RestrictedCell_PlayerCannotBypass_TransitionHaltsAtOrigin()
+ {
+ var engine = MakeEngine(MakeIndoorCell(restrictionObj: 0xABCDu));
+
+ var from = new Vector3(0.1f, 0f, 0.2f);
+ var to = new Vector3(0.7f, 0f, 0.2f);
+ var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, cellId: CellId);
+ t.ObjectInfo.State |= ObjectInfoState.IsPlayer;
+
+ t.FindTransitionalPosition(engine);
+
+ Assert.True(
+ System.MathF.Abs(t.SpherePath.CurPos.X - from.X) < 1e-4f,
+ $"Restricted cell must halt the player at the origin; CurPos.X={t.SpherePath.CurPos.X:F4}");
+ }
+
+ [Fact]
+ public void EndToEnd_RestrictedCell_PlayerCanBypass_TransitionReachesTarget()
+ {
+ var engine = MakeEngine(MakeIndoorCell(restrictionObj: 0xABCDu));
+
+ var from = new Vector3(0.1f, 0f, 0.2f);
+ var to = new Vector3(0.7f, 0f, 0.2f);
+ var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, cellId: CellId);
+ t.ObjectInfo.State |= ObjectInfoState.IsPlayer | ObjectInfoState.CanBypassMoveRestrictions;
+
+ t.FindTransitionalPosition(engine);
+
+ Assert.True(
+ System.MathF.Abs(t.SpherePath.CurPos.X - to.X) < 1e-4f,
+ $"An admin/bypass-flagged player must pass through unimpeded; CurPos.X={t.SpherePath.CurPos.X:F4}");
+ }
+
+ [Fact]
+ public void EndToEnd_OrdinaryCell_NoRestrictionObj_PlayerUnaffected()
+ {
+ // Zero-behavior-change proof: an ordinary (non-house) cell, empty BSP,
+ // reaches the target exactly like it did before AP-71 landed.
+ var engine = MakeEngine(MakeIndoorCell(restrictionObj: 0u));
+
+ var from = new Vector3(0.1f, 0f, 0.2f);
+ var to = new Vector3(0.7f, 0f, 0.2f);
+ var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, cellId: CellId);
+ t.ObjectInfo.State |= ObjectInfoState.IsPlayer;
+
+ t.FindTransitionalPosition(engine);
+
+ Assert.True(
+ System.MathF.Abs(t.SpherePath.CurPos.X - to.X) < 1e-4f,
+ $"Ordinary cell must be a complete no-op; CurPos.X={t.SpherePath.CurPos.X:F4}");
+ }
+}