fix(physics): AP-71 - port check_entry_restrictions at the head of indoor FindEnvCollisions
Campaign P Slice P4 item 1. Ports retail's CObjCell::check_entry_restrictions (pc:308873-308912, 0x0052b6d0), called FIRST by CEnvCell::find_env_collisions (pc:309576) before any BSP work, as ObjectInfo.CheckEntryRestrictions wired at the top of the indoor branch of Transition.FindEnvCollisions. Resolves the research doc's open question on restriction_obj's source: the ACE cross-check (references/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs:32, 66-67) plus an independent reflection probe of Chorizite.DatReaderWriter 2.1.7's own EnvCell.RestrictionObj field confirm it is a plain DAT-baked uint32 gated by EnvCellFlags.HasRestrictionObj (0x8) - not a live wire override. The BN pseudo-C's "count for an array alloc" read at the same UnPack offset was the mis-attributed field-name collision feedback_bn_decomp_field_names warned about. CellPhysics.RestrictionObj is wired from envCell.RestrictionObj in BOTH the dev/graph-fixture path (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. The mover's own CanBypassMoveRestrictions (BF_ADMIN 0x100000 AND BF_IMMUNE_CELL_RESTRICTIONS 0x400000, acclient.h:6452-6454) is decoded via the same PWD-bitfield pipeline TS-23 established for PK/PKLite/Impenetrable (EntityCollisionFlags -> ToMoverState -> ObjectInfoState moverFlags). Remaining gap (filed as AP-129, replacing the retired AP-71 row): CanMoveInto (house owner IID + guest/ban list) is unmodeled, so a genuinely restricted cell fails CLOSED for everyone, not just intruders - matching retail's own fallback when the restriction weenie can't be resolved (pc:704-716). Outdoor CLandCell restriction (LandblockInfo.RestrictionTables, a separate DAT structure) is explicitly out of scope for this gate. Conformance: Ap71EntryRestrictionGateTests covers the pure gate logic (NPC bypass, admin bypass, fail-closed, ordinary-cell no-op), the PWD-bitfield two-bit AND decode, and three end-to-end Transition.FindEnvCollisions scenarios proving zero behavior change for ordinary cells. AcDream.Core.Tests: 4026 passed, 2 skipped, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
eddad9cb38
commit
d6c3f8657a
5 changed files with 380 additions and 2 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -49,6 +49,19 @@ public enum EntityCollisionFlags : byte
|
|||
/// requires this distinction for ethereal targets.
|
||||
/// </summary>
|
||||
HasWeenie = 0x20,
|
||||
/// <summary>
|
||||
/// AP-71 (Campaign P Slice P4, 2026-07-30): set when BOTH
|
||||
/// <c>BF_ADMIN (0x100000)</c> and <c>BF_IMMUNE_CELL_RESTRICTIONS
|
||||
/// (0x400000)</c> are set in <c>pwd._bitfield</c> — the exact two-bit AND
|
||||
/// retail's <c>ACCWeenieObject::CanBypassMoveRestrictions</c>
|
||||
/// (0x0058c500) evaluates to let a mover through an access-restricted
|
||||
/// (house-barrier) cell without an owner/guest-list check. Bit names
|
||||
/// confirmed at <c>acclient.h:6452-6454</c>
|
||||
/// (<c>PublicWeenieDesc::BitfieldIndex</c>); <c>BF_ADMIN</c> is
|
||||
/// independently decoded the same way in
|
||||
/// <see cref="AcDream.Core.Ui.RadarBlipColors"/>.
|
||||
/// </summary>
|
||||
CanBypassMoveRestrictions = 0x40,
|
||||
}
|
||||
|
||||
/// <summary>Helpers to convert raw retail bitfields into <see cref="EntityCollisionFlags"/>.</summary>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<uint>(
|
||||
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).
|
||||
/// </summary>
|
||||
public bool SeenOutside { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// AP-71 (Campaign P Slice P4, 2026-07-30): retail <c>CObjCell::
|
||||
/// restriction_obj</c> — the DAT-baked object IID (gated by
|
||||
/// <c>EnvCellFlags.HasRestrictionObj</c>) that <c>CObjCell::
|
||||
/// check_entry_restrictions</c> (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 <see cref="ObjectInfo.CheckEntryRestrictions"/> via
|
||||
/// <see cref="Transition.FindEnvCollisions"/>. Resolving the value into
|
||||
/// a live restriction weenie (for the owner/guest-list
|
||||
/// <c>CanMoveInto</c> check) is NOT modeled — see the AP-71 register
|
||||
/// history for the narrower unfed-input row.
|
||||
/// </summary>
|
||||
public uint RestrictionObj { get; init; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,15 @@ public enum ObjectInfoState : uint
|
|||
/// retail's <c>OBJECTINFO::state</c> bits (acclient.h:6190-6194).</summary>
|
||||
IsPK = 0x800,
|
||||
IsPKLite = 0x1000,
|
||||
/// <summary>
|
||||
/// AP-71 (Campaign P Slice P4, 2026-07-30): the mover's own PWD-bitfield
|
||||
/// <c>BF_ADMIN & BF_IMMUNE_CELL_RESTRICTIONS</c> AND (see
|
||||
/// <c>EntityCollisionFlags.CanBypassMoveRestrictions</c>), consumed by
|
||||
/// <see cref="ObjectInfo.CheckEntryRestrictions"/> exactly like
|
||||
/// <see cref="IsPK"/>/<see cref="IsPKLite"/> flow through the same
|
||||
/// moverFlags OR-in.
|
||||
/// </summary>
|
||||
CanBypassMoveRestrictions = 0x2000,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Verbatim decision tree from retail <c>OBJECTINFO::missile_ignore</c>
|
||||
|
|
@ -124,6 +134,59 @@ public sealed class ObjectInfo
|
|||
&& (targetFlags & EntityCollisionFlags.IsCreature) != 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AP-71 (Campaign P Slice P4, 2026-07-30). Retail <c>CObjCell::
|
||||
/// check_entry_restrictions</c> (named-retail pc:308873-308912,
|
||||
/// 0x0052b6d0) — called FIRST by both <c>CEnvCell::find_env_collisions</c>
|
||||
/// (pc:309576) and <c>CLandCell::find_env_collisions</c> (pc:317075),
|
||||
/// before any BSP/terrain collision work. Ported here for the indoor
|
||||
/// (EnvCell) call site only; the outdoor <c>CLandCell</c> equivalent
|
||||
/// reads a completely separate DAT structure
|
||||
/// (<c>LandblockInfo.RestrictionTables</c>, a per-landblock packed hash
|
||||
/// table — confirmed via <c>references/ACE/Source/ACE.Server/Physics/
|
||||
/// Common/Landblock.cs:508-520</c> and <c>ACE.DatLoader.FileTypes.
|
||||
/// LandblockInfo.cs:43</c>) that acdream does not parse; that scope gap
|
||||
/// is filed separately (see the AP-71 register history), not ported here.
|
||||
///
|
||||
/// <para>
|
||||
/// Gate order, verbatim from the decompile except for the "mover is
|
||||
/// null" branch: acdream's <see cref="ObjectInfo"/> is never invoked on
|
||||
/// a null mover (unlike retail's <c>t->object_info.object</c>
|
||||
/// pointer, which is defensively null-checked but never actually null
|
||||
/// for a live transition — <c>CPhysicsObj::transition</c> 0x00512dc0
|
||||
/// always seeds <c>object_info.object = this</c>), so that branch and
|
||||
/// the parallel <c>moverWeenie == 0</c> check have no acdream analog and
|
||||
/// are omitted. What remains: NPCs/props (not <see cref="IsPlayer"/>)
|
||||
/// bypass entirely (retail's <c>state & 0x100</c> test); a mover
|
||||
/// whose own PWD bitfield grants <see cref="CanBypassMoveRestrictions"/>
|
||||
/// bypasses; an ordinary cell (<paramref name="cellRestrictionObj"/> ==
|
||||
/// 0 — the overwhelmingly common case, DAT-gated by
|
||||
/// <c>EnvCellFlags.HasRestrictionObj</c>) is a no-op.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Otherwise (a restriction IS authored on this cell and the mover
|
||||
/// cannot bypass): retail resolves the live restriction weenie
|
||||
/// (<c>CPhysicsObj::GetObjectA</c>) and asks <c>CanMoveInto</c> (house
|
||||
/// owner IID + guest/ban list, <c>ACCWeenieObject::CanMoveInto</c>
|
||||
/// 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
|
||||
/// <c>CanMoveInto</c> — it fails CLOSED (<see
|
||||
/// cref="TransitionState.Collided"/>), exactly matching retail's own
|
||||
/// fallback when the restriction object can't be resolved (pc:704-716
|
||||
/// falls through to <c>COLLIDED_TS</c>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance tests for AP-71 (Campaign P Slice P4, 2026-07-30) — retail
|
||||
/// <c>CObjCell::check_entry_restrictions</c> (named-retail pc:308873-308912,
|
||||
/// 0x0052b6d0), ported as <see cref="ObjectInfo.CheckEntryRestrictions"/> and
|
||||
/// wired at the head of the indoor branch of
|
||||
/// <c>Transition.FindEnvCollisions</c> (<c>src/AcDream.Core/Physics/
|
||||
/// TransitionTypes.cs</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail gate order: NPCs/props (not a player) bypass entirely; a mover
|
||||
/// whose PWD bitfield grants <c>CanBypassMoveRestrictions</c> (BF_ADMIN &
|
||||
/// BF_IMMUNE_CELL_RESTRICTIONS) bypasses; an ordinary cell (no
|
||||
/// <c>restriction_obj</c> authored) is a no-op; otherwise retail resolves
|
||||
/// the restriction weenie and asks <c>CanMoveInto</c> — 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).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<ushort, ResolvedPolygon>(),
|
||||
};
|
||||
|
||||
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<ushort, ResolvedPolygon>(),
|
||||
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}");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue