diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
index 4c470455..385e0639 100644
--- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
+++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
@@ -2532,6 +2532,99 @@ public static class PhysicsDiagnostics
return "?";
}
+ // -----------------------------------------------------------------------
+ // #338 — step-height provenance
+ // -----------------------------------------------------------------------
+
+ ///
+ /// #338 (2026-08-06, TEMPORARY). Traces the LOCAL PLAYER's step-up /
+ /// step-down heights along the chain that is supposed to carry them from
+ /// the authored Setup to the resolver, so we can see WHERE the observed
+ /// 0.400 / 0.400 wins.
+ ///
+ ///
+ /// What is already known and does NOT need measuring: retail reads the
+ /// authored field (CTransition::step_up @0x0050b610 substitutes
+ /// object_info.step_up_height when state & 2, i.e.
+ /// OnWalkable, and otherwise uses 0.0399999991f), and acdream
+ /// already ports that gate faithfully in Transition.DoStepUp. The
+ /// value is the defect, not the gate.
+ ///
+ ///
+ ///
+ /// What is NOT known, and is exactly what this probe decides: the
+ /// controller's fields initialise to 0.4f, while
+ /// RuntimeSetPositionMoverPreparation genuinely computes the
+ /// Setup-derived value and a writer genuinely assigns it. So either the
+ /// prepare/publish path never runs for the local player, or it runs and
+ /// something later overwrites it. A live reading of 0.400 alone
+ /// cannot tell those apart, and a fix chosen without knowing which is a
+ /// coin flip.
+ ///
+ ///
+ /// Decision table. Read the [step-h] lines in order:
+ ///
+ /// - No site=prepare line at all → the Setup-derived path
+ /// never runs for the local player. Fix is to wire it (and the missing
+ /// PlayerModeController.ApplyStepHeights the doc comment names
+ /// was probably it).
+ /// - site=prepare shows 0.600/1.500 but no
+ /// site=publish → the command is built and never consumed on this
+ /// path.
+ /// - prepare and publish both show 0.600/1.500 but
+ /// site=resolve shows 0.400 → a later writer clobbers it; find
+ /// that writer, do not re-set the field.
+ /// - site=prepare itself shows 0.400 → the Setup lookup is
+ /// returning the wrong Setup, or scale is wrong.
+ /// - site=prepare shows 0.000 → preparation.Setup.Collision
+ /// was null and the retail dummy path was taken.
+ ///
+ ///
+ ///
+ ///
+ /// Initial state from ACDREAM_PROBE_STEP_HEIGHTS=1. Zero cost when
+ /// off (one static bool read per site).
+ ///
+ ///
+ public static bool ProbeStepHeightsEnabled { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_PROBE_STEP_HEIGHTS") == "1";
+
+ private static readonly object _stepHeightGate = new();
+ private static readonly Dictionary _stepHeightSeen = new();
+
+ ///
+ /// One [step-h] line. Self-guards on
+ /// , and is edge-triggered per site:
+ /// a site that keeps reporting the same pair prints once, so the
+ /// per-tick resolve site cannot drown the two one-shot sites it has to be
+ /// compared against.
+ ///
+ /// Where on the chain this reading was taken —
+ /// prepare, publish, or resolve.
+ /// The step-up height at that point, in metres.
+ /// The step-down height at that point.
+ /// Free-form provenance: the Setup id, the scale, or
+ /// which branch produced the value. This is what makes a surprising
+ /// reading actionable instead of merely surprising.
+ public static void LogStepHeights(
+ string site, float stepUp, float stepDown, string detail)
+ {
+ if (!ProbeStepHeightsEnabled) return;
+
+ lock (_stepHeightGate)
+ {
+ if (_stepHeightSeen.TryGetValue(site, out var prev)
+ && prev.Up == stepUp && prev.Down == stepDown)
+ {
+ return;
+ }
+ _stepHeightSeen[site] = (stepUp, stepDown);
+ }
+
+ Console.WriteLine(
+ $"[step-h] site={site} stepUp={stepUp:F3} stepDown={stepDown:F3} {detail}");
+ }
+
private static int ParsePositiveInt(string? value) =>
int.TryParse(
value,
diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
index d8968b89..c013fe9b 100644
--- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
+++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
@@ -2625,6 +2625,15 @@ public sealed class PlayerMovementController
// or it re-zeros the gravity velocity and the body re-wedges instead of falling off.
bool candidateMoved = postIntegratePos != preIntegratePos;
+ // #338 (TEMPORARY): third and last reading along the chain — what
+ // the resolver is ACTUALLY handed, per ordinary movement tick.
+ // Edge-triggered inside the probe, so this per-tick site prints
+ // once per distinct pair and cannot drown the two one-shot sites
+ // it exists to be compared against.
+ PhysicsDiagnostics.LogStepHeights(
+ "resolve", StepUpHeight, StepDownHeight,
+ $"onWalkable={_body.OnWalkable} cell=0x{CellId:X8}");
+
// ── 3. Collision resolution via CTransition sphere-sweep ─────────────
// The Transition system subdivides the movement from pre→post into
// sphere-radius steps, testing terrain collision at each step.
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs
index b84b0d37..a5380dbf 100644
--- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs
@@ -214,6 +214,15 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
controller.LocalEntityId = record.Key!.Value.LocalEntityId;
controller.StepUpHeight = command.Physics.StepUpHeight;
controller.StepDownHeight = command.Physics.StepDownHeight;
+
+ // #338 (TEMPORARY): second of three readings. If `prepare` printed the
+ // authored pair and this line does not appear, the command is built
+ // and never consumed on the local player's path.
+ PhysicsDiagnostics.LogStepHeights(
+ "publish",
+ command.Physics.StepUpHeight,
+ command.Physics.StepDownHeight,
+ $"localEntityId={controller.LocalEntityId} scale={command.Physics.Scale:F3}");
controller.SphereList = command.Physics.Spheres;
controller.ObjectScale = command.Physics.Scale;
controller.PreparePositionForCommit(
diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs
index fc6aef6e..648e3d02 100644
--- a/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs
+++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs
@@ -180,6 +180,15 @@ internal static class RuntimeSetPositionMoverPreparer
float stepUp = setup is not null ? setup.StepUpHeight * scale : 0f;
float stepDown = setup is not null ? setup.StepDownHeight * scale : 0f;
+ // #338 (TEMPORARY): first of three readings along the chain. Prints
+ // the raw authored pair alongside the scaled one, so a surprise here
+ // separates "wrong Setup" from "wrong scale" without a second run.
+ PhysicsDiagnostics.LogStepHeights(
+ "prepare", stepUp, stepDown,
+ setup is not null
+ ? $"authored=({setup.StepUpHeight:F3},{setup.StepDownHeight:F3}) scale={scale:F3}"
+ : "setup=NULL (retail dummy path, exact zero steps)");
+
EntityCollisionFlags collisionFlags =
EntityCollisionFlagsExt.FromPwdBitfield(
record.Snapshot.ObjectDescriptionFlags ?? 0u);