diff --git a/docs/ISSUES.md b/docs/ISSUES.md index d1e2de8b..338ee30e 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,42 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #330 — The headless host registers no live-entity collision at all: a bot walks through every NPC and every server-spawned object + +**Status:** OPEN +**Severity:** HIGH for headless gameplay fidelity; zero impact on the graphical client. +**Filed:** 2026-08-06, from the AP-22 deletion's blast-radius survey (§5 of +[`docs/research/2026-08-06-ap22-contract.md`](research/2026-08-06-ap22-contract.md)). + +`ShadowShapeBuilder.FromSetup` — the only producer of live-entity collision +shapes — has exactly **one** production caller, +`src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`, which lives in +`AcDream.App`. `AcDream.Headless` references `AcDream.Runtime` and +`AcDream.Content`, never `AcDream.App`, so no headless code path ever builds or +registers a collision shape for a server weenie. `HeadlessSessionHost.cs:640` +additionally pins the local player to +`RuntimeLocalPlayerShadowDisposition.ProvenShapeless`. + +Consequence: a headless bot has landblock **static** collision (published +through `LandblockPhysicsContentBuilder.PublishStaticCollision`, which the +headless world projection does call) but no collision against creatures, NPCs, +players, or any other server-spawned object. It walks straight through all of +them. Retail collides against every cell-resident object with a shape. + +This is a **pre-existing gap**, not introduced or widened by the AP-22 +deletion — AP-22 removed a branch that was unreachable for all 5,935 installed +Setups, so it changed no registered shape on either host. It is filed +separately because the AP-22 survey is what established it and because nothing +currently tracks it. Checked at filing time: **#291** is a different thing (the +headless 3x3 collision *window* wanting a divergence-register row), and no +register row covers this. + +Not a one-to-two-commit change: closing it means giving Runtime or Content +ownership of live-entity shape construction (today an App concern), which +overlaps the Slice-J ownership work. If it is ever *accepted* rather than +fixed, it needs a divergence-register row; it is filed here as a defect +because the intent is to fix it. + ## #329 — The portal wait cue arms five seconds late; retail emits it per tunnel rotation segment, unconditionally **Status:** OPEN diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 0132a182..d375945c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -160,7 +160,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 105 active rows (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) — 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) 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 @@ -178,6 +178,7 @@ 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-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 | @@ -196,7 +197,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 | | AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 | | AP-21 | Entity translucency retains the invented α<0.05 fragment discard. World GfxObj/Setup instances now apply their DAT AlphaBlend/Additive/InvAlpha factors through the retail shared alpha queue, but sealed off-screen WbDrawDispatcher consumers (paperdoll/UI Studio) retain the old immediate normal-alpha pass for all three kinds | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`DrawDeferredAlphaBatch` versus immediate Phase 8) | World presentation needed exact per-surface blend for spell/particle density and translucent-object intersections. Off-screen object previews are isolated render targets and have not shown an authored additive entity surface that justifies splitting their compact immediate pass | A faint world fringe below 5% alpha is discarded; a hypothetical additive/inverse-alpha paperdoll or UI Studio entity composites darker than retail inside that private viewport | `D3DPolyRender::SetSurface`; `D3DPolyRender::RenderMeshSubset`; SurfaceType.Additive → D3DBLEND_ONE | -| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 | +| ~~AP-22~~ | **RETIRED 2026-08-06.** The invented cylinder is deleted, not re-derived: retail synthesizes NO shape for a shapeless object. `CPhysicsObj::FindObjCollisions` @0x0050f050 dispatches EXCLUSIVELY — BSP xor CylSphere xor Sphere xor nothing — and with zero cylspheres, zero spheres and no physics BSP it branches straight to the epilogue at `0x0050f22f je 0x50f31b`, returning the `OK_TS` seeded at `0x0050f13b mov edi,1`. Byte-verified against the PDB-paired binary (`9e847e2f-777c-4bd9-886c-22256bb87f32`), disassembled independently rather than read from the Binary Ninja text, whose `ebp_1` aliasing in this function is visibly corrupt. **`CPartArray::GetRadius` (0x005180a0) and `GetHeight` (0x005180b0) are absent from the function's entire call set** (which is exactly `GetNumCylsphere` 0x518080, `GetCylsphere` 0x518090, `GetNumSphere` 0x518060, `GetSphere` 0x518070, `CCylSphere::intersects_sphere` 0x53b8f0, `CSphere::intersects_sphere` 0x537fd0, `OBJECTINFO::missile_ignore` 0x50ceb0, `CPartArray::FindObjCollisions` 0x518180, `COLLISIONINFO::add_object` 0x6b4e20) — `Setup.Radius`/`Height` serve attack cones, `cylinder_distance` and MoveTo, never collision geometry; that consumer (`LiveEntityMotionRuntimeController.GetSetupCylinder`) is retail-faithful and untouched. **The row's site list was incomplete and partly wrong**: it named `ShadowShapeBuilder.cs`, which never reads `Setup.Radius` at all, and omitted TWO real copies — including `LandblockPhysicsContentBuilder`, the ONLY one the headless host executes. Fixing just the cited site would have left headless statics on the invented footprint. All three are deleted. **The row's risk statement was also stale**: it described a live approximation over "rare decorative props", but the branch was unreachable dead code. A sweep of all 5,935 Setups in the installed `client_portal.dat` — validated by byte accounting (5,935/5,935 records consumed with an exact `20 + 48*numLights` residual tail and zero unexplained bytes) and reproduced independently by the production `FlatCollisionAssetBuilder.FlattenSetup` path — finds **0** Setups satisfying the guard: every Setup with `Radius > 0.0001` carries at least one CylSphere or Sphere, and all 1,294 genuinely shapeless Setups have `Radius` exactly 0. Buckets: 678 with ≥1 CylSphere, 3,605 with 0 CylSpheres and ≥1 Sphere, 358 with no primitive but ≥1 physics-BSP part, 1,294 shapeless, 4,282 with `Radius > 0.0001`. Nothing loses collision, because nothing gained it; no visual gate is required. Pinned by `InstalledSetupCollisionReachabilityTests` (negative claim plus five external positive controls so a broken enumeration cannot pass it vacuously — sabotage-verified: inverting the claim reddens it, and an emptied enumeration fails on the controls at `0 != 5935` rather than passing) and by `ShapelessSetupWithRadius_ProducesNoRegistration`, whose sabotage (restoring the deleted block) reddens exactly that fact. The one prior test pinning the fallback built a DAT-impossible Setup; its live state/flag/seed-cell assertions were re-hosted onto a CylSphere fixture rather than deleted, and sabotage-verified in both directions. Evidence: `docs/research/2026-08-06-ap22-contract.md`. | `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs` (`Build`); `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs` (`PublishStaticEntity`); `src/AcDream.Content/LandblockPhysicsContentBuilder.cs` (`PublishStaticCollision`, headless-only); `tests/AcDream.Content.Tests/InstalledSetupCollisionReachabilityTests.cs`; `tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs` | — | — | `CPhysicsObj::FindObjCollisions` 0x0050f050 (OK_TS seed 0x0050f13b; BSP dispatch `test …,0x10000` 0x0050f165 / `je` 0x0050f16f; BSP-branch exit `jmp` 0x0050f19d; zero-spheres exit `je 0x50f31b` 0x0050f22f; epilogue 0x0050f31b); `CPartArray::GetRadius` 0x005180a0 and `GetHeight` 0x005180b0 (absent from that call set); consumers `CPhysicsObj::check_attack` 0x0050ec80, `get_distance_to_object` 0x0050f7a0 | | AP-23 | Invented per-type pickup-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating plus the speculative local TurnToObject/MoveToObject install through the player's MoveToManager. **R5-V3 narrowed it:** the install threads the target's real Setup radius/height (`GetSetupCylinder`, same as wire mt-6) and the player's real radius; only the radius buckets remain invented. **Use retired from this seam 2026-07-25** and now sends immediately. | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`TryGetApproach`/`GetUseRadius`); `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs` (`BeginApproach`) | The retained pickup presentation reserves a destination slot before the authoritative transfer; its close branch still needs an arrival boundary | A target whose real UseRadius differs from the bucket misjudges the pickup gate — pickup waits forever or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b | | ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs`; `src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` | | AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 | diff --git a/docs/research/2026-08-06-ap22-contract.md b/docs/research/2026-08-06-ap22-contract.md new file mode 100644 index 00000000..cc542e3b --- /dev/null +++ b/docs/research/2026-08-06-ap22-contract.md @@ -0,0 +1,736 @@ +# AP-22 contract — the invented `setup.Radius` collision cylinder + +**Status:** authored 2026-08-06, planning only. No production or test code +written; no commit made. +**Worktree:** `.claude/worktrees/peaceful-visvesvaraya-e0a196`, +branch `claude/acdream-physics-divergence-5aa784`, base HEAD `bcb66ccd`. +**Register row:** AP-22 (`docs/architecture/retail-divergence-register.md:199`). + +--- + +## 1. Verdict + +**Retail synthesizes no shape for a shapeless object. It simply does not +collide.** There is no `setup->radius` fallback, no default cylinder, no +default sphere anywhere in `CPhysicsObj::FindObjCollisions`. A shapeless +object is still inserted into cells (so it is in every candidate list), and +then contributes `OK_TS` — no block, no slide, no contact. + +**The fix is deletion.** Not a corrected derivation, not a better height +formula. + +**And the deletion is behaviorally inert.** A DAT sweep of all 5,935 Setups +in the installed `client_portal.dat` found **zero** Setups that can reach the +fallback branch — in any of its three copies. Every Setup with +`Radius > 0.0001` carries at least one CylSphere or Sphere, so the guard +`shapes.Count == 0 && setup.Radius > 0.0001f` is unsatisfiable against retail +data. The branch is provably dead code, not a live approximation. + +That changes the shape of the gate. The register row's stated risk ("rare +decorative props collide with an invented footprint") is **not what is +happening** — those props do not exist. Nothing loses collision on deletion, +because nothing gained it. The gate is therefore a *reachability proof*, not +a before/after behavior comparison, and it is cheap and strong. + +Three things found stale or false at HEAD are in §11; one of them +(a genuine, unregistered, 172-Setup divergence at the same file) is the real +finding of this investigation and is spun off, not folded in. + +--- + +## 2. What retail does — byte-verified + +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` reports +`=== MATCH: this exe pairs with our acclient.pdb ===`. Image base `0x00400000`. +Disassembly below is from that binary; pseudo-C line numbers are into +`docs/research/named-retail/acclient_2013_pseudo_c.txt`. + +### 2.1 The dispatch is exclusive, and it is a three-way XOR plus "nothing" + +`CPhysicsObj::FindObjCollisions` @ `0x0050f050` (pc:276776). +`edi` holds the result and is seeded `OK_TS` at `0x0050f13b` +(`mov edi, 1` — `OK_TS == 1`, confirmed by the early-out at `0x0050f083` +`mov eax, 1` and by `CPartArray::FindObjCollisions`'s `result = OK_TS` +initializer at pc:286241). + +``` +0050f165 f786a800000000000100 test dword ptr [esi + 0xa8], 0x10000 ; state & HAS_PHYSICS_BSP_PS +0050f16f 7431 je 0x50f1a2 ; not set -> CylSphere/Sphere path +0050f171 85ed test ebp, ebp +0050f173 752d jne 0x50f1a2 ; player-passthrough -> CylSphere/Sphere path +0050f175 56 push esi +0050f176 8bcb mov ecx, ebx +0050f178 e833ddffff call 0x50ceb0 ; OBJECTINFO::missile_ignore +0050f17d 85c0 test eax, eax +0050f17f 7521 jne 0x50f1a2 ; ignore -> CylSphere/Sphere path +0050f181 8b4e10 mov ecx, dword ptr [esi + 0x10] ; this->part_array +0050f184 85c9 test ecx, ecx +0050f186 0f848f010000 je 0x50f31b ; null -> return edi (OK_TS) +0050f18d e8ee8f0000 call 0x518180 ; CPartArray::FindObjCollisions (BSP walk) +0050f192 8bf8 mov edi, eax +0050f194 83ff01 cmp edi, 1 +0050f197 0f847e010000 je 0x50f31b ; OK -> return +0050f19d e90e010000 jmp 0x50f2b0 ; not OK -> COLLISIONINFO bookkeeping, then return +``` + +`0x0050f19d` is an unconditional `jmp` past `0x0050f1a2`. **The BSP branch +never reaches the primitive branches.** Not "prefers"; cannot reach. + +The CylSphere branch, `0x0050f1a2`: + +``` +0050f1a2 8b4e10 mov ecx,[esi+0x10] ; part_array +0050f1a5 85c9 test ecx, ecx +0050f1a7 7474 je 0x50f21d ; null -> Sphere path +0050f1a9 e8d28e00 call 0x518080 ; CPartArray::GetNumCylsphere (= setup->num_cylsphere) +0050f1ae 85c0 test eax, eax +0050f1b0 746b je 0x50f21d ; zero -> Sphere path +... +0050f1c9 0f844801 je 0x50f317 ; loop exhausted -> 0x50f31b, RETURN +0050f1d6 0f833b01 jae 0x50f317 ; loop exhausted -> 0x50f31b, RETURN +``` + +A CylSphere-bearing object that survives its whole CylSphere loop **returns**; +it never falls through to the Sphere loop. `ShadowShapeBuilder`'s step-2 gate +(`if (setup.CylSpheres.Count == 0)`) is correct and matches this. + +The Sphere branch, `0x0050f21d`: + +``` +0050f21d 8b4e10 mov ecx,[esi+0x10] +0050f220 85c9 test ecx, ecx +0050f222 0f84f300 je 0x50f31b ; null part array -> RETURN edi (OK_TS) +0050f228 e8338e00 call 0x518060 ; CPartArray::GetNumSphere (= setup->num_sphere) +0050f22d 85c0 test eax, eax +0050f22f 0f84e600 je 0x50f31b ; ZERO SPHERES -> RETURN edi (OK_TS) +``` + +`0x0050f22f` is the decisive instruction. Zero cylspheres, zero spheres, no +physics BSP ⇒ jump straight to the epilogue at `0x0050f31b`: + +``` +0050f31b 8bc7 mov eax, edi ; still OK_TS +0050f31e c783cc01000000000000 mov dword ptr [ebx+0x1cc], 0 ; obstruction_ethereal = 0 +0050f32e c20400 ret 4 +``` + +**No fallback shape is constructed.** The complete call set of this function +is `GetNumCylsphere` `0x518080`, `GetCylsphere` `0x518090`, `GetNumSphere` +`0x518060`, `GetSphere` `0x518070`, `CCylSphere::intersects_sphere` `0x53b8f0`, +`CSphere::intersects_sphere` `0x537fd0`, `OBJECTINFO::missile_ignore` +`0x50ceb0`, `CPartArray::FindObjCollisions` `0x518180`, +`COLLISIONINFO::add_object` `0x6b4e20`. **`CPartArray::GetRadius` (`0x5180a0`) +and `GetHeight` (`0x5180b0`) are not among them.** + +### 2.2 What `setup->radius` / `setup->height` are actually for in retail + +They exist and are read — just never for collision geometry. +`CPartArray::GetRadius` @ `0x005180a0` (pc:286138) returns +`setup->radius * this->scale`; `GetHeight` @ `0x005180b0` likewise. Their +consumers: + +| Retail site | Use | +|---|---| +| `CPhysicsObj::check_attack` `0x0050ec80` (pc:276549) | `CSphere::attack` cone radius/height | +| `CPhysicsObj::get_distance_to_object` `0x0050f7a0` (pc:277387) | `Position::cylinder_distance` | +| `CPhysicsObj::…` `0x005127e0` (pc:280583/280637) | parent-relative distance / MoveTo setup | + +acdream mirrors this correctly at +`src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs:284` +(`GetSetupCylinder` → wire `MoveToObject` radius/height). **That site is +retail-faithful and must not be touched by this change.** + +### 2.3 A shapeless object is still cell-resident + +`CPhysicsObj::calc_cross_cells` @ `0x00515230` (pc:283330) and +`calc_cross_cells_static` @ `0x00515160` (pc:283280) both fall back to +`CPartArray::GetSortingSphere` → `CObjCell::find_cell_list` when there are no +cylspheres. So the object is in the cell's object list and *is* visited by +`CObjCell::find_obj_collisions` (`0x0052b750`, pc:308916) — it just returns +`OK_TS`. Retail's "no collision" is an empty shape walk, not an absence from +the world. acdream's equivalent (`Build` returns `null`, nothing registered in +`ShadowObjectRegistry`) is observationally identical: the registry is a +collision structure only. + +### 2.4 The dispatch flag is client-derived, exactly like ours + +`HAS_PHYSICS_BSP_PS` (`0x10000`) is not taken on faith from the wire. +`CPhysicsObj::CacheHasPhysicsBSP` @ `0x0050f570` (pc:277205) sets or clears it +from `CPartArray::CacheHasPhysicsBSP` @ `0x00518110` (pc:286198), which walks +the parts and looks for **any** part whose `gfxobj->physics_bsp != 0`. It is +called from `CPhysicsObj::InitPartArrayObject` @ `0x005126b0` (call at +`0x0051272e`). acdream's `_hasPhysicsBsp(gfxId)` predicate +(`LiveEntityCollisionBuilder.cs:46`) is the faithful equivalent of that +derivation. + +`CPhysicsPart::find_obj_collisions` @ `0x0050d8d0` (pc:275045) additionally +guards `gfxobj->physics_bsp != 0` per part, so a BSP-less part inside a +BSP-bearing part array contributes nothing. `ShadowShapeBuilder` step 3 +(`if (!hasPhysicsBsp(gfxId)) continue;`) matches. + +--- + +## 3. Reachability — how rare is "rare decorative props"? + +**Answer: not rare. Nonexistent. Zero of 5,935.** + +### 3.1 Method + +Read-only scratchpad parse of the user's installed +`%USERPROFILE%\Documents\Asheron's Call\client_portal.dat` (B-tree directory +walk + block-chain file reads + Setup record parse), no repo code written. + +Parse validation, three independent checks: + +1. **Byte accounting.** All 5,935 Setup records parsed with an exact + residual tail of `20 + 48 × numLights` bytes and **zero** unexplained + bytes. A wrong field offset anywhere upstream would desynchronize the tail + on essentially every record; 5,935/5,935 clean is not achievable by luck. +2. **Cross-check against the in-repo tool.** + `dotnet run --project tools/SetupInspect -- 0x02000001` reports + `Radius/Height = 0.679 / 1.835`, `StepUp/StepDown = 0.600 / 1.500`, + `Spheres = 2`, `CylSpheres = 0`, `Parts = 34`. The sweep reports the same + numbers bit for bit. +3. **Physics-BSP flag hypothesis validated on known cases.** `GfxObjFlags` + bit `0x01` (`HasPhysics`, the name our own test fixtures use at + `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs:1126`) + was validated against three documented cases: the Facility Hub door Setup + `0x02000C9D` — parts `0x0B/0x0B/0x0A`, BSP-bearing, matching the #175 + door work; the cottage door `0x020019FF` — part[0] `0x0B`, matching the + 2026-05-24 door-collision handoff; and the human Setup `0x02000001` — all + 34 parts `0x0A`, no physics BSP, colliding via body Spheres, matching + `TransitionTypes.cs:4264`'s documented humanoid path. + +### 3.2 Results (all 5,935 Setups in `client_portal.dat`) + +| Bucket | Count | Share | +|---|---|---| +| ≥1 CylSphere (retail: CylSphere branch) | 678 | 11.4% | +| 0 CylSpheres, ≥1 Sphere (retail: Sphere branch) | 3,605 | 60.7% | +| 0 primitives, ≥1 physics-BSP part (retail: BSP branch) | 358 | 6.0% | +| **Shapeless** — 0 CylSpheres, 0 Spheres, 0 physics-BSP parts | **1,294** | **21.8%** | +| — of those, `Radius > 0.0001` (**fallback fires**) | **0** | **0%** | +| Setups with `Radius > 0.0001` overall | 4,282 | 72.1% | +| Setups with `Radius > 0.0001` **and** no CylSphere and no Sphere | **0** | **0%** | +| Setups whose only primitives all have `radius <= 0` | 0 | 0% | + +**Every one of the 1,294 shapeless Setups has `Radius` exactly 0.** +Authoring correlates the summary radius with the presence of collision +primitives, so the fallback's own precondition never holds. + +The static-path variant of the gate +(`Cylinders.Length == 0 && Spheres.Length == 0 && Radius > 0f`) returns the +same **0**, because `FlatCollisionAssetBuilder.FlattenSetup` +(`src/AcDream.Core/Physics/FlatCollisionAssetBuilder.cs:248,287`) is a +bit-exact DAT pass-through with no filtering and no synthesis. + +### 3.3 The two escape hatches, both closed + +- **A GfxObj-sourced entity** (`SourceGfxObjOrSetupId` with a `0x01` prefix) + cannot synthesize a Setup with a nonzero radius. Live: + `DatLiveEntityProjectionMaterializer.cs:188` does + `_dats.Get(spawn.SetupTableId.Value)`; a non-Setup id yields `null` + and the entity is **dropped** (`_missingSetup++`, `:193`) — we have no + `CSetup::makeSimpleSetup` (`0x00520090`, pc:295344) equivalent at all. + Static: `cache.GetFlatSetup(0x01…)` misses and the entity records + `noCollision`. Either way `Radius` is never fabricated. +- **`effectivePartGfxObjIds` part swaps** (retail `AnimPartChanged`) can only + empty step 3 of the walk. They cannot remove a Setup's CylSpheres or + Spheres, so they cannot manufacture the `shapes.Count == 0` **and** + `Radius > 0` combination. + +### 3.4 What this means for the "walking through a prop" concern + +It does not arise. No entity in the installed DAT set is currently colliding +by way of the invented cylinder, so no entity stops colliding when it is +removed. The correct gate is a **reachability proof**, described in §7–§8, not +a prop-by-prop behavioral comparison. Confirming that positively (a real +connected run that observes zero executions of the branch) is still required — +see §8 — because the sweep proves a property of the DAT, and the gate must +prove the property of the running system. + +--- + +## 4. The exact change, by symbol + +### 4.1 Delete — three copies, not one + +| # | Symbol | Location at `bcb66ccd` | Host reach | +|---|---|---|---| +| 1 | `LiveEntityCollisionBuilder.Build` | `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:136-146` | graphical only | +| 2 | `LandblockPhysicsPublisher.PublishStaticEntity` | `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:1041-1058` | graphical only | +| 3 | `LandblockPhysicsContentBuilder.PublishStaticCollision` | `src/AcDream.Content/LandblockPhysicsContentBuilder.cs:694-707` | **headless only** | + +All three are the same eleven-line block: `if (no cylinders && no spheres && +Radius > 0) → add one Cylinder shape of radius `Radius × scale` and height +`(Height > 0 ? Height : Radius × 2) × scale``. Delete the `if` block in each. +In sites 2 and 3 the surrounding `setupShapes.Count == 0 → noCollision` +handling already exists and needs no edit. In site 1 the existing +`if (shapes.Count == 0 && !retainEmptyPayload) return null;` at `:148` already +handles the resulting case and needs no edit. + +### 4.2 Do not touch + +- `LiveEntityMotionRuntimeController.cs:284` (`GetSetupCylinder`) — retail-faithful, + see §2.2. +- `PhysicsDataCache.cs:392` (`Radius = setup.Radius`) and + `FlatSetupCollision.Radius` — the field is still consumed by the MoveTo path. +- `ShadowShapeBuilder.FromSetup` steps 1 and 2 — byte-verified correct in §2.1. +- `ShadowShapeBuilder.FromSetup` step 3's *additive* emission — real divergence, + but **out of scope**; see §11.3 and §12.2. + +### 4.3 Comment corrections in the same commit + +- `LiveEntityCollisionBuilder.cs:30-35` class doc — the phrase + "and the established Setup-radius fallback for the remaining ACE prop data" + describes the deleted branch and a data population that does not exist. +- `ShadowShapeBuilder.cs:25-30` — see §11.2; the retail claim in that + paragraph is false and must be corrected whether or not the exclusivity fix + lands, because a future reader will re-derive the additive design from it. + +--- + +## 5. Blast radius across both hosts + +This is where the C5b lesson bites and where the register row is wrong. +**A survey of the live-entity call graph alone misses two thirds of the +change.** + +``` +AcDream.Headless ─▶ AcDream.Runtime ─▶ AcDream.Content ─▶ AcDream.Core +AcDream.App ─▶ AcDream.Runtime, AcDream.Content, AcDream.Core +``` + +| Path | Producer | Graphical | Headless | +|---|---|---|---| +| Live server entities (weenies) | `LiveEntityCollisionBuilder.Build` → `ShadowShapeBuilder.FromSetup` | yes | **no — not registered at all** | +| Landblock statics, streaming | `LandblockPhysicsPublisher.PublishStaticEntity` | yes | no | +| Landblock statics, prepared content | `LandblockPhysicsContentBuilder.PublishStaticCollision` | no | **yes** (`HeadlessSessionWorldProjection.cs:461`) | + +Verified call sites: `LandblockPhysicsContentBuilder.PublishStaticCollision` +has exactly one caller, `HeadlessSessionWorldProjection.cs:461`; +`src/AcDream.App/Streaming/LandblockBuildFactory.cs` calls only +`BuildPreparedCollisionClosure` / `HydrateStaticEntities` / `BuildDatBundle` +from that class, never `PublishStaticCollision`. `ShadowShapeBuilder.FromSetup` +has exactly one production caller, `LiveEntityCollisionBuilder.cs:116`. + +Two consequences the implementer must carry: + +1. **Site 3 is only exercised by `AcDream.Headless.Tests` and by a native/WSL + headless run.** An App-only suite will stay green through a wrong edit + there. The suite matrix in §8 names both. +2. **Headless registers no live-entity collision at all today** (no + `ShadowShapeBuilder.FromSetup` caller in `Runtime`/`Headless`; + `HeadlessSessionHost.cs:640` even pins the local player to + `RuntimeLocalPlayerShadowDisposition.ProvenShapeless`). That is a + pre-existing gap, **not** created or widened by this change, and **not** in + scope. Note it; do not fix it here. If it is not already registered, file + it as its own row — a headless bot currently walks through every NPC. + +`RuntimeRemotePhysicsUpdater.cs:478` calls `RegisterMultiPart` but only to +re-publish an already-built shape list at a resolved pose; it constructs no +shapes and is unaffected. + +--- + +## 6. Proof obligations + +The commit is not complete until each of these is discharged with the named +evidence attached to the commit message or the closeout note. + +| # | Obligation | Evidence | +|---|---|---| +| P1 | Retail synthesizes no shape for a shapeless object | §2.1 disassembly, reproduced in the commit message: `0x0050f22f je 0x50f31b` and the `CPartArray::GetRadius`-absent call set | +| P2 | The deleted branch is unreachable for every installed Setup | The DAT reachability test of §7.1, run and reported with its bucket counts | +| P3 | The deletion changes no registered shape for any entity | Diff of registered shape counts across a connected route, before vs after — §8.2 | +| P4 | All three copies are gone | `grep -rn "setup.Radius\|\.Radius > 0f" --include=*.cs src/` returns only `GetSetupCylinder`, `PhysicsDataCache.cs:392`, and non-collision hits | +| P5 | Headless is exercised, not merely compiled | `AcDream.Headless.Tests` green **and** a headless connected run — §8.3 | +| P6 | The behaviors the deleted test covered are still covered | The re-hosted test of §7.2, with its sabotage result | +| P7 | AP-22 is retired, and the three site citations are corrected in the retirement text so the record is not wrong twice | Register diff in the same commit | + +--- + +## 7. Test plan + +Design rule for every item: **state the sabotage that must redden it, and run +that sabotage.** Four green-but-empty tests shipped in this campaign already +(C5b's D3 conservation test, #276's three settler tests, #280's tautological +integration test, the atlas-tier seam 4,170 tests missed). Assume each test +below discriminates nothing until the sabotage run proves otherwise. + +### 7.1 NEW — installed-DAT reachability (the load-bearing test) + +Where: `tests/AcDream.Content.Tests` (Content is the lowest layer that owns +the flattened Setup shape and is reachable from both hosts) or, if the suite's +installed-DAT convention lives elsewhere, wherever the I2/I4 "representative +installed DATs" tests live. Skip cleanly when `ACDREAM_DAT_DIR` is absent, +matching the existing installed-DAT convention. + +Enumerate every Setup in `client_portal.dat`, flatten with +`FlatCollisionAssetBuilder.FlattenSetup`, and assert: + +- **(a) the negative claim:** zero Setups satisfy + `Cylinders.Length == 0 && Spheres.Length == 0 && Radius > 0.0001f`. +- **(b) positive controls, so (a) cannot pass vacuously:** the enumeration + yielded 5,935 Setups; 678 have ≥1 Cylinder; 3,605 have 0 Cylinders and ≥1 + Sphere; 1,294 have neither; 4,282 have `Radius > 0.0001f`. + +(b) is not decoration. It is the entire reason the test discriminates: a +broken enumerator, a wrong dat path, or a silently-empty flatten all satisfy +(a) trivially and are caught only by (b). This is exactly the failure the +atlas-tier seam commit (`bcb66ccd`) was written to close. + +Do not assert the 358 physics-BSP-part bucket here — that requires GfxObj +resolution and belongs to §7.3 if wanted at all. + +**Sabotage (must run both):** +- Invert (a) to `> 0` expected: must fail on assertion (a). +- Point the enumeration at an empty id set: must fail on assertion (b) with + `0 != 5935`, *not* pass (a). + +**Trap:** do not re-derive the bucket numbers from the same predicate the +production code uses and then assert they agree — that is a tautology. The +numbers in (b) are external constants measured in §3.2 and must be written as +literals. + +### 7.2 REPLACE — `LiveEntityCollisionBuilderTests.RadiusFallback_UsesExactStateFlagsScaleAndFullSeedCell` + +`tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs:28-58`. +This test constructs `new Setup { Radius = 0.75f, Height = 2.5f }` with zero +primitives — a Setup that **cannot exist in the DAT** (§3.2). It is the only +test in the tree that pins the fallback, and it will fail on deletion. + +Do **not** simply delete it. Beyond the fallback shape it asserts real, +still-live behavior: `registration.State` == `record.FinalPhysicsState`, +`HasWeenie`/`IsCreature`/`IsPlayer`/`IsPK` flag decode from the PWD bitfield +`0x28`, `LandblockId` and `SeedCellId` both the full cell, and +`WorldOffsetX/Y` pass-through. Re-host those assertions on a DAT-possible +fixture — a Setup with one CylSphere (`Radius 0.4`, `Height 1.2`, the shape +`LandblockPhysicsPublisherTests` already uses) — and rename accordingly +(`Build_PropagatesExactStateFlagsScaleAndFullSeedCell`). + +**Sabotage:** flip one flag bit in `EntityCollisionFlagsExt.FromPwdBitfield`, +and separately swap `SeedCellId` for the landblock id at the construction +site. Each must redden this test. If neither does, the re-host lost the +coverage and the test is decoration. + +### 7.3 NEW — the deleted-branch shape assertion (cheap, keeps the claim honest) + +One fact in the same App test class: a Setup with `Radius = 0.75f`, +`Height = 2.5f` and **no** primitives and **no** BSP parts produces +`Build(...) == null` — no registration, no cylinder. + +This is the direct inverse of the deleted test and states the retail rule in +acdream's own vocabulary. It sits beside the existing +`ShapelessSetup_ProducesNoRegistration` (`:275`), which today passes only +because its `new Setup()` has `Radius == 0`; the new fact is the one that +would have failed before the deletion. + +**Sabotage:** restore the deleted `if` block — this fact must fail. This is +the only sabotage in the plan that directly re-proves the production change, +so it must be run. + +### 7.4 Static-path coverage + +`LandblockPhysicsPublisherTests` and the Content-side static tests use +CylSphere fixtures throughout and pin nothing about the fallback (checked: +`Radius = 0.4f` at `:228`/`:1098` are CylSphere radii, `Radius = 10f`/`1.25f` +are BSP bounding spheres). **No static test should need editing.** If one +turns red, that is a signal the deletion in site 2 or 3 removed more than the +`if` block — stop and re-read the diff rather than adjusting the test. + +--- + +## 8. Gates + +### 8.1 Suite matrix (both hosts — the C5b lesson) + +- `dotnet build -c Release` green. +- `AcDream.Core.Tests`, `AcDream.Content.Tests`, `AcDream.App.Tests`, + `AcDream.Runtime.Tests`, **`AcDream.Headless.Tests`** — the last is the one + that covers site 3 and is the one a habitual App-only run skips. +- Complete Release solution suite at the expected count for `bcb66ccd` + (11,090 passed / 4 skipped / 0 failed, adjusted for the one replaced and two + added facts). + +### 8.2 Connected graphical route — **positive evidence, not absence of signal** + +Batch into the next connected session. Absence-of-crash is not a criterion. +Run the canonical nine-stop route +(`tools/connected-dense-town.route.txt` / the world-lifecycle route) twice on +the same binary, once at `bcb66ccd` and once with the change, with +`ACDREAM_PROBE_BUILDING=1` so `LiveEntityCollisionBuilder.Register`'s +`[entity-source]` line (`:202`) is emitted, and diff: + +- the multiset of `(entityId, src, shapes=cylN+bspN)` tuples must be + **identical** between the two runs. Not "no errors" — byte-identical + registration inventory. That is the positive statement that no entity lost + or changed a shape. +- registered-owner counts per landblock (`setupOwners`/`bspOwners`/ + `noCollision` from `PublishStaticCollision`, and the publisher's equivalent) + must be identical. + +**Known probe gap, must be handled:** the `[entity-source]` line reports +`shapes=cylN+bspN` and classifies the fallback cylinder as an ordinary +`Cylinder`, indistinguishable from a CylSphere-derived one. As-is it cannot +tell you the fallback fired. Since §3 proves the count is zero either way the +identical-multiset diff is still decisive, but if the implementer wants direct +evidence, add a temporary `cause=` tag at the branch before deleting it, run +one route, observe zero, then delete tag and branch together. Do not ship the +tag. + +### 8.3 Connected headless route + +Native Linux or WSL headless single-session run through the four-stop portal +route (the K1/K3 gate). Positive criterion: static-collision publication +counts per landblock identical to a pre-change run, graceful ACE-confirmed +logout, terminal ownership ledger at zero. + +### 8.4 User visual gate + +**Not required.** No registered shape changes, so there is nothing for the +user to look at. Do not spend a connected session's visual budget on this; +spend it on the AP-152 slice (§12.2), which does change 172 Setups' shapes and +does need eyes. + +--- + +## 9. Traps + +1. **Deleting only the site the register row cites.** The row names + `LiveEntityCollisionBuilder.cs` and `ShadowShapeBuilder.cs`. The real + population is three copies, and the one the row does not mention at all + (`LandblockPhysicsContentBuilder`) is the **only** one headless executes. +2. **Assuming `ShadowShapeBuilder.cs` contains the fallback.** It does not — + the row's site list is wrong about which file (§11.1). Grep by symbol + before editing. +3. **Treating this as a behavior change and over-gating it.** It is dead-code + removal. The temptation to run a full two-client matrix wastes a connected + session; the reachability test is the stronger evidence and costs minutes. +4. **The inverse trap: treating "it's dead code" as licence to skip the + headless suite.** Site 3 compiles in App builds and runs only in headless. +5. **Deleting `setup.Radius` plumbing.** `FlatSetupCollision.Radius`, + `PhysicsDataCache.cs:392` and `GetSetupCylinder` are live and + retail-faithful. Only the *collision-shape* consumer dies. +6. **Deleting the fallback test outright** and silently dropping the state / + flag / seed-cell coverage riding on it (§7.2). +7. **Folding in the exclusivity fix** because it is "right there in the same + function." It changes 172 Setups including doors, needs its own visual + gate, and would destroy this commit's zero-risk profile (§12.2). +8. **Trusting the Binary Ninja text for the dispatch polarity.** BN renders + the branch as `if ((state & 0x10000) == 0 || ebp_1 != 0 || eax_12 != 0)` + with the *primitive* path in the `then` and the *BSP* path in the `else` — + readable, but easy to invert when skimming, and BN's `ebp_1` aliasing in + this function is visibly corrupt (`ebp_1 = &ebp_1->object_info.ethereal` + at pc:276891 is nonsense). Cite the disassembly, not the pseudo-C, for any + claim about which branch runs. +9. **`OK_TS` is 1, not 0.** Two independent confirmations in §2.1. A reader who + assumes 0 will misread `cmp edi, 1 / je` as an error path. + +--- + +## 10. Size and split + +**Size:** small. ~33 production lines deleted across 3 files, 1 test replaced, +2 test facts added, 1 new installed-DAT test (~60 lines), 2 doc comments +corrected, 1 register row retired + 1 filed. **One commit.** + +**Split call: land AP-22 alone.** Do not bundle: + +- **AP-152 (§12.2, the exclusivity divergence)** — separate slice. It is a + live behavior change on 172 Setups including every BSP door, it needs a + connected visual gate, and bundling it would mean the AP-22 retirement's + evidence no longer says "nothing changed." +- **The headless live-entity collision gap (§5)** — separate, larger, and + likely already tracked. +- **`makeSimpleSetup` parity** (retail builds a one-part Setup for a GfxObj + id; we drop the entity — §3.3) — separate, needs a look at whether ACE ever + sends one. + +--- + +## 11. Claims found false or stale at HEAD + +Numbered, as required. Every one was verified against source or the binary, +not inherited. + +### 11.1 The register row's site list is incomplete and partly wrong — **FALSE** + +Row AP-22 cites +`src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; +`src/AcDream.Core/Physics/ShadowShapeBuilder.cs`. + +- `ShadowShapeBuilder.cs` **does not contain the invented cylinder.** Grep for + `setup.Radius` in that file returns nothing; the class never reads + `Setup.Radius` or `Setup.Height` at all. It is cited as the *mitigation*, + and the site column absorbed it. +- Two real sites are missing: + `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs:1041` and + `src/AcDream.Content/LandblockPhysicsContentBuilder.cs:694`. The second is + the headless-only copy. A reader following the row would have shipped a fix + that leaves headless statics on the invented footprint. + +The retirement text must state the corrected three-site list, or the record is +wrong in both directions. + +### 11.2 `ShadowShapeBuilder`'s retail anchor comment is factually wrong — **FALSE** + +`src/AcDream.Core/Physics/ShadowShapeBuilder.cs:25-30`: + +> `CPhysicsObj::FindObjCollisions` calls `CPartArray::FindObjCollisions` which +> iterates parts; each part's `find_obj_collisions` tests CylSpheres + GfxObj BSP. + +Two errors. + +- **`CPhysicsPart::find_obj_collisions` (`0x0050d8d0`, pc:275045) tests only + the GfxObj BSP.** Its 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: CylSpheres are a **Setup**-level array, reached via + `CPartArray::GetCylsphere` → `this->setup->cylsphere` (`0x00518090`, + pc:286130). Parts have no cylsphere member. +- **`CPhysicsObj::FindObjCollisions` does not "call `CPartArray::FindObjCollisions` + which iterates parts" as one leg of a combined walk.** It calls it in the + branch it takes *instead of* the primitive walk (§2.1). + +This comment is the written justification for the additive emission in 11.3, +so it must be corrected in this commit regardless of whether 11.3 is fixed +here. + +### 11.3 The row's mitigation claim — "ShadowShapeBuilder (the faithful walk)" — is **STALE**, and the real divergence is bigger than AP-22 + +`ShadowShapeBuilder.FromSetup` emits CylSpheres (step 1) **and** Spheres when +there are no CylSpheres (step 2) **and** every physics-BSP part (step 3), +**additively**. Retail's walk is exclusive: BSP **xor** CylSphere **xor** +Sphere **xor** nothing (§2.1, byte-verified). + +Measured impact: **172 of 5,935 Setups (2.9%)** carry a primitive *and* at +least one physics-BSP part — 73 CylSphere+BSP, 99 Sphere+BSP. Every one of +them registers an extra collision primitive retail never tests. This is not a +tail case: the cottage door `0x020019FF` is in the set, and +`ShadowShapeBuilderTests.FromSetup_DoorSetup_ProducesFourShapes` +(`tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs:51`) **pins the +additive behavior as intended** — 1 Sphere + 3 BSP shapes where retail would +register the BSP walk alone. + +Note also the internal inconsistency: acdream's two **static** paths already +implement the exclusive rule (`LandblockPhysicsPublisher` gates the Setup walk +on `entityBspCount == 0`; `LandblockPhysicsContentBuilder` `continue`s after +BSP shapes). Only the **live** path is additive. The same object registers a +different shape set depending on whether it arrived as a landblock static or a +server weenie. + +**Not fixed here.** Filed as AP-152, §12.2. + +### 11.4 The register row's risk statement is **STALE** + +> Those props collide with an invented footprint (especially the Radius×2 +> height guess) — slides/blocks at non-retail distances. + +No prop does. Zero of 5,935 Setups can reach the branch (§3.2). The risk as +written describes a live approximation; the reality is unreachable code. Both +the mitigation ("preserves prior behavior so rare decorative props don't lose +collision") and the risk are describing a population that does not exist. + +### 11.5 `LiveEntityCollisionBuilder`'s own class doc is **STALE** + +`:30-35` describes "the established Setup-radius fallback for the remaining +ACE prop data." There is no remaining ACE prop data with that shape. + +### 11.6 The only test pinning the fallback uses DAT-impossible data — **noted** + +`RadiusFallback_UsesExactStateFlagsScaleAndFullSeedCell` builds +`new Setup { Radius = 0.75f, Height = 2.5f }` with zero primitives. No such +Setup exists in `client_portal.dat`. The test is green and covers a code path +no real input reaches — the fifth instance of this campaign's recurring +pattern. §7.2 re-hosts its live assertions rather than deleting them. + +--- + +## 12. Register edits + +### 12.1 Retire AP-22 in the implementing commit + +Retirement text must carry: the three-site corrected list (§11.1); the +byte-level anchor (`CPhysicsObj::FindObjCollisions` `0x0050f050`, the +zero-spheres exit `0x0050f22f je 0x50f31b`, and the absence of +`CPartArray::GetRadius`/`GetHeight` from the call set); and the reachability +measurement (0 of 5,935 Setups, with the §3.2 bucket table as the positive +control). Cite this document. + +### 12.2 File AP-152 — live-path collision shape emission is additive, retail's is exclusive + +Draft row: + +- **Divergence:** `ShadowShapeBuilder.FromSetup` emits Setup primitives + **and** per-part physics-BSP shapes additively. Retail + `CPhysicsObj::FindObjCollisions` dispatches exclusively on + `HAS_PHYSICS_BSP_PS`: BSP walk **or** CylSpheres **or** Spheres **or** + nothing, never a union. Also internally inconsistent — acdream's two static + publication paths already implement the exclusive rule; only the live path + does not. +- **Sites:** `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` + step 3, unconditional); consumer + `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:116`. Exclusive + counterparts for comparison: + `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs` (`entityBspCount == 0` + gate), `src/AcDream.Content/LandblockPhysicsContentBuilder.cs` (BSP-then- + `continue`). +- **Mitigation:** 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; behaviour is pinned by tests and + has survived the #150/#175/#182 door work. +- **Risk:** 172 of 5,935 Setups (2.9%; 73 CylSphere+BSP, 99 Sphere+BSP) + register a collision primitive retail never tests — including BSP doors, + where the door's ~10 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. +- **Retail anchor:** `CPhysicsObj::FindObjCollisions` 0x0050f050 + (dispatch `0x0050f165 test …,0x10000` / `0x0050f16f je 0x50f1a2`; + BSP branch exit `0x0050f19d jmp 0x50f2b0`); + `CPhysicsPart::find_obj_collisions` 0x0050d8d0 (BSP only); + `CPartArray::CacheHasPhysicsBSP` 0x00518110; + `CPhysicsObj::CacheHasPhysicsBSP` 0x0050f570. + +### 12.3 Check before filing + +Confirm the headless live-entity collision gap (§5) is not already an open row +or issue. If it is not, file it — it is larger than either row above. + +--- + +## Appendix — reproducing the measurement + +The sweep was a throwaway scratchpad script, deliberately not committed. To +reproduce or to build §7.1's test, the Setup record layout confirmed against +5,935/5,935 records with zero residual bytes is: + +``` +u32 id +u32 flags +u32 numParts +u32 parts[numParts] +if (flags & 1) u32 parentIndex[numParts] +if (flags & 2) float3 defaultScale[numParts] +u32 numHoldingLocations ; each 36 bytes (u32 key, u32 partId, Frame{float3, float4}) +u32 numConnectionPoints ; each 36 bytes (same) +u32 numPlacementFrames ; each: u32 key, Frame[numParts] (28 bytes each), u32 numHooks +u32 numCylSpheres ; each 20 bytes (float3 origin, float radius, float height) +u32 numSpheres ; each 16 bytes (float3 origin, float radius) +float height, radius, stepUpHeight, stepDownHeight +Sphere sortingSphere (16), Sphere selectionSphere (16) +u32 numLights ; each 48 bytes +u32 defaultAnimation, defaultScript, defaultMotionTable, defaultSoundTable, defaultPhysicsScriptTable +``` + +Physics-BSP presence per part is `GfxObj.Flags & 0x01` +(`GfxObjFlags.HasPhysics`), read from bytes 4..7 of the GfxObj record. +Distribution across the 15,318 GfxObjs in `client_portal.dat`: +flags `0x02` ×10,546, `0x0A` ×3,514, `0x03` ×641, `0x0B` ×617 — so 1,258 +GfxObjs (8.2%) carry a physics BSP. + +Production code should of course use `FlatCollisionAssetBuilder.FlattenSetup` +and `PhysicsDataCache`, not this layout; it is recorded only so the numbers in +§3.2 are independently checkable. diff --git a/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs b/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs index 47d1903a..7e03ecae 100644 --- a/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs +++ b/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs @@ -29,10 +29,20 @@ internal sealed record LiveEntityCollisionRegistration( /// /// Ports the live-object collision-shape policy used by -/// CPartArray::FindObjCollisions: CylSpheres before Spheres, every -/// physics-BSP part, and the established Setup-radius fallback for the -/// remaining ACE prop data. +/// CPartArray::FindObjCollisions: CylSpheres before Spheres, and every +/// physics-BSP part. /// +/// +/// A Setup that yields no shape produces no registration. Retail synthesizes +/// nothing for a shapeless object: CPhysicsObj::FindObjCollisions +/// (0x0050f050) walks CylSpheres or Spheres or the physics BSP, and when +/// CPartArray::GetNumSphere returns zero it branches straight to the +/// epilogue (0x0050f22f je 0x50f31b) returning the seeded +/// OK_TS. CPartArray::GetRadius (0x005180a0) and +/// GetHeight (0x005180b0) are absent from that function's entire call +/// set — Setup.Radius/Height serve attack cones, +/// cylinder_distance, and MoveTo, never collision geometry. +/// internal sealed class LiveEntityCollisionBuilder { private readonly Func _hasPhysicsBsp; @@ -133,18 +143,6 @@ internal sealed class LiveEntityCollisionBuilder } } - if (shapes.Count == 0 && setup.Radius > 0.0001f) - { - shapes.Add(new ShadowShape( - GfxObjId: 0u, - LocalPosition: Vector3.Zero, - LocalRotation: Quaternion.Identity, - Scale: scale, - CollisionType: ShadowCollisionType.Cylinder, - Radius: setup.Radius * scale, - CylHeight: (setup.Height > 0f ? setup.Height : setup.Radius * 2f) * scale)); - } - if (shapes.Count == 0 && !retainEmptyPayload) return null; diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index 87cb5628..175e70fc 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -1038,24 +1038,9 @@ public sealed class LandblockPhysicsPublisher } } - if (setup.Cylinders.Length == 0 - && setup.Spheres.Length == 0 - && setup.Radius > 0f) - { - float radius = setup.Radius * scale; - float height = (setup.Height > 0f - ? setup.Height - : setup.Radius * 2f) * scale; - setupShapes.Add(new ShadowShape( - GfxObjId: entity.SourceGfxObjOrSetupId, - LocalPosition: Vector3.Zero, - LocalRotation: Quaternion.Identity, - Scale: scale, - CollisionType: ShadowCollisionType.Cylinder, - Radius: radius, - CylHeight: height)); - } - + // No Setup-radius fallback: retail synthesizes no shape for a + // Setup with neither CylSpheres nor Spheres. See + // LiveEntityCollisionBuilder's remarks for the retail anchor. if (setupShapes.Count > 0) { publication.StagingEngine.ShadowObjects.RegisterMultiPart( diff --git a/src/AcDream.Content/LandblockPhysicsContentBuilder.cs b/src/AcDream.Content/LandblockPhysicsContentBuilder.cs index e6228a8a..fe1e0fcc 100644 --- a/src/AcDream.Content/LandblockPhysicsContentBuilder.cs +++ b/src/AcDream.Content/LandblockPhysicsContentBuilder.cs @@ -691,24 +691,9 @@ public static class LandblockPhysicsContentBuilder } } - if (setup.Cylinders.Length == 0 - && setup.Spheres.Length == 0 - && setup.Radius > 0f) - { - float radius = setup.Radius * scale; - float height = (setup.Height > 0f - ? setup.Height - : setup.Radius * 2f) * scale; - setupShapes.Add(new ShadowShape( - entity.SourceGfxObjOrSetupId, - Vector3.Zero, - Quaternion.Identity, - scale, - ShadowCollisionType.Cylinder, - radius, - height)); - } - + // No Setup-radius fallback: retail synthesizes no shape for a + // Setup with neither CylSpheres nor Spheres. See + // LiveEntityCollisionBuilder's remarks for the retail anchor. if (setupShapes.Count == 0) { noCollision++; diff --git a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs index 81dec813..bc6064b9 100644 --- a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs +++ b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs @@ -22,11 +22,26 @@ namespace AcDream.Core.Physics; /// /// /// -/// Retail anchor: CPhysicsObj::FindObjCollisions calls -/// CPartArray::FindObjCollisions which iterates parts; each part's -/// find_obj_collisions tests CylSpheres + GfxObj BSP. We emit one -/// ShadowShape per part contribution so the existing FindObjCollisions -/// iteration loop in tests each part independently. +/// Retail anchor: CPhysicsObj::FindObjCollisions (0x0050f050) +/// dispatches EXCLUSIVELY on HAS_PHYSICS_BSP_PS (0x10000): 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. +/// CPhysicsPart::find_obj_collisions (0x0050d8d0) tests ONLY the +/// GfxObj physics BSP; CylSpheres and Spheres are Setup-level arrays +/// reached through CPartArray::GetCylsphere (0x00518090) and +/// GetSphere (0x00518070), so a part has no primitive of its own. +/// +/// +/// +/// 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. /// /// public static class ShadowShapeBuilder diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs index 44c8d55a..af25931e 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs @@ -7,6 +7,7 @@ using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; namespace AcDream.App.Tests.Physics; @@ -25,10 +26,23 @@ public sealed class LiveEntityCollisionBuilderTests Assert.Equal([0x01001001u, 0x01002000u], resolved); } + /// + /// Re-hosts the state/flag/seed-cell coverage that used to ride on the + /// deleted Setup-radius fallback (AP-22). The old fixture was a Setup with + /// a radius and zero primitives, which cannot exist in client_portal.dat — + /// all 1,294 shapeless Setups have Radius exactly 0. This uses a + /// DAT-possible single-CylSphere Setup instead. + /// [Fact] - public void RadiusFallback_UsesExactStateFlagsScaleAndFullSeedCell() + public void Build_PropagatesExactStateFlagsScaleAndFullSeedCell() { - var setup = new Setup { Radius = 0.75f, Height = 2.5f }; + var setup = new Setup(); + setup.CylSpheres.Add(new CylSphere + { + Origin = Vector3.Zero, + Radius = 0.4f, + Height = 1.2f, + }); WorldSession.EntitySpawn spawn = Spawn( scale: 2f, itemType: (uint)ItemType.Creature, @@ -45,8 +59,8 @@ public sealed class LiveEntityCollisionBuilderTests ShadowShape shape = Assert.Single(registration.Shapes); Assert.Equal(ShadowCollisionType.Cylinder, shape.CollisionType); - Assert.Equal(1.5f, shape.Radius); - Assert.Equal(5f, shape.CylHeight); + Assert.Equal(0.8f, shape.Radius); + Assert.Equal(2.4f, shape.CylHeight); Assert.Equal((uint)record.FinalPhysicsState, registration.State); Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.HasWeenie)); Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.IsCreature)); @@ -57,6 +71,33 @@ public sealed class LiveEntityCollisionBuilderTests Assert.Equal((192f, -192f), (registration.WorldOffsetX, registration.WorldOffsetY)); } + /// + /// AP-22: retail synthesizes no shape for a Setup with no primitives and + /// no physics-BSP part, whatever its summary Radius/Height say. + /// CPhysicsObj::FindObjCollisions (0x0050f050) branches to the + /// epilogue at 0x0050f22f je 0x50f31b and returns the seeded + /// OK_TS; CPartArray::GetRadius/GetHeight are absent from + /// its call set. This is the direct inverse of the deleted + /// RadiusFallback fact. + /// + [Fact] + public void ShapelessSetupWithRadius_ProducesNoRegistration() + { + var setup = new Setup { Radius = 0.75f, Height = 2.5f }; + WorldSession.EntitySpawn spawn = Spawn(scale: 2f); + var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); + WorldEntity entity = Entity(); + record.WorldEntity = entity; + + Assert.Null(Builder().Build( + entity, + setup, + Array.Empty(), + spawn, + record, + new Vector3(192f, -192f, 0f))); + } + [Fact] public void BspOnlyPart_UsesRealScaledPhysicsBoundingRadius() { diff --git a/tests/AcDream.Content.Tests/InstalledSetupCollisionReachabilityTests.cs b/tests/AcDream.Content.Tests/InstalledSetupCollisionReachabilityTests.cs new file mode 100644 index 00000000..94d5f6f2 --- /dev/null +++ b/tests/AcDream.Content.Tests/InstalledSetupCollisionReachabilityTests.cs @@ -0,0 +1,88 @@ +using AcDream.Core.Physics; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; + +namespace AcDream.Content.Tests; + +/// +/// AP-22 reachability proof. The deleted Setup-radius collision fallback fired +/// only for a Setup with no CylSphere, no Sphere, and a nonzero summary +/// Radius. This sweeps every Setup in the installed client_portal.dat and +/// asserts no such Setup exists, so the deletion cannot remove collision from +/// any real object. +/// +public sealed class InstalledSetupCollisionReachabilityTests +{ + // Positive controls. These are EXTERNAL constants, measured on 2026-08-06 + // by an independent raw client_portal.dat parse (B-tree directory walk + + // Setup record decode) that validated itself by byte accounting: all + // 5,935 records consumed with an exact residual tail of + // 20 + 48 * numLights and zero unexplained bytes. + // + // They are deliberately NOT derived from FlatCollisionAssetBuilder. A + // broken enumeration, a wrong dat path, or a silently-empty flatten all + // satisfy the negative claim vacuously and are caught only here. + private const int ExpectedSetups = 5935; + private const int ExpectedWithCylinder = 678; + private const int ExpectedSphereOnlyNoCylinder = 3605; + private const int ExpectedWithoutAnyPrimitive = 1652; + private const int ExpectedWithSummaryRadius = 4282; + + [Fact] + public void InstalledSetups_NeverReachTheDeletedRadiusFallback() + { + string? datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) + return; + + using var dats = new DatCollection(datDir, DatAccessType.Read); + + int total = 0; + int withCylinder = 0; + int sphereOnly = 0; + int withoutPrimitive = 0; + int withRadius = 0; + var fallbackReachable = new List(); + + foreach (uint id in dats.GetAllIdsOfType()) + { + if (!dats.Portal.TryGet(id, out Setup? setup) + || setup is null) + { + continue; + } + + FlatSetupCollision flat = + FlatCollisionAssetBuilder.FlattenSetup(setup); + total++; + + bool hasCylinder = flat.Cylinders.Length > 0; + bool hasSphere = flat.Spheres.Length > 0; + if (hasCylinder) + withCylinder++; + else if (hasSphere) + sphereOnly++; + else + withoutPrimitive++; + + if (flat.Radius > 0.0001f) + withRadius++; + + // The exact guard the three deleted copies used. + if (!hasCylinder && !hasSphere && flat.Radius > 0.0001f) + fallbackReachable.Add(id); + } + + // (b) Positive controls first: if the enumeration is broken, fail here + // rather than passing (a) for the wrong reason. + Assert.Equal(ExpectedSetups, total); + Assert.Equal(ExpectedWithCylinder, withCylinder); + Assert.Equal(ExpectedSphereOnlyNoCylinder, sphereOnly); + Assert.Equal(ExpectedWithoutAnyPrimitive, withoutPrimitive); + Assert.Equal(ExpectedWithSummaryRadius, withRadius); + + // (a) The negative claim the deletion rests on. + Assert.Empty(fallbackReachable); + } +}