From d3c0d9ec0e208d8d9f0791aaee809f83a5ac93ba Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 14:08:51 +0200 Subject: [PATCH] test(physics): harden TS-4 production chronology --- .../retail-divergence-register.md | 13 +- .../2026-07-29-physics-parity-campaign.md | 63 +- ...0-response-layer-edge-family-pseudocode.md | 103 ++- .../Ts4ProductionQuantumConformanceTests.cs | 695 ++++++++++++++++++ .../Physics/Ts4SteepRoofWedgeCaptureTests.cs | 34 +- 5 files changed, 807 insertions(+), 101 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index f845dff9..94269d5f 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -241,7 +241,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | ~~TS-1~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the row was stale, not the code.** The cited `:1254` line is unrelated stepping-loop code; the file moved substantially since the row was written. Retail's `EdgeSlide → PrecipiceSlide / CliffSlide` chain is already a real, tested port: `SpherePath.PrecipiceSlide` (`TransitionTypes.cs:943-970`, retail `SPHEREPATH::precipice_slide` pc:274316), `Transition.CliffSlide` (`:2080-2164`, retail `CTransition::cliff_slide` pc:272397, return-value mapping verified against `acclient.h:6100-6108`), and `Transition.EdgeSlideAfterStepDownFailed` (`:1907-2078`, mirrors `CTransition::edge_slide` pc:273001-273090). The one real gap (back-probe fallback skipping retail's `walkable_check_pos`/`localspace_sphere` recache, pc:274318-274326) needed no code change: acdream's `WalkableVertices`/`GlobalSphere` are populated in unified world space at assignment time (`SetWalkable`/`SetWalkableTransformed`, `SetCheckPos`/`RestoreCheckPos`), so both operands `BSPQuery.FindCrossedEdge` compares are already commensurable — retail's per-cell local-frame reprojection is a no-op correction here. Documented in-code at the back-probe site and pinned by `EdgeSlideBackProbePrecipiceSlideTests`. The chain's two acdream-only compensating branches (CliffSlide's three-source reference-normal fallback; the walkable-steepness reroute to CliffSlide before PrecipiceSlide) are real, non-retail additions — filed as AD-53 / AD-54 rather than folded into this row. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SpherePath.PrecipiceSlide`, `Transition.CliffSlide`, `Transition.EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/EdgeSlideBackProbePrecipiceSlideTests.cs` | — | — | `SPHEREPATH::precipice_slide` pc:274316 (0050cc80); `CTransition::cliff_slide` pc:272397 (0050a6d0); `CTransition::edge_slide` pc:273001-273090 (0050b3d0); `SPHEREPATH::get_walkable_pos`/`cache_localspace_sphere`/`set_walkable_check_pos` pc:274318-274326 (0050a8f0/0050c9d0/00509ce0); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2, §6 Step 1 | -| ~~TS-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 2B).** The graph and prepared-flat Path-6 implementations now match retail's exact two-sphere split: every primary/foot polygon hit calls `SetCollide`, sets `WalkableAllowance=LandingZ`, and returns `Adjusted`; only a secondary/head hit writes `CollisionNormal` and returns `Collided`. The steep tangent shortcut and both BSP-layer `SetSlidingNormal` writes are deleted. Exact site tests pin all changed and preserved fields plus raw-bit graph/flat parity. Production-shaped multi-frame vertical/inward/tangential/uphill/downhill roof/wall/ledge traces carry accepted body state between frames, match graph/flat by raw result/body bits, reject penetration and uphill launch/bounce, and pass the complete historical collision matrix without compensation. | `src/AcDream.Core/Physics/BSPQuery.cs`; `src/AcDream.Core/Physics/FlatBspQuery.cs`; `tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs` | — | — | `BSPTREE::find_collisions` 0x0053A440: head `0x0053A793..0x0053A7A4`, foot `0x0053A7B3..0x0053A7DC`; research §10 | +| ~~TS-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 2B; corrective acceptance complete).** The graph and prepared-flat Path-6 implementations now match retail's exact two-sphere split: every primary/foot polygon hit calls `SetCollide`, sets `WalkableAllowance=LandingZ`, and returns `Adjusted`; only a secondary/head hit writes `CollisionNormal` and returns `Collided`. The steep tangent shortcut and every BSP-layer `SetSlidingNormal` write are deleted. Exact site tests pin all changed and preserved fields plus raw-bit graph/flat parity. A corrective 90-tick already-airborne, zero-root-motion Core suite executes acceleration, body integration, transition resolution, exact commit, and `handle_all_collisions` while retaining every behavior-bearing collision/body field used by that specialized quantum. Vertical, inward, tangential, downhill, and positive-Z uphill-jump traces match graph/flat by raw bits, reject penetration/fixed points/second launches, and pin exact terminal velocity, contact, sliding, and contact-plane state. The older resolver-only capture is explicitly historical and restored to its three-second bound. | `src/AcDream.Core/Physics/BSPQuery.cs`; `src/AcDream.Core/Physics/FlatBspQuery.cs`; `tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs` | — | — | `BSPTREE::find_collisions` 0x0053A440: head `0x0053A793..0x0053A7A4`, foot `0x0053A7B3..0x0053A7DC`; research §10 | | TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) | | TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 | | ~~TS-8~~ | **RETIRED 2026-07-31 (#268 stat-chain closeout).** `EnchantmentWireReader` parses the complete 0x02C2 payload and `GameEventWiring` publishes its StatMod type/key/value and bucket through the same `ActiveEnchantmentRecord` used at login. An end-to-end dispatch test proves a mid-session skill modifier changes `LocalPlayerState.GetEffectiveSkill` immediately. | `src/AcDream.Core.Net/Messages/EnchantmentWireReader.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` | — | — | `CEnchantmentRegistry::EnchantAttribute @ 0x00594570`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0`; holtburger `messages/magic/types.rs` | @@ -306,12 +306,11 @@ phase-gated — they carry their trigger in their row and should land WITH that phase, not before. 1. **TS-27 — INBOUND retransmit handling** — the outbound sent-packet cache + resend landed with Campaign N Slice N1 (2026-07-29, class-doc gap list fixed same commit); the inbound sequence-aligned ISAAC + client NAK emission (N2/N4) remain the hard blocker for non-loopback play — one lost S2C packet still deafens the session permanently. -2. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis. -3. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output). -4. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check. -5. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it. -6. **TS-55 — AdminEnvirons fog/radar presentation** — exact retail mechanism is known; port the authored ambient/fog fields, radar blanking, Clear, and `0x270F` together. -7. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging. +2. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output). +3. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check. +4. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it. +5. **TS-55 — AdminEnvirons fog/radar presentation** — exact retail mechanism is known; port the authored ambient/fog fields, radar blanking, Clear, and `0x270F` together. +6. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging. **Phase-gated (do WITH the phase, flagged here so they aren't forgotten):** M2 combat must land TS-25 diff --git a/docs/plans/2026-07-29-physics-parity-campaign.md b/docs/plans/2026-07-29-physics-parity-campaign.md index caa443cc..62f199c7 100644 --- a/docs/plans/2026-07-29-physics-parity-campaign.md +++ b/docs/plans/2026-07-29-physics-parity-campaign.md @@ -99,36 +99,30 @@ character state continuously. ### P2 — Response-layer edge family — retires TS-1, TS-4, AP-7; closes #166, #116 -**Status (2026-07-30, FINAL — Campaign P final physics slice):** TS-1 and -AP-7 retired same-day as originally recorded. **TS-4 is now ALSO -RETIRED** — the oracle follow-up pass -(`docs/research/2026-07-30-ts4-116-oracle-plan.md`) found the freeze the -first implementation attempt hit was one layer downstream of Path 6 -(inside `AdjustOffset`'s crease projection against a purely-vertical -offset — a genuine retail-identical degeneracy, not a bug) and ran the -plan's own decisive confirming test: `Ts4SteepRoofWedgeCaptureTests`'s -horizontal-velocity variant (matching the realistic live-play input that -originally validated the shortcut) converges cleanly with the shortcut -removed. The Path-6 steep-poly shortcut is deleted from both -`BSPQuery.cs` and `FlatBspQuery.cs`; the pure-vertical degenerate case is -pinned (not fixed) as register row AD-56. **#116 is narrowed, not -closed**: shape-2 (D4 first-airborne-frame hard-stop) is CLOSED — the -oracle plan's structural dispatch-routing hypothesis was confirmed by -instrumentation with no cdb session needed, and the D4 pin is un-skipped. -Shape-1 (tick-22760 lateral-slide loss) got a real, independently-decomp- -confirmed fix (Path 6's foot-clear/head-hit branch now returns `Collided` -+ `SetCollisionNormal` directly, matching pc:323824-323834/ACE -`BSPTree.cs:221-230`), but the confirming replay showed this does NOT -explain tick-22760 itself — that mover is grounded (dispatches through -Path 5, not Path 6) and the actual "no normal recorded" mechanism -(`SpherePath.PrecipiceSlide`'s `find_crossed_edge`-false fallback) is -independently confirmed byte-exact retail behavior too. The remaining -divergence is most likely this test's simplified door-registration -fixture, not the response layer — see ISSUES.md #116 and the oracle -plan's Addendum 2 for the full trace and the concrete next step (re-run -against the faithful Setup-based door registration). AD-55 (the sled -slope-flatness constant, split out of AP-7's retirement) is ALSO retired -this same slice — byte-proven `cos(10°)` per the oracle plan's Addendum. +**Status (2026-07-31, FINAL):** TS-1 and AP-7 retired as originally +recorded. TS-4's first 2026-07-30 removal was accepted by an incomplete +resolver-only fixture, failed the live matrix with a roof wedge/uphill-bounce +regression, and was reverted. The 2026-07-31 closure began from a fresh +`BSPTREE::find_collisions` read and ports the exact asymmetric Path-6 split: +primary/foot hits use `SetCollide` + `LandingZ` + `Adjusted`, while +secondary/head hits use `CollisionNormal` + `Collided`; neither writes a +sliding normal. Both graph and prepared-flat implementations match that +oracle. + +The corrective acceptance no longer calls the old horizontal-input fixture +"production-shaped." `Ts4ProductionQuantumConformanceTests` executes the +already-airborne, zero-root-motion 30 Hz Core collision tail — acceleration, +body integration, transition resolve, exact body/cell commit, then +`handle_all_collisions` — and retains its behavior-bearing cell, contact, +sliding, stationary-fall, and velocity state for 90 ticks. Graph and flat +match by raw bits for vertical/inward/tangential/downhill cases and a genuine +positive-Z uphill jump; exact terminal state, non-penetration, no fixed point, +and no second launch are pinned. No further product-code correction was +needed after that test became faithful, and there is no active AD-56 row. The +older resolver-only wedge test remains only as a historical three-second +signature control. #116 shape-2 remains closed; shape-1 remains narrowed as +recorded in its issue history. AD-55 remains retired by the raw-byte +`cos(10°)` proof. The collision *response* layer (what happens after a hit): ground friction, cliff edges, downhill landings, near-perpendicular wall @@ -145,10 +139,11 @@ binds every subagent here. stop-at-edge. 3. **#166:** port the landing "sled" (Sledding state set/clear sites; the sled friction constants already sit in `calc_friction`). -4. **TS-4:** replace the Path-6 steep-poly in-place-slide shortcut with - retail's `SetCollide → Path-4 → ContactPlane` landing chain, and - remove the two BSP-layer `SetSlidingNormal` writes (retail's only - in-transition writer is `validate_transition`). +4. **TS-4:** remove the Path-6 steep-poly shortcuts and port retail's exact + sphere split: primary/foot uses `SetCollide` + `LandingZ` + `Adjusted`; + secondary/head uses `CollisionNormal` + `Collided`. Remove every BSP-layer + `SetSlidingNormal` write (retail's only in-transition writer is + `validate_transition`). 5. **#116:** the near-perpendicular lateral-slide loss + first-airborne- frame divergence, driven by the existing tick-22760 replay and D4 pins. diff --git a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md index cbd10b33..631d744a 100644 --- a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md +++ b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md @@ -599,17 +599,16 @@ manufacture a caller that sets it. ### The former shortcut (HISTORICAL FACT; removed by Slice 2B) Before Slice 2B, Path-6 (the default -`sphere_intersects_poly → collide_with_pt / SetCollide` dispatch) tested each -hit polygon's world-space normal. For BOTH sphere0 (feet) and sphere1 (head), -if `worldNormal.Z < PhysicsGlobals.FloorZ` (steeper than ~49° from -horizontal), acdream took a SPECIAL BRANCH: -projects the move along the steep face, writes -`collisions.SetCollisionNormal(worldNormal)` **and** -`collisions.SetSlidingNormal(worldNormal)`, and returns -`TransitionState.Slid` immediately — bypassing `SetCollide` entirely for -steep hits. Only the shallow case (`worldNormal.Z >= FloorZ`) reaches -`path.SetCollide(worldNormal); path.WalkableAllowance = LandingZ; return -Adjusted;`. +`sphere_intersects_poly → collide_with_pt / SetCollide` dispatch) was not +symmetrically wrong. The parsed-graph primary/foot branch tested the hit +polygon's world-space normal and, below `PhysicsGlobals.FloorZ`, projected +the move along the face, wrote both collision and sliding normals, and +returned `Slid`. Its secondary/head branch had already been corrected by +#116 to retail's unconditional `CollisionNormal` + `Collided` response. The +prepared-flat port still applied the steep shortcut to both spheres and sent +both shallow cases through `SetCollide` + `Adjusted`; its head branch was +therefore wrong for every slope. This distinction matters: retail does not +apply one common response to both spheres. The in-code comment is unusually candid about why: **"This is a deliberate deviation from retail... Validated against retail debugger @@ -621,10 +620,11 @@ was shipped SAME-DAY as (and BECAUSE) the retail-faithful §2 above — also dated 2026-04-30, tagged "L.4") still wedged in testing when tried without this shortcut. -### Retail: NO steepness branch at the BSP layer (FACT, pseudo-C:323740-323783, `0053a730` region) +### Retail: NO steepness branch in either Path-6 sphere response (FACT, `0053a730` region) Read directly from the named decomp (the `sphere_intersects_poly` / -`set_collide` dispatch inside `BSPTREE::find_collisions`'s default path): +`set_collide` dispatch for the primary/foot sphere inside +`BSPTREE::find_collisions`'s default path): ``` if (sphere_intersects_poly(...) || eax_26 != 0) { @@ -635,11 +635,21 @@ if (sphere_intersects_poly(...) || eax_26 != 0) { } ``` -**There is no steepness test here at all.** Retail's BSP layer calls -`set_collide` and returns `ADJUSTED_TS` **unconditionally**, for a steep -roof exactly the same as a shallow ramp. `walkable_allowance` is always -set to `LandingZ` (the permissive landing threshold) at this layer, -regardless of the actual polygon slope. This directly confirms the P2 +**There is no steepness test in this foot branch.** It calls `set_collide` +and returns `ADJUSTED_TS` for a steep roof exactly as for a shallow ramp. +`walkable_allowance` is always set to `LandingZ` (the permissive landing +threshold), regardless of polygon slope. If the foot is clear but the +secondary/head sphere hits, retail instead performs the other slope-agnostic +response: + +``` +localtoglobalvec(sphere_path.localspace_pos, &normal, &head_poly->plane.N); +COLLISIONINFO::set_collision_normal(&collision_info, &normal); +return 2; // COLLIDED_TS +``` + +The head branch neither calls `set_collide` nor returns `ADJUSTED_TS`. +Neither sphere response has a steepness branch. This directly confirms the P2 plan's description and the digest's #137-mechanism-2 finding (`memory/project_physics_collision_digest.md:1008-1014`): **retail's BSP/sphere collision layer never writes `collision_info.sliding_normal` @@ -653,16 +663,13 @@ domain, §2 above). ### TS-4 port shape (COMPLETED 2026-07-31) -Delete both `if (worldNormal{0,1}.Z < PhysicsGlobals.FloorZ) { ... return -TransitionState.Slid; }` blocks (`BSPQuery.cs:2200-2215` and -`:2240-2255`) entirely. Both sphere0 and sphere1 hits should fall straight -through to the existing `path.SetCollide(worldNormal); path.WalkableAllowance -= PhysicsGlobals.LandingZ; return TransitionState.Adjusted;` — i.e., make -Path-6 do EXACTLY what its own shallow branch already does, for every -hit, matching retail's unconditional `set_collide`. This mechanically -retires both `SetSlidingNormal` write sites (satisfying DO-NOT-RETRY §0 -item 1 permanently — deleted, not just avoided) with no replacement logic -needed at this layer. +Delete the primary/foot steep shortcut in both representations. Every foot +hit must call `SetCollide`, set `WalkableAllowance=LandingZ`, and return +`Adjusted`. Preserve the parsed-graph head branch corrected by #116, and +replace the prepared-flat head steep/shallow split with the same unconditional +`SetCollisionNormal` + `Collided` response. No Path-6 branch writes a sliding +normal. This is the exact retail foot/head split, not a shared fallback to the +foot behavior. ### Port-order coupling with TS-1 (historical guard, now satisfied) @@ -1202,17 +1209,35 @@ representations by raw bits and pin every mutated and preserved field: - both: a pre-existing sliding normal is preserved byte-for-byte. The failed first removal was a test-harness lesson, not a retail exception. -Its gravity-only replay always passed `isOnGround:false` and discarded each -accepted result's Contact/OnWalkable bits, making the nested edge/StepDown -chain impossible to exercise on the next frame. The replacement replay uses -the same retained `PhysicsBody` chronology as production. Parsed graph and -prepared flat now match by raw result/body bits for vertical roof descent, -downhill input, uphill pressure (including a no-launch/no-bounce assertion), -tangential roof travel, inward-plus-tangent wall travel, and flat-roof ledge -rejection. Every trace asserts finite bounded motion and signed-plane non- -penetration. The former #116 D4 control is active: its primary-sphere hit -hard-stops frame one through SetCollide, then the accepted persistent normal -permits the downward slide on frame two. +Its gravity-only replay always passed `isOnGround:false`, manually zeroed +vertical velocity, and only copied part of the accepted contact state, so it +could not reproduce the live caller's next-frame chronology. The replacement +`Ts4ProductionQuantumConformanceTests` replay executes each already-airborne, +zero-root-motion 30 Hz Core collision quantum in the production order: +`calc_acceleration`, `UpdatePhysicsInternal`, +`ResolveWithTransition`, exact body/cell commit, then +`PhysicsObjUpdate.CommitSetPositionTransition` (the Core owner of retail's +Contact/OnWalkable replacement plus `handle_all_collisions`). It carries the +same body, cell, contact plane, walkable plane, sliding state, stationary-fall +counter, cached velocity, and transient flags for all 90 ticks. PositionManager +root composition, animation hooks, movement callbacks, and fresh-airborne +`LeaveGround`/`HitGround` edges are deliberately outside this already-airborne +dat-free fixture; it does not claim to replay those presentation/motion stages. + +Parsed graph and prepared flat match by raw result/body bits for vertical, +inward, tangential, and downhill roof motion plus a genuine positive-Z uphill +jump. The foot-contact jump's apex precedes roof contact and every later +candidate velocity remains non-positive in Z. A separate elevated head-only +collision reaches the wall while Z velocity is still positive and pins the +valid-normal, inward-dot retail 5%-elastic reflection without adding vertical +velocity. Every direction rejects a half-second fixed point and signed-plane +penetration, while exact terminal velocity, Contact/OnWalkable/Sliding bits, +sliding normal, and contact plane are fixed by raw float bits. The older +resolver-only wedge capture remains a deliberately weaker historical control +and is restored to its original three-second bound. The former #116 D4 +control remains active: its primary-sphere hit hard-stops frame one through +SetCollide, then the accepted persistent normal permits the downward slide on +frame two. The complete historical #273/#271/#269/#265/#185/#137/#116/cellar/roof matrix and the full Core Release suite pass without a replacement diff --git a/tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs b/tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs new file mode 100644 index 00000000..783d24a2 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs @@ -0,0 +1,695 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Numerics; +using System.Text; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Production-quantum acceptance for retail Path 6. Unlike the historical +/// bare-resolver fixture, every frame executes the already-airborne, +/// zero-root-motion Core collision tail used by PlayerMovementController: +/// acceleration, PhysicsBody integration, sweep, complete position/contact +/// commit, and handle_all_collisions. +/// +public sealed class Ts4ProductionQuantumConformanceTests +{ + private const uint Cell = 0xA9B40001u; + private const uint GfxId = 0x0100E1B0u; + private const float Dt = 1f / 30f; + private const float Radius = 0.48f; + private const int TickCount = 90; + private const ushort RoofPolygonId = 1; + + private static readonly ImmutableArray HumanSpheres = + ImmutableArray.Create( + new FlatCollisionSphere(new Vector3(0f, 0f, 0.475f), Radius), + new FlatCollisionSphere(new Vector3(0f, 0f, 1.350f), Radius)); + + private readonly ITestOutputHelper _output; + + public Ts4ProductionQuantumConformanceTests(ITestOutputHelper output) => + _output = output; + + [Theory] + [InlineData("vertical", 0f, 0f, 0f, 0xC18415BEu, 0x00000000u, 0xC201B112u, 0x00000000u, 0x00000000u, 0xC1EB3325u)] + [InlineData("inward", 0.5366564f, 0f, -0.2683282f, 0xC18464EAu, 0x00000000u, 0xC202003Eu, 0x3F096250u, 0x00000000u, 0xC1ED58AEu)] + [InlineData("tangent", 0f, 0.30f, 0f, 0xC18415BEu, 0x3F63F3DBu, 0xC201B112u, 0x00000000u, 0x3E99999Au, 0xC1EB3325u)] + [InlineData("downhill", -0.2683282f, 0f, -0.5366564f, 0xC18A74DBu, 0x00000000u, 0xC208102Fu, 0xBE896250u, 0x00000000u, 0xC1EF7E37u)] + public void RoofDirections_ProductionQuantum_GraphFlatExactAndNeverWedge( + string direction, + float velocityX, + float velocityY, + float velocityZ, + uint terminalPositionXBits, + uint terminalPositionYBits, + uint terminalPositionZBits, + uint terminalVelocityXBits, + uint terminalVelocityYBits, + uint terminalVelocityZBits) + { + var initialPosition = new Vector3(0.5f, 0f, 3f); + var initialVelocity = new Vector3(velocityX, velocityY, velocityZ); + QuantumTrace graph = RunTrace( + preparedFlat: false, + initialPosition, + initialVelocity, + TickCount); + QuantumTrace flat = RunTrace( + preparedFlat: true, + initialPosition, + initialVelocity, + TickCount); + + AssertTraceExact(graph, flat); + AssertNoSteepSurfaceFixedPoint(graph, direction); + AssertNoSlopePenetration(graph, direction); + Assert.True(graph.FirstContactTick >= 0, + $"{direction} never contacted the authored steep roof."); + AssertCollisionResponseExact(graph.Frames[graph.FirstContactTick]); + AssertTerminalStateExact( + graph, + terminalPositionXBits, + terminalPositionYBits, + terminalPositionZBits, + terminalVelocityXBits, + terminalVelocityYBits, + terminalVelocityZBits); + + _output.WriteLine( + $"{direction}: peak={graph.PeakZ:R}, contactTick={graph.FirstContactTick}, " + + $"terminalPos={graph.Frames[^1].Position}, " + + $"terminalVelocity={graph.Frames[^1].Velocity}, " + + $"terminalFlags={graph.Frames[^1].TransientState}, " + + $"terminalSliding={graph.Frames[^1].SlidingNormal}"); + } + + [Fact] + public void UphillPositiveZJump_ProductionQuantum_GraphFlatExactWithoutLaunch() + { + // Positive-Z jump whose +X component points into/up the authored roof. + // Its authored separation keeps the real apex before Path-6 contact, + // then exercises SetCollide -> SetPositionInternal -> + // handle_all_collisions without manufacturing a second launch. + Vector3 initialPosition = new(-0.20f, 0f, 0.422f); + Vector3 initialVelocity = new(0.4472136f, 0f, 0.8944272f); + QuantumTrace graph = RunTrace( + preparedFlat: false, + initialPosition, + initialVelocity, + TickCount); + QuantumTrace flat = RunTrace( + preparedFlat: true, + initialPosition, + initialVelocity, + TickCount); + + AssertTraceExact(graph, flat); + Assert.True(graph.FirstContactTick >= 0, + $"The uphill jump never hit the roof; peak={graph.PeakZ:R}, " + + $"terminal={graph.Frames[^1].Position}, " + + $"collisionTick={graph.Frames.FindIndex(frame => frame.CollisionNormalValid)}."); + AssertNoSteepSurfaceFixedPoint(graph, "uphill-jump"); + AssertNoSlopePenetration(graph, "uphill-jump"); + + QuantumFrame hit = graph.Frames[graph.FirstContactTick]; + int peakFrame = graph.Frames.FindIndex(frame => frame.Position.Z == graph.PeakZ); + Assert.InRange(peakFrame, 1, graph.FirstContactTick - 1); + Assert.True(hit.CandidateVelocity.Z < 0f, + $"The jump must contact after its real apex: {hit.CandidateVelocity}."); + Assert.All( + graph.Frames.GetRange( + graph.FirstContactTick, + graph.Frames.Count - graph.FirstContactTick), + frame => Assert.True(frame.CandidateVelocity.Z <= 0f, + $"The foot-contact path created an upward relaunch at frame " + + $"{frame.Tick}: {frame.CandidateVelocity}.")); + float postHitPeak = graph.Frames + .GetRange(graph.FirstContactTick, graph.Frames.Count - graph.FirstContactTick) + .Max(frame => frame.Position.Z); + Assert.True(postHitPeak <= graph.PeakZ + 0.001f, + $"The collision created a second launch: firstPeak={graph.PeakZ:R}, " + + $"postHitPeak={postHitPeak:R}."); + AssertCollisionResponseExact(hit); + Assert.Equal(0x3EECC54Bu, BitConverter.SingleToUInt32Bits(graph.PeakZ)); + Assert.Equal(6, graph.FirstContactTick); + AssertTerminalStateExact( + graph, + 0xC18334B2u, + 0x00000000u, + 0xC200D006u, + 0x3EE4F92Eu, + 0x00000000u, + 0xC1E40B5Du); + + _output.WriteLine( + $"uphill-jump: peak={graph.PeakZ:R}, contactTick={graph.FirstContactTick}, " + + $"hitCandidateVelocity={hit.CandidateVelocity}, " + + $"hitPreResponseVelocity={hit.PreResponseVelocity}, " + + $"hitVelocity={hit.Velocity}, " + + $"terminalPos={graph.Frames[^1].Position}, " + + $"terminalVelocity={graph.Frames[^1].Velocity}, " + + $"terminalFlags={graph.Frames[^1].TransientState}, " + + $"terminalSliding={graph.Frames[^1].SlidingNormal}"); + } + + [Fact] + public void PositiveZJump_HeadOnlyCollision_UsesExactRetailElasticReflection() + { + var fixture = ElevatedHeadWall(); + Vector3 initialPosition = new(-0.60f, 0f, 0f); + Vector3 initialVelocity = new(2f, 0f, 2f); + QuantumTrace graph = RunTrace( + preparedFlat: false, + initialPosition, + initialVelocity, + ticks: 12, + fixture); + QuantumTrace flat = RunTrace( + preparedFlat: true, + initialPosition, + initialVelocity, + ticks: 12, + fixture); + + AssertTraceExact(graph, flat); + int hitIndex = graph.Frames.FindIndex(frame => frame.CollisionNormalValid); + Assert.InRange(hitIndex, 0, graph.Frames.Count - 1); + QuantumFrame hit = graph.Frames[hitIndex]; + Assert.True(hit.CandidateVelocity.Z > 0f, + $"The head-only collision did not occur during positive-Z travel: {hit.CandidateVelocity}."); + Assert.True(Vector3.Dot(hit.PreResponseVelocity, hit.CollisionNormal) < 0f, + $"The fixture did not exercise the inward reflection branch: " + + $"v={hit.PreResponseVelocity}, n={hit.CollisionNormal}."); + AssertCollisionResponseExact(hit); + AssertFloatBits(hit.PreResponseVelocity.Z, hit.Velocity.Z); + Assert.True(hit.Velocity.X < 0f, + $"The 5%-elastic retail reflection did not reverse the inward component: {hit.Velocity}."); + + for (int i = hitIndex + 1; i < graph.Frames.Count; i++) + { + Assert.True(graph.Frames[i].CandidateVelocity.Z + < graph.Frames[i - 1].CandidateVelocity.Z, + $"The head collision manufactured a later vertical launch at frame {i}."); + } + } + + private static QuantumTrace RunTrace( + bool preparedFlat, + Vector3 initialPosition, + Vector3 initialVelocity, + int ticks) => + RunTrace(preparedFlat, initialPosition, initialVelocity, ticks, WideSteepRoof()); + + private static QuantumTrace RunTrace( + bool preparedFlat, + Vector3 initialPosition, + Vector3 initialVelocity, + int ticks, + (PhysicsBSPNode Root, Dictionary Resolved) fixture) + { + PhysicsEngine engine = BuildEngine(fixture, preparedFlat); + var body = new PhysicsBody + { + Position = initialPosition, + Orientation = Quaternion.Identity, + Velocity = initialVelocity, + State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(Cell, initialPosition, initialPosition); + + var frames = new List(ticks); + float peakZ = body.Position.Z; + int firstContactTick = -1; + uint cell = Cell; + for (int tick = 0; tick < ticks; tick++) + { + Vector3 preIntegratePosition = body.Position; + + body.calc_acceleration(); + body.UpdatePhysicsInternal(Dt); + + Vector3 candidatePosition = body.Position; + Vector3 candidateVelocity = body.Velocity; + bool candidateMoved = candidatePosition != preIntegratePosition; + bool onGroundBeforeResolve = body.OnWalkable; + ResolveResult result = engine.ResolveWithTransition( + preIntegratePosition, + candidatePosition, + cell, + Radius, + sphereHeight: 1.835f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f, + isOnGround: onGroundBeforeResolve, + body, + ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0x01000000u, + sphereList: HumanSpheres, + sphereScale: 1f); + + Vector3 preResponseVelocity = body.Velocity; + int preResponseStationaryFall = body.FramesStationaryFall; + + // Production captures these after ResolveWithTransition has + // published plane/sliding/fsf state but before SetPositionInternal + // replaces Contact/OnWalkable. Resolve never mutates these two bits. + bool previousContact = body.InContact; + bool previousOnWalkable = body.OnWalkable; + + body.CachedVelocity = candidateMoved + ? (result.Position - preIntegratePosition) / Dt + : Vector3.Zero; + body.CommitTransitionPosition(result.CellId, result.Position); + cell = result.CellId; + + bool commitApplied = result.Ok && candidateMoved; + if (commitApplied) + { + PhysicsObjUpdate.CommitSetPositionTransition( + body, + result.InContact, + result.OnWalkable, + result.CollisionNormalValid, + result.CollisionNormal, + previousContact, + previousOnWalkable); + } + + peakZ = MathF.Max(peakZ, body.Position.Z); + if (firstContactTick < 0 && result.InContact) + firstContactTick = tick; + frames.Add(CaptureFrame( + tick, + candidatePosition, + candidateVelocity, + preResponseVelocity, + preResponseStationaryFall, + previousOnWalkable, + candidateMoved, + commitApplied, + result, + body)); + } + + return new QuantumTrace(frames, peakZ, firstContactTick); + } + + private static ( + PhysicsBSPNode Root, + Dictionary Resolved) WideSteepRoof() + { + Vector3[] vertices = + [ + new(-64f, -64f, -128f), + new( 64f, -64f, 128f), + new( 64f, 64f, 128f), + new(-64f, 64f, -128f), + ]; + Vector3 normal = Vector3.Normalize( + Vector3.Cross(vertices[1] - vertices[0], vertices[3] - vertices[0])); + if (normal.X > 0f) + normal = -normal; + float d = -Vector3.Dot(normal, vertices[0]); + + var root = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 192f }, + }; + root.Polygons.Add(RoofPolygonId); + return (root, new Dictionary + { + [RoofPolygonId] = new ResolvedPolygon + { + Id = RoofPolygonId, + Vertices = vertices, + Plane = new Plane(normal, d), + NumPoints = vertices.Length, + SidesType = CullMode.None, + }, + }); + } + + private static ( + PhysicsBSPNode Root, + Dictionary Resolved) ElevatedHeadWall() + { + Vector3[] vertices = + [ + new(0f, 64f, 1.10f), + new(0f, -64f, 1.10f), + new(0f, -64f, 64.00f), + new(0f, 64f, 64.00f), + ]; + var plane = new Plane(-Vector3.UnitX, 0f); + var root = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere + { + Origin = new Vector3(0f, 0f, 32f), + Radius = 96f, + }, + }; + root.Polygons.Add(RoofPolygonId); + return (root, new Dictionary + { + [RoofPolygonId] = new ResolvedPolygon + { + Id = RoofPolygonId, + Vertices = vertices, + Plane = plane, + NumPoints = vertices.Length, + SidesType = CullMode.None, + }, + }); + } + + private static PhysicsEngine BuildEngine( + (PhysicsBSPNode Root, Dictionary Resolved) fixture, + bool preparedFlat) + { + var normalized = new Dictionary(fixture.Resolved.Count); + foreach ((ushort id, ResolvedPolygon polygon) in fixture.Resolved) + { + normalized.Add(id, new ResolvedPolygon + { + Id = id, + Vertices = polygon.Vertices, + Plane = polygon.Plane, + NumPoints = polygon.NumPoints, + SidesType = polygon.SidesType, + }); + } + + var physics = new GfxObjPhysics + { + SourceId = GfxId, + BSP = new PhysicsBSPTree { Root = fixture.Root }, + Resolved = normalized, + BoundingSphere = fixture.Root.BoundingSphere, + }; + var cache = new PhysicsDataCache(); + if (preparedFlat) + { + cache.CollisionTraversalMode = CollisionTraversalMode.Flat; + cache.CacheGfxObj(GfxId, FlatCollisionAssetBuilder.FlattenGfxObj(physics)); + } + else + { + cache.RegisterGfxObjForTest(GfxId, physics); + } + + var heights = new byte[81]; + var heightTable = new float[256]; + Array.Fill(heightTable, -1000f); + var engine = new PhysicsEngine { DataCache = cache }; + engine.AddLandblock( + 0xA9B40000u, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + engine.ShadowObjects.Register( + GfxId, + GfxId, + Vector3.Zero, + Quaternion.Identity, + fixture.Root.BoundingSphere.Radius, + 0f, + 0f, + 0xA9B4FFFFu, + ShadowCollisionType.BSP, + 1f); + return engine; + } + + private static void AssertTraceExact(QuantumTrace graph, QuantumTrace flat) + { + Assert.Equal(graph.PeakZ, flat.PeakZ); + Assert.Equal(graph.FirstContactTick, flat.FirstContactTick); + Assert.Equal(graph.Frames.Count, flat.Frames.Count); + for (int i = 0; i < graph.Frames.Count; i++) + Assert.Equal(graph.Frames[i].Bits, flat.Frames[i].Bits); + } + + private static void AssertNoSteepSurfaceFixedPoint( + QuantumTrace trace, + string direction) + { + int frozenStreak = 0; + for (int i = 1; i < trace.Frames.Count; i++) + { + QuantumFrame previous = trace.Frames[i - 1]; + QuantumFrame current = trace.Frames[i]; + bool onSlope = IsOverSlope(current.Position) + && current.ContactPlaneValid + && current.ContactPlane.Normal.Z < PhysicsGlobals.FloorZ; + frozenStreak = onSlope + && Vector3.Distance(previous.Position, current.Position) < 0.001f + ? frozenStreak + 1 + : 0; + Assert.True(frozenStreak <= 15, + $"{direction} fixed on the steep roof for {frozenStreak} ticks at " + + $"frame {i}, position={current.Position}."); + } + } + + private static void AssertNoSlopePenetration(QuantumTrace trace, string direction) + { + Plane slope = WideSteepRoof().Resolved[RoofPolygonId].Plane; + for (int i = 0; i < trace.Frames.Count; i++) + { + QuantumFrame frame = trace.Frames[i]; + Assert.True(float.IsFinite(frame.Position.X) + && float.IsFinite(frame.Position.Y) + && float.IsFinite(frame.Position.Z), + $"{direction} produced a non-finite position at frame {i}: {frame.Position}."); + if (!IsOverSlope(frame.Position)) + continue; + + Vector3 footCenter = frame.Position + HumanSpheres[0].Origin; + float signedDistance = Vector3.Dot(slope.Normal, footCenter) + slope.D; + Assert.True(signedDistance >= Radius - 0.015f, + $"{direction} penetrated the roof at frame {i}: " + + $"distance={signedDistance:R}, position={frame.Position}."); + } + } + + private static bool IsOverSlope(Vector3 position) => + position.X is >= -64f and <= 64f && MathF.Abs(position.Y) <= 64f; + + private static void AssertCollisionResponseExact(QuantumFrame hit) + { + Assert.True(hit.ResultOk, + $"Frame {hit.Tick} reported contact without an accepted transition."); + Assert.True(hit.CandidateMoved, + $"Frame {hit.Tick} reported contact without a moving candidate."); + Assert.True(hit.CommitApplied, + $"Frame {hit.Tick} did not execute the production commit/response gate."); + + Vector3 expected = hit.PreResponseVelocity; + bool shouldReflect = !hit.PreviousOnWalkable || !hit.BodyOnWalkable; + if (hit.PreResponseStationaryFall > 1) + { + expected = Vector3.Zero; + } + else if (shouldReflect && hit.CollisionNormalValid) + { + float dot = Vector3.Dot(expected, hit.CollisionNormal); + if (dot < 0f) + expected += hit.CollisionNormal * (-(dot * 1.05f)); + } + + AssertVectorBits(expected, hit.Velocity); + } + + private static void AssertTerminalStateExact( + QuantumTrace trace, + uint positionXBits, + uint positionYBits, + uint positionZBits, + uint velocityXBits, + uint velocityYBits, + uint velocityZBits) + { + QuantumFrame terminal = trace.Frames[^1]; + Assert.Equal(positionXBits, BitConverter.SingleToUInt32Bits(terminal.Position.X)); + Assert.Equal(positionYBits, BitConverter.SingleToUInt32Bits(terminal.Position.Y)); + Assert.Equal(positionZBits, BitConverter.SingleToUInt32Bits(terminal.Position.Z)); + Assert.Equal(velocityXBits, BitConverter.SingleToUInt32Bits(terminal.Velocity.X)); + Assert.Equal(velocityYBits, BitConverter.SingleToUInt32Bits(terminal.Velocity.Y)); + Assert.Equal(velocityZBits, BitConverter.SingleToUInt32Bits(terminal.Velocity.Z)); + Assert.Equal(TransientStateFlags.Active | TransientStateFlags.Contact, + terminal.TransientState); + Assert.True(terminal.BodyInContact); + Assert.False(terminal.BodyOnWalkable); + Assert.False(terminal.BodySliding); + Assert.Equal(Vector3.Zero, terminal.SlidingNormal); + Assert.False(terminal.CollisionNormalValid); + Assert.True(terminal.ContactPlaneValid); + + Assert.Equal(0xBF64F92Fu, + BitConverter.SingleToUInt32Bits(terminal.ContactPlane.Normal.X)); + Assert.Equal(0x00000000u, + BitConverter.SingleToUInt32Bits(terminal.ContactPlane.Normal.Y)); + Assert.Equal(0x3EE4F92Fu, + BitConverter.SingleToUInt32Bits(terminal.ContactPlane.Normal.Z)); + Assert.Equal(0x80000000u, + BitConverter.SingleToUInt32Bits(terminal.ContactPlane.D)); + } + + private static QuantumFrame CaptureFrame( + int tick, + Vector3 candidatePosition, + Vector3 candidateVelocity, + Vector3 preResponseVelocity, + int preResponseStationaryFall, + bool previousOnWalkable, + bool candidateMoved, + bool commitApplied, + ResolveResult result, + PhysicsBody body) + { + var bits = new StringBuilder(768); + Append(bits, tick); + Append(bits, candidatePosition); + Append(bits, candidateVelocity); + Append(bits, preResponseVelocity); + Append(bits, preResponseStationaryFall); + Append(bits, candidateMoved); + Append(bits, commitApplied); + Append(bits, result.Position); + Append(bits, result.CellId); + Append(bits, result.IsOnGround); + Append(bits, result.CollisionNormalValid); + Append(bits, result.CollisionNormal); + Append(bits, result.Ok); + Append(bits, result.Orientation); + Append(bits, result.InContact); + Append(bits, result.OnWalkable); + Append(bits, body.Position); + Append(bits, body.CellPosition.ObjCellId); + Append(bits, body.CellPosition.Frame.Origin); + Append(bits, body.CellPosition.Frame.Orientation); + Append(bits, body.Velocity); + Append(bits, body.CachedVelocity); + Append(bits, body.Acceleration); + Append(bits, body.GroundNormal); + Append(bits, body.SlidingNormal); + Append(bits, body.ContactPlaneValid); + Append(bits, body.ContactPlane); + Append(bits, body.ContactPlaneCellId); + Append(bits, body.ContactPlaneIsWater); + Append(bits, body.WalkablePolygonValid); + Append(bits, body.WalkablePlane); + Append(bits, body.WalkableUp); + Append(bits, body.FramesStationaryFall); + Append(bits, (uint)body.State); + Append(bits, (uint)body.TransientState); + + return new QuantumFrame( + tick, + candidatePosition, + candidateVelocity, + preResponseVelocity, + preResponseStationaryFall, + candidateMoved, + result.Ok, + commitApplied, + body.Position, + body.Velocity, + body.TransientState, + body.InContact, + body.OnWalkable, + (body.TransientState & TransientStateFlags.Sliding) != 0, + body.SlidingNormal, + result.CollisionNormalValid, + result.CollisionNormal, + previousOnWalkable, + body.ContactPlaneValid, + body.ContactPlane, + bits.ToString()); + } + + private static void Append(StringBuilder target, bool value) => + target.Append(value ? "1|" : "0|"); + + private static void Append(StringBuilder target, int value) => + target.Append(value).Append('|'); + + private static void Append(StringBuilder target, uint value) => + target.Append(value.ToString("X8")).Append('|'); + + private static void Append(StringBuilder target, float value) => + Append(target, BitConverter.SingleToUInt32Bits(value)); + + private static void Append(StringBuilder target, Vector3 value) + { + Append(target, value.X); + Append(target, value.Y); + Append(target, value.Z); + } + + private static void Append(StringBuilder target, Quaternion value) + { + Append(target, value.X); + Append(target, value.Y); + Append(target, value.Z); + Append(target, value.W); + } + + private static void Append(StringBuilder target, Plane value) + { + Append(target, value.Normal); + Append(target, value.D); + } + + private static void AssertFloatBits(float expected, float actual) => + Assert.Equal( + BitConverter.SingleToUInt32Bits(expected), + BitConverter.SingleToUInt32Bits(actual)); + + private static void AssertVectorBits(Vector3 expected, Vector3 actual) + { + AssertFloatBits(expected.X, actual.X); + AssertFloatBits(expected.Y, actual.Y); + AssertFloatBits(expected.Z, actual.Z); + } + + private sealed record QuantumTrace( + List Frames, + float PeakZ, + int FirstContactTick); + + private sealed record QuantumFrame( + int Tick, + Vector3 CandidatePosition, + Vector3 CandidateVelocity, + Vector3 PreResponseVelocity, + int PreResponseStationaryFall, + bool CandidateMoved, + bool ResultOk, + bool CommitApplied, + Vector3 Position, + Vector3 Velocity, + TransientStateFlags TransientState, + bool BodyInContact, + bool BodyOnWalkable, + bool BodySliding, + Vector3 SlidingNormal, + bool CollisionNormalValid, + Vector3 CollisionNormal, + bool PreviousOnWalkable, + bool ContactPlaneValid, + Plane ContactPlane, + string Bits); +} diff --git a/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs b/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs index 3dcf6c22..3d5959a0 100644 --- a/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs @@ -6,13 +6,10 @@ using Xunit.Abstractions; namespace AcDream.Core.Tests.Physics; /// -/// Campaign P Slice 2B's production-shaped steep-roof control. It carries -/// contact state between 30 Hz resolves exactly as the live PhysicsBody path -/// does, so the nested edge/StepDown dispatcher can turn a vertical landing -/// into retail's downhill response instead of the old under-modeled fixed -/// point. The paired graph/flat direction matrix in -/// covers vertical, inward, -/// tangential, uphill, downhill, wall, roof, and ledge histories. +/// Historical resolver-only steep-roof control retained for the original +/// half-second wedge signature. The authoritative production chronology and +/// graph/flat direction matrix live in +/// . /// public class Ts4SteepRoofWedgeCaptureTests { @@ -21,7 +18,7 @@ public class Ts4SteepRoofWedgeCaptureTests private const uint CellId = 0xA9B40001u; private const int TicksPerSecond = 30; // #32 L.5 retail physics tick rate - private const int MaxTicks = 6 * TicksPerSecond; + private const int MaxTicks = 3 * TicksPerSecond; private const int WedgeTickThreshold = 15; // 0.5 s of zero motion == wedged private const float WedgeEpsilon = 0.001f; // 1 mm @@ -74,12 +71,12 @@ public class Ts4SteepRoofWedgeCaptureTests /// /// Falls a player-flagged mover from directly above the slope and carries - /// each frame's contact state into the next frame, matching the production - /// PhysicsBody path. The exact edge/step-down chain must move the body - /// downhill and onto the reference floor without a half-second wedge. + /// resolver contact bits between frames. Within the original three-second + /// capture window it must keep making downhill progress and never enter + /// the reported half-second fixed point. /// [Fact] - public void FallOntoSteepSlope_PureVertical_NeverWedgesAndReachesFloor() + public void FallOntoSteepSlope_PureVertical_NeverWedgesWithinThreeSeconds() { var engine = MakeSlopeEngine(); float r = BSPStepUpFixtures.SphereRadius; @@ -97,8 +94,8 @@ public class Ts4SteepRoofWedgeCaptureTests float fallVelocityZ = 0f; uint cell = CellId; + Vector3 start = pos; int frozenStreak = 0; - bool reachedFloor = false; for (int tick = 0; tick < MaxTicks; tick++) { @@ -148,15 +145,10 @@ public class Ts4SteepRoofWedgeCaptureTests Assert.True(frozenStreak <= WedgeTickThreshold, $"Body froze for {frozenStreak} ticks at {pos}."); - if (pos.X < 0f && pos.Z <= r + 0.05f) - { - reachedFloor = true; - break; - } } - Assert.True(reachedFloor, - $"The production-shaped vertical trace did not reach the floor within " + - $"{MaxTicks} ticks; final=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})."); + Assert.True(pos.X < start.X - 0.10f, + $"The resolver-only trace made no downhill progress within " + + $"{MaxTicks} ticks; start={start}, final={pos}."); } }