From 5a0f9868a6ddb8aaad317951aaca7ed2f6f1378d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 09:10:53 +0200 Subject: [PATCH] fix(physics): port retail slope landing stop --- docs/ISSUES.md | 28 ++- docs/plans/2026-04-11-roadmap.md | 12 +- .../2026-07-29-physics-parity-campaign.md | 22 +- .../2026-07-31-269-slope-stop-capture.md | 104 ++++++++++ .../project_movement_collision_conformance.md | 13 +- src/AcDream.Core/Physics/TransitionTypes.cs | 109 +++------- .../Gameplay/PlayerMovementController.cs | 35 ++++ .../Gameplay/PlayerPhysicsQuantumCapture.cs | 191 ++++++++++++++++++ .../Physics/BSPStepUpTests.cs | 48 +++++ .../PlayerPhysicsQuantumCaptureTests.cs | 164 +++++++++++++++ tools/analyze_269_slope_stop_capture.py | 125 ++++++++++++ tools/cdb/issue269-slope-stop.cdb | 48 +++++ tools/cdb/run-issue269-slope-stop.ps1 | 74 +++++++ 13 files changed, 870 insertions(+), 103 deletions(-) create mode 100644 docs/research/2026-07-31-269-slope-stop-capture.md create mode 100644 src/AcDream.Runtime/Gameplay/PlayerPhysicsQuantumCapture.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/PlayerPhysicsQuantumCaptureTests.cs create mode 100644 tools/analyze_269_slope_stop_capture.py create mode 100644 tools/cdb/issue269-slope-stop.cdb create mode 100644 tools/cdb/run-issue269-slope-stop.ps1 diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 1bc96735..c64ad444 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 (`0x0050AAED–0x0050AB42`). 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`. --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index d9b4954b..4aa27c21 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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. --- diff --git a/docs/plans/2026-07-29-physics-parity-campaign.md b/docs/plans/2026-07-29-physics-parity-campaign.md index 814ef6df..b7bc2d59 100644 --- a/docs/plans/2026-07-29-physics-parity-campaign.md +++ b/docs/plans/2026-07-29-physics-parity-campaign.md @@ -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. diff --git a/docs/research/2026-07-31-269-slope-stop-capture.md b/docs/research/2026-07-31-269-slope-stop-capture.md new file mode 100644 index 00000000..e48cb9c4 --- /dev/null +++ b/docs/research/2026-07-31-269-slope-stop-capture.md @@ -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=` 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)` | +| 1742–1758 | 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 +`0x0050AAED–0x0050AB42` 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. diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md index 6117dc6b..98eea409 100644 --- a/memory/project_movement_collision_conformance.md +++ b/memory/project_movement_collision_conformance.md @@ -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`. diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index d565bcec..d808ac5c 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -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,79 +5590,9 @@ 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 { + ci.LastKnownContactPlaneValid = false; oi.State &= ~(ObjectInfoState.Contact | ObjectInfoState.OnWalkable); } diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index fb8dc1ab..a3d8182b 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -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 ───────────────────────────── diff --git a/src/AcDream.Runtime/Gameplay/PlayerPhysicsQuantumCapture.cs b/src/AcDream.Runtime/Gameplay/PlayerPhysicsQuantumCapture.cs new file mode 100644 index 00000000..1ab432f3 --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/PlayerPhysicsQuantumCapture.cs @@ -0,0 +1,191 @@ +using System.Numerics; +using System.Text.Json; +using AcDream.Core.Physics; + +namespace AcDream.Runtime.Gameplay; + +/// +/// Opt-in, player-only trace of the complete retail object quantum. +/// +/// Set ACDREAM_CAPTURE_PLAYER_QUANTA=<path> 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 +/// CPhysicsObj::UpdateObjectInternal 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. +/// +/// +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); diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs index 560db1d6..ce837e81 100644 --- a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs @@ -483,6 +483,54 @@ public class BSPStepUpTests "wall (failed step-up should preserve LastKnownContactPlane)."); } + /// + /// 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. + /// + [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."); + } + + /// + /// 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. + /// + [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); + } + /// /// L.2.3b regression: Path 5 dispatch must be guarded against re-entry while /// a step-up is already in progress. Test runs FindTransitionalPosition diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerPhysicsQuantumCaptureTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerPhysicsQuantumCaptureTests.cs new file mode 100644 index 00000000..7c136cea --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerPhysicsQuantumCaptureTests.cs @@ -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"; +} diff --git a/tools/analyze_269_slope_stop_capture.py b/tools/analyze_269_slope_stop_capture.py new file mode 100644 index 00000000..29f55956 --- /dev/null +++ b/tools/analyze_269_slope_stop_capture.py @@ -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()) diff --git a/tools/cdb/issue269-slope-stop.cdb b/tools/cdb/issue269-slope-stop.cdb new file mode 100644 index 00000000..0ec5e652 --- /dev/null +++ b/tools/cdb/issue269-slope-stop.cdb @@ -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 diff --git a/tools/cdb/run-issue269-slope-stop.ps1 b/tools/cdb/run-issue269-slope-stop.ps1 new file mode 100644 index 00000000..564dcf36 --- /dev/null +++ b/tools/cdb/run-issue269-slope-stop.ps1 @@ -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"