fix(physics): AP-129 review fix - port CanMoveInto/IsAllowedIn, stop failing closed
Campaign P Slice P4 Opus review verdict: FIX-FIRST. RestrictionObjPrevalenceInspectionTests
(commit 3b5e0992) found 103,766 of 729,888 installed EnvCells (1,293 landblocks -
the whole housing estate) carry a baked RestrictionObj. The AP-71 gate's
unconditional fail-closed default (CanMoveInto unmodeled) would have locked
every apartment/cottage/villa interior for every player, including its own
owner - a live regression, not the "inert in dev content" the original
register row assumed.
Ports ACCWeenieObject::CanMoveInto (0x0058da40, pc:407982-408056) and
RestrictionDB::IsAllowedIn (0x005ae8f0, pc:444493-444516) verbatim into
ObjectInfo.CheckEntryRestrictions:
- owner_iid == 0 or == mover's own guid -> admit (open/owner)
- no RestrictionDB (retail _db == 0, i.e. never authored or not yet
received) -> admit
- present RestrictionDB -> IsAllowedIn: open-to-public flag, OR mover
shares the house's allegiance monarch, OR mover's own guid is a
guest-table member
- unresolved restriction object -> fails CLOSED, exactly retail's own
fallback when GetObjectA can't resolve it (pc:704-716)
Wire feed (Core.Net):
- CreateObject.cs: HouseOwner (WeenieHeaderFlag 0x02000000), HouseRestrictions
(0x04000000), and Monarch (0x40) PWD-tail fields were parsed-and-skipped;
now captured. Also fixes the HouseRestrictions PHashTable header
misconception: the wire is ONE packed u32 (low 24 bits = entry count),
not a separate count(u16)+numBuckets(u16) pair - verified against
Chorizite's RestrictionDB.generated.cs. The old skip's byte-count
happened to match for realistic guest-list sizes, but a future
numBuckets value >255 would have corrupted the parse; now correct
regardless.
- GameEvents.cs/GameEventWiring.cs: new House_UpdateRestrictions (0x0248)
parser + wiring - retail's live guest-list refresh, whole-unit replace.
No-ops if the house object hasn't arrived via CreateObject yet.
- ClientObject/WeenieData/ClientObjectTable: HouseOwnerId, MonarchId,
Restrictions (new HouseRestrictionRecord) fields + merge-preserving
Ingest + targeted UpdateHouseRestrictions.
Physics wiring:
- PhysicsEngine gains an Objects (ClientObjectTable?) property, mirroring
the existing DataCache pattern - acdream's GetObjectA equivalent, used
ONLY by the entry-restriction gate.
- RuntimeEntityObjectLifetime wires Physics.Engine.Objects = Objects in
all three constructors, right alongside the table's own construction -
the same canonical table every other subsystem borrows from, never a
second one. This is the production fix: without it the gate still fails
closed on every restricted cell (unresolvable object), so the wiring is
load-bearing, not cosmetic.
Register: AP-129 narrowed (not retired) to the genuine remaining residual -
House_UpdateRestrictions' Sequence byte isn't used for staleness/reordering
rejection (low-probability, self-correcting), and outdoor CLandCell
restriction (a separate DAT structure) remains unported and unaffected by
this fix.
Tests: 15 new/updated in Ap71EntryRestrictionGateTests.cs (resolved-unowned
admits, owner admits, present-list-excluded blocks, present-list-included
admits, open-to-public admits, shared-allegiance-monarch admits, unresolved
blocks via null and via an empty table, plus two new end-to-end
PhysicsEngine.Objects-wired scenarios); 2 new CreateObject parser tests +
2 new GameEventWiring tests for the wire feed.
AcDream.Core.Tests: 4049 passed, 2 skipped, 0 failed.
AcDream.Core.Net.Tests: 761 passed, 0 skipped, 0 failed.
Complete solution suite: 9,961 total, 9,956 passed, 5 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
dc0468cc2b
commit
7a0f836af5
15 changed files with 704 additions and 81 deletions
|
|
@ -116,7 +116,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Documented approximation (AP) — 92 active rows (AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `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)
|
## 3. Documented approximation (AP) — 92 active rows (AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `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-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
|
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
|
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
|
||||||
|
|
@ -181,7 +181,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
||||||
| 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-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-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~~ | **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-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-129 | **NARROWED 2026-07-30 (P4 Opus review fix) — `CanMoveInto`/`IsAllowedIn` are now ported and fed; two narrow gaps remain.** `ObjectInfo.CheckEntryRestrictions` resolves the cell's `RestrictionObj` via `PhysicsEngine.Objects` (a `ClientObjectTable`, acdream's `GetObjectA` equivalent) and evaluates the real owner IID / `HouseRestrictionRecord` (open flag, allegiance monarch, guest table) fed from CreateObject's `HouseOwner`/`HouseRestrictions`/`Monarch` PWD-tail fields and live `House_UpdateRestrictions (0x0248)` refreshes — see `RestrictionObjPrevalenceInspectionTests` (103,766 of 729,888 installed EnvCells, 1,293 landblocks, carry a baked `RestrictionObj`; this is the whole housing estate, not a rare case, which is why the OLD unconditional-fail-closed row was upgraded to FIX-FIRST rather than shipped). Remaining gaps: (1) `House_UpdateRestrictions`'s `Sequence` byte is parsed but not used for staleness/reordering rejection — a lost-then-late UDP delivery could transiently apply an older restriction snapshot over a newer one (low-probability; the next full CreateObject or another update self-corrects). (2) Outdoor `CLandCell` restriction (`LandblockInfo.RestrictionTables`, a separate per-landblock packed hash table) remains entirely unported — unaffected by this fix, since the gate only reads the indoor/EnvCell `CellPhysics.RestrictionObj` field. `HouseData (0x0225)`/`HouseStatus (0x0226)` and the guest-management opcode family (`House_AddPermanentGuest`, `House_UpdateHAR`, etc.) remain unparsed but are NOT consulted by this entry gate (they carry rent/ownership-transfer UI data, not the owner-iid/guest-list pair `CanMoveInto` needs) — noted for future house-UI work, not a residual of this row. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ObjectInfo.CheckEntryRestrictions`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`Objects`); `src/AcDream.Core/Items/{ClientObject,ClientObjectTable,HouseRestrictions}.cs`; `src/AcDream.Core.Net/{Messages/CreateObject.cs,Messages/GameEvents.cs,GameEventWiring.cs}`; `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (production wiring) | A reordered `House_UpdateRestrictions` pair could transiently apply the older snapshot; self-corrects on the next update or CreateObject. An outdoor restricted cell (if that content ever exists) is not gated at all. | `ACCWeenieObject::CanMoveInto` 0x0058da40 (pc:407982-408056); `RestrictionDB::IsAllowedIn` 0x005ae8f0 (pc:444493-444516); `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/RestrictionDB.generated.cs`; `references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventHouseUpdateRestrictions.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-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-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` |
|
| 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` |
|
||||||
|
|
|
||||||
|
|
@ -362,6 +362,19 @@ public static class GameEventWiring
|
||||||
containerTypeHint: p.Value.ContainerType);
|
containerTypeHint: p.Value.ContainerType);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): House_UpdateRestrictions
|
||||||
|
// (0x0248) — live refresh of a house object's guest/ban list. Feeds
|
||||||
|
// ObjectInfo.CheckEntryRestrictions' CanMoveInto port. No-ops if the house
|
||||||
|
// object hasn't arrived via CreateObject yet (UpdateHouseRestrictions
|
||||||
|
// returns false), matching every other targeted property update here.
|
||||||
|
registrar.Register(GameEventType.HouseUpdateRestrictions, e =>
|
||||||
|
{
|
||||||
|
var p = GameEvents.ParseHouseUpdateRestrictions(e.Payload.Span);
|
||||||
|
if (p is null) return;
|
||||||
|
|
||||||
|
items.UpdateHouseRestrictions(p.Value.SenderId, p.Value.Restrictions);
|
||||||
|
});
|
||||||
|
|
||||||
// ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you
|
// ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you
|
||||||
// opened (Use 0x0036). Treat it as a full projection-only REPLACE: update membership without
|
// opened (Use 0x0036). Treat it as a full projection-only REPLACE: update membership without
|
||||||
// inventing ContainerSlot values, then publish one ContainerContentsReplaced notification so
|
// inventing ContainerSlot values, then publish one ContainerContentsReplaced notification so
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
|
||||||
namespace AcDream.Core.Net.Messages;
|
namespace AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
|
@ -227,7 +228,14 @@ public static class CreateObject
|
||||||
// PublicWeenieDesc._material_type, gated by PWD_Packed_MaterialType.
|
// PublicWeenieDesc._material_type, gated by PWD_Packed_MaterialType.
|
||||||
// ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) uses this live
|
// ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) uses this live
|
||||||
// spawn field to prefix the authored material name.
|
// spawn field to prefix the authored material name.
|
||||||
uint? MaterialType = null);
|
uint? MaterialType = null,
|
||||||
|
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): house-restriction
|
||||||
|
// PWD tail fields (WeenieHeaderFlag.Owner 0x02000000, .HouseRestrictions
|
||||||
|
// 0x04000000, .Monarch 0x40). Feeds ObjectInfo.CheckEntryRestrictions'
|
||||||
|
// CanMoveInto port.
|
||||||
|
uint? HouseOwnerId = null,
|
||||||
|
uint? MonarchId = null,
|
||||||
|
HouseRestrictionRecord? Restrictions = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The relevant subset of the server-sent <c>MovementData</c> /
|
/// The relevant subset of the server-sent <c>MovementData</c> /
|
||||||
|
|
@ -861,10 +869,10 @@ public static class CreateObject
|
||||||
// 0x01000000 Workmanship f32 (skip)
|
// 0x01000000 Workmanship f32 (skip)
|
||||||
// 0x00200000 Burden u16 (skip)
|
// 0x00200000 Burden u16 (skip)
|
||||||
// 0x00400000 Spell u16 (skip)
|
// 0x00400000 Spell u16 (skip)
|
||||||
// 0x02000000 HouseOwner u32 (skip)
|
// 0x02000000 HouseOwner u32 CAPTURE (AP-129)
|
||||||
// 0x04000000 HouseRestrictions RestrictionDB (skip, variable-length)
|
// 0x04000000 HouseRestrictions RestrictionDB CAPTURE (AP-129, variable-length)
|
||||||
// 0x20000000 HookItemTypes u32 CAPTURE
|
// 0x20000000 HookItemTypes u32 CAPTURE
|
||||||
// 0x00000040 Monarch u32 (skip)
|
// 0x00000040 Monarch u32 CAPTURE (AP-129)
|
||||||
// 0x10000000 HookType u16 CAPTURE
|
// 0x10000000 HookType u16 CAPTURE
|
||||||
// 0x40000000 IconOverlay PackedDwordKnownType(0x06000000) CAPTURE
|
// 0x40000000 IconOverlay PackedDwordKnownType(0x06000000) CAPTURE
|
||||||
// weenieFlags2 bit 0x01:
|
// weenieFlags2 bit 0x01:
|
||||||
|
|
@ -897,6 +905,13 @@ public static class CreateObject
|
||||||
uint? cooldownId = null;
|
uint? cooldownId = null;
|
||||||
double? cooldownDuration = null;
|
double? cooldownDuration = null;
|
||||||
uint? materialType = null;
|
uint? materialType = null;
|
||||||
|
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): house-restriction
|
||||||
|
// PWD tail fields, previously skipped. HouseOwner/Monarch are plain u32
|
||||||
|
// ids; HouseRestrictions is the full RestrictionDB (open flag + allegiance
|
||||||
|
// monarch + guest table) — see AcDream.Core.Items.HouseRestrictionRecord.
|
||||||
|
uint? houseOwnerId = null;
|
||||||
|
uint? monarchId = null;
|
||||||
|
HouseRestrictionRecord? restrictions = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458
|
// BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458
|
||||||
|
|
@ -1051,37 +1066,50 @@ public static class CreateObject
|
||||||
spellId = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
spellId = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||||
pos += 2;
|
pos += 2;
|
||||||
}
|
}
|
||||||
if ((weenieFlags & 0x02000000u) != 0) // HouseOwner u32
|
if ((weenieFlags & 0x02000000u) != 0) // HouseOwner u32 ← CAPTURE (AP-129)
|
||||||
{
|
{
|
||||||
if (body.Length - pos < 4) throw new FormatException("trunc HouseOwner");
|
if (body.Length - pos < 4) throw new FormatException("trunc HouseOwner");
|
||||||
pos += 4;
|
houseOwnerId = ReadU32(body, ref pos);
|
||||||
}
|
}
|
||||||
if ((weenieFlags & 0x04000000u) != 0) // HouseRestrictions (RestrictionDB)
|
if ((weenieFlags & 0x04000000u) != 0) // HouseRestrictions (RestrictionDB) ← CAPTURE (AP-129)
|
||||||
{
|
{
|
||||||
// Wire layout per ACE RestrictionDB + RestrictionDBExtensions.Write:
|
// Wire layout per Chorizite RestrictionDB.generated.cs (verified against
|
||||||
// u32 Version, u32 OpenStatus, u32 MonarchId,
|
// protocol.xml:6270-6275): u32 Version, u32 Flags, u32 MonarchId, then a
|
||||||
// u16 count, u16 numBuckets, then count × (u32 guid + u32 value).
|
// PHashTable<uint,uint> (NOT the u16-count/u16-size PackableHashTable shape
|
||||||
// Fixed header = 12 bytes; PackableHashTable header = 4 bytes.
|
// this skip previously assumed — the byte total happened to match for any
|
||||||
// Total = 16 + count * 8.
|
// realistic guest-list size, but the count extraction was reading the wrong
|
||||||
if (body.Length - pos < 16) throw new FormatException("trunc RestrictionDB header");
|
// half of the packed dword). PHashTable's own header is ONE packed u32 whose
|
||||||
// Version(4) + OpenStatus(4) + MonarchId(4) = 12 bytes
|
// low 24 bits are the entry count.
|
||||||
pos += 12;
|
if (body.Length - pos < 12) throw new FormatException("trunc RestrictionDB header");
|
||||||
ushort tableCount = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
pos += 4; // Version — not consulted
|
||||||
pos += 2; // count u16
|
uint flags = ReadU32(body, ref pos); // 0 = private, 1 = open
|
||||||
pos += 2; // numBuckets u16
|
uint restrictionMonarchId = ReadU32(body, ref pos);
|
||||||
int entryBytes = tableCount * 8; // each entry: u32 guid + u32 value
|
if (body.Length - pos < 4) throw new FormatException("trunc RestrictionDB PHashTable header");
|
||||||
|
uint packedSize = ReadU32(body, ref pos);
|
||||||
|
uint entryCount = packedSize & 0xFFFFFFu;
|
||||||
|
long entryBytes = (long)entryCount * 8; // each entry: u32 guid + u32 value
|
||||||
if (body.Length - pos < entryBytes) throw new FormatException("trunc RestrictionDB entries");
|
if (body.Length - pos < entryBytes) throw new FormatException("trunc RestrictionDB entries");
|
||||||
pos += entryBytes;
|
var guests = new Dictionary<uint, uint>((int)entryCount);
|
||||||
|
for (uint i = 0; i < entryCount; i++)
|
||||||
|
{
|
||||||
|
uint guestId = ReadU32(body, ref pos);
|
||||||
|
uint permission = ReadU32(body, ref pos);
|
||||||
|
guests[guestId] = permission;
|
||||||
|
}
|
||||||
|
restrictions = new HouseRestrictionRecord(
|
||||||
|
OpenToPublic: flags != 0,
|
||||||
|
AllegianceMonarchId: restrictionMonarchId,
|
||||||
|
Guests: guests);
|
||||||
}
|
}
|
||||||
if ((weenieFlags & 0x20000000u) != 0) // HookItemTypes u32
|
if ((weenieFlags & 0x20000000u) != 0) // HookItemTypes u32
|
||||||
{
|
{
|
||||||
if (body.Length - pos < 4) throw new FormatException("trunc HookItemTypes");
|
if (body.Length - pos < 4) throw new FormatException("trunc HookItemTypes");
|
||||||
wHookItemTypes = ReadU32(body, ref pos);
|
wHookItemTypes = ReadU32(body, ref pos);
|
||||||
}
|
}
|
||||||
if ((weenieFlags & 0x00000040u) != 0) // Monarch u32
|
if ((weenieFlags & 0x00000040u) != 0) // Monarch u32 ← CAPTURE (AP-129)
|
||||||
{
|
{
|
||||||
if (body.Length - pos < 4) throw new FormatException("trunc Monarch");
|
if (body.Length - pos < 4) throw new FormatException("trunc Monarch");
|
||||||
pos += 4;
|
monarchId = ReadU32(body, ref pos);
|
||||||
}
|
}
|
||||||
if ((weenieFlags & 0x10000000u) != 0) // HookType u16
|
if ((weenieFlags & 0x10000000u) != 0) // HookType u16
|
||||||
{
|
{
|
||||||
|
|
@ -1158,7 +1186,10 @@ public static class CreateObject
|
||||||
CooldownId: cooldownId,
|
CooldownId: cooldownId,
|
||||||
CooldownDuration: cooldownDuration,
|
CooldownDuration: cooldownDuration,
|
||||||
Physics: physics,
|
Physics: physics,
|
||||||
MaterialType: materialType);
|
MaterialType: materialType,
|
||||||
|
HouseOwnerId: houseOwnerId,
|
||||||
|
MonarchId: monarchId,
|
||||||
|
Restrictions: restrictions);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using System;
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
|
||||||
namespace AcDream.Core.Net.Messages;
|
namespace AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
|
@ -538,6 +539,57 @@ public static class GameEvents
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
|
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── House ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 0x0248 House_UpdateRestrictions: retail's live refresh of a house
|
||||||
|
/// object's guest/ban list (whole-unit replace, not a delta). Wire shape
|
||||||
|
/// confirmed verbatim against <c>references/Chorizite.ACProtocol
|
||||||
|
/// /Chorizite.ACProtocol/Messages/S2C/Events/House_UpdateRestrictions
|
||||||
|
/// .generated.cs</c>: <c>byte Sequence, uint SenderId, RestrictionDB
|
||||||
|
/// Restrictions</c> — Sequence is a single unpadded byte, immediately
|
||||||
|
/// followed by the 4-byte SenderId (the house object whose restrictions
|
||||||
|
/// changed).
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct HouseUpdateRestrictions(
|
||||||
|
byte Sequence,
|
||||||
|
uint SenderId,
|
||||||
|
HouseRestrictionRecord Restrictions);
|
||||||
|
|
||||||
|
public static HouseUpdateRestrictions? ParseHouseUpdateRestrictions(ReadOnlySpan<byte> payload)
|
||||||
|
{
|
||||||
|
// Sequence(1) + SenderId(4) + RestrictionDB{Version(4)+Flags(4)+MonarchId(4)+PHashTable-header(4)} = 21
|
||||||
|
if (payload.Length < 21) return null;
|
||||||
|
int pos = 0;
|
||||||
|
byte sequence = payload[pos]; pos += 1;
|
||||||
|
uint senderId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||||
|
|
||||||
|
pos += 4; // Version — not consulted
|
||||||
|
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||||
|
uint allegianceMonarchId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||||
|
|
||||||
|
uint packedSize = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||||
|
uint entryCount = packedSize & 0xFFFFFFu;
|
||||||
|
long entryBytes = (long)entryCount * 8;
|
||||||
|
if (payload.Length - pos < entryBytes) return null;
|
||||||
|
|
||||||
|
var guests = new Dictionary<uint, uint>((int)entryCount);
|
||||||
|
for (uint i = 0; i < entryCount; i++)
|
||||||
|
{
|
||||||
|
uint guestId = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||||
|
uint permission = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||||
|
guests[guestId] = permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HouseUpdateRestrictions(
|
||||||
|
sequence,
|
||||||
|
senderId,
|
||||||
|
new HouseRestrictionRecord(
|
||||||
|
OpenToPublic: flags != 0,
|
||||||
|
AllegianceMonarchId: allegianceMonarchId,
|
||||||
|
Guests: guests));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Shared string reader (matches LoginRequest.ReadString16L) ───────────
|
// ── Shared string reader (matches LoginRequest.ReadString16L) ───────────
|
||||||
|
|
||||||
private static string ReadString16L(ReadOnlySpan<byte> source, ref int pos)
|
private static string ReadString16L(ReadOnlySpan<byte> source, ref int pos)
|
||||||
|
|
|
||||||
|
|
@ -190,5 +190,8 @@ public static class ObjectTableWiring
|
||||||
SpellId: s.SpellId,
|
SpellId: s.SpellId,
|
||||||
CooldownId: s.CooldownId,
|
CooldownId: s.CooldownId,
|
||||||
CooldownDuration: s.CooldownDuration,
|
CooldownDuration: s.CooldownDuration,
|
||||||
MaterialType: s.MaterialType);
|
MaterialType: s.MaterialType,
|
||||||
|
HouseOwnerId: s.HouseOwnerId,
|
||||||
|
MonarchId: s.MonarchId,
|
||||||
|
Restrictions: s.Restrictions);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,11 @@ public sealed class WorldSession : IDisposable
|
||||||
PhysicsSpawnData? Physics = null,
|
PhysicsSpawnData? Physics = null,
|
||||||
uint? HookItemTypes = null,
|
uint? HookItemTypes = null,
|
||||||
uint? HookType = null,
|
uint? HookType = null,
|
||||||
uint? MaterialType = null);
|
uint? MaterialType = null,
|
||||||
|
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30).
|
||||||
|
uint? HouseOwnerId = null,
|
||||||
|
uint? MonarchId = null,
|
||||||
|
HouseRestrictionRecord? Restrictions = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Projects the wire-level CreateObject result into the stable session
|
/// Projects the wire-level CreateObject result into the stable session
|
||||||
|
|
@ -245,7 +249,10 @@ public sealed class WorldSession : IDisposable
|
||||||
Physics: parsed.Physics,
|
Physics: parsed.Physics,
|
||||||
HookItemTypes: parsed.HookItemTypes,
|
HookItemTypes: parsed.HookItemTypes,
|
||||||
HookType: parsed.HookType,
|
HookType: parsed.HookType,
|
||||||
MaterialType: parsed.MaterialType);
|
MaterialType: parsed.MaterialType,
|
||||||
|
HouseOwnerId: parsed.HouseOwnerId,
|
||||||
|
MonarchId: parsed.MonarchId,
|
||||||
|
Restrictions: parsed.Restrictions);
|
||||||
|
|
||||||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||||
public event Action<EntitySpawn>? EntitySpawned;
|
public event Action<EntitySpawn>? EntitySpawned;
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,31 @@ public sealed class ClientObject
|
||||||
/// the base object name.
|
/// the base object name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public uint? MaterialType { get; set; }
|
public uint? MaterialType { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc
|
||||||
|
/// ._house_owner_iid</c>. Present only on a house/dwelling restriction
|
||||||
|
/// object (wire <c>WeenieHeaderFlag.Owner</c>, 0x02000000). Zero or a
|
||||||
|
/// match against the mover's own id admits regardless of the guest list.
|
||||||
|
/// </summary>
|
||||||
|
public uint? HouseOwnerId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc
|
||||||
|
/// ._monarch_iid</c> (wire <c>WeenieHeaderFlag.Monarch</c>, 0x40) — this
|
||||||
|
/// object's OWN allegiance monarch. Present on any weenie, not just house
|
||||||
|
/// objects; a player's own value is what <c>RestrictionDB::IsAllowedIn</c>
|
||||||
|
/// compares against a house's <see cref="HouseRestrictionRecord.AllegianceMonarchId"/>.
|
||||||
|
/// </summary>
|
||||||
|
public uint? MonarchId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc
|
||||||
|
/// ._db</c> (<c>RestrictionDB*</c>) — the house's own guest/ban list.
|
||||||
|
/// Null means retail's <c>_db == 0</c> ("no list", i.e. open) OR simply
|
||||||
|
/// that neither a CreateObject HouseRestrictions field nor a live
|
||||||
|
/// <c>House_UpdateRestrictions (0x0248)</c> has arrived yet for this
|
||||||
|
/// object — acdream cannot distinguish the two, and both resolve to
|
||||||
|
/// the same retail-faithful "allow" default.
|
||||||
|
/// </summary>
|
||||||
|
public HouseRestrictionRecord? Restrictions { get; set; }
|
||||||
public PropertyBundle Properties { get; } = new();
|
public PropertyBundle Properties { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -332,7 +357,14 @@ public readonly record struct WeenieData(
|
||||||
double? CooldownDuration = null,
|
double? CooldownDuration = null,
|
||||||
uint? HookItemTypes = null,
|
uint? HookItemTypes = null,
|
||||||
uint? HookType = null,
|
uint? HookType = null,
|
||||||
uint? MaterialType = null);
|
uint? MaterialType = null,
|
||||||
|
// AP-129 (Campaign P Slice P4 review fix): house-restriction PWD tail
|
||||||
|
// fields. Restrictions is null-preserving (a CreateObject that doesn't
|
||||||
|
// carry HouseRestrictions this time must not clobber a value fed
|
||||||
|
// separately by a live House_UpdateRestrictions event).
|
||||||
|
uint? HouseOwnerId = null,
|
||||||
|
uint? MonarchId = null,
|
||||||
|
HouseRestrictionRecord? Restrictions = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
|
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
|
||||||
|
|
|
||||||
|
|
@ -771,6 +771,23 @@ public sealed class ClientObjectTable
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AP-129 (Campaign P Slice P4 review fix): live <c>House_UpdateRestrictions
|
||||||
|
/// (0x0248)</c> refresh of a house object's guest/ban list — retail resends
|
||||||
|
/// the whole <c>RestrictionDB</c> (open flag + allegiance monarch + guest
|
||||||
|
/// table) as one unit rather than a delta. No-ops (returns false) if the
|
||||||
|
/// house object hasn't arrived via CreateObject yet, matching every other
|
||||||
|
/// targeted property update in this table.
|
||||||
|
/// </summary>
|
||||||
|
public bool UpdateHouseRestrictions(uint guid, HouseRestrictionRecord restrictions)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(restrictions);
|
||||||
|
if (!_objects.TryGetValue(guid, out var item)) return false;
|
||||||
|
item.Restrictions = restrictions;
|
||||||
|
ObjectUpdated?.Invoke(item);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Canonical CreateObject ingestion: create-if-absent, else patch the
|
/// Canonical CreateObject ingestion: create-if-absent, else patch the
|
||||||
/// wire-carried fields in place (retail SetWeenieDesc). Preserves the
|
/// wire-carried fields in place (retail SetWeenieDesc). Preserves the
|
||||||
|
|
@ -828,6 +845,9 @@ public sealed class ClientObjectTable
|
||||||
if (d.MaxStructure is { } ms) obj.MaxStructure = ms;
|
if (d.MaxStructure is { } ms) obj.MaxStructure = ms;
|
||||||
if (d.Workmanship is { } wm) obj.Workmanship = wm;
|
if (d.Workmanship is { } wm) obj.Workmanship = wm;
|
||||||
if (d.MaterialType is { } materialType) obj.MaterialType = materialType;
|
if (d.MaterialType is { } materialType) obj.MaterialType = materialType;
|
||||||
|
if (d.HouseOwnerId is { } houseOwnerId) obj.HouseOwnerId = houseOwnerId;
|
||||||
|
if (d.MonarchId is { } monarchId) obj.MonarchId = monarchId;
|
||||||
|
if (d.Restrictions is { } restrictions) obj.Restrictions = restrictions;
|
||||||
|
|
||||||
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
|
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
|
||||||
obj.ObjectId,
|
obj.ObjectId,
|
||||||
|
|
|
||||||
44
src/AcDream.Core/Items/HouseRestrictions.cs
Normal file
44
src/AcDream.Core/Items/HouseRestrictions.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Items;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AP-129 (Campaign P Slice P4 review fix, 2026-07-30). Retail
|
||||||
|
/// <c>RestrictionDB</c> — the access-control list carried on a house/dwelling
|
||||||
|
/// object's <c>PublicWeenieDesc</c> (<c>WeenieHeaderFlag.HouseRestrictions</c>,
|
||||||
|
/// 0x04000000) and refreshed live via <c>House_UpdateRestrictions (0x0248)</c>.
|
||||||
|
/// Wire shape confirmed verbatim against
|
||||||
|
/// <c>references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/RestrictionDB.generated.cs</c>
|
||||||
|
/// and <c>protocol.xml:6270-6275</c>: <c>uint Version, uint Flags (0=private,
|
||||||
|
/// 1=open), ObjectId MonarchId, PHashTable<ObjectId,uint> Permissions</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="OpenToPublic">Wire <c>Flags != 0</c> — retail's <c>_bitmask
|
||||||
|
/// & 1</c>. When true, every mover is admitted regardless of the guest
|
||||||
|
/// list.</param>
|
||||||
|
/// <param name="AllegianceMonarchId">Wire <c>MonarchId</c> — retail
|
||||||
|
/// <c>_monarch_iid</c>. A mover whose own monarch matches this id is admitted
|
||||||
|
/// (allegiance-wide access), independent of the guest list.</param>
|
||||||
|
/// <param name="Guests">Wire <c>Permissions</c> — guid to permission value
|
||||||
|
/// (0 = dwelling access only, 1 = storage access also). Retail's
|
||||||
|
/// <c>IsAllowedIn</c> only consults key membership for entry; the permission
|
||||||
|
/// value is preserved for wire fidelity but not consulted here.</param>
|
||||||
|
public sealed record HouseRestrictionRecord(
|
||||||
|
bool OpenToPublic,
|
||||||
|
uint AllegianceMonarchId,
|
||||||
|
IReadOnlyDictionary<uint, uint> Guests)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verbatim port of retail <c>RestrictionDB::IsAllowedIn</c>
|
||||||
|
/// (named-retail pc:444493-444516, 0x005ae8f0):
|
||||||
|
/// <c>if ((_bitmask & 1) == 0) { if (_monarch_iid == 0 || arg3 !=
|
||||||
|
/// _monarch_iid) { if (arg2 == 0) return 0; if (!table.find(arg2)) return
|
||||||
|
/// 0; } } return 1;</c> — open bit set, OR mover shares the house's
|
||||||
|
/// allegiance monarch, OR mover's own guid is a table member.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsAllowedIn(uint moverId, uint moverMonarchId)
|
||||||
|
{
|
||||||
|
if (OpenToPublic) return true;
|
||||||
|
if (AllegianceMonarchId != 0 && moverMonarchId == AllegianceMonarchId) return true;
|
||||||
|
return moverId != 0 && Guests.ContainsKey(moverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
|
||||||
namespace AcDream.Core.Physics;
|
namespace AcDream.Core.Physics;
|
||||||
|
|
||||||
|
|
@ -125,6 +126,20 @@ public sealed class PhysicsEngine
|
||||||
}
|
}
|
||||||
private PhysicsDataCache? _dataCache;
|
private PhysicsDataCache? _dataCache;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): optional live
|
||||||
|
/// weenie-object table, consulted ONLY by
|
||||||
|
/// <see cref="Transition.FindEnvCollisions"/>'s entry-restriction gate to
|
||||||
|
/// resolve a cell's <c>RestrictionObj</c> into its owner/guest-list data
|
||||||
|
/// (retail <c>ACCWeenieObject::CanMoveInto</c>). Mirrors the
|
||||||
|
/// <see cref="DataCache"/> pattern: nullable, settable, defaults null so
|
||||||
|
/// every existing test/one-shot caller is unaffected. An unset table
|
||||||
|
/// makes a restricted cell fail CLOSED — exactly retail's own fallback
|
||||||
|
/// when the restriction weenie can't be resolved — so production MUST
|
||||||
|
/// wire this to the live table for the fix to actually admit anyone.
|
||||||
|
/// </summary>
|
||||||
|
public ClientObjectTable? Objects { get; set; }
|
||||||
|
|
||||||
private sealed record LandblockPhysics(
|
private sealed record LandblockPhysics(
|
||||||
TerrainSurface Terrain,
|
TerrainSurface Terrain,
|
||||||
IReadOnlyList<CellSurface> Cells,
|
IReadOnlyList<CellSurface> Cells,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Items;
|
||||||
using DatReaderWriter.Types;
|
using DatReaderWriter.Types;
|
||||||
|
|
||||||
namespace AcDream.Core.Physics;
|
namespace AcDream.Core.Physics;
|
||||||
|
|
@ -169,22 +170,51 @@ public sealed class ObjectInfo
|
||||||
/// cannot bypass): retail resolves the live restriction weenie
|
/// cannot bypass): retail resolves the live restriction weenie
|
||||||
/// (<c>CPhysicsObj::GetObjectA</c>) and asks <c>CanMoveInto</c> (house
|
/// (<c>CPhysicsObj::GetObjectA</c>) and asks <c>CanMoveInto</c> (house
|
||||||
/// owner IID + guest/ban list, <c>ACCWeenieObject::CanMoveInto</c>
|
/// owner IID + guest/ban list, <c>ACCWeenieObject::CanMoveInto</c>
|
||||||
/// 0x0058da40). acdream has no restriction-weenie resolution or
|
/// 0x0058da40, named-retail pc:407982-408056). AP-129 (Campaign P Slice
|
||||||
/// guest-list wire model yet (see the AP-71 register history for the
|
/// P4 review fix, 2026-07-30) ports this verbatim via
|
||||||
/// narrower unfed-input row this leaves), so it cannot correctly answer
|
/// <paramref name="objects"/> (retail's <c>GetObjectA</c> equivalent —
|
||||||
/// <c>CanMoveInto</c> — it fails CLOSED (<see
|
/// the house object is an ordinary <c>CreateObject</c> entity in range):
|
||||||
/// 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>
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Unresolved restriction object (<paramref name="objects"/> is
|
||||||
|
/// null, or has no row for <paramref name="cellRestrictionObj"/>) —
|
||||||
|
/// fails CLOSED (<see cref="TransitionState.Collided"/>), exactly
|
||||||
|
/// retail's own fallback when <c>rObj == 0</c> (pc:704-716 falls
|
||||||
|
/// through to <c>COLLIDED_TS</c>).</item>
|
||||||
|
/// <item>Resolved, <c>HouseOwnerId</c> zero or equal to the mover's own
|
||||||
|
/// id — OK (owner always in, or no owner set = open).</item>
|
||||||
|
/// <item>Resolved, no <c>Restrictions</c> (retail <c>_db == 0</c>) — OK
|
||||||
|
/// (an un-received or never-authored guest list is open, same as a
|
||||||
|
/// retail client that hasn't been sent restrictions yet).</item>
|
||||||
|
/// <item>Resolved with a <c>Restrictions</c> list — delegates to
|
||||||
|
/// <see cref="HouseRestrictionRecord.IsAllowedIn"/> (retail
|
||||||
|
/// <c>RestrictionDB::IsAllowedIn</c> 0x005ae8f0): open-to-public,
|
||||||
|
/// OR mover shares the house's allegiance monarch, OR the mover's
|
||||||
|
/// own guid is a guest-table member.</item>
|
||||||
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public TransitionState CheckEntryRestrictions(uint cellRestrictionObj)
|
public TransitionState CheckEntryRestrictions(
|
||||||
|
uint cellRestrictionObj,
|
||||||
|
ClientObjectTable? objects)
|
||||||
{
|
{
|
||||||
if (!IsPlayer) return TransitionState.OK; // NPCs/props bypass entirely
|
if (!IsPlayer) return TransitionState.OK; // NPCs/props bypass entirely
|
||||||
if (CanBypassMoveRestrictions) return TransitionState.OK;
|
if (CanBypassMoveRestrictions) return TransitionState.OK;
|
||||||
if (cellRestrictionObj == 0) return TransitionState.OK; // ordinary cell — no-op
|
if (cellRestrictionObj == 0) return TransitionState.OK; // ordinary cell — no-op
|
||||||
|
|
||||||
return TransitionState.Collided;
|
ClientObject? restrictionObject = objects?.Get(cellRestrictionObj);
|
||||||
|
if (restrictionObject is null) return TransitionState.Collided; // retail: rObj==0 -> COLLIDED
|
||||||
|
|
||||||
|
uint moverId = SelfEntityId;
|
||||||
|
uint houseOwnerId = restrictionObject.HouseOwnerId ?? 0;
|
||||||
|
if (houseOwnerId == 0 || houseOwnerId == moverId) return TransitionState.OK;
|
||||||
|
|
||||||
|
HouseRestrictionRecord? restrictions = restrictionObject.Restrictions;
|
||||||
|
if (restrictions is null) return TransitionState.OK; // retail: _db==0 -> open
|
||||||
|
|
||||||
|
uint moverMonarchId = objects?.Get(moverId)?.MonarchId ?? 0;
|
||||||
|
return restrictions.IsAllowedIn(moverId, moverMonarchId)
|
||||||
|
? TransitionState.OK
|
||||||
|
: TransitionState.Collided;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -3033,8 +3063,12 @@ public sealed class Transition
|
||||||
// find_env_collisions (pc:309576) calls check_entry_restrictions
|
// find_env_collisions (pc:309576) calls check_entry_restrictions
|
||||||
// as its FIRST statement — before any HasPhysics/BSP dispatch.
|
// as its FIRST statement — before any HasPhysics/BSP dispatch.
|
||||||
// cellPhysics is null-safe: an uncached/BSP-less cell has no
|
// cellPhysics is null-safe: an uncached/BSP-less cell has no
|
||||||
// authored restriction data either.
|
// authored restriction data either. AP-129 (review fix): pass
|
||||||
var restrictionState = ObjectInfo.CheckEntryRestrictions(cellPhysics?.RestrictionObj ?? 0);
|
// engine.Objects so the gate can resolve CanMoveInto instead of
|
||||||
|
// failing closed on every restricted cell (see PhysicsEngine.Objects).
|
||||||
|
var restrictionState = ObjectInfo.CheckEntryRestrictions(
|
||||||
|
cellPhysics?.RestrictionObj ?? 0,
|
||||||
|
engine.Objects);
|
||||||
if (restrictionState != TransitionState.OK)
|
if (restrictionState != TransitionState.OK)
|
||||||
{
|
{
|
||||||
if ((ObjectInfo.State & ObjectInfoState.Contact) == 0)
|
if ((ObjectInfo.State & ObjectInfoState.Contact) == 0)
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,13 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
||||||
Entities = new RuntimeEntityDirectory(firstLocalEntityId);
|
Entities = new RuntimeEntityDirectory(firstLocalEntityId);
|
||||||
Physics = new RuntimePhysicsState(Entities, timeProvider: timeProvider);
|
Physics = new RuntimePhysicsState(Entities, timeProvider: timeProvider);
|
||||||
Objects = new ClientObjectTable();
|
Objects = new ClientObjectTable();
|
||||||
|
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): the physics
|
||||||
|
// entry-restriction gate (ObjectInfo.CheckEntryRestrictions) resolves
|
||||||
|
// a restricted cell's owner/guest list through the SAME live
|
||||||
|
// ClientObjectTable every other subsystem borrows from this owner —
|
||||||
|
// never a second table. Without this, every restricted cell fails
|
||||||
|
// closed for everyone (see PhysicsEngine.Objects).
|
||||||
|
Physics.Engine.Objects = Objects;
|
||||||
var views = new RuntimeEntityObjectViews(Entities, Objects);
|
var views = new RuntimeEntityObjectViews(Entities, Objects);
|
||||||
EntityView = views.Entities;
|
EntityView = views.Entities;
|
||||||
InventoryView = views.Inventory;
|
InventoryView = views.Inventory;
|
||||||
|
|
@ -118,6 +125,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
||||||
physicsDataCache,
|
physicsDataCache,
|
||||||
timeProvider);
|
timeProvider);
|
||||||
Objects = new ClientObjectTable();
|
Objects = new ClientObjectTable();
|
||||||
|
Physics.Engine.Objects = Objects;
|
||||||
var views = new RuntimeEntityObjectViews(Entities, Objects);
|
var views = new RuntimeEntityObjectViews(Entities, Objects);
|
||||||
EntityView = views.Entities;
|
EntityView = views.Entities;
|
||||||
InventoryView = views.Inventory;
|
InventoryView = views.Inventory;
|
||||||
|
|
@ -136,6 +144,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
||||||
physicsEngine,
|
physicsEngine,
|
||||||
timeProvider);
|
timeProvider);
|
||||||
Objects = new ClientObjectTable();
|
Objects = new ClientObjectTable();
|
||||||
|
Physics.Engine.Objects = Objects;
|
||||||
var views = new RuntimeEntityObjectViews(Entities, Objects);
|
var views = new RuntimeEntityObjectViews(Entities, Objects);
|
||||||
EntityView = views.Entities;
|
EntityView = views.Entities;
|
||||||
InventoryView = views.Inventory;
|
InventoryView = views.Inventory;
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,13 @@ public sealed class GameEventWiringTests
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void AppendU32(List<byte> payload, uint value)
|
||||||
|
{
|
||||||
|
byte[] u32 = new byte[4];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(u32, value);
|
||||||
|
payload.AddRange(u32);
|
||||||
|
}
|
||||||
|
|
||||||
private static byte[] WrapEnvelope(GameEventType type, byte[] payload)
|
private static byte[] WrapEnvelope(GameEventType type, byte[] payload)
|
||||||
{
|
{
|
||||||
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
|
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
|
||||||
|
|
@ -222,6 +229,65 @@ public sealed class GameEventWiringTests
|
||||||
Assert.Equal(new[] { 0x1500u }, wieldConfirmed);
|
Assert.Equal(new[] { 0x1500u }, wieldConfirmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): 0x0248
|
||||||
|
/// House_UpdateRestrictions refreshes an already-known house object's
|
||||||
|
/// guest/ban list. Wire shape verified against Chorizite
|
||||||
|
/// House_UpdateRestrictions.generated.cs: byte Sequence, uint SenderId,
|
||||||
|
/// then RestrictionDB (Version u32, Flags u32, MonarchId u32, PHashTable
|
||||||
|
/// packed-u32-count + count × (guid u32 + value u32)).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void WireAll_HouseUpdateRestrictions_UpdatesClientObjectTable()
|
||||||
|
{
|
||||||
|
var (d, items, _, _, _) = MakeAll();
|
||||||
|
const uint houseGuid = 0x70200099u;
|
||||||
|
items.AddOrUpdate(new ClientObject { ObjectId = houseGuid, WeenieClassId = 1 });
|
||||||
|
|
||||||
|
var payload = new List<byte>();
|
||||||
|
payload.Add(5); // Sequence
|
||||||
|
AppendU32(payload, houseGuid); // SenderId
|
||||||
|
AppendU32(payload, 0x10000002u); // Version
|
||||||
|
AppendU32(payload, 0u); // Flags (private)
|
||||||
|
AppendU32(payload, 0x50000500u); // MonarchId
|
||||||
|
AppendU32(payload, 1u); // PHashTable packed size (count=1)
|
||||||
|
AppendU32(payload, 0x50000001u); // guest guid
|
||||||
|
AppendU32(payload, 1u); // guest permission (storage)
|
||||||
|
|
||||||
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.HouseUpdateRestrictions, payload.ToArray()));
|
||||||
|
d.Dispatch(env!.Value);
|
||||||
|
|
||||||
|
var house = items.Get(houseGuid);
|
||||||
|
Assert.NotNull(house);
|
||||||
|
Assert.NotNull(house!.Restrictions);
|
||||||
|
Assert.False(house.Restrictions!.OpenToPublic);
|
||||||
|
Assert.Equal(0x50000500u, house.Restrictions.AllegianceMonarchId);
|
||||||
|
Assert.Single(house.Restrictions.Guests);
|
||||||
|
Assert.Equal(1u, house.Restrictions.Guests[0x50000001u]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WireAll_HouseUpdateRestrictions_UnknownHouseObject_NoOps()
|
||||||
|
{
|
||||||
|
// Retail's own client would only receive this for an object it already
|
||||||
|
// knows about; a stub half-formed ClientObject must not be invented.
|
||||||
|
var (d, items, _, _, _) = MakeAll();
|
||||||
|
const uint unknownHouseGuid = 0x70200100u;
|
||||||
|
|
||||||
|
var payload = new List<byte>();
|
||||||
|
payload.Add(1);
|
||||||
|
AppendU32(payload, unknownHouseGuid);
|
||||||
|
AppendU32(payload, 0x10000002u);
|
||||||
|
AppendU32(payload, 1u); // open
|
||||||
|
AppendU32(payload, 0u);
|
||||||
|
AppendU32(payload, 0u); // zero guests
|
||||||
|
|
||||||
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.HouseUpdateRestrictions, payload.ToArray()));
|
||||||
|
d.Dispatch(env!.Value);
|
||||||
|
|
||||||
|
Assert.Null(items.Get(unknownHouseGuid));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WireAll_ConfirmationRequest_UsesRetailTwoIntegerHeader()
|
public void WireAll_ConfirmationRequest_UsesRetailTwoIntegerHeader()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -431,15 +431,22 @@ public sealed class CreateObjectTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryParse_HouseRestrictionsSkipped_ThenIconOverlayCaptured()
|
public void TryParse_HouseRestrictionsCaptured_ThenIconOverlayCaptured()
|
||||||
{
|
{
|
||||||
// Verifies that the variable-length RestrictionDB skip (weenieFlags bit
|
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): HouseRestrictions
|
||||||
// 0x04000000) lands the cursor at the correct position so that
|
// (weenieFlags bit 0x04000000) is now CAPTURED, not skipped. Verifies both
|
||||||
// IconOverlay (bit 0x40000000) immediately after it is still captured.
|
// that the RestrictionDB parses correctly (zero-entry PHashTable) AND that
|
||||||
|
// the cursor lands at the correct position so IconOverlay (bit 0x40000000)
|
||||||
|
// immediately after it is still captured.
|
||||||
//
|
//
|
||||||
// Wire layout per ACE RestrictionDB (16 bytes, zero entries):
|
// Wire layout per Chorizite RestrictionDB.generated.cs (verified against
|
||||||
// Version(u32) + OpenStatus(u32) + MonarchId(u32) = 12 bytes
|
// protocol.xml:6270-6275; zero entries):
|
||||||
// count(u16) + numBuckets(u16) = 4 bytes
|
// Version(u32) + Flags(u32) + MonarchId(u32) = 12 bytes
|
||||||
|
// PHashTable header: ONE packed u32 whose low 24 bits are the entry
|
||||||
|
// count (NOT a separate count(u16)+numBuckets(u16) pair — the fixture
|
||||||
|
// below writes count=0/numBuckets=768 as two u16s, which happens to
|
||||||
|
// decode identically under the packed-u32 mask since 768 (0x0300)
|
||||||
|
// lands entirely in the discarded high byte) = 4 bytes
|
||||||
// entries: count(0) × 8 = 0 bytes
|
// entries: count(0) × 8 = 0 bytes
|
||||||
// total = 16 bytes
|
// total = 16 bytes
|
||||||
//
|
//
|
||||||
|
|
@ -463,10 +470,65 @@ public sealed class CreateObjectTests
|
||||||
var parsed = CreateObject.TryParse(body);
|
var parsed = CreateObject.TryParse(body);
|
||||||
|
|
||||||
Assert.NotNull(parsed);
|
Assert.NotNull(parsed);
|
||||||
Assert.Equal(0x06003333u, parsed!.Value.IconOverlayId);
|
Assert.NotNull(parsed!.Value.Restrictions);
|
||||||
|
Assert.False(parsed.Value.Restrictions!.OpenToPublic);
|
||||||
|
Assert.Equal(0u, parsed.Value.Restrictions.AllegianceMonarchId);
|
||||||
|
Assert.Empty(parsed.Value.Restrictions.Guests);
|
||||||
|
Assert.Equal(0x06003333u, parsed.Value.IconOverlayId);
|
||||||
Assert.Equal(0x06004444u, parsed.Value.IconUnderlayId);
|
Assert.Equal(0x06004444u, parsed.Value.IconUnderlayId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_HouseRestrictionsWithGuests_CapturesGuestTable()
|
||||||
|
{
|
||||||
|
// AP-129: a non-empty guest table (2 entries) parses correctly and the
|
||||||
|
// cursor still reaches HookItemTypes/Monarch/HookType/IconOverlay after it.
|
||||||
|
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||||
|
guid: 0x5000000Fu,
|
||||||
|
name: "HouseCottage",
|
||||||
|
itemType: (uint)ItemType.Portal,
|
||||||
|
weenieFlags: 0x04000000u | 0x20000000u | 0x00000040u | 0x10000000u | 0x40000000u,
|
||||||
|
houseRestrictionOpen: false,
|
||||||
|
houseRestrictionMonarchId: 0x50000500u,
|
||||||
|
houseRestrictionGuests: new Dictionary<uint, uint> { [0x50000001u] = 0u, [0x50000002u] = 1u },
|
||||||
|
hookItemTypes: 0x1u,
|
||||||
|
hookType: 2,
|
||||||
|
iconOverlayId: 0x5555u);
|
||||||
|
|
||||||
|
var parsed = CreateObject.TryParse(body);
|
||||||
|
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
Assert.NotNull(parsed!.Value.Restrictions);
|
||||||
|
Assert.False(parsed.Value.Restrictions!.OpenToPublic);
|
||||||
|
Assert.Equal(0x50000500u, parsed.Value.Restrictions.AllegianceMonarchId);
|
||||||
|
Assert.Equal(2, parsed.Value.Restrictions.Guests.Count);
|
||||||
|
Assert.Equal(0u, parsed.Value.Restrictions.Guests[0x50000001u]);
|
||||||
|
Assert.Equal(1u, parsed.Value.Restrictions.Guests[0x50000002u]);
|
||||||
|
Assert.Equal(0x1u, parsed.Value.HookItemTypes);
|
||||||
|
Assert.Equal((uint)2, parsed.Value.HookType);
|
||||||
|
Assert.Equal(0x06005555u, parsed.Value.IconOverlayId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_HouseOwnerAndMonarch_Captured()
|
||||||
|
{
|
||||||
|
// AP-129: HouseOwner (0x02000000) and Monarch (0x00000040) are now
|
||||||
|
// captured instead of skipped.
|
||||||
|
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||||
|
guid: 0x50000012u,
|
||||||
|
name: "HouseDoor",
|
||||||
|
itemType: (uint)ItemType.Portal,
|
||||||
|
weenieFlags: 0x02000000u | 0x00000040u,
|
||||||
|
houseOwnerId: 0x50000042u,
|
||||||
|
monarchId: 0x50000777u);
|
||||||
|
|
||||||
|
var parsed = CreateObject.TryParse(body);
|
||||||
|
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
Assert.Equal(0x50000042u, parsed!.Value.HouseOwnerId);
|
||||||
|
Assert.Equal(0x50000777u, parsed.Value.MonarchId);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags bit 0x80) — captured
|
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags bit 0x80) — captured
|
||||||
// instead of skipped. Drives the icon's effect-overlay recolor.
|
// instead of skipped. Drives the icon's effect-overlay recolor.
|
||||||
|
|
@ -768,7 +830,13 @@ public sealed class CreateObjectTests
|
||||||
uint petOwnerId = 0,
|
uint petOwnerId = 0,
|
||||||
ushort spellId = 0,
|
ushort spellId = 0,
|
||||||
uint hookItemTypes = 0,
|
uint hookItemTypes = 0,
|
||||||
ushort hookType = 0)
|
ushort hookType = 0,
|
||||||
|
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30).
|
||||||
|
uint? houseOwnerId = null,
|
||||||
|
uint? monarchId = null,
|
||||||
|
bool houseRestrictionOpen = false,
|
||||||
|
uint houseRestrictionMonarchId = 0,
|
||||||
|
IReadOnlyDictionary<uint, uint>? houseRestrictionGuests = null)
|
||||||
{
|
{
|
||||||
var bytes = new List<byte>();
|
var bytes = new List<byte>();
|
||||||
WriteU32(bytes, CreateObject.Opcode);
|
WriteU32(bytes, CreateObject.Opcode);
|
||||||
|
|
@ -858,20 +926,25 @@ public sealed class CreateObjectTests
|
||||||
}
|
}
|
||||||
if ((weenieFlags & 0x00200000u) != 0) WriteU16(bytes, burden ?? 0); // Burden u16
|
if ((weenieFlags & 0x00200000u) != 0) WriteU16(bytes, burden ?? 0); // Burden u16
|
||||||
if ((weenieFlags & 0x00400000u) != 0) WriteU16(bytes, spellId); // Spell u16
|
if ((weenieFlags & 0x00400000u) != 0) WriteU16(bytes, spellId); // Spell u16
|
||||||
if ((weenieFlags & 0x02000000u) != 0) WriteU32(bytes, 0); // HouseOwner u32
|
if ((weenieFlags & 0x02000000u) != 0) WriteU32(bytes, houseOwnerId ?? 0); // HouseOwner u32
|
||||||
// HouseRestrictions (0x04000000): not parameterized (zero entries).
|
// HouseRestrictions (0x04000000). Wire per Chorizite RestrictionDB.generated.cs:
|
||||||
// Wire: Version(u32) + OpenStatus(u32) + MonarchId(u32) + count(u16) + numBuckets(u16) + entries.
|
// Version(u32) + Flags(u32) + MonarchId(u32) + PHashTable<uint,uint> (one packed
|
||||||
// Zero entries → 16 bytes total.
|
// u32 whose low 24 bits are the entry count, then count × (guid u32 + value u32)).
|
||||||
if ((weenieFlags & 0x04000000u) != 0)
|
if ((weenieFlags & 0x04000000u) != 0)
|
||||||
{
|
{
|
||||||
WriteU32(bytes, 0x10000002u); // Version
|
WriteU32(bytes, 0x10000002u); // Version
|
||||||
WriteU32(bytes, 0u); // OpenStatus
|
WriteU32(bytes, houseRestrictionOpen ? 1u : 0u); // Flags
|
||||||
WriteU32(bytes, 0u); // MonarchId
|
WriteU32(bytes, houseRestrictionMonarchId); // MonarchId
|
||||||
WriteU16(bytes, 0); // count
|
var guests = houseRestrictionGuests ?? new Dictionary<uint, uint>();
|
||||||
WriteU16(bytes, 768); // numBuckets (retail constant)
|
WriteU32(bytes, (uint)guests.Count); // PHashTable packed size (low 24 bits = count)
|
||||||
|
foreach (var kvp in guests)
|
||||||
|
{
|
||||||
|
WriteU32(bytes, kvp.Key);
|
||||||
|
WriteU32(bytes, kvp.Value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ((weenieFlags & 0x20000000u) != 0) WriteU32(bytes, hookItemTypes); // HookItemTypes u32
|
if ((weenieFlags & 0x20000000u) != 0) WriteU32(bytes, hookItemTypes); // HookItemTypes u32
|
||||||
if ((weenieFlags & 0x00000040u) != 0) WriteU32(bytes, 0); // Monarch u32
|
if ((weenieFlags & 0x00000040u) != 0) WriteU32(bytes, monarchId ?? 0); // Monarch u32
|
||||||
if ((weenieFlags & 0x10000000u) != 0) WriteU16(bytes, hookType); // HookType u16
|
if ((weenieFlags & 0x10000000u) != 0) WriteU16(bytes, hookType); // HookType u16
|
||||||
if ((weenieFlags & 0x40000000u) != 0) WritePackedDword(bytes, iconOverlayId); // IconOverlay
|
if ((weenieFlags & 0x40000000u) != 0) WritePackedDword(bytes, iconOverlayId); // IconOverlay
|
||||||
if ((weenieFlags2 & 0x00000001u) != 0) WritePackedDword(bytes, iconUnderlayId); // IconUnderlay
|
if ((weenieFlags2 & 0x00000001u) != 0) WritePackedDword(bytes, iconUnderlayId); // IconUnderlay
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Items;
|
||||||
using AcDream.Core.Physics;
|
using AcDream.Core.Physics;
|
||||||
using DatReaderWriter.Enums;
|
using DatReaderWriter.Enums;
|
||||||
using DatReaderWriter.Types;
|
using DatReaderWriter.Types;
|
||||||
|
|
@ -8,34 +9,49 @@ using Xunit;
|
||||||
namespace AcDream.Core.Tests.Physics;
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Conformance tests for AP-71 (Campaign P Slice P4, 2026-07-30) — retail
|
/// Conformance tests for AP-71/AP-129 (Campaign P Slice P4, 2026-07-30 —
|
||||||
|
/// including the 2026-07-30 Opus review fix) — retail
|
||||||
/// <c>CObjCell::check_entry_restrictions</c> (named-retail pc:308873-308912,
|
/// <c>CObjCell::check_entry_restrictions</c> (named-retail pc:308873-308912,
|
||||||
/// 0x0052b6d0), ported as <see cref="ObjectInfo.CheckEntryRestrictions"/> and
|
/// 0x0052b6d0) plus <c>ACCWeenieObject::CanMoveInto</c> (pc:407982-408056)
|
||||||
/// wired at the head of the indoor branch of
|
/// and <c>RestrictionDB::IsAllowedIn</c> (pc:444493-444516), ported as
|
||||||
/// <c>Transition.FindEnvCollisions</c> (<c>src/AcDream.Core/Physics/
|
/// <see cref="ObjectInfo.CheckEntryRestrictions"/> and wired at the head of
|
||||||
/// TransitionTypes.cs</c>).
|
/// the indoor branch of <c>Transition.FindEnvCollisions</c>
|
||||||
|
/// (<c>src/AcDream.Core/Physics/TransitionTypes.cs</c>).
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Retail gate order: NPCs/props (not a player) bypass entirely; a mover
|
/// Retail gate order: NPCs/props (not a player) bypass entirely; a mover
|
||||||
/// whose PWD bitfield grants <c>CanBypassMoveRestrictions</c> (BF_ADMIN &
|
/// whose PWD bitfield grants <c>CanBypassMoveRestrictions</c> (BF_ADMIN &
|
||||||
/// BF_IMMUNE_CELL_RESTRICTIONS) bypasses; an ordinary cell (no
|
/// BF_IMMUNE_CELL_RESTRICTIONS) bypasses; an ordinary cell (no
|
||||||
/// <c>restriction_obj</c> authored) is a no-op; otherwise retail resolves
|
/// <c>restriction_obj</c> authored) is a no-op; otherwise retail resolves
|
||||||
/// the restriction weenie and asks <c>CanMoveInto</c> — acdream has no
|
/// the restriction weenie (<see cref="ClientObjectTable"/> is acdream's
|
||||||
/// owner/guest-list model for that yet, so it fails CLOSED (Collided),
|
/// <c>GetObjectA</c> equivalent) and asks <c>CanMoveInto</c>: an unresolved
|
||||||
/// matching retail's own fallback when the restriction object can't be
|
/// object fails CLOSED (retail's own fallback, pc:704-716); a resolved
|
||||||
/// resolved (pc:704-716).
|
/// object with no owner (or the mover IS the owner) admits; a resolved
|
||||||
|
/// object with no guest list (<c>_db == 0</c>) admits; otherwise
|
||||||
|
/// <c>RestrictionDB::IsAllowedIn</c> decides (open-to-public, shared
|
||||||
|
/// allegiance monarch, or explicit guest-table membership).
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// AP-129 review-fix context: 103,766 of 729,888 installed EnvCells (1,293
|
||||||
|
/// landblocks — the housing estate) carry a baked <c>RestrictionObj</c>
|
||||||
|
/// (<c>RestrictionObjPrevalenceInspectionTests</c>). Shipping the gate
|
||||||
|
/// unconditionally fail-closed for every one of those cells would lock
|
||||||
|
/// every house interior for every player, including its own owner — this
|
||||||
|
/// suite's "resolved" scenarios are what prevents that regression.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class Ap71EntryRestrictionGateTests
|
public sealed class Ap71EntryRestrictionGateTests
|
||||||
{
|
{
|
||||||
private const uint RestrictionObjGuid = 0x80001234u;
|
private const uint RestrictionObjGuid = 0x80001234u;
|
||||||
|
private const uint MoverGuid = 0x50000001u;
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OrdinaryCell_NoRestrictionObj_IsNoOp_ForPlayer()
|
public void OrdinaryCell_NoRestrictionObj_IsNoOp_ForPlayer()
|
||||||
{
|
{
|
||||||
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer };
|
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer };
|
||||||
|
|
||||||
Assert.Equal(TransitionState.OK, mover.CheckEntryRestrictions(cellRestrictionObj: 0));
|
Assert.Equal(TransitionState.OK, mover.CheckEntryRestrictions(cellRestrictionObj: 0, objects: null));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -45,7 +61,7 @@ public sealed class Ap71EntryRestrictionGateTests
|
||||||
// the overwhelmingly common (non-house) content regardless of mover kind.
|
// the overwhelmingly common (non-house) content regardless of mover kind.
|
||||||
var mover = new ObjectInfo { State = ObjectInfoState.None };
|
var mover = new ObjectInfo { State = ObjectInfoState.None };
|
||||||
|
|
||||||
Assert.Equal(TransitionState.OK, mover.CheckEntryRestrictions(cellRestrictionObj: 0));
|
Assert.Equal(TransitionState.OK, mover.CheckEntryRestrictions(cellRestrictionObj: 0, objects: null));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -57,36 +73,192 @@ public sealed class Ap71EntryRestrictionGateTests
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
TransitionState.OK,
|
TransitionState.OK,
|
||||||
mover.CheckEntryRestrictions(RestrictionObjGuid));
|
mover.CheckEntryRestrictions(RestrictionObjGuid, objects: null));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RestrictedCell_PlayerMover_CannotBypass_FailsClosed()
|
public void RestrictedCell_PlayerMover_UnresolvedRestrictionObject_FailsClosed()
|
||||||
{
|
{
|
||||||
// A restricted cell, a player mover, no CanBypassMoveRestrictions bit:
|
// A restricted cell, a player mover, no CanBypassMoveRestrictions bit,
|
||||||
// acdream cannot resolve CanMoveInto (no guest-list model) -> Collided,
|
// and no way to resolve the restriction object (objects table null,
|
||||||
// matching retail's own fallback when the restriction weenie can't be
|
// or the object simply hasn't arrived via CreateObject yet) ->
|
||||||
// resolved (pc:704-716 fallthrough to COLLIDED_TS).
|
// Collided, exactly retail's own fallback when GetObjectA(restriction_obj)
|
||||||
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer };
|
// returns null (pc:704-716 fallthrough to COLLIDED_TS).
|
||||||
|
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
TransitionState.Collided,
|
TransitionState.Collided,
|
||||||
mover.CheckEntryRestrictions(RestrictionObjGuid));
|
mover.CheckEntryRestrictions(RestrictionObjGuid, objects: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RestrictedCell_PlayerMover_ResolvedButObjectsTableHasNoRow_FailsClosed()
|
||||||
|
{
|
||||||
|
// A non-null table that simply doesn't (yet) know this specific
|
||||||
|
// restriction object -- same fail-closed outcome, proven with a real
|
||||||
|
// (empty) ClientObjectTable rather than a null one.
|
||||||
|
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
TransitionState.Collided,
|
||||||
|
mover.CheckEntryRestrictions(RestrictionObjGuid, objects));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RestrictedCell_PlayerMover_CanBypassMoveRestrictions_PassesThrough()
|
public void RestrictedCell_PlayerMover_CanBypassMoveRestrictions_PassesThrough()
|
||||||
{
|
{
|
||||||
// BF_ADMIN & BF_IMMUNE_CELL_RESTRICTIONS both set on the mover's own
|
// BF_ADMIN & BF_IMMUNE_CELL_RESTRICTIONS both set on the mover's own
|
||||||
// PWD bitfield (via EntityCollisionFlagsExt.ToMoverState) -> OK.
|
// PWD bitfield (via EntityCollisionFlagsExt.ToMoverState) -> OK,
|
||||||
|
// regardless of whether the restriction object could even resolve.
|
||||||
var mover = new ObjectInfo
|
var mover = new ObjectInfo
|
||||||
{
|
{
|
||||||
State = ObjectInfoState.IsPlayer | ObjectInfoState.CanBypassMoveRestrictions,
|
State = ObjectInfoState.IsPlayer | ObjectInfoState.CanBypassMoveRestrictions,
|
||||||
|
SelfEntityId = MoverGuid,
|
||||||
};
|
};
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
TransitionState.OK,
|
TransitionState.OK,
|
||||||
mover.CheckEntryRestrictions(RestrictionObjGuid));
|
mover.CheckEntryRestrictions(RestrictionObjGuid, objects: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AP-129 review fix: CanMoveInto / IsAllowedIn resolved scenarios ────
|
||||||
|
|
||||||
|
private static ClientObjectTable MakeObjectsWithRestrictionObject(
|
||||||
|
uint? houseOwnerId,
|
||||||
|
HouseRestrictionRecord? restrictions)
|
||||||
|
{
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = RestrictionObjGuid,
|
||||||
|
HouseOwnerId = houseOwnerId,
|
||||||
|
Restrictions = restrictions,
|
||||||
|
});
|
||||||
|
return objects;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvedUnownedHouse_NoOwnerNoRestrictions_Admits()
|
||||||
|
{
|
||||||
|
// HouseOwnerId absent (0) and no RestrictionDB at all -- retail:
|
||||||
|
// house_owner_iid==0 -> CanMoveInto returns true immediately.
|
||||||
|
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
|
||||||
|
var objects = MakeObjectsWithRestrictionObject(houseOwnerId: null, restrictions: null);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
TransitionState.OK,
|
||||||
|
mover.CheckEntryRestrictions(RestrictionObjGuid, objects));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvedHouse_MoverIsOwner_Admits()
|
||||||
|
{
|
||||||
|
// HouseOwnerId == mover's own guid -- retail: house_owner_iid==mover.id
|
||||||
|
// -> CanMoveInto returns true, even with a closed guest-only RestrictionDB
|
||||||
|
// that would otherwise exclude this exact mover.
|
||||||
|
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
|
||||||
|
var closedList = new HouseRestrictionRecord(
|
||||||
|
OpenToPublic: false,
|
||||||
|
AllegianceMonarchId: 0,
|
||||||
|
Guests: new Dictionary<uint, uint>());
|
||||||
|
var objects = MakeObjectsWithRestrictionObject(houseOwnerId: MoverGuid, restrictions: closedList);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
TransitionState.OK,
|
||||||
|
mover.CheckEntryRestrictions(RestrictionObjGuid, objects));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvedHouse_NoRestrictionDb_Admits()
|
||||||
|
{
|
||||||
|
// Owned by someone else, but Restrictions is null (retail _db == 0)
|
||||||
|
// -- "no list" is open, matching a retail client that hasn't been
|
||||||
|
// sent House_UpdateRestrictions yet.
|
||||||
|
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
|
||||||
|
const uint otherOwnerId = 0x50000099u;
|
||||||
|
var objects = MakeObjectsWithRestrictionObject(houseOwnerId: otherOwnerId, restrictions: null);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
TransitionState.OK,
|
||||||
|
mover.CheckEntryRestrictions(RestrictionObjGuid, objects));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvedHouse_PresentListExcludesMover_Blocks()
|
||||||
|
{
|
||||||
|
// Owned by someone else, closed (not open-to-public), no shared
|
||||||
|
// allegiance monarch, and the mover's guid is NOT in the guest table
|
||||||
|
// -- retail RestrictionDB::IsAllowedIn falls through to deny.
|
||||||
|
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
|
||||||
|
const uint otherOwnerId = 0x50000099u;
|
||||||
|
var restrictions = new HouseRestrictionRecord(
|
||||||
|
OpenToPublic: false,
|
||||||
|
AllegianceMonarchId: 0,
|
||||||
|
Guests: new Dictionary<uint, uint> { [0x50000002u] = 0u }); // a DIFFERENT guest
|
||||||
|
var objects = MakeObjectsWithRestrictionObject(houseOwnerId: otherOwnerId, restrictions: restrictions);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
TransitionState.Collided,
|
||||||
|
mover.CheckEntryRestrictions(RestrictionObjGuid, objects));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvedHouse_PresentListIncludesMover_Admits()
|
||||||
|
{
|
||||||
|
// Same closed house, but the mover's own guid IS a guest-table entry
|
||||||
|
// -- RestrictionDB::IsAllowedIn finds it -> admit. Permission VALUE
|
||||||
|
// (0=dwelling,1=storage) is not consulted for entry, matching retail.
|
||||||
|
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
|
||||||
|
const uint otherOwnerId = 0x50000099u;
|
||||||
|
var restrictions = new HouseRestrictionRecord(
|
||||||
|
OpenToPublic: false,
|
||||||
|
AllegianceMonarchId: 0,
|
||||||
|
Guests: new Dictionary<uint, uint> { [MoverGuid] = 1u }); // storage guest
|
||||||
|
var objects = MakeObjectsWithRestrictionObject(houseOwnerId: otherOwnerId, restrictions: restrictions);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
TransitionState.OK,
|
||||||
|
mover.CheckEntryRestrictions(RestrictionObjGuid, objects));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvedHouse_OpenToPublic_AdmitsEvenWithoutGuestEntry()
|
||||||
|
{
|
||||||
|
// Flags bit 0 set (open to public) -- everyone in, regardless of the
|
||||||
|
// guest table or allegiance.
|
||||||
|
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
|
||||||
|
const uint otherOwnerId = 0x50000099u;
|
||||||
|
var restrictions = new HouseRestrictionRecord(
|
||||||
|
OpenToPublic: true,
|
||||||
|
AllegianceMonarchId: 0,
|
||||||
|
Guests: new Dictionary<uint, uint>());
|
||||||
|
var objects = MakeObjectsWithRestrictionObject(houseOwnerId: otherOwnerId, restrictions: restrictions);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
TransitionState.OK,
|
||||||
|
mover.CheckEntryRestrictions(RestrictionObjGuid, objects));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvedHouse_MoverSharesAllegianceMonarch_Admits()
|
||||||
|
{
|
||||||
|
// Closed, mover not in the guest table, but the mover's OWN MonarchId
|
||||||
|
// (retail mover_weenie+0x128) matches the house's AllegianceMonarchId
|
||||||
|
// -- RestrictionDB::IsAllowedIn's allegiance branch admits.
|
||||||
|
var mover = new ObjectInfo { State = ObjectInfoState.IsPlayer, SelfEntityId = MoverGuid };
|
||||||
|
const uint otherOwnerId = 0x50000099u;
|
||||||
|
const uint monarchId = 0x50000500u;
|
||||||
|
var restrictions = new HouseRestrictionRecord(
|
||||||
|
OpenToPublic: false,
|
||||||
|
AllegianceMonarchId: monarchId,
|
||||||
|
Guests: new Dictionary<uint, uint>());
|
||||||
|
var objects = MakeObjectsWithRestrictionObject(houseOwnerId: otherOwnerId, restrictions: restrictions);
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = MoverGuid, MonarchId = monarchId });
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
TransitionState.OK,
|
||||||
|
mover.CheckEntryRestrictions(RestrictionObjGuid, objects));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PWD-bitfield decode (mirrors the existing EntityCollisionFlagsTests
|
// ── PWD-bitfield decode (mirrors the existing EntityCollisionFlagsTests
|
||||||
|
|
@ -135,11 +307,11 @@ public sealed class Ap71EntryRestrictionGateTests
|
||||||
ObjectInfoState moverState =
|
ObjectInfoState moverState =
|
||||||
ObjectInfoState.IsPlayer | EntityCollisionFlagsExt.FromPwdBitfield(bitfield).ToMoverState();
|
ObjectInfoState.IsPlayer | EntityCollisionFlagsExt.FromPwdBitfield(bitfield).ToMoverState();
|
||||||
|
|
||||||
var mover = new ObjectInfo { State = moverState };
|
var mover = new ObjectInfo { State = moverState, SelfEntityId = MoverGuid };
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
TransitionState.OK,
|
TransitionState.OK,
|
||||||
mover.CheckEntryRestrictions(RestrictionObjGuid));
|
mover.CheckEntryRestrictions(RestrictionObjGuid, objects: null));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CellPhysics.RestrictionObj plumbing (ordinary-cell no-op proof) ────
|
// ── CellPhysics.RestrictionObj plumbing (ordinary-cell no-op proof) ────
|
||||||
|
|
@ -246,4 +418,56 @@ public sealed class Ap71EntryRestrictionGateTests
|
||||||
System.MathF.Abs(t.SpherePath.CurPos.X - to.X) < 1e-4f,
|
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}");
|
$"Ordinary cell must be a complete no-op; CurPos.X={t.SpherePath.CurPos.X:F4}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── AP-129 review-fix end-to-end: the full PhysicsEngine.Objects wiring
|
||||||
|
// (production-representative — a real house owner/guest resolution, not
|
||||||
|
// just an unresolved fail-closed default) ──────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EndToEnd_RestrictedCell_MoverIsResolvedOwner_TransitionReachesTarget()
|
||||||
|
{
|
||||||
|
var engine = MakeEngine(MakeIndoorCell(restrictionObj: RestrictionObjGuid));
|
||||||
|
engine.Objects = MakeObjectsWithRestrictionObject(
|
||||||
|
houseOwnerId: MoverGuid,
|
||||||
|
restrictions: new HouseRestrictionRecord(
|
||||||
|
OpenToPublic: false,
|
||||||
|
AllegianceMonarchId: 0,
|
||||||
|
Guests: new Dictionary<uint, uint>()));
|
||||||
|
|
||||||
|
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.ObjectInfo.SelfEntityId = MoverGuid;
|
||||||
|
|
||||||
|
t.FindTransitionalPosition(engine);
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
System.MathF.Abs(t.SpherePath.CurPos.X - to.X) < 1e-4f,
|
||||||
|
$"The house's own owner must pass through unimpeded; CurPos.X={t.SpherePath.CurPos.X:F4}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EndToEnd_RestrictedCell_MoverIsResolvedNonGuest_TransitionHaltsAtOrigin()
|
||||||
|
{
|
||||||
|
var engine = MakeEngine(MakeIndoorCell(restrictionObj: RestrictionObjGuid));
|
||||||
|
engine.Objects = MakeObjectsWithRestrictionObject(
|
||||||
|
houseOwnerId: 0x50000099u,
|
||||||
|
restrictions: new HouseRestrictionRecord(
|
||||||
|
OpenToPublic: false,
|
||||||
|
AllegianceMonarchId: 0,
|
||||||
|
Guests: new Dictionary<uint, uint> { [0x50000002u] = 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.ObjectInfo.SelfEntityId = MoverGuid;
|
||||||
|
|
||||||
|
t.FindTransitionalPosition(engine);
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
System.MathF.Abs(t.SpherePath.CurPos.X - from.X) < 1e-4f,
|
||||||
|
$"A resolved house that excludes this mover must still halt them; CurPos.X={t.SpherePath.CurPos.X:F4}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue