From 909bff0aa50fe32ffb9bad0d95853ddc1caf2a11 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Jul 2026 17:55:49 +0200 Subject: [PATCH 1/2] test(physics): #265 mining tool + real-trajectory replay harness for the steep-slope response family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tools/analyze_265_steep_slope_capture.py (segment miner for the ACDREAM_CAPTURE_RESOLVE JSONL captures: uphill-jump-bounce and lost-slide/edge-wedge signature scans) and tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs (a synthetic single-polygon PhysicsEngine that replays the EXACT real captured ballistic approach + landing from artifacts/matrix-session2-resolve.jsonl records 3415-3434, driving PhysicsEngine.ResolveWithTransition directly at the Core boundary). Mining found two dramatic real "velocity annihilation + permanent freeze" events (records 3153/3159 and 3433/3434): a high-speed fall lands on a moderate roof slope (normal.Z=0.857, ABOVE PhysicsGlobals.FloorZ — walkable by threshold), and the very next tick shows Velocity forced to exactly (0,0,0) with the position frozen byte-identical for the rest of the capture (12,292 ticks to EOF for the second event). No production code changes. Full Core.Tests suite: 4070 passed / 2 skipped. Co-Authored-By: Claude Fable 5 --- .../Issue265SteepSlopeCaptureBisectTests.cs | 366 ++++++++++++++++++ tools/analyze_265_steep_slope_capture.py | 234 +++++++++++ 2 files changed, 600 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs create mode 100644 tools/analyze_265_steep_slope_capture.py diff --git a/tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs b/tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs new file mode 100644 index 00000000..da53acf6 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs @@ -0,0 +1,366 @@ +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using AcDream.Core.Physics; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Issue #265 capture-driven bisection: "Steep-slope response set" (uphill-jump +/// bounce, lost roof slide, edge wedge). This harness replays a REAL trajectory +/// mined from a live capture (ACDREAM_CAPTURE_RESOLVE, +/// artifacts/matrix-session2-resolve.jsonl, records 3415-3434) through a +/// synthetic single-polygon built from the EXACT +/// polygon the live capture landed on +/// (bodyAfter.walkableVertices: (240,0,88),(264,0,80),(264,24,68), +/// normal (2,3,6)/7 = (0.2857,0.4286,0.8571) — a moderate, WALKABLE-BY-THRESHOLD +/// roof slope, normal.Z=0.857 > PhysicsGlobals.FloorZ (0.6642)). +/// +/// +/// Live symptom this reproduces (mining evidence): the player falls +/// (v ≈ (11.15, 14.13, -23.14) m/s at landing) onto this roof slope. Live capture +/// record 3433 shows collisionNormalValid=true, the correct real polygon +/// normal, and walkablePolygonValid=true — a legitimate walkable landing. +/// Record 3434 (the very next tick) shows the body FROZEN: velocity forced to +/// exactly (0,0,0), transientState=7 (Contact|OnWalkable|Sliding), and the +/// position stays byte-identical for the remaining 12,292 captured ticks (to +/// the end of the file) — i.e. the player never moves again. A second, +/// independent instance of the same shape appears at records 3153-3199+ (a +/// shallower ~18° roof edge, frozen for 46+ captured ticks). Full mining +/// evidence: docs/research/2026-07-30-265-capture-bisect.md. +/// +/// +/// +/// Candidate mechanism (S1): commit db2889af ("#116 shape-1") +/// changed BSPQuery.cs's Path-6 hasSphere1 (head-sphere-only hit +/// while airborne, foot sphere clear) branch from a steepness-gated +/// SetCollide→Adjusted (shallow, Z≥FloorZ) / slide-tangent-then-Slid (steep, +/// Z<FloorZ) dual path — IDENTICAL in shape to the still-unchanged sphere0 +/// (foot) branch a few lines above it — to an UNCONDITIONAL +/// SetCollisionNormal + return Collided, regardless of steepness. A +/// `Collided` return short-circuits TransitionalInsert immediately +/// (if (transitState == TransitionState.Collided) return +/// TransitionState.Collided;) — it never reaches the retry loop's Phase 3 +/// (if (sp.Collide) ...DoCheckWalkable...Placement retry...), which is +/// the ONLY place a shallow/walkable head-sphere hit can smoothly commit to a +/// real ContactPlane + OnWalkable via the SetCollide+Adjusted +/// path. This test's real captured polygon has normal.Z=0.857 — well +/// ABOVE FloorZ (0.6642) — so it is the SHALLOW case, not the steep one; +/// S1 removed the steepness branch entirely, so this shallow graze now takes +/// the SAME hard-stop path a steep hit would. +/// +/// +/// +/// Method: integrates the EXACT +/// captured ballistic state (position + velocity) from record 3415 forward +/// with real gravity (dt=1/30s, matching retail's tick rate), calling +/// every tick exactly like +/// does at the +/// Core boundary (this harness intentionally stops at that boundary — it does +/// NOT call PhysicsObjUpdate.HandleAllCollisions or model the R6 +/// animation-root-motion grounded-movement zeroing, both of which live outside +/// Core and are confirmed NOT part of the S1/S2 candidate set — see the research +/// doc). Once the mover reports IsOnGround, the harness keeps REQUESTING +/// forward motion each tick (simulating held input) so a genuine "does the +/// engine allow continued advance across this surface" signal is observable, +/// rather than trivially replaying the live capture's own (already-frozen, +/// no-input) subsequent targets. +/// +/// +/// +/// A/B protocol (see the research doc for the executed results): this +/// same test is run unmodified against (i) HEAD, (ii) BSPQuery.cs with +/// the S1 sphere1 branch reverted to mirror the still-current sphere0 shape +/// (local, uncommitted diagnostic edit), (iii) production unaffected by S2 +/// (calc_friction's AP-7 threshold) since S2 has zero call sites outside its +/// own unit test — confirmed by grep -rn "\.calc_friction(" src/ — so no +/// S2 toggle is needed for THIS harness, and (iv) both. The per-tick dump +/// ( list, printed via ) +/// is the diff target. +/// +/// +public class Issue265SteepSlopeCaptureBisectTests +{ + private readonly ITestOutputHelper _out; + public Issue265SteepSlopeCaptureBisectTests(ITestOutputHelper output) => _out = output; + + // ── Real captured polygon (bodyAfter.walkableVertices, record 3433) ────── + // artifacts/matrix-session2-resolve.jsonl, tick 3433, cell 0xAAB40011. + // normal = cross(v1-v0, v2-v0) normalized = (2,3,6)/7 exactly. + private static readonly Vector3 RoofV0 = new(240f, 0f, 88f); + private static readonly Vector3 RoofV1 = new(264f, 0f, 80f); + private static readonly Vector3 RoofV2 = new(264f, 24f, 68f); + + // Outdoor cell suffix MUST be < 0x0100 (retail's indoor/outdoor LandCell + // convention — CellTransit.BuildShadowCellSet branches on it) and its + // block index must be (0,0) so that the flood's landblock-local grid math + // (CellTransit.AddAllOutsideCells, an 8x8 24-m-cell grid over the 192-m + // landblock) treats this harness's coordinates as directly landblock-local + // — CellGraph.TryGetTerrainOrigin has no registered terrain for this + // synthetic landblock so it Zero-falls-back, meaning raw "world" position + // IS landblock-local position (documented anchor-frame convention, same + // one Ts4SteepRoofWedgeCaptureTests/DoorBugTrajectoryReplayTests rely on). + // The harness's very first run registered the shadow object at the REAL + // captured world coordinates (X≈256) under this convention — 256 is + // outside the valid [0,192) landblock-local range, so the flood produced + // an empty cell set and the object was silently never registered at all + // (zero collisions the whole replay). Fix: re-anchor the entire synthetic + // scene (triangle + approach trajectory) at the roof centroid so every + // coordinate here is small and landblock-local (see the research doc's + // harness-commissioning note). + // Suffix 0x0001 is the canonical (gridX=0, gridY=0) outdoor LandCell — the + // grid cell whose local origin is (0,0) — matching this harness's + // re-anchored roof centroid at world (0,0,0) (see the note above). An + // earlier attempt used suffix 0x0011; CellTransit.AddAllOutsideCells' + // LandDefs.AdjustToOutside re-derives the (lx,ly) grid cell from the + // sphere's ACTUAL position and silently corrects a mismatched seed, so the + // registration landed in cell 0x00000001 regardless of the literal seed + // passed — GetObjectsInCell(0x00000011) found nothing (see the research + // doc's harness-commissioning note). + private const uint CellId = 0x00000001u; + private const uint LandblockId = 0x00000000u; + private const uint SyntheticGfxId = 0x265BEEF1u; + + private const int TicksPerSecond = 30; + private const float Gravity = -9.8f; + private const float SphereRadius = 0.48f; // production human Setup 0x02000001 + private const float SphereHeight = 1.835f; // production human Setup 0x02000001 + + // Real captured state, record index 3415 (session2, tick 3415) — 18 ticks + // before the landing/freeze at record 3433/3434. vx/vy are constant across + // this whole approach (pure ballistic fall, no further horizontal drive). + // Re-anchored: subtract RoofCentroid from the real captured world position + // (see the landblock-local note above) — the RELATIVE approach geometry + // (distance, direction, velocity) is preserved exactly. + private static readonly Vector3 ApproachStartPosReal = new(244.59f, -0.81f, 92.79f); + private static readonly Vector3 ApproachStartVel = new(11.1509495f, 14.129979f, -16.72f); + + // ShadowObjects.Register's broad-phase culls candidates by distance from + // `worldPos` within `radius` — registering at Vector3.Zero with the real + // (far-from-origin) captured world coordinates put the polygon ~264 units + // from the query point, well outside any sane radius, so the very first + // run of this harness found ZERO collisions at all (see the research doc's + // "harness commissioning" note). Fix: register the entity at the + // triangle's centroid and express the polygon in LOCAL coordinates + // relative to that centroid (identity rotation, scale 1 — world = local + + // worldPos reconstructs the exact real-world triangle). + private static readonly Vector3 RoofCentroid = (RoofV0 + RoofV1 + RoofV2) / 3f; + + private static PhysicsEngine MakeRoofEngine() + { + var resolved = new Dictionary(); + var verts = new[] { RoofV0 - RoofCentroid, RoofV1 - RoofCentroid, RoofV2 - RoofCentroid }; + var normal = Vector3.Normalize(Vector3.Cross(verts[1] - verts[0], verts[2] - verts[0])); + float d = -Vector3.Dot(normal, verts[0]); + resolved[1] = new ResolvedPolygon + { + Vertices = verts, + Plane = new Plane(normal, d), + NumPoints = 3, + SidesType = CullMode.None, + }; + + var leaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 30f }, + }; + leaf.Polygons.Add(1); + + 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 PhysicsBSPTree { Root = leaf }; + var physics = new GfxObjPhysics + { + BSP = bspTree, + PhysicsPolygons = new Dictionary(), + Vertices = new VertexArray(), + Resolved = resolved, + BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 30f }, + }; + cache.RegisterGfxObjForTest(SyntheticGfxId, physics); + engine.DataCache = cache; + + // ShadowObjectRegistry is the per-cell shadow-object index (BR-7/A6.P4): + // Register() FLOODS from a SEED CELL outward and registers the entity + // into the resulting cell set; the query side (GetObjectsInCell) looks + // up strictly by the mover's CURRENT cell id. Leaving seedCellId at its + // default (0u) makes Register() call DeriveOutdoorSeed(worldPos, ...), + // which computes its OWN outdoor landcell id from world position — for + // the real captured coordinates used here (worldPos.X=256, well outside + // landblock 0xAAB40000's own 192 m span) that derives to a DIFFERENT + // cell than the literal CellId this harness resolves against, so the + // very first run of this fixture found zero collisions (see the + // research doc's harness-commissioning note). Passing seedCellId + // explicitly bypasses the derivation and floods from the exact cell + // the replay loop queries. + engine.ShadowObjects.Register( + entityId: SyntheticGfxId, + gfxObjId: SyntheticGfxId, + worldPos: Vector3.Zero, + rotation: Quaternion.Identity, + radius: 30f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: LandblockId, + collisionType: ShadowCollisionType.BSP, + scale: 1.0f, + seedCellId: CellId); + + return engine; + } + + public sealed record TickSample( + int Tick, + Vector3 Pos, + float Advance, + bool CollisionNormalValid, + Vector3 CollisionNormal, + bool OnGround, + int FrozenStreak); + + /// + /// Replays the real captured ballistic approach + landing, then keeps + /// REQUESTING forward motion (simulating held input) for + /// additional ticks once grounded, to + /// see whether the engine allows continued advance across the roof surface + /// or wedges in place. Returns one per tick. + /// + public static List ReplayRealRoofLanding(int postLandingTicks = 60) + { + var engine = MakeRoofEngine(); + const float dt = 1f / TicksPerSecond; + + var body = new PhysicsBody { TransientState = TransientStateFlags.Active }; + + Vector3 pos = ApproachStartPosReal - RoofCentroid; + Vector3 vel = ApproachStartVel; + uint cell = CellId; + bool grounded = false; + int frozenStreak = 0; + int ticksSinceGrounded = -1; + + var samples = new List(); + + // Budget: enough ticks to cover the ~18-tick ballistic approach plus the + // requested post-landing continuation window. + int maxTicks = 18 + postLandingTicks + 20; + + for (int tick = 0; tick < maxTicks; tick++) + { + if (!grounded) + vel = new Vector3(vel.X, vel.Y, vel.Z + Gravity * dt); + // Once grounded, keep requesting the SAME horizontal advance each + // tick (simulating held forward input) — this is the "does a + // slide continue" probe. Vertical requested delta is zero (resting + // against the surface, not still falling). + Vector3 requestedVel = grounded ? new Vector3(vel.X, vel.Y, 0f) : vel; + Vector3 target = pos + requestedVel * dt; + + var result = engine.ResolveWithTransition( + currentPos: pos, + targetPos: target, + cellId: cell, + sphereRadius: SphereRadius, + sphereHeight: SphereHeight, + stepUpHeight: 0.6f, + stepDownHeight: 1.5f, + isOnGround: grounded, + body: body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0x01000000u); + + float advance = Vector3.Distance(result.Position, pos); + if (advance < 0.001f) + frozenStreak++; + else + frozenStreak = 0; + + samples.Add(new TickSample( + tick, result.Position, advance, + result.CollisionNormalValid, result.CollisionNormal, + result.IsOnGround, frozenStreak)); + + pos = result.Position; + cell = result.CellId; + body.Position = pos; + + if (!grounded && result.IsOnGround) + { + grounded = true; + ticksSinceGrounded = 0; + } + else if (grounded) + { + ticksSinceGrounded++; + if (ticksSinceGrounded >= postLandingTicks) + break; + } + } + + return samples; + } + + /// + /// Characterization test: dumps the full per-tick trajectory so the A/B + /// bisect (this file's class doc) can diff HEAD vs the S1-reverted local + /// edit. Always passes — this is a diagnostic capture, matching the + /// project's existing LiveCompare_FirstCap_DiagnosticDump-style + /// tests. The actual pass/fail verdict is recorded in + /// docs/research/2026-07-30-265-capture-bisect.md, not as a + /// hardcoded assertion here, because the correct fix shape (and therefore + /// the correct future regression assertion) is still being decided. + /// + [Fact] + public void RealCapturedRoofLanding_CharacterizeCurrentBehavior() + { + PhysicsDiagnostics.ResetForTest(); + PhysicsDiagnostics.ProbeIndoorBspEnabled = true; + PhysicsDiagnostics.ProbeBuildingEnabled = true; + try + { + var samples = ReplayRealRoofLanding(); + + int maxFrozen = 0; + int landedAtTick = -1; + foreach (var s in samples) + { + maxFrozen = System.Math.Max(maxFrozen, s.FrozenStreak); + if (landedAtTick < 0 && s.OnGround) landedAtTick = s.Tick; + _out.WriteLine(string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "t{0,3}: pos=({1:F3},{2:F3},{3:F3}) adv={4:F4} cnv={5} n=({6:F3},{7:F3},{8:F3}) onGround={9} frozen={10}", + s.Tick, s.Pos.X, s.Pos.Y, s.Pos.Z, s.Advance, + s.CollisionNormalValid, s.CollisionNormal.X, s.CollisionNormal.Y, s.CollisionNormal.Z, + s.OnGround, s.FrozenStreak)); + } + + _out.WriteLine($"=== landedAtTick={landedAtTick} maxFrozenStreak={maxFrozen} totalTicks={samples.Count} ==="); + + // Sanity-only assertion: the replay must actually reach the roof + // (land) within the ballistic approach window — if this fails the + // synthetic fixture itself is wrong, not a physics-engine finding. + Assert.True(landedAtTick is >= 0 and < 30, + $"Replay never reached the synthetic roof polygon (landedAtTick={landedAtTick}); " + + "fixture geometry or approach trajectory needs adjustment before this is a valid oracle."); + } + finally + { + PhysicsDiagnostics.ResetForTest(); + } + } +} diff --git a/tools/analyze_265_steep_slope_capture.py b/tools/analyze_265_steep_slope_capture.py new file mode 100644 index 00000000..a79f8e7b --- /dev/null +++ b/tools/analyze_265_steep_slope_capture.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Issue #265 segment miner: steep-slope response family (uphill-jump bounce, +lost roof slide, edge wedge) from ACDREAM_CAPTURE_RESOLVE JSONL captures. + +Companion to tools/analyze_resolve_capture.py (the #182 OK/partial/stuck +classifier). This script targets the #265 symptom set specifically: + + (a) uphill-jump bounce — jumping INTO an upward slope reflects velocity + upward instead of sliding (retail does not bounce here). + (b) lost roof slide — a body resting on a steep (non-walkable) roof + surface stops advancing instead of gliding/sliding off. + (c) edge wedge — the body oscillates near-motionless at a + collision point for many consecutive ticks. + +Each JSONL record (PhysicsResolveCapture.ResolveCaptureRecord) has: + input.{currentPos,targetPos,cellId,...} + bodyBefore/bodyAfter (PhysicsBodySnapshot incl. velocity, transientState — + bit 0 = Contact, bit 1 = OnWalkable) + result.{position,cellId,isOnGround,collisionNormalValid,collisionNormal} + +IMPORTANT ordering fact (verified against source, 2026-07-30): capture happens +INSIDE PhysicsEngine.ResolveWithTransition (PhysicsEngine.cs:1489), so +bodyAfter reflects state at the end of the resolve call — BEFORE +PhysicsObjUpdate.HandleAllCollisions runs (that happens later in +PlayerMovementController, using resolveResult.CollisionNormal directly). So a +velocity REFLECTION from HandleAllCollisions shows up as a jump in the NEXT +record's bodyBefore.velocity, not in the current record's bodyAfter.velocity. +This script's bounce heuristic accounts for that one-tick lag. + +Usage: py tools/analyze_265_steep_slope_capture.py capture1.jsonl [capture2.jsonl ...] +""" +import sys +import json +import math + +FLOOR_Z = 0.6642 # PhysicsGlobals.FloorZ — walkable/steep boundary +CONTACT_BIT = 0x1 # TransientStateFlags.Contact +WALKABLE_BIT = 0x2 # TransientStateFlags.OnWalkable + +STEEP_NORMAL_MIN = 0.02 # exclude near-vertical walls (normal.z ~ 0) +STEEP_NORMAL_MAX = FLOOR_Z # exclude walkable/floor-like surfaces + +BOUNCE_VZ_JUMP = 0.5 # m/s — next-tick upward Z-velocity jump considered a "bounce" +STALL_EPS = 0.01 # m — position barely advanced +STALL_MIN_RUN = 6 # consecutive stalled ticks to call it a "lost slide" / wedge + + +def vlen(v): + return math.sqrt(v["x"] ** 2 + v["y"] ** 2 + v["z"] ** 2) + + +def vsub(a, b): + return {"x": a["x"] - b["x"], "y": a["y"] - b["y"], "z": a["z"] - b["z"]} + + +def dist(a, b): + return vlen(vsub(a, b)) + + +def load(path): + records = [] + with open(path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + records.append(json.loads(line)) + except json.JSONDecodeError: + continue + return records + + +def is_airborne(bb): + return (bb.get("transientState", 0) & CONTACT_BIT) == 0 + + +def is_contact_not_walkable(bb): + ts = bb.get("transientState", 0) + return (ts & CONTACT_BIT) != 0 and (ts & WALKABLE_BIT) == 0 + + +def is_steep_normal(n): + z = n["z"] + return STEEP_NORMAL_MIN < z < STEEP_NORMAL_MAX + + +def find_uphill_bounce_candidates(records, path_label): + """Signature A: airborne mover hits a steep (non-floor, non-wall) normal, + then the NEXT record's bodyBefore.velocity.z jumps up noticeably while + still airborne — the fingerprint of an elastic reflection off a slope + (PhysicsObjUpdate.HandleAllCollisions, gated by CollisionNormalValid and + shouldReflect=true-while-airborne).""" + hits = [] + for i in range(len(records) - 1): + rec = records[i] + bb = rec.get("bodyBefore") or {} + res = rec.get("result") or {} + if not is_airborne(bb): + continue + if not res.get("collisionNormalValid"): + continue + n = res.get("collisionNormal") or {"x": 0, "y": 0, "z": 0} + if not is_steep_normal(n): + continue + + nxt = records[i + 1] + nbb = nxt.get("bodyBefore") or {} + vz_now = bb.get("velocity", {}).get("z", 0.0) + vz_next = nbb.get("velocity", {}).get("z", 0.0) + still_airborne_next = is_airborne(nbb) + + if still_airborne_next and (vz_next - vz_now) > BOUNCE_VZ_JUMP: + hits.append({ + "file": path_label, + "tick": rec.get("tick"), + "index": i, + "normal": n, + "vz_before": vz_now, + "vz_after_next_tick": vz_next, + "pos": rec.get("input", {}).get("currentPos"), + "cellId": rec.get("input", {}).get("cellId"), + }) + return hits + + +def find_lost_slide_runs(records, path_label): + """Signature B: a run of >= STALL_MIN_RUN consecutive ticks where the + body is in Contact-but-not-OnWalkable (resting against a steep surface, + the retail-faithful state for a roof/slope per the R6/#182 digest), a + move was requested each tick, but net advance stays near zero — the + "lost roof slide" / edge-wedge fingerprint. Runs adjacent in tick order + are merged; only runs of qualifying length are reported.""" + runs = [] + i = 0 + n = len(records) + while i < n: + rec = records[i] + bb = rec.get("bodyBefore") or {} + inp = rec.get("input") or {} + res = rec.get("result") or {} + + requested = dist(inp.get("targetPos", inp.get("currentPos", {"x":0,"y":0,"z":0})), + inp.get("currentPos", {"x": 0, "y": 0, "z": 0})) + advanced = dist(res.get("position", inp.get("currentPos", {"x":0,"y":0,"z":0})), + inp.get("currentPos", {"x": 0, "y": 0, "z": 0})) + + qualifies = (is_contact_not_walkable(bb) + and requested > STALL_EPS + and advanced <= STALL_EPS) + + if not qualifies: + i += 1 + continue + + start = i + while i < n: + rec2 = records[i] + bb2 = rec2.get("bodyBefore") or {} + inp2 = rec2.get("input") or {} + res2 = rec2.get("result") or {} + requested2 = dist(inp2.get("targetPos", inp2.get("currentPos", {"x":0,"y":0,"z":0})), + inp2.get("currentPos", {"x": 0, "y": 0, "z": 0})) + advanced2 = dist(res2.get("position", inp2.get("currentPos", {"x":0,"y":0,"z":0})), + inp2.get("currentPos", {"x": 0, "y": 0, "z": 0})) + ok2 = (is_contact_not_walkable(bb2) and requested2 > STALL_EPS and advanced2 <= STALL_EPS) + if not ok2: + break + i += 1 + end = i - 1 + + run_len = end - start + 1 + if run_len >= STALL_MIN_RUN: + first = records[start] + last = records[end] + runs.append({ + "file": path_label, + "start_tick": first.get("tick"), + "end_tick": last.get("tick"), + "start_index": start, + "end_index": end, + "length": run_len, + "cellId": first.get("input", {}).get("cellId"), + "pos_start": first.get("input", {}).get("currentPos"), + "pos_end": last.get("input", {}).get("currentPos"), + "contactPlaneNormal": (first.get("bodyBefore") or {}).get("contactPlane", {}).get("normal"), + }) + return runs + + +def main(paths): + all_bounce = [] + all_stall = [] + for path in paths: + records = load(path) + label = path + bounce = find_uphill_bounce_candidates(records, label) + stall = find_lost_slide_runs(records, label) + all_bounce.extend(bounce) + all_stall.extend(stall) + + print(f"=== {path}: {len(records)} records ===") + print(f" uphill-jump-bounce candidates: {len(bounce)}") + print(f" lost-slide/edge-wedge runs (>= {STALL_MIN_RUN} ticks): {len(stall)}") + + print() + print("=== Signature A: uphill-jump bounce (first 15) ===") + for h in all_bounce[:15]: + print(f" {h['file']} tick={h['tick']} idx={h['index']} cell=0x{h['cellId']:08X} " + f"normal=({h['normal']['x']:.3f},{h['normal']['y']:.3f},{h['normal']['z']:.3f}) " + f"vz {h['vz_before']:.3f} -> {h['vz_after_next_tick']:.3f} " + f"pos=({h['pos']['x']:.2f},{h['pos']['y']:.2f},{h['pos']['z']:.2f})") + + print() + print("=== Signature B: lost-slide / edge-wedge runs (first 15, sorted by length desc) ===") + all_stall.sort(key=lambda r: -r["length"]) + for r in all_stall[:15]: + cn = r["contactPlaneNormal"] or {"x": 0, "y": 0, "z": 0} + print(f" {r['file']} ticks=[{r['start_tick']}..{r['end_tick']}] " + f"idx=[{r['start_index']}..{r['end_index']}] len={r['length']} " + f"cell=0x{r['cellId']:08X} cpNormal=({cn['x']:.3f},{cn['y']:.3f},{cn['z']:.3f}) " + f"pos {r['pos_start']['x']:.2f},{r['pos_start']['y']:.2f},{r['pos_start']['z']:.2f} " + f"-> {r['pos_end']['x']:.2f},{r['pos_end']['y']:.2f},{r['pos_end']['z']:.2f}") + + print() + print(f"TOTAL: {len(all_bounce)} bounce candidates, {len(all_stall)} stall runs " + f"across {len(paths)} file(s).") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + main(sys.argv[1:]) From 96a62a191bbe41e6f804871b2dfe699b81e6f32c Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Jul 2026 17:59:57 +0200 Subject: [PATCH 2/2] docs(research): #265 capture bisect - S1 and S2 both cleared, real culprit is R6 grounded-animation-zero (S3, pre-Campaign-P) Root-caused via segment mining + a real-trajectory replay harness (previous commit). Mined two dramatic real "velocity annihilation + permanent freeze" events from the live capture (a high-speed fall landing on a moderate, walkable-by-threshold roof slope, then a full velocity zero + frozen position for the rest of the capture - 12,292 ticks for the worse of the two). A/B verdict: S1 (db2889af, #116 shape-1's Path-6 hasSphere1 change) is provably UNREACHED for the mined trajectory - hit1 never fires once across the 80-tick replay, and diagnostic instrumentation shows the landing actually goes through the still-unchanged sphere0 (foot) branch. Reverting S1 locally produced byte-identical replay output, confirming this mechanically rather than by inference. S2 (calc_friction's AP-7 threshold) has zero production call sites (grep-confirmed) - it is dead code and cannot affect any live behavior in either direction. The real mechanism, hand-traced against both mined events exactly: the R6 "grounded movement is animation-root-motion-owned" architecture (PlayerMovementController.cs:1868-1882, landed 2026-07-20 via f961d700, ten days before Campaign P) unconditionally zeros horizontal Velocity every tick once OnWalkable is true. With no held movement key at the instant of landing, the mover never advances again - a frozen-phase design predating Campaign P entirely, not a regression from S1/S2. Recommended direction: do not revert S1 (a real, narrow, retail-faithful fix unrelated to these two events); do not touch S2 until it's actually wired into a live path; the real target is #166 (downhill sled) plus the grounded-movement architecture, which needs a brainstorming pass before any implementation, not a quick S1/S2 revert. Co-Authored-By: Claude Fable 5 --- .../research/2026-07-30-265-capture-bisect.md | 437 ++++++++++++++++++ 1 file changed, 437 insertions(+) create mode 100644 docs/research/2026-07-30-265-capture-bisect.md diff --git a/docs/research/2026-07-30-265-capture-bisect.md b/docs/research/2026-07-30-265-capture-bisect.md new file mode 100644 index 00000000..819e7a1a --- /dev/null +++ b/docs/research/2026-07-30-265-capture-bisect.md @@ -0,0 +1,437 @@ +# #265 capture-driven bisection — steep-slope response family + +**Status: verdict reached, S1 and S2 both CLEARED for the two concrete +mined events; real mechanism identified as a pre-existing (frozen-phase) +architecture, not a Campaign P regression.** This is a research/bisection +pass; no production code was changed. The harness (committed, +`tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs`) +and mining tool (`tools/analyze_265_steep_slope_capture.py`) are permanent; +the A/B code toggles described below were applied and reverted locally and +never committed. + +## 0. Scope recap + +Issue #265 (`docs/ISSUES.md`): after the TS-4-removal-then-revert +(`2e27d066`+`a8a7d64b`), the live matrix gate (2026-07-30, scenarios 4/5) +found three symptoms: (a) jumping INTO an uphill slope bounces (retail does +not), (b) house-roof slides no longer happen, (c) occasional +stuck-sliding-on-an-edge. Two remaining Campaign-P suspects were named: + +- **S1** — `db2889af` ("#116 shape-1"): `BSPQuery.cs` Path-6's `hasSphere1` + (head-sphere-only hit while airborne, foot sphere clear) branch changed + from a steepness-gated dual path (steep → slide-tangent-then-`Slid`; + shallow → `SetCollide`+`Adjusted`) to an unconditional + `SetCollisionNormal` + `return Collided`. +- **S2** — the AP-7 `calc_friction` threshold rewrite (merge `26e0334a`): + `0.0` → `0.25`, unconditional into-plane velocity subtraction past the + threshold. + +## 1. Segment mining + +Captures used: `artifacts/matrix-session2-resolve.jsonl` (15,726 records, +copied from the coordinator worktree's `artifacts/matrix-session2-resolve.jsonl`) +and `artifacts/matrix-session3-resolve.jsonl` (12,145 records at copy time). + +**Session3 is not usable.** Every one of its 12,145 records shows the +identical position `(60.372223, 9.071998, 79.344925)`, zero velocity, and +`transientState=3` (Contact|OnWalkable) from tick 0 to tick 12144 — the +player was standing perfectly still (likely AFK / alt-tabbed) for the +entire ~7.2-minute capture window. It contains no motion at all and was +excluded from further analysis. + +### 1.1 First pass — strict signature scan (`tools/analyze_265_steep_slope_capture.py`) + +Two signatures were scanned for directly on the JSONL fields: + +- **Signature A (uphill-jump bounce)**: an airborne record (`bodyBefore` + Contact bit clear) with `result.collisionNormalValid=true` and a "steep, + non-floor, non-wall" normal (`0.02 < normal.Z < FloorZ=0.6642`), followed + by a next-tick upward jump in `bodyBefore.velocity.z` while still + airborne. +- **Signature B (lost-slide / edge-wedge)**: ≥6 consecutive ticks with + Contact set but OnWalkable clear (resting against a non-walkable steep + surface), a non-trivial requested move each tick, and near-zero net + advance. + +**Result: 0 hits for both signatures, in both files.** Session2 has only +38 `collisionNormalValid=true` records total (out of 15,726), and every one +of them has `normal.Z` in the `[0.85, 1.0]` bucket — i.e. every reported +collision normal in this capture is CLOSE TO FLAT/floor-like, never in the +"genuinely steep" `< FloorZ` band my first-pass signature targeted. This is +an honest negative result for the specific "steep" heuristic; the actual +symptom-bearing frames, mined below, are moderate-angle (Z≈0.86–0.95, +above `FloorZ` — walkable BY THE THRESHOLD) and are found by a different +signature. + +### 1.2 Second pass — velocity-annihilation scan + +Widened the signature to "a tick where `|horizontal velocity|` before is +`>2 m/s` and after (next tick) is `<0.05 m/s`, then how long the position +stays frozen afterward." This found exactly **two events**, both in +session2: + +| idx (0-based) | tick | `|v_horiz|` before | frozen for | cell | +|---|---|---|---|---| +| 3153 | 3153 | 8.90 m/s | 46+ ticks (session2 continues past it; not EOF) | `0xAAB30007` | +| **3434** | **3434** | **18.00 m/s** | **12,292 ticks — to EOF** | `0xAAB40011` | + +**Event at idx 3433/3434 (the primary oracle for this pass), full trace** +(`records[3415..3434+41]`, printed via ad-hoc Python — see +`tools/analyze_265_steep_slope_capture.py` for the reusable scanner): + +- Ticks 3415–3432: clean ballistic fall. `vBefore = (11.15, 14.13, vz)` + with `vz` accumulating from ‑16.72 to ‑23.14 (pure gravity, no further + horizontal drive — a jump/leap with residual momentum, exactly the kind + of trajectory the oracle plan's §1.3 predicted would NOT hit the + TS-4 degenerate case). `Z` falls from 92.79 to 79.90. +- **Tick 3433 (the landing):** `result.collisionNormalValid=true`, + `result.collisionNormal=(0.2857143, 0.42857143, 0.85714287)` — exactly + `(2,3,6)/7`, a REAL polygon normal (not the `UnitZ` degenerate default). + `result.isOnGround=true`. `bodyAfter.contactPlaneValid=true`, + `bodyAfter.walkablePolygonValid=true`, `bodyAfter.walkableVertices` = the + triangle `(240,0,88), (264,0,80), (264,24,68)` — `normal.Z=0.857`, well + ABOVE `PhysicsGlobals.FloorZ` (0.6642): **a legitimately walkable roof + slope, not the "steep, non-walkable" case either S1 or the original TS-4 + shortcut ever targeted.** `bodyAfter.velocity` is UNCHANGED + `(11.15,14.13,‑23.14)` — confirms (see `PhysicsEngine.cs:1489`, capture + fires inside `ResolveWithTransition`, before any caller-side velocity + response) that neither `calc_friction` nor `HandleAllCollisions` ran yet. +- **Tick 3434 (the very next resolve call):** `input.currentPos == + input.targetPos` (ZERO requested motion this tick — the previous frame's + integration already produced zero displacement). `bodyBefore.velocity = + (0, 0, 0)` — **already fully zeroed by the time THIS resolve call even + starts.** `transientState=7` (Contact|OnWalkable|Sliding). Every + subsequent record (12,292 of them, to the literal end of the file) is + byte-identical: same position, same zero velocity, same + `transientState=7`. + +**Event at idx 3152/3153** is the same shape at a shallower ~18° roof edge +(`normal≈(0,0.32,0.95)`): the player glides/climbs cleanly along the edge +for ~140 ticks (idx 3016–3152, gaining ~10 m of Z — this portion is +healthy behavior), then at idx 3153 horizontal velocity is forced to +exactly zero in one tick and the position freezes for the rest of the +examined window. + +**Both events are the SAME shape**: a real, correct, non-default collision +normal is recorded on the landing tick; on the very next tick the mover's +full horizontal velocity has already vanished and the position never +changes again. This is what the user experiences as "roof slides no +longer happen" (symptom b) and "occasional stuck-sliding-on-an-edge" +(symptom c). Neither event's landing surface is steep by `FloorZ` — both +are moderate, walkable-by-threshold roof pitches. + +## 2. Replay harness + +`Issue265SteepSlopeCaptureBisectTests.cs` builds a synthetic +`PhysicsEngine` containing ONE polygon — the exact real triangle recovered +from record 3433's `bodyAfter.walkableVertices` — registered via +`ShadowObjectRegistry`, then replays the EXACT real captured ballistic +state (position + velocity, record index 3415) forward with real gravity +at 30 Hz, calling `PhysicsEngine.ResolveWithTransition` every tick exactly +like `PlayerMovementController` does at the Core boundary. Once the mover +reports `IsOnGround`, the harness keeps REQUESTING the same forward +velocity every tick (simulating held input) — this is deliberate: it turns +the harness from "replay what the live game did" (which trivially +reproduces the freeze, since the live game's own subsequent inputs were +already zero — see §4) into "does the physics engine itself allow +continued advance across this surface," which is the actual question S1 +and S2 bear on. + +### 2.1 Harness commissioning (three real bugs found and fixed while building it — kept as code comments) + +1. `ShadowObjectRegistry.Register`'s broad-phase culls by distance from + `worldPos`. Registering at the literal real-world coordinates (X≈256) + while querying at world origin put the polygon ~264 units away — the + very first run found **zero collisions at all**. Fixed by re-anchoring + the whole synthetic scene (triangle + approach trajectory) at the + triangle's centroid. +2. `CellTransit.BuildShadowCellSet`'s outdoor flood + (`CellTransit.AddAllOutsideCells`) treats world position as + landblock-local (an anchor-frame convention shared with + `Ts4SteepRoofWedgeCaptureTests`/`DoorBugTrajectoryReplayTests`, active + whenever `CellGraph.TryGetTerrainOrigin` has no real terrain to + consult). The real-world coordinates (X≈256) are outside the valid + `[0,192)` per-landblock range even after centroid re-anchoring picked a + bad cell id — still zero collisions. +3. `LandDefs.AdjustToOutside` (inside the flood) **silently re-derives** + the actual `(lx,ly)` grid cell from the sphere's real position and + corrects a mismatched seed rather than honoring the literal + `seedCellId` passed to `Register` — an arbitrary chosen cell id + (`0x00000011`) registered successfully (`TotalRegistered=1`) but + `GetObjectsInCell(0x00000011)` came back empty; the entity had actually + landed in cell `0x00000001` (the canonical grid-(0,0) cell, matching + the re-anchored centroid). Switching the harness's cell id to + `0x00000001` fixed it. + +These are documented in the test file's code comments in case another +harness hits the same three traps. + +## 3. A/B outcomes + +### (i) HEAD vs (ii) S1-reverted + +The S1 revert (`BSPQuery.cs`'s `hasSphere1` branch restored to the +pre-`db2889af` steepness-gated dual path, mirroring the still-current +`sphere0` branch — applied locally, verified `git diff --stat` clean +before and after, never committed) produced **byte-identical output** to +HEAD for the full 80-tick replay: same landing tick (19), same clean +44-tick glide (ticks 20–62, `adv=0.5149` every tick, `cnv=false`), same +freeze at tick 63 (`adv=0.0000` for 16+ consecutive ticks, capped by the +harness's tick budget — it would continue indefinitely), same recorded +`collisionNormal=(-0.958,0.128,0.256)` from that point on. + +**Why they're identical — confirmed by diagnostic instrumentation** +(`PhysicsDiagnostics.ProbeIndoorBspEnabled`/`ProbeBuildingEnabled`, the +`[path-dispatch]`/`[path5-diag]` probes `db2889af` itself added): across +the entire 80-tick replay, **`hit1=True` never appears once.** The two +`[path-dispatch] ... collide=True ... contact=False ...` lines (Path 6 +firing during the airborne approach) are followed by +`insertType=Placement` (Phase 3's walkable-landing retry succeeding) — +this is the STILL-UNCHANGED `sphere0` (foot) branch's graceful +`SetCollide`→`Adjusted`→Phase-3-Placement chain, not the `hasSphere1` +branch S1 touched. Once grounded, every subsequent Path-5 dispatch reports +`hit0=False hitPoly0=False` then `hit1=False hitPoly1=False` — a genuinely +clean glide with no collision at all, which is why the S1 edit (which only +fires inside `if (hit1 || hitPoly1 is not null)`) never executes for this +trajectory. **S1's site is provably unreached by the real mined +trajectory that produced the freeze.** Reverting code that never runs +cannot change the outcome — this is not a coincidence, it's the direct +mechanical explanation. + +### (iii) S2 toggle + +**Not run as a harness A/B — proven inert by static analysis instead.** +`grep -rn "\.calc_friction(" src/` returns **zero production call sites** — +the only callers of `PhysicsBody.calc_friction` in the entire repository +are its own unit tests (`tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs`). +`PlayerMovementController.cs` mentions it only in a code comment +(line ~2021, "friction next frame") — it is never invoked. Neither +`ResolveWithTransition` nor `PlayerMovementController`'s tick loop calls +`calc_friction` anywhere. **S2's threshold value (0.0 vs 0.25) cannot +affect any live or replayed behavior, full stop** — there is no toggle to +run because there is no live code path to toggle. + +### (iv) Both reverted + +Follows immediately from (ii) and (iii): with S1 reverted producing +byte-identical output to HEAD, and S2 provably inert, the "both" variant +is mathematically identical to (ii), which is identical to (i). No +separate run was needed. + +### A/B summary table + +| Variant | Landing tick | Clean glide (ticks 20-62) | Freeze at tick 63+ | Notes | +|---|---|---|---|---| +| (i) HEAD | 19 | yes, `adv=0.5149`/tick | yes, frozen forever | `hit1` never true | +| (ii) S1 reverted | 19 (identical) | yes (identical) | yes (identical) | S1's branch unreached | +| (iii) S2 toggle | n/a | n/a | n/a | dead code, no call sites | +| (iv) both | 19 (identical) | yes (identical) | yes (identical) | follows from (ii)+(iii) | + +## 4. The actual mechanism (found by hand-tracing the live capture against `PlayerMovementController.cs`, independently confirming it explains BOTH mined freeze events exactly) + +Neither S1 nor S2 touch velocity. The full-zero-in-one-tick signature +(§1.2) is produced by two pre-existing, Campaign-P-independent pieces +working in sequence: + +1. **The landing tick** (`PlayerMovementController.cs`, the + `if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f)` block): + Contact+OnWalkable are set, and — because `Velocity.Z < 0` — ONLY the + Z component is hand-zeroed: velocity becomes `(11.15, 14.13, 0)`. + `PhysicsObjUpdate.HandleAllCollisions` then runs with `shouldReflect = + true` (the mover was airborne the frame before: `prevOnWalkable=false` + makes `shouldReflect` unconditionally true regardless of the new + grounded state — see `PhysicsObjUpdate.cs:163-164`). But + `dot(velocity, collisionNormal) = dot((11.15,14.13,0), + (0.286,0.429,0.857)) ≈ +9.25` — POSITIVE (moving away from, not into, + the surface, because the Z component that would have made it negative + was just zeroed) — so the `if (dot < 0f)` reflection guard + (`PhysicsObjUpdate.cs:177`) never fires. Velocity survives this tick as + `(11.15, 14.13, 0)`. +2. **The very next tick** (`PlayerMovementController.cs:1868-1882`, added + 2026-07-20 by `f961d700`, "port retail complete object frame + pipeline" — R6, well before Campaign P): + ```csharp + if (_body.OnWalkable) + { + float savedWorldVz = _body.Velocity.Z; + if (hasAnimationRootMotion) + { + _body.Velocity = new Vector3(0f, 0f, savedWorldVz); + } + ... + } + ``` + `OnWalkable` is now true (set last tick), so this runs UNCONDITIONALLY, + EVERY tick, for as long as the mover stays grounded: it zeros + `Velocity.X/Y` to exactly zero (`savedWorldVz` is already 0 from step + 1), replacing physics-integrated horizontal velocity with + animation-root-motion-driven displacement (`pmDelta.Origin`, populated + from `_advanceAnimationRootMotion`, which only produces nonzero + displacement when a movement key is actually held). **With no key held + at the instant of landing, `pmDelta.Origin` stays `Vector3.Zero` forever, + and the mover never advances again.** This reproduces `bodyBefore.velocity + = (0,0,0)` at record 3434 exactly, and the permanent freeze that follows. + +This is the R6 "local player animation-owned grounded movement" +architecture: once grounded, walking is driven entirely by held-input + +animation root motion, not by integrating `Velocity`. It has been in +place since 2026-07-20 — **ten days before Campaign P and the TS-4 +removal/revert (2026-07-29/30)** — and is explicitly a frozen-phase +architecture per the milestones doc (R6 shipped; the freeze list bars +rework without a dedicated brainstorm). It is retail-DIVERGENT in one +specific way that matters here: retail does not need a held key to carry +residual momentum across a landing — a fast fall onto a walkable-but- +sloped surface should glide/sled per `docs/ISSUES.md` #166 ("Slope-landing +glide + bounce absent... acdream lands clean and dead"), which is filed, +open, and explicitly OUT OF SCOPE for this pass (the `Sledding` +`PhysicsStateFlags` bit that would let `calc_friction`'s Sledding-gated +overrides engage is never set anywhere in the codebase — a separate, +already-tracked gap). + +`git log --oneline -3 -- src/AcDream.Core/Physics/PhysicsObjUpdate.cs` +confirms `HandleAllCollisions` itself was also last touched by an +unrelated water fix (AP-10, `cc8d57a2`) — Campaign P did not modify it +either. + +## 5. Re-reading the oracle plan's S1 claim against the mined evidence + +The task asked specifically: if S1's port is faithful but its SCOPE is +wrong, say exactly that. Re-checked against +`docs/research/2026-07-30-ts4-116-oracle-plan.md` §2.3-§2.4 and §3, plus +this pass's own finding: + +- **S1's port IS faithful in isolation.** Its cited sources + (`acclient_2013_pseudo_c.txt:323824-323834`, ACE `BSPTree.cs:221-230`) + are an exact structural match — not a BN misdecompile, not a citation + error. This was independently re-verified by reading the current + `BSPQuery.cs:2259-2302` against the same two sources again this pass; no + discrepancy found. +- **S1's scope is narrower than any symptom this pass could reproduce — + not wider.** The oracle plan's own Addendum 2 (§"implementation + session") already found this exact pattern once, for the door + tick-22760 capture: the hypothesis assumed the "not-yet-in-Contact" + branch would fire, but the mover was actually GROUNDED (`Contact` set), + so dispatch went to Path 5 instead and S1's site was never reached. This + pass finds the SAME pattern a second, independent time, for a + DIFFERENT capture (a genuine airborne fall, not a grounded door-push): + the foot sphere (`sphere0`) reaches the rising/sloped polygon at the + same moment as or before the head sphere, so the `if (hit0 || + hitPoly0 is not null)` branch above `hasSphere1`'s check fires first and + RETURNS before `hasSphere1`'s block is ever entered + (`BSPQuery.cs:2188` gates the whole `hasSphere1` block behind falling + through that first `if`). `hit1=True` never appears once across the + entire 80-tick replay, confirming this mechanically, not just by + inference. +- **Two independent capture families (a grounded door-push, and now an + airborne fall-and-land) both show S1's site going unreached.** This + strongly suggests S1's real-world reach is much narrower than its + authors worried — for it to matter, a trajectory would need the FOOT + sphere to stay clear while the HEAD sphere alone grazes a polygon + during an airborne (not-yet-grounded) frame — e.g. jumping up under an + overhang, or clipping a roof's underside while airborne with the feet + still below the eave line. **Neither of #265's two concrete mined + freeze events is that geometry.** S1 remains a real, citable, retail- + faithful port-accuracy improvement and should NOT be reverted on this + evidence (it fixes a genuine, if narrow, divergence for whenever its + exact geometry does occur) — but it is not implicated in the symptoms + #265 was filed against. + +## 6. Named culprit + +**Neither S1 nor S2. This is S3 — but not a NEW regression: it is the +pre-existing, frozen-phase R6 "grounded movement is animation-root-motion- +owned" architecture (`PlayerMovementController.cs:1868-1882`, landed +2026-07-20 via `f961d700`, ten days before Campaign P), which +unconditionally zeros the mover's horizontal `Velocity` every tick once +`OnWalkable` is true, with no gate on approach speed, surface steepness, +or how the mover became grounded.** It was mechanically traced, tick by +tick, against BOTH of #265's concrete mined freeze events and reproduces +the observed `(0,0,0)` velocity and permanent position-freeze exactly. + +This explains symptom (b) (roof slides don't continue — there is no +"continue," walking requires a held key that landing doesn't supply) and +symptom (c) (stuck at the landing spot indefinitely) completely, for both +mined events. It does **not**, by itself, explain symptom (a) (the +"bounce" on jumping into an uphill slope) — that is a property of +`PhysicsObjUpdate.HandleAllCollisions`'s elastic reflection (`shouldReflect += true` whenever the mover was NOT already on walkable ground before AND +after the resolve — `PhysicsObjUpdate.cs:163-164`), which is ALSO +pre-existing (from the #182 rebuild, well before Campaign P) and fires for +ANY valid `CollisionNormal` reported while airborne, regardless of which +BSPQuery branch produced it. This pass did not find or replay a concrete +"bounce" event in the captures (the closest analogue — the tick-63 +edge-freeze in the replay harness — shows a suspicious secondary normal, +`(-0.958,0.128,0.256)`, unrelated to the registered polygon's own plane +normal, with Path-5 diagnostics showing no fresh BSP hit during the frozen +ticks; this smells like stale `ContactPlane`/`CollisionNormal` persistence +at a polygon boundary rather than a fresh reflection, and — like the S1 +revert — was unaffected by reverting S1. It is flagged as a genuine open +question, not resolved this pass, and may be an artifact of this +harness's single small (24-unit) synthetic triangle rather than a general +production bug; a real roof's continuous mesh would not present a "run off +the edge of a 24-unit patch" boundary at all. See §7). + +## 7. What's still open (do not guess, per CLAUDE.md) + +1. **Why does the user perceive this as a NEW regression coinciding with + Campaign P**, if the freeze mechanism (§4) predates it by ten days and + is unaffected by S1/S2? Two honest hypotheses, neither confirmed: + (a) the roof-jump/fall scenario was specifically exercised for the + FIRST time as part of the Campaign P visual matrix (scenarios 4/5), + surfacing a pre-existing bug rather than a new one; (b) a genuinely + separate, not-yet-isolated interaction exists. Resolving this needs + either a live retail-vs-acdream side-by-side of the EXACT same + fall-and-land-with-no-input scenario pre-Campaign-P (to confirm the + freeze is not new), or a fresh capture of the user's ACTUAL "roof + slide" repro (holding a movement key throughout, not a passive fall) to + see whether the animation-root-motion path (which DOES produce + displacement while a key is held) also fails. +2. **The tick-63 edge freeze** in this pass's own harness (§6, closing + parenthetical) — a `CollisionNormal` unrelated to the registered + polygon's plane, reported while Path-5 diagnostics show no fresh hit. + Candidate next step: extend the harness's synthetic roof to several + contiguous polygons (removing the small-triangle-edge artifact) and + re-run; if the freeze persists on a much larger interior region, it is + a real, separate, third mechanism worth its own root-cause pass + (possibly `SpherePath.PrecipiceSlide`'s edge-crossing test, or stale + `LastKnownContactPlane` persistence — NOT yet confirmed, do not guess + further). +3. **Symptom (a)'s bounce** was analyzed only by static code reading + (`HandleAllCollisions`'s reflection math), not independently reproduced + against a live-captured bounce event — none of the 38 + `collisionNormalValid=true` records in session2 showed the "airborne, + then a large upward `Velocity.Z` jump next tick" signature this pass's + Signature-A scanner looked for. A fresh capture specifically of a + jump-into-an-upward-slope repro (ideally with `ACDREAM_PROBE_RESOLVE=1` + or `ACDREAM_CAPTURE_RESOLVE` active for the WHOLE approach, not just + the moment of impact) would let Signature A actually fire and give a + concrete oracle the way records 3433/3434 did for the freeze. + +## 8. Recommended fix direction + +**Do not touch S1** (`BSPQuery.cs`'s `hasSphere1` branch) — it is a real, +narrow, retail-faithful improvement unrelated to #265's two concrete mined +events; reverting it would only reopen the #116 shape-1 door-collision gap +it was written to close, for zero benefit here. + +**Do not spend further effort on S2** (`calc_friction`'s threshold) until +it is actually wired into a live code path — right now changing it changes +nothing observable, in either direction. If/when `calc_friction` IS wired +into `PlayerMovementController` (a legitimate future piece of closing #166, +the downhill-sled issue), the 0.25 threshold becomes live and worth +re-testing at that point, not before. + +**The real target is #166 + the grounded-movement architecture (§4/§6), +which is a frozen-phase design question, not a quick fix.** Per CLAUDE.md's +"the roadmap and the observed bug disagree → brainstorm before writing +code" rule, this needs `superpowers:brainstorming` before any +implementation: does acdream want a genuine physics-driven momentum carry +across a landing (porting the retail `Sledding` state + a real +`calc_friction` wiring), or a narrower "if IsOnGround at high incoming +speed, force a minimum coast distance regardless of held input" patch? The +former is retail-faithful and already has a filed target (#166); the +latter would be a new, unfiled design decision. Either way, this is +explicitly NOT an S1/S2 code change — it is new work against +`PlayerMovementController.cs`'s grounded-movement block and +`PhysicsBody.calc_friction`'s wiring, gated on a design conversation, not a +revert.