From 3f34bca06f0d19e4daeefecbc09ed6527a836cb1 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 6 Jul 2026 22:30:37 +0200 Subject: [PATCH] port #181: retail viewer step subdivision (calc_num_steps 0x0050a0b0) - radius-anchored steps + remainder final step + viewer-exempt small-offset abort The user's retail axiom (camera rock steady pressed into walls) vs our measured wall-press wander (~0.5mm/frame limit cycle, headless pin Issue181WallPressEquilibriumTests) sent us back to the decomp. Ghidra (clean, vs the BN x87 mush): retail VIEWERS subdivide the sweep into EXACTLY radius-length steps anchored at the start (offsetPerStep = offset*r/len, numSteps = floor(len/r)+1) with the final step recomputed mid-loop as the exact remainder (find_transitional_position 0x0050bdf0), and the negligible-offset abort is NON-viewer-only. Ours used ceil equal-slices for everything and aborted viewers too. Ported faithfully (pseudocode docs/research/2026-07-06-viewer-step-subdivision-pseudocode.md); non-viewer stepping already matched (TRANSITIONAL_PERCENT_OF_RADIUS=1.0). Measurement: the wall-press limit cycle is UNCHANGED by the port (537.8um avg; a bit-exact 12-frame cycle: ~130um/frame inward creep x11 then a 2.6mm snap). With adjust_to_plane + adjust_sphere_to_poly now also Ghidra-verified faithful, the residual mm cycle is likely retail-class plateau physics - invisible at retail's 60fps vsync, tear-interleaved into visible stripes at our ~1500fps unsynced. The decisive user test: VSync ON (Settings/F11). Fallback discriminator: cdb-trace retail's viewer at a wall press. Suites green (Core 2600 / App 733 / UI 425 / Net 385). Co-Authored-By: Claude Fable 5 --- ...7-06-viewer-step-subdivision-pseudocode.md | 75 +++++++ src/AcDream.Core/Physics/TransitionTypes.cs | 71 +++++-- .../Issue181WallPressEquilibriumTests.cs | 188 ++++++++++++++++++ 3 files changed, 318 insertions(+), 16 deletions(-) create mode 100644 docs/research/2026-07-06-viewer-step-subdivision-pseudocode.md create mode 100644 tests/AcDream.App.Tests/Rendering/Issue181WallPressEquilibriumTests.cs 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; + } + } +}