From c5443b3df9f62072e2f6549a908fea53affab5ed Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 7 Aug 2026 09:21:40 +0200 Subject: [PATCH] =?UTF-8?q?test(physics):=20S6=20=E2=80=94=20the=20camera?= =?UTF-8?q?=20provably=20reaches=20both=20PerfectClip=20TOI=20tails;=20con?= =?UTF-8?q?tained,=20not=20dormant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../retail-divergence-register.md | 4 +- .../2026-08-06-collision-fidelity-campaign.md | 11 +- .../Physics/PhysicsDiagnostics.cs | 119 ++++++++ src/AcDream.Core/Physics/TransitionTypes.cs | 36 ++- .../S6PerfectClipTailContainmentTests.cs | 265 ++++++++++++++++++ 5 files changed, 425 insertions(+), 10 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/S6PerfectClipTailContainmentTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 3268e31e..1b3b413e 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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-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-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-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-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 | **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-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) | diff --git a/docs/plans/2026-08-06-collision-fidelity-campaign.md b/docs/plans/2026-08-06-collision-fidelity-campaign.md index f5722068..06a203a0 100644 --- a/docs/plans/2026-08-06-collision-fidelity-campaign.md +++ b/docs/plans/2026-08-06-collision-fidelity-campaign.md @@ -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 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 -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. **Scope:** the twelve remaining collision-domain items — five shape/membership divergences, three resolution-math divergences, two undecodable-math rows, and diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index e7924b53..0a5eeb07 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -2170,6 +2170,9 @@ public static class PhysicsDiagnostics // Dump-trigger sets ProbeDumpCellIds = new System.Collections.Generic.HashSet(); ProbeDumpGfxObjIds = new System.Collections.Generic.HashSet(); + + // S6 PerfectClip TOI-tail containment guard (AP-83/AP-91). + ResetPerfectClipTailGuardForTest(); } private static IReadOnlySet ParseHexIdList(string? raw) @@ -2656,6 +2659,122 @@ public static class PhysicsDiagnostics $"[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. + // ------------------------------------------------------------------ + + /// + /// S6 reachability proof + /// (docs/research/2026-08-07-s6-perfectclip-containment-contract.md): + /// the ONLY production site that sets + /// on a mover is + /// PhysicsCameraCollisionProbe.SweepEye + /// (src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:69), + /// which ALSO sets . The camera's + /// resolve DOES reach both the Cyl and Sphere PerfectClip time-of-impact + /// tails (TransitionTypes.CylCollideWithPoint / AP-83, + /// SphereCollideWithPoint / AP-91) — neither candidate cut from the + /// S6 scoping doc holds: CollisionExemption.ShouldSkip only + /// exempts a viewer mover against a CREATURE target + /// (CollisionExemption.cs:91-94), and + /// TransitionTypes.FindObjCollisionsInCell walks every cell's + /// shadow list unconditionally, with no viewer/mover-flag gate before the + /// per-target loop (called from FindPrimaryCellCollisions, + /// TransitionTypes.cs:2477). A non-creature Cyl/Sphere-shaped + /// shadow entry is a real production population — static landblock + /// scenery registered by LandblockPhysicsPublisher.PublishStaticEntity + /// with EntityCollisionFlags.None (tracked live via + /// publication.CylinderOwnerCount) — so the camera's foot sphere + /// reaches the tail whenever it overlaps one: the camera is always + /// PathClipped and never grounded (its resolve passes body: null, + /// isOnGround: false), which is exactly Branch 4's PathClipped + /// route in both CylinderCollision and SphereCollision. + /// + /// + /// Call this at the head of the PerfectClip branch inside each TOI tail, + /// after confirming ObjectInfo.State carries + /// . A mover that ALSO carries + /// 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 IsViewer 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 (PhysicsEngine.cs:1984: "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. + /// + public static void RecordSpherePerfectClipTailReach(bool moverIsViewer) => + RecordPerfectClipTailReachCore( + "Sphere", moverIsViewer, + ref _sphereToiCameraLiveCount, ref _sphereToiUnverifiedCount, + ref _sphereToiUnverifiedAnnounced); + + /// + 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; + + /// Diagnostic counter — see . + public static int SphereToiCameraLiveCount => _sphereToiCameraLiveCount; + /// Diagnostic counter — see . + public static int SphereToiUnverifiedCount => _sphereToiUnverifiedCount; + /// Diagnostic counter — see . + public static int CylToiCameraLiveCount => _cylToiCameraLiveCount; + /// Diagnostic counter — see . + public static int CylToiUnverifiedCount => _cylToiUnverifiedCount; + + /// + /// 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. + /// + public static void ResetPerfectClipTailGuardForTest() + { + _sphereToiCameraLiveCount = 0; + _sphereToiUnverifiedCount = 0; + _sphereToiUnverifiedAnnounced = 0; + _cylToiCameraLiveCount = 0; + _cylToiUnverifiedCount = 0; + _cylToiUnverifiedAnnounced = 0; + } + private static int ParsePositiveInt(string? value) => int.TryParse( value, diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index d36485a6..6e85a7e6 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -4803,8 +4803,15 @@ public sealed class Transition /// PathClipped movers + airborne head-sphere hits. Non-PerfectClip records the /// center-to-center collision normal and hard-stops (the M1.5 load-bearing /// path — players never set PerfectClip). PerfectClip gets the exact - /// time-of-impact reposition (missiles only — AP-91, dead in M1.5, ported per - /// ACE Sphere.cs:175-210; re-verify vs Ghidra before missiles ship). + /// time-of-impact reposition (ported per ACE Sphere.cs:175-210). + /// + /// + /// S6 (AP-91, 2026-08-07): this branch is NOT dead — the camera + /// (PhysicsCameraCollisionProbe) 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 . + /// /// private TransitionState SphereCollideWithPoint(ShadowEntry obj, SpherePath sp, Sphere checkSphere, float radsum, int sphereNum) @@ -4819,7 +4826,11 @@ public sealed class Transition 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; double toi = FindSphereTimeOfCollision(checkOffset, globalOffset, radsum + PhysicsGlobals.EPSILON); if (toi < PhysicsGlobals.EPSILON || toi > 1.0) @@ -5232,10 +5243,17 @@ public sealed class Transition /// PathClipped movers + airborne head-sphere hits. Non-PerfectClip movers /// record the collision normal and hard-stop; PerfectClip movers get the /// exact time-of-impact reposition. TOI sub-branches ported per ACE - /// CylSphere.CollideWithPoint (BN mush too heavy in 0x0053adb6+); no - /// PerfectClip mover exists in M1.5 (players never set it), so only the - /// Collided path is load-bearing today — revisit against Ghidra if - /// missiles ever arm PerfectClip (pseudocode doc §7). + /// CylSphere.CollideWithPoint (BN mush too heavy in 0x0053adb6+). + /// + /// + /// S6 (AP-83, 2026-08-07): this branch is NOT dead — the camera + /// (PhysicsCameraCollisionProbe) 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 + /// . + /// /// private TransitionState CylCollideWithPoint(ShadowEntry obj, SpherePath sp, float cylHeight, Sphere checkSphere, Vector3 disp, float radsum, int sphereNum) @@ -5251,6 +5269,10 @@ public sealed class Transition 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 // (0x0053ad26; ACE agrees) — verbatim. Vector3 globCenter = sp.GlobalCurrCenter[0].Origin; diff --git a/tests/AcDream.Core.Tests/Physics/S6PerfectClipTailContainmentTests.cs b/tests/AcDream.Core.Tests/Physics/S6PerfectClipTailContainmentTests.cs new file mode 100644 index 00000000..89457aff --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/S6PerfectClipTailContainmentTests.cs @@ -0,0 +1,265 @@ +using System; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Physics; + +/// +/// S6 (Campaign S, 2026-08-07) — reachability proof + containment guard for +/// the AP-83/AP-91 PerfectClip time-of-impact tails +/// (TransitionTypes.CylCollideWithPoint / SphereCollideWithPoint). +/// Contract: docs/research/2026-08-07-s6-perfectclip-containment-contract.md. +/// +/// +/// FINDING (not a failure — the contract explicitly anticipates this outcome): +/// BOTH scoping-doc candidate arguments for "the camera can never reach these +/// tails" fail. CollisionExemption.ShouldSkip only exempts a viewer +/// mover against a CREATURE target (CollisionExemption.cs:91-94) — a +/// non-creature Cyl/Sphere shadow entry passes straight through. And +/// FindObjCollisionsInCell walks every cell's shadow list +/// unconditionally, with no viewer skip-all gate before the per-target loop +/// (TransitionTypes.cs:3806, reached from FindPrimaryCellCollisions with +/// no mover-flag check, TransitionTypes.cs:2477). So the camera +/// (PhysicsCameraCollisionProbe.SweepEye — 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 EntityCollisionFlags.None +/// (LandblockPhysicsPublisher.PublishStaticEntity, tracked live via +/// publication.CylinderOwnerCount). +/// +/// +/// +/// These tests drive the REAL production resolve +/// (PhysicsEngine.ResolveWithTransition) with the camera's exact call +/// shape (IsViewer|PathClipped|FreeRotate|PerfectClip, single 0.3 m sphere, +/// body=null, isOnGround=false — mirrors PhysicsCameraCollisionProbe +/// .SweepEye and the harness already used by +/// CameraCornerSealReplayTests / Issue180CorridorSweepHysteresis +/// ReplayTests) against a synthetic Cyl/Sphere shadow entry, and assert +/// the guard (PhysicsDiagnostics.RecordCylPerfectClipTailReach / +/// RecordSpherePerfectClipTailReach) records the reach as camera-live, +/// never as unverified. +/// +/// +/// +/// Sabotage (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 CollisionExemption.ShouldSkip actually +/// reads (EntityCollisionFlags.IsCreature) 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". +/// +/// +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(), + portals: Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + return engine; + } + + /// + /// 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). + /// + 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); + } +}