test(physics): #265 mining tool + real-trajectory replay harness for the steep-slope response family
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 <noreply@anthropic.com>
This commit is contained in:
parent
4880d7d9cf
commit
909bff0aa5
2 changed files with 600 additions and 0 deletions
|
|
@ -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;
|
||||
|
||||
/// <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 > 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<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>
|
||||
/// </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;
|
||||
|
||||
private static PhysicsEngine MakeRoofEngine()
|
||||
{
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon>();
|
||||
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<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 = 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);
|
||||
|
||||
/// <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();
|
||||
}
|
||||
}
|
||||
}
|
||||
234
tools/analyze_265_steep_slope_capture.py
Normal file
234
tools/analyze_265_steep_slope_capture.py
Normal file
|
|
@ -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:])
|
||||
Loading…
Add table
Add a link
Reference in a new issue