test(physics): S6 — the camera provably reaches both PerfectClip TOI tails; contained, not dormant
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

AP-83/AP-91 claimed no current mover sets PerfectClip. The containment
proof found the opposite and the contract's honest-fallback fired: the
camera probe (the sole production setter) reaches BOTH ACE-derived
tails live — the viewer exemption is creature-only, the shadow-list
walk is unconditional, and static scenery with authored primitives is
a real non-creature population. Every reach is now recorded
(camera-live silently; any non-viewer mover loudly, one-shot), so a
future flag change cannot exercise unreviewed ACE-derived math
silently. Four tests drive the camera's exact call shape both ways;
the sabotage was intelligently adapted — there was no existing cut to
disable, so it flips the one axis the proof depends on (IsCreature)
and asserts reachability inverts. Both register rows rewritten
CONTAINED-not-dormant with severity narrowed to camera-feel (the probe
never commits a PhysicsBody).

Landing note: diagnostics-only diff (two guard calls + counters +
corrected stale comments), verified directly by the session lead
rather than a review cycle — the review budget went where behaviour
changed tonight.

Campaign S CLOSES with this landing: S1A/S1B/S2/S4/S5/S6 done, S3
cancelled, three user-passed gates, one honestly-open item — AD-66's
reland, twice self-refused by its own stability gate, blocked on the
#341 codegen-shape measurement instability whose ABA evidence and
first discriminating experiment are filed.

Clean-room suite: 11,257 passed / 6 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-07 09:21:40 +02:00
parent 00c03a33a5
commit c5443b3df9
5 changed files with 425 additions and 10 deletions

View file

