acdream/tests/AcDream.App.Tests/Rendering/Issue181WallPressEquilibriumTests.cs
Erik 3f34bca06f 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 <noreply@anthropic.com>
2026-07-06 22:30:37 +02:00

188 lines
8.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// #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.
/// </summary>
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<DatEnvCell>(id);
if (datCell is null) continue;
var environment = dats.Get<DatEnvironment>(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<CellSurface>(), Array.Empty<PortalPlane>(), 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<Vector3>(20000);
var cells = new HashSet<uint>();
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;
}
}
}