From c24bc571cfb78519197b94ebc0220afbdb751c0d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 12:10:03 +0200 Subject: [PATCH] fix(physics): enforce retail step-down support radius (#273) --- .../retail-divergence-register.md | 2 +- .../2026-07-31-issue273-tight-gap-support.md | 95 + src/AcDream.Core/Physics/BSPQuery.cs | 36 +- src/AcDream.Core/Physics/TransitionTypes.cs | 43 +- .../Fixtures/issue273/0x01000F69.gfxobj.json | 1626 +++++++++++++++++ .../Issue185OutdoorStairsSeamReplayTests.cs | 18 +- .../Issue273HoltburgTightGapReplayTests.cs | 259 +++ tools/A8CellAudit/Program.cs | 37 +- tools/SetupInspect/Program.cs | 25 +- 9 files changed, 2117 insertions(+), 24 deletions(-) create mode 100644 docs/research/2026-07-31-issue273-tight-gap-support.md create mode 100644 tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json create mode 100644 tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index f68e1b5e..6ebad661 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -128,7 +128,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. |---|---|---|---|---|---| | AP-1 | Snap-path Z settle: validated claims ground on their own walkable polys, but floor-less claims (thresholds, stair lips) fall through to a legacy nearest-in-Z scan over every CellSurface in the landblock; retail settles via `CheckPositionInternal` → `find_valid_position` | `src/AcDream.Core/Physics/PhysicsEngine.cs:614` | `find_valid_position` unported; the **#111** fix narrowed the legacy pick's blast radius (validated claims bypass it) rather than replacing it | A threshold/stair-lip snap can still pick a neighbouring cell's same-height floor by iteration order — wrong cell or Z at login/teleport arrival (the #111 clobber class) | `SetPositionInternal` :283426 → find_valid_position | -| AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 | +| AP-3 | Step-down chain also runs for a valid contact plane when that plane is steeper than walkable; retail's `transitional_insert` OK-path returns immediately for every valid contact plane and enters the step-down tail only when contact is invalid | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`) | The added steep-contact entry preserves the current cliff-slide compensation while the response-layer state/order family remains open (AP-4/AD-53/AD-54/TS-4) | A steep valid contact can enter step-down/edge response where retail restores or validates state through its normal contact path, producing different retry and slide behavior | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 | | AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) | | AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 | | ~~AP-7~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the "state gate" was a BN decompiler artifact, not a locomotion exemption.** `calc_friction` now ports retail's confirmed 0.25f threshold (`if (angle >= 0.25f) return;`) unconditionally, no special-cased gate. The "state check at pc:276702" the old row cited is `PhysicsState.Sledding` (confirmed via ACE's `PhysicsObj.calc_friction`, references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2120-2141, and `SLEDDING_PS=0x800000` in acclient.h:2838) — it gates the 1.5625/6.25/near-flat friction-value OVERRIDE, not the threshold return itself; acdream had no live Sledding setter then or now (see #166 research, docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §3), so the branch was simply unreachable dead code, not an exemption for ordinary walking. The reverted 2026-04-30 L.3c attempt (naive 0.0→0.25 bump, forward locomotion 3→0.16 m/s in `PlayerMovementControllerTests`) does not reproduce on the production graphical local-player path post-R6: `PlayerMovementController` zeroes `Velocity.X/Y` to exactly zero every tick before `calc_friction` runs whenever animation root motion drives the walk, so friction has no horizontal velocity left to hammer (pinned at the PhysicsBody level by `GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests`). The headless/`get_state_velocity` movement-controller path and remote/NPC movers still feed real velocity into this function and remain the ones to watch if a similar regression resurfaces there. **CORRECTION (2026-07-30, same day, #265/#166 capture bisect):** the sentence above undersold the gap — `calc_friction` wasn't merely "no horizontal velocity to hammer," it was structurally UNREACHABLE with meaningful data on ANY grounded path: (a) the animation-root-motion path zeroed `Velocity.X/Y` outright every tick (the actual #265/#166 root cause, ten days pre-existing, not a Campaign-P regression), and (b) `PhysicsBody.GroundNormal` — the vector `calc_friction` dots velocity against — had ZERO production writers anywhere and silently defaulted to `Vector3.UnitZ` forever, so even surviving velocity would have been tested against a fake flat-ground normal on any real slope. Both gaps are now closed: `PlayerMovementController.cs`'s grounded block no longer reconstructs `Velocity` for the animation-root-motion case, and `PhysicsEngine.cs` syncs `body.GroundNormal` from the committed `ContactPlane.Normal` at the same commit point that already publishes `ContactPlane`. The 0.25f threshold port itself (this row's original subject) was always correct — it just had nothing real to operate on until this fix. See `docs/research/2026-07-30-265-capture-bisect.md`'s as-fixed addendum. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`GroundNormal` wiring); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (grounded-velocity fix); `tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs` (AP-7 test block); `tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs`; `tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs` | — | — | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70); ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141; `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1; `docs/research/2026-07-30-265-capture-bisect.md` | diff --git a/docs/research/2026-07-31-issue273-tight-gap-support.md b/docs/research/2026-07-31-issue273-tight-gap-support.md new file mode 100644 index 00000000..7ee1c305 --- /dev/null +++ b/docs/research/2026-07-31-issue273-tight-gap-support.md @@ -0,0 +1,95 @@ +# Issue #273 — Holtburg tight-gap support validation + +**Date:** 2026-07-31 +**Status:** implementation, automated gates, and exact live gate pass +**Scope:** grounded player step-down support at a floor edge beside a static +cylinder + +## Captured scene + +The reproducible gap is in outdoor cell `0xA9B40032`, between: + +- building shell GfxObj `0x01000F69`, placed at + `(158.178, 37.7055, 94.0)` with quaternion + `(w=.939319, x=0, y=0, z=-.343045)`; +- static post `0xCA9B4027`, placed at `(160.173, 34.487, 95.975)`, + represented by its Setup-authored cylinder (`radius=.282`, + `height=5.564`); +- the local player Setup's exact two spheres (`radius=.48`, origins + `z=.475` and `z=1.35`). + +The building's supporting ledge terminates at local `x=4`. The first +post-side response moved the player's foot-sphere center to approximately +local `x=4.33`. The full `.48` movement sphere still overlapped the floor, so +the existing step-down path accepted the candidate. Repeated frames then +carried the player around the post and outside the building shell. + +The fixture +`tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json` preserves +the installed DAT PhysicsBSP. The replay in +`Issue273HoltburgTightGapReplayTests` uses the captured object placement, +player spheres, static posts, and movement offsets. + +## Retail mechanism + +The missing rule is not extra collision padding and is not a larger player +sphere. It is retail's second-stage support validation: + +1. `CTransition::step_down` (`0x0050B2A0`) performs the ordinary downward + collision probe. +2. After finding a walkable contact plane, an EdgeSlide mover that is not in + StepUp calls `CTransition::check_walkable` (`0x0050AFF0`). The binary + sequence is `test ah,2` at `0x0050B36A`, which is state bit `0x200` + (`EdgeSlide`), followed by the `step_up == 0` test and call at + `0x0050B380`. +3. `CTransition::check_walkable` first calls + `SPHEREPATH::check_walkables` (`0x0050C3E0`). +4. `SPHEREPATH::check_walkables` halves the saved foot-sphere radius and + calls `CPolygon::check_walkable` (`0x00538E60`). +5. If the remembered polygon does not support that smaller sphere, + `CTransition::check_walkable` performs a downward CheckWalkable insertion. + BSP leaves require both `walkable_hits_sphere` and + `CPolygon::check_small_walkable` (`BSPLEAF::hits_walkable`, + `0x0053D670`). +6. If neither check finds support, `CTransition::step_down` rejects the + candidate and the existing edge-response chain handles it. + +ACDream already had the small-radius BSP-leaf test, but +`DoCheckWalkable` treated the mere presence of a remembered polygon as +success, and the ordinary `DoStepDown(..., runPlacement:false)` path never +called it. This let a full-radius overlap stand in for actual foot support. + +## Port + +- `BSPQuery.CheckWalkableSupport` is the shared resolved-polygon form of + retail `CPolygon::check_walkable`. +- `SpherePath.CheckWalkables` implements the retail half-radius remembered + polygon check without mutating canonical sphere state. +- `Transition.DoCheckWalkable` now tests the remembered polygon rather than + treating a non-null polygon as sufficient. +- `Transition.DoStepDown` restores the EdgeSlide/non-StepUp support gate + before the existing placement-policy seam. + +There are no location checks, object IDs, guessed radii, widened collision +shapes, or gap-specific tolerances in the production fix. + +## Regression impact + +The existing #271 staircase-side replay begins with its center `.288 m` +outside a tread whose retail half-radius support boundary is `.24 m`. +Retail may therefore stop that exact candidate. The test now preserves the +original user-visible invariant—never reverse or accelerate downhill—without +requiring forward progress beyond retail's support boundary. The ordinary +continuous staircase replay still requires and achieves forward progress. + +## Gates + +- issue #273 fixture/replay: 3 passed; +- focused BSP, step-up, edge-slide, #185/#271 family: 42 passed / 1 skipped; +- complete Core tests: 4,111 passed / 2 skipped; +- Release solution build: passed; +- complete Release solution tests: 10,068 passed / 5 skipped. + +The user accepted the exact in-client Holtburg gap gate on 2026-07-31: the +gap blocks from the tested approach, and the adjacent movement checks remain +healthy. diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 7e314906..2d08eb6e 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -284,28 +284,46 @@ public static class BSPQuery CollisionSphere sphere, Vector3 up, bool small) + => CheckWalkableSupport( + poly.Plane, + poly.Vertices, + sphere.Center, + small ? sphere.Radius * 0.5f : sphere.Radius, + up); + + /// + /// Retail CPolygon::check_walkable against an already resolved + /// polygon. The caller supplies the effective support radius; retail's + /// SPHEREPATH::check_walkables halves its saved sphere before + /// entering this routine. + /// + internal static bool CheckWalkableSupport( + Plane plane, + ReadOnlySpan vertices, + Vector3 center, + float supportRadius, + Vector3 up) { - float angleUp = Vector3.Dot(poly.Plane.Normal, up); + float angleUp = Vector3.Dot(plane.Normal, up); if (angleUp < PhysicsGlobals.EPSILON) return false; - float angle = (Vector3.Dot(poly.Plane.Normal, sphere.Center) + poly.Plane.D) / angleUp; - var center = sphere.Center - up * angle; + float angle = (Vector3.Dot(plane.Normal, center) + plane.D) / angleUp; + center -= up * angle; - float radsum = sphere.Radius * sphere.Radius; - if (small) radsum *= 0.25f; + float radsum = supportRadius * supportRadius; - int n = poly.Vertices.Length; + int n = vertices.Length; int prevIdx = n - 1; for (int i = 0; i < n; i++) { - var v = poly.Vertices[i]; - var lv = poly.Vertices[prevIdx]; + var v = vertices[i]; + var lv = vertices[prevIdx]; prevIdx = i; var edge = v - lv; var disp = center - lv; - var cross = Vector3.Cross(poly.Plane.Normal, edge); + var cross = Vector3.Cross(plane.Normal, edge); float diff = Vector3.Dot(disp, cross); if (diff < 0f) diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index b3f9cf81..1f2a1de2 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -850,6 +850,27 @@ public sealed class SpherePath return true; } + /// + /// Retail SPHEREPATH::check_walkables (0x0050C3E0). A missing + /// remembered polygon passes; otherwise the foot sphere is tested against + /// that polygon with half its normal radius. Retail mutates an embedded + /// scratch sphere before the test. Our world-space representation can pass + /// the equivalent effective radius without mutating canonical sphere state. + /// + internal bool CheckWalkables() + { + if (!HasWalkablePolygon || WalkableVertices is null) + return true; + + var footSphere = GlobalSphere[0]; + return BSPQuery.CheckWalkableSupport( + WalkablePlane, + WalkableVertices, + footSphere.Origin, + footSphere.Radius * 0.5f, + WalkableUp); + } + /// /// Retail SPHEREPATH::init reset for a retained transition record. /// Every logical value is restored; only the two private exact-length @@ -5211,6 +5232,22 @@ public sealed class Transition && CollisionInfo.ContactPlaneValid && CollisionInfo.ContactPlane.Normal.Z >= walkableZ) { + // Retail CTransition::step_down (0x0050B2A0) validates the + // candidate's actual support before placement whenever an + // already-grounded mover is not performing a step-up. The first + // check uses SPHEREPATH::check_walkables' half-radius foot sphere; + // when that fails, DoCheckWalkable performs the downward BSP + // probe with the same small-support rule. Omitting this gate let a + // wall/post slide leave most of the player sphere beyond a floor + // edge while the full-radius step-down overlap still counted as + // grounded (issue #273). + if (ObjectInfo.EdgeSlide + && !sp.StepUp + && !DoCheckWalkable(walkableZ, engine)) + { + return false; + } + // L.2.3h (2026-04-29): Placement validation is for the // DoStepUp use case (prevents climbing through walls by // stepping up onto ground beyond a tall wall). For the @@ -5461,8 +5498,10 @@ public sealed class Transition if ((oi.State & ObjectInfoState.OnWalkable) == 0) return true; - // If the current walkable entry is still valid, skip the probe. - if (sp.WalkableValid) + // Retail first validates the remembered polygon with a half-radius + // support sphere. Merely having a polygon pointer is insufficient: + // the candidate may already hang too far beyond its edge. + if (sp.CheckWalkables()) return true; sp.SaveCheckPos(); diff --git a/tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json b/tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json new file mode 100644 index 00000000..bd0deee2 --- /dev/null +++ b/tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json @@ -0,0 +1,1626 @@ +{ + "GfxObjId": 16781161, + "BoundingSphereOrigin": { + "X": -0.270307, + "Y": 0.579602, + "Z": 4.34457 + }, + "BoundingSphereRadius": 9.92391, + "ResolvedPolygons": [ + { + "Id": 0, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0.2503828, + "Y": 0, + "Z": 0.968147 + }, + "D": -9.729877 + }, + "Vertices": [ + { + "X": 4.64, + "Y": 7.9196, + "Z": 8.85 + }, + { + "X": 2.32, + "Y": 7.9196, + "Z": 9.45 + }, + { + "X": 0, + "Y": -5.8396, + "Z": 10.05 + }, + { + "X": 2.32, + "Y": -5.8396, + "Z": 9.45 + } + ] + }, + { + "Id": 1, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": -7.28 + }, + "Vertices": [ + { + "X": 4, + "Y": 7.28, + "Z": 7.55 + }, + { + "X": -4, + "Y": 7.28, + "Z": 7.55 + }, + { + "X": -4, + "Y": 7.28, + "Z": 8.05 + }, + { + "X": 4, + "Y": 7.28, + "Z": 8.05 + } + ] + }, + { + "Id": 2, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": -5.2 + }, + "Vertices": [ + { + "X": -4, + "Y": -5.2, + "Z": 7.55 + }, + { + "X": 4, + "Y": -5.2, + "Z": 7.55 + }, + { + "X": 4, + "Y": -5.2, + "Z": 8.05 + }, + { + "X": -4, + "Y": -5.2, + "Z": 8.05 + } + ] + }, + { + "Id": 3, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1.3701269E-05, + "Y": -0.00013341238, + "Z": 1 + }, + "D": -1.9990327 + }, + "Vertices": [ + { + "X": 4, + "Y": 7.25, + "Z": 2 + }, + { + "X": -4, + "Y": 7.25, + "Z": 2 + }, + { + "X": -3.3, + "Y": 6.50034, + "Z": 1.9998 + }, + { + "X": 3.3, + "Y": 6.5003, + "Z": 2 + } + ] + }, + { + "Id": 4, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0.00012937472, + "Y": 9.773888E-06, + "Z": 1 + }, + "D": -1.9994949 + }, + "Vertices": [ + { + "X": -4, + "Y": -4.7, + "Z": 2 + }, + { + "X": -3.3, + "Y": -2.01574, + "Z": 2 + }, + { + "X": -3.3, + "Y": 6.50034, + "Z": 1.9998 + }, + { + "X": -4, + "Y": 7.25, + "Z": 2 + } + ] + }, + { + "Id": 5, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": -2 + }, + "Vertices": [ + { + "X": 4, + "Y": 7.25, + "Z": 2 + }, + { + "X": 3.3, + "Y": 6.5003, + "Z": 2 + }, + { + "X": 3.3, + "Y": -2.0157, + "Z": 2 + }, + { + "X": 4, + "Y": -4.7, + "Z": 2 + } + ] + }, + { + "Id": 6, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": -2 + }, + "Vertices": [ + { + "X": -3.3, + "Y": -2.01574, + "Z": 2 + }, + { + "X": -4, + "Y": -4.7, + "Z": 2 + }, + { + "X": 1, + "Y": -2.01571, + "Z": 2 + } + ] + }, + { + "Id": 7, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": 1 + }, + "D": -2 + }, + "Vertices": [ + { + "X": 1, + "Y": -2.01571, + "Z": 2 + }, + { + "X": -4, + "Y": -4.7, + "Z": 2 + }, + { + "X": 4, + "Y": -4.7, + "Z": 2 + }, + { + "X": 3.3, + "Y": -2.0157, + "Z": 2 + } + ] + }, + { + "Id": 8, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -4.1392085E-09, + "Z": -1 + }, + "D": 3.000926E-08 + }, + "Vertices": [ + { + "X": -4, + "Y": 7.25, + "Z": 0 + }, + { + "X": 4, + "Y": 7.25, + "Z": 0 + }, + { + "X": 4, + "Y": -7.15, + "Z": 5.96046E-08 + }, + { + "X": -4, + "Y": -7.15, + "Z": 5.96046E-08 + } + ] + }, + { + "Id": 9, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -0.63237643, + "Z": 0.77466124 + }, + "D": -4.5214915 + }, + "Vertices": [ + { + "X": -4, + "Y": -7.15, + "Z": 5.96046E-08 + }, + { + "X": 4, + "Y": -7.15, + "Z": 5.96046E-08 + }, + { + "X": 4, + "Y": -4.7, + "Z": 2 + }, + { + "X": -4, + "Y": -4.7, + "Z": 2 + } + ] + }, + { + "Id": 10, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": -4 + }, + "Vertices": [ + { + "X": -4, + "Y": -4.7, + "Z": 2 + }, + { + "X": -4, + "Y": 7.25, + "Z": 2 + }, + { + "X": -4, + "Y": 7.25, + "Z": 0 + } + ] + }, + { + "Id": 11, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4 + }, + "Vertices": [ + { + "X": 4, + "Y": 7.25, + "Z": 0 + }, + { + "X": 4, + "Y": 7.25, + "Z": 2 + }, + { + "X": 4, + "Y": -4.7, + "Z": 2 + } + ] + }, + { + "Id": 12, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": -7.25 + }, + "Vertices": [ + { + "X": 4, + "Y": 7.25, + "Z": 2 + }, + { + "X": 4, + "Y": 7.25, + "Z": 0 + }, + { + "X": -4, + "Y": 7.25, + "Z": 0 + }, + { + "X": -4, + "Y": 7.25, + "Z": 2 + } + ] + }, + { + "Id": 13, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": -7.9196 + }, + "Vertices": [ + { + "X": 2.32, + "Y": 7.9196, + "Z": 9.45 + }, + { + "X": -2.32, + "Y": 7.9196, + "Z": 9.45 + }, + { + "X": 0, + "Y": 7.9196, + "Z": 10.05 + } + ] + }, + { + "Id": 14, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": -7.9196 + }, + "Vertices": [ + { + "X": -2.32, + "Y": 7.9196, + "Z": 9.45 + }, + { + "X": 0, + "Y": 7.9196, + "Z": 8.85 + }, + { + "X": -4.64, + "Y": 7.9196, + "Z": 8.85 + } + ] + }, + { + "Id": 15, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": -7.9196 + }, + "Vertices": [ + { + "X": 0, + "Y": 7.9196, + "Z": 8.85 + }, + { + "X": 2.32, + "Y": 7.9196, + "Z": 9.45 + }, + { + "X": 4.64, + "Y": 7.9196, + "Z": 8.85 + } + ] + }, + { + "Id": 16, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": -7.9196 + }, + "Vertices": [ + { + "X": 2.32, + "Y": 7.9196, + "Z": 9.45 + }, + { + "X": 0, + "Y": 7.9196, + "Z": 8.85 + }, + { + "X": -2.32, + "Y": 7.9196, + "Z": 9.45 + } + ] + }, + { + "Id": 17, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": -5.8396 + }, + "Vertices": [ + { + "X": -2.32, + "Y": -5.8396, + "Z": 9.45 + }, + { + "X": 2.32, + "Y": -5.8396, + "Z": 9.45 + }, + { + "X": 0, + "Y": -5.8396, + "Z": 10.05 + } + ] + }, + { + "Id": 18, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": -5.8396 + }, + "Vertices": [ + { + "X": 2.32, + "Y": -5.8396, + "Z": 9.45 + }, + { + "X": 0, + "Y": -5.8396, + "Z": 8.85 + }, + { + "X": 4.64, + "Y": -5.8396, + "Z": 8.85 + } + ] + }, + { + "Id": 19, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": -5.8396 + }, + "Vertices": [ + { + "X": 0, + "Y": -5.8396, + "Z": 8.85 + }, + { + "X": -2.32, + "Y": -5.8396, + "Z": 9.45 + }, + { + "X": -4.64, + "Y": -5.8396, + "Z": 8.85 + } + ] + }, + { + "Id": 20, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": -5.8396 + }, + "Vertices": [ + { + "X": -2.32, + "Y": -5.8396, + "Z": 9.45 + }, + { + "X": 0, + "Y": -5.8396, + "Z": 8.85 + }, + { + "X": 2.32, + "Y": -5.8396, + "Z": 9.45 + } + ] + }, + { + "Id": 21, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -0.5302367, + "Z": -0.84784967 + }, + "D": 4.067959 + }, + "Vertices": [ + { + "X": -4, + "Y": -5.2, + "Z": 8.05 + }, + { + "X": 4, + "Y": -5.2, + "Z": 8.05 + }, + { + "X": 4.62, + "Y": -5.8396, + "Z": 8.45 + }, + { + "X": -4.62, + "Y": -5.8396, + "Z": 8.45 + } + ] + }, + { + "Id": 22, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0.5421266, + "Y": 0, + "Z": -0.8402968 + }, + "D": 4.595883 + }, + "Vertices": [ + { + "X": 4, + "Y": -5.2, + "Z": 8.05 + }, + { + "X": 4, + "Y": 7.28, + "Z": 8.05 + }, + { + "X": 4.62, + "Y": 7.9196, + "Z": 8.45 + }, + { + "X": 4.62, + "Y": -5.8396, + "Z": 8.45 + } + ] + }, + { + "Id": 23, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": -3.3 + }, + "Vertices": [ + { + "X": -3.3, + "Y": -2.01574, + "Z": 7.55 + }, + { + "X": -3.3, + "Y": 6.50034, + "Z": 7.55 + }, + { + "X": -3.3, + "Y": 6.50034, + "Z": 1.9998 + }, + { + "X": -3.3, + "Y": -2.01574, + "Z": 2 + } + ] + }, + { + "Id": 24, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -3.3 + }, + "Vertices": [ + { + "X": 3.3, + "Y": 6.5003, + "Z": 2 + }, + { + "X": 3.3, + "Y": 6.5003, + "Z": 7.55 + }, + { + "X": 3.3, + "Y": -2.0157, + "Z": 7.55 + }, + { + "X": 3.3, + "Y": -2.0157, + "Z": 2 + } + ] + }, + { + "Id": 25, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 6.0688367E-06, + "Y": 1, + "Z": 0 + }, + "D": -6.50032 + }, + "Vertices": [ + { + "X": 3.3, + "Y": 6.5003, + "Z": 2 + }, + { + "X": -3.3, + "Y": 6.50034, + "Z": 1.9998 + }, + { + "X": -3.3, + "Y": 6.50034, + "Z": 7.55 + }, + { + "X": 3.3, + "Y": 6.5003, + "Z": 7.55 + } + ] + }, + { + "Id": 26, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": -4 + }, + "Vertices": [ + { + "X": -4, + "Y": 7.28, + "Z": 7.55 + }, + { + "X": -4, + "Y": -5.2, + "Z": 7.55 + }, + { + "X": -4, + "Y": -5.2, + "Z": 8.05 + }, + { + "X": -4, + "Y": 7.28, + "Z": 8.05 + } + ] + }, + { + "Id": 27, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4 + }, + "Vertices": [ + { + "X": 4, + "Y": -5.2, + "Z": 7.55 + }, + { + "X": 4, + "Y": 7.28, + "Z": 7.55 + }, + { + "X": 4, + "Y": 7.28, + "Z": 8.05 + }, + { + "X": 4, + "Y": -5.2, + "Z": 8.05 + } + ] + }, + { + "Id": 28, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0.2503826, + "Y": 0, + "Z": 0.968147 + }, + "D": -9.7298765 + }, + "Vertices": [ + { + "X": 2.32, + "Y": -5.8396, + "Z": 9.45 + }, + { + "X": 4.64, + "Y": -5.8396, + "Z": 8.85 + }, + { + "X": 4.64, + "Y": 7.9196, + "Z": 8.85 + } + ] + }, + { + "Id": 29, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -0.2503828, + "Y": 0, + "Z": 0.968147 + }, + "D": -9.729877 + }, + "Vertices": [ + { + "X": -4.64, + "Y": 7.9196, + "Z": 8.85 + }, + { + "X": -4.64, + "Y": -5.8396, + "Z": 8.85 + }, + { + "X": 0, + "Y": -5.8396, + "Z": 10.05 + }, + { + "X": 0, + "Y": 7.9196, + "Z": 10.05 + } + ] + }, + { + "Id": 30, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0.530237, + "Z": -0.8478495 + }, + "D": 2.965063 + }, + "Vertices": [ + { + "X": 4, + "Y": 7.28, + "Z": 8.05 + }, + { + "X": -4, + "Y": 7.28, + "Z": 8.05 + }, + { + "X": -4.62, + "Y": 7.9196, + "Z": 8.45 + }, + { + "X": 4.62, + "Y": 7.9196, + "Z": 8.45 + } + ] + }, + { + "Id": 31, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -0.9987523, + "Y": 0, + "Z": -0.049937498 + }, + "D": -4.1922636 + }, + "Vertices": [ + { + "X": -4.62, + "Y": 7.9196, + "Z": 8.45 + }, + { + "X": -4.62, + "Y": -5.8396, + "Z": 8.45 + }, + { + "X": -4.64, + "Y": -5.8396, + "Z": 8.85 + }, + { + "X": -4.64, + "Y": 7.9196, + "Z": 8.85 + } + ] + }, + { + "Id": 32, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 1, + "Z": 0 + }, + "D": -7.9196 + }, + "Vertices": [ + { + "X": 4.62, + "Y": 7.9196, + "Z": 8.45 + }, + { + "X": -4.62, + "Y": 7.9196, + "Z": 8.45 + }, + { + "X": -4.64, + "Y": 7.9196, + "Z": 8.85 + }, + { + "X": 4.64, + "Y": 7.9196, + "Z": 8.85 + } + ] + }, + { + "Id": 33, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0.9987523, + "Y": 0, + "Z": -0.049937498 + }, + "D": -4.1922636 + }, + "Vertices": [ + { + "X": 4.62, + "Y": -5.8396, + "Z": 8.45 + }, + { + "X": 4.62, + "Y": 7.9196, + "Z": 8.45 + }, + { + "X": 4.64, + "Y": 7.9196, + "Z": 8.85 + }, + { + "X": 4.64, + "Y": -5.8396, + "Z": 8.85 + } + ] + }, + { + "Id": 34, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": -1, + "Z": 0 + }, + "D": -5.8396 + }, + "Vertices": [ + { + "X": -4.62, + "Y": -5.8396, + "Z": 8.45 + }, + { + "X": 4.62, + "Y": -5.8396, + "Z": 8.45 + }, + { + "X": 4.64, + "Y": -5.8396, + "Z": 8.85 + }, + { + "X": -4.64, + "Y": -5.8396, + "Z": 8.85 + } + ] + }, + { + "Id": 35, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -0.5421266, + "Y": 0, + "Z": -0.8402968 + }, + "D": 4.595883 + }, + "Vertices": [ + { + "X": -4, + "Y": 7.28, + "Z": 8.05 + }, + { + "X": -4, + "Y": -5.2, + "Z": 8.05 + }, + { + "X": -4.62, + "Y": -5.8396, + "Z": 8.45 + }, + { + "X": -4.62, + "Y": 7.9196, + "Z": 8.45 + } + ] + }, + { + "Id": 36, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 4.3537307E-06, + "Y": -1, + "Z": 0 + }, + "D": -2.0157256 + }, + "Vertices": [ + { + "X": -3.3, + "Y": -2.01574, + "Z": 2 + }, + { + "X": -1, + "Y": -2.01573, + "Z": 2 + }, + { + "X": -1, + "Y": -2.01573, + "Z": 5.528 + }, + { + "X": -3.3, + "Y": -2.01574, + "Z": 7.55 + } + ] + }, + { + "Id": 37, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 7.871597E-06, + "Y": -1, + "Z": 2.000765E-06 + }, + "D": -2.0157309 + }, + "Vertices": [ + { + "X": 1, + "Y": -2.01571, + "Z": 7.55 + }, + { + "X": -3.3, + "Y": -2.01574, + "Z": 7.55 + }, + { + "X": -1, + "Y": -2.01573, + "Z": 5.528 + }, + { + "X": 1, + "Y": -2.01571, + "Z": 5.528 + } + ] + }, + { + "Id": 38, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 4.3537307E-06, + "Y": -1, + "Z": 0 + }, + "D": -2.0157144 + }, + "Vertices": [ + { + "X": 1, + "Y": -2.01571, + "Z": 5.528 + }, + { + "X": 1, + "Y": -2.01571, + "Z": 2 + }, + { + "X": 3.3, + "Y": -2.0157, + "Z": 2 + }, + { + "X": 3.3, + "Y": -2.0157, + "Z": 7.55 + } + ] + }, + { + "Id": 39, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 7.55 + }, + "Vertices": [ + { + "X": 4, + "Y": 7.28, + "Z": 7.55 + }, + { + "X": 3.3, + "Y": 6.5003, + "Z": 7.55 + }, + { + "X": -3.3, + "Y": 6.50034, + "Z": 7.55 + }, + { + "X": -4, + "Y": 7.28, + "Z": 7.55 + } + ] + }, + { + "Id": 40, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 7.5500007 + }, + "Vertices": [ + { + "X": -3.3, + "Y": 6.50034, + "Z": 7.55 + }, + { + "X": -3.3, + "Y": -2.01574, + "Z": 7.55 + }, + { + "X": -4, + "Y": -5.2, + "Z": 7.55 + } + ] + }, + { + "Id": 41, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 7.5500007 + }, + "Vertices": [ + { + "X": -3.3, + "Y": 6.50034, + "Z": 7.55 + }, + { + "X": -4, + "Y": -5.2, + "Z": 7.55 + }, + { + "X": -4, + "Y": 7.28, + "Z": 7.55 + } + ] + }, + { + "Id": 42, + "NumPoints": 4, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 7.55 + }, + "Vertices": [ + { + "X": -3.3, + "Y": -2.01574, + "Z": 7.55 + }, + { + "X": 3.3, + "Y": -2.0157, + "Z": 7.55 + }, + { + "X": 4, + "Y": -5.2, + "Z": 7.55 + }, + { + "X": -4, + "Y": -5.2, + "Z": 7.55 + } + ] + }, + { + "Id": 43, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 7.5500007 + }, + "Vertices": [ + { + "X": 3.3, + "Y": -2.0157, + "Z": 7.55 + }, + { + "X": 3.3, + "Y": 6.5003, + "Z": 7.55 + }, + { + "X": 4, + "Y": 7.28, + "Z": 7.55 + } + ] + }, + { + "Id": 44, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0, + "Y": 0, + "Z": -1 + }, + "D": 7.5500007 + }, + "Vertices": [ + { + "X": 3.3, + "Y": -2.0157, + "Z": 7.55 + }, + { + "X": 4, + "Y": 7.28, + "Z": 7.55 + }, + { + "X": 4, + "Y": -5.2, + "Z": 7.55 + } + ] + }, + { + "Id": 45, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 4.3537307E-06, + "Y": -1, + "Z": 0 + }, + "D": -2.0157144 + }, + "Vertices": [ + { + "X": 1, + "Y": -2.01571, + "Z": 5.528 + }, + { + "X": 3.3, + "Y": -2.0157, + "Z": 7.55 + }, + { + "X": 1, + "Y": -2.01571, + "Z": 7.55 + } + ] + }, + { + "Id": 46, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": -1, + "Y": 0, + "Z": 0 + }, + "D": -4 + }, + "Vertices": [ + { + "X": -4, + "Y": 7.25, + "Z": 0 + }, + { + "X": -4, + "Y": -7.15, + "Z": 5.96046E-08 + }, + { + "X": -4, + "Y": -4.7, + "Z": 2 + } + ] + }, + { + "Id": 47, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 1, + "Y": 0, + "Z": 0 + }, + "D": -4 + }, + "Vertices": [ + { + "X": 4, + "Y": -4.7, + "Z": 2 + }, + { + "X": 4, + "Y": -7.15, + "Z": 5.96046E-08 + }, + { + "X": 4, + "Y": 7.25, + "Z": 0 + } + ] + }, + { + "Id": 48, + "NumPoints": 3, + "SidesType": 0, + "Plane": { + "Normal": { + "X": 0.250383, + "Y": 0, + "Z": 0.96814686 + }, + "D": -9.7298765 + }, + "Vertices": [ + { + "X": 2.32, + "Y": 7.9196, + "Z": 9.45 + }, + { + "X": 0, + "Y": 7.9196, + "Z": 10.05 + }, + { + "X": 0, + "Y": -5.8396, + "Z": 10.05 + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs index 9ee07592..de6bf12b 100644 --- a/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs @@ -179,12 +179,18 @@ public class Issue185OutdoorStairsSeamReplayTests /// /// #271 live capture, quantum 310: a forward/uphill displacement that also - /// presses into the staircase's side wall must keep its uphill tangent. - /// Pre-fix the composite retry path reversed that tangent, moving from - /// Y=75.539 to Y=75.199 and rapidly carrying the player back down the stairs. + /// presses into the staircase's side wall must never reverse downhill. + /// Pre-fix the composite retry path moved from Y=75.539 to Y=75.199 and + /// rapidly carried the player back down the stairs. + /// + /// The captured center is 0.288 m beyond the tread's side edge. Retail's + /// SPHEREPATH::check_walkables uses a 0.24 m half-radius support sphere, so + /// stopping at this exact side-wall position is valid; advancing farther + /// uphill is not. The regression invariant is therefore no downhill motion, + /// not mandatory forward progress beyond retail's support boundary. /// [Fact] - public void OutdoorStairs_SideWallContact_DoesNotReverseUphillTangent() + public void OutdoorStairs_SideWallContact_DoesNotReverseDownhill() { var engine = BuildStairEngine(); var body = GroundedOnTread(); @@ -211,8 +217,8 @@ public class Issue185OutdoorStairsSeamReplayTests $"collision={result.CollisionNormalValid} " + $"normal=({result.CollisionNormal.X:F3},{result.CollisionNormal.Y:F3},{result.CollisionNormal.Z:F3})"); - Assert.True(result.Position.Y > body.Position.Y + 0.25f, - $"Side-wall response failed to preserve meaningful uphill motion: " + + Assert.True(result.Position.Y >= body.Position.Y - 0.001f, + $"Side-wall response reversed the intended uphill motion: " + $"{body.Position.Y:F6} -> {result.Position.Y:F6}."); Assert.True(result.Position.Z >= body.Position.Z - 0.001f, $"Side-wall response dropped the grounded player downhill: " + diff --git a/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs new file mode 100644 index 00000000..66abfcc7 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Immutable; +using System.IO; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Issue #273 — exact Holtburg tight-gap replay captured live on 2026-07-31. +/// The player presses between building shell 0x01000F69 and the timber post +/// at 0xCA9B4027. Retail blocks this passage; before the fix ACDream lets the +/// post slide feed a displaced step-down probe into precipice-slide, which +/// carries the player around the post and along the building's outer edge. +/// +/// The shell collision is a self-contained dump of the installed DAT's real +/// PhysicsBSP. Post dimensions, building frame, player Setup spheres, and +/// movement frames are copied from the live trace. +/// +public sealed class Issue273HoltburgTightGapReplayTests +{ + private readonly ITestOutputHelper _output; + + public Issue273HoltburgTightGapReplayTests(ITestOutputHelper output) + => _output = output; + + private const uint Landblock = 0xA9B40000u; + private const uint Cell = 0xA9B40032u; + private const uint ShellGfxObj = 0x01000F69u; + private const uint PlayerEntity = 0x000F4243u; + + private static readonly Vector3 BuildingOrigin = new(158.178f, 37.7055f, 94f); + private static readonly Quaternion BuildingRotation = + Quaternion.Normalize(new Quaternion(0f, 0f, -0.343045f, 0.939319f)); + private static readonly Matrix4x4 BuildingTransform = + Matrix4x4.CreateFromQuaternion(BuildingRotation) + * Matrix4x4.CreateTranslation(BuildingOrigin); + + private static readonly ImmutableArray PlayerSpheres = + [ + new FlatCollisionSphere(new Vector3(0f, 0f, 0.475f), 0.480f), + new FlatCollisionSphere(new Vector3(0f, 0f, 1.350f), 0.480f), + ]; + + private static PhysicsEngine BuildEngine() + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + + string dumpPath = Path.Combine( + SolutionRoot(), + "tests", + "AcDream.Core.Tests", + "Fixtures", + "issue273", + "0x01000F69.gfxobj.json"); + Assert.True(File.Exists(dumpPath), $"Missing issue #273 fixture: {dumpPath}"); + cache.RegisterGfxObjForTest( + ShellGfxObj, + GfxObjDumpSerializer.Hydrate(GfxObjDumpSerializer.Read(dumpPath))); + + // The shell is registered through retail's building channel, not as a + // shadow object. Its one portal is irrelevant to this exterior sweep. + cache.CacheBuilding( + Cell, + Array.Empty(), + BuildingTransform, + ShellGfxObj); + + // Terrain is deliberately below the shell. The player stands on the + // shell's authored z=2 ledge (world z=96), not synthetic terrain. + var heights = new byte[81]; + var heightTable = new float[256]; + Array.Fill(heightTable, -1000f); + engine.AddLandblock( + Landblock, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + + RegisterPost( + engine, + 0xCA9B4027u, + new Vector3(160.173f, 34.487f, 95.975f), + radius: 0.282f); + RegisterPost( + engine, + 0xCA9B402Eu, + new Vector3(158.282f, 34.610f, 95.975f), + radius: 0.282f); + RegisterPost( + engine, + 0xCA9B402Fu, + new Vector3(157.952f, 32.239f, 96f), + radius: 0.600f); + + return engine; + } + + private static void RegisterPost( + PhysicsEngine engine, + uint entityId, + Vector3 basePosition, + float radius) + { + engine.ShadowObjects.Register( + entityId, + gfxObjId: 0u, + worldPos: basePosition, + rotation: Quaternion.Identity, + radius, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: Landblock, + collisionType: ShadowCollisionType.Cylinder, + cylHeight: 5.564f, + state: 0u, + seedCellId: Cell, + isStatic: true); + } + + private static PhysicsBody GroundedBody(Vector3 position) + { + Vector3[] localWalkable = + [ + new(4f, 7.25f, 2f), + new(3.3f, 6.5003f, 2f), + new(3.3f, -2.0157f, 2f), + new(4f, -4.7f, 2f), + ]; + var worldWalkable = new Vector3[localWalkable.Length]; + for (int i = 0; i < localWalkable.Length; i++) + worldWalkable[i] = Vector3.Transform(localWalkable[i], BuildingTransform); + + var floor = new Plane(Vector3.UnitZ, -96f); + return new PhysicsBody + { + Position = position, + Orientation = Quaternion.Identity, + ContactPlaneValid = true, + ContactPlane = floor, + ContactPlaneCellId = Cell, + WalkablePolygonValid = true, + WalkablePlane = floor, + WalkableUp = Vector3.UnitZ, + WalkableVertices = worldWalkable, + TransientState = + TransientStateFlags.Contact | TransientStateFlags.OnWalkable, + }; + } + + [Theory] + [InlineData(1.239f, true)] + [InlineData(1.241f, false)] + public void RetailHalfRadiusSupport_RejectsCenterBeyondQuarterMeterEdge( + float centerX, + bool expected) + { + Vector3[] floor = + [ + new(0f, 0f, 0f), + new(1f, 0f, 0f), + new(1f, 1f, 0f), + new(0f, 1f, 0f), + ]; + + // The player's 0.48 m foot sphere becomes a 0.24 m support sphere in + // SPHEREPATH::check_walkables. Just inside that boundary is supported; + // just outside it is not, even though the full movement sphere still + // overlaps the floor polygon. + bool supported = BSPQuery.CheckWalkableSupport( + new Plane(Vector3.UnitZ, 0f), + floor, + new Vector3(centerX, 0.5f, 0.48f), + supportRadius: 0.24f, + Vector3.UnitZ); + + Assert.Equal(expected, supported); + } + + [Fact] + public void CapturedRun_DoesNotSqueezeBetweenPostAndBuilding() + { + PhysicsEngine engine = BuildEngine(); + Vector3 position = new(160.016f, 33.562f, 96.005f); + var body = GroundedBody(position); + uint cell = Cell; + + // First frame is copied verbatim from the live capture. Subsequent + // held-forward frames use the stable displacement visible in that + // same trace after input acceleration settles. + Vector3[] offsets = + [ + new(1.396f, 1.123f, 0f), + new(0.727f, 0.584f, 0f), + new(0.624f, 0.501f, 0f), + new(0.727f, 0.585f, 0f), + new(0.728f, 0.585f, 0f), + new(0.727f, 0.585f, 0f), + new(0.727f, 0.585f, 0f), + new(0.728f, 0.585f, 0f), + ]; + + for (int frame = 0; frame < offsets.Length; frame++) + { + ResolveResult result = engine.ResolveWithTransition( + currentPos: position, + targetPos: position + offsets[frame], + cellId: cell, + sphereRadius: 0.48f, + sphereHeight: 1.835f, + stepUpHeight: 0.6f, + stepDownHeight: 1.5f, + isOnGround: true, + body: body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: PlayerEntity, + sphereList: PlayerSpheres, + sphereScale: 1f); + + _output.WriteLine( + $"f{frame}: in=({position.X:F3},{position.Y:F3},{position.Z:F3}) " + + $"out=({result.Position.X:F3},{result.Position.Y:F3},{result.Position.Z:F3}) " + + $"hit={result.CollisionNormalValid} " + + $"normal=({result.CollisionNormal.X:F3}," + + $"{result.CollisionNormal.Y:F3},{result.CollisionNormal.Z:F3})"); + + position = result.Position; + cell = result.CellId; + body.Position = position; + } + + // The captured broken run reached (163.234, 36.982) by this point, + // already beyond the post and sliding along the building. Retail + // blocks the passage before the player can cross the post's Y. + Assert.True( + position.Y < 34.487f, + $"Player squeezed through the retail-blocked gap: " + + $"final=({position.X:F3},{position.Y:F3},{position.Z:F3})."); + } + + private static string SolutionRoot() + { + string? directory = AppContext.BaseDirectory; + while (!string.IsNullOrEmpty(directory)) + { + if (File.Exists(Path.Combine(directory, "AcDream.slnx"))) + return directory; + directory = Path.GetDirectoryName(directory); + } + + throw new InvalidOperationException( + $"Could not locate AcDream.slnx from {AppContext.BaseDirectory}."); + } +} diff --git a/tools/A8CellAudit/Program.cs b/tools/A8CellAudit/Program.cs index d3948ac8..c9d3df60 100644 --- a/tools/A8CellAudit/Program.cs +++ b/tools/A8CellAudit/Program.cs @@ -323,9 +323,9 @@ static (int RegistryBuildings, int ShellEntities) DumpLandblockBuildings(LandBlo { uint lbPrefix = landblockId & 0xFFFF0000u; uint stabIdBase = 0xC0000000u - | (((landblockId >> 24) & 0xFFu) << 16) - | (((landblockId >> 16) & 0xFFu) << 8); - uint nextEntityId = stabIdBase + 1u; + | (((landblockId >> 24) & 0xFFu) << 20) + | (((landblockId >> 16) & 0xFFu) << 12); + uint nextEntityId = stabIdBase; int supportedObjects = 0; foreach (var obj in info.Objects) @@ -333,7 +333,13 @@ static (int RegistryBuildings, int ShellEntities) DumpLandblockBuildings(LandBlo if (!IsSupported(obj.Id)) continue; supportedObjects++; - nextEntityId++; + uint entityId = nextEntityId++; + Console.WriteLine( + $"objectOrdinal={supportedObjects} entity=0x{entityId:X8} " + + $"model=0x{obj.Id:X8} " + + $"pos=({obj.Frame.Origin.X:R},{obj.Frame.Origin.Y:R},{obj.Frame.Origin.Z:R}) " + + $"quat=({obj.Frame.Orientation.W:R},{obj.Frame.Orientation.X:R}," + + $"{obj.Frame.Orientation.Y:R},{obj.Frame.Orientation.Z:R})"); } Console.WriteLine( @@ -365,7 +371,9 @@ static (int RegistryBuildings, int ShellEntities) DumpLandblockBuildings(LandBlo Console.WriteLine( $"buildingOrdinal={zeroBased + 1} registryId={registryText} shellEntity=0x{shellEntityId:X8} " + - $"model=0x{building.ModelId:X8} pos=({building.Frame.Origin.X:F2},{building.Frame.Origin.Y:F2},{building.Frame.Origin.Z:F2}) " + + $"model=0x{building.ModelId:X8} pos=({building.Frame.Origin.X:R},{building.Frame.Origin.Y:R},{building.Frame.Origin.Z:R}) " + + $"quat=({building.Frame.Orientation.W:R},{building.Frame.Orientation.X:R}," + + $"{building.Frame.Orientation.Y:R},{building.Frame.Orientation.Z:R}) " + $"portalCells={portalText}"); } @@ -436,6 +444,25 @@ static void DumpGfxObj(DatCollection dats, uint gfxObjId) Console.WriteLine( $"classify: walls={walls} (outwardFacing={outwardWalls} inwardFacing={inwardWalls}) " + $"floors={floors} ceilings={ceilings} slopes={slopes}"); + Console.WriteLine("physics polygons:"); + foreach (var (polyId, poly) in g.PhysicsPolygons.OrderBy(p => p.Key)) + { + Vector3 polyMin = new(float.MaxValue); + Vector3 polyMax = new(float.MinValue); + foreach (ushort vertexId in poly.VertexIds.Select(id => (ushort)id)) + { + if (!g.VertexArray.Vertices.TryGetValue(vertexId, out var vertex)) + continue; + polyMin = Vector3.Min(polyMin, vertex.Origin); + polyMax = Vector3.Max(polyMax, vertex.Origin); + } + Vector3 normal = ComputeNormalG(g, poly); + Console.WriteLine( + $" poly=0x{polyId:X4} n=({normal.X:F3},{normal.Y:F3},{normal.Z:F3}) " + + $"min=({polyMin.X:F3},{polyMin.Y:F3},{polyMin.Z:F3}) " + + $"max=({polyMax.X:F3},{polyMax.Y:F3},{polyMax.Z:F3}) " + + $"sides={poly.SidesType} stip={poly.Stippling}"); + } Console.WriteLine(); } diff --git a/tools/SetupInspect/Program.cs b/tools/SetupInspect/Program.cs index c08c1225..54ea24cc 100644 --- a/tools/SetupInspect/Program.cs +++ b/tools/SetupInspect/Program.cs @@ -45,6 +45,26 @@ if (!dats.TryGet(setupId, out var setup) || setup is null) Console.WriteLine($"=== Setup 0x{setupId:X8} ==="); Console.WriteLine($"Flags = 0x{(uint)setup.Flags:X8}"); +Console.WriteLine($"Radius/Height = {setup.Radius:F3} / {setup.Height:F3}"); +Console.WriteLine($"StepUp/StepDown = {setup.StepUpHeight:F3} / {setup.StepDownHeight:F3}"); +Console.WriteLine($"Spheres = {setup.Spheres.Count}"); +for (int i = 0; i < setup.Spheres.Count; i++) +{ + Sphere sphere = setup.Spheres[i]; + Console.WriteLine( + $" sphere[{i}] origin=({sphere.Origin.X:R},{sphere.Origin.Y:R},{sphere.Origin.Z:R}) " + + $"radius={sphere.Radius:R} " + + $"radiusBits=0x{BitConverter.SingleToUInt32Bits(sphere.Radius):X8}"); +} +Console.WriteLine($"CylSpheres = {setup.CylSpheres.Count}"); +for (int i = 0; i < setup.CylSpheres.Count; i++) +{ + CylSphere cylinder = setup.CylSpheres[i]; + Console.WriteLine( + $" cyl[{i}] origin=({cylinder.Origin.X:R},{cylinder.Origin.Y:R},{cylinder.Origin.Z:R}) " + + $"radius={cylinder.Radius:R} height={cylinder.Height:R} " + + $"radiusBits=0x{BitConverter.SingleToUInt32Bits(cylinder.Radius):X8}"); +} Console.WriteLine($"Parts = {setup.Parts.Count}"); for (int i = 0; i < setup.Parts.Count; i++) { @@ -78,7 +98,10 @@ foreach (uint gfxId in setup.Parts.Select(p => (uint)p).Distinct()) Console.WriteLine( $" gfx=0x{gfxId:X8} verts={count} " + $"x[{minX:F2},{maxX:F2}] y[{minY:F2},{maxY:F2}] z[{minZ:F2},{maxZ:F2}] " - + $"sortCenter=({gfx.SortCenter.X:F2},{gfx.SortCenter.Y:F2},{gfx.SortCenter.Z:F2})"); + + $"sortCenter=({gfx.SortCenter.X:F2},{gfx.SortCenter.Y:F2},{gfx.SortCenter.Z:F2}) " + + $"flags=0x{(uint)gfx.Flags:X8} " + + $"physicsBsp={(gfx.PhysicsBSP?.Root is null ? "none" : "present")} " + + $"physicsPolygons={gfx.PhysicsPolygons.Count}"); } Console.WriteLine($"DefaultAnimation = 0x{(uint)setup.DefaultAnimation:X8}");