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