From 65de6921ced251183d1a20a3d7479fbcc71d64b3 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Jul 2026 08:28:18 +0200 Subject: [PATCH] test(physics): TS-4 fixture-first attempt reproduces the 2026-04-30 wedge; shortcut stays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign P Slice P2 step 2-3 (docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §4, §6 Step 3). Per the research doc's own port order, TS-4's Path-6 steep-poly shortcut may only be removed after a fixture reproduces the original "stuck in falling animation on a steep roof" symptom cleanly with the shortcut disabled. No surviving live-session fixture exists from the 2026-04-30 L.4 commit (b1af56e); this adds a dat-free multi-frame capture (Ts4SteepRoofWedgeCaptureTests) using BSPStepUpFixtures.SlopedUnwalkable's 63.4 degree slope, replayed at 30 Hz with gravity integrated between PhysicsEngine.ResolveWithTransition calls -- the same idiom as Issue185OutdoorStairsSeamReplayTests. Against today's baseline (shortcut active) the capture is green, as expected (the shortcut's explicit AddOffsetToCheckPos keeps the body moving every tick by construction). Scratch-removed the shortcut (both BSPQuery.cs sphere0/sphere1 branches, not committed -- reverted after capture) and re-ran the same test: the body falls and lands cleanly on the steep polygon at tick 17 (InContact, OnWalkable=false, via retail's own permissive CTransition::check_walkable LandingZ gate, pc:273202), then freezes at that exact position for the rest of the run -- the exact historical wedge shape, tripping the test's own >0.5s-frozen threshold at tick 33. Root-cause diagnosis via ACDREAM_DUMP_EDGE_SLIDE=1: the freeze is upstream of EdgeSlideAfterStepDownFailed/CliffSlide entirely (none of that dispatch's diagnostics fire). TransitionalInsert's Phase 2 object-collision check returns Adjusted on every retry attempt because Path 6's retail- faithful SetCollide returns ADJUSTED_TS without repositioning the sphere (unlike the interim shortcut, which explicitly pushes the sphere off the face) -- the same steep polygon re-triggers Path 6 on the immediate retry, forever, and Phase 3 (the sp.Collide handling that contains DoCheckWalkable, the Placement re-test, and the TS-1 CliffSlide chain) is gated on Phase 1 AND Phase 2 both returning OK, so it is structurally unreachable from this state. TS-1's completeness is moot here -- the code path that would call into it never runs. Per the mission's explicit escape valve: STOP here, keep the shortcut, and report -- do not improvise a third variant. Full diagnosis, the exact capture, and the concrete next research question (does retail's own transitional_insert loop check sphere_path.collide on every iteration regardless of Phase 2's own return value, or only when Phase 2 returns OK?) are recorded in the research doc's §7 item 6 and the doc's headline; the campaign plan's P2 section gets a matching status note. Physics test suite: 1841 passed, 1 skipped (D4, pre-existing/unrelated), 0 failed. Co-Authored-By: Claude Opus 5 --- .../2026-07-29-physics-parity-campaign.md | 9 + ...0-response-layer-edge-family-pseudocode.md | 122 +++++++++-- .../Physics/Ts4SteepRoofWedgeCaptureTests.cs | 202 ++++++++++++++++++ 3 files changed, 311 insertions(+), 22 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs diff --git a/docs/plans/2026-07-29-physics-parity-campaign.md b/docs/plans/2026-07-29-physics-parity-campaign.md index bb173915..a73f2dcc 100644 --- a/docs/plans/2026-07-29-physics-parity-campaign.md +++ b/docs/plans/2026-07-29-physics-parity-campaign.md @@ -84,6 +84,15 @@ character state continuously. ### P2 — Response-layer edge family — retires TS-1, TS-4, AP-7; closes #166, #116 +**Status (2026-07-30):** TS-1 and AP-7 retired; #166 reattributed in +ISSUES.md (no new code — see the research doc §3). **TS-4 is deferred, not +retired** — the fixture-first removal attempt this doc's §6 Step 3 requires +reproduced the historical 2026-04-30 L.4 wedge (a dat-free capture, +`Ts4SteepRoofWedgeCaptureTests`); the shortcut stays in `BSPQuery.cs`. Root +cause and the concrete next research step are recorded in +`docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §7 item +6. #116 is untouched (oracle-first, own session). + The collision *response* layer (what happens after a hit): ground friction, cliff edges, downhill landings, near-perpendicular wall slides. One oracle-driven pass; the physics digest's DO-NOT-RETRY table 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 2dc9ba92..ff83cf7c 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 @@ -1,20 +1,30 @@ # P2 — Collision response-layer edge family: port-ready pseudocode -**Status: RESEARCH PASS COMPLETE (2026-07-30).** Research-only doc for -Campaign P Slice P2 (`docs/plans/2026-07-29-physics-parity-campaign.md` -§P2). Retires TS-1, TS-4, AP-7; closes #166, #116. No source changes made -by this doc; it is the pre-port research artifact for a future -implementation session. **Headline findings that change the plan's -assumptions:** TS-1 is already substantially ported (the register row -and plan phrasing are stale — see §2); #166 is very likely NOT about a -literal `PhysicsState.Sledding` auto-toggle at all (see §3); AP-7's L.3c -regression may no longer reproduce under the post-R6 animation-root-motion -architecture for the graphical path, but likely still reproduces for the -headless/test path (see §1); TS-4's shortcut removal is coupled to TS-1's -completion and must not be done independently (see §6 Step 3-4); #116 -remains a genuine oracle-first research item needing live cdb/Ghidra, not -an implementation item (see §5). Read §6 (port order) before starting -implementation — the safe sequence is not the plan's listed item order. +**Status: RESEARCH PASS COMPLETE (2026-07-30); IMPLEMENTATION PASS PARTIAL +(2026-07-30).** Originally a research-only doc for Campaign P Slice P2 +(`docs/plans/2026-07-29-physics-parity-campaign.md` §P2); a same-day +implementation session landed TS-1's retirement and AP-7's fix, attempted +TS-4 per this doc's own §6 Step 3 fixture-first order, reproduced the +historical wedge, and stopped — see §7 item 6 for the full capture and +root-cause diagnosis. TS-4 is NOT retired; its shortcut stays in place. +#166 got a reattribution note in ISSUES.md rather than new code (per §3). +#116 remains untouched (oracle-first, out of implementation scope). +**Headline findings that change the plan's assumptions:** TS-1 was already +substantially ported (the register row and plan phrasing were stale — see +§2); the one real gap needed no code change (acdream's unified world-space +`SpherePath` design makes retail's per-cell recache a no-op correction here +— see §2 and the TS-1 register row's retirement text); #166 is very likely +NOT about a literal `PhysicsState.Sledding` auto-toggle at all (see §3); +AP-7's L.3c regression does not reproduce on the production graphical +root-motion path post-R6, and now ports retail's confirmed 0.25f threshold +(see §1); TS-4's shortcut removal is coupled to TS-1's completion and +reproduces a wedge even after TS-1 lands — see the §7 item 6 update for the +precise mechanism (Phase 3 of `TransitionalInsert` is structurally +unreachable from Path 6's unconditional `SetCollide`, which returns +`Adjusted` without repositioning the sphere) and what a future attempt +needs to check first; #116 remains a genuine oracle-first research item +needing live cdb/Ghidra, not an implementation item (see §5). Read §6 (port +order) before starting further implementation on this family. Every claim below is tagged **FACT** (grep/read-verified against the named-retail decomp, the register, ISSUES.md, or current acdream source @@ -959,13 +969,81 @@ it blocks. walkable-steepness reroute as real compensating adaptations) or whether they're unnecessary inventions. Needs a fresh, focused named-decomp read (not Ghidra/cdb-gated — just not done this pass). -6. **[TS-4 / Step 3]** Whether TS-1's Step 1 fix alone is sufficient to - let TS-4's shortcut be safely removed, or whether the 2026-04-30 L.4 - session's wedge had additional causes not yet identified. Answerable - only by the capture in §6 Step 3 — not a Ghidra/cdb question, but - listed here because it's the single highest-risk unresolved item in - this document (deleting a load-bearing shortcut based on an unproven - assumption). +6. **[TS-4 / Step 3] ANSWERED 2026-07-30 (implementation session) — NOT + sufficient; the wedge reproduces, and its mechanism is now precisely + characterized.** TS-1's gap #1 fix (this document's §2, landed the same + session) does NOT unblock TS-4. A dat-free multi-frame capture + (`tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs`, + using `BSPStepUpFixtures.SlopedUnwalkable`'s 63.4° slope, + `PhysicsEngine.ResolveWithTransition` replayed at 30 Hz with gravity + integrated between resolves — the same replay idiom as + `Issue185OutdoorStairsSeamReplayTests`) reproduces the EXACT historical + shape with the Path-6 steep shortcut temporarily removed (both + `BSPQuery.cs` sphere0/sphere1 branches): the body falls cleanly (30 + ticks, position advancing every tick), lands at + `(0.500, 0.000, 1.247)` at tick 17 with `InContact=true, OnWalkable=false` + (confirming the steep polygon WAS accepted via the permissive + `CTransition::check_walkable(0.0871556997f)` / `LandingZ` gate exactly + as predicted from the retail source read below), then **freezes at that + exact position for the remaining 16+ ticks with zero movement** — the + test's own wedge-detection threshold (>15 consecutive frozen ticks = + >0.5s) trips at tick 33. With the shortcut restored, the same test is + green (the shortcut's explicit `AddOffsetToCheckPos` keeps the body + moving every tick by construction). **The shortcut stays; TS-4 is NOT + retired this session.** + + **Root-cause diagnosis (`ACDREAM_DUMP_EDGE_SLIDE=1` capture against the + scratch shortcut-removed build):** the freeze is NOT inside + `EdgeSlideAfterStepDownFailed`/`CliffSlide` at all — none of that + dispatch's diagnostic lines (`DumpEdgeSlideBranch`, + `DumpStepDownBranchGate`, the `[steep-roof] PHASE3-RESET*` lines) fire + even once during the frozen ticks. Every frozen tick instead logs only + `edge-slide: phase2 attempt=0 env=OK obj=Adjusted` followed by + `attempt=1 env=OK obj=Adjusted` — i.e. `TransitionalInsert`'s Phase 2 + object-collision check (`FindObjCollisionsInCell`, `TransitionTypes.cs:1572`) + returns `Adjusted` on BOTH retry attempts, and per + `TransitionTypes.cs:1591-1596` an `Adjusted` `objState` unconditionally + `continue`s (retries Phase 1/2 from the top) rather than falling through + toward Phase 2.5/Phase 3. **Phase 3 — the `if (sp.Collide) { ... }` + block at `TransitionTypes.cs:1625` that contains the `DoCheckWalkable` + Placement re-test AND (on walkable failure) the reset-with-conditional- + `kill_velocity` path — is gated on Phase 1 AND Phase 2 BOTH returning + `OK` simultaneously (`TransitionTypes.cs:1568-1621`). Path 6's own + unconditional `SetCollide` (the retail-faithful code path TS-4 would + restore) returns `Adjusted`, not `OK` (matching retail's own + `return 3; // ADJUSTED_TS` at pc:323783, quoted in §4 above) AND does + NOT itself reposition the sphere** — unlike the interim shortcut, which + explicitly calls `AddOffsetToCheckPos` to push the sphere off the face + every time it fires. With no repositioning, the SAME steep polygon at + the SAME distance re-triggers Path 6 on the immediate retry, which + again returns `Adjusted`, forever — an Adjusted↔retry oscillation at a + fixed point that the loop's 2-attempt-per-resolve budget silently + absorbs (returning the frozen position as though the resolve + succeeded), repeating identically on every subsequent tick's fresh + resolve call. **Phase 3 (and therefore `DoCheckWalkable`, + `CliffSlide`, and the TS-1 chain entirely) is structurally unreachable + from this state** — TS-1's completeness is moot here because the code + path that would call into it never runs. + + **What this means for a future attempt:** the missing piece is NOT + (only) in `EdgeSlideAfterStepDownFailed`/`CliffSlide` — it is in how + `TransitionalInsert`'s Phase 1/2/2.5/3 dispatch (`TransitionTypes.cs + :1568-1710`) distinguishes "Phase 2 found a NEW collision, retry from + the top" from "Phase 2 registered a touch via `sp.Collide` and should + fall through toward Phase 3 regardless of its own `Adjusted` return." + Retail's own `transitional_insert` (pc:273137, `0050b6f0`) has NOT been + read closely enough this pass to say definitively whether it treats a + Path-6-sourced `ADJUSTED_TS` differently from an ordinary Adjusted + result before this session's `continue`-on-Adjusted structure was + written — that fresh, close read (specifically: does retail's loop + check `sphere_path.collide` on EVERY iteration regardless of the + latest Phase-2 return value, or only when Phase 2 returns OK?) is the + concrete next step, not a second speculative code change. Do not retry + the plain shortcut-deletion variant without that read; do not invent a + third variant (e.g. teaching Path 6 to reposition the sphere itself) + without confirming that's what retail actually does — that would be + exactly the kind of guess CLAUDE.md's workflow forbids twice in a row + on the same item. 7. **[#116 shape-1]** Where exactly, in the BSP/environment hit-test dispatch (candidate: `BSPTREE::find_collisions`'s `PathClipped`/ `collide_with_pt` arm, pseudo-C ~323700-323830, sibling to the diff --git a/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs b/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs new file mode 100644 index 00000000..29f55a22 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs @@ -0,0 +1,202 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Campaign P Slice P2, TS-4 (Section 6 Step 3): the 2026-04-30 "L.4" fixture +/// capture required before the Path-6 steep-poly slide-tangent shortcut may be +/// removed (docs/research/2026-07-30-response-layer-edge-family-pseudocode.md +/// §4, §6 Step 3). The original repro was a live-client jump onto a steep +/// roof that got the body "stuck in falling animation" for many frames; no +/// captured fixture from that live session survives in the repo (checked +/// docs/research/2026-04-30-* and the L.4 commit `b1af56e`), so this +/// test builds a dat-free multi-frame replay from the existing +/// geometry (a 63.4° +/// slope, normal.Z ≈ 0.447 — below PhysicsGlobals.FloorZ ≈ 0.6642 but +/// above PhysicsGlobals.LandingZ ≈ 0.0871, i.e. exactly the band the +/// L.4 commit's own steep-poly shortcut targets) using the same +/// PhysicsEngine.ResolveWithTransition multi-frame replay idiom as +/// Issue185OutdoorStairsSeamReplayTests. +/// +/// +/// A body falls from directly above the slope's mid-face, integrating +/// gravity between resolves exactly as PhysicsBody.UpdatePhysicsInternal +/// would, for up to 3 simulated seconds (90 ticks at 30 Hz — retail's physics +/// tick rate, #32 L.5). "Wedged" is defined precisely, matching the original +/// bug report ("stuck in falling animation on the roof" for many consecutive +/// frames): the body's position stops changing (within 1 mm) for more than +/// 15 consecutive ticks (0.5 s) while never reaching the flat reference +/// floor at x<0, z=0. A healthy resolution reaches the flat floor (Z ≈ +/// ) well before the 90-tick +/// budget expires, whether it does so by retail's own COLLIDED-then-fall +/// bounce (this file's own git history documents that as retail's actual +/// behavior for a clean Path-6 steep hit with no pre-existing contact plane) +/// or by committing to the steep "walkable" surface via the permissive +/// LandingZ threshold (matching CTransition::check_walkable, +/// pc:273202, 0.0871556997f) and then downhill-drifting off it via +/// the already-ported TS-1 CliffSlide chain. +/// +/// +/// +/// Run TWICE across this slice's git history: once with the Path-6 steep +/// shortcut ACTIVE (pins today's baseline — always green, since the +/// shortcut's own in-frame slide-tangent cannot wedge by construction), and +/// once with it REMOVED (the retail-strict candidate). If both pass, TS-4's +/// removal is evidenced safe and lands in the same commit that deletes the +/// shortcut and its SetSlidingNormal writes. If the removed-shortcut +/// run wedges, the shortcut stays and this file's result against ToT is the +/// recorded evidence — see the commit message / research doc open questions +/// for the outcome actually reached. +/// +/// +public class Ts4SteepRoofWedgeCaptureTests +{ + private readonly ITestOutputHelper _out; + public Ts4SteepRoofWedgeCaptureTests(ITestOutputHelper output) => _out = output; + + private const uint CellId = 0xA9B40001u; + private const int TicksPerSecond = 30; // #32 L.5 retail physics tick rate + 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 + + private static PhysicsEngine MakeSlopeEngine() + { + var (root, resolved) = BSPStepUpFixtures.SlopedUnwalkable(); + + const uint LandblockId = 0xA9B4FFFFu; + const uint SyntheticGfxId = 0xDEADBEEFu; + + var heights = new byte[81]; + var heightTab = new float[256]; + for (int i = 0; i < 256; i++) heightTab[i] = -1000f; // terrain never interferes + + var engine = new PhysicsEngine(); + engine.AddLandblock( + LandblockId, + new TerrainSurface(heights, heightTab), + System.Array.Empty(), + System.Array.Empty(), + worldOffsetX: 0f, worldOffsetY: 0f); + + var cache = new PhysicsDataCache(); + var bspTree = new DatReaderWriter.Types.PhysicsBSPTree { Root = root }; + var physics = new GfxObjPhysics + { + BSP = bspTree, + PhysicsPolygons = new System.Collections.Generic.Dictionary(), + Vertices = new DatReaderWriter.Types.VertexArray(), + Resolved = resolved, + BoundingSphere = new DatReaderWriter.Types.Sphere { Origin = Vector3.Zero, Radius = 15f }, + }; + cache.RegisterGfxObjForTest(SyntheticGfxId, physics); + engine.DataCache = cache; + + engine.ShadowObjects.Register( + entityId: SyntheticGfxId, + gfxObjId: SyntheticGfxId, + worldPos: Vector3.Zero, + rotation: Quaternion.Identity, + radius: 15f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: LandblockId, + collisionType: ShadowCollisionType.BSP, + scale: 1.0f); + + return engine; + } + + /// + /// Falls a player-flagged mover from directly above the 63.4° slope's + /// mid-face and asserts it reaches the flat floor (or at minimum keeps + /// making downward/downhill progress) without a >0.5s frozen stretch. + /// + [Fact] + public void FallOntoSteepSlope_NeverFreezesForOverHalfASecond_AndReachesFloor() + { + var engine = MakeSlopeEngine(); + float r = BSPStepUpFixtures.SphereRadius; + const float dt = 1f / TicksPerSecond; + const float gravity = -9.8f; + + var body = new PhysicsBody + { + TransientState = TransientStateFlags.Active, + }; + + // Start well above the slope's mid-face (slope spans x in [0,1], z in + // [0,2] at that x-range), falling straight down. + Vector3 pos = new(0.5f, 0f, 3.0f); + float fallVelocityZ = 0f; + uint cell = CellId; + + var positions = new List(MaxTicks) { pos }; + int frozenStreak = 0; + bool reachedFloor = false; + + for (int tick = 0; tick < MaxTicks; tick++) + { + fallVelocityZ += gravity * dt; + Vector3 target = pos + new Vector3(0f, 0f, fallVelocityZ * dt); + + var result = engine.ResolveWithTransition( + currentPos: pos, + targetPos: target, + cellId: cell, + sphereRadius: r, + sphereHeight: r * 2f, + stepUpHeight: 0.30f, + stepDownHeight: 0.04f, + isOnGround: false, + body: body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0x01000000u); + + var newPos = result.Position; + float moved = Vector3.Distance(newPos, pos); + + if (moved < WedgeEpsilon) + frozenStreak++; + else + frozenStreak = 0; + + _out.WriteLine( + $"t{tick,3}: pos=({newPos.X:F3},{newPos.Y:F3},{newPos.Z:F3}) " + + $"moved={moved:F4} onGround={result.IsOnGround} onWalkable={result.OnWalkable} " + + $"contact={result.InContact} vz={fallVelocityZ:F2} frozen={frozenStreak}"); + + Assert.True(frozenStreak <= WedgeTickThreshold, + $"Body frozen for {frozenStreak} consecutive ticks (>{WedgeTickThreshold} == " + + $">0.5s) at tick {tick}, position ({newPos.X:F3},{newPos.Y:F3},{newPos.Z:F3}) — " + + "this is the 'stuck in falling animation on the roof' wedge shape."); + + pos = newPos; + cell = result.CellId; + body.Position = pos; + + if (result.IsOnGround) + fallVelocityZ = 0f; + + positions.Add(pos); + + // Reached the flat reference floor (x<0, z ~ r) — resolved cleanly. + if (pos.X < 0f && pos.Z <= r + 0.05f) + { + reachedFloor = true; + break; + } + } + + Assert.True(reachedFloor, + $"Body never reached the flat reference floor within {MaxTicks} ticks " + + $"({MaxTicks / (float)TicksPerSecond:F1}s); final position " + + $"({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) — this is the wedge the L.4 shortcut guards " + + "against (never resolving off the steep surface at all), distinct from a bounded " + + "per-tick freeze."); + } +}