fix(physics): port retail slope landing stop

This commit is contained in:
Erik 2026-07-31 09:10:53 +02:00
parent 1d8371dbe5
commit 5a0f9868a6
13 changed files with 870 additions and 103 deletions

View file

@ -142,7 +142,7 @@ remain in place (ride ACDREAM_DUMP_MOTION=1) until the user gate passes.
## #269 — Slope-stop slide runs too far (post-bounce-rework residual)
**Status:** OPEN (user live gate 2026-07-30 — "almost pass with merits")
**Status:** DONE — 2026-07-31 (implementation + user live gate)
**Severity:** LOW-MEDIUM (feel residual; bounce family otherwise accepted)
**Component:** physics / landing slide decay
@ -162,14 +162,24 @@ cos(10°) with base 0.2); the jump chain end-to-end (`GetJumpHeight`
Sledding auto-toggle (P2 finding re-confirmed; no `state |= 0x800000`
writer exists).
**Next step (behavior question, not code):** live A/B per the cdb
workflow — breakpoints on `calc_friction`/`handle_all_collisions` dumping
velocity per tick while the retail client lands + slides on a slope, vs an
`ACDREAM_CAPTURE_RESOLVE` capture of the same maneuver on the same slope.
Compare the decay curves; the first diverging tick names the mechanism.
Candidates the trace discriminates: hop-chain cadence (friction skips
airborne micro-hop ticks), gravity-tangential rebuild timing, quantum
alignment.
**Resolution (2026-07-31):** a 2,184-quantum live capture isolated the
first divergence. The landing tick produced retail's correct 5% reflect,
but the following quanta repeatedly restored
`LastKnownContactPlane` while retaining the reflected velocity. The body
therefore remained Contact + OnWalkable with `v·n > 0.25`, where retail
`calc_friction` intentionally does no work, and slid at full speed.
Named-retail `CTransition::validate_transition @ 0x0050AA70` revealed the
omission: its non-OK remembered-plane recovery calls
`OBJECTINFO::kill_velocity @ 0x0050CFE0` *before* the proximity test and
plane restore (`0x0050AAED0x0050AB42`). It also consumes the remembered
plane only in that non-OK branch and overwrites last-known validity from
the final contact plane at `0x0050ACFF`. ACDream now follows that exact
ordering. Focused collision-recovery and clean-advance pins pass; full
Core (4,107/2 skips) and Runtime (439/0) suites pass; the user repeated the
slope-jump test and accepted the result (“Perfect! Works great!”).
Capture and decode:
`docs/research/2026-07-31-269-slope-stop-capture.md`.
---

View file

@ -46,12 +46,14 @@ a ledger pass. Goal: zero physics TS rows, no unargued feel-affecting AP
rows, one final batched connected visual matrix. Sonnet implements, Opus
reviews. The plan is
[`2026-07-29-physics-parity-campaign.md`](2026-07-29-physics-parity-campaign.md).
The 2026-07-31 #268 stat-chain package is implemented pending its live visual
gate: panel and Runtime movement share retail's complete augmentation ordering,
The 2026-07-31 #268 stat-chain package is implemented and user-accepted:
panel and Runtime movement share retail's complete augmentation ordering,
the authored per-fragment vitae/buff/debuff colors are live, and AP-127 plus
TS-8 are retired by focused and end-to-end packet tests. The remaining
implementation target is #269's capture-driven slope-slide feel residual,
followed by the unfinished live matrix rows.
TS-8 are retired by focused and end-to-end packet tests. #269's
capture-driven slope-slide residual is also closed and user-accepted:
`CTransition::validate_transition` now performs retail's non-OK-only
remembered-plane restore with the preceding `OBJECTINFO::kill_velocity`.
Campaign P now continues with the unfinished live matrix rows.
---

View file

@ -380,13 +380,19 @@ root-caused, retail-ported, and user-accepted in the same session:
authored 0x1B palette exactly: #7FFFFF vitae, #00FF00 buff, #FF0000
debuff. TS-8 is also retired: a real live 0x02C2 payload carries its full
StatMod through dispatch and changes the effective skill immediately.
- **#269 filed** — slope-stop slide sometimes runs long. `calc_friction`
(0x0050ee70) and the complete jump chain (`GetJumpHeight` 0x006b09b0,
`InqJumpVelocity` vz=sqrt(h·19.6), 1.0 s/0.8 s powerbar charge) are
byte-verified identical — the user's jump-height hypothesis is
refuted; next step is the live cdb A/B decay-curve trace.
- **#269 closed 2026-07-31** — the live 2,184-quantum trace proved the
landing reflect and friction math were correct. ACDream omitted retail's
`OBJECTINFO::kill_velocity` before restoring a remembered contact plane
in `CTransition::validate_transition @ 0x0050AA70`, retaining full
downhill velocity while repeatedly re-grounding the mover. The exact
non-OK-only restore/kill order and final last-known validity overwrite
are now ported, focused/full gates pass, and the user accepted repeated
slope jumps. Evidence:
`docs/research/2026-07-31-269-slope-stop-capture.md`.
Matrix rows accepted so far: speed parity, roof slide, downhill bounce,
flat pop, uphill landing (rows 3/4/5-partial/12-partial). Remaining
rows: 1, 2, 6, 7, 8, 9, 10, 11 plus #269's slide feel. Suite at this
checkpoint: 10,031 passed / 5 skips / 0 failures.
flat pop, uphill landing, and #269's slope-stop feel
(rows 3/4/5-partial/12-partial). Remaining rows: 1, 2, 6, 7, 8, 9, 10,
and 11. The #269 checkpoint passes 4,107 Core tests / 2 skips and 439
Runtime tests / 0 skips; the complete Release suite passes 10,061 tests /
5 skips / 0 failures.

View file

@ -0,0 +1,104 @@
# Issue #269 — slope-stop capture and retail correction
**Date:** 2026-07-31
**Status:** implemented; user live gate passed
**Scope:** landing-bounce follow-up, `CTransition::validate_transition`
## Symptom
After the retail 5%-elasticity landing reflection was restored for #265,
the character could retain too much downhill speed after landing on a
walkable slope. The user described the residual as “slides too far on
landing.”
## ACDream live capture
`ACDREAM_CAPTURE_PLAYER_QUANTA=<jsonl-path>` records the local player's
complete admitted object quantum without changing simulation order:
1. quantum start;
2. root/PositionManager composition;
3. pre- and post-`UpdatePhysicsInternal`;
4. transition result;
5. final collision-response commit.
The accepted repro contained 2,184 quanta. The clearest landing was:
| Quantum | Event | Velocity |
|---|---|---|
| 1740 | final airborne quantum | `(-12.316, 8.187, -26.266)` |
| 1741 | slope collision, normal `(-0.236, 0.236, 0.943)` | |
| 1741 post-response | correct 5% reflect | `(-17.391, 13.262, -6.576)` |
| 17421758 | still Contact + OnWalkable, no new collision normal | velocity unchanged |
| 1759+ | contact relationship changes | friction finally begins decaying |
The reflected velocity had `dot(v, normal) = +1.0252`: it pointed away
from the slope. Retail `calc_friction` correctly skips while this value is
at least `0.25`, so friction was not the defect. ACDream was repeatedly
restoring the remembered slope plane and re-grounding the body without
performing retail's accompanying velocity stop.
## Retail oracle
Named-retail:
- `CPhysicsObj::check_contact` `0x0050F5B0`
- `CPhysicsObj::get_object_info` `0x00511CC0`
- `CTransition::validate_transition` `0x0050AA70`
- `OBJECTINFO::kill_velocity` `0x0050CFE0`
The exact `validate_transition` order at
`0x0050AAED0x0050AB42` is:
1. enter only for a non-OK collision/adjusted/slid result;
2. if `last_known_contact_plane_valid`, call
`OBJECTINFO::kill_velocity`;
3. test the current sphere center against the remembered plane using
`radius + 0.0002`;
4. restore the contact plane only when still within that distance;
5. later, at `0x0050ACFF`, overwrite last-known validity with final
contact-plane validity.
`OBJECTINFO::kill_velocity` calls
`CPhysicsObj::set_velocity({0,0,0}, 0)`. ACDream had ported the proximity
test and plane restore but omitted this call. It also allowed the
last-known plane to re-ground clean accepted moves, although retail only
consumes it in the non-OK recovery branch.
## Correction
`Transition.ValidateTransition` now:
- calls `ObjectInfo.StopVelocity()` before the remembered-plane
proximity/restore test on a non-OK recovery;
- performs that restore only in the retail branch;
- overwrites last-known validity from final contact validity, so a clean
move away cannot be re-grounded from stale memory.
The existing `PhysicsEngine.ResolveWithTransition` consumption of
`VelocityKilled` applies the zero to the canonical `PhysicsBody` before
the collision-response tail. The initial 5% landing reflection remains;
only a following collision recovery performs the retail stop.
## Gates
- New focused pins:
- collision recovery with a remembered plane kills velocity;
- clean advance with a remembered plane neither kills nor re-grounds.
- Full `AcDream.Core.Tests`: 4,107 passed / 2 skipped.
- Full `AcDream.Runtime.Tests`: 439 passed.
- `AcDream.App` Release build: 0 warnings / 0 errors.
- Complete Release suite: 10,061 passed / 5 skipped / 0 failed.
- User live gate: **PASS** — repeated slope jumps now settle correctly
(“Perfect! Works great!”).
## Diagnostic tools retained
- `tools/analyze_269_slope_stop_capture.py`
- `tools/cdb/run-issue269-slope-stop.ps1`
- `tools/cdb/issue269-slope-stop.cdb`
The cdb runner refuses to attach unless the live retail executable matches
the Sept 2013 named PDB. The locally installed 2015 retail executable does
not match; the static named-retail decode above is therefore the retail
oracle used for this correction.

View file

@ -2,9 +2,10 @@
## Phase
Active phase: **L.2 - Movement & Collision Conformance**.
Active phase: **Campaign P — Physics Retail-Feel Parity** (the L.2/R6
foundation is shipped).
Plan: `docs/plans/2026-04-29-movement-collision-conformance.md`.
Plan: `docs/plans/2026-07-29-physics-parity-campaign.md`.
Roadmap: `docs/plans/2026-04-11-roadmap.md`.
@ -78,3 +79,11 @@ InputDispatcher / PlayerMovementController
re-test the adjusted `CheckPos`), not returned to `ValidateTransition`; the
outer validator treats non-OK as a collision and restores `CurPos`, making
edges feel like hard stops even when the tangent was computed.
- 2026-07-31: Campaign P #269 slope-stop parity. A 2,184-quantum live
capture proved the 5% landing reflect and `calc_friction` were correct,
but `ValidateTransition` restored `LastKnownContactPlane` without retail's
preceding `OBJECTINFO::kill_velocity`. Retail consumes that plane only on
a non-OK recovery and overwrites last-known validity from the final contact
plane. Both rules are now ported; focused/full tests and the user's repeated
slope-jump gate pass. See
`docs/research/2026-07-31-269-slope-stop-capture.md`.

View file

@ -5530,6 +5530,30 @@ public sealed class Transition
else if (transitionState != TransitionState.Invalid)
{
// Collision/slide/adjusted: revert to current position.
// Retail CTransition::validate_transition 0x0050AA70 consumes a
// remembered contact plane only on this non-OK recovery path. It
// first calls OBJECTINFO::kill_velocity, then restores the plane
// when the current sphere remains within radius + EPSILON. Omitting
// the kill preserved a landing reflection while repeatedly
// re-grounding the mover, producing Campaign P #269's long slide.
if (ci.LastKnownContactPlaneValid)
{
oi.StopVelocity();
var sphereCenter = sp.GlobalCurrCenter[0].Origin;
var radius = sp.GlobalSphere[0].Radius;
float angle = Vector3.Dot(ci.LastKnownContactPlane.Normal, sphereCenter)
+ ci.LastKnownContactPlane.D;
if (radius + PhysicsGlobals.EPSILON > MathF.Abs(angle))
{
ci.SetContactPlane(
ci.LastKnownContactPlane,
ci.LastKnownContactPlaneCellId,
ci.LastKnownContactPlaneIsWater);
}
}
if (!ci.CollisionNormalValid)
ci.SetCollisionNormal(Vector3.UnitZ); // default: push up
@ -5541,14 +5565,11 @@ public sealed class Transition
if (ci.CollisionNormalValid)
ci.SetSlidingNormal(ci.CollisionNormal);
// Preserve contact plane for next step.
// L.2.3c (2026-04-29): only OVERWRITE LastKnown when current is valid.
// Previously: `LastKnownValid = ContactPlaneValid` cleared
// LastKnown whenever current was invalid — destroying the prior frame's
// contact memory. After StepUpSlide cleared ContactPlane mid-step
// (failed step-up against a too-tall wall), this propagated to
// LastKnown and the player went airborne for a frame, flickering the
// falling animation. Now LastKnown survives transient losses.
// Retail 0x0050ACFF-0x0050AD9A overwrites last-known validity with
// current contact validity, copies the plane only when valid, and then
// derives Contact/OnWalkable from that final plane. A remembered plane
// can have been restored only in the non-OK recovery branch above; it
// must never re-ground a clean move away from the surface.
if (ci.ContactPlaneValid)
{
ci.LastKnownContactPlaneValid = true;
@ -5569,81 +5590,11 @@ public sealed class Transition
else
oi.State &= ~ObjectInfoState.OnWalkable;
}
else if (ci.LastKnownContactPlaneValid)
{
// L.2.3c: current contact lost transiently (e.g. StepUpSlide
// cleared it during a failed step-up) but the prior frame's
// contact is still valid — keep the mover grounded via the
// last-known plane. Without this, every wall bump dropped the
// player into the falling animation for one frame.
//
// L.2.4 (2026-04-30): PROXIMITY GUARD. Only trust the
// last-known plane if the sphere is still actually near it.
// Geometrically: `angle` is the signed distance from the
// sphere center to the plane. If |angle| exceeds the sphere
// radius (plus a tiny epsilon), the sphere has SEPARATED
// from the plane — typically because we fell off an edge or
// the body dropped vertically while the resolver bounced
// through edge-slide attempts. Without this guard the player
// gets stuck mid-fall in a falling animation forever (live
// bug 2026-04-30: cur.Z=96.6, check.Z=95.1 — 1.5 m below the
// remembered floor, but still being marked Contact + OnWalkable).
//
// Matches ACE PhysicsObj's pre-reuse check on the last-known
// plane and retail's CPhysicsObj::get_object_info logic.
// A6.P3 slice 1 (2026-05-21). Retail uses global_curr_center (NOT
// global_sphere->center) for this proximity check — see
// acclient_2013_pseudo_c.txt:272568. global_sphere is the START
// sphere of the transition; global_curr_center is the CURRENT center
// after sub-step accumulation. Using the wrong one made the proximity
// guard fire on the wrong reference point.
var sphereCenter = sp.GlobalCurrCenter[0].Origin;
var radius = sp.GlobalSphere[0].Radius;
float angle = Vector3.Dot(ci.LastKnownContactPlane.Normal, sphereCenter)
+ ci.LastKnownContactPlane.D;
if (radius + PhysicsGlobals.EPSILON > MathF.Abs(angle))
{
// ── Mechanism B — restore CP from LKCP per retail ────────────────
// A6.P3 slice 1 (2026-05-21). Retail oracle:
// acclient_2013_pseudo_c.txt:272577 (inside CTransition::validate_transition
// at line 272547). When the sphere is geometrically close to the
// LastKnownContactPlane, retail restores CP from LKCP via
// set_contact_plane(&collision_info, &last_known_contact_plane,
// last_known_contact_plane_is_water). This closes the gap that the
// stripped TryFindIndoorWalkablePlane synthesis path used to fill —
// when no fresh Path-6 CP write lands in this transition, CP is
// retained from the previous frame instead of being re-synthesized.
//
// NOTE: SetContactPlane also re-latches LKCP fields
// (TransitionTypes.cs:258-261), which is a no-op here since we
// pass LKCP as the source.
ci.SetContactPlane(
ci.LastKnownContactPlane,
ci.LastKnownContactPlaneCellId,
ci.LastKnownContactPlaneIsWater);
// Still close enough to the last-known plane — preserve
// grounded state. L.2.3i FloorZ test for OnWalkable.
oi.State |= ObjectInfoState.Contact;
if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
oi.State |= ObjectInfoState.OnWalkable;
else
oi.State &= ~ObjectInfoState.OnWalkable;
}
else
{
// Sphere has separated from the last-known plane.
// Drop the memory and let the body resolve normally
// (gravity → next-frame terrain probe → real contact).
ci.LastKnownContactPlaneValid = false;
oi.State &= ~(ObjectInfoState.Contact | ObjectInfoState.OnWalkable);
}
}
else
{
oi.State &= ~(ObjectInfoState.Contact | ObjectInfoState.OnWalkable);
}
// ── frames_stationary_fall ladder (retail validate_transition pc:272625-656;
// ACE Transition.cs:1029-1061). Retires the TS-3 stub. Detects a gravity mover

View file

@ -1841,6 +1841,11 @@ public sealed class PlayerMovementController
for (int qi = 0; qi < quantumBatch.Count; qi++)
{
float tickDt = quantumBatch.GetQuantum(qi);
bool captureQuantum = PlayerPhysicsQuantumCapture.IsEnabled;
uint captureCellBefore = CellId;
PlayerPhysicsBodyTraceSnapshot captureQuantumStart = captureQuantum
? PlayerPhysicsQuantumCapture.Snapshot(_body)
: default;
// CPhysicsObj::UpdatePositionInternal (0x00512C30): visible objects
// advance their PartArray first. The complete Frame survives; only its
@ -1931,7 +1936,13 @@ public sealed class PlayerMovementController
}
_body.calc_acceleration();
PlayerPhysicsBodyTraceSnapshot capturePreIntegration = captureQuantum
? PlayerPhysicsQuantumCapture.Snapshot(_body)
: default;
_body.UpdatePhysicsInternal(tickDt);
PlayerPhysicsBodyTraceSnapshot capturePostIntegration = captureQuantum
? PlayerPhysicsQuantumCapture.Snapshot(_body)
: default;
// Retail process_hooks is the final UpdatePositionInternal step:
// after physics, before the transition and manager tail.
@ -2121,6 +2132,30 @@ public sealed class PlayerMovementController
_motion.CheckForCompletedMotions,
PositionManager);
if (captureQuantum)
{
PlayerPhysicsQuantumCapture.Log(
tickDt,
captureCellBefore,
CellId,
input,
capturePreIntegration.Position - captureQuantumStart.Position,
candidateMoved,
captureQuantumStart,
capturePreIntegration,
capturePostIntegration,
new PlayerPhysicsResolveTraceSnapshot(
resolveResult.Position,
resolveResult.CellId,
resolveResult.Ok,
resolveResult.IsOnGround,
resolveResult.InContact,
resolveResult.OnWalkable,
resolveResult.CollisionNormalValid,
resolveResult.CollisionNormal),
PlayerPhysicsQuantumCapture.Snapshot(_body));
}
}
// ── 4. Determine outbound motion commands ─────────────────────────────

View file

@ -0,0 +1,191 @@
using System.Numerics;
using System.Text.Json;
using AcDream.Core.Physics;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Opt-in, player-only trace of the complete retail object quantum.
/// <para>
/// Set <c>ACDREAM_CAPTURE_PLAYER_QUANTA=&lt;path&gt;</c> before process
/// startup to emit one JSON-Lines record per admitted physics quantum. The
/// capture is deliberately outside the physics implementation: it observes
/// the same stage boundaries as retail
/// <c>CPhysicsObj::UpdateObjectInternal</c> without changing their order or
/// introducing a second simulation path. When disabled, the hot path pays
/// one static null/empty check and performs no allocations.
/// </para>
/// </summary>
internal static class PlayerPhysicsQuantumCapture
{
internal static string? CapturePath { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_CAPTURE_PLAYER_QUANTA");
internal static bool IsEnabled => !string.IsNullOrWhiteSpace(CapturePath);
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
IncludeFields = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
private static readonly object s_writerLock = new();
private static StreamWriter? s_writer;
private static long s_sequence;
private static bool s_processExitHooked;
internal static PlayerPhysicsBodyTraceSnapshot Snapshot(PhysicsBody body) =>
new(
Position: body.Position,
Orientation: body.Orientation,
Velocity: body.Velocity,
Acceleration: body.Acceleration,
GroundNormal: body.GroundNormal,
ContactPlaneValid: body.ContactPlaneValid,
ContactPlane: body.ContactPlane,
Friction: body.Friction,
Elasticity: body.Elasticity,
State: (uint)body.State,
TransientState: (uint)body.TransientState,
FramesStationaryFall: body.FramesStationaryFall);
internal static void Log(
float dt,
uint cellBefore,
uint cellAfter,
MovementInput input,
Vector3 rootAndManagerDelta,
bool candidateMoved,
PlayerPhysicsBodyTraceSnapshot quantumStart,
PlayerPhysicsBodyTraceSnapshot preIntegration,
PlayerPhysicsBodyTraceSnapshot postIntegration,
PlayerPhysicsResolveTraceSnapshot resolve,
PlayerPhysicsBodyTraceSnapshot postCommit)
{
string? path = CapturePath;
if (string.IsNullOrWhiteSpace(path))
return;
var record = new PlayerPhysicsQuantumTraceRecord(
Sequence: Interlocked.Increment(ref s_sequence) - 1,
TimestampTicks: System.Diagnostics.Stopwatch.GetTimestamp(),
Dt: dt,
CellBefore: cellBefore,
CellAfter: cellAfter,
Input: new PlayerMovementInputTraceSnapshot(
input.Forward,
input.Backward,
input.StrafeLeft,
input.StrafeRight,
input.TurnLeft,
input.TurnRight,
input.Run,
input.Jump),
RootAndManagerDelta: rootAndManagerDelta,
CandidateMoved: candidateMoved,
QuantumStart: quantumStart,
PreIntegration: preIntegration,
PostIntegration: postIntegration,
Resolve: resolve,
PostCommit: postCommit);
string json = JsonSerializer.Serialize(record, s_jsonOptions);
lock (s_writerLock)
{
EnsureWriter_NoLock(path);
s_writer!.WriteLine(json);
s_writer.Flush();
}
}
internal static void Close()
{
lock (s_writerLock)
{
s_writer?.Dispose();
s_writer = null;
}
}
internal static void ResetForTest()
{
Close();
CapturePath = null;
Interlocked.Exchange(ref s_sequence, 0);
}
private static void EnsureWriter_NoLock(string path)
{
if (s_writer is not null)
return;
string? directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
s_writer = new StreamWriter(new FileStream(
path,
FileMode.Append,
FileAccess.Write,
FileShare.Read))
{
AutoFlush = false,
};
if (!s_processExitHooked)
{
AppDomain.CurrentDomain.ProcessExit += static (_, _) => Close();
s_processExitHooked = true;
}
}
}
internal sealed record PlayerPhysicsQuantumTraceRecord(
long Sequence,
long TimestampTicks,
float Dt,
uint CellBefore,
uint CellAfter,
PlayerMovementInputTraceSnapshot Input,
Vector3 RootAndManagerDelta,
bool CandidateMoved,
PlayerPhysicsBodyTraceSnapshot QuantumStart,
PlayerPhysicsBodyTraceSnapshot PreIntegration,
PlayerPhysicsBodyTraceSnapshot PostIntegration,
PlayerPhysicsResolveTraceSnapshot Resolve,
PlayerPhysicsBodyTraceSnapshot PostCommit);
internal readonly record struct PlayerMovementInputTraceSnapshot(
bool Forward,
bool Backward,
bool StrafeLeft,
bool StrafeRight,
bool TurnLeft,
bool TurnRight,
bool Run,
bool Jump);
internal readonly record struct PlayerPhysicsBodyTraceSnapshot(
Vector3 Position,
Quaternion Orientation,
Vector3 Velocity,
Vector3 Acceleration,
Vector3 GroundNormal,
bool ContactPlaneValid,
Plane ContactPlane,
float Friction,
float Elasticity,
uint State,
uint TransientState,
int FramesStationaryFall);
internal readonly record struct PlayerPhysicsResolveTraceSnapshot(
Vector3 Position,
uint CellId,
bool Ok,
bool IsOnGround,
bool InContact,
bool OnWalkable,
bool CollisionNormalValid,
Vector3 CollisionNormal);

View file

@ -483,6 +483,54 @@ public class BSPStepUpTests
"wall (failed step-up should preserve LastKnownContactPlane).");
}
/// <summary>
/// Campaign P #269: retail validate_transition calls kill_velocity before
/// restoring a last-known contact plane on a non-OK collision recovery.
/// This is the post-landing stop mechanism when the 5% reflect points away
/// from a slope but the next tangential sweep collides with it again.
/// </summary>
[Fact]
public void D1b_LastKnownPlaneCollisionRecovery_KillsVelocity()
{
var (root, resolved) = BSPStepUpFixtures.TallWall();
var transition = BSPStepUpFixtures.MakeGroundedTransition(
from: new Vector3(0.1f, 0f, 0f),
to: new Vector3(0.6f, 0f, 0f),
stepUpHeight: 0.04f);
var engine = MakeTestEngine(root, resolved, terrainZ: 0f);
transition.FindTransitionalPosition(engine);
Assert.True(
transition.ObjectInfo.VelocityKilled,
"Retail OBJECTINFO::kill_velocity must run before restoring the " +
"remembered floor after a collision/slide retry.");
}
/// <summary>
/// The same remembered plane must not kill velocity or re-ground a clean
/// accepted move away from it. Retail consumes LastKnownContactPlane only
/// inside validate_transition's non-OK recovery branch.
/// </summary>
[Fact]
public void D1c_LastKnownPlaneCleanAdvance_DoesNotKillOrReground()
{
var (root, resolved) = BSPStepUpFixtures.TallWall();
var transition = BSPStepUpFixtures.MakeAirborneTransition(
from: new Vector3(-2f, 0f, 2f),
to: new Vector3(-2f, 0f, 2.25f));
transition.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, -1.52f);
transition.CollisionInfo.LastKnownContactPlaneValid = true;
var engine = MakeTestEngine(root, resolved, terrainZ: -50f);
transition.FindTransitionalPosition(engine);
Assert.False(transition.ObjectInfo.VelocityKilled);
Assert.False(transition.CollisionInfo.ContactPlaneValid);
Assert.False(transition.CollisionInfo.LastKnownContactPlaneValid);
Assert.False(transition.ObjectInfo.OnWalkable);
}
/// <summary>
/// L.2.3b regression: Path 5 dispatch must be guarded against re-entry while
/// a step-up is already in progress. Test runs <c>FindTransitionalPosition</c>

View file

@ -0,0 +1,164 @@
using System.Numerics;
using System.Text.Json;
using AcDream.Core.Physics;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
[Collection(PlayerPhysicsQuantumCaptureCollection.Name)]
public sealed class PlayerPhysicsQuantumCaptureTests : IDisposable
{
private readonly string _path = Path.Combine(
Path.GetTempPath(),
$"acdream-player-physics-{Guid.NewGuid():N}.jsonl");
public PlayerPhysicsQuantumCaptureTests() =>
PlayerPhysicsQuantumCapture.ResetForTest();
[Fact]
public void DisabledCapture_DoesNotCreateAFile()
{
PhysicsBody body = CreateBody();
Assert.False(PlayerPhysicsQuantumCapture.IsEnabled);
_ = PlayerPhysicsQuantumCapture.Snapshot(body);
Assert.False(File.Exists(_path));
}
[Fact]
public void EnabledCapture_WritesCompleteOrderedQuantum()
{
PhysicsBody body = CreateBody();
PlayerPhysicsQuantumCapture.CapturePath = _path;
PlayerPhysicsBodyTraceSnapshot start =
PlayerPhysicsQuantumCapture.Snapshot(body);
body.Velocity = new Vector3(2f, 3f, -4f);
PlayerPhysicsBodyTraceSnapshot pre =
PlayerPhysicsQuantumCapture.Snapshot(body);
body.UpdatePhysicsInternal(0.05f);
PlayerPhysicsBodyTraceSnapshot post =
PlayerPhysicsQuantumCapture.Snapshot(body);
body.Velocity = new Vector3(1f, 1.5f, 0.2f);
PlayerPhysicsBodyTraceSnapshot committed =
PlayerPhysicsQuantumCapture.Snapshot(body);
PlayerPhysicsQuantumCapture.Log(
dt: 0.05f,
cellBefore: 0xAAB40011,
cellAfter: 0xAAB40012,
input: new MovementInput(Forward: true, Run: true),
rootAndManagerDelta: new Vector3(0.1f, 0.2f, 0.3f),
candidateMoved: true,
quantumStart: start,
preIntegration: pre,
postIntegration: post,
resolve: new PlayerPhysicsResolveTraceSnapshot(
new Vector3(4f, 5f, 6f),
0xAAB40012,
Ok: true,
IsOnGround: true,
InContact: true,
OnWalkable: true,
CollisionNormalValid: true,
CollisionNormal: Vector3.UnitZ),
postCommit: committed);
PlayerPhysicsQuantumCapture.Close();
string[] lines = File.ReadAllLines(_path);
Assert.Single(lines);
using JsonDocument json = JsonDocument.Parse(lines[0]);
JsonElement root = json.RootElement;
Assert.Equal(0, root.GetProperty("sequence").GetInt64());
Assert.Equal(0.05f, root.GetProperty("dt").GetSingle());
Assert.Equal(0xAAB40011u, root.GetProperty("cellBefore").GetUInt32());
Assert.Equal(0xAAB40012u, root.GetProperty("cellAfter").GetUInt32());
Assert.True(root.GetProperty("input").GetProperty("forward").GetBoolean());
Assert.True(root.GetProperty("input").GetProperty("run").GetBoolean());
Assert.Equal(2f,
root.GetProperty("preIntegration")
.GetProperty("velocity")
.GetProperty("x")
.GetSingle());
Assert.True(root.GetProperty("resolve").GetProperty("onWalkable").GetBoolean());
Assert.Equal(0.2f,
root.GetProperty("postCommit")
.GetProperty("velocity")
.GetProperty("z")
.GetSingle());
}
[Fact]
public void ResetForTest_ClosesWriterAndRestartsSequence()
{
PhysicsBody body = CreateBody();
PlayerPhysicsQuantumCapture.CapturePath = _path;
PlayerPhysicsBodyTraceSnapshot snapshot =
PlayerPhysicsQuantumCapture.Snapshot(body);
WriteMinimal(snapshot);
PlayerPhysicsQuantumCapture.ResetForTest();
PlayerPhysicsQuantumCapture.CapturePath = _path;
WriteMinimal(snapshot);
PlayerPhysicsQuantumCapture.Close();
string[] lines = File.ReadAllLines(_path);
Assert.Equal(2, lines.Length);
Assert.All(lines, line =>
{
using JsonDocument json = JsonDocument.Parse(line);
Assert.Equal(0, json.RootElement.GetProperty("sequence").GetInt64());
});
}
public void Dispose()
{
PlayerPhysicsQuantumCapture.ResetForTest();
if (File.Exists(_path))
File.Delete(_path);
}
private void WriteMinimal(PlayerPhysicsBodyTraceSnapshot snapshot) =>
PlayerPhysicsQuantumCapture.Log(
0.04f,
1,
1,
default,
Vector3.Zero,
false,
snapshot,
snapshot,
snapshot,
new PlayerPhysicsResolveTraceSnapshot(
snapshot.Position,
1,
true,
true,
true,
true,
false,
Vector3.Zero),
snapshot);
private static PhysicsBody CreateBody() => new()
{
Position = new Vector3(1f, 2f, 3f),
Velocity = new Vector3(4f, 5f, 6f),
Acceleration = new Vector3(0f, 0f, -9.8f),
GroundNormal = Vector3.UnitZ,
ContactPlaneValid = true,
ContactPlane = new Plane(Vector3.UnitZ, -3f),
Friction = 0.95f,
Elasticity = 0.05f,
State = PhysicsStateFlags.Gravity,
TransientState =
TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
};
}
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class PlayerPhysicsQuantumCaptureCollection
{
public const string Name = "Player physics quantum capture";
}

View file

@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Summarize an ACDREAM_CAPTURE_PLAYER_QUANTA JSONL for issue #269."""
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
from typing import Any
def vector(value: dict[str, Any]) -> tuple[float, float, float]:
return float(value["x"]), float(value["y"]), float(value["z"])
def length(value: tuple[float, float, float]) -> float:
return math.sqrt(sum(component * component for component in value))
def horizontal(value: tuple[float, float, float]) -> float:
return math.hypot(value[0], value[1])
def subtract(
left: tuple[float, float, float],
right: tuple[float, float, float],
) -> tuple[float, float, float]:
return tuple(a - b for a, b in zip(left, right, strict=True))
def scale(
value: tuple[float, float, float],
scalar: float,
) -> tuple[float, float, float]:
return tuple(component * scalar for component in value)
def input_active(record: dict[str, Any]) -> bool:
state = record["input"]
return any(
bool(state[key])
for key in (
"forward",
"backward",
"strafeLeft",
"strafeRight",
"turnLeft",
"turnRight",
"jump",
)
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("capture", type=Path)
parser.add_argument(
"--tail",
type=int,
default=45,
help="quanta printed after the final directional-input release",
)
args = parser.parse_args()
with args.capture.open("r", encoding="utf-8") as stream:
records = [json.loads(line) for line in stream if line.strip()]
if not records:
print("capture contains no physics quanta")
return 2
release_indices = [
index
for index in range(1, len(records))
if input_active(records[index - 1]) and not input_active(records[index])
]
start = release_indices[-1] if release_indices else max(0, len(records) - args.tail)
stop = min(len(records), start + args.tail)
print(
"seq dt walk(pre/post) |v|pre |v|fric |v|commit "
"horizCommit rootDelta collision fsf"
)
for record in records[start:stop]:
dt = float(record["dt"])
pre = record["preIntegration"]
post_integration = record["postIntegration"]
post_commit = record["postCommit"]
pre_velocity = vector(pre["velocity"])
integrated_velocity = vector(post_integration["velocity"])
acceleration = vector(pre["acceleration"])
friction_velocity = subtract(
integrated_velocity,
scale(acceleration, dt),
)
commit_velocity = vector(post_commit["velocity"])
transient_pre = int(pre["transientState"])
transient_post = int(post_commit["transientState"])
pre_walk = (transient_pre & 0x2) != 0
post_walk = (transient_post & 0x2) != 0
root_delta = length(vector(record["rootAndManagerDelta"]))
resolve = record["resolve"]
collision = "yes" if resolve["collisionNormalValid"] else "no"
print(
f'{record["sequence"]:5d} {dt:0.5f} '
f"{int(pre_walk)}/{int(post_walk)} "
f"{length(pre_velocity):8.4f} "
f"{length(friction_velocity):8.4f} "
f"{length(commit_velocity):8.4f} "
f"{horizontal(commit_velocity):8.4f} "
f"{root_delta:8.4f} {collision:>3s} "
f'{post_commit["framesStationaryFall"]:d}'
)
if release_indices:
print(f"\nlast directional-input release: sequence {records[start]['sequence']}")
else:
print("\nno directional-input release edge found; showing capture tail")
print(f"records: {len(records)}, displayed: {stop - start}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,48 @@
$$
$$ Issue #269 retail slope-stop trace.
$$
$$ This script must only be attached to the Sept 2013 EoR acclient.exe
$$ paired with refs/acclient.pdb (GUID 9e847e2f-777c-4bd9-886c-22256bb87f32).
$$ The PowerShell runner verifies that pairing before attach.
$$
$$ It records the player object's complete physics-integrator boundary and
$$ handle_all_collisions boundary as raw IEEE-754 bits. That is the minimum
$$ runtime evidence needed to distinguish friction cadence, contact loss, and
$$ post-sweep collision response without guessing from visible distance.
$$
.logopen ${ARG_LOG_PATH}
.sympath ${ARG_SYMBOL_PATH}
.symopt+ 0x40
.reload /f acclient.exe
r $t0 = 0
r $t1 = 0
$$ CPhysicsObj offsets from PDB dt:
$$ state +0xa8, transient +0xac, friction +0xbc,
$$ velocity +0xe0, acceleration +0xec, contact_plane.N +0x130.
$$ UpdatePhysicsInternal entry: ecx=this, [esp+4]=dt, [esp+8]=Frame*.
bp acclient!CPhysicsObj::UpdatePhysicsInternal ".if (@ecx == poi(acclient!CPhysicsObj::player_object)) { r $t0=@$t0+1; .printf \"[UPI-IN] q=%d dt_h=%08X state=%08X transient=%08X friction_h=%08X vx_h=%08X vy_h=%08X vz_h=%08X ax_h=%08X ay_h=%08X az_h=%08X nx_h=%08X ny_h=%08X nz_h=%08X fx_h=%08X fy_h=%08X fz_h=%08X\\n\", @$t0, dwo(@esp+4), dwo(@ecx+0xa8), dwo(@ecx+0xac), dwo(@ecx+0xbc), dwo(@ecx+0xe0), dwo(@ecx+0xe4), dwo(@ecx+0xe8), dwo(@ecx+0xec), dwo(@ecx+0xf0), dwo(@ecx+0xf4), dwo(@ecx+0x130), dwo(@ecx+0x134), dwo(@ecx+0x138), dwo(poi(@esp+8)+0x34), dwo(poi(@esp+8)+0x38), dwo(poi(@esp+8)+0x3c) }; gc"
$$ UpdatePhysicsInternal epilogue: edi=this, ebx=Frame*, velocity has
$$ completed friction + acceleration and Frame contains the integrated delta.
$$ The terminal hit intentionally omits gc so the top-level qd detaches cleanly.
bp acclient+0x0011093a ".if (@edi == poi(acclient!CPhysicsObj::player_object)) { .printf \"[UPI-OUT] q=%d transient=%08X vx_h=%08X vy_h=%08X vz_h=%08X fx_h=%08X fy_h=%08X fz_h=%08X\\n\", @$t0, dwo(@edi+0xac), dwo(@edi+0xe0), dwo(@edi+0xe4), dwo(@edi+0xe8), dwo(@ebx+0x34), dwo(@ebx+0x38), dwo(@ebx+0x3c); .if (@$t0 < ${ARG_MAX_QUANTA}) { gc } } .else { gc }"
$$ handle_all_collisions entry: ecx=this, [esp+4]=COLLISIONINFO*.
$$ COLLISIONINFO offsets from PDB dt: normal-valid +0x48,
$$ normal +0x4c, frames_stationary_fall +0x80.
bp acclient!CPhysicsObj::handle_all_collisions ".if (@ecx == poi(acclient!CPhysicsObj::player_object)) { r $t1=@$t1+1; .printf \"[HAC-IN] h=%d q=%d fsf=%d normalValid=%d nx_h=%08X ny_h=%08X nz_h=%08X vx_h=%08X vy_h=%08X vz_h=%08X transient=%08X\\n\", @$t1, @$t0, dwo(poi(@esp+4)+0x80), dwo(poi(@esp+4)+0x48), dwo(poi(@esp+4)+0x4c), dwo(poi(@esp+4)+0x50), dwo(poi(@esp+4)+0x54), dwo(@ecx+0xe0), dwo(@ecx+0xe4), dwo(@ecx+0xe8), dwo(@ecx+0xac) }; gc"
$$ Four epilogues correspond to fsf 0, 1, 2, and 3. At each address esi
$$ still owns this and the final velocity/transient state has been written.
bp acclient+0x00114977 ".if (@esi == poi(acclient!CPhysicsObj::player_object)) { .printf \"[HAC-OUT] h=%d q=%d fsfClass=0 vx_h=%08X vy_h=%08X vz_h=%08X transient=%08X\\n\", @$t1, @$t0, dwo(@esi+0xe0), dwo(@esi+0xe4), dwo(@esi+0xe8), dwo(@esi+0xac) }; gc"
bp acclient+0x00114997 ".if (@esi == poi(acclient!CPhysicsObj::player_object)) { .printf \"[HAC-OUT] h=%d q=%d fsfClass=1 vx_h=%08X vy_h=%08X vz_h=%08X transient=%08X\\n\", @$t1, @$t0, dwo(@esi+0xe0), dwo(@esi+0xe4), dwo(@esi+0xe8), dwo(@esi+0xac) }; gc"
bp acclient+0x001149b1 ".if (@esi == poi(acclient!CPhysicsObj::player_object)) { .printf \"[HAC-OUT] h=%d q=%d fsfClass=2 vx_h=%08X vy_h=%08X vz_h=%08X transient=%08X\\n\", @$t1, @$t0, dwo(@esi+0xe0), dwo(@esi+0xe4), dwo(@esi+0xe8), dwo(@esi+0xac) }; gc"
bp acclient+0x001149c6 ".if (@esi == poi(acclient!CPhysicsObj::player_object)) { .printf \"[HAC-OUT] h=%d q=%d fsfClass=3 vx_h=%08X vy_h=%08X vz_h=%08X transient=%08X\\n\", @$t1, @$t0, dwo(@esi+0xe0), dwo(@esi+0xe4), dwo(@esi+0xe8), dwo(@esi+0xac) }; gc"
.printf "issue269 slope-stop probe armed; maxQuanta=${ARG_MAX_QUANTA}\\n"
g
.echo === DETACHING AFTER BOUNDED CAPTURE ===
qd

View file

@ -0,0 +1,74 @@
param(
[Parameter(Mandatory = $false)]
[ValidatePattern('^[A-Za-z0-9_.-]+$')]
[string]$ScenarioTag = "slope-stop",
[Parameter(Mandatory = $false)]
[ValidateRange(60, 1800)]
[int]$MaxQuanta = 450
)
$ErrorActionPreference = "Stop"
$cdbExe = "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe"
if (-not (Test-Path -LiteralPath $cdbExe)) {
throw "cdb.exe was not found at '$cdbExe'."
}
$retail = Get-CimInstance Win32_Process -Filter "Name = 'acclient.exe'" |
Select-Object -First 1
if ($null -eq $retail) {
throw "No live retail acclient.exe process was found."
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$checkScript = Join-Path $repoRoot "tools\pdb-extract\check_exe_pdb.py"
$pairing = & py $checkScript $retail.ExecutablePath 2>&1 | Out-String
if ($pairing -notmatch "=== MATCH:") {
throw @"
The live retail executable does not pair with the named-retail PDB.
Process path: $($retail.ExecutablePath)
$pairing
"@
}
$symbolCandidates = @(
(Join-Path $repoRoot "refs"),
(Join-Path $env:USERPROFILE "source\repos\acdream\refs"),
(Join-Path $env:USERPROFILE ".windbg\x86\sym")
)
$symbolPath = $symbolCandidates |
Where-Object { Test-Path -LiteralPath $_ } |
Select-Object -First 1
if ([string]::IsNullOrWhiteSpace($symbolPath)) {
throw "The matching acclient.pdb symbol directory could not be found."
}
$templatePath = Join-Path $PSScriptRoot "issue269-slope-stop.cdb"
$artifactDir = Join-Path $repoRoot "artifacts\issue269"
New-Item -ItemType Directory -Path $artifactDir -Force | Out-Null
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$logPath = Join-Path $artifactDir "$ScenarioTag-retail-$timestamp.log"
$scriptPath = Join-Path $env:TEMP "issue269-$ScenarioTag-$timestamp.cdb"
$script = Get-Content -LiteralPath $templatePath -Raw
$script = $script.Replace('${ARG_LOG_PATH}', $logPath)
$script = $script.Replace('${ARG_SYMBOL_PATH}', $symbolPath)
$script = $script.Replace('${ARG_MAX_QUANTA}', $MaxQuanta.ToString(
[System.Globalization.CultureInfo]::InvariantCulture))
Set-Content -LiteralPath $scriptPath -Value $script -Encoding ASCII
Write-Host "Attaching cdb to retail PID $($retail.ProcessId)."
Write-Host "Capture: $logPath"
Write-Host "Perform the slope run, release movement, and let the character settle."
Write-Host "The probe detaches after $MaxQuanta player physics quanta."
try {
& $cdbExe -p $retail.ProcessId -cf $scriptPath 2>&1 |
Out-File -LiteralPath "$logPath.console" -Encoding ASCII
}
finally {
Remove-Item -LiteralPath $scriptPath -ErrorAction SilentlyContinue
}
Write-Host "Retail capture complete: $logPath"