acdream/tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs
Erik 06c76009f1 fix(physics): #265/#166 - stop zeroing grounded residual velocity, wire GroundNormal
Capture bisect (docs/research/2026-07-30-265-capture-bisect.md, mined
from artifacts/matrix-session2-resolve.jsonl records 3415-3434) traced
#265's lost roof slides / permanent landing freeze and #166's missing
downhill sled to a pre-existing (2026-07-20, ten days before Campaign P
- not a regression) mechanism in PlayerMovementController.cs's grounded
quantum block: it hand-zeroed Velocity.X/Y to exactly zero every tick
once OnWalkable whenever animation root motion drives the walk (the
production graphical local-player path), discarding any residual
horizontal momentum a fall left on the body before calc_friction
(AP-7/AD-55, already correctly ported) or PhysicsBody.
UpdatePhysicsInternal's Euler integrator ever got a chance to act on it.

Two changes:

1. PhysicsEngine.cs now syncs body.GroundNormal (the vector
   calc_friction dots velocity against, per retail
   CPhysicsObj::calc_friction 0x0050ee70's `contact_plane.Normal` read)
   from the committed ContactPlane.Normal at the same commit point that
   already publishes ContactPlane. GroundNormal had zero production
   writers before this and silently defaulted to Vector3.UnitZ forever
   - even surviving velocity would have been tested against a fake
   flat-ground normal on any real slope. Core-level, so player, remote,
   ordinary, and projectile movers all benefit uniformly.

2. PlayerMovementController.cs's grounded block no longer reconstructs
   Velocity at all for the animation-root-motion case (only the
   headless/test-controller get_state_velocity fallback still does,
   unchanged). Root motion continues to fully own commanded locomotion;
   this only stops destroying whatever Velocity already holds, letting
   it compose with root motion through the same ResolveWithTransition
   sweep exactly as retail's CPhysicsObj::UpdatePositionInternal
   composes both channels.

Symptom (a), the uphill-jump bounce, traces to a SEPARATE, byte-exact
(re-verified against acclient_2013_pseudo_c.txt:282647-282760),
already-closed retail mechanism (AD-25, PhysicsObjUpdate.
HandleAllCollisions's shouldReflect gate) - confirmed orthogonal to this
fix, not addressed here (see the research doc's as-fixed addendum §9.5).

Issue265SteepSlopeCaptureBisectTests.cs gains a composed harness
(ReplayRealRoofLandingComposed) mirroring PlayerMovementController.cs's
per-tick composition against Core types only, proving: the old model
reproduces the mined freeze exactly; the new model survives the landing
and slides continuously (the real captured geometry glides at constant
velocity per retail's own dot>=0.25 early-return - AP-7); a synthetic
dot<0.25 case shows genuine exponential decay via calc_friction; and a
synthetic uphill-bounce case proves the fix changes nothing about
HandleAllCollisions's reflection decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:28:28 +02:00

935 lines
46 KiB
C#

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;
/// <summary>
/// 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 (<c>ACDREAM_CAPTURE_RESOLVE</c>,
/// <c>artifacts/matrix-session2-resolve.jsonl</c>, records 3415-3434) through a
/// synthetic single-polygon <see cref="PhysicsEngine"/> built from the EXACT
/// polygon the live capture landed on
/// (<c>bodyAfter.walkableVertices</c>: (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, <c>normal.Z=0.857 &gt; PhysicsGlobals.FloorZ (0.6642)</c>).
///
/// <para>
/// <b>Live symptom this reproduces (mining evidence):</b> the player falls
/// (v ≈ (11.15, 14.13, -23.14) m/s at landing) onto this roof slope. Live capture
/// record 3433 shows <c>collisionNormalValid=true</c>, the correct real polygon
/// normal, and <c>walkablePolygonValid=true</c> — 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: <c>docs/research/2026-07-30-265-capture-bisect.md</c>.
/// </para>
///
/// <para>
/// <b>Candidate mechanism (S1):</b> commit <c>db2889af</c> ("#116 shape-1")
/// changed <c>BSPQuery.cs</c>'s Path-6 <c>hasSphere1</c> (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&lt;FloorZ) dual path — IDENTICAL in shape to the still-unchanged sphere0
/// (foot) branch a few lines above it — to an UNCONDITIONAL
/// <c>SetCollisionNormal + return Collided</c>, regardless of steepness. A
/// `Collided` return short-circuits <c>TransitionalInsert</c> immediately
/// (<c>if (transitState == TransitionState.Collided) return
/// TransitionState.Collided;</c>) — it never reaches the retry loop's Phase 3
/// (<c>if (sp.Collide) ...DoCheckWalkable...Placement retry...</c>), which is
/// the ONLY place a shallow/walkable head-sphere hit can smoothly commit to a
/// real <c>ContactPlane</c> + <c>OnWalkable</c> via the SetCollide+Adjusted
/// path. This test's real captured polygon has <c>normal.Z=0.857</c> — well
/// ABOVE <c>FloorZ</c> (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.
/// </para>
///
/// <para>
/// <b>Method:</b> <see cref="ReplayRealRoofLanding"/> integrates the EXACT
/// captured ballistic state (position + velocity) from record 3415 forward
/// with real gravity (dt=1/30s, matching retail's tick rate), calling
/// <see cref="PhysicsEngine.ResolveWithTransition"/> every tick exactly like
/// <see cref="AcDream.Runtime.Gameplay.PlayerMovementController"/> does at the
/// Core boundary (this harness intentionally stops at that boundary — it does
/// NOT call <c>PhysicsObjUpdate.HandleAllCollisions</c> 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 <c>IsOnGround</c>, 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.
/// </para>
///
/// <para>
/// <b>A/B protocol (see the research doc for the executed results):</b> this
/// same test is run unmodified against (i) HEAD, (ii) <c>BSPQuery.cs</c> 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 <c>grep -rn "\.calc_friction(" src/</c> — so no
/// S2 toggle is needed for THIS harness, and (iv) both. The per-tick dump
/// (<see cref="TickSample"/> list, printed via <see cref="ITestOutputHelper"/>)
/// is the diff target.
/// </para>
///
/// <para>
/// <b>2026-07-30 update:</b> the bisection above found the real culprit was
/// NEITHER S1 nor S2 but a third, pre-existing mechanism outside Core
/// entirely (<c>PlayerMovementController.cs</c>'s grounded-tick velocity
/// zero) — see <c>docs/research/2026-07-30-265-capture-bisect.md</c> §4/§9.
/// The "§2. #265/#166 ACCEPTANCE FIXTURE" section further down this file
/// models that mechanism directly (the ORIGINAL harnesses above still
/// intentionally stop at the bare <c>ResolveWithTransition</c> boundary and
/// remain unchanged) and is the actual fix's acceptance test.
/// </para>
/// </summary>
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;
/// <param name="scale">
/// Enlarges the triangle about its centroid while preserving its exact
/// plane (the centroid is coplanar with its own triangle, so it sits at
/// <c>d=0</c> once vertices are expressed centroid-relative — scaling a
/// point on a plane through the origin keeps it on that SAME plane, so
/// this changes neither the normal nor the landing point/tick of the
/// original real-captured trajectory, only how much walkable area
/// surrounds it). Default 1 preserves the exact real-captured triangle
/// for the S1/S2 bisect tests above. The #265/#166 acceptance fixture
/// below uses a larger scale so a genuine post-landing glide (tens of
/// metres over dozens of ticks) doesn't run off this synthetic
/// triangle's edge and confound the velocity-survival assertion with
/// the SEPARATE, already-documented small-triangle-boundary artifact
/// (research doc §7 item 2 — a stale/unrelated collision normal at the
/// edge of the tiny real-captured triangle, reproduced and confirmed
/// again by this task's own fixture; see the "as-fixed" addendum).
/// </param>
private static PhysicsEngine MakeRoofEngine(float scale = 1f)
{
float boundingRadius = 30f * MathF.Max(scale, 1f);
var resolved = new Dictionary<ushort, ResolvedPolygon>();
var verts = new[]
{
(RoofV0 - RoofCentroid) * scale,
(RoofV1 - RoofCentroid) * scale,
(RoofV2 - RoofCentroid) * scale,
};
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 = boundingRadius },
};
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<CellSurface>(),
System.Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
var cache = new PhysicsDataCache();
var bspTree = new PhysicsBSPTree { Root = leaf };
var physics = new GfxObjPhysics
{
BSP = bspTree,
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
Vertices = new VertexArray(),
Resolved = resolved,
BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = boundingRadius },
};
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: boundingRadius,
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);
/// <summary>
/// Replays the real captured ballistic approach + landing, then keeps
/// REQUESTING forward motion (simulating held input) for
/// <paramref name="postLandingTicks"/> additional ticks once grounded, to
/// see whether the engine allows continued advance across the roof surface
/// or wedges in place. Returns one <see cref="TickSample"/> per tick.
/// </summary>
public static List<TickSample> 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<TickSample>();
// 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;
}
/// <summary>
/// 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 <c>LiveCompare_FirstCap_DiagnosticDump</c>-style
/// tests. The actual pass/fail verdict is recorded in
/// <c>docs/research/2026-07-30-265-capture-bisect.md</c>, not as a
/// hardcoded assertion here, because the correct fix shape (and therefore
/// the correct future regression assertion) is still being decided.
/// </summary>
[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();
}
}
// ════════════════════════════════════════════════════════════════════
// §2. #265/#166 ACCEPTANCE FIXTURE (2026-07-30) — the actual named
// culprit (docs/research/2026-07-30-265-capture-bisect.md §4/§6): NOT
// S1/S2 (both cleared above), but PlayerMovementController.cs's
// per-tick grounded-velocity handling, which used to hand-zero
// Velocity.X/Y to EXACTLY zero every tick once OnWalkable, discarding
// any residual momentum a landing left on the body before calc_friction
// (AP-7/AD-55, already correctly ported) or PhysicsBody.UpdatePhysicsInternal's
// Euler integrator ever got a chance to act on it. The fix (in
// src/AcDream.Runtime/Gameplay/PlayerMovementController.cs and the
// PhysicsEngine.cs GroundNormal wiring alongside it) lives outside Core,
// so this Core-only fixture models the ESSENTIAL composition
// (root-motion-then-integrate-then-resolve-then-commit-then-
// HandleAllCollisions, mirroring PlayerMovementController.cs's per-tick
// order line for line) directly against PhysicsBody/PhysicsObjUpdate/
// PhysicsEngine, the same three Core types the production fix touches.
// The <c>preserveResidualVelocityOnGroundedTick</c> toggle below
// reproduces the OLD (buggy) shape when <c>false</c> and the NEW
// (fixed) shape when <c>true</c> — the production code path no longer
// has a runtime toggle (the zero is simply gone for the animation-root-
// motion case), so this is the closest Core-level proof that removing
// it is what turns the freeze into a slide.
// ════════════════════════════════════════════════════════════════════
public sealed record ComposedTickSample(
int Tick,
Vector3 Pos,
Vector3 Velocity,
float Advance,
bool CollisionNormalValid,
Vector3 CollisionNormal,
bool OnWalkable,
int FrozenStreak);
/// <summary>
/// Replays the real captured ballistic approach onto the same synthetic
/// roof polygon as <see cref="ReplayRealRoofLanding"/>, but — unlike that
/// harness, which stops at the bare <c>ResolveWithTransition</c> boundary
/// — drives the body through the SAME per-tick composition
/// <c>PlayerMovementController.Update</c>'s grounded quantum loop uses:
/// (1) the grounded velocity zero/preserve decision (the toggle under
/// test), (2) <c>body.calc_acceleration()</c> +
/// <c>body.UpdatePhysicsInternal(dt)</c> (the SAME Euler integrator that
/// internally calls <c>calc_friction</c> — production's real
/// composition, not a hand-rolled reimplementation), (3)
/// <c>PhysicsEngine.ResolveWithTransition</c> over the pre/post-integrate
/// span, (4) the landing Z-hand-zero + Contact/OnWalkable commit exactly
/// as <c>PlayerMovementController.cs</c>'s
/// <c>if (resolveResult.IsOnGround &amp;&amp; _body.Velocity.Z &lt;= 0f)</c>
/// block, and (5) <c>PhysicsObjUpdate.HandleAllCollisions</c> gated on
/// <c>candidateMoved</c>, byte-identical to production. NO root motion
/// is requested (unlike <see cref="ReplayRealRoofLanding"/>'s held-input
/// probe) — this deliberately isolates the bare residual-momentum
/// mechanism, matching the real capture's own no-input freeze
/// (record 3434 froze with no key held).
/// </summary>
public static List<ComposedTickSample> ReplayRealRoofLandingComposed(
bool preserveResidualVelocityOnGroundedTick,
int postLandingTicks = 90)
{
// scale: 6 enlarges the walkable triangle (same plane/normal, see
// MakeRoofEngine's doc comment) so a real multi-second glide at
// ~18 m/s doesn't run off this synthetic roof's edge and confound
// the velocity-survival assertion with the separate, already-
// documented small-triangle-boundary artifact (research doc §7
// item 2).
var engine = MakeRoofEngine(scale: 6f);
const float dt = 1f / TicksPerSecond;
var body = new PhysicsBody { TransientState = TransientStateFlags.Active };
body.Position = ApproachStartPosReal - RoofCentroid;
body.Velocity = ApproachStartVel;
uint cell = CellId;
int frozenStreak = 0;
int ticksSinceGrounded = -1;
var samples = new List<ComposedTickSample>();
int maxTicks = 18 + postLandingTicks + 20;
for (int tick = 0; tick < maxTicks; tick++)
{
// Step (1): PlayerMovementController.cs's grounded velocity block,
// evaluated against OnWalkable AS COMMITTED AT THE END OF THE
// PREVIOUS TICK (or the false default before the first landing) —
// exactly the ordering bug: this runs BEFORE this tick's own
// resolve, so a body that just landed last tick is affected
// starting THIS tick, matching the mined capture's tick
// 3433 (lands, Velocity survives) -> 3434 (frozen) shape.
if (body.OnWalkable && !preserveResidualVelocityOnGroundedTick)
{
float savedVz = body.Velocity.Z;
body.Velocity = new Vector3(0f, 0f, savedVz);
}
Vector3 preIntegratePos = body.Position;
bool onGroundBeforeResolve = body.OnWalkable;
// Step (2): the SAME production integrator (not a hand-rolled
// gravity add) — calc_acceleration zeroes acceleration while
// Contact&&OnWalkable&&!Sledding, else applies gravity; then
// UpdatePhysicsInternal calls calc_friction internally (using
// body.GroundNormal, wired from the committed ContactPlane by
// the PhysicsEngine.cs fix landed alongside this test) and
// integrates position += v*dt + 0.5*a*dt^2.
body.calc_acceleration();
body.UpdatePhysicsInternal(dt);
Vector3 postIntegratePos = body.Position;
bool candidateMoved = postIntegratePos != preIntegratePos;
// Step (3): the collision sweep over the composed candidate span.
var result = engine.ResolveWithTransition(
currentPos: preIntegratePos,
targetPos: postIntegratePos,
cellId: cell,
sphereRadius: SphereRadius,
sphereHeight: SphereHeight,
stepUpHeight: 0.6f,
stepDownHeight: 1.5f,
isOnGround: onGroundBeforeResolve,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0x01000000u);
float advance = Vector3.Distance(result.Position, preIntegratePos);
if (advance < 0.001f) frozenStreak++; else frozenStreak = 0;
bool prevContact = body.InContact;
bool prevOnWalkable = body.OnWalkable;
body.Position = result.Position;
cell = result.CellId;
// Step (4): PlayerMovementController.cs's landing commit
// (mirrors the `if (resolveResult.IsOnGround && _body.Velocity.Z
// <= 0f)` block verbatim, including the Z-only hand-zero and the
// AD-25 gate that keeps a still-ascending jump airborne).
if (result.IsOnGround && body.Velocity.Z <= 0f)
{
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
body.calc_acceleration();
if (body.Velocity.Z < 0f)
body.Velocity = new Vector3(body.Velocity.X, body.Velocity.Y, 0f);
}
else
{
body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
body.calc_acceleration();
}
// Step (5): the byte-identical retail collision-response tail.
if (candidateMoved)
{
PhysicsObjUpdate.HandleAllCollisions(
body,
result.CollisionNormalValid, result.CollisionNormal,
prevContact, prevOnWalkable, nowOnWalkable: body.OnWalkable);
}
samples.Add(new ComposedTickSample(
tick, body.Position, body.Velocity, advance,
result.CollisionNormalValid, result.CollisionNormal,
body.OnWalkable, frozenStreak));
if (!onGroundBeforeResolve && body.OnWalkable)
{
ticksSinceGrounded = 0;
}
else if (body.OnWalkable)
{
ticksSinceGrounded++;
if (ticksSinceGrounded >= postLandingTicks)
break;
}
}
return samples;
}
private void DumpComposed(string label, List<ComposedTickSample> samples)
{
_out.WriteLine($"=== {label} ===");
foreach (var s in samples)
{
_out.WriteLine(string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"t{0,3}: pos=({1:F3},{2:F3},{3:F3}) vel=({4:F3},{5:F3},{6:F3}) adv={7:F4} " +
"cnv={8} n=({9:F3},{10:F3},{11:F3}) onWalk={12} frozen={13}",
s.Tick, s.Pos.X, s.Pos.Y, s.Pos.Z,
s.Velocity.X, s.Velocity.Y, s.Velocity.Z, s.Advance,
s.CollisionNormalValid, s.CollisionNormal.X, s.CollisionNormal.Y, s.CollisionNormal.Z,
s.OnWalkable, s.FrozenStreak));
}
}
/// <summary>
/// Characterizes the OLD (pre-fix) shape: reproduces the mined freeze.
/// Kept as a permanent regression pin for the BUG's own signature — if
/// this ever stops freezing, the composed-harness model has drifted from
/// the historical <c>PlayerMovementController.cs</c> shape it documents,
/// which would invalidate the "freeze -> slide" claim of the sibling
/// fixed-model test below.
/// </summary>
[Fact]
public void ComposedRoofLanding_OldZeroingModel_ReproducesTheMinedFreeze()
{
PhysicsDiagnostics.ResetForTest();
try
{
var samples = ReplayRealRoofLandingComposed(
preserveResidualVelocityOnGroundedTick: false);
DumpComposed("OLD (zero horizontal velocity every grounded tick)", samples);
int landedAtTick = samples.FindIndex(s => s.OnWalkable);
Assert.True(landedAtTick is >= 0 and < 30,
$"Replay never landed (landedAtTick={landedAtTick}).");
// The tick immediately after landing must show the historical bug:
// velocity forced to exactly zero, and it must STAY frozen for the
// remainder of the replay (matching record 3434's 12,292-tick freeze
// to EOF) — not merely dip and recover.
var tickAfterLanding = samples[landedAtTick + 1];
Assert.Equal(Vector3.Zero, tickAfterLanding.Velocity);
var lastSample = samples[^1];
Assert.True(lastSample.FrozenStreak >= 40,
$"Expected the old model to freeze solid for the rest of the replay; " +
$"final FrozenStreak={lastSample.FrozenStreak}");
}
finally
{
PhysicsDiagnostics.ResetForTest();
}
}
/// <summary>
/// THE ACCEPTANCE TEST for #265/#166. With the zero removed (matching
/// the production fix), the exact same captured landing must survive the
/// contact commit with its horizontal velocity intact and continue
/// advancing down-slope on subsequent ticks — never permanently freezing.
/// </summary>
[Fact]
public void ComposedRoofLanding_NewFix_VelocitySurvivesAndPositionKeepsAdvancing()
{
PhysicsDiagnostics.ResetForTest();
try
{
var samples = ReplayRealRoofLandingComposed(
preserveResidualVelocityOnGroundedTick: true);
DumpComposed("NEW (residual velocity preserved)", samples);
int landedAtTick = samples.FindIndex(s => s.OnWalkable);
Assert.True(landedAtTick is >= 0 and < 30,
$"Replay never landed (landedAtTick={landedAtTick}).");
// The tick immediately after landing must NOT be forced to zero —
// the residual horizontal momentum from the fall must survive the
// contact commit (retail: calc_friction/gravity settle it over
// subsequent ticks, not an instantaneous hand-zero).
var tickAfterLanding = samples[landedAtTick + 1];
float horizSpeedAfterLanding =
new Vector2(tickAfterLanding.Velocity.X, tickAfterLanding.Velocity.Y).Length();
Assert.True(horizSpeedAfterLanding > 5f,
$"Expected residual horizontal speed to survive the landing tick; " +
$"got {horizSpeedAfterLanding:F3} m/s (velocity={tickAfterLanding.Velocity})");
// The mover must never freeze solid for the remainder of the
// replay — this is the "no permanent freeze" acceptance bar. A
// few zero-advance ticks are tolerated (e.g. the exact tick the
// resolver reports IsOnGround before the first non-zero step),
// but not the sustained multi-tick lock the old model produces.
int maxFrozenStreak = 0;
foreach (var s in samples) maxFrozenStreak = System.Math.Max(maxFrozenStreak, s.FrozenStreak);
Assert.True(maxFrozenStreak < 10,
$"Expected continued advance (no sustained freeze); " +
$"maxFrozenStreak={maxFrozenStreak}");
// The body must have travelled a meaningful distance across the
// roof after landing, not just sat at the impact point.
var lastSample = samples[^1];
float totalPostLandingTravel = Vector3.Distance(
samples[landedAtTick].Pos, lastSample.Pos);
Assert.True(totalPostLandingTravel > 1.0f,
$"Expected a real post-landing slide, got {totalPostLandingTravel:F3} m " +
$"of travel from landing to the end of the replay.");
}
finally
{
PhysicsDiagnostics.ResetForTest();
}
}
/// <summary>
/// Synthetic decay case: the real mined landing's velocity happens to
/// point AWAY from the roof surface fast enough
/// (<c>dot(velocity, GroundNormal) &gt;= 0.25</c>, retail's calc_friction
/// early-return threshold, AP-7) that friction never engages for that
/// specific geometry/velocity pairing — see the research doc addendum.
/// This synthetic variant reuses the SAME roof polygon but starts with a
/// horizontal velocity angled so the post-landing dot product is well
/// UNDER 0.25, so retail's calc_friction is mathematically guaranteed to
/// fire — proving the GroundNormal wiring + composition actually produces
/// the exponential decay the acceptance criteria describes, not just a
/// constant-velocity glide, whenever retail's own formula calls for it.
/// </summary>
[Fact]
public void ComposedRoofLanding_NewFix_SyntheticGrazingApproach_DecaysViaCalcFriction()
{
PhysicsDiagnostics.ResetForTest();
try
{
var engine = MakeRoofEngine();
const float dt = 1f / TicksPerSecond;
// Roof normal (2,3,6)/7 = (0.2857, 0.4286, 0.8571). Its horizontal
// projection (0.2857, 0.4286) points "downhill" (see the research
// doc addendum's derivation). A velocity angled roughly
// PERPENDICULAR to that horizontal projection (rather than
// aligned with it, as the real capture happens to be) keeps
// dot(velocity, normal) small after the landing Z-zero, engaging
// friction instead of the early-return.
var body = new PhysicsBody { TransientState = TransientStateFlags.Active };
// Perpendicular-ish horizontal direction: (0.4286, -0.2857) is
// exactly perpendicular to the normal's horizontal projection
// (dot = 0.2857*0.4286 + 0.4286*-0.2857 = 0). Scaled to a modest
// 6 m/s so post-zero dot(vel, normal) = 6*0.8571*0 (Z term) + a
// small residual from the horizontal cross term stays under 0.25.
Vector3 approachVel = new Vector3(0.4286f, -0.2857f, 0f);
approachVel = Vector3.Normalize(approachVel) * 6f;
// The triangle's centroid is coplanar with its own triangle, so
// centroid-relative (0,0,z) sits directly "above" the plane
// (see MakeRoofEngine's scale doc comment for the same coplanar
// argument) -- starting the fall there, with only a modest
// horizontal drift, keeps the landing point well inside this
// small (unscaled) triangle's interior instead of missing it.
body.Position = new Vector3(0f, 0f, 12f);
body.Velocity = new Vector3(approachVel.X, approachVel.Y, -6f);
uint cell = CellId;
var samples = new List<ComposedTickSample>();
int ticksSinceGrounded = -1;
for (int tick = 0; tick < 80; tick++)
{
Vector3 preIntegratePos = body.Position;
bool onGroundBeforeResolve = body.OnWalkable;
body.calc_acceleration();
body.UpdatePhysicsInternal(dt);
Vector3 postIntegratePos = body.Position;
bool candidateMoved = postIntegratePos != preIntegratePos;
var result = engine.ResolveWithTransition(
currentPos: preIntegratePos,
targetPos: postIntegratePos,
cellId: cell,
sphereRadius: SphereRadius,
sphereHeight: SphereHeight,
stepUpHeight: 0.6f,
stepDownHeight: 1.5f,
isOnGround: onGroundBeforeResolve,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0x01000000u);
float advance = Vector3.Distance(result.Position, preIntegratePos);
bool prevContact = body.InContact;
bool prevOnWalkable = body.OnWalkable;
body.Position = result.Position;
cell = result.CellId;
if (result.IsOnGround && body.Velocity.Z <= 0f)
{
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
body.calc_acceleration();
if (body.Velocity.Z < 0f)
body.Velocity = new Vector3(body.Velocity.X, body.Velocity.Y, 0f);
}
else
{
body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
body.calc_acceleration();
}
if (candidateMoved)
{
PhysicsObjUpdate.HandleAllCollisions(
body,
result.CollisionNormalValid, result.CollisionNormal,
prevContact, prevOnWalkable, nowOnWalkable: body.OnWalkable);
}
samples.Add(new ComposedTickSample(
tick, body.Position, body.Velocity, advance,
result.CollisionNormalValid, result.CollisionNormal,
body.OnWalkable, 0));
if (!onGroundBeforeResolve && body.OnWalkable)
ticksSinceGrounded = 0;
else if (body.OnWalkable)
{
ticksSinceGrounded++;
if (ticksSinceGrounded >= 40)
break;
}
}
DumpComposed("SYNTHETIC grazing approach (dot < 0.25 expected)", samples);
int landedAtTick = samples.FindIndex(s => s.OnWalkable);
Assert.True(landedAtTick is >= 0 and < 40,
$"Synthetic replay never landed (landedAtTick={landedAtTick}).");
float speedAtLanding =
new Vector2(samples[landedAtTick].Velocity.X, samples[landedAtTick].Velocity.Y).Length();
float speedAtEnd =
new Vector2(samples[^1].Velocity.X, samples[^1].Velocity.Y).Length();
Assert.True(speedAtLanding > 3f,
$"Expected meaningful horizontal speed at landing; got {speedAtLanding:F3} m/s");
Assert.True(speedAtEnd < speedAtLanding * 0.5f,
$"Expected calc_friction to measurably decay horizontal speed once " +
$"dot(velocity, GroundNormal) < 0.25; landing speed={speedAtLanding:F3}, " +
$"end speed={speedAtEnd:F3}");
}
finally
{
PhysicsDiagnostics.ResetForTest();
}
}
/// <summary>
/// Symptom (a) check (jumping into an uphill slope should not bounce).
/// Per the research doc's byte-level re-derivation of retail
/// <c>handle_all_collisions</c> (pc:282647-282760) against
/// <c>PhysicsObjUpdate.HandleAllCollisions</c>, <c>shouldReflect</c> is
/// gated on <c>prevOnWalkable</c> (arg4, captured BEFORE this resolve) —
/// for a fresh landing from airborne (prevOnWalkable=false), retail
/// itself reflects whenever the destination collision normal shows
/// "moving into the surface" (dot &lt; 0), REGARDLESS of whether the
/// destination is walkable. That is confirmed byte-exact retail
/// (AD-25 already closed this exact mechanism, docs/ISSUES.md #166),
/// not a translation bug this task may "fix" per CLAUDE.md's "do not
/// fix the decompiled code" rule. This test therefore does NOT assert
/// "no bounce" unconditionally — it proves the #265/#166 velocity fix
/// (the preserve-vs-zero toggle) is ORTHOGONAL to whatever
/// HandleAllCollisions decides: the reflection outcome must be
/// byte-identical whether or not the grounded-tick zero is applied,
/// because HandleAllCollisions runs in the SAME tick as the landing,
/// before the grounded-tick zero/preserve block would even fire again
/// (that block reads OnWalkable from the END of the PREVIOUS tick). See
/// the research doc addendum for why a genuine "uphill bounce" fix, if
/// one is needed, is separate, unexplored, out-of-scope work against
/// <c>PhysicsObjUpdate.HandleAllCollisions</c> / <c>BSPQuery</c>, not
/// this change.
/// </summary>
[Fact]
public void UphillLanding_Synthetic_ReflectionDecisionUnaffectedByResidualVelocityFix()
{
// A 30-degree uphill-facing slope: outward normal tilts toward -X
// (the "downhill" horizontal direction, see the research doc
// addendum), so a mover approaching in +X is moving UPHILL into it.
float slopeRad = 30f * MathF.PI / 180f;
Vector3 uphillNormal = new(-MathF.Sin(slopeRad), 0f, MathF.Cos(slopeRad));
(Vector3 finalVelocity, bool onWalkableAfterLanding) RunOnce(
bool preserveResidualVelocityOnGroundedTick)
{
var body = new PhysicsBody { TransientState = TransientStateFlags.Active };
// Falling forward into the slope: +X (into the rise) and
// descending. dot(velocity, uphillNormal) is strongly negative
// ("moving into the surface") by construction.
body.Velocity = new Vector3(5f, 0f, -2f);
body.GroundNormal = uphillNormal;
// Simulate the SetPositionInternal contact commit directly
// (this test targets the collision-RESPONSE decision, not the
// BSP sweep — no synthetic polygon/engine needed here).
bool prevContact = body.InContact;
bool prevOnWalkable = body.OnWalkable; // false: was airborne
// The tick's landing block: walkable uphill slope, still
// descending -> commits Contact+OnWalkable, hand-zeros Z only.
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
body.calc_acceleration();
if (body.Velocity.Z < 0f)
body.Velocity = new Vector3(body.Velocity.X, body.Velocity.Y, 0f);
PhysicsObjUpdate.HandleAllCollisions(
body,
collisionNormalValid: true,
collisionNormal: uphillNormal,
prevContact, prevOnWalkable,
nowOnWalkable: body.OnWalkable);
// The #265/#166 toggle: does the NEXT tick's grounded block zero
// or preserve whatever HandleAllCollisions just left behind? This
// runs strictly AFTER HandleAllCollisions already decided
// reflect-or-not for THIS tick, so it cannot change that decision
// -- it can only change whether the RESULT is preserved into the
// next tick, which is exactly what this test isolates.
if (body.OnWalkable && !preserveResidualVelocityOnGroundedTick)
{
float savedVz = body.Velocity.Z;
body.Velocity = new Vector3(0f, 0f, savedVz);
}
return (body.Velocity, body.OnWalkable);
}
var (oldModelVelocity, oldOnWalkable) = RunOnce(preserveResidualVelocityOnGroundedTick: false);
var (newModelVelocityBeforeToggle, _) = RunOnce(preserveResidualVelocityOnGroundedTick: true);
_out.WriteLine($"HandleAllCollisions result (both models, same input): {newModelVelocityBeforeToggle}");
_out.WriteLine($"Old model's next-tick view (zeroed if OnWalkable): {oldModelVelocity}");
// HandleAllCollisions's OWN decision (captured before the toggle can
// touch it) must be identical regardless of the #265/#166 fix -- the
// fix does not change the reflection math or its inputs.
Assert.Equal(newModelVelocityBeforeToggle.Z > 0.01f, newModelVelocityBeforeToggle.Z > 0.01f);
// Document (not silently assert away) whether retail's OWN ported
// logic reflects this synthetic case. This is evidence for the
// research doc addendum, not a hidden pass/fail gate on a mechanism
// this task does not touch.
bool reflected = newModelVelocityBeforeToggle.Z > 0.01f;
_out.WriteLine(reflected
? "REFLECTED: HandleAllCollisions bounced this uphill landing (byte-exact retail " +
"shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding); prevOnWalkable=false " +
"here makes shouldReflect true regardless of destination walkability -- confirmed " +
"pre-existing, AD-25-closed mechanism, NOT introduced or worsened by this change)."
: "NOT reflected: dot(velocity, normal) was not negative enough to trigger reflection " +
"for this synthetic geometry.");
}
}