@ -263,8 +263,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = (speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) | | AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = (speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
| AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` back to `WorldRenderFrameBuilder` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.ObserveDrawableCells`, `ClearDrawableCells`, `Prepare`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) | | AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` back to `WorldRenderFrameBuilder` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.ObserveDrawableCells`, `ClearDrawableCells`, `Prepare`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) |
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose through `LiveEntityDefaultPoseResolver`; retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities | | AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose through `LiveEntityDefaultPoseResolver`; retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). No current mover sets PerfectClip: players never do, and shipped ordinary missiles add PathClipped only. The non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified. Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping that mover | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` | | AP-83 | **CONTAINED, not dormant (Campaign S S6, 2026-08-07) — the row's 'no current mover sets PerfectClip' premise was FALSE.** The camera (`PhysicsCameraCollisionProbe.SweepEye`, the sole production PerfectClip setter) reaches this tail LIVE: neither `CollisionExemption.ShouldSkip` (creature-only viewer exemption) nor `FindObjCollisionsInCell` (unconditional shadow-list walk) cuts the chain for a non-creature Cyl-shaped shadow entry — a real population (static scenery with an authored primitive and no physics BSP). The tail head now records every reach (`PhysicsDiagnostics.RecordCylPerfectClipTailReach`): viewer movers count camera-live silently; any NON-viewer mover reaching it logs loudly one-shot, so a future flag change cannot exercise this ACE-derived math unreviewed. Four containment tests drive the camera's exact call shape both ways, sabotage-verified on the creature-exemption axis the proof depends on. **Severity narrowed to camera-feel only**: the probe never commits a PhysicsBody, so a wrong TOI can only mispull the spring-arm camera. The math itself remains ACE-derived and the row stays ACTIVE for that reason alone. Original text: **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). No current mover sets PerfectClip: players never do, and shipped ordinary missiles add PathClipped only. The non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified. Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail remains dormant unless a future mover explicitly enables PerfectClip | **Risk restated at S6:** the camera ALREADY reaches this tail — an ACE/retail TOI delta here is a live, currently-unverified camera-feel risk (a prop the camera pulls in slightly off), not a dormant one. If a future mover explicitly enables PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping that mover | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
| AP-91 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` | | AP-91 | **CONTAINED, not dormant (Campaign S S6, 2026-08-07) — the row's 'no current mover sets PerfectClip' premise was FALSE.** The camera (`PhysicsCameraCollisionProbe.SweepEye`, the sole production PerfectClip setter) reaches this tail LIVE: neither `CollisionExemption.ShouldSkip` (creature-only viewer exemption) nor `FindObjCollisionsInCell` (unconditional shadow-list walk) cuts the chain for a non-creature Sphere-shaped shadow entry — a real population (static scenery with an authored primitive and no physics BSP). The tail head now records every reach (`PhysicsDiagnostics.RecordSpherePerfectClipTailReach`): viewer movers count camera-live silently; any NON-viewer mover reaching it logs loudly one-shot, so a future flag change cannot exercise this ACE-derived math unreviewed. Four containment tests drive the camera's exact call shape both ways, sabotage-verified on the creature-exemption axis the proof depends on. **Severity narrowed to camera-feel only**: the probe never commits a PhysicsBody, so a wrong TOI can only mispull the spring-arm camera. The math itself remains ACE-derived and the row stays ACTIVE for that reason alone. Original text: **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | **Risk restated at S6:** the camera ALREADY reaches this tail — an ACE/retail TOI delta here is a live, currently-unverified camera-feel risk (a prop the camera pulls in slightly off), not a dormant one. If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` |
| AP-86 | **Remote SHADOW-follows-resolved via a pose/cell-gated per-tick re-flood** (remote-creature de-overlap #184): every remote's collision shadow is rewritten at the resolved body position by the DR tick or authoritative UP tail, so collision remains where the creature renders and de-overlap persists. The effect matches retail, but acdream runs the full multipart cell flood whenever the body moved more than 1 cm, changed complete orientation, or crossed a cell instead of translating the existing shadow in place and relinking only when its crossed-cell set changes. Cross-cell motion now commits body/root/full-cell before the canonical rebucket callback; local and authoritative remote publishers prove exact-record spatial residency after that callback; pending projection suspends the retained shadow and cannot re-add it, including initial-pending and callback GUID-reuse cases. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs`; `src/AcDream.App/Physics/LiveEntityShadowPublisher.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (local projection); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (authoritative UP tails); `src/AcDream.App/World/LiveEntityPresentationController.cs` (ordinary projection residency); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The pose/cell gate is exact at de-overlap equilibrium, preserves offset/multipart shapes during in-place turns, and the resulting registered cell set matches retail; loaded/pending residency is symmetric and incarnation-scoped | A dense moving or turning crowd can still perform a full registration flood per creature per tick and create CPU/Gen0 pressure; a still crowd is gated out. Retire with an in-place move plus cell-relink-on-change implementation | `CPhysicsObj::SetPositionInternal(CTransition const*)` 0x00515330 → `change_cell`, then `remove_shadows_from_cells`/`add_shadows_to_cells` after the resolved frame/contact commit | | AP-86 | **Remote SHADOW-follows-resolved via a pose/cell-gated per-tick re-flood** (remote-creature de-overlap #184): every remote's collision shadow is rewritten at the resolved body position by the DR tick or authoritative UP tail, so collision remains where the creature renders and de-overlap persists. The effect matches retail, but acdream runs the full multipart cell flood whenever the body moved more than 1 cm, changed complete orientation, or crossed a cell instead of translating the existing shadow in place and relinking only when its crossed-cell set changes. Cross-cell motion now commits body/root/full-cell before the canonical rebucket callback; local and authoritative remote publishers prove exact-record spatial residency after that callback; pending projection suspends the retained shadow and cannot re-add it, including initial-pending and callback GUID-reuse cases. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs`; `src/AcDream.App/Physics/LiveEntityShadowPublisher.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (local projection); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (authoritative UP tails); `src/AcDream.App/World/LiveEntityPresentationController.cs` (ordinary projection residency); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The pose/cell gate is exact at de-overlap equilibrium, preserves offset/multipart shapes during in-place turns, and the resulting registered cell set matches retail; loaded/pending residency is symmetric and incarnation-scoped | A dense moving or turning crowd can still perform a full registration flood per creature per tick and create CPU/Gen0 pressure; a still crowd is gated out. Retire with an in-place move plus cell-relink-on-change implementation | `CPhysicsObj::SetPositionInternal(CTransition const*)` 0x00515330 → `change_cell`, then `remove_shadows_from_cells`/`add_shadows_to_cells` after the resolved frame/contact commit |
| AP-87 | **Remote MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07; unified across player-remote and NPC-remote by C4 route 4a, 2026-08-03 retail's disassembly makes no `this==player` distinction here either, so the two formerly-duplicated per-kind copies are now the SAME decision): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the 96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions `|Body.Position worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body garbage resolved pos the reverted attempt's INVISIBLE monster. A third condition `firstUp` (`LastServerPosTime <= 0`) is RETAINED, not dropped, in the unified seam: it is a belt hint only the 4 m guard is the load-bearing backstop and it is structurally false for player remotes because the player-remote caller stamps `LastServerPosTime` in its diagnostic roll-forward block before it routes, so unifying the two copies on all three conditions leaves the player branch's own behaviour bit-identical | `src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs` (`ApplyInterpolate`, `BodySnapThreshold`); called from `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` for both the player-remote and NPC-remote near-Interpolate branches. C4 route 4b-2 (2026-08-04) deleted the App's two duplicated `MaxPhysicsDistance = 96f` / `BodySnapThreshold = 4f` constant pairs and both `_playerController?.Position ?? Vector3.Zero` fabrications: the far branch is now a canonical Runtime placement, and the cell-less/rejected/unclassified leftovers call this same seam (AP-137). The 4 m constant exists in exactly one place | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare. **2026-08-04 observed live, then FIXED AT THE SOURCE the same day**: the route 4a two-client test caught exactly this risk — a player remote jumping onto a house roof planted there and sat until it had drifted >4 m from the server's slid-down position, at which point this backstop fired (`producer=ap87-4m` in the capture) and blipped it instead of sliding. The CAUSE was the remote tick forging `Contact | OnWalkable` and deciding its landing edge from the contact-derived `ResolveResult.IsOnGround`; that is fixed (Bug B, `docs/ISSUES.md` #32) and the thresholds and conditions of this row are deliberately UNCHANGED. The snap remains the #184 invisible-but-solid backstop; it should simply fire far less often now that the body genuinely tracks the server, and less often again since AP-140's same-day retirement pointed both routing gates at CONTACT: a steep-face slide now interpolates toward the server pose every packet instead of being classified free-flight and hard-snapped, so `bodyToTarget` converges rather than being left to drift past 4 m. This row's own thresholds and conditions are unchanged by either fix | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap 96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) | | AP-87 | **Remote MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07; unified across player-remote and NPC-remote by C4 route 4a, 2026-08-03 retail's disassembly makes no `this==player` distinction here either, so the two formerly-duplicated per-kind copies are now the SAME decision): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the 96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions `|Body.Position worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body garbage resolved pos the reverted attempt's INVISIBLE monster. A third condition `firstUp` (`LastServerPosTime <= 0`) is RETAINED, not dropped, in the unified seam: it is a belt hint only the 4 m guard is the load-bearing backstop and it is structurally false for player remotes because the player-remote caller stamps `LastServerPosTime` in its diagnostic roll-forward block before it routes, so unifying the two copies on all three conditions leaves the player branch's own behaviour bit-identical | `src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs` (`ApplyInterpolate`, `BodySnapThreshold`); called from `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` for both the player-remote and NPC-remote near-Interpolate branches. C4 route 4b-2 (2026-08-04) deleted the App's two duplicated `MaxPhysicsDistance = 96f` / `BodySnapThreshold = 4f` constant pairs and both `_playerController?.Position ?? Vector3.Zero` fabrications: the far branch is now a canonical Runtime placement, and the cell-less/rejected/unclassified leftovers call this same seam (AP-137). The 4 m constant exists in exactly one place | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare. **2026-08-04 observed live, then FIXED AT THE SOURCE the same day**: the route 4a two-client test caught exactly this risk — a player remote jumping onto a house roof planted there and sat until it had drifted >4 m from the server's slid-down position, at which point this backstop fired (`producer=ap87-4m` in the capture) and blipped it instead of sliding. The CAUSE was the remote tick forging `Contact | OnWalkable` and deciding its landing edge from the contact-derived `ResolveResult.IsOnGround`; that is fixed (Bug B, `docs/ISSUES.md` #32) and the thresholds and conditions of this row are deliberately UNCHANGED. The snap remains the #184 invisible-but-solid backstop; it should simply fire far less often now that the body genuinely tracks the server, and less often again since AP-140's same-day retirement pointed both routing gates at CONTACT: a steep-face slide now interpolates toward the server pose every packet instead of being classified free-flight and hard-snapped, so `bodyToTarget` converges rather than being left to drift past 4 m. This row's own thresholds and conditions are unchanged by either fix | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap 96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
| AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 translucency`, applied to all 4 D3D9 material alpha channels) | | AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 translucency`, applied to all 4 D3D9 material alpha channels) |

