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.");
+ }
+}