From 4abd1b5eb7bc0b2d8c2627665b392b730f3f5423 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Aug 2026 14:51:36 +0200 Subject: [PATCH] =?UTF-8?q?fix(physics):=20AP-152=20=E2=80=94=20dispatch?= =?UTF-8?q?=20collision=20shapes=20BSP-first,=20at=20emission=20and=20at?= =?UTF-8?q?=20the=20cell=20flood?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The register row predicted "catching or stopping on a doorway sill". That symptom could not have been occurring. `Transition.BspOnlyDispatch` (TransitionTypes.cs:1348, landed 2026-05-25 as A6.P7) already skipped both primitive branches (:3911, :3954) whenever the target's wire PhysicsState carries HAS_PHYSICS_BSP_PS, and ACE sets that bit from CSetup.HasPhysicsBSP for every affected Setup. The extra primitive was never tested for collision. The live defect was CELL MEMBERSHIP. The same shape list feeds `ShadowObjectRegistry.BuildFloodSpheres`, which had no such guard and preferred Cylinders over everything whenever any Cylinder existed — retail's SECOND priority applied ahead of its first. For the 73 CylSphere+BSP Setups acdream therefore flooded shadow cells from the cylinder and never from the slab: an object absent from cells it physically occupies, which is the #98 / #168 symptom class, not the door-collision class the row named. Retail, re-disassembled from the PDB-paired binary (v11.4186, CodeView GUID 9e847e2f-777c-4bd9-886c-22256bb87f32, check_exe_pdb.py MATCH) rather than taken from Binary Ninja, which drops flag tests: CPhysicsObj::FindObjCollisions @0x0050f050 0x0050f165 test dword [esi+0xa8], 0x10000 0x0050f16f je 0x50f1a2 ; clear -> primitive dispatch 0x0050f18d call 0x518180 ; CPartArray::FindObjCollisions 0x0050f19d jmp 0x50f2b0 ; UNCONDITIONAL, past BOTH primitive loops ; (CylSphere 0x50f1a2, Sphere 0x50f21d) 0x0050f1d6 jae 0x50f317 ; CylSphere loop exhausted -> RETURN 0x0050f22f je 0x50f31b ; zero Spheres -> RETURN seeded OK_TS CPhysicsObj::calc_cross_cells @0x00515230 0x00515285 test dword [esi+0xa8], 0x10000 0x0051528f jne 0x515305 -> CPhysicsObj::find_bbox_cell_list @0x00510fc0 0x005152d1 call 0x52b9f0 ; cylsphere branch, below the jump 0x005152fb call 0x52b990 ; sorting-sphere branch, below the jump Priority at both consumers: BSP -> CylSphere -> Sphere -> nothing. BSP wins. Every address above was resolved back to its symbol by exact lookup in named-retail/symbols.json. Changes: * `ShadowShapeBuilder.FromSetup` gains a step-0 dispatch gate. Steps 1 and 2 are skipped entirely when any part's EFFECTIVE GfxObj carries a physics BSP. The gate and step 3 now share one `EffectivePartGfxObjId` helper, so they cannot read different identities — a gate on `setup.Parts` would, after an ObjDesc swap, suppress the primitives while step 3 emitted nothing and `Build` returned null, deleting the entity's collision. Emission order is unchanged. This also removes acdream's undeclared reliance on the server sending the flag: the gate is derived from the parts, exactly as CPartArray::CacheHasPhysicsBSP @0x00518110 derives it. * `ShadowObjectRegistry.BuildFloodSpheres` now applies calc_cross_cells' own order: BSP, else Cylinder, else everything. Given the gate above this is a no-op for every shape list acdream produces (FromSetup is now exclusive; both landblock-static publishers already emit homogeneous lists), so the measured membership delta remains attributable to the gate alone. It is kept for the same reason BspOnlyDispatch is kept: retail genuinely dispatches here, and it guards a future additive producer. `Transition.BspOnlyDispatch` is deliberately untouched. Register: AP-152 RETIRED with its four false statements corrected — the risk statement (the symptom was already inert); "small and centred at the part origin" (max primitive is 6.714 m, and 0x0200086E's sphere origin is (0.759, 0.165, 5.842)); the cottage door's "~14 cm base Sphere" (it is 0.100 m; 0.141 is Setup.Radius, which AP-22 proved is never collision geometry); and naming one pinning test where two existed. AP-153/154/155 filed: retail's dispatch flag is cached once at InitPartArrayObject+0x7e where acdream's gate is live; the query-time guard takes a client-derived flag off the wire; and the static publishers emit Setup Spheres as height-capped Cylinders while BuildFloodSpheres approximates retail's bounding box with bounding spheres. Tests. Both pinning tests corrected, neither deleted: `FromSetup_DoorSetup_ProducesFourShapes` -> `..._EmitsBspPartsOnly`; `FromSetup_DoorSetup_SphereAtExpectedLocalOffset` re-hosted on `_ => false`, the DAT-real configuration for the 3,605 Sphere-only Setups. `FromSetup_ScaleFactor_MultipliesAllRadiiAndOffsets` was the campaign's eighth green test covering nothing — its assertions sat inside `if (CollisionType == Cylinder)` on a fixture with zero CylSpheres, so only `Scale == 2.0f` ever ran. Proved empirically: with the sphere radius scale deleted, the old body passes and the corrected body fails. Three new facts: the effective-identity gate, the App-layer CylSphere+BSP registration (no App fixture combined the two before), and the flood-set dispatch. One new installed-DAT sweep pins 172 affected Setups (73 CylSphere+BSP, 99 Sphere+BSP) behind external bucket controls, re-measured independently and agreeing exactly with the filing commit's separate sweep. All eight sabotages run and reported; every discriminating fact reddens in the intended direction and only there. Clean Release build after deleting every bin/obj: 0 errors. Complete suite 11,203 passed / 4 skipped / 0 failed, +5 on the 11,198 baseline at ec29a732 — exactly the five added facts, no new skips. Blast radius, corrected: the FromSetup half is graphical-only (its sole production caller is LiveEntityCollisionBuilder in AcDream.App, which AcDream.Headless cannot reference — Headless -> Runtime -> Core/Content). The BuildFloodSpheres half lives in AcDream.Core and DOES execute in Headless via LandblockPhysicsContentBuilder, but is behaviour-neutral there because both of that builder's registrations pass homogeneous lists. Headless suite green at 89/89. NOT yet gated live: this changes shadow-cell membership for 22 Setups used by 151 Door weenies and 38 stationary props. Needs a connected session. Co-Authored-By: Claude Opus 4.8 --- .../retail-divergence-register.md | 7 +- docs/research/2026-08-06-ap152-contract.md | 1057 +++++++++++++++++ .../Physics/ShadowObjectRegistry.cs | 48 +- .../Physics/ShadowShapeBuilder.cs | 145 ++- .../LiveEntityCollisionBuilderTests.cs | 43 + ...InstalledSetupBspPrimitiveDispatchTests.cs | 159 +++ .../ShadowObjectRegistryMultiPartTests.cs | 109 ++ .../ShadowShapeBuilderShapeSourceTests.cs | 18 +- .../Physics/ShadowShapeBuilderTests.cs | 134 ++- 9 files changed, 1635 insertions(+), 85 deletions(-) create mode 100644 docs/research/2026-08-06-ap152-contract.md create mode 100644 tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b9f70298..9622a48a 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -162,7 +162,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 105 active rows (AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; 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-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) — 107 active rows (AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; 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-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -180,7 +180,10 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-149 | **Filed 2026-08-05 at the #280 fix (portal destination prefetch).** The reveal gate's OUTER ring accepts terrain-only publication where retail requires the landblock's full static-DAT closure. Retail's `LScape::PreFetchCells` @0x00505660 walks the whole `mid_radius` square and, for EVERY in-bounds landblock, requires (1) its terrain record resident, (2) its `LandBlockInfo` type-2 record resident, and (3) via `CLandBlock::PreFetchCells` @0x00530240 -> `CLandBlockInfo::PreFetchCells` @0x0052E7C0 -> `CBldPortal::PreFetchCells` @0x0053BD00, every EnvCell of every building it contains. acdream's outer ring is Far-tier: heightmap + terrain render mesh + terrain collision, with NO LandBlockInfo, no buildings, no building EnvCells and no procedural scenery, because the Far tier does not load them at all. The gate therefore converges on a strictly weaker condition than retail's out beyond `NearRadius`. **#280 closed the 11.4:1 reveal-window/visible-window ratio; it did NOT close this. Do not let a later closeout claim parity.** | `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`, the far arm); `src/AcDream.App/Streaming/LandblockBuildFactory.cs` (the Far build's contents); `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs` | Closing it would mean promoting the entire Far window to Near, i.e. deleting the two-tier streaming design that exists precisely because full hydration of a 25x25 window is unaffordable. Retail affords it because retail's ONE square is 17x17 at its default draw distance and it blocks the whole simulation while loading it (`CellManager::blocking_for_cells`), which acdream deliberately does not do (see AD-2). The residual is bounded to content that is only ever seen at Far distances. | A distant BUILDING, its interior EnvCell shells, or distant procedural scenery can still appear after the viewport opens, at Far-ring distances (beyond ~768 m at the shipped High preset), where retail would have kept blocking. Distant TERRAIN — the reported #280 symptom — no longer can. | `LScape::PreFetchCells` 0x00505660; `CLandBlock::PreFetchCells` 0x00530240; `CLandBlockInfo::PreFetchCells` 0x0052E7C0; `CBldPortal::PreFetchCells` 0x0053BD00 | | AP-150 | **Filed 2026-08-06 at the #280 retail-conformance review (finding F2).** acdream arms the `"In Portal Space - Please Wait..."` cue only after the hold has run five seconds (`RuntimeWorldTransitState.RetailWaitCueDelay = TimeSpan.FromSeconds(5)`, enforced at the readiness tick; `PortalTunnelPresentation.TickRotation` then re-emits per rotation segment only `if (_waitCueVisible)`). Retail has no such threshold. The emit site is inside `gmSmartBoxUI::UseTime`'s `TAS_TUNNEL*` branch, in the `else` arm of the rotation-segment-expiry test at 0x004D6FCD: when a segment expires retail picks a new random segment and calls `ECM_UI::SendNotice_DisplayStringInfo(0x1a, ...)` UNCONDITIONALLY, whether or not `CellManager::blocking_for_cells` is set — the notice is a property of being in the tunnel, not of being blocked. Byte-decoded at 0x004D6FE6-0x004D7049: `teleportRotationDuration = RandDouble(0.6, 1.8)` s (`0x3ffccccc/0xcccccccd` = 1.8, `0x3fe33333/0x33333333` = 0.6) and `teleportRotationEndAngle = RandDouble(0, 360)` (`0x40768000`). The unrelated 5.0 s constant at 0x007991B0 belongs to `CellManager::CheckPrefetchStatus` @0x00455BE0, the prefetch RETRY cadence, and was mis-attributed to the cue by #280's commit message. acdream's own segment constants (`RotationDurationMin = 0.6f`, `RotationDurationMax = 1.8f`) already match retail exactly, so the cadence is faithful and only the ARMING is not. Pre-dates #280; filed here because #280 reasoned from the wrong model and because the row did not exist. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs` (`RetailWaitCueDelay`); `src/AcDream.App/Rendering/PortalTunnelPresentation.cs` (`RotationDurationMin`/`Max`, `TickRotation`); `src/AcDream.App/UI/PortalWaitNoticeController.cs` | Deliberate at the time as a "don't flash a scary notice on a fast portal" softening, but it was never recorded as a divergence and AD-2/AP-115 described it as acdream behaviour without stating that retail has no threshold. Adopting retail's unconditional per-segment emit is a one-line arming change; it is not made here because it is a user-visible presentation change outside the defect this commit fixes, and it wants the user's eyes. Filed as issue #329. | Every acdream portal shorter than 5 s shows a silent tunnel where retail shows the notice; every portal longer than 5 s shows it 3.2-4.4 s late (retail's first segment expires at 0.6-1.8 s). #280 makes holds longer, which MASKS this rather than fixing it. | `gmSmartBoxUI::UseTime` 0x004D6E30 (emit at the 0x004D6FCD segment-expiry else-arm); `ECM_UI::SendNotice_DisplayStringInfo` call @0x004D70A1 (-> 0x006925B0); the wait-cue string's `PStringBase` construction is the neighbouring @0x004D7064, `:219516` — **corrected 2026-08-06 at the D-1 fix review; this row originally cited the string constructor as the call site**; wait-cue string VA 0x007BD6A8; `CellManager::CheckPrefetchStatus` 0x00455BE0 (the 5.0 s constant, VA 0x007991B0) | | AP-151 | **Filed 2026-08-06 at the #280 retail-conformance review (finding F3).** The reveal gate is materially STRICTER than retail's prefetch predicate on the mesh-build/GPU-upload axis, over an equally large square. Retail's `LScape::PreFetchCells` @0x00505660 requires, per member, only that the DAT records be resident in memory (`DBObj::PreFetch` -> `IN_MEMORY` or `IN_FILE` -> `DBObj::Get` non-null); no geometry construction, no vertex arrays and no GPU upload are part of the blocking predicate — that work happens lazily at draw. acdream's gate requires, for every member of the derived window (25x25 at the shipped High preset): a worker-thread DAT read, a terrain mesh build, a render-thread `TerrainModernRenderer.AddLandblock` upload, a spatial commit, a physics collision-generation admission, and a spawn-adapter activation, all metered at `MaxCompletionsPerFrame`. The hold is therefore systematically longer than retail's for identical content, and nothing currently bounds it. Note this is the OPPOSITE asymmetry from AP-149, which records where the outer ring is WEAKER than retail; both are live simultaneously, on different axes. | `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Streaming/StreamingWorkBudget.cs` | It is what makes "no visible assembly after reveal" true at all: acdream draws through a bindless/MDI pipeline whose landblock slots must exist before the viewport opens, where retail can begin drawing a landblock the frame its DAT record lands. Weakening the predicate to DAT residency would restore retail's hold duration and reintroduce the visible-assembly artifact #280 exists to remove. AD-2's blanket "async readiness gates replace retail's synchronous destination cell load" pre-dates the window being 625 members wide and does not name this axis. | Portal/recall holds of several seconds where retail (warm cache) is near-instant, on EVERY transit rather than only on cold DAT. No upper bound is enforced and no progress readout is shown (#327). A slow disk or a saturated upload budget lengthens the hold without limit. | `LScape::PreFetchCells` 0x00505660; `DBObj::PreFetch`/`DBObj::Get` call sites @0x0050575C, @0x0050579C; `CellManager::PreFetchCells` 0x00455820 | -| AP-152 | **Filed 2026-08-06 at the AP-22 retirement.** `ShadowShapeBuilder.FromSetup` emits Setup primitives **and** per-part physics-BSP shapes ADDITIVELY. Retail `CPhysicsObj::FindObjCollisions` @0x0050f050 dispatches EXCLUSIVELY on `HAS_PHYSICS_BSP_PS` (0x10000): the BSP walk **or** CylSpheres **or** Spheres **or** nothing, never a union — the BSP branch leaves via an unconditional `jmp` at 0x0050f19d that cannot reach the primitive branches, and a CylSphere-bearing object that survives its loop returns rather than falling through to the Sphere loop. Also internally inconsistent: acdream's two STATIC publication paths already implement the exclusive rule (`LandblockPhysicsPublisher` gates the Setup walk on `entityBspCount == 0`; `LandblockPhysicsContentBuilder` `continue`s after BSP shapes), so the same object registers a different shape set depending on whether it arrived as a landblock static or a server weenie. Deliberately NOT folded into the AP-22 commit: it is a live behavior change on 172 Setups including every BSP door, it needs its own connected visual gate, and bundling it would have destroyed that commit's "nothing changed" evidence. The false retail-anchor comment that was the written justification for the additive design (`ShadowShapeBuilder.cs`, which claimed each part's `find_obj_collisions` tests "CylSpheres + GfxObj BSP") IS corrected in the AP-22 commit, because a future reader would otherwise re-derive this design from it: `CPhysicsPart::find_obj_collisions` @0x0050d8d0's entire body is `if (gfxobj != 0 && gfxobj->physics_bsp != 0) { cache_localspace_sphere; CGfxObj::find_obj_collisions }` — there is no CylSphere test inside a part, and there cannot be, since CylSpheres are a Setup-level array reached via `CPartArray::GetCylsphere` -> `this->setup->cylsphere`. | `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 3, unconditional); consumer `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`. Exclusive counterparts for comparison: `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`, `src/AcDream.Content/LandblockPhysicsContentBuilder.cs`. `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs` (`FromSetup_DoorSetup_ProducesFourShapes`) currently PINS the additive behavior as intended and must be rewritten when this is fixed. | Over-inclusion is the conservative direction (an extra primitive can only add blocking, never remove it); the affected primitives are small and centred at the part origin; the behaviour is pinned by tests and has survived the #150/#175/#182 door work. | 172 of 5,935 installed Setups (2.9%; 73 CylSphere+BSP, 99 Sphere+BSP) register a collision primitive retail never tests — including BSP doors such as the cottage door 0x020019FF, whose ~14 cm base Sphere sits at the threshold. Symptom class: catching or stopping on a doorway sill, or a small non-retail obstacle at a BSP prop's base. | `CPhysicsObj::FindObjCollisions` 0x0050f050 (dispatch `test …,0x10000` 0x0050f165 / `je 0x50f1a2` 0x0050f16f; BSP-branch exit `jmp 0x50f2b0` 0x0050f19d); `CPhysicsPart::find_obj_collisions` 0x0050d8d0 (BSP only); `CPartArray::GetCylsphere` 0x00518090; `CPartArray::CacheHasPhysicsBSP` 0x00518110; `CPhysicsObj::CacheHasPhysicsBSP` 0x0050f570 | +| AP-153 | **Filed 2026-08-06 at the AP-152 retirement — a modelling difference the fix itself introduces.** Retail's shape-dispatch flag is CACHED ONCE. `CPartArray::CacheHasPhysicsBSP` @0x00518110 walks the part array, ORs 0x10000 into `CPartArray::pa_state` on the first part whose `gfxobj->physics_bsp` is non-null, and `CPhysicsObj::CacheHasPhysicsBSP` @0x0050f570 mirrors it onto `CPhysicsObj::state+0xa8`. A full `.text` scan for direct call/jmp to 0x0050f570 finds EXACTLY ONE caller, `CPhysicsObj::InitPartArrayObject+0x7e` @0x0051272e — so after an `AnimPartChanged` part swap retail's DISPATCH flag is stale while its per-part test (`CPhysicsPart::find_obj_collisions` @0x0050d8d0) stays live. acdream's step-0 gate is LIVE in both: it re-derives from the effective part identities on every `FromSetup` call. | `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 0); `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs` (`ReconcileAppearance`) | The two disagree only when a swap adds or removes the LAST physics-BSP part. Humanoid part swaps (clothing / armour) involve no physics-BSP GfxObj on either side, so this is unreachable against ACE today. Deliberately NOT modelled with cached state — that would be inventing staleness to reproduce a retail bug. | If a server ever swapped a prop's part array across the physics-BSP boundary, acdream would switch its collision geometry on the swap where retail would keep dispatching on the construction-time flag: a prop that gained a BSP part would lose its primitive immediately in acdream and only on re-init in retail. | `CPartArray::CacheHasPhysicsBSP` 0x00518110; `CPhysicsObj::CacheHasPhysicsBSP` 0x0050f570; sole caller `CPhysicsObj::InitPartArrayObject+0x7e` 0x0051272e | +| AP-154 | **Filed 2026-08-06 at the AP-152 retirement (contract §11.6) — an undeclared dependency on a specific server implementation.** Retail COMPUTES `HAS_PHYSICS_BSP_PS` itself from its own part array (AP-153's anchors). acdream's query-time guard `Transition.BspOnlyDispatch` reads it out of the SERVER's wire `PhysicsState`: `LiveEntityCollisionBuilder.cs:161` copies `exactRecord.FinalPhysicsState` into `ShadowEntry.State`, and a repo-wide grep for `PhysicsStateFlags.HasPhysicsBsp` in `src/` returns only that predicate and one unrelated mover-state read. acdream never ORs the bit in client-side. It happens to be correct because ACE derives the same DAT bit (`WorldObject_Networking.cs:665-668` from `SetupFlags.HasPhysicsBSP`), overriding the weenie's authored value — which is why a 2018 weenie dump showing `PhysicsState = 0x8` for the cottage door does not contradict our own live capture of `0x10008`. | `src/AcDream.Core/Physics/TransitionTypes.cs:1348` (`BspOnlyDispatch`), call sites `:3911` / `:3954`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:161` | Narrowed, not closed, by the AP-152 fix: the shape list no longer contains a primitive for a BSP-bearing object, so the guard has nothing left to skip and the OUTCOME is now independent of the wire. The guard itself still keys on the wire. Not bundled — changing `registration.State` touches every consumer of `FinalPhysicsState` (Hidden, Missile, ethereal layer 2, the `[setstate]` log) and needs its own gate. | Against a server that does not derive the bit from the DAT, a BSP-bearing object built by a producer other than `FromSetup` would have its primitive tested where retail tests only the BSP. | `CPartArray::CacheHasPhysicsBSP` 0x00518110 (derives) vs `LiveEntityCollisionBuilder.cs:161` (copies); `HAS_PHYSICS_BSP_PS` acclient.h:2833 | +| AP-155 | **Filed 2026-08-06 at the AP-152 retirement; its cell-membership half is CLOSED by that commit, its static half is not.** Two shape-source divergences between the live and static publication paths, neither previously registered. **(a) Static paths emit a Setup Sphere as a height-capped CYLINDER.** `LandblockPhysicsPublisher.cs:1030-1037` and `LandblockPhysicsContentBuilder.cs:683-690` both convert a Setup Sphere to `ShadowCollisionType.Cylinder` with `CylHeight = radius * 2f` and the origin shifted down by one radius; the live path emits a true `ShadowCollisionType.Sphere`, which is produced at exactly ONE site in `src/` (`ShadowShapeBuilder.cs`). Retail tests a Setup Sphere with `CSphere::intersects_sphere` @0x00537a80 / @0x00537fd0 (two overloads) in both cases — 3-D distance, no height clamp. The static paths also derive "has BSP" from `entity.MeshRefs` (the render mesh list) where the live path derives it from `setup.Parts` plus the effective post-`AnimPartChanged` identities; the two sources can disagree. **(b) `ShadowObjectRegistry.BuildFloodSpheres` used retail's SECOND priority first** — it preferred Cylinders over everything whenever any Cylinder existed, where `CPhysicsObj::calc_cross_cells` @0x00515230 tests `HAS_PHYSICS_BSP_PS` at 0x00515285 and routes a BSP-bearing object to `CPhysicsObj::find_bbox_cell_list` @0x00510fc0 (`0x0051528f jne 0x515305`) BEFORE reaching its cylsphere branch (0x005152d1) or its sorting-sphere branch (0x005152fb). **(b) is fixed in this commit**: the method now chooses BSP -> Cylinder -> everything else, matching retail's order. What remains open under (b) is that acdream approximates retail's bounding BOX with the BSP parts' bounding SPHERES, capped at 10. | (a) `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:1030-1037`; `src/AcDream.Content/LandblockPhysicsContentBuilder.cs:683-690`. (b) `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`BuildFloodSpheres`); `tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs` (`BuildFloodSpheres_BspBearingOwner_FloodsFromBspNotFromCylinder`) | (a) affects static props only and changes their collision geometry over a much larger population than AP-152's 172, so it needs its own count and its own gate. (b)'s bounding-sphere approximation is over-inclusive (a sphere contains the box's inscribed extent but is larger in the diagonal), which floods MORE cells rather than fewer — the safe direction for membership. | (a) A static prop whose Setup carries a Sphere blocks over a height-clamped cylinder instead of a true sphere, and rests one radius lower than the authored origin. (b) An object floods more shadow cells than retail, costing broadphase work; the UNDER-inclusive direction, which is the #98 / #168 symptom class, is what the fix removed. | `CSphere::intersects_sphere` 0x00537a80 / 0x00537fd0; `CPhysicsObj::calc_cross_cells` 0x00515230 (0x00515285 test / 0x0051528f jne); `CPhysicsObj::find_bbox_cell_list` 0x00510fc0; `CObjCell::find_cell_list` 0x0052b9f0 (cylsphere) / 0x0052b990 (sorting sphere); `CPartArray::GetSortingSphere` 0x00518b00 | +| ~~AP-152~~ | **RETIRED 2026-08-06 (the commit that filed it is one day old; this retirement corrects four statements in it).** `ShadowShapeBuilder.FromSetup` now DISPATCHES instead of unioning: a step-0 gate derived from the parts suppresses steps 1 and 2 whenever any part's EFFECTIVE GfxObj carries a physics BSP. Retail's priority, re-disassembled from the PDB-paired binary for this commit rather than inherited: `CPhysicsObj::FindObjCollisions` @0x0050f050 tests `HAS_PHYSICS_BSP_PS` FIRST (`0x0050f165 test dword [esi+0xa8],0x10000` / `0x0050f16f je 0x50f1a2`) and leaves the BSP branch through the UNCONDITIONAL `0x0050f19d jmp 0x50f2b0`, which is past the CylSphere loop at 0x50f1a2 AND the Sphere loop at 0x50f21d; a CylSphere-bearing object that survives its loop RETURNS (`0x0050f1d6 jae 0x50f317`); a Setup with zero spheres returns the seeded OK_TS (`0x0050f22f je 0x50f31b`). **BSP wins.** **CORRECTION 1 — the row's risk statement was FALSE as written.** It predicted "catching or stopping on a doorway sill". acdream did not test the extra primitive either: `Transition.BspOnlyDispatch` (`TransitionTypes.cs:1348`, landed 2026-05-25 as A6.P7) already skipped BOTH primitive branches (`:3911`, `:3954`) whenever the target's wire `PhysicsState` carries 0x10000, and ACE sets that bit from `CSetup.HasPhysicsBSP` (`WorldObject_Networking.cs:665-668`). The row's own anchor column cites the flag it failed to notice acdream was already keying on. So this retirement is NOT a collision-response change; the live half was CELL MEMBERSHIP, which had no such guard (see AP-155). **CORRECTION 2 — "the affected primitives are small and centred at the part origin" was FALSE in both halves.** The largest is `0x02001741`'s CylSphere at **r = 6.714 m**; `0x0200086E`'s Sphere is r = 5.842 m with origin (0.759, 0.165, 5.842), nowhere near the part origin. **CORRECTION 3 — the cottage door's "~14 cm base Sphere" was the wrong field.** `0x020019FF`'s Sphere radius is **0.100 m** at origin (0, 0, 0.018); `0.141` is `Setup.Radius`, which AP-22 had just finished proving is never collision geometry. **CORRECTION 4 — the row named ONE pinning test where TWO existed.** `FromSetup_DoorSetup_SphereAtExpectedLocalOffset` also failed under the exclusive rule; both are corrected, neither deleted. Population re-measured independently at 172 of 5,935 (73 CylSphere+BSP, 99 Sphere+BSP; 530 carry a physics-BSP part), agreeing exactly with the filing commit's separate sweep, and now pinned by an installed-DAT test with external bucket controls. | RETIRED — `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 0 gate + `EffectivePartGfxObjId`, shared with step 3 so the two can never read different identities); `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs` (`FromSetup_DoorSetup_EmitsBspPartsOnly`, `FromSetup_DoorSetup_SphereAtExpectedLocalOffset` re-hosted on `_ => false`, `FromSetup_DispatchGateReadsTheEffectivePartIdentities`); `tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs` (`CylSphereAndPhysicsBspPart_EmitsOnlyTheScaledBspShape` — no App fixture combined a primitive with a BSP part before); `tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs` (population). `Transition.BspOnlyDispatch` is deliberately KEPT: retail genuinely dispatches at the query site too, and it guards against a future additive producer. | — | — | `CPhysicsObj::FindObjCollisions` 0x0050f050 (0x0050f165 / 0x0050f16f / 0x0050f19d / 0x0050f1d6 / 0x0050f22f); `CPhysicsObj::calc_cross_cells` 0x00515230 (0x00515285 / 0x0051528f) -> `CPhysicsObj::find_bbox_cell_list` 0x00510fc0; `CPhysicsPart::find_obj_collisions` 0x0050d8d0; `CPartArray::CacheHasPhysicsBSP` 0x00518110; evidence `docs/research/2026-08-06-ap152-contract.md` | | ~~AP-145~~ | **RETIRED 2026-08-05 (C5a commit 1, closing #318; corrected at the architecture-review re-pass, A1/A2).** `RuntimePlacementPresentationSink.TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose(entity, entity.Position, entity.Rotation, record.FullCellId, force: true)` — the SAME publisher ordinary per-tick movement uses — instead of writing `LocalPlayerShadowState.Set` directly. `SyncPose` calls `ShadowPositionSynchronizer.Sync` → `ShadowObjectRegistry.UpdatePosition` (the real `PhysicsEngine.ShadowObjects` publish) BEFORE it records the dedup cache as its own last step, so the cache can no longer be pre-seeded ahead of the real publish. `force: true` because this is the authoritative placement commit, not an ordinary refresh — it must never be skipped by `SyncPose`'s own dedup check. **`TryPublishWithdrawal` carried the exact mirror asymmetry** (a bare `_localPlayerShadow.Clear()` with no `ShadowObjects.Suspend`, leaving a live phantom row at the park's source cell for the whole park window — the #184 shape) and is fixed in the SAME commit, same one-call shape: `_localPlayerShadowSync.Suspend(entity)`. The sink no longer holds a direct `LocalPlayerShadowState` reference at all — both halves route exclusively through the one synchronizer, which owns the cache internally. One synchronizer instance is constructed in `LivePresentationComposition.cs` (before the sink) and threaded through `LivePresentationResult` to `SessionPlayerComposition.cs`, which no longer builds its own. `#318`'s composition test (`RuntimePlacementShadowCompositionTests.cs`, 4 facts) proves: the real `ShadowObjects` registry holds a row at the destination cell (not just the cache) after a bare `Place` with no subsequent tick; the SOURCE cell's row is gone, not duplicated; a subsequent ordinary per-tick `Sync` call is a correct no-op; a `Withdraw` suspends the real registry row (not just the cache) — the source cell carries zero rows and the retained (suspendable) registration survives for a later restore; and a Place for a **registered** non-local-player entity leaves its row at the source cell and does not pollute the player's cache (route 7 P4 — the fix lives entirely inside the pre-existing player-only gate; the first version of this fact registered nothing for the child and was vacuous under the gate's own removal, corrected at the review). Sabotage-verified all four facts, both directions: reverted, each fails at its own discriminating assertion; applied, all green. | `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` (`TryPublishPlace`, `TryPublishWithdrawal`); `src/AcDream.App/Composition/LivePresentationComposition.cs` (`LocalPlayerShadowSynchronizer` construction + `LivePresentationResult` field); `src/AcDream.App/Composition/SessionPlayerComposition.cs` (consumes the shared instance); `tests/AcDream.App.Tests/World/RuntimePlacementShadowCompositionTests.cs` | — | — | No retail analogue — retail has no separate shadow-cache/publish split; this was an acdream-only two-object seam (`LocalPlayerShadowState` cache + `LocalPlayerShadowSynchronizer` publisher) that a direct `.Set()`/`.Clear()` call could desynchronize from | | ~~AP-1~~ | **RETIRED 2026-08-05 (C5a deletion sweep).** "Production zero-delta routes deliberately remain on the legacy resolver until 4B2" is false at HEAD: the exhaustive receiver census over `src/` shows zero `PhysicsEngine.Resolve`/`.ResolvePlacement` call sites, and every production placement writer reaches canonical `PhysicsEngine.SetPosition` only through `RuntimeSetPositionState` (three call sites total). C5a deleted `Resolve`, `ResolvePlacement`, and their `HasCellSurface` helper outright — the resolver-shaped entry points this row described no longer exist, so the condition is retired structurally, not just narrowed. The narrower survivors (#276 settle-cell discard, AD-61 force-seed, AD-62 non-commit outcomes) are separately filed rows and are unaffected. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (deletion); `docs/research/2026-08-05-c5a-contract.md` | — | — | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `CPhysicsObj::handle_all_collisions` 0x00514780; `track_object_collision` 0x00513F10; `report_collision_end` 0x00514620; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 | diff --git a/docs/research/2026-08-06-ap152-contract.md b/docs/research/2026-08-06-ap152-contract.md new file mode 100644 index 00000000..01e3dae7 --- /dev/null +++ b/docs/research/2026-08-06-ap152-contract.md @@ -0,0 +1,1057 @@ +# AP-152 contract — additive vs exclusive collision-shape emission + +**Status:** authored 2026-08-06, planning only. No production or test code +written; no commit made. The only file this session wrote inside the repo is +this one. +**Worktree:** `.claude/worktrees/resume-session-e0bd03e1-d5bf45`, +branch `claude/resume-session-e0bd03e1-d5bf45`, base HEAD `0d62a5ff` +(identical to `main`). +**Register row:** AP-152, `docs/architecture/retail-divergence-register.md:183`. +**Predecessor:** `docs/research/2026-08-06-ap22-contract.md` (§11.3 is where +this row was spun off). + +--- + +## 1. Verdict + +Retail's exclusivity is real and I re-verified every instruction of it +independently (§2). acdream's live shape list really is additive (§3). +**The 172-Setup figure is exactly right** — re-derived here from three +independent decoders (§4). + +**But the register row's stated risk does not currently occur, and the reason +is a mechanism the row does not mention at all.** + +acdream already implements retail's exclusive dispatch — at **collision-query +time**, not at build time. `Transition.BspOnlyDispatch(obj.State)` +(`TransitionTypes.cs:1348`, landed 2026-05-25 as "A6.P7") skips **both** the +Cylinder branch (`:3954`) and the Sphere branch (`:3911`) whenever the target +entity's `PhysicsState` carries `HAS_PHYSICS_BSP_PS` (0x10000). And ACE sets +that bit on every affected object: `WorldObject_Networking.cs:665-668` derives +it from `CSetup.HasPhysicsBSP`, the DAT-authored `SetupFlags.HasPhysicsBSP` +bit — which my sweep proves agrees with the per-part derived predicate on +**all 5,935 installed Setups, zero disagreements** (§4.2). + +So on the live path, against ACE, **the extra primitive is never tested**. +"Catching or stopping on a doorway sill" is not a symptom that can be +occurring. The row's risk statement describes a defect that the A6.P7 guard +already closed fourteen months of commits ago. + +What the additive list **does** still change is **cell membership**. The shape +list is the input to `ShadowObjectRegistry.BuildFloodSpheres` +(`ShadowObjectRegistry.cs:606`), which has *no* `BspOnlyDispatch` guard and +which **prefers Cylinders over everything else whenever any Cylinder is +present**. Retail's `CPhysicsObj::calc_cross_cells` @`0x00515230` dispatches on +the *same* `HAS_PHYSICS_BSP_PS` flag *first* and routes a BSP-bearing object to +`CPhysicsObj::find_bbox_cell_list` @`0x00510fc0` — never to the cylspheres +(§2.3, byte-verified). So for the **73 CylSphere+BSP** Setups acdream floods +shadow cells from the wrong geometry today, and the fix corrects that as a side +effect. This — not door blocking — is the real behavioural surface, and it is +the `#98`/`#168` symptom class (an object present in the wrong set of shadow +cells). + +**The fix is still worth doing and is still small.** It removes a false shape +list, it makes the live path agree with the two static paths, it corrects the +flood source for 73 Setups, and it makes the behaviour independent of what ACE +chooses to put in `PhysicsState` — which is the deeper divergence (§11.6) and +which acdream is currently relying on without saying so anywhere. + +Eleven claims found false or stale at HEAD are in §11. Four of them are in the +AP-152 row itself. + +--- + +## 2. What retail does — re-verified from the binary + +Binary: `C:\Users\erikn\Downloads\acclient.exe`, v11.4186, linker UTC +2013-09-06T00:17:56, CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`. +`py tools/pdb-extract/check_exe_pdb.py` → +`=== MATCH: this exe pairs with our acclient.pdb ===`. Image base `0x00400000`. + +Every address below was disassembled from that binary this session with a +from-scratch capstone script, **and** resolved back to a name through +`docs/research/named-retail/symbols.json` by exact address. The name/address +pairs are not inherited from AP-22: + +| Address | PDB name (exact hit) | +|---|---| +| `0x0050f050` | `CPhysicsObj::FindObjCollisions` | +| `0x00518180` | `CPartArray::FindObjCollisions` | +| `0x0050d8d0` | `CPhysicsPart::find_obj_collisions` | +| `0x00518060` / `0x00518070` | `CPartArray::GetNumSphere` / `GetSphere` | +| `0x00518080` / `0x00518090` | `CPartArray::GetNumCylsphere` / `GetCylsphere` | +| `0x005180a0` / `0x005180b0` | `CPartArray::GetRadius` / `GetHeight` | +| `0x0050f570` | `CPhysicsObj::CacheHasPhysicsBSP` | +| `0x00518110` | `CPartArray::CacheHasPhysicsBSP` | +| `0x00515230` | `CPhysicsObj::calc_cross_cells` | +| `0x00510fc0` | `CPhysicsObj::find_bbox_cell_list` | +| `0x0052b9f0` / `0x0052b990` | `CObjCell::find_cell_list` | +| `0x00518b00` | `CPartArray::GetSortingSphere` | +| `0x0050ceb0` | `OBJECTINFO::missile_ignore` | + +`HAS_PHYSICS_BSP_PS = 0x10000` is `acclient.h:2833`, in `enum PhysicsState`. + +### 2.1 The collision dispatch is a four-way exclusive choice + +`CPhysicsObj::FindObjCollisions` @ `0x0050f050`. `edi` is the result, seeded +`OK_TS` at `0x0050f13b mov edi, 1`. + +``` +0050f165 f786a800000000000100 test dword ptr [esi + 0xa8], 0x10000 ; this->state & HAS_PHYSICS_BSP_PS +0050f16f 7431 je 0x50f1a2 ; clear -> primitive dispatch +0050f171 85ed test ebp, ebp +0050f173 752d jne 0x50f1a2 ; pass-through predicate -> primitive dispatch +0050f178 e833ddffff call 0x50ceb0 ; OBJECTINFO::missile_ignore +0050f17f 7521 jne 0x50f1a2 +0050f181 8b4e10 mov ecx, dword ptr [esi + 0x10] ; this->part_array +0050f186 0f848f010000 je 0x50f31b ; null -> epilogue, return OK_TS +0050f18d e8ee8f0000 call 0x518180 ; CPartArray::FindObjCollisions (per-part BSP walk) +0050f194 83ff01 cmp edi, 1 +0050f197 0f847e010000 je 0x50f31b ; OK -> return +0050f19d e90e010000 jmp 0x50f2b0 ; UNCONDITIONAL — past BOTH primitive loops +``` + +`0x0050f19d` is an unconditional `jmp` to `0x50f2b0`. The CylSphere loop begins +at `0x50f1a2` and the Sphere loop at `0x50f21d`; both are below the target. +**The BSP branch cannot reach either primitive branch — not "prefers", cannot.** + +CylSphere branch, `0x50f1a2`: + +``` +0050f1a7 je 0x50f21d ; null part array -> Sphere path +0050f1a9 call 0x518080 ; GetNumCylsphere +0050f1b0 je 0x50f21d ; zero cylspheres -> Sphere path +0050f1c9 je 0x50f317 ; loop guard -> 0x50f31b RETURN +0050f1d6 jae 0x50f317 ; loop exhausted -> 0x50f31b RETURN +``` + +A CylSphere-bearing object that survives its loop **returns**; it never falls +into the Sphere loop. Sphere branch, `0x50f21d`: + +``` +0050f222 je 0x50f31b ; null part array -> RETURN OK_TS +0050f228 call 0x518060 ; GetNumSphere +0050f22f je 0x50f31b ; zero spheres -> RETURN OK_TS +``` + +**Priority order, decided top-down: BSP → CylSphere → Sphere → nothing.** +When a Setup carries both a primitive and a physics-BSP part, **the BSP wins**. +That is the answer to "which shape wins", and it is decided by a single +`test`/`je` pair at the top of the function, before any primitive is read. + +`ebp` is a whole-object pass-through predicate computed at `0x0050f0cf-0x0050f134` +(weenie present, two virtual calls, `[transition->object_info.state]` bits `0x100`, +`0x80`, `0x800`, `0x10`). It is tested identically in all three branches — set, +the object collides with nothing at all. It does not change which shape wins. + +### 2.2 A part has no primitive of its own — re-confirmed + +`CPhysicsPart::find_obj_collisions` @ `0x0050d8d0`, whole body: + +``` +0050d8d3 mov ecx, [esi+0x20] ; this->gfxobj +0050d8d6 mov ecx, [ecx] +0050d8da mov eax, 1 ; OK_TS +0050d8df je 0x50d90d ; null gfxobj -> return OK +0050d8e1 mov edx, [ecx+0x78] ; gfxobj->physics_bsp +0050d8e6 je 0x50d90d ; null bsp -> return OK +0050d8f8 call 0x50c9d0 ; cache localspace sphere +0050d907 call 0x534700 ; CGfxObj::find_obj_collisions +``` + +No CylSphere test, no Sphere test. And there cannot be one: the four +accessors are one-liners that dereference `CPartArray::setup` at `+0x54`: + +``` +00518060 mov eax,[ecx+0x54] ; mov eax,[eax+0x50] ; setup->num_sphere +00518070 mov eax,[ecx+0x54] ; mov eax,[eax+0x54] ; setup->sphere +00518080 mov eax,[ecx+0x54] ; mov eax,[eax+0x48] ; setup->num_cylsphere +00518090 mov eax,[ecx+0x54] ; mov eax,[eax+0x4c] ; setup->cylsphere +``` + +Those offsets reconcile exactly with `acclient.h`'s `CSetup` +(`num_cylsphere` 0x48, `cylsphere` 0x4c, `num_sphere` 0x50, `sphere` 0x54, +`height` 0x60, `radius` 0x64 — and `GetHeight`/`GetRadius` read `[eax+0x60]` / +`[eax+0x64]`). **CylSpheres and Spheres are Setup-level arrays. Parts have +none.** `CPartArray::FindObjCollisions` @`0x518180` is a bare +`for i in 0..num_parts: if (parts[i]) CPhysicsPart::find_obj_collisions(...)` +loop with an early exit on `!= 1`. + +### 2.3 Cell membership dispatches on the SAME flag, and also exclusively + +This is the part the register row does not have, and it is where the fix +actually bites. `CPhysicsObj::calc_cross_cells` @ `0x00515230`: + +``` +00515285 f786a800000000000100 test dword ptr [esi+0xa8], 0x10000 +0051528f 7574 jne 0x515305 ; BSP-bearing +00515291 8b4e10 mov ecx,[esi+0x10] +00515296 7444 je 0x5152dc ; null part array -> sorting sphere +00515298 e8e32d0000 call 0x518080 ; GetNumCylsphere +0051529f 743b je 0x5152dc ; zero -> sorting sphere +005152d1 e81a670100 call 0x52b9f0 ; CObjCell::find_cell_list (cylsphere array) +005152da eb35 jmp 0x515311 +005152dc ... call 0x518b00 ; CPartArray::GetSortingSphere +005152fb e890660100 call 0x52b990 ; CObjCell::find_cell_list (sorting sphere) +00515305 8bce / e8afbcffff call 0x510fc0 ; CPhysicsObj::find_bbox_cell_list +``` + +**Retail's flood priority is BSP-bbox → cylspheres → sorting sphere.** A door +with both a CylSphere and a physics BSP floods from the BSP bounding box; its +CylSphere is never consulted for membership either. + +acdream's `BuildFloodSpheres` (`ShadowObjectRegistry.cs:606-635`) does the +opposite: `if (anyCyl) use only the Cylinders`, else use every shape's centre +and radius, capped at 10. So today, for the 73 CylSphere+BSP Setups, acdream +floods from the cylinders where retail floods from the BSP bbox. Removing the +cylinders from the shape list flips that to "all BSP shapes' bounding spheres" +— still an approximation of a bbox, but the right geometry. + +### 2.4 The dispatch flag is CLIENT-derived, and cached exactly once + +`CPartArray::CacheHasPhysicsBSP` @ `0x00518110` walks `num_parts` /`parts`, +reads `part->gfxobj[0]->physics_bsp` (`[esi+0x78]`), and on the first non-null +does `or [ecx], 0x10000` on `CPartArray::pa_state` (offset 0, per `acclient.h`), +returning 1; otherwise `and [ecx], 0xfffeffff`, returning 0. +`CPhysicsObj::CacheHasPhysicsBSP` @ `0x0050f570` mirrors the result onto +`CPhysicsObj::state` at `+0xa8`. + +**A full `.text` scan for direct `call`/`jmp` to `0x0050f570` finds exactly one +caller: `CPhysicsObj::InitPartArrayObject+0x7e` (`0x0051272e`).** The flag is +computed once at part-array construction and never recomputed — notably not +after a part swap. The per-part guard in §2.2 stays live, so retail's +*per-part* test tracks a swapped GfxObj while its *dispatch flag* does not. +See trap 7. + +--- + +## 3. The divergence at HEAD, by symbol + +All line numbers read at `0d62a5ff` this session, not inherited. + +### 3.1 The live path — additive + +`src/AcDream.Core/Physics/ShadowShapeBuilder.cs`, `FromSetup`: + +| Step | Lines | Gate | +|---|---|---| +| 1 — CylSpheres → `Cylinder` shapes | `:85-97` | `cyl.Radius > 0f` only | +| 2 — Spheres → `Sphere` shapes | `:103-117` | `setup.CylSpheres.Count == 0` | +| 3 — per-part physics BSP → `BSP` shapes | `:123-155` | `hasPhysicsBsp(effectiveId)` per part, **unconditional w.r.t. steps 1/2** | + +Step 2's gate is correct (it mirrors `0x50f1b0`). Step 3 has no gate against +steps 1/2, and steps 1/2 have no gate against step 3. That is the whole +divergence. + +`FromSetup` has exactly **one** production caller: +`src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:126`. Its `Build` +substitutes the real BSP bounding radius (`:135-139`) and returns null on an +empty list (`:146`). Two entry points reach it: +`DatLiveEntityProjectionMaterializer.cs:832` (spawn) and +`LiveEntityAppearanceBinding.cs:118` → `ReconcileAppearance` (ObjDesc swap). + +The class doc at `ShadowShapeBuilder.cs:38-45` **already carries a +`KNOWN DIVERGENCE (AP-152)` paragraph** — the AP-22 commit added it while +correcting the false retail anchor. Nothing in the class doc needs re-deriving; +it needs deleting when the divergence goes. + +### 3.2 The static paths — exclusive, but not by the same mechanism + +| Path | File | Exclusivity gate | BSP source | +|---|---|---|---| +| streaming statics (graphical) | `LandblockPhysicsPublisher.cs` | `if (setup is not null && entityBspCount == 0)` at `:984` | `FromLandblockBspParts(entity.MeshRefs, …)` at `:951` | +| prepared-content statics (headless) | `LandblockPhysicsContentBuilder.cs` | `bspOwners++; continue;` at `:631-632` | `FromLandblockBspParts(entity.MeshRefs, …)` at `:612` | + +Both are genuinely exclusive, so the row's claim holds. But the row's framing +("the same object registers a different shape set depending on how it +arrived") stays true **even after this fix**, for two reasons it does not +mention: + +1. The static paths derive "has BSP" from **`entity.MeshRefs`**, the render + mesh pipeline's per-part list; the live path derives it from + **`setup.Parts` + the effective post-`AnimPartChanged` identities**. Those + are different sources and can disagree. +2. The static paths emit a Setup **Sphere as a `Cylinder`** + (`LandblockPhysicsPublisher.cs:1030-1037`, + `LandblockPhysicsContentBuilder.cs:683-690`: radius = sphere radius, + `CylHeight = radius * 2f`, origin shifted down by one radius). The live path + emits a true `ShadowCollisionType.Sphere`. `ShadowCollisionType.Sphere` is + produced at exactly one site in `src/` — `ShadowShapeBuilder.cs:113`. + Retail tests a Setup Sphere with `CSphere::intersects_sphere` @`0x537fd0` in + both cases. This is an unregistered divergence; see §12.3. + +### 3.3 The query-time gate that makes the divergence inert today + +`src/AcDream.Core/Physics/TransitionTypes.cs`: + +``` +:1348 public static bool BspOnlyDispatch(uint entityState) +:1349 => (entityState & (uint)PhysicsStateFlags.HasPhysicsBsp) != 0; +:3911 Sphere branch: if (BspOnlyDispatch(obj.State)) { …[sph-skip-bsp]…; continue; } +:3954 Cylinder branch: if (BspOnlyDispatch(obj.State)) { …[cyl-skip-bsp]…; continue; } +``` + +`obj.State` is `ShadowEntry.State`, written by +`ShadowObjectRegistry.RegisterMultiPart(…, state, …)` (`:486`), which for the +live path is `(uint)exactRecord.FinalPhysicsState` +(`LiveEntityCollisionBuilder.cs:161`) — the canonical **wire** PhysicsState +from `CreateObject`/`SetState`. **acdream never ORs `HasPhysicsBsp` in +client-side**: a repo-wide grep for `PhysicsStateFlags.HasPhysicsBsp` in `src/` +returns exactly two hits, `TransitionTypes.cs:1349` (this predicate) and +`PhysicsEngine.cs:1613` (the **mover's** state selecting a shadow-commit +action — unrelated). + +ACE supplies the bit: `references/ACE/Source/ACE.Server/WorldObjects/ +WorldObject_Networking.cs:665-668` — + +```csharp +////HasPhysicsBSP = 0x00010000, +if (CSetup.HasPhysicsBSP) + physicsState |= PhysicsState.HasPhysicsBSP; +else + physicsState &= ~PhysicsState.HasPhysicsBSP; +``` + +`CSetup.HasPhysicsBSP` is `SetupFlags.HasPhysicsBSP` (0x8) straight from the +DAT (`ACE.DatLoader/FileTypes/SetupModel.cs:53`). It **overrides** the weenie's +authored `PhysicsState`, which is why the 2018 weenie dump showing +`PhysicsState = 0x8` for the cottage door (wcid 412) does not contradict our +own live capture of `0x10008` +(`DoorBugTrajectoryReplayTests.cs:997`, `DoorClosedState`). + +And §4.2 shows `SetupFlags.HasPhysicsBSP` agrees with the derived per-part +predicate on 5,935 / 5,935 Setups. **Therefore every one of the 172 affected +Setups arrives from ACE with 0x10000 set, and its extra primitive is skipped at +query time.** Landblock statics register `state: 0u`, so the guard never fires +for them — but their build paths are already exclusive, so there is nothing to +skip. + +`BuildFloodSpheres` has no such guard. That is the gap. + +--- + +## 4. The 172 figure — independently re-derived + +### 4.1 Method + +A throwaway .NET 10 console project written this session **outside the repo** +(`/Ap152Sweep/`), referencing `Chorizite.DatReaderWriter 2.1.7` — +the same package `AcDream.Core` consumes — and enumerating the user's installed +`%USERPROFILE%\Documents\Asheron's Call\client_portal.dat` via +`DatCollection.GetAllIdsOfType()` / `()`. It reproduces +`ShadowShapeBuilder.FromSetup`'s three steps literally (including the +`Radius > 0f` filters and step 2's `CylSpheres.Count == 0` gate). Built clean +from an empty `obj/`; no repo assembly is involved, so no stale-DLL risk. + +Not a raw byte parser: I deliberately used the production decoder rather than +re-deriving AP-22's hand-rolled layout, so this is a genuinely different +instrument from the four that produced AP-22's number. + +### 4.2 Three decoders, one answer + +| Predicate for "this part has a physics BSP" | Affected Setups | +|---|---| +| acdream production (`Flags.HasPhysics && PhysicsBSP.Root != null && VertexArray != null`, per `FlatCollisionAssetBuilder.cs:377-380`) | **172** | +| retail (`gfxobj->physics_bsp != 0`, i.e. `PhysicsBSP.Root != null`) | **172** | +| DAT-authored `SetupFlags.HasPhysicsBSP` (0x8) at Setup level | **530 Setups carry it; 0 disagreements with the derived predicate across all 5,935** | + +GfxObj side: 15,318 GfxObjs; 1,258 carry `Flags.HasPhysics`; of those, **0** +have a null `PhysicsBSP.Root` and **0** have a null `VertexArray` — which is +why acdream's stricter predicate and retail's coincide exactly. + +`SetupFlags.HasPhysicsBSP = 0x8` was confirmed against the binary, not taken +from the enum name: `CSetup::UnPack` @`0x00520c50` reads the flags dword and +does `movzx edx, bl; shr edx, 3; and edx, 1; mov [edi+0x28], edx`, where `edi` +is the `PackObj` sub-object at `CSetup+0x30`, so `[edi+0x28]` is `CSetup+0x58` += `has_physics_bsp` in `acclient.h`. (`[edi+0x2c]` = `+0x5c` = +`allow_free_heading` = bit 2.) + +### 4.3 Full distribution (5,935 Setups) + +| Bucket | Count | +|---|---| +| step 1/2 emits ≥1 primitive | 4,283 | +| step 3 emits ≥1 BSP part shape | 530 | +| **both — AFFECTED** | **172** (73 CylSphere+BSP, 99 Sphere+BSP) | +| BSP only | 358 | +| primitive only | 4,111 | +| neither (no registration at all) | 1,294 | +| ≥1 CylSphere (any radius) | 678 — none with all radii ≤ 0 | +| 0 CylSpheres, ≥1 Sphere | 3,605 — none with all radii ≤ 0 | + +These reconcile with the corrected AP-22 record: 1,294 + 358 = **1,652 +no-primitive Setups**, the figure the AP-22 architecture review substituted for +the row's original 1,294. + +Spot-check against production tooling: `dotnet run --project tools/SetupInspect +-- 0x020019FF` reports 1 Sphere `radius=0.1`, origin `(0,0,0.018)`, 3 parts, +part[0] `0x010044B5` `flags=0x0000000B physicsBsp=present`, parts[1..2] +`0x010044B6` `physicsBsp=none`. The sweep says the same: `sph=1 bsp=1 parts=3 +primRadius 0.1000`. + +### 4.4 What the 172 actually are + +Cross-referenced against `references/weenies(single file).json` (the 2018 ACE +weenie dump) by `didStats key 1 = Setup`: + +- **98 of 172** are referenced by ≥1 ACE weenie. This is a **lower bound**, not + a partition — Setup `0x020019FF` is referenced by **zero** weenies in that + dump, yet our own live capture at `DoorBugTrajectoryReplayTests.cs:997` + records `live: spawn … name=Door setup=0x020019FF`. Treat the 2018 dump as a + characteriser, never as a reachability proof. +- **22 Setups are used by 151 `Door` weenies**, including `0x0200027C` + ("Witshire's Cottage Door", 21 weenies), the sliding-door family + `0x02000310`–`0x02000313`, `0x020005DA` / `0x020005F1` / `0x020005F2`, + `0x020009A9` ("Lyceum Gates", "Vault Door"), `0x020011C5` ("Armory Door"), + `0x020010A8` ("Watcher's Wall"). Their primitive radii run 0.10 m – 1.31 m, + median 0.40 m. +- **38 Setups are used by `Creature`-typed weenies — and every one is a + stationary prop**: Garbage Barrel, Magically Sealed Dais, Menhir, Sarcophagus, + Mosswart Enchantment Idol, Abyssal Totem, Colosseum Arena, Boulder, Altar of + the Black Crystal, Ancient Throne, Wall of Ice, Security Station, Rynthid + Assessment Crystal, and a large set of Doors/Walls/Barriers. **No mobile + monster and no humanoid is in the affected set.** The human Setup + `0x02000001` is not affected — all 34 parts are `flags=0x0A`, no physics BSP — + so player/creature body collision is untouched. The Facility Hub door + `0x02000C9D` is also not affected (BSP parts, no primitives). +- Remaining types: Generic 30 Setups, Hooker 7, Container 5, Chest 4, Switch 3, + Portal 3, PressurePlate 2, HotSpot 2, Book 2, and one each of Gem, CraftTool, + Caster, PKModifier, LScoreKeeper, GScoreGatherer. +- Largest primitives: `0x02001741` CylSphere **r = 6.714 m** (h 1.476 m, one + BSP part — a 10 × 10 m flat plate); `0x0200086E` Sphere **r = 5.842 m** at + origin `(0.759, 0.165, 5.842)`, 7 parts. Neither appears in the weenie dump. + Among weenie-referenced Setups the max is 2.000 m (`0x02001761`, "Boulder"). +- Total shapes per affected Setup never exceeds 10, so the fix cannot interact + with `BuildFloodSpheres`'s `RetailSphereCap = 10`. + +--- + +## 5. The exact change, by symbol + +### 5.1 The one production edit + +`src/AcDream.Core/Physics/ShadowShapeBuilder.cs`, `FromSetup`. + +Insert a pre-pass before step 1 that answers "will step 3 emit anything?", and +skip steps 1 and 2 when it will. Shape: + +``` +bool anyPhysicsBspPart = false; +for (int i = 0; i < setup.Parts.Count; i++) +{ + uint gfxId = ; + if (hasPhysicsBsp(gfxId)) { anyPhysicsBspPart = true; break; } +} +if (!anyPhysicsBspPart) { ; ; } + +``` + +Three properties this shape has that alternatives do not: + +- **BSP wins**, matching `0x0050f165`/`0x0050f16f` (§2.1). Getting this + backwards would delete a door's slab collision and leave a 10 cm sphere. +- **Emission order is preserved** (primitives, then BSP). Reordering to + "return early with the BSP list" would change `shapes[0]`/`shapes[1]` + indices that `Issue175HubDoorPoseInspectionTests` relies on, and would change + the order `RegisterMultiPart` writes `ShadowEntry` rows into cells. +- **The pre-pass uses the identical effective-id expression as step 3.** If it + used `setup.Parts[i]` while step 3 uses `effectivePartGfxObjIds[i]`, an + ObjDesc swap could make the gate and the emission disagree — the gate would + suppress the primitives while step 3 emitted nothing, producing a shapeless + registration and `Build` returning null. That is trap 1. +- Zero allocation; `FromSetup` is a build-time path, not a per-resolve one, but + the pre-pass adds nothing either way. + +No signature change. No caller change. `LiveEntityCollisionBuilder.Build`'s +`shapes.Count == 0 && !retainEmptyPayload → return null` at `:146` already +handles every new case. + +### 5.2 Comment / doc edits in the same commit + +- `ShadowShapeBuilder.cs:38-45` — delete the `KNOWN DIVERGENCE (AP-152)` + paragraph; replace with one sentence recording that the emission is exclusive + and why (BSP first, `0x0050f165` / `0x0050f16f` / `0x0050f19d`). +- `ShadowShapeBuilder.cs:17-21` — the summary still says "Walks (1) … (2) … + and (3) …", which reads as a union. Rewrite as the dispatch. +- `ShadowShapeBuilder.cs:99-100` and `LandblockPhysicsContentBuilder.cs:695` + both point at "`GameWindow.cs:6034`" for the landblock-static convention. + `GameWindow.cs` is 1,622 lines. Dead citation; drop or repoint. +- `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs:51-55` + carries a second false retail anchor: *"the outer loop in + `CPartArray::FindObjCollisions` iterates all parts regardless of + CylSpheres/Spheres. `ShadowShapeBuilder.FromSetup` mirrors this by emitting + one BSP shape per part"*. The first clause is true; the second uses it to + justify the union. Correct it, for the same reason AP-22 corrected the one in + `ShadowShapeBuilder.cs`: a future reader re-derives the additive design from + it otherwise. + +### 5.3 Do NOT touch + +- `Transition.BspOnlyDispatch` and its two call sites. After the fix it is + redundant for live entities and inert for statics (`State == 0`), but retail + genuinely dispatches at query time (§2.1) and the guard is the faithful + modelling of that. Deleting it would also silently re-open the divergence for + any future producer that builds an additive list. Leave it; note in the + commit message that it is now belt-and-braces. +- `BuildFloodSpheres` (`ShadowObjectRegistry.cs:606`). Its cylinder-preference + is a *separate* divergence from `calc_cross_cells` (§2.3, §12.4). This fix + changes its **input**, deliberately; changing its **logic** in the same commit + would make the flood delta impossible to attribute. +- Both static publishers. They are already exclusive. If a static test turns + red, the diff is wrong — do not adjust the test. +- `LiveEntityMotionRuntimeController.GetSetupCylinder` and every other + `Setup.Radius`/`Height` consumer. AP-22 settled those. + +--- + +## 6. Blast radius across both hosts + +``` +AcDream.App -> AcDream.Runtime, AcDream.Content, AcDream.Core +AcDream.Headless -> AcDream.Runtime, AcDream.Content (never AcDream.App) +``` + +| Path | Producer | Graphical | Headless | +|---|---|---|---| +| live server weenies | `LiveEntityCollisionBuilder.Build` → `ShadowShapeBuilder.FromSetup` | yes | **no — see below** | +| landblock statics, streaming | `LandblockPhysicsPublisher.PublishStaticEntity` | yes | no | +| landblock statics, prepared content | `LandblockPhysicsContentBuilder.PublishStaticCollision` | no | yes | + +**#330 is not merely adjacent; it bounds this fix's reach.** Issue #330 +(`docs/ISSUES.md:172`) states the headless host registers no live-entity +collision at all. I re-verified its two premises at `0d62a5ff` rather than +inheriting them: + +- `ShadowShapeBuilder.FromSetup` has exactly one production caller and it is in + `AcDream.App` (`LiveEntityCollisionBuilder.cs:126`); a repo-wide grep returns + no other `src/` hit. +- The `LiveEntityCollisionBuilder` type is referenced only from `AcDream.App` + (`Composition/ContentEffectsAudioComposition.cs`, + `Rendering/DatLiveEntityProjectionMaterializer.cs`, + `Rendering/LiveEntityAppearanceBinding.cs`, `Rendering/GameWindow.cs`, + `Physics/LiveEntityPvpBitfieldSync.cs`) plus two doc-comment mentions in + `AcDream.Runtime` and `AcDream.Content`. + +**Consequence for this fix: it is graphical-only.** Unlike AP-22 — which had +three copies, one of them headless-only — AP-152 has exactly one production +site and headless cannot execute it. The C5b lesson still applies in the +opposite direction: run the headless suite and a headless connected route to +prove the change did **not** reach it, rather than assuming it didn't. + +Do not "fix" #330 here. Do not let this fix's scope creep into giving Runtime +ownership of live shape construction. + +`RuntimeRemotePhysicsUpdater` re-publishes an already-built shape list at a +resolved pose; it constructs no shapes and is unaffected. + +--- + +## 7. Proof obligations + +| # | Obligation | Evidence | +|---|---|---| +| P1 | Retail's collision dispatch is exclusive and BSP-first | §2.1 disassembly reproduced in the commit message: `0x0050f165` / `0x0050f16f` / `0x0050f19d` / `0x0050f1d6` / `0x0050f22f` | +| P2 | The affected population is 172 Setups | §7.1's installed-DAT test, run, with its bucket controls | +| P3 | The extra primitive was already inert for collision, via `BspOnlyDispatch` + ACE's `CSetup.HasPhysicsBSP` | §3.3, plus the §8.2 connected diff showing zero collision-response change | +| P4 | Cell membership changed only for affected owners, and only in the retail-correct direction | §8.2's flood-set diff, keyed by owner | +| P5 | The gate is computed from the same effective identities step 3 uses | §7.3's fact, sabotage-verified | +| P6 | The change did not reach headless | `AcDream.Headless.Tests` green **and** a headless connected route with identical static publication counts (§8.3) | +| P7 | `FromSetup_DoorSetup_ProducesFourShapes` is corrected, not deleted, and the second test that pins the union is found too | §7.2 | +| P8 | AP-152 is retired with the row's four false/stale claims explicitly corrected | register diff in the same commit; §11.1–11.4 | + +--- + +## 8. Gates + +### 8.1 Suites + +Clean build first — three stale-DLL incidents this session, one under +`-t:Rebuild`. Delete `bin/` and `obj/` for the touched projects, then +`dotnet build -c Release`. + +`AcDream.Core.Tests` (owns `ShadowShapeBuilder`), `AcDream.App.Tests`, +`AcDream.Content.Tests`, `AcDream.Runtime.Tests`, **`AcDream.Headless.Tests`**, +then the complete Release solution suite. Baseline recorded at the C5c closeout +(`1304dafa`) is **11,196 passed / 4 skipped / 0 failed**; re-measure at +`0d62a5ff` before the change so the delta is attributable, and expect it to +move only by the facts §7 adds. + +### 8.2 Connected graphical route — positive evidence + +Absence of a crash proves nothing here; the whole point of §3.3 is that the +obvious symptom was already suppressed. The criterion is a **keyed diff**, run +twice on the same binary, once at `0d62a5ff` and once with the change, over the +canonical nine-stop route (`tools/connected-world-lifecycle.route.txt` or +`tools/connected-dense-town.route.txt`) with `ACDREAM_PROBE_BUILDING=1`: + +1. **Shape inventory must change only for affected owners.** The + `[entity-source]` line (`LiveEntityCollisionBuilder.cs:200`) prints + `shapes=cyl{n}+bsp{m}` per registration. Every `(entityId, src, cyl, bsp)` + tuple whose `src` is **not** in the 172 must be byte-identical between runs; + every tuple whose `src` **is** in the 172 must go from `cyl>0 && bsp>0` to + `cyl0+bsp{m}` with the same `m`. Note the probe labels a `Sphere` shape as + `bsp` (its `else` branch at `:197-198` counts everything non-Cylinder as + BSP) — so for the 99 Sphere+BSP Setups the counter will show `cyl0+bspN` + before **and** `cyl0+bsp(N-1)` after. Verify the count drop, not the label. +2. **`[cyl-skip-bsp]` / `[sph-skip-bsp]` must go to zero.** Before the change + these fire for exactly the affected live entities and are the direct + observation that the query-time gate was carrying the divergence. After, no + such shape exists to skip. A non-zero count after the fix means an affected + Setup slipped the gate — investigate, do not adjust. +3. **Flood-set diff, the load-bearing one.** For each affected owner, the set + of shadow cells `RegisterMultiPart` produces (`_entityToCells`) will change + for the 73 CylSphere+BSP Setups (flood source flips from cylinders to BSP + bounding spheres) and may shrink by one contributing sphere for the 99 + Sphere+BSP ones. This is the only real behavioural delta and it must be + *measured*, not assumed. The registry has no per-owner cell probe today; + the cheapest honest instrument is a temporary probe line at + `ShadowObjectRegistry.cs:498` printing `owner, cellSet.Count, sorted cellIds` + under `ProbeBuildingEnabled`, run both routes, diff, then **delete the probe + before committing**. Acceptance: no owner outside the 172 changes its cell + set; every affected owner's new set is a superset-or-subset explainable by + the geometry swap; no affected owner ends with an **empty** set (that would + silently drop its collision — `RegisterMultiPart` returns early at `:465`). +4. **Collision response unchanged.** Walk each of the affected door families + reachable on the route, from at least two approach headings and two source + cells. Blocking distance and slide direction must be indistinguishable — + which §3.3 predicts, because the primitive was already skipped. + +### 8.3 Connected headless route + +Native Linux or WSL single-session run, four-stop portal route (the K1/K3 +gate). Positive criterion: per-landblock `bspOwners`/`setupOwners`/ +`noCollision` counts from `PublishStaticCollision` **identical** to a +pre-change run, graceful ACE-confirmed logout, terminal ownership ledger at +zero. This is a *negative-reach* proof (§6) and it needs the counts, not just a +clean exit. + +### 8.4 User visual gate — narrow, and not about doorway feel + +Worth eyes, but a full matrix is not. Batch into a connected session and ask +the user to look at exactly one thing: **stand next to, walk around, and walk +through the doorway of two or three of the affected props from different +landcells** — a cottage door (`0x0200027C`), a large one (`0x020010A8` +"Watcher's Wall" — 3 CylSpheres up to 0.77 m plus 4 BSP shapes, the biggest +live shape-list change on the list), and one big free-standing prop +(`0x02001761` "Boulder", 2.00 m sphere + 1 BSP). The symptom to watch for is a +**cell-membership** failure, not a feel change: the prop stops blocking when +you approach it from one particular direction or from the neighbouring cell, +while still blocking from another. That is the `#98`/`#168` signature and it is +the only way this commit can break something. + +Do **not** ask for a general "does the door feel right" pass. §3.3 predicts no +feel change, and asking for one spends the session's visual budget on a +question the disassembly already answered. + +--- + +## 9. Test plan + +Design rule, unchanged from AP-22 and for the same reason: **state the sabotage +that must redden each fact, and run it.** This campaign has shipped or caught +**seven** green tests covering nothing; §11.5 makes it eight. Assume each fact +below discriminates nothing until its sabotage proves otherwise. + +### 9.1 CORRECT — `ShadowShapeBuilderTests.FromSetup_DoorSetup_ProducesFourShapes` + +`tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs:50-72`. Its +fixture `CreateDoorSetup()` is the real `0x020019FF` — 1 Sphere r=0.100 at +`(0,0,0.018)`, 3 parts — and `hasBsp` returns true for both part ids, so it +asserts `1 Sphere + 3 BSP = 4`. + +It must become `FromSetup_DoorSetup_EmitsBspPartsOnly`: **3 shapes, all +`ShadowCollisionType.BSP`, zero Sphere shapes**, with a comment naming the +retail anchor (`0x0050f165` test / `0x0050f16f je` / `0x0050f19d jmp`). Keep +the fixture — it is real DAT data and it is the exact case the register row +names. + +**Sabotage:** restore the additive emission (delete the pre-pass gate). This +fact must redden. This is the one sabotage that directly re-proves the +production change, so it must be run. + +### 9.2 CORRECT — the SECOND test the register row does not mention + +`ShadowShapeBuilderTests.FromSetup_DoorSetup_SphereAtExpectedLocalOffset` +(`:74-90`) calls `FromSetup(setup, 1.0f, _ => true)` — every part BSP-bearing — +and then asserts a `Sphere`-typed shape exists at `(0,0,0.018)` with r=0.100 +and `CylHeight == 0`. **Under the exclusive rule it returns no Sphere at all +and this test fails.** The AP-152 row names only +`FromSetup_DoorSetup_ProducesFourShapes`; this is the same "named one site +where two existed" failure AP-22's row had. + +Do not delete it — its content (Setup Spheres emit true `Sphere`, not a +height-capped Cylinder; local offset and radius pass through) is live and is +the premise of the whole `CSphere` family port (`TransitionTypes.cs:4264`, +`SphereCollisionFamilyTests`). Re-host it on `hasPhysicsBsp: _ => false`, which +is the DAT-real configuration for the 3,605 Sphere-only Setups. + +**Sabotage:** change `ShadowShapeBuilder.cs:113` from +`ShadowCollisionType.Sphere` to `Cylinder`. Must redden. If it does not, the +re-host lost the coverage. + +### 9.3 NEW — the effective-identity gate fact + +Same class. A Setup with one CylSphere **and** one part, where +`hasPhysicsBsp` is true only for a *replacement* id supplied through +`effectivePartGfxObjIds`: + +- with the replacement supplied → **BSP only**, no Cylinder; +- with `effectivePartGfxObjIds` null (so the gate sees the base id, for which + `hasPhysicsBsp` is false) → **Cylinder only**, no BSP. + +This is the only fact that discriminates "the gate reads the same identities +step 3 does" from "the gate reads `setup.Parts`". + +**Sabotage:** change the pre-pass to read `(uint)setup.Parts[i]` instead of the +effective id. The first case must redden (it would emit Cylinder + BSP), and +watch that the second stays green. + +### 9.4 NEW — App-layer registration fact + +`tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs`. **No +existing App test combines a primitive and a BSP part** — I checked all +fourteen facts in that file; every fixture is primitive-only or BSP-only. So +today nothing at the App layer would catch this change either way. + +Add: a Setup with one CylSphere (r 0.4, h 1.2) *and* one part whose +`hasPhysicsBsp` is true, built through `LiveEntityCollisionBuilder.Build` with +`scale: 1.5f`, registers **exactly one shape**, `CollisionType == BSP`, +`Radius == physicsBspRadius * scale` — i.e. the BSP radius substitution at +`:135-139` still applies and no Cylinder survives. + +**Sabotage:** restore the additive emission. Must redden on `Assert.Single`. + +### 9.5 NEW — installed-DAT population fact + +Where the AP-22 commit put `InstalledSetupCollisionReachabilityTests` +(`tests/AcDream.Content.Tests/`), beside it, same `ACDREAM_DAT_DIR` skip +convention. + +Enumerate every Setup, apply `FromSetup`'s own three steps through the +production decoder, and assert: + +- **(a)** exactly **172** Setups emit ≥1 primitive *and* ≥1 BSP part shape; + 73 of them CylSphere-bearing, 99 Sphere-bearing. +- **(b) positive controls, so (a) cannot pass vacuously:** 5,935 Setups + enumerated; 4,283 emit ≥1 primitive; 530 emit ≥1 BSP part shape; 358 BSP-only; + 1,294 emit nothing. +- **(c)** the DAT-authored `SetupFlags.HasPhysicsBSP` agrees with the derived + per-part predicate on all 5,935 — **0** disagreements. This is the fact that + underwrites §3.3's "ACE always sends the bit", and it is the one that would + silently rot if a future DAT patch changed the relationship. + +(b) is not decoration; it is why (a) discriminates. A broken enumerator, a +wrong dat path, or a silently-empty flatten all satisfy (a) trivially. This is +exactly the failure the atlas-tier seam commit was written to close, and +exactly the failure the AP-22 architecture review found in the *other* +direction (a coverage claim that sabotage disproved). + +**Sabotage (run both):** invert (a) to expect 0 — must fail on (a). Point the +enumeration at an empty id set — must fail on **(b)** at `0 != 5935`, *not* +pass (a). + +**Trap:** the numbers in (b) and (c) are external constants measured in §4.3. +Write them as literals. Deriving them from the same predicate the assertion +uses is a tautology. + +### 9.6 VERIFY, do not edit + +Predicted green; a red one is *information about the divergence*, not a test to +adjust. If any of these reddens, stop and read the diff — it means the shape +this contract says was inert was in fact carrying behaviour. + +| Test | Why it survives | +|---|---| +| `ShadowShapeBuilderTests.FromSetup_PartWithoutBsp_SkipsBspShape` (`:92`) | counts BSP shapes only; still 1 | +| `…FromSetup_EffectivePartIdentitiesControlPhysicsBspSelection` (`:106`) | no primitives in fixture | +| `…FromSetup_CreatureWithCylSpheres_OnlyEmitsCylinders` (`:126`) | `_ => false`, no BSP | +| `…FromSetup_EmptySetup_ReturnsEmptyList`, `…NullSetup_Throws` | unchanged | +| all four `ShadowShapeBuilderShapeSourceTests` | every fixture is primitive-only or BSP-only | +| `Issue175HubDoorPoseInspectionTests` ×3 `FromSetup` facts | `MakeTwoPartSetup()` has no primitives; and the DAT Setup they load, `0x02000C9D`, is **not** in the affected 172 | +| `DoorCollisionApparatusTests` (`:404`, real Setup `0x020019FF`) | its blocking assertions test the door **slab BSP**; the 10 cm sphere was already skipped at query time by `BspOnlyDispatch` — but this suite registers with `state = 0x10008`, so verify the state literal is still what makes that true | +| `DoorBugTrajectoryReplayTests` (`:783`, `:895`, real `0x020019FF`) | same; `Assert.Contains(shapes, BSP)` still holds | +| `LandblockPhysicsPublisherTests`, Content static tests | static paths untouched | +| all 14 `LiveEntityCollisionBuilderTests` facts | every fixture is primitive-only or BSP-only | + +`DoorCollisionApparatusTests` and `DoorBugTrajectoryReplayTests` are the +**discriminating behavioural evidence** for §3.3: they are the only tests in +the tree that run a real affected Setup through a real resolve. Their staying +green is the positive statement that the primitive was not doing the blocking. + +--- + +## 10. Traps + +1. **Deriving the gate from `setup.Parts` while step 3 derives from + `effectivePartGfxObjIds`.** They can disagree after an ObjDesc swap, and the + disagreement is silent: the gate suppresses the primitives, step 3 emits + nothing, `Build` returns null, and the entity's collision disappears + entirely. §9.3 is the only fact that catches it. +2. **Getting the priority backwards.** BSP wins. A door that kept its 10 cm + sphere and lost its slab would be walk-through — and would still be green on + any test that only counts shapes. +3. **Reordering the emission** by returning the BSP list early. Changes + `shapes[0]`/`shapes[1]` indices in `Issue175HubDoorPoseInspectionTests` and + the order `ShadowEntry` rows enter cells. +4. **Believing this changes door blocking.** It does not (§3.3). Gating on + "walk into a door and see if it feels different" produces a green gate that + proves nothing, and would have proved nothing before the change either. +5. **Missing the flood-set change.** It is the *only* behavioural delta, it is + in a function with no `BspOnlyDispatch` guard, and nothing currently + instruments it. §8.2 item 3 is the load-bearing gate. +6. **Deleting `BspOnlyDispatch` as "now redundant".** It is retail's actual + dispatch site and the guard against a future additive producer. §5.3. +7. **Modelling retail's flag as live when it is cached once.** + `CPhysicsObj::CacheHasPhysicsBSP` has exactly one caller, + `InitPartArrayObject+0x7e`, so after an `AnimPartChanged` part swap retail's + *dispatch flag* is stale while its *per-part* test is live (§2.4). acdream's + gate will be live in both. The two disagree only when a swap adds or removes + the last physics-BSP part; humanoid part swaps (clothing/armour) involve no + physics-BSP GfxObjs on either side, so this is not reachable against ACE + today. **Note it in the register; do not build state to model it.** +8. **Trusting the Binary Ninja text for branch polarity.** BN renders the + dispatch as `if ((state & 0x10000) == 0 || ebp_1 != 0 || eax_12 != 0)` with + the *primitive* path in the `then`, and its `ebp_1` aliasing in this + function is visibly corrupt. Cite the disassembly. +9. **Treating the 2018 weenie dump as authoritative for reachability.** + `0x020019FF` is in zero of its Setup didStats and is nevertheless a live + ACE spawn in our own capture (§4.4). +10. **Assuming "not in `AcDream.App`" means "not affected".** It is true here + (§6) — which is exactly why it must be *proved* with the headless suite and + a headless route rather than asserted, per C5b. + +--- + +## 11. Claims found false or stale at HEAD + +Numbered, as required. Each was checked against the binary, the DAT, or the +source at `0d62a5ff` — none inherited. + +### 11.1 The AP-152 row's risk statement is **FALSE as written** + +> "172 … register a collision primitive retail never tests … Symptom class: +> catching or stopping on a doorway sill, or a small non-retail obstacle at a +> BSP prop's base." + +acdream **does not test it either**. `Transition.BspOnlyDispatch(obj.State)` +skips both primitive branches (`TransitionTypes.cs:3911`, `:3954`) whenever the +target's `PhysicsState` carries 0x10000, and ACE sets that bit from +`CSetup.HasPhysicsBSP` for every affected Setup +(`WorldObject_Networking.cs:665-668`; authored-vs-derived agreement 5,935/5,935, +§4.2). The stated symptom cannot be occurring on the live path against ACE. The +row's own retail-anchor column even cites the flag it fails to notice acdream +is already keying on — and the guard's source comment +(`TransitionTypes.cs:3936-3949`) names the exact door and the exact bug +("stuck on door" phantom, `door-a6p6-v2.utf8.log`) that this row re-predicts as +open. + +The true risk is **cell membership** (§2.3, §8.2 item 3), which is a different +symptom class with a different gate. + +### 11.2 The row's mitigation "the affected primitives are small and centred at the part origin" is **FALSE** + +Neither half. Sizes: `0x02001741` CylSphere r = **6.714 m**, `0x0200086E` +Sphere r = **5.842 m**, `0x02000D85` r = 5.576 m; median across the 172 is +0.500 m and the weenie-reachable max is 2.000 m. Centring: `0x0200086E`'s +sphere origin is `(0.759, 0.165, 5.842)`, nowhere near the part origin. + +### 11.3 The row's "cottage door 0x020019FF, whose ~14 cm base Sphere" is **WRONG about the number** + +The Sphere radius is **0.100 m**, origin `(0, 0, 0.018)`. `0.141` is +`Setup.Radius` — a *different field*, and the one AP-22 had just finished +proving is never collision geometry. Confirmed twice: `tools/SetupInspect -- +0x020019FF` prints `Radius/Height = 0.141 / 0.200` and separately +`sphere[0] … radius=0.1 radiusBits=0x3DCCCCCD`; the sweep agrees. The row +conflated the two on the same line as its `AP-22` neighbour. "Sits at the +threshold" is right — 1.8 cm above the part origin. + +Also worth pinning for the register: **`0x020019FF` has `DefaultMotionTable = +0x00000000`** and appears in zero weenies of the 2018 ACE dump, yet is a live +ACE spawn in our own capture. Either statement alone would mislead. + +### 11.4 The row names one pinning test where there are **two** — **INCOMPLETE** + +It names `FromSetup_DoorSetup_ProducesFourShapes`. It omits +`FromSetup_DoorSetup_SphereAtExpectedLocalOffset` +(`ShadowShapeBuilderTests.cs:74`), which also fails under the exclusive rule +(§9.2). This is structurally the same error AP-22's row made with its site +list, one row earlier in the same file. + +### 11.5 `FromSetup_ScaleFactor_MultipliesAllRadiiAndOffsets` is a **green test covering nothing** — new instance + +`ShadowShapeBuilderTests.cs:150-166`. It runs `CreateDoorSetup()` (0 CylSpheres) +through `FromSetup` and then asserts radii/offsets **inside** +`if (s.CollisionType == ShadowCollisionType.Cylinder)`. That branch has been +unreachable since Setup Spheres started emitting `ShadowCollisionType.Sphere` +(the 2026-06-24 change noted at `:60`). The only assertion that executes is +`Scale == 2.0f`. The test's name promises radius and offset scaling and pins +neither. Eighth instance of the pattern this campaign. Out of scope to fix here +— note it, or fold it into §9.2's re-host if it is free. + +### 11.6 The deeper divergence the row does not name: acdream takes a CLIENT-DERIVED flag from the WIRE + +Retail computes `HAS_PHYSICS_BSP_PS` itself, from its own part array, at +`InitPartArrayObject` (§2.4). acdream reads it out of the server's +`PhysicsState` (`LiveEntityCollisionBuilder.cs:161` → +`ShadowEntry.State` → `BspOnlyDispatch`) and never derives it. It happens to be +correct because ACE reads the same DAT bit — that is an undocumented dependency +on a specific server implementation, in a subsystem whose whole premise is that +the client decides. It is unregistered. §12.3 files it. + +This fix makes the *outcome* independent of the wire without touching the flag, +which is why it is worth doing even though §11.1 shows the current symptom is +inert. + +### 11.7 The static paths are exclusive but **not equivalent** — the row's "internally inconsistent" is understated + +Even after AP-152 is fixed the live and static paths still disagree, in two +ways the row does not mention (§3.2): the static paths emit a Setup Sphere as a +**Cylinder** with `CylHeight = radius * 2` and a base-shifted origin, and they +derive "has BSP" from `entity.MeshRefs` rather than from `setup.Parts`. +`ShadowCollisionType.Sphere` is produced at exactly one site in the whole of +`src/`. Unregistered; §12.4 files it. + +### 11.8 `BuildFloodSpheres`'s cylinder preference contradicts `calc_cross_cells` — **unregistered** + +`ShadowObjectRegistry.cs:614-622` uses cylinders exclusively whenever any exist. +Retail routes a BSP-bearing object to `find_bbox_cell_list` *before* looking at +cylspheres (§2.3). For the 73 CylSphere+BSP Setups acdream floods from the +wrong geometry today. This fix corrects it by accident (the cylinders stop +existing); the general rule — BSP-bbox first — is still unimplemented, and +acdream approximates a bbox with per-shape bounding spheres in any case. +§12.4 files it. + +### 11.9 AP-22's contract §5 said headless "registers no live-entity collision at all" — **still true, and now load-bearing here** + +Re-verified independently (§6). Recorded because this fix's blast radius +*depends* on it: AP-152 is graphical-only precisely because #330 is open. +Closing #330 later will retroactively widen this fix's reach to headless bots, +which is a reason to land the exclusivity rule now rather than after. + +### 11.10 The stale `GameWindow.cs:6034` citations + +`ShadowShapeBuilder.cs:19` and `:100`, and +`LandblockPhysicsContentBuilder.cs:695`, cite a `GameWindow.cs` line number +from before the eight-slice decomposition; the file is now 1,622 lines. Free to +fix in this commit (§5.2). + +### 11.11 The register row's "must be rewritten when this is fixed" is right, and the row itself must go + +AP-152's row states the test must be rewritten. Confirmed (§9.1). Adding it +here so the retirement text carries the corrected two-test list rather than +repeating the row's single-test claim. + +--- + +## 12. Register edits + +### 12.1 Retire AP-152 in the implementing commit + +The retirement text must carry, or the record is wrong twice: + +- the byte-level anchor with the priority order stated + (`0x0050f165` test / `0x0050f16f je` → primitives; `0x0050f19d jmp` past both; + `0x0050f1d6 jae` → return; `0x0050f22f je` → return; **BSP wins**); +- the corrected cottage-door number (**0.100 m** Sphere, not ~14 cm — the + `0.141` is `Setup.Radius`); +- the corrected mitigation (primitives run to **6.714 m**, not "small", and are + not all origin-centred); +- **the correction that the collision symptom was already suppressed** by + `BspOnlyDispatch` + ACE's `CSetup.HasPhysicsBSP`, so the retirement is not a + collision-behaviour change but a shape-list and **cell-membership** change; +- the two corrected tests, not one; +- the 172 figure with its three-decoder derivation and the 5,935/5,935 + authored-vs-derived agreement; +- a pointer to this document. + +### 12.2 File — retail's dispatch flag is cached once; acdream's gate is live + +Narrow, unreachable against ACE today (trap 7), but it is a real modelling +difference introduced by putting the gate in `FromSetup`, and it must be +recorded in the same commit that introduces it. One row, with +`CPhysicsObj::CacheHasPhysicsBSP` 0x0050f570 / single caller +`InitPartArrayObject+0x7e` 0x0051272e as the anchor. + +### 12.3 File — `HAS_PHYSICS_BSP_PS` is taken from the wire, not derived + +§11.6. Anchor: `CPartArray::CacheHasPhysicsBSP` 0x00518110 (derives from the +parts) vs `LiveEntityCollisionBuilder.cs:161` (copies +`FinalPhysicsState`). Note ACE's `WorldObject_Networking.cs:665-668` as the +reason it currently agrees, and that the agreement is not guaranteed by +anything on our side. + +### 12.4 File — static-vs-live shape-source divergences + +§11.7 and §11.8, as one row or two: +(a) static paths emit Setup Spheres as height-capped Cylinders with a shifted +origin where retail and the live path use `CSphere::intersects_sphere`; +(b) `BuildFloodSpheres` prefers cylinders where `calc_cross_cells` @0x00515230 +routes a BSP-bearing object to `find_bbox_cell_list` @0x00510fc0 first. +(b) is partly retired by this fix for the 73 CylSphere+BSP Setups; the general +rule is not. + +### 12.5 Check before filing + +Confirm none of 12.2–12.4 is already covered by an existing row or by #291. +I grepped the register for `LandblockPhysicsPublisher` / +`LandblockPhysicsContentBuilder` and found only the retired AD-6 and the AP-152 +and AP-22 rows themselves; no row covers the sphere-as-cylinder conversion or +the flood-source rule. + +--- + +## 13. Size and split + +**Size: small.** One production method gains a ~6-line pre-pass. Two tests +corrected, three added (one of them an installed-DAT sweep, ~70 lines), four +comments fixed, one register row retired, up to three filed. **One commit.** + +**Split call: land AP-152 alone.** + +Do not bundle: + +- **#330** (headless live-entity collision). Larger, overlaps Slice-J ownership, + and bundling would destroy this commit's "graphical-only, proven by the + headless route" evidence. +- **The wire-vs-derived flag (§12.3).** Changing `registration.State` touches + every consumer of `FinalPhysicsState` — Hidden, Missile, ethereal layer-2, + the `[setstate]` log. Separate slice, separate gate. +- **The static-path sphere-as-cylinder conversion (§12.4a).** It changes static + collision geometry on a much larger population and needs its own count. +- **`BuildFloodSpheres` → `find_bbox_cell_list` parity (§12.4b).** Changing the + flood *logic* in the same commit that changes its *input* makes the §8.2 + item-3 diff unattributable. That is the single strongest reason to keep this + commit narrow. +- **`FromSetup_ScaleFactor_MultipliesAllRadiiAndOffsets` (§11.5).** Free to fix + if it falls out of §9.2; otherwise file it. + +--- + +## Appendix — reproducing the measurements + +**Disassembly.** A from-scratch capstone script over +`C:\Users\erikn\Downloads\acclient.exe` (PE32, image base `0x00400000`, RVA→file +offset via the section table), plus a `.text` scan for `E8`/`E9` relative +targets to build the xref lists in §2.4. Symbol resolution by exact address +against `docs/research/named-retail/symbols.json` (18,366 entries). + +**DAT sweep.** A .NET 10 console project outside the repo referencing +`Chorizite.DatReaderWriter 2.1.7`, enumerating via +`DatCollection.GetAllIdsOfType()` / `()` and reproducing +`FromSetup`'s three steps. Emits `ap152-affected.txt` +(`setupId, cylShapes, sphShapes, bspShapes, parts, minPrimRadius, +maxPrimRadius`) for the 172. Deliberately not committed; §9.5 is the committed +form of the load-bearing assertion. + +**Characterisation.** A streaming regex pass over +`references/weenies(single file).json` (168 MB, 2018 ACE dump) keying +`didStats key 1 = Setup` to `wcid` / `Name` / `weenieType`, and `intStats +key 93 = PhysicsState`. Of 459 weenies whose Setup is in the affected 172, 163 +carry 0x10000 in the *authored* PhysicsState and 296 do not — which is why +§3.3 rests on ACE's `WorldObject_Networking` override rather than on the +authored value. Treat this dump as a characteriser only; see trap 9. + +**Setup DAT layout** is `Setup.generated.cs` in +`references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/`; the +`SetupFlags` bit assignment was independently confirmed against +`CSetup::UnPack` @`0x00520c50` (§4.2). diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 53e064ae..df50055e 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -597,11 +597,35 @@ public sealed class ShadowObjectRegistry } /// - /// Retail flood-sphere rule (CylSphere overload, Ghidra 0x0052b9f0): - /// when the object has cylinder shapes, each contributes one sphere at - /// its world BASE point (low_pt) with the cylinder radius, capped at 10; - /// otherwise the BSP parts' bounding spheres are the footprint (the - /// sorting-sphere fallback, calc_cross_cells 0x00515230 tail). + /// Retail cross-cell dispatch, CPhysicsObj::calc_cross_cells + /// @0x00515230, in retail's own priority order: + /// + /// + /// BSP-bearing (0x00515285 test dword [esi+0xa8],0x10000 / + /// 0x0051528f jne 0x515305) → CPhysicsObj::find_bbox_cell_list + /// @0x00510fc0. The cylsphere and sorting-sphere branches are BOTH below + /// that jump and unreachable from it. acdream approximates the bbox with + /// the BSP parts' bounding spheres. + /// else cylspheres (0x00515298 GetNumCylsphere non-zero) → + /// CObjCell::find_cell_list @0x0052b9f0 over the cylsphere array; + /// each contributes one sphere at its world BASE point with the cylinder + /// radius, capped at 10. + /// else the sorting sphere (0x005152dc → + /// CPartArray::GetSortingSphere @0x00518b00 → + /// CObjCell::find_cell_list @0x0052b990). + /// + /// + /// + /// The BSP-first rule is redundant for every shape list acdream produces + /// today — dispatches at + /// emission (AP-152) and both landblock-static publishers emit + /// homogeneous lists — exactly as + /// Transition.BspOnlyDispatch is redundant at the query site. It is + /// kept because retail genuinely dispatches here, and because a producer + /// that handed this method a mixed list would otherwise flood a + /// BSP-bearing object from its primitive and silently place it in the + /// wrong shadow cells (the #98 / #168 symptom class). + /// /// private static List BuildFloodSpheres( Vector3 entityWorldPos, @@ -611,15 +635,25 @@ public sealed class ShadowObjectRegistry const int RetailSphereCap = 10; var spheres = new List(); + bool anyBsp = false; bool anyCyl = false; foreach (var s in shapes) { - if (s.CollisionType == ShadowCollisionType.Cylinder) { anyCyl = true; break; } + if (s.CollisionType == ShadowCollisionType.BSP) anyBsp = true; + else if (s.CollisionType == ShadowCollisionType.Cylinder) anyCyl = true; } + // Retail's branch, chosen once: BSP-bbox, else cylspheres, else the + // sorting sphere (which acdream approximates with the remaining + // shapes' bounding spheres). + ShadowCollisionType? only = + anyBsp ? ShadowCollisionType.BSP + : anyCyl ? ShadowCollisionType.Cylinder + : null; + foreach (var s in shapes) { - if (anyCyl && s.CollisionType != ShadowCollisionType.Cylinder) + if (only is { } required && s.CollisionType != required) continue; if (spheres.Count >= RetailSphereCap) break; diff --git a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs index bc6064b9..103a3b06 100644 --- a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs +++ b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs @@ -14,21 +14,25 @@ namespace AcDream.Core.Physics; /// . /// /// -/// Walks (1) every CylSphere → Cylinder shape, (2) every Sphere ONLY when no -/// CylSpheres are present (matches retail and the existing landblock-static -/// convention at GameWindow.cs:6034), and (3) every Part whose GfxObj has a -/// non-null PhysicsBSP → per-part BSP shape, with local transforms from -/// PlacementFrames[Resting | Default | first available]. +/// This is a DISPATCH, not a union. In priority order: (3) when ANY Part's +/// effective GfxObj has a non-null PhysicsBSP, emit one BSP shape per such +/// Part — and nothing else; otherwise (1) every CylSphere → Cylinder shape; +/// otherwise (2) every Sphere → Sphere shape; otherwise nothing at all. Local +/// transforms come from PlacementFrames[Resting | Default | first available]. /// /// /// /// Retail anchor: CPhysicsObj::FindObjCollisions (0x0050f050) -/// dispatches EXCLUSIVELY on HAS_PHYSICS_BSP_PS (0x10000): it calls +/// dispatches EXCLUSIVELY on HAS_PHYSICS_BSP_PS (0x10000) +/// (0x0050f165 test dword [esi+0xa8],0x10000 / +/// 0x0050f16f je 0x50f1a2): it calls /// CPartArray::FindObjCollisions (the per-part BSP walk) and returns -/// (0x0050f19d jmp past the primitive branches), OR walks the Setup's -/// CylSpheres, OR walks the Setup's Spheres, OR — with none of the three — -/// returns the seeded OK_TS without synthesizing any shape -/// (0x0050f22f je 0x50f31b). It is never a union. +/// (0x0050f19d jmp 0x50f2b0, an UNCONDITIONAL jump past both primitive +/// branches — the CylSphere loop starts at 0x50f1a2 and the Sphere loop at +/// 0x50f21d), OR walks the Setup's CylSpheres and returns +/// (0x0050f1d6 jae 0x50f317), OR walks the Setup's Spheres, OR — with +/// none of the three — returns the seeded OK_TS without synthesizing +/// any shape (0x0050f22f je 0x50f31b). BSP wins. /// CPhysicsPart::find_obj_collisions (0x0050d8d0) tests ONLY the /// GfxObj physics BSP; CylSpheres and Spheres are Setup-level arrays /// reached through CPartArray::GetCylsphere (0x00518090) and @@ -36,12 +40,28 @@ namespace AcDream.Core.Physics; /// /// /// -/// KNOWN DIVERGENCE (AP-152): steps 1/2 and step 3 below are emitted -/// ADDITIVELY here, where retail is exclusive — 172 of 5,935 installed -/// Setups carry a primitive and a physics-BSP part. Do not cite the anchor -/// above as justification for the additive design; it is the evidence -/// against it. acdream's two static publication paths already implement the -/// exclusive rule. +/// Cell membership dispatches on the SAME flag and in the same priority: +/// CPhysicsObj::calc_cross_cells (0x00515230) tests +/// 0x10000 at 0x00515285 and routes a BSP-bearing object to +/// CPhysicsObj::find_bbox_cell_list (0x00510fc0) at +/// 0x0051528f jne 0x515305, never reaching its cylsphere +/// (0x005152d1) or sorting-sphere (0x005152fb) branches. That +/// is why the exclusivity is enforced HERE, at emission, rather than only at +/// the query-time guard Transition.BspOnlyDispatch: the shape list is +/// also the input to ShadowObjectRegistry.BuildFloodSpheres. +/// +/// +/// +/// AP-152 (filed and retired 2026-08-06): these three steps used to be +/// emitted ADDITIVELY — 172 of 5,935 installed Setups carry both a primitive +/// and a physics-BSP part. The collision half of that divergence was already +/// inert, because Transition.BspOnlyDispatch skips both primitive +/// branches whenever the wire PhysicsState carries 0x10000 and ACE +/// derives that bit from the same DAT flag; the live half was CELL +/// MEMBERSHIP, which had no such guard. Gating here also removes acdream's +/// undeclared dependency on the server sending the bit: the gate is derived +/// from the parts, exactly as retail's CPartArray::CacheHasPhysicsBSP +/// (0x00518110) derives it. /// /// public static class ShadowShapeBuilder @@ -81,38 +101,66 @@ public static class ShadowShapeBuilder var result = new List(); - // 1. CylSpheres — each becomes a Cylinder shape. - foreach (var cyl in setup.CylSpheres) + // 0. Retail dispatch gate. CPhysicsObj::FindObjCollisions tests + // HAS_PHYSICS_BSP_PS FIRST (0x0050f165) and leaves the BSP branch + // through an unconditional jmp past both primitive loops + // (0x0050f19d); CPhysicsObj::calc_cross_cells tests the same flag + // at 0x00515285 and routes to find_bbox_cell_list. Retail derives + // the flag from the part array itself + // (CPartArray::CacheHasPhysicsBSP 0x00518110 ORs 0x10000 on the + // first part whose gfxobj->physics_bsp is non-null), so the gate + // below reads the SAME effective part identities step 3 reads — + // never setup.Parts directly. A gate keyed on a different identity + // could suppress the primitives while step 3 emitted nothing, + // silently deleting the entity's collision. + bool anyPhysicsBspPart = false; + for (int i = 0; i < setup.Parts.Count; i++) { - if (cyl.Radius <= 0f) continue; - float baseHeight = cyl.Height > 0f ? cyl.Height : cyl.Radius * 4f; - result.Add(new ShadowShape( - GfxObjId: 0u, - LocalPosition: new Vector3(cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale, - LocalRotation: Quaternion.Identity, - Scale: entScale, - CollisionType: ShadowCollisionType.Cylinder, - Radius: cyl.Radius * entScale, - CylHeight: baseHeight * entScale)); + if (hasPhysicsBsp(EffectivePartGfxObjId(setup, effectivePartGfxObjIds, i))) + { + anyPhysicsBspPart = true; + break; + } } - // 2. Spheres — only when no CylSpheres (matches landblock-static convention - // at GameWindow.cs:6034). Each becomes a true Sphere (no height clamping). - // Retail anchor: CSphere::intersects_sphere @ 0x00537A80 uses 3-D distance - // for the overlap check, unlike CCylSphere which clips to [low_pt, high_pt]. - if (setup.CylSpheres.Count == 0) + // Steps 1 and 2 run ONLY for an object with no physics-BSP part. + if (!anyPhysicsBspPart) { - foreach (var sph in setup.Spheres) + // 1. CylSpheres — each becomes a Cylinder shape. + foreach (var cyl in setup.CylSpheres) { - if (sph.Radius <= 0f) continue; + if (cyl.Radius <= 0f) continue; + float baseHeight = cyl.Height > 0f ? cyl.Height : cyl.Radius * 4f; result.Add(new ShadowShape( GfxObjId: 0u, - LocalPosition: new Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale, + LocalPosition: new Vector3(cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale, LocalRotation: Quaternion.Identity, Scale: entScale, - CollisionType: ShadowCollisionType.Sphere, - Radius: sph.Radius * entScale, - CylHeight: 0f)); + CollisionType: ShadowCollisionType.Cylinder, + Radius: cyl.Radius * entScale, + CylHeight: baseHeight * entScale)); + } + + // 2. Spheres — only when no CylSpheres. Retail's CylSphere loop + // returns rather than falling into the Sphere loop + // (0x0050f1d6 jae 0x50f317). Each becomes a true Sphere (no + // height clamping): CSphere::intersects_sphere @ 0x00537A80 + // uses 3-D distance for the overlap check, unlike CCylSphere + // which clips to [low_pt, high_pt]. + if (setup.CylSpheres.Count == 0) + { + foreach (var sph in setup.Spheres) + { + if (sph.Radius <= 0f) continue; + result.Add(new ShadowShape( + GfxObjId: 0u, + LocalPosition: new Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale, + LocalRotation: Quaternion.Identity, + Scale: entScale, + CollisionType: ShadowCollisionType.Sphere, + Radius: sph.Radius * entScale, + CylHeight: 0f)); + } } } @@ -126,10 +174,7 @@ public static class ShadowShapeBuilder // degrade array before CPartArray::FindObjCollisions reads it. // Keep the stable Setup part index/pose, but source collision // identity from that effective part when one was supplied. - uint gfxId = effectivePartGfxObjIds is not null - && i < effectivePartGfxObjIds.Count - ? effectivePartGfxObjIds[i] - : (uint)setup.Parts[i]; + uint gfxId = EffectivePartGfxObjId(setup, effectivePartGfxObjIds, i); if (!hasPhysicsBsp(gfxId)) continue; Frame partFrame; @@ -241,6 +286,20 @@ public static class ShadowShapeBuilder return shapes; } + /// + /// The collision identity of part : the installed + /// AnimPartChanged replacement when one was supplied, else the + /// Setup's own part. Shared by the step-0 dispatch gate and the step-3 + /// emission so the two can never read different identities. + /// + private static uint EffectivePartGfxObjId( + Setup setup, + IReadOnlyList? effectivePartGfxObjIds, + int index) + => effectivePartGfxObjIds is not null && index < effectivePartGfxObjIds.Count + ? effectivePartGfxObjIds[index] + : (uint)setup.Parts[index]; + /// Resolve the placement frame in priority Resting → Default → /// first available. Mirrors SetupMesh.Flatten's convention. private static AnimationFrame? ResolvePlacementFrame(Setup setup) diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs index af25931e..88da872a 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs @@ -122,6 +122,49 @@ public sealed class LiveEntityCollisionBuilderTests Assert.Equal(part, shape.GfxObjId); } + /// + /// AP-152. Every other fixture in this file is primitive-only or BSP-only, + /// so nothing at the App layer used to exercise the CylSphere+BSP + /// combination — 73 of the 172 affected installed Setups. + /// Retail's CPhysicsObj::FindObjCollisions @0x0050f050 tests + /// HAS_PHYSICS_BSP_PS at 0x0050f165 and leaves the BSP branch + /// through the unconditional 0x0050f19d jmp 0x50f2b0, past both the + /// CylSphere loop (0x50f1a2) and the Sphere loop (0x50f21d); + /// calc_cross_cells @0x00515230 dispatches identically at + /// 0x00515285. The CylSphere must not survive, and the surviving BSP shape + /// must still receive the real scaled bounding radius. + /// + [Fact] + public void CylSphereAndPhysicsBspPart_EmitsOnlyTheScaledBspShape() + { + const uint part = 0x0100AC01u; + var setup = new Setup(); + setup.Parts.Add(part); + setup.CylSpheres.Add(new CylSphere + { + Origin = Vector3.Zero, + Radius = 0.4f, + Height = 1.2f, + }); + WorldSession.EntitySpawn spawn = Spawn(scale: 1.5f); + var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); + WorldEntity entity = Entity(); + record.WorldEntity = entity; + var builder = new LiveEntityCollisionBuilder( + id => id == part, + id => id == part ? 3f : null, + PoseResolver()); + + LiveEntityCollisionRegistration registration = + Assert.IsType(builder.Build( + entity, setup, [part], spawn, record, Vector3.Zero)); + + ShadowShape shape = Assert.Single(registration.Shapes); + Assert.Equal(ShadowCollisionType.BSP, shape.CollisionType); + Assert.Equal(4.5f, shape.Radius); // 3 m physics-BSP radius * 1.5 scale + Assert.Equal(part, shape.GfxObjId); + } + [Fact] public void EffectiveReplacementWithoutPhysicsBsp_RemovesBasePartCollision() { diff --git a/tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs b/tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs new file mode 100644 index 00000000..c8a9df59 --- /dev/null +++ b/tests/AcDream.Content.Tests/InstalledSetupBspPrimitiveDispatchTests.cs @@ -0,0 +1,159 @@ +using AcDream.Core.Physics; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Options; + +namespace AcDream.Content.Tests; + +/// +/// AP-152 population + behaviour proof over the installed client_portal.dat. +/// +/// +/// Retail dispatches a Setup's collision geometry EXCLUSIVELY, BSP first, at +/// both consumers: CPhysicsObj::FindObjCollisions @0x0050f050 tests +/// HAS_PHYSICS_BSP_PS at 0x0050f165 and leaves the BSP branch +/// through the unconditional 0x0050f19d jmp 0x50f2b0, past both the +/// CylSphere loop (0x50f1a2) and the Sphere loop (0x50f21d); and +/// CPhysicsObj::calc_cross_cells @0x00515230 tests the same flag at +/// 0x00515285 and routes to CPhysicsObj::find_bbox_cell_list +/// @0x00510fc0 at 0x0051528f jne 0x515305, never reaching its +/// cylsphere (0x005152d1) or sorting-sphere (0x005152fb) branches. +/// +/// +/// +/// This sweep pins the affected population and asserts that +/// emits NO primitive for any of +/// it. Retail derives the dispatch flag from the parts themselves +/// (CPartArray::CacheHasPhysicsBSP @0x00518110 ORs 0x10000 on the first +/// part whose gfxobj->physics_bsp is non-null), which is exactly the +/// predicate used here. +/// +/// +public sealed class InstalledSetupBspPrimitiveDispatchTests +{ + // EXTERNAL constants. The four bucket controls are the ones already + // committed by the AP-22 reachability sweep (measured by an independent + // raw client_portal.dat B-tree parse that validated itself by byte + // accounting); the affected counts were measured on 2026-08-06 by a + // separate DatReaderWriter sweep that reproduced FromSetup's steps rather + // than calling it. + // + // They are deliberately NOT derived from the predicates below. A broken + // enumeration, a wrong dat path, or a silently-empty decode all satisfy + // the affected-count claim vacuously and are caught only by the controls. + private const int ExpectedSetups = 5935; + private const int ExpectedWithCylinder = 678; + private const int ExpectedSphereOnlyNoCylinder = 3605; + private const int ExpectedWithoutAnyPrimitive = 1652; + + private const int ExpectedAffected = 172; + private const int ExpectedAffectedCylinderBearing = 73; + private const int ExpectedAffectedSphereBearing = 99; + private const int ExpectedWithPhysicsBspPart = 530; + + [Fact] + public void InstalledSetups_WithBothAPrimitiveAndAPhysicsBspPart_EmitOnlyBspShapes() + { + string? datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) + return; + + using var dats = new DatCollection(datDir, DatAccessType.Read); + + // Production physics-BSP predicate, FlatCollisionAssetBuilder.cs:377-380. + var physicsBspCache = new Dictionary(); + bool HasPhysicsBsp(uint gfxObjId) + { + if (physicsBspCache.TryGetValue(gfxObjId, out bool cached)) + return cached; + bool result = + dats.Portal.TryGet(gfxObjId, out GfxObj? gfx) + && gfx is not null + && gfx.Flags.HasFlag(GfxObjFlags.HasPhysics) + && gfx.PhysicsBSP?.Root is not null + && gfx.VertexArray is not null; + physicsBspCache[gfxObjId] = result; + return result; + } + + int total = 0; + int withCylinder = 0; + int sphereOnly = 0; + int withoutPrimitive = 0; + int withPhysicsBspPart = 0; + int affected = 0; + int affectedCylinderBearing = 0; + int affectedSphereBearing = 0; + var affectedThatStillEmitAPrimitive = new List(); + + foreach (uint id in dats.GetAllIdsOfType()) + { + if (!dats.Portal.TryGet(id, out Setup? setup) || setup is null) + continue; + total++; + + bool hasCylinder = false; + foreach (var cyl in setup.CylSpheres) + { + if (cyl.Radius > 0f) { hasCylinder = true; break; } + } + bool hasSphere = false; + foreach (var sph in setup.Spheres) + { + if (sph.Radius > 0f) { hasSphere = true; break; } + } + // FromSetup step 2 is gated on CylSpheres.Count == 0, so a Setup + // with both only ever emitted Cylinders. + bool emitsSphere = setup.CylSpheres.Count == 0 && hasSphere; + + if (hasCylinder) withCylinder++; + else if (emitsSphere) sphereOnly++; + else withoutPrimitive++; + + bool hasBspPart = false; + foreach (uint partId in setup.Parts) + { + if (HasPhysicsBsp(partId)) { hasBspPart = true; break; } + } + if (hasBspPart) withPhysicsBspPart++; + + if (!hasBspPart || !(hasCylinder || emitsSphere)) + continue; + + affected++; + if (hasCylinder) affectedCylinderBearing++; + else affectedSphereBearing++; + + // The behaviour: for every affected Setup the production builder + // must emit BSP shapes only. + IReadOnlyList shapes = + ShadowShapeBuilder.FromSetup(setup, 1f, HasPhysicsBsp); + bool clean = shapes.Count > 0; + foreach (ShadowShape shape in shapes) + { + if (shape.CollisionType != ShadowCollisionType.BSP) + { + clean = false; + break; + } + } + if (!clean) + affectedThatStillEmitAPrimitive.Add(id); + } + + // Positive controls first — without these the claim below is + // satisfiable by an empty enumeration. + Assert.Equal(ExpectedSetups, total); + Assert.Equal(ExpectedWithCylinder, withCylinder); + Assert.Equal(ExpectedSphereOnlyNoCylinder, sphereOnly); + Assert.Equal(ExpectedWithoutAnyPrimitive, withoutPrimitive); + Assert.Equal(ExpectedWithPhysicsBspPart, withPhysicsBspPart); + + Assert.Equal(ExpectedAffected, affected); + Assert.Equal(ExpectedAffectedCylinderBearing, affectedCylinderBearing); + Assert.Equal(ExpectedAffectedSphereBearing, affectedSphereBearing); + + Assert.Empty(affectedThatStillEmitAPrimitive); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs index 303a73ea..99a708ea 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs @@ -217,4 +217,113 @@ public class ShadowObjectRegistryMultiPartTests Assert.Equal(0, reg.TotalRegistered); } + + // --------------------------------------------------------------------- + // AP-152 — cross-cell dispatch. CPhysicsObj::calc_cross_cells @0x00515230 + // tests HAS_PHYSICS_BSP_PS at 0x00515285 and routes a BSP-bearing object + // to CPhysicsObj::find_bbox_cell_list @0x00510fc0 through + // 0x0051528f jne 0x515305. The cylsphere branch (0x005152d1 + // CObjCell::find_cell_list @0x0052b9f0) and the sorting-sphere branch + // (0x005152fb ... @0x0052b990) are BOTH below that jump and unreachable + // from it. BuildFloodSpheres used to prefer Cylinders over everything + // whenever any Cylinder was present, which is retail's SECOND priority + // applied ahead of its first. + // --------------------------------------------------------------------- + + /// Cells of the landblock that hold at least one row for owner. + private static List OutdoorCellsHolding(ShadowObjectRegistry reg, uint ownerId) + { + var cells = new List(); + for (uint index = 1u; index <= 64u; index++) + { + uint cellId = LbId | index; + if (reg.GetObjectsInCell(cellId).Any(e => e.EntityId == ownerId)) + cells.Add(cellId); + } + return cells; + } + + private static ShadowShape Cyl(float radius) => new( + GfxObjId: 0u, LocalPosition: Vector3.Zero, LocalRotation: Quaternion.Identity, + Scale: 1f, CollisionType: ShadowCollisionType.Cylinder, + Radius: radius, CylHeight: radius * 2f); + + private static ShadowShape Bsp(float radius) => new( + GfxObjId: 0x010044B5u, LocalPosition: Vector3.Zero, LocalRotation: Quaternion.Identity, + Scale: 1f, CollisionType: ShadowCollisionType.BSP, + Radius: radius, CylHeight: 0f); + + private static List FloodCellsFor(params ShadowShape[] shapes) + { + var reg = new ShadowObjectRegistry(); + const uint ownerId = 0xBEEF01u; + // Centre of the landblock's cell (1,1) so a 14 m footprint stays + // inside the block's own 8x8 outdoor grid on every side. + reg.RegisterMultiPart( + ownerId, new Vector3(36f, 36f, 50f), Quaternion.Identity, + shapes, 0x10008u, EntityCollisionFlags.None, OffX, OffY, LbId); + return OutdoorCellsHolding(reg, ownerId); + } + + [Fact] + public void BuildFloodSpheres_BspBearingOwner_FloodsFromBspNotFromCylinder() + { + List cylinderOnly = FloodCellsFor(Cyl(0.5f)); + List bspOnly = FloodCellsFor(Bsp(14f)); + List mixed = FloodCellsFor(Cyl(0.5f), Bsp(14f)); + + // Controls: the two footprints must actually differ, or the fact below + // is satisfiable by any dispatch rule at all. + Assert.Equal([LbId | 10u], cylinderOnly); + Assert.True(bspOnly.Count > 1, + $"BSP footprint control failed: expected >1 cell, got {bspOnly.Count}"); + + // The fact: a mixed list floods from the BSP shapes, exactly as if the + // cylinder were not there. Retail 0x0051528f. + Assert.Equal(bspOnly, mixed); + Assert.NotEqual(cylinderOnly, mixed); + } + + /// + /// The AP-152 delta end-to-end: a CylSphere+BSP Setup (73 of the 172 + /// affected installed Setups are this shape) registered through the + /// production builder. Before the fix the emitted list carried both, and + /// BuildFloodSpheres' cylinder preference confined the owner to the + /// cylinder's cell while its slab BSP reached further — an object absent + /// from shadow cells it physically occupies, the #98 / #168 symptom class. + /// + [Fact] + public void FromSetup_CylSphereAndBspSetup_FloodsTheBspFootprint() + { + const uint part = 0x010044B5u; + var setup = new DatReaderWriter.DBObjs.Setup + { + Parts = { part }, + CylSpheres = { new DatReaderWriter.Types.CylSphere + { Radius = 0.5f, Height = 1f, Origin = Vector3.Zero } }, + }; + + IReadOnlyList raw = + ShadowShapeBuilder.FromSetup(setup, entScale: 1f, hasPhysicsBsp: id => id == part); + // Production substitutes the real BSP bounding radius at registration + // time (LiveEntityCollisionBuilder.Build); 14 m stands in for a slab + // wide enough to leave its own landcell. + var shapes = raw.Select(s => s.CollisionType == ShadowCollisionType.BSP + ? s with { Radius = 14f } + : s).ToList(); + + ShadowShape only = Assert.Single(shapes); + Assert.Equal(ShadowCollisionType.BSP, only.CollisionType); + + var reg = new ShadowObjectRegistry(); + const uint ownerId = 0xBEEF02u; + reg.RegisterMultiPart( + ownerId, new Vector3(36f, 36f, 50f), Quaternion.Identity, + shapes, 0x10008u, EntityCollisionFlags.None, OffX, OffY, LbId); + + List cells = OutdoorCellsHolding(reg, ownerId); + Assert.Contains(LbId | 10u, cells); + Assert.True(cells.Count > 1, + $"Expected the slab footprint to span more than its own landcell; got {cells.Count}"); + } } diff --git a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs index 925ba472..5776db7b 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs @@ -49,10 +49,20 @@ public class ShadowShapeBuilderShapeSourceTests // BEFORE ShadowShapeBuilder ran and discarded these entities entirely. // // Retail oracle: CPhysicsPart::find_obj_collisions@0x0050D8D0 -- when - // physics_bsp is non-null the part IS tested; the outer loop in - // CPartArray::FindObjCollisions iterates all parts regardless of - // CylSpheres/Spheres. ShadowShapeBuilder.FromSetup mirrors this by emitting - // one BSP shape per part that `hasPhysicsBsp` returns true for. + // physics_bsp is non-null the part IS tested, and the outer loop in + // CPartArray::FindObjCollisions@0x00518180 iterates ALL parts. + // ShadowShapeBuilder.FromSetup mirrors this by emitting one BSP shape per + // part that `hasPhysicsBsp` returns true for. + // + // AP-152 correction (2026-08-06): that "regardless of CylSpheres/Spheres" + // used to be stated here and was then used to justify emitting the BSP + // shapes IN ADDITION TO the primitives. It does not support that. The + // per-part loop is reached only from the BSP branch of + // CPhysicsObj::FindObjCollisions@0x0050f050, which is entered on + // HAS_PHYSICS_BSP_PS (0x0050f165 test / 0x0050f16f je) and left by the + // UNCONDITIONAL 0x0050f19d jmp 0x50f2b0 — past both primitive loops. The + // dispatch is exclusive and BSP wins; this fixture's Setup simply has no + // primitive to lose. [Fact] public void Setup_WithBspPart_NoCylSpheres_EmitsBspShape() { diff --git a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs index a1ede47c..c836f887 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs @@ -47,40 +47,57 @@ public class ShadowShapeBuilderTests return setup; } + /// + /// AP-152, corrected 2026-08-06 (was FromSetup_DoorSetup_ProducesFourShapes, + /// which pinned the additive emission as intended). + /// + /// + /// Retail dispatches EXCLUSIVELY and BSP wins. + /// CPhysicsObj::FindObjCollisions @0x0050f050 tests + /// HAS_PHYSICS_BSP_PS first (0x0050f165 + /// test dword [esi+0xa8],0x10000 / 0x0050f16f je 0x50f1a2) and + /// leaves the BSP branch through the UNCONDITIONAL + /// 0x0050f19d jmp 0x50f2b0, which is past both the CylSphere loop + /// (0x50f1a2) and the Sphere loop (0x50f21d). + /// CPhysicsObj::calc_cross_cells @0x00515230 tests the same flag at + /// 0x00515285 and routes to find_bbox_cell_list @0x00510fc0. + /// So the cottage door's 0.100 m base Sphere is neither tested for + /// collision nor used for cell membership — only the three slab BSP parts. + /// + /// [Fact] - public void FromSetup_DoorSetup_ProducesFourShapes() + public void FromSetup_DoorSetup_EmitsBspPartsOnly() { var setup = CreateDoorSetup(); Func hasBsp = id => id == 0x010044B5u || id == 0x010044B6u; var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1.0f, hasBsp); - Assert.Equal(4, shapes.Count); - - // Task 2 (2026-06-24): Setup.Spheres now emit ShadowCollisionType.Sphere, - // not Cylinder. A door's Sphere entry contributes the Sphere-typed shape; - // the 3 parts (all with physics BSP) contribute the 3 BSP shapes. - int sphereCount = 0; - int bspCount = 0; - foreach (var s in shapes) - { - if (s.CollisionType == ShadowCollisionType.Sphere) sphereCount++; - else if (s.CollisionType == ShadowCollisionType.BSP) bspCount++; - } - Assert.Equal(1, sphereCount); - Assert.Equal(3, bspCount); + Assert.Equal(3, shapes.Count); + Assert.All(shapes, s => Assert.Equal(ShadowCollisionType.BSP, s.CollisionType)); + Assert.DoesNotContain(shapes, s => s.CollisionType == ShadowCollisionType.Sphere); + Assert.DoesNotContain(shapes, s => s.CollisionType == ShadowCollisionType.Cylinder); } + /// + /// AP-152, re-hosted 2026-08-06 on hasPhysicsBsp: _ => false — the + /// DAT-real configuration for the 3,605 Sphere-only Setups. The fact + /// itself is unchanged and still live: a Setup Sphere emits a TRUE + /// (not a height-capped + /// Cylinder), passing its local offset and radius through. + /// ShadowCollisionType.Sphere is produced at exactly one site in + /// src/, and it is the premise of the whole CSphere family port. + /// Retail: CSphere::intersects_sphere @0x00537A80 uses 3-D + /// distance, so there is no height cap. + /// [Fact] public void FromSetup_DoorSetup_SphereAtExpectedLocalOffset() { var setup = CreateDoorSetup(); - var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => true); + var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => false); - // Task 2 (2026-06-24): Spheres emit ShadowCollisionType.Sphere (not Cylinder). - // Retail: CSphere::intersects_sphere @ 0x00537A80 uses 3-D distance; no height cap. - var sphereShape = shapes.FirstOrDefault(s => s.CollisionType == ShadowCollisionType.Sphere); - Assert.NotEqual(default, sphereShape); + var sphereShape = Assert.Single(shapes); + Assert.Equal(ShadowCollisionType.Sphere, sphereShape.CollisionType); Assert.Equal(0f, sphereShape.LocalPosition.X, 4); Assert.Equal(0f, sphereShape.LocalPosition.Y, 4); Assert.Equal(0.018f, sphereShape.LocalPosition.Z, 4); @@ -89,6 +106,43 @@ public class ShadowShapeBuilderTests Assert.Equal(0f, sphereShape.CylHeight, 4); } + /// + /// AP-152 trap 1. The step-0 dispatch gate and the step-3 emission must + /// read the SAME part identities. If the gate read setup.Parts + /// while step 3 read the installed AnimPartChanged replacements, + /// a swap could suppress the primitives while step 3 emitted nothing — + /// LiveEntityCollisionBuilder.Build would then return null and the + /// entity's collision would disappear entirely. + /// + [Fact] + public void FromSetup_DispatchGateReadsTheEffectivePartIdentities() + { + const uint basePart = 0x010044B5u; + const uint replacementWithBsp = 0x0100AA01u; + var setup = new Setup + { + Parts = { basePart }, + CylSpheres = { new CylSphere { Radius = 0.4f, Height = 1.2f, Origin = Vector3.Zero } }, + }; + Func hasBsp = id => id == replacementWithBsp; + + var swapped = ShadowShapeBuilder.FromSetup( + setup, 1.0f, hasBsp, effectivePartGfxObjIds: [replacementWithBsp]); + var unswapped = ShadowShapeBuilder.FromSetup(setup, 1.0f, hasBsp); + + // Replacement carries the BSP -> BSP wins, the CylSphere is suppressed. + ShadowShape swappedShape = Assert.Single(swapped); + Assert.Equal(ShadowCollisionType.BSP, swappedShape.CollisionType); + Assert.Equal(replacementWithBsp, swappedShape.GfxObjId); + + // Base identity has no BSP -> no BSP shape exists, so the CylSphere + // must survive. A gate reading setup.Parts would agree here and + // disagree above; a gate reading nothing at all would disagree here. + ShadowShape unswappedShape = Assert.Single(unswapped); + Assert.Equal(ShadowCollisionType.Cylinder, unswappedShape.CollisionType); + Assert.Equal(0.4f, unswappedShape.Radius, 4); + } + [Fact] public void FromSetup_PartWithoutBsp_SkipsBspShape() { @@ -147,22 +201,44 @@ public class ShadowShapeBuilderTests Assert.Equal(1.20f, shapes[0].CylHeight, 3); } + /// + /// Corrected 2026-08-06 (AP-152 §11.5). This test used to run + /// CreateDoorSetup() — which has ZERO CylSpheres — and then assert + /// radius/offset scaling inside + /// if (s.CollisionType == ShadowCollisionType.Cylinder). That + /// branch had been unreachable since Setup Spheres started emitting + /// (2026-06-24), so the only + /// assertion that ever executed was Scale == 2.0f: the name + /// promised radius and offset scaling and pinned neither. Both primitive + /// kinds are now asserted unconditionally, on fixtures that actually + /// emit them. + /// [Fact] public void FromSetup_ScaleFactor_MultipliesAllRadiiAndOffsets() { - var setup = CreateDoorSetup(); + var sphereShape = Assert.Single( + ShadowShapeBuilder.FromSetup(CreateDoorSetup(), entScale: 2.0f, _ => false)); + Assert.Equal(ShadowCollisionType.Sphere, sphereShape.CollisionType); + Assert.Equal(2.0f, sphereShape.Scale, 3); + Assert.Equal(0.200f, sphereShape.Radius, 3); // 0.100 * 2 + Assert.Equal(0.036f, sphereShape.LocalPosition.Z, 3); // 0.018 * 2 - var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 2.0f, _ => true); - - foreach (var s in shapes) + var cylSetup = new Setup { - Assert.Equal(2.0f, s.Scale, 3); - if (s.CollisionType == ShadowCollisionType.Cylinder) + CylSpheres = { - Assert.Equal(0.200f, s.Radius, 3); - Assert.Equal(0.036f, s.LocalPosition.Z, 3); + new CylSphere { Radius = 0.40f, Height = 1.20f, Origin = new Vector3(0.1f, 0.2f, 0.6f) } } - } + }; + var cylShape = Assert.Single( + ShadowShapeBuilder.FromSetup(cylSetup, entScale: 2.0f, _ => false)); + Assert.Equal(ShadowCollisionType.Cylinder, cylShape.CollisionType); + Assert.Equal(2.0f, cylShape.Scale, 3); + Assert.Equal(0.800f, cylShape.Radius, 3); // 0.40 * 2 + Assert.Equal(2.400f, cylShape.CylHeight, 3); // 1.20 * 2 + Assert.Equal(0.200f, cylShape.LocalPosition.X, 3); + Assert.Equal(0.400f, cylShape.LocalPosition.Y, 3); + Assert.Equal(1.200f, cylShape.LocalPosition.Z, 3); } [Fact]