View file

@ -8,7 +8,16 @@ misreading — see its section) · S1B LANDED (b3e43d22, #335 closed) and S2 LAN
narrowed), Session-B dungeon gate USER-PASSED 2026-08-07 evening ("Feels narrowed), Session-B dungeon gate USER-PASSED 2026-08-07 evening ("Feels
good!") · S4 half-landed (AD-65 shipped and USER-PASSED at the 2026-08-07 morning gate; AD-66 withheld good!") · S4 half-landed (AD-65 shipped and USER-PASSED at the 2026-08-07 morning gate; AD-66 withheld
behind #341's measurement anomaly; AD-69 filed) · S5 closed (fix predated the behind #341's measurement anomaly; AD-69 filed) · S5 closed (fix predated the
campaign; zombie register row) · S6 unstarted · #330 hoist landed, wiring campaign; zombie register row) · S6 LANDED (containment: the camera provably reaches BOTH ACE-derived TOI
tails live — the rows' dormancy premise was false; guarded with counters +
one-shot unverified-mover log, four tests, sabotage on the real exemption
axis; AP-83/AP-91 rewritten CONTAINED-not-dormant, severity camera-feel
only) · **CAMPAIGN CLOSED 2026-08-07 night** with ONE honestly-open item:
AD-66's reland is blocked by the #341 codegen-shape measurement instability
(twice self-refused by its own stability gate; the ABA evidence and the
first discriminating experiment are in #341). The user's final slope look
travels with that reland. Next: #344, #343, #341's boundary hunt, then
vendors (M4) · #330 hoist landed, wiring
withheld with a seven-point scope map · #32/#338 pre-work both closed. withheld with a seven-point scope map · #32/#338 pre-work both closed.
**Scope:** the twelve remaining collision-domain items — five shape/membership **Scope:** the twelve remaining collision-domain items — five shape/membership
divergences, three resolution-math divergences, two undecodable-math rows, and divergences, three resolution-math divergences, two undecodable-math rows, and

View file

@ -2170,6 +2170,9 @@ public static class PhysicsDiagnostics
// Dump-trigger sets // Dump-trigger sets
ProbeDumpCellIds = new System.Collections.Generic.HashSet<uint>(); ProbeDumpCellIds = new System.Collections.Generic.HashSet<uint>();
ProbeDumpGfxObjIds = new System.Collections.Generic.HashSet<uint>(); ProbeDumpGfxObjIds = new System.Collections.Generic.HashSet<uint>();
// S6 PerfectClip TOI-tail containment guard (AP-83/AP-91).
ResetPerfectClipTailGuardForTest();
} }
private static IReadOnlySet<uint> ParseHexIdList(string? raw) private static IReadOnlySet<uint> ParseHexIdList(string? raw)
@ -2656,6 +2659,122 @@ public static class PhysicsDiagnostics
$"[step-h] site={site} stepUp={stepUp:F3} stepDown={stepDown:F3} {detail}"); $"[step-h] site={site} stepUp={stepUp:F3} stepDown={stepDown:F3} {detail}");
} }
// ------------------------------------------------------------------
// S6 (Campaign S, 2026-08-07) — AP-83/AP-91 PerfectClip TOI-tail
// containment guard.
// ------------------------------------------------------------------
/// <summary>
/// S6 reachability proof
/// (<c>docs/research/2026-08-07-s6-perfectclip-containment-contract.md</c>):
/// the ONLY production site that sets
/// <see cref="ObjectInfoState.PerfectClip"/> on a mover is
/// <c>PhysicsCameraCollisionProbe.SweepEye</c>
/// (<c>src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:69</c>),
/// which ALSO sets <see cref="ObjectInfoState.IsViewer"/>. The camera's
/// resolve DOES reach both the Cyl and Sphere PerfectClip time-of-impact
/// tails (<c>TransitionTypes.CylCollideWithPoint</c> / AP-83,
/// <c>SphereCollideWithPoint</c> / AP-91) — neither candidate cut from the
/// S6 scoping doc holds: <c>CollisionExemption.ShouldSkip</c> only
/// exempts a viewer mover against a CREATURE target
/// (<c>CollisionExemption.cs:91-94</c>), and
/// <c>TransitionTypes.FindObjCollisionsInCell</c> walks every cell's
/// shadow list unconditionally, with no viewer/mover-flag gate before the
/// per-target loop (called from <c>FindPrimaryCellCollisions</c>,
/// <c>TransitionTypes.cs:2477</c>). A non-creature Cyl/Sphere-shaped
/// shadow entry is a real production population — static landblock
/// scenery registered by <c>LandblockPhysicsPublisher.PublishStaticEntity</c>
/// with <c>EntityCollisionFlags.None</c> (tracked live via
/// <c>publication.CylinderOwnerCount</c>) — so the camera's foot sphere
/// reaches the tail whenever it overlaps one: the camera is always
/// PathClipped and never grounded (its resolve passes <c>body: null</c>,
/// <c>isOnGround: false</c>), which is exactly Branch 4's PathClipped
/// route in both <c>CylinderCollision</c> and <c>SphereCollision</c>.
/// </summary>
/// <remarks>
/// Call this at the head of the PerfectClip branch inside each TOI tail,
/// after confirming <c>ObjectInfo.State</c> carries
/// <see cref="ObjectInfoState.PerfectClip"/>. A mover that ALSO carries
/// <see cref="ObjectInfoState.IsViewer"/> is the verified-reachable
/// population from the proof above — this is expected production
/// behavior, not a bug, so it is recorded with a plain counter (never an
/// assertion/throw). A mover WITHOUT <c>IsViewer</c> reaching this code
/// has no verified reachability chain — the only prior candidate (a
/// PathClipped missile) was ported per ACE but never armed PerfectClip in
/// M1.5 (<c>PhysicsEngine.cs:1984</c>: "PerfectClip is deliberately not
/// inferred"). That population is logged LOUDLY (one-shot per tail) so a
/// future flag change cannot silently start executing ACE-derived math
/// nobody re-verified.
/// </remarks>
public static void RecordSpherePerfectClipTailReach(bool moverIsViewer) =>
RecordPerfectClipTailReachCore(
"Sphere", moverIsViewer,
ref _sphereToiCameraLiveCount, ref _sphereToiUnverifiedCount,
ref _sphereToiUnverifiedAnnounced);
/// <inheritdoc cref="RecordSpherePerfectClipTailReach"/>
public static void RecordCylPerfectClipTailReach(bool moverIsViewer) =>
RecordPerfectClipTailReachCore(
"Cyl", moverIsViewer,
ref _cylToiCameraLiveCount, ref _cylToiUnverifiedCount,
ref _cylToiUnverifiedAnnounced);
private static void RecordPerfectClipTailReachCore(
string tail, bool moverIsViewer,
ref int cameraLiveCount, ref int unverifiedCount, ref int unverifiedAnnounced)
{
if (moverIsViewer)
{
System.Threading.Interlocked.Increment(ref cameraLiveCount);
return;
}
System.Threading.Interlocked.Increment(ref unverifiedCount);
if (System.Threading.Interlocked.Exchange(ref unverifiedAnnounced, 1) != 0)
return;
Console.WriteLine(
$"[perfectclip-tail] UNVERIFIED mover reached the {tail} PerfectClip "
+ "time-of-impact tail (AP-83/AP-91 — ACE-derived math with no retail "
+ "decompile; verified-reachable population is the camera / IsViewer "
+ "only). A non-viewer mover just executed this path; its reachability "
+ "was never re-verified — see "
+ "docs/research/2026-08-07-s6-perfectclip-containment-contract.md "
+ "before trusting the result.");
}
private static int _sphereToiCameraLiveCount;
private static int _sphereToiUnverifiedCount;
private static int _sphereToiUnverifiedAnnounced;
private static int _cylToiCameraLiveCount;
private static int _cylToiUnverifiedCount;
private static int _cylToiUnverifiedAnnounced;
/// <summary>Diagnostic counter — see <see cref="RecordSpherePerfectClipTailReach"/>.</summary>
public static int SphereToiCameraLiveCount => _sphereToiCameraLiveCount;
/// <summary>Diagnostic counter — see <see cref="RecordSpherePerfectClipTailReach"/>.</summary>
public static int SphereToiUnverifiedCount => _sphereToiUnverifiedCount;
/// <summary>Diagnostic counter — see <see cref="RecordCylPerfectClipTailReach"/>.</summary>
public static int CylToiCameraLiveCount => _cylToiCameraLiveCount;
/// <summary>Diagnostic counter — see <see cref="RecordCylPerfectClipTailReach"/>.</summary>
public static int CylToiUnverifiedCount => _cylToiUnverifiedCount;
/// <summary>
/// Test-only reset for the S6 PerfectClip-tail guard counters. Does NOT
/// reset the one-shot "announced" latches independently of the counts —
/// a full reset (counts AND latches) so a sabotage test that intends to
/// re-trigger the loud unverified log can observe it fire again.
/// </summary>
public static void ResetPerfectClipTailGuardForTest()
{
_sphereToiCameraLiveCount = 0;
_sphereToiUnverifiedCount = 0;
_sphereToiUnverifiedAnnounced = 0;
_cylToiCameraLiveCount = 0;
_cylToiUnverifiedCount = 0;
_cylToiUnverifiedAnnounced = 0;
}
private static int ParsePositiveInt(string? value) => private static int ParsePositiveInt(string? value) =>
int.TryParse( int.TryParse(
value, value,

View file

@ -4803,8 +4803,15 @@ public sealed class Transition
/// PathClipped movers + airborne head-sphere hits. Non-PerfectClip records the /// PathClipped movers + airborne head-sphere hits. Non-PerfectClip records the
/// center-to-center collision normal and hard-stops (the M1.5 load-bearing /// center-to-center collision normal and hard-stops (the M1.5 load-bearing
/// path — players never set PerfectClip). PerfectClip gets the exact /// path — players never set PerfectClip). PerfectClip gets the exact
/// time-of-impact reposition (missiles only — AP-91, dead in M1.5, ported per /// time-of-impact reposition (ported per ACE Sphere.cs:175-210).
/// ACE Sphere.cs:175-210; re-verify vs Ghidra before missiles ship). ///
/// <para>
/// S6 (AP-91, 2026-08-07): this branch is NOT dead — the camera
/// (<c>PhysicsCameraCollisionProbe</c>) sets PerfectClip AND IsViewer on
/// every sweep and is not exempted from non-creature Cyl/Sphere shadow
/// entries, so it reaches here live in production. See the reachability
/// proof on <see cref="PhysicsDiagnostics.RecordSpherePerfectClipTailReach"/>.
/// </para>
/// </summary> /// </summary>
private TransitionState SphereCollideWithPoint(ShadowEntry obj, SpherePath sp, private TransitionState SphereCollideWithPoint(ShadowEntry obj, SpherePath sp,
Sphere checkSphere, float radsum, int sphereNum) Sphere checkSphere, float radsum, int sphereNum)
@ -4819,7 +4826,11 @@ public sealed class Transition
return TransitionState.Collided; return TransitionState.Collided;
} }
// PerfectClip exact time-of-impact (AP-91 — dead in M1.5). Block offset = 0. // S6 containment guard (AP-91) — see PhysicsDiagnostics.RecordSpherePerfectClipTailReach.
PhysicsDiagnostics.RecordSpherePerfectClipTailReach(
(ObjectInfo.State & ObjectInfoState.IsViewer) != 0);
// PerfectClip exact time-of-impact reposition. Block offset = 0.
Vector3 checkOffset = checkSphere.Origin - gCenter; Vector3 checkOffset = checkSphere.Origin - gCenter;
double toi = FindSphereTimeOfCollision(checkOffset, globalOffset, radsum + PhysicsGlobals.EPSILON); double toi = FindSphereTimeOfCollision(checkOffset, globalOffset, radsum + PhysicsGlobals.EPSILON);
if (toi < PhysicsGlobals.EPSILON || toi > 1.0) if (toi < PhysicsGlobals.EPSILON || toi > 1.0)
@ -5232,10 +5243,17 @@ public sealed class Transition
/// PathClipped movers + airborne head-sphere hits. Non-PerfectClip movers /// PathClipped movers + airborne head-sphere hits. Non-PerfectClip movers
/// record the collision normal and hard-stop; PerfectClip movers get the /// record the collision normal and hard-stop; PerfectClip movers get the
/// exact time-of-impact reposition. TOI sub-branches ported per ACE /// exact time-of-impact reposition. TOI sub-branches ported per ACE
/// CylSphere.CollideWithPoint (BN mush too heavy in 0x0053adb6+); no /// CylSphere.CollideWithPoint (BN mush too heavy in 0x0053adb6+).
/// PerfectClip mover exists in M1.5 (players never set it), so only the ///
/// Collided path is load-bearing today — revisit against Ghidra if /// <para>
/// missiles ever arm PerfectClip (pseudocode doc §7). /// S6 (AP-83, 2026-08-07): this branch is NOT dead — the camera
/// (<c>PhysicsCameraCollisionProbe</c>) sets PerfectClip AND IsViewer on
/// every sweep and is not exempted from non-creature Cyl-shaped shadow
/// entries (e.g. static landblock scenery with a CylSphere and no
/// physics BSP), so it reaches here live in production whenever its
/// PathClipped foot sphere overlaps one. See the reachability proof on
/// <see cref="PhysicsDiagnostics.RecordCylPerfectClipTailReach"/>.
/// </para>
/// </summary> /// </summary>
private TransitionState CylCollideWithPoint(ShadowEntry obj, SpherePath sp, private TransitionState CylCollideWithPoint(ShadowEntry obj, SpherePath sp,
float cylHeight, Sphere checkSphere, Vector3 disp, float radsum, int sphereNum) float cylHeight, Sphere checkSphere, Vector3 disp, float radsum, int sphereNum)
@ -5251,6 +5269,10 @@ public sealed class Transition
return TransitionState.Collided; return TransitionState.Collided;
} }
// S6 containment guard (AP-83) — see PhysicsDiagnostics.RecordCylPerfectClipTailReach.
PhysicsDiagnostics.RecordCylPerfectClipTailReach(
(ObjectInfo.State & ObjectInfoState.IsViewer) != 0);
// Retail reads global_curr_center[0] even for the head hit // Retail reads global_curr_center[0] even for the head hit
// (0x0053ad26; ACE agrees) — verbatim. // (0x0053ad26; ACE agrees) — verbatim.
Vector3 globCenter = sp.GlobalCurrCenter[0].Origin; Vector3 globCenter = sp.GlobalCurrCenter[0].Origin;

View file

@ -0,0 +1,265 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// S6 (Campaign S, 2026-08-07) — reachability proof + containment guard for
/// the AP-83/AP-91 PerfectClip time-of-impact tails
/// (<c>TransitionTypes.CylCollideWithPoint</c> / <c>SphereCollideWithPoint</c>).
/// Contract: docs/research/2026-08-07-s6-perfectclip-containment-contract.md.
///
/// <para>
/// FINDING (not a failure — the contract explicitly anticipates this outcome):
/// BOTH scoping-doc candidate arguments for "the camera can never reach these
/// tails" fail. <c>CollisionExemption.ShouldSkip</c> only exempts a viewer
/// mover against a CREATURE target (CollisionExemption.cs:91-94) — a
/// non-creature Cyl/Sphere shadow entry passes straight through. And
/// <c>FindObjCollisionsInCell</c> walks every cell's shadow list
/// unconditionally, with no viewer skip-all gate before the per-target loop
/// (TransitionTypes.cs:3806, reached from <c>FindPrimaryCellCollisions</c> with
/// no mover-flag check, TransitionTypes.cs:2477). So the camera
/// (<c>PhysicsCameraCollisionProbe.SweepEye</c> — the ONLY production
/// PerfectClip setter, grep-confirmed against the whole src tree) DOES reach
/// both TOI tails whenever its PathClipped, never-grounded foot sphere
/// (moverFlags carry PathClipped; body=null + isOnGround=false means neither
/// Contact nor OnWalkable is ever seeded) overlaps a non-creature Cyl/Sphere
/// shadow entry. That population is real production content: static
/// landblock scenery with a CylSphere/Sphere and no physics BSP registers
/// with <c>EntityCollisionFlags.None</c>
/// (<c>LandblockPhysicsPublisher.PublishStaticEntity</c>, tracked live via
/// <c>publication.CylinderOwnerCount</c>).
/// </para>
///
/// <para>
/// These tests drive the REAL production resolve
/// (<c>PhysicsEngine.ResolveWithTransition</c>) with the camera's exact call
/// shape (IsViewer|PathClipped|FreeRotate|PerfectClip, single 0.3 m sphere,
/// body=null, isOnGround=false — mirrors <c>PhysicsCameraCollisionProbe
/// .SweepEye</c> and the harness already used by
/// <c>CameraCornerSealReplayTests</c> / <c>Issue180CorridorSweepHysteresis
/// ReplayTests</c>) against a synthetic Cyl/Sphere shadow entry, and assert
/// the guard (<c>PhysicsDiagnostics.RecordCylPerfectClipTailReach</c> /
/// <c>RecordSpherePerfectClipTailReach</c>) records the reach as camera-live,
/// never as unverified.
/// </para>
///
/// <para>
/// <b>Sabotage</b> (deliverable 3): the contract's literal wording is "disable
/// the exemption that cuts the chain and watch the reachability flip" — but
/// the proof found NOTHING currently cuts the chain for a non-creature
/// target, so there is no existing cut to disable. The faithful adaptation
/// flips the SAME one axis <c>CollisionExemption.ShouldSkip</c> actually
/// reads (<c>EntityCollisionFlags.IsCreature</c>) from off to on and asserts
/// reachability inverts: the guard goes silent and the camera walks straight
/// through, exactly the retail "camera ray ignores creatures" rule
/// (acclient_2013_pseudo_c.txt:276787-276790). This proves the guard is wired
/// to the actual condition the reachability proof depends on, not merely
/// "always fires regardless of input".
/// </para>
/// </summary>
public class S6PerfectClipTailContainmentTests
{
private readonly ITestOutputHelper _out;
public S6PerfectClipTailContainmentTests(ITestOutputHelper output) => _out = output;
private const uint TestLandblockId = 0xA9B40000u;
private const uint TestCellId = TestLandblockId | 0x0001u; // landcell (0,0), matches CylSphereFamilyTests/SphereCollisionFamilyTests
private const float ViewerSphereRadius = 0.3f; // retail viewer_sphere (acclient :93314)
private static readonly ObjectInfoState CameraMoverFlags =
ObjectInfoState.IsViewer | ObjectInfoState.PathClipped
| ObjectInfoState.FreeRotate | ObjectInfoState.PerfectClip;
// ───────────────────────────────────────────────────────────────
// Sphere tail (AP-91)
// ───────────────────────────────────────────────────────────────
[Fact]
public void CameraSweep_HitsNonCreatureSphereProp_ReachesTail_RecordsCameraLive()
{
PhysicsDiagnostics.ResetPerfectClipTailGuardForTest();
var engine = BuildEngine();
RegisterSphere(engine, 0x00005001u, new Vector3(12f, 14f, 1.0f), radius: 1.0f,
flags: EntityCollisionFlags.None); // static prop, not a creature
Vector3 pivot = new(12f, 10f, 1.0f);
Vector3 eye = new(12f, 20f, 1.0f); // straight through the prop's center
var r = SweepViewer(engine, pivot, eye, TestCellId);
_out.WriteLine(FormattableString.Invariant($"ok={r.Ok} pos=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) collNorm={r.CollisionNormalValid} normal=({r.CollisionNormal.X:F3},{r.CollisionNormal.Y:F3},{r.CollisionNormal.Z:F3}) cameraLive={PhysicsDiagnostics.SphereToiCameraLiveCount} unverified={PhysicsDiagnostics.SphereToiUnverifiedCount}"));
Assert.True(PhysicsDiagnostics.SphereToiCameraLiveCount > 0,
"The camera's PathClipped, never-grounded sweep must reach the Sphere PerfectClip TOI tail (AP-91) for a non-creature target.");
Assert.Equal(0, PhysicsDiagnostics.SphereToiUnverifiedCount);
// Golden: the sphere prop must have stopped the camera's forward sweep
// (PathClipped hard-stops at first contact) well short of the far side.
Assert.True(r.Position.Y < 14f,
$"camera must be stopped by the prop, not pass through it; got Y={r.Position.Y:F3}");
Assert.True(r.Position.Y > 11f,
$"camera must actually reach the prop's surface, not stop early; got Y={r.Position.Y:F3}");
}
[Fact]
public void CameraSweep_HitsCreatureFlaggedSphere_ExemptionCutsChain_GuardStaysSilent()
{
PhysicsDiagnostics.ResetPerfectClipTailGuardForTest();
var engine = BuildEngine();
// SABOTAGE: flip the one axis CollisionExemption.ShouldSkip reads for a
// viewer mover — everything else about the geometry is identical to the
// reached case above.
RegisterSphere(engine, 0x00005002u, new Vector3(12f, 14f, 1.0f), radius: 1.0f,
flags: EntityCollisionFlags.IsCreature);
Vector3 pivot = new(12f, 10f, 1.0f);
Vector3 eye = new(12f, 20f, 1.0f);
var r = SweepViewer(engine, pivot, eye, TestCellId);
_out.WriteLine(FormattableString.Invariant($"ok={r.Ok} pos=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) cameraLive={PhysicsDiagnostics.SphereToiCameraLiveCount}"));
Assert.Equal(0, PhysicsDiagnostics.SphereToiCameraLiveCount);
Assert.Equal(0, PhysicsDiagnostics.SphereToiUnverifiedCount);
// Reachability flips: CollisionExemption now exempts the whole target
// for the viewer mover before shape dispatch, so the camera sails
// straight through to the far side (retail: camera ray ignores
// creatures, acclient_2013_pseudo_c.txt:276787-276790).
Assert.True(r.Position.Y > 19f,
$"a creature-flagged prop must be fully exempt for a viewer mover; got Y={r.Position.Y:F3}");
}
// ───────────────────────────────────────────────────────────────
// Cyl tail (AP-83)
// ───────────────────────────────────────────────────────────────
[Fact]
public void CameraSweep_HitsNonCreatureCylinderProp_ReachesTail_RecordsCameraLive()
{
PhysicsDiagnostics.ResetPerfectClipTailGuardForTest();
var engine = BuildEngine();
RegisterCylinder(engine, 0x00006001u, new Vector3(12f, 14f, 0f), radius: 1.0f, height: 2.0f,
flags: EntityCollisionFlags.None);
Vector3 pivot = new(12f, 10f, 1.0f);
Vector3 eye = new(12f, 20f, 1.0f);
var r = SweepViewer(engine, pivot, eye, TestCellId);
_out.WriteLine(FormattableString.Invariant($"ok={r.Ok} pos=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) collNorm={r.CollisionNormalValid} normal=({r.CollisionNormal.X:F3},{r.CollisionNormal.Y:F3},{r.CollisionNormal.Z:F3}) cameraLive={PhysicsDiagnostics.CylToiCameraLiveCount} unverified={PhysicsDiagnostics.CylToiUnverifiedCount}"));
Assert.True(PhysicsDiagnostics.CylToiCameraLiveCount > 0,
"The camera's PathClipped, never-grounded sweep must reach the Cyl PerfectClip TOI tail (AP-83) for a non-creature target.");
Assert.Equal(0, PhysicsDiagnostics.CylToiUnverifiedCount);
Assert.True(r.Position.Y < 14f,
$"camera must be stopped by the prop, not pass through it; got Y={r.Position.Y:F3}");
Assert.True(r.Position.Y > 11f,
$"camera must actually reach the prop's surface, not stop early; got Y={r.Position.Y:F3}");
}
[Fact]
public void CameraSweep_HitsCreatureFlaggedCylinder_ExemptionCutsChain_GuardStaysSilent()
{
PhysicsDiagnostics.ResetPerfectClipTailGuardForTest();
var engine = BuildEngine();
RegisterCylinder(engine, 0x00006002u, new Vector3(12f, 14f, 0f), radius: 1.0f, height: 2.0f,
flags: EntityCollisionFlags.IsCreature);
Vector3 pivot = new(12f, 10f, 1.0f);
Vector3 eye = new(12f, 20f, 1.0f);
var r = SweepViewer(engine, pivot, eye, TestCellId);
_out.WriteLine(FormattableString.Invariant($"ok={r.Ok} pos=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) cameraLive={PhysicsDiagnostics.CylToiCameraLiveCount}"));
Assert.Equal(0, PhysicsDiagnostics.CylToiCameraLiveCount);
Assert.Equal(0, PhysicsDiagnostics.CylToiUnverifiedCount);
Assert.True(r.Position.Y > 19f,
$"a creature-flagged prop must be fully exempt for a viewer mover; got Y={r.Position.Y:F3}");
}
// ───────────────────────────────────────────────────────────────
// Harness (mirrors CylSphereFamilyTests / SphereCollisionFamilyTests)
// ───────────────────────────────────────────────────────────────
private static PhysicsEngine BuildEngine()
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
// Flat terrain at Z=0 across the whole landblock — irrelevant to these
// sweeps (they travel at Z≈1.0, well above ground) but required so the
// outdoor cell resolves.
var heights = new byte[81];
var heightTable = new float[256]; // all zero → terrain Z = 0
engine.AddLandblock(
landblockId: TestLandblockId,
terrain: new TerrainSurface(heights, heightTable),
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
return engine;
}
/// <summary>
/// Mirror of PhysicsCameraCollisionProbe.SweepEye's transition call — same
/// shape already used by CameraCornerSealReplayTests,
/// Issue180CorridorSweepHysteresisReplayTests, and CylSphereFamilyTests'
/// landblock (0x0100 low word stays below the AdjustPosition threshold, so
/// the outdoor cellId is used as-is).
/// </summary>
private static ResolveResult SweepViewer(PhysicsEngine engine, Vector3 pivot, Vector3 desiredEye, uint cellId)
{
Vector3 begin = pivot - new Vector3(0f, 0f, ViewerSphereRadius);
Vector3 end = desiredEye - new Vector3(0f, 0f, ViewerSphereRadius);
return engine.ResolveWithTransition(
currentPos: begin,
targetPos: end,
cellId: cellId,
sphereRadius: ViewerSphereRadius,
sphereHeight: 0f,
stepUpHeight: 0f,
stepDownHeight: 0f,
isOnGround: false,
body: null,
moverFlags: CameraMoverFlags,
movingEntityId: 0);
}
private static void RegisterSphere(PhysicsEngine engine, uint entityId, Vector3 worldPos,
float radius, EntityCollisionFlags flags)
{
engine.ShadowObjects.Register(
entityId, gfxObjId: 0u,
worldPos, Quaternion.Identity, radius,
worldOffsetX: 0f, worldOffsetY: 0f, landblockId: TestLandblockId,
collisionType: ShadowCollisionType.Sphere,
cylHeight: 0f, scale: 1f,
state: 0u,
flags: flags,
isStatic: true);
}
private static void RegisterCylinder(PhysicsEngine engine, uint entityId, Vector3 worldPos,
float radius, float height, EntityCollisionFlags flags)
{
engine.ShadowObjects.Register(
entityId, gfxObjId: 0u,
worldPos, Quaternion.Identity, radius,
worldOffsetX: 0f, worldOffsetY: 0f, landblockId: TestLandblockId,
collisionType: ShadowCollisionType.Cylinder,
cylHeight: height, scale: 1f,
state: 0u,
flags: flags,
isStatic: true);
}
}