test(physics): TS-4 fixture-first attempt reproduces the 2026-04-30 wedge; shortcut stays

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-30 08:28:18 +02:00
parent 4f7e29f7cf
commit 65de6921ce
3 changed files with 311 additions and 22 deletions

View file

@ -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

View file

@ -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

View file

@ -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;
/// <summary>
/// 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 (<c>docs/research/2026-07-30-response-layer-edge-family-pseudocode.md</c>
/// §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
/// <c>docs/research/2026-04-30-*</c> and the L.4 commit `b1af56e`), so this
/// test builds a dat-free multi-frame replay from the existing
/// <see cref="BSPStepUpFixtures.SlopedUnwalkable"/> geometry (a 63.4°
/// slope, normal.Z ≈ 0.447 — below <c>PhysicsGlobals.FloorZ</c> ≈ 0.6642 but
/// above <c>PhysicsGlobals.LandingZ</c> ≈ 0.0871, i.e. exactly the band the
/// L.4 commit's own steep-poly shortcut targets) using the same
/// <c>PhysicsEngine.ResolveWithTransition</c> multi-frame replay idiom as
/// <c>Issue185OutdoorStairsSeamReplayTests</c>.
///
/// <para>
/// A body falls from directly above the slope's mid-face, integrating
/// gravity between resolves exactly as <c>PhysicsBody.UpdatePhysicsInternal</c>
/// 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&lt;0, z=0. A healthy resolution reaches the flat floor (Z ≈
/// <see cref="BSPStepUpFixtures.SphereRadius"/>) 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
/// <c>LandingZ</c> threshold (matching <c>CTransition::check_walkable</c>,
/// pc:273202, <c>0.0871556997f</c>) and then downhill-drifting off it via
/// the already-ported TS-1 CliffSlide chain.
/// </para>
///
/// <para>
/// 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 <c>SetSlidingNormal</c> 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.
/// </para>
/// </summary>
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<CellSurface>(),
System.Array.Empty<PortalPlane>(),
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<ushort, DatReaderWriter.Types.Polygon>(),
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;
}
/// <summary>
/// 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.
/// </summary>
[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<Vector3>(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.");
}
}