diff --git a/docs/research/2026-07-06-viewer-step-subdivision-pseudocode.md b/docs/research/2026-07-06-viewer-step-subdivision-pseudocode.md
new file mode 100644
index 00000000..6a29793e
--- /dev/null
+++ b/docs/research/2026-07-06-viewer-step-subdivision-pseudocode.md
@@ -0,0 +1,75 @@
+# `CTransition::calc_num_steps` + the viewer step loop — pseudocode (#181 equilibrium fix)
+
+Source: Ghidra decompile (patchmem project, PDB-named) of `calc_num_steps`
+(`0x0050a0b0`) and `find_transitional_position` (`0x0050bdf0`), read 2026-07-06
+after the user's retail axiom ("retail camera is rock steady" pressed into
+walls) contradicted acdream's measured wall-press wander (~0.5 mm/frame
+forever, headless pin `Issue181WallPressEquilibriumTests`). The BN pseudo-C of
+the same functions is x87 mush; Ghidra's is clean.
+
+## `calc_num_steps` — 0x0050a0b0
+
+```
+calc_num_steps(this, out offset, out offsetPerStep, out numSteps):
+ if begin_pos == null: offset = 0; offsetPerStep = 0; numSteps = 1; return
+ offset = get_offset(begin_pos, end_pos)
+ r = local_sphere.radius
+ len = |offset|
+
+ if object_info.state & 4: # VIEWER (bit 2 of the 0x5c camera flags)
+ if len > F_EPSILON: # 2e-4
+ offsetPerStep = offset * (r / len) # EXACTLY radius-length steps, start-anchored
+ numSteps = floor(len / r) + 1 # the end lands INSIDE the last step
+ else:
+ offsetPerStep = 0; numSteps = 0 # zero-length path → the no-step success path
+ return
+
+ # non-viewer: equal slices of ~radius (TRANSITIONAL_PERCENT_OF_RADIUS = 1.0, 0x007c6874)
+ steps = len / (TRANSITIONAL_PERCENT_OF_RADIUS * r)
+ if steps > 1:
+ numSteps = ceil(steps); offsetPerStep = offset / numSteps
+ elif offset == 0:
+ offsetPerStep = 0; numSteps = 0
+ else:
+ offsetPerStep = offset; numSteps = 1 # short move → one whole step
+```
+
+## The step loop's viewer handling — `find_transitional_position` 0x0050bdf0
+
+```
+for i in 0 .. numSteps-1:
+ # VIEWER LAST-STEP REMAINDER: the final step covers exactly what's left,
+ # so the swept path ends exactly at end_pos (no overshoot):
+ if (state & 4) and i == numSteps-1 and len > F_EPSILON:
+ offsetPerStep = offset * ((len - (numSteps-1)*r) / len)
+
+ global_offset = adjust_offset(offsetPerStep)
+
+ # The negligible-offset abort is NON-VIEWER-ONLY — a pressed camera keeps
+ # stepping through sub-epsilon adjusted offsets:
+ if (state & 4) == 0 and |global_offset|² < F_EPSILON²: break
+
+ ... check_pos += global_offset; transitional_insert(3); validate ...
+ if collision_normal_valid and (state & 8): break # PathClipped first-hit stop
+```
+
+## Why this stabilizes the wall-press equilibrium (#181)
+
+acdream's pre-fix stepping for ALL movers was `numSteps = ceil(len/r)` equal
+slices. The camera's convergence loop (sought = lerp(viewer→desired) → sweep →
+viewer) perturbs `len` by mm every frame:
+
+- equal slices: every step boundary shifts with `len`, and at `len` near an
+ exact multiple of r (the measured press pose: 1.5 m = 5 × 0.3 m) the step
+ COUNT flaps ceil-wise, teleporting the colliding step's window ~5 cm — the
+ clip's committed point jumps mm-scale, and the loop orbits a limit cycle
+ instead of a fixed point (the measured ~0.5 mm/frame wander → the #181
+ flicker excitation);
+- retail viewer grid: the first `floor(len/r)` steps are constant radius-length
+ increments anchored at the pivot — invariant under mm target drift — and only
+ the remainder step breathes. The colliding step's window is stable, the clip
+ result is stable, the loop reaches retail's fixed point ("rock steady").
+
+Port sites: `Transition.FindTransitionalPosition` (TransitionTypes.cs) — the
+subdivision block + the per-step loop (last-step remainder + the abort gate).
+Non-viewer behavior unchanged (already matches).
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 20dcdef1..a9a9414d 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -770,8 +770,19 @@ public sealed class Transition
return false;
// ------------------------------------------------------------------
- // Step subdivision: each sub-step travels at most one sphere radius
- // to prevent tunnelling through thin surfaces.
+ // Step subdivision (retail CTransition::calc_num_steps 0x0050a0b0,
+ // pseudocode docs/research/2026-07-06-viewer-step-subdivision-pseudocode.md).
+ // Two shapes:
+ // - VIEWER (the camera sweep, state & 4): steps of EXACTLY one radius,
+ // anchored at the start — numSteps = floor(len/r) + 1, with the final
+ // step recomputed in the loop as the exact remainder. The anchored
+ // grid is invariant under mm-scale target drift, which is what makes
+ // retail's wall-pressed camera a fixed point ("rock steady"); the
+ // pre-#181 equal-slice grid reshuffled every boundary per frame and
+ // the camera orbited a ~0.5 mm/frame limit cycle instead
+ // (Issue181WallPressEquilibriumTests).
+ // - non-viewer: equal slices of ~one radius (TRANSITIONAL_PERCENT_OF_
+ // RADIUS = 1.0 at 0x007c6874) — unchanged, matches retail.
// ------------------------------------------------------------------
Vector3 offset = sp.EndPos - sp.BeginPos;
float dist = offset.Length();
@@ -781,25 +792,41 @@ public sealed class Transition
if (radius <= PhysicsGlobals.EPSILON)
return false;
- float step = dist / radius;
-
int numSteps;
Vector3 offsetPerStep;
- if (step > 1.0f)
+ if (ObjectInfo.IsViewer)
{
- numSteps = (int)MathF.Ceiling(step);
- offsetPerStep = offset * (1f / numSteps);
- }
- else if (offset != Vector3.Zero)
- {
- numSteps = 1;
- offsetPerStep = offset;
+ if (dist > PhysicsGlobals.EPSILON)
+ {
+ offsetPerStep = offset * (radius / dist); // radius-length steps
+ numSteps = (int)MathF.Floor(dist / radius) + 1;
+ }
+ else
+ {
+ numSteps = 0;
+ offsetPerStep = Vector3.Zero;
+ }
}
else
{
- numSteps = 0;
- offsetPerStep = Vector3.Zero;
+ float step = dist / radius;
+
+ if (step > 1.0f)
+ {
+ numSteps = (int)MathF.Ceiling(step);
+ offsetPerStep = offset * (1f / numSteps);
+ }
+ else if (offset != Vector3.Zero)
+ {
+ numSteps = 1;
+ offsetPerStep = offset;
+ }
+ else
+ {
+ numSteps = 0;
+ offsetPerStep = Vector3.Zero;
+ }
}
// Retail safety cap (30 steps). Viewer/sight objects bypass it, matching
@@ -843,6 +870,13 @@ public sealed class Transition
for (int i = 0; i < numSteps; i++)
{
+ // Viewer last-step remainder (retail find_transitional_position
+ // 0x0050bdf0: on i == numSteps−1 for state&4, the step offset is
+ // recomputed as offset · (len − (numSteps−1)·r)/len — the sweep
+ // ends exactly at EndPos, never overshooting the anchored grid).
+ if (ObjectInfo.IsViewer && i == numSteps - 1 && dist > PhysicsGlobals.EPSILON)
+ offsetPerStep = offset * ((dist - (numSteps - 1) * radius) / dist);
+
Vector3 requestedOffset = offsetPerStep;
// Per ACE order: AdjustOffset FIRST (uses state from previous step),
@@ -859,8 +893,13 @@ public sealed class Transition
}
// Abort if adjusted offset is negligible (stuck against a wall
- // with no slide tangent available).
- if (sp.GlobalOffset.LengthSquared() < PhysicsGlobals.EpsilonSq)
+ // with no slide tangent available). NON-VIEWER-ONLY per retail
+ // (find_transitional_position 0x0050bdf0: `(state & 4) == 0 &&
+ // |global_offset|² < F_EPSILON²`) — a pressed camera keeps
+ // stepping through sub-epsilon adjusted offsets so its remainder
+ // step still lands exactly on the sought (#181 equilibrium).
+ if (!ObjectInfo.IsViewer
+ && sp.GlobalOffset.LengthSquared() < PhysicsGlobals.EpsilonSq)
{
if (stepWalkProbe)
{
diff --git a/tests/AcDream.App.Tests/Rendering/Issue181WallPressEquilibriumTests.cs b/tests/AcDream.App.Tests/Rendering/Issue181WallPressEquilibriumTests.cs
new file mode 100644
index 00000000..bd57825b
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Issue181WallPressEquilibriumTests.cs
@@ -0,0 +1,188 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.Core.Physics;
+using AcDream.Core.Rendering;
+using DatReaderWriter;
+using DatReaderWriter.Options;
+using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
+using DatEnvironment = DatReaderWriter.DBObjs.Environment;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace AcDream.App.Tests.Rendering;
+
+///
+/// #181 excitation, isolated headlessly — the WALL-PRESS equilibrium. Live
+/// evidence: a camera pressed into corridor walls/openings never reaches a
+/// fixed point (sought steps α·gap into the wall per frame; the sweep clips it
+/// back within adjust_to_plane's parametric 0.02 window) → the published eye
+/// wanders ~1 mm/frame, and when the wander straddles a cell boundary the
+/// VIEWER CELL flaps (launch-181-pressed.log: viewer≠player on 85.5% of
+/// frames, one-frame A→B→A root flips) — each flip re-roots the whole
+/// visibility frame (the #176/#181 flicker).
+///
+/// This test runs the REAL RetailChaseCamera + the REAL
+/// PhysicsCameraCollisionProbe against the REAL Facility Hub BSP with a
+/// static player backed against the corridor wall, and measures the
+/// steady-state eye wander + ViewerCellId stability over 20k frames.
+/// Diagnostic (reporting) first; the equilibrium fix turns the wander/flap
+/// numbers into hard pins.
+///
+public class Issue181WallPressEquilibriumTests
+{
+ private const uint FacilityHubLandblock = 0x8A020000u;
+
+ private readonly ITestOutputHelper _out;
+ public Issue181WallPressEquilibriumTests(ITestOutputHelper output) => _out = output;
+
+ // Mirrors AcDream.Core.Tests Conformance.ConformanceDats (not referencable
+ // from App.Tests): resolve the dat dir + load real EnvCells into the cache.
+ private static string? ResolveDatDir()
+ {
+ var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
+ if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) return fromEnv;
+ var def = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "Documents", "Asheron's Call");
+ return Directory.Exists(def) ? def : null;
+ }
+
+ private static (PhysicsEngine, PhysicsDataCache) BuildCorridorEngine(DatCollection dats)
+ {
+ var cache = new PhysicsDataCache();
+ var engine = new PhysicsEngine { DataCache = cache };
+ for (uint low = 0x0100u; low <= 0x01FFu; low++)
+ {
+ uint id = FacilityHubLandblock | low;
+ var datCell = dats.Get(id);
+ if (datCell is null) continue;
+ var environment = dats.Get(0x0D000000u | datCell.EnvironmentId);
+ if (environment is null) continue;
+ if (!environment.Cells.TryGetValue(datCell.CellStructure, out var cellStruct) || cellStruct is null)
+ continue;
+ var world = Matrix4x4.CreateFromQuaternion(datCell.Position.Orientation) *
+ Matrix4x4.CreateTranslation(datCell.Position.Origin);
+ cache.CacheCellStruct(id, datCell, cellStruct, world);
+ }
+ var heights = new byte[81];
+ var heightTable = new float[256];
+ for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
+ engine.AddLandblock(FacilityHubLandblock, new TerrainSurface(heights, heightTable),
+ Array.Empty(), Array.Empty(), 0f, 0f);
+ return (engine, cache);
+ }
+
+ [Fact]
+ public void Diagnostic_WallPressedCamera_EyeWanderAndViewerCellStability()
+ {
+ var datDir = ResolveDatDir();
+ if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ var (engine, _) = BuildCorridorEngine(dats);
+
+ bool savedAlign = CameraDiagnostics.AlignToSlope;
+ bool savedColl = CameraDiagnostics.CollideCamera;
+ float savedT = CameraDiagnostics.TranslationStiffness;
+ float savedR = CameraDiagnostics.RotationStiffness;
+ try
+ {
+ CameraDiagnostics.AlignToSlope = true;
+ CameraDiagnostics.CollideCamera = true;
+ CameraDiagnostics.TranslationStiffness = 0.45f;
+ CameraDiagnostics.RotationStiffness = 0.45f;
+
+ // The live parked spot from the leak-fix log: player at the corridor
+ // spawn (cell 0x0142), backed near the +Y wall so the full boom is
+ // blocked (live [resolve]: hit=yes n=(0,-1,0) every frame).
+ var playerPos = new Vector3(50.331f, -39.357f, -5.90f);
+ // Live [resolve]: the sweep target headed (-2.13,+1.32,+0.75) from the
+ // pivot and hit the n=(0,-1,0) wall — so the player faces (+X,-Y)-ish
+ // and the boom presses -X+Y into that wall. yaw = atan2(-0.53, 0.85).
+ float yaw = -0.556f;
+ uint cellId = 0x8A020142u;
+ float dt = 1f / 1500f;
+
+ var cam = new RetailChaseCamera
+ {
+ CollisionProbe = new PhysicsCameraCollisionProbe(engine),
+ };
+
+ void Step() => cam.Update(
+ playerPosition: playerPos,
+ playerYaw: yaw,
+ playerVelocity: Vector3.Zero,
+ isOnGround: true,
+ contactPlaneNormal: Vector3.UnitZ,
+ dt: dt,
+ cellId: cellId,
+ selfEntityId: 0x5);
+
+ // Settle into the wall-press equilibrium.
+ for (int i = 0; i < 5000; i++) Step();
+
+ // Measure 20k steady-state frames.
+ var eyes = new List(20000);
+ var cells = new HashSet();
+ int cellTransitions = 0;
+ uint prevCell = cam.ViewerCellId;
+ Vector3 prevEye = cam.Position;
+ float maxStep = 0f; double sumStep = 0;
+ for (int i = 0; i < 20000; i++)
+ {
+ Step();
+ float d = Vector3.Distance(cam.Position, prevEye);
+ maxStep = MathF.Max(maxStep, d);
+ sumStep += d;
+ prevEye = cam.Position;
+ eyes.Add(cam.Position);
+ cells.Add(cam.ViewerCellId);
+ if (cam.ViewerCellId != prevCell) { cellTransitions++; prevCell = cam.ViewerCellId; }
+ }
+
+ // Wander bounding box.
+ Vector3 mn = eyes[0], mx = eyes[0];
+ foreach (var e in eyes) { mn = Vector3.Min(mn, e); mx = Vector3.Max(mx, e); }
+ var span = mx - mn;
+
+ _out.WriteLine(FormattableString.Invariant(
+ $"steady-state: avgStep={sumStep / 20000 * 1e6:F1}um maxStep={maxStep * 1e6:F1}um wanderBox=({span.X * 1000:F2},{span.Y * 1000:F2},{span.Z * 1000:F2})mm"));
+ _out.WriteLine(FormattableString.Invariant(
+ $"viewer cells seen: {cells.Count} transitions={cellTransitions} eye=({cam.Position.X:F6},{cam.Position.Y:F6},{cam.Position.Z:F6}) cell=0x{cam.ViewerCellId:X8}"));
+
+ // Orbit structure: 16 consecutive frames at 6dp, with the sweep's
+ // own [flap-sweep] lines captured for the same frames.
+ bool savedFlap = RenderingDiagnostics.ProbeFlapEnabled;
+ var savedOut = Console.Out;
+ try
+ {
+ RenderingDiagnostics.ProbeFlapEnabled = true;
+ using var writer = new StringWriter();
+ Console.SetOut(writer);
+ for (int i = 0; i < 16; i++)
+ {
+ Step();
+ writer.WriteLine(FormattableString.Invariant(
+ $"orbit[{i:D2}] eye=({cam.Position.X:F6},{cam.Position.Y:F6},{cam.Position.Z:F6})"));
+ }
+ Console.SetOut(savedOut);
+ foreach (var line in writer.ToString().Split('\n'))
+ if (line.Length > 1) _out.WriteLine(line.TrimEnd());
+ }
+ finally
+ {
+ Console.SetOut(savedOut);
+ RenderingDiagnostics.ProbeFlapEnabled = savedFlap;
+ }
+ }
+ finally
+ {
+ CameraDiagnostics.AlignToSlope = savedAlign;
+ CameraDiagnostics.CollideCamera = savedColl;
+ CameraDiagnostics.TranslationStiffness = savedT;
+ CameraDiagnostics.RotationStiffness = savedR;
+ }
+ }
+}