Merge branch 'main' into claude/peaceful-visvesvaraya-e0a196

# Conflicts:
#	docs/ISSUES.md
#	docs/architecture/retail-divergence-register.md
This commit is contained in:
Erik 2026-07-09 23:18:52 +02:00
commit 217a4bad69
329 changed files with 81439 additions and 8499 deletions

View file

@ -0,0 +1,49 @@
using AcDream.App.Diagnostics;
using Xunit;
namespace AcDream.App.Tests;
public class FrameProfilerReportTests
{
[Fact]
public void FormatReport_IsInvariantAndComplete()
{
var cpu = new FrameStatsBuffer(16);
var gpu = new FrameStatsBuffer(16);
var alloc = new FrameStatsBuffer(16);
var stages = new[] { new FrameStatsBuffer(16), new FrameStatsBuffer(16), new FrameStatsBuffer(16) };
for (long i = 1; i <= 10; i++)
{
cpu.Push(i * 1000); // 1..10 ms in µs
gpu.Push(i * 100);
alloc.Push(i * 1024); // bytes
stages[0].Push(i * 200);
stages[1].Push(i * 50);
stages[2].Push(i * 10);
}
string line = FrameProfiler.FormatReport(
frameCount: 10, cpu: cpu, gpu: gpu, gpuActive: true,
alloc: alloc, gc0: 3, gc1: 1, gc2: 0, stages: stages);
Assert.StartsWith("[frame-prof]", line);
Assert.Contains("n=10", line);
Assert.Contains("cpu_ms p50=5.0 p95=10.0 p99=10.0 max=10.0", line);
Assert.Contains("gpu_ms p50=0.5", line);
Assert.Contains("alloc_kb p50=5.0 max=10.0", line);
Assert.Contains("gc=3/1/0", line);
Assert.Contains("upd p50=1.0", line); // stage 0: 200µs·5 = 1.0 ms
Assert.DoesNotContain(",0", line.Replace("gc=3/1/0", "")); // no comma decimals (invariant culture)
}
[Fact]
public void FormatReport_GpuInactive_SaysWhy()
{
var empty = new FrameStatsBuffer(4);
string line = FrameProfiler.FormatReport(
frameCount: 0, cpu: empty, gpu: empty, gpuActive: false,
alloc: empty, gc0: 0, gc1: 0, gc2: 0,
stages: new[] { empty, empty, empty });
Assert.Contains("gpu=off(wbdiag)", line);
}
}

View file

@ -0,0 +1,59 @@
using AcDream.App.Diagnostics;
using Xunit;
namespace AcDream.App.Tests;
public class FrameStatsBufferTests
{
[Fact]
public void Percentiles_OnKnownDistribution_AreExact()
{
var buf = new FrameStatsBuffer(capacity: 100);
// 1..100 µs — p50 = 50, p95 = 95, p99 = 99, max = 100.
for (long i = 1; i <= 100; i++) buf.Push(i);
Assert.Equal(50, buf.Percentile(0.50));
Assert.Equal(95, buf.Percentile(0.95));
Assert.Equal(99, buf.Percentile(0.99));
Assert.Equal(100, buf.Max());
}
[Fact]
public void Push_PastCapacity_KeepsOnlyNewestWindow()
{
var buf = new FrameStatsBuffer(capacity: 4);
foreach (long v in new long[] { 1000, 1000, 1000, 1000, 1, 2, 3, 4 })
buf.Push(v);
// The four 1000s were overwritten; window is {1,2,3,4}.
Assert.Equal(4, buf.Count);
Assert.Equal(4, buf.Max());
Assert.Equal(2, buf.Percentile(0.50));
}
[Fact]
public void Percentile_Empty_ReturnsZero()
{
var buf = new FrameStatsBuffer(capacity: 8);
Assert.Equal(0, buf.Percentile(0.95));
Assert.Equal(0, buf.Max());
Assert.Equal(0, buf.Count);
}
[Fact]
public void Reset_ClearsWindow()
{
var buf = new FrameStatsBuffer(capacity: 8);
buf.Push(5); buf.Push(7);
buf.Reset();
Assert.Equal(0, buf.Count);
Assert.Equal(0, buf.Percentile(0.5));
}
[Fact]
public void Max_AllNegative_ReturnsTrueMax()
{
var buf = new FrameStatsBuffer(capacity: 4);
buf.Push(-5); buf.Push(-2); buf.Push(-9);
Assert.Equal(-2, buf.Max());
}
}

View file

@ -88,7 +88,6 @@ public class CornerFloodReplayTests
var portals = new List<CellPortalInfo>();
var clipPlanes = new List<PortalClipPlane>();
var portalPolygons = new List<Vector3[]>();
var centroid = (boundsMin + boundsMax) * 0.5f;
foreach (var portal in envCell.CellPortals)
{
@ -106,10 +105,13 @@ public class CornerFloodReplayTests
var p2 = new Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z);
var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
float d = -Vector3.Dot(normal, p0);
float centroidDot = Vector3.Dot(normal, centroid) + d;
// InsideSide from the dat PortalSide bit — mirrors the production fix
// (GameWindow.BuildLoadedCell): retail InitCell (0x005a4b70) + physics
// CellTransit use the dat bit; the old AABB-centroid guess mis-sided thin
// connectors (#186). Kept identical here so this replay stays a faithful mirror.
clipPlanes.Add(new PortalClipPlane
{
Normal = normal, D = d, InsideSide = centroidDot >= 0 ? 0 : 1,
Normal = normal, D = d, InsideSide = ((ushort)portal.Flags & 0x2) == 0 ? 1 : 0,
});
}
else

View file

@ -77,7 +77,6 @@ public class Issue113MeetingHallFloodTests
var portals = new List<CellPortalInfo>();
var clipPlanes = new List<PortalClipPlane>();
var portalPolygons = new List<Vector3[]>();
var centroid = (boundsMin + boundsMax) * 0.5f;
foreach (var portal in envCell.CellPortals)
{
@ -95,10 +94,13 @@ public class Issue113MeetingHallFloodTests
var p2 = new Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z);
var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
float d = -Vector3.Dot(normal, p0);
float centroidDot = Vector3.Dot(normal, centroid) + d;
// InsideSide from the dat PortalSide bit — mirrors the production fix
// (GameWindow.BuildLoadedCell): retail InitCell (0x005a4b70) + physics
// CellTransit use the dat bit; the old AABB-centroid guess mis-sided thin
// connectors (#186). Kept identical here so this replay stays a faithful mirror.
clipPlanes.Add(new PortalClipPlane
{
Normal = normal, D = d, InsideSide = centroidDot >= 0 ? 0 : 1,
Normal = normal, D = d, InsideSide = ((ushort)portal.Flags & 0x2) == 0 ? 1 : 0,
});
}
else

View file

@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #176 (purple flashing at corridor seams, camera-angle dependent) + #177
/// (stairs pop in/out) — headless portal-flood replay in the Facility Hub
/// landblock 0x8A02. The unified hypothesis after the dat + draw-path reads:
/// both artifacts are FLOOD ADMISSION instability (a cell dropping out of
/// PortalVisibilityBuilder's admitted set paints the fog-purple clear color
/// where its geometry was; stair cells failing admission = the pop).
///
/// Production-matched inputs: Build(root, eye, lookup, viewProj,
/// buildingMembership: null, drawLiftZ: ShellDrawLiftZ) — the drawLiftZ
/// mirrors RetailPViewRenderer.DrawInside.
///
/// Scenarios:
/// A. #177 approach — stand in corridor 0x0178, look +X at the stair ramp
/// (0x0182) and the lower cell (0x0183): are they admitted?
/// B. #177 descent — eye path down the ramp crossing into 0x0183: does
/// 0x0182 (the ramp geometry's owner) drop near the transit?
/// C. #176 gaze sweep — eye parked in 0x016E near the 0x017A seam, yaw
/// sweep at several pitches: any cell admitted at angle k, gone at k+1,
/// back at k+2 (the bistability signature)?
/// D. #176 walk — eye tracks down the corridor across two seams, gaze
/// locked +X: per-step admitted-set diffs (drop-for-one-step churn).
/// </summary>
public class Issue176177FacilityHubFloodReplayTests
{
private const uint FacilityHub = 0x8A020000u;
private readonly ITestOutputHelper _out;
public Issue176177FacilityHubFloodReplayTests(ITestOutputHelper output) => _out = output;
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 gazeDir)
{
var view = Matrix4x4.CreateLookAt(eye, eye + gazeDir, Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f);
return view * proj;
}
private static List<uint> Flood(
Dictionary<uint, LoadedCell> cells, uint rootId, Vector3 eye, Vector3 gazeDir)
{
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
var frame = PortalVisibilityBuilder.Build(
cells[rootId], eye, lookup, ViewProjFor(eye, gazeDir),
buildingMembership: null,
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
var result = new List<uint>(frame.OrderedVisibleCells);
result.Sort();
return result;
}
private static string CellSetString(IEnumerable<uint> ids)
=> string.Join(" ", ids.Select(id => $"{id & 0xFFFFu:X4}"));
[Fact]
public void ScenarioA_StairApproach_AdmissionsFromCorridor()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
Assert.True(cells.ContainsKey(FacilityHub | 0x0178u), "0x0178 not loaded");
// 0x0178 spans x<=95 at z 6..3 (floor 6); the ramp cell 0x0182 runs
// x 95→98.33 descending; 0x0183 continues at z 9 beyond x=98.33.
// Eye at standing height (~1.7 m above the 6 floor), approaching the
// stair portal at x=95, gazing +X with a slight downward pitch (the
// natural look at a descending stair).
foreach (float eyeX in new[] { 88f, 90f, 92f, 94f, 94.9f })
{
var eye = new Vector3(eyeX, -40f, -4.3f);
foreach (var (gaze, label) in new (Vector3, string)[]
{
(new Vector3(1f, 0f, 0f), "level"),
(Vector3.Normalize(new Vector3(1f, 0f, -0.35f)), "pitch-19"),
(Vector3.Normalize(new Vector3(1f, 0f, -0.7f)), "pitch-35"),
})
{
var visible = Flood(cells, FacilityHub | 0x0178u, eye, gaze);
bool ramp = visible.Contains(FacilityHub | 0x0182u);
bool lower = visible.Contains(FacilityHub | 0x0183u);
_out.WriteLine($"eyeX={eyeX,5:F1} gaze={label,-8} flood={visible.Count,2} " +
$"ramp0182={(ramp ? "Y" : "MISSING")} lower0183={(lower ? "Y" : "MISSING")} " +
$"[{CellSetString(visible)}]");
}
}
}
[Fact]
public void ScenarioB_StairDescent_RampCellRetention()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
// Descend the ramp: floor z goes 6 (x=95) → 9 (x=98.33), then flat.
// Eye rides ~1.7 m above the local floor. Root = the cell containing
// the eye by x-range (0x0178 x<95, ramp 0x0182 95..98.33, 0x0183 after).
// Gaze: forward and slightly down (running down stairs).
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.4f));
List<uint>? prev = null;
for (float x = 94.0f; x <= 100.5f; x += 0.1f)
{
float floorZ = x < 95f ? -6f
: x < 98.333f ? -6f - 3f * (x - 95f) / 3.333f
: -9f;
var eye = new Vector3(x, -40f, floorZ + 1.7f);
uint rootId = x < 95f ? FacilityHub | 0x0178u
: x < 98.333f ? FacilityHub | 0x0182u
: FacilityHub | 0x0183u;
var visible = Flood(cells, rootId, eye, gazeDir);
bool ramp = visible.Contains(FacilityHub | 0x0182u);
bool upper = visible.Contains(FacilityHub | 0x0178u);
string diff = "";
if (prev is not null)
{
var removed = prev.Except(visible).ToList();
var added = visible.Except(prev).ToList();
if (removed.Count > 0) diff += $" REMOVED=[{CellSetString(removed)}]";
if (added.Count > 0) diff += $" added=[{CellSetString(added)}]";
}
_out.WriteLine($"x={x,6:F1} root={rootId & 0xFFFFu:X4} eyeZ={eye.Z,6:F2} flood={visible.Count,2} " +
$"ramp0182={(ramp ? "Y" : "MISSING")} up0178={(upper ? "Y" : "-")}{diff}");
prev = visible;
}
}
[Fact]
public void ScenarioC_CorridorSeamGazeSweep_Bistability()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
// Parked eye in 0x016E near the 0x017A seam (x=85), standing height.
var eye = new Vector3(83.5f, -40f, -4.3f);
uint rootId = FacilityHub | 0x016Eu;
int churnEvents = 0;
foreach (float pitchZ in new[] { 0f, -0.35f, -0.75f, -1.2f })
{
List<uint>? prevSet = null;
float prevYaw = 0f;
var perYawSets = new List<(float yaw, List<uint> set)>();
for (float yawDeg = -180f; yawDeg <= 180f; yawDeg += 2f)
{
float rad = yawDeg * MathF.PI / 180f;
var gaze = Vector3.Normalize(new Vector3(MathF.Cos(rad), MathF.Sin(rad), pitchZ));
var visible = Flood(cells, rootId, eye, gaze);
perYawSets.Add((yawDeg, visible));
if (prevSet is not null)
{
var removed = prevSet.Except(visible).ToList();
var added = visible.Except(prevSet).ToList();
if (removed.Count > 0 || added.Count > 0)
{
_out.WriteLine($"pitch={pitchZ,5:F2} yaw {prevYaw,6:F0}->{yawDeg,6:F0}: " +
$"flood {prevSet.Count}->{visible.Count}" +
(removed.Count > 0 ? $" REMOVED=[{CellSetString(removed)}]" : "") +
(added.Count > 0 ? $" added=[{CellSetString(added)}]" : ""));
}
}
prevSet = visible;
prevYaw = yawDeg;
}
// Bistability: a cell present at yaw k, absent at k+1, present at k+2.
for (int i = 2; i < perYawSets.Count; i++)
{
var flicker = perYawSets[i - 2].set
.Intersect(perYawSets[i].set)
.Except(perYawSets[i - 1].set)
.ToList();
if (flicker.Count > 0)
{
churnEvents++;
_out.WriteLine($">>> BISTABLE pitch={pitchZ:F2} yaw={perYawSets[i - 1].yaw:F0}: " +
$"cells [{CellSetString(flicker)}] dropped for ONE 2-degree step");
}
}
}
_out.WriteLine($"bistable one-step drop events: {churnEvents}");
}
/// <summary>
/// THE production lag-window scenario (from launch-137-gate2.log
/// [cell-transit] lines): membership transits fire 0.10.6 m PAST the
/// portal plane in the travel direction (016E→017A at x=85.3385.47 vs
/// the plane at x=85.00; 0182→0183 at 98.5698.64 vs 98.33). The render
/// root (viewer cell, same membership machinery) therefore holds the OLD
/// cell while the camera eye is already beyond the boundary portal's
/// plane. This scenario reproduces exactly that window: root=old cell,
/// eye stepped across and past the plane, gaze forward. If the forward
/// chain (the next corridor cells) drops inside the window, that is #176
/// (purple = fog clear color where the forward cells' geometry was) and
/// #177(a)/(c) at the stair transit.
/// </summary>
[Theory]
[InlineData(0x016Eu, 0x017Au, 85.00f, -4.3f)] // corridor seam, plane x=85
[InlineData(0x0182u, 0x0183u, 98.333f, -7.3f)] // stair-bottom transit, plane x=98.33
public void ScenarioE_RootLagWindow_ForwardChainRetention(
uint rootLow, uint forwardLow, float planeX, float eyeZ)
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
uint rootId = FacilityHub | rootLow;
uint forwardId = FacilityHub | forwardLow;
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.3f));
_out.WriteLine($"root=0x{rootLow:X4} forward=0x{forwardLow:X4} plane x={planeX:F2} " +
"(eye sweeps across; root HELD at the old cell = the production lag window)");
foreach (float dx in new[] { -0.30f, -0.10f, -0.02f, 0.00f, 0.02f, 0.05f, 0.10f, 0.20f, 0.30f, 0.45f, 0.60f })
{
var eye = new Vector3(planeX + dx, -40f, eyeZ);
var visible = Flood(cells, rootId, eye, gazeDir);
bool fwd = visible.Contains(forwardId);
_out.WriteLine($" eyeX=plane{(dx >= 0 ? "+" : "")}{dx:F2} flood={visible.Count,2} " +
$"forward={(fwd ? "Y" : ">>> DROPPED <<<")} [{CellSetString(visible)}]");
}
}
[Fact]
public void ScenarioD_CorridorWalk_PerStepChurn()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
// Walk 0x0165 → 0x016E → 0x017A along y=40 (seams at x=75 and x=85),
// gaze locked +X, slight downward pitch (the running view). Root flips
// by x-range at the seams (the camera transits the same portals).
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.3f));
List<uint>? prev = null;
int churn = 0;
for (float x = 71f; x <= 89f; x += 0.05f)
{
uint rootId = x < 75f ? FacilityHub | 0x0165u
: x < 85f ? FacilityHub | 0x016Eu
: FacilityHub | 0x017Au;
if (!cells.ContainsKey(rootId)) continue;
var eye = new Vector3(x, -40f, -4.3f);
var visible = Flood(cells, rootId, eye, gazeDir);
if (prev is not null)
{
var removed = prev.Except(visible).ToList();
var added = visible.Except(prev).ToList();
if (removed.Count > 0 || added.Count > 0)
{
churn++;
_out.WriteLine($"x={x,6:F2} root={rootId & 0xFFFFu:X4} flood={visible.Count,2}" +
(removed.Count > 0 ? $" REMOVED=[{CellSetString(removed)}]" : "") +
(added.Count > 0 ? $" added=[{CellSetString(added)}]" : ""));
}
}
prev = visible;
}
_out.WriteLine($"admitted-set change events over the walk: {churn}");
}
}

View file

@ -0,0 +1,560 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
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>
/// #177 decisive full-chain replay: drive the REAL <see cref="RetailChaseCamera"/>
/// + REAL <see cref="PhysicsCameraCollisionProbe"/> down the Facility Hub
/// staircase against the REAL BSP, then feed the swept (ViewerCellId, eye,
/// View*Projection) into <see cref="PortalVisibilityBuilder.Build"/> — exactly
/// the production coupling (GameWindow ~9055-9065 + RetailPViewRenderer.DrawInside).
///
/// The earlier Issue176177 replays fed SYNTHETIC (root, eye) pairs: Scenario A
/// admitted the stairs fine from a coherent corridor root, Scenario E collapsed
/// the flood 12->1 but only because it HELD the root at the old cell while the
/// eye crossed a portal plane. This test removes the assumption: the root IS the
/// swept camera cell, the eye IS the swept camera eye, both from the same sweep.
/// If the stairs still collapse, #177 is a production root/eye coupling defect
/// and the printed per-portal side-test D names the failing gate. If they stay
/// admitted, the flood is exonerated and the artifact lives downstream.
/// </summary>
public class Issue177StairDescentCameraFloodTests
{
private const uint FacilityHub = 0x8A020000u;
private readonly ITestOutputHelper _out;
public Issue177StairDescentCameraFloodTests(ITestOutputHelper output) => _out = output;
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 BuildHubEngine(DatCollection dats)
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
for (uint low = 0x0100u; low <= 0x01FFu; low++)
{
uint id = FacilityHub | 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(FacilityHub, new TerrainSurface(heights, heightTable),
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
return engine;
}
// Analytic ramp floor from Scenario B: flat -6 (x<95), descends to -9 across
// x 95..98.333, then flat -9.
private static float FloorZ(float x)
=> x < 95f ? -6f
: x < 98.333f ? -6f - 3f * (x - 95f) / 3.333f
: -9f;
// Replicated side test (PortalVisibilityBuilder.CameraOnInteriorSide is private).
private const float PortalSideEpsilon = 0.01f;
private static bool EyeInteriorSide(LoadedCell cell, int i, Vector3 eye)
{
if (i >= cell.ClipPlanes.Count) return true;
var pl = cell.ClipPlanes[i];
if (pl.Normal.LengthSquared() < 1e-8f) return true;
var local = Vector3.Transform(eye, cell.InverseWorldTransform);
float dot = Vector3.Dot(pl.Normal, local) + pl.D;
return pl.InsideSide == 0 ? dot >= -PortalSideEpsilon : dot <= PortalSideEpsilon;
}
private static float SideD(LoadedCell cell, int i, Vector3 eye)
{
if (i >= cell.ClipPlanes.Count) return float.NaN;
var pl = cell.ClipPlanes[i];
if (pl.Normal.LengthSquared() < 1e-8f) return float.NaN;
var local = Vector3.Transform(eye, cell.InverseWorldTransform);
return Vector3.Dot(pl.Normal, local) + pl.D;
}
private static string CellSet(IEnumerable<uint> ids)
=> string.Join(" ", ids.Select(id => $"{id & 0xFFFFu:X4}"));
[Fact]
public void StairCellComposition_ShellVsStatics()
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
foreach (uint low in new uint[] { 0x01C8, 0x01C4, 0x01C9, 0x0210, 0x020E, 0x01C1, 0x01C0 })
{
uint id = FacilityHub | low;
var envCell = dats.Get<DatEnvCell>(id);
if (envCell is null) { _out.WriteLine($"0x{low:X4}: (no dat cell)"); continue; }
var environment = dats.Get<DatEnvironment>(0x0D000000u | envCell.EnvironmentId);
environment!.Cells.TryGetValue(envCell.CellStructure, out var cs);
int polys = cs?.Polygons.Count ?? -1;
int verts = cs?.VertexArray.Vertices.Count ?? -1;
var mn = new Vector3(float.MaxValue); var mx = new Vector3(float.MinValue);
if (cs is not null)
foreach (var kv in cs.VertexArray.Vertices)
{ var o = kv.Value.Origin; var v = new Vector3(o.X, o.Y, o.Z); mn = Vector3.Min(mn, v); mx = Vector3.Max(mx, v); }
int statics = envCell.StaticObjects?.Count ?? 0;
bool seenOut = (envCell.Flags & DatReaderWriter.Enums.EnvCellFlags.SeenOutside) != 0;
_out.WriteLine(FormattableString.Invariant(
$"0x{low:X4} env=0x{envCell.EnvironmentId:X4} struct={envCell.CellStructure} shellPolys={polys} shellVerts={verts} bounds=[{mn.X:F1},{mn.Y:F1},{mn.Z:F1}]..[{mx.X:F1},{mx.Y:F1},{mx.Z:F1}] portals={envCell.CellPortals.Count} statics={statics} seenOut={seenOut}"));
if (envCell.StaticObjects is not null)
foreach (var stab in envCell.StaticObjects)
{
var o = stab.Frame.Origin;
_out.WriteLine(FormattableString.Invariant(
$" static Id=0x{stab.Id:X8} kind={(stab.Id >> 24 == 0x02 ? "Setup" : stab.Id >> 24 == 0x01 ? "GfxObj" : "?")} pos=({o.X:F2},{o.Y:F2},{o.Z:F2})"));
}
// Portal normals (local space) — horizontal (|Nz|~1) = FLOOR/CEILING portal
// (the edge-on-clip suspect); vertical (|Nz|~0) = wall/doorway portal.
var lc = renderLookup(id);
if (lc is not null)
for (int i = 0; i < lc.Portals.Count; i++)
{
var n = i < lc.ClipPlanes.Count ? lc.ClipPlanes[i].Normal : Vector3.Zero;
string kind = MathF.Abs(n.Z) > 0.7f ? "FLOOR/CEIL" : MathF.Abs(n.Z) < 0.3f ? "wall" : "slope";
_out.WriteLine(FormattableString.Invariant(
$" portal[{i}]->0x{lc.Portals[i].OtherCellId:X4} N=({n.X:F2},{n.Y:F2},{n.Z:F2}) {kind} inside={lc.ClipPlanes[i].InsideSide}"));
}
}
LoadedCell? renderLookup(uint id)
{
try { return CornerFloodReplayTests.LoadCell(dats, id); }
catch (InvalidOperationException) { return null; }
}
}
/// <summary>
/// #177 re-diagnosis (fix#1 failed the visual gate): the vanish flips on a SLIGHT turn +
/// depends on zoom, so it is a gaze/eye knife-edge, not the eye-squarely-in-opening case
/// fix#1 handled. Drive the REAL camera at a fixed player spot on the 015F stairs, sweep
/// yaw FINELY at two zooms, and print the viewer cell + stair-cell admission each step to
/// locate the knife-edge and which cell flips.
/// </summary>
[Fact]
public void RealStaircase_FineYawZoomSweep_FindKnifeEdge()
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var renderCells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
var engine = BuildHubEngine(dats);
Func<uint, LoadedCell?> lookup = id => renderCells.TryGetValue(id, out var c) ? c : null;
bool sC = CameraDiagnostics.CollideCamera; bool sA = CameraDiagnostics.AlignToSlope;
try
{
CameraDiagnostics.CollideCamera = true; CameraDiagnostics.AlignToSlope = true;
CameraDiagnostics.TranslationStiffness = 0.45f; CameraDiagnostics.RotationStiffness = 0.45f;
uint[] stair = { 0x015Eu, 0x015Fu, 0x01C1u, 0x01C0u, 0x020Fu };
// Player standing mid-015F stairs, eye near the ceiling boundary (per the sweep char).
foreach (var playerPos in new[] { new Vector3(60.5f, -46f, -2.5f), new Vector3(60.5f, -47f, -3.0f) })
foreach (float dist in new[] { 2.61f, 12.0f })
{
_out.WriteLine(FormattableString.Invariant(
$"=== player=({playerPos.X:F1},{playerPos.Y:F1},{playerPos.Z:F1}) zoom={dist} ==="));
var cam = new RetailChaseCamera
{
CollisionProbe = new PhysicsCameraCollisionProbe(engine),
FovY = MathF.PI / 3f, Aspect = 16f / 9f, Distance = dist,
};
uint prevViewer = 0; string prevAdmit = "";
for (float yawDeg = 0f; yawDeg < 360f; yawDeg += 6f)
{
float yaw = yawDeg * MathF.PI / 180f;
var (pcell, _) = engine.AdjustPosition(FacilityHub | 0x015Fu, playerPos + new Vector3(0, 0, 0.1f));
for (int i = 0; i < 160; i++)
cam.Update(playerPos, yaw, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f, pcell, 0x5000000A);
var eye = cam.Position; uint viewer = cam.ViewerCellId;
string admit = "?";
if (viewer != 0u && renderCells.TryGetValue(viewer, out var rc))
{
var f = PortalVisibilityBuilder.Build(rc, eye, lookup, cam.View * cam.Projection, null, PortalVisibilityBuilder.ShellDrawLiftZ);
var s = f.OrderedVisibleCells;
admit = string.Concat(stair.Select(low => (s.Contains(FacilityHub | low) ? "1" : "0")));
}
// Print only when viewer or the admit pattern CHANGES (knife-edges).
if (viewer != prevViewer || admit != prevAdmit)
{
_out.WriteLine(FormattableString.Invariant(
$" yaw={yawDeg,3:F0} eye=({eye.X,6:F2},{eye.Y,6:F2},{eye.Z,6:F2}) viewer=0x{viewer & 0xFFFF:X4} stair[5E,5F,C1,C0,0F]={admit}"));
prevViewer = viewer; prevAdmit = admit;
}
}
}
}
finally { CameraDiagnostics.CollideCamera = sC; CameraDiagnostics.AlignToSlope = sA; }
}
/// <summary>
/// #177 retail-vs-us flood DEPTH: retail's cdb capture shows PView cell_draw_num reaching 26
/// cells from camera cell 015E (avg 14.3) looking down the spiral shaft. Sweep OUR real camera
/// from 015E over many gaze/zoom poses and report the max/avg OrderedVisibleCells — if ours
/// caps well below retail's 26, our flood truncates the spiral (the vanish).
/// </summary>
[Fact]
public void FloodDepthFrom015E_VsRetail26()
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var renderCells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
var engine = BuildHubEngine(dats);
Func<uint, LoadedCell?> lookup = id => renderCells.TryGetValue(id, out var c) ? c : null;
bool sC = CameraDiagnostics.CollideCamera; bool sA = CameraDiagnostics.AlignToSlope;
try
{
CameraDiagnostics.CollideCamera = true; CameraDiagnostics.AlignToSlope = true;
CameraDiagnostics.TranslationStiffness = 0.45f; CameraDiagnostics.RotationStiffness = 0.45f;
int maxCells = 0; uint bestViewer = 0; double sum = 0; int n = 0; string bestSet = "";
foreach (var playerPos in new[] { new Vector3(61f, -42f, -4f), new Vector3(60f, -41f, -5f), new Vector3(62f, -43f, -4f) })
foreach (float dist in new[] { 2.61f, 4f, 8f })
foreach (float pitch in new[] { 0.291f, 0.7f, -0.2f })
{
var cam = new RetailChaseCamera { CollisionProbe = new PhysicsCameraCollisionProbe(engine), FovY = MathF.PI / 3f, Aspect = 16f / 9f, Distance = dist, Pitch = pitch };
for (float yawDeg = 0f; yawDeg < 360f; yawDeg += 10f)
{
float yaw = yawDeg * MathF.PI / 180f;
var (pcell, _) = engine.AdjustPosition(FacilityHub | 0x015Eu, playerPos + new Vector3(0, 0, 0.1f));
for (int i = 0; i < 140; i++) cam.Update(playerPos, yaw, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f, pcell, 0x5000000A);
uint viewer = cam.ViewerCellId;
if (viewer == 0u || !renderCells.TryGetValue(viewer, out var rc)) continue;
var f = PortalVisibilityBuilder.Build(rc, cam.Position, lookup, cam.View * cam.Projection, null, PortalVisibilityBuilder.ShellDrawLiftZ);
int c = f.OrderedVisibleCells.Count; sum += c; n++;
if (c > maxCells) { maxCells = c; bestViewer = viewer; bestSet = CellSet(f.OrderedVisibleCells.OrderBy(v => v)); }
}
}
_out.WriteLine(FormattableString.Invariant(
$"OUR flood from 015E-area: maxCells={maxCells} (retail max=26) avg={sum / Math.Max(1, n):F1} (retail avg=14.3) bestViewer=0x{bestViewer & 0xFFFF:X4}"));
_out.WriteLine($" best set: [{bestSet}]");
}
finally { CameraDiagnostics.CollideCamera = sC; CameraDiagnostics.AlignToSlope = sA; }
}
private static Matrix4x4 ViewProj(Vector3 eye, Vector3 dir)
=> Matrix4x4.CreateLookAt(eye, eye + Vector3.Normalize(dir), Vector3.UnitZ)
* Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 0.1f, 5000f);
// Signed distance of a WORLD point to a render cell's portal[i] plane (local-space plane).
private static float PortalPlaneDistance(LoadedCell cell, int i, Vector3 worldPt)
{
var pl = cell.ClipPlanes[i];
var local = Vector3.Transform(worldPt, cell.InverseWorldTransform);
return Vector3.Dot(pl.Normal, local) + pl.D;
}
/// <summary>
/// #177 THE decisive experiment. Reproduce the captured failing frame — camera in
/// cell 0x015F with the eye sitting IN the ceiling-portal plane (production [flap]:
/// root=015F eye=(60.47,-46.57,-0.00), p->01C1 clip=0) — and confirm the upper
/// stair cell 01C1 drops from the flood. Then move the SAME eye 0.4 m off that
/// plane (into the cell interior) and confirm 01C1 is re-admitted. If the contrast
/// holds, #177's fix is a viewpoint-off-the-portal-plane rule, and the load-bearing
/// clip (#119/#181) is correct as-is.
/// </summary>
[Fact]
public void EdgeOnCeilingPortal_DropsUpperCell_OffPlaneReadmits()
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
var root = cells[FacilityHub | 0x015Fu];
uint upperId = FacilityHub | 0x01C1u;
// Find 015F's portal to 01C1 (the ceiling/up portal) + report its normal.
int upIdx = -1;
for (int i = 0; i < root.Portals.Count; i++)
if (root.Portals[i].OtherCellId == 0x01C1u) { upIdx = i; break; }
Assert.True(upIdx >= 0, "015F has no portal to 01C1");
var upN = root.ClipPlanes[upIdx].Normal;
_out.WriteLine(FormattableString.Invariant(
$"015F portal[{upIdx}]->01C1 localN=({upN.X:F2},{upN.Y:F2},{upN.Z:F2})"));
// Look UP the stairs so the ceiling portal + 01C1 land on screen.
var gaze = new Vector3(0f, -0.3f, 1f);
// Captured FAILING eye — in the ceiling-portal plane (world z ~0).
var eyeEdgeOn = new Vector3(60.47f, -46.57f, -0.00f);
// Same eye, pulled 0.4 m DOWN (off the ceiling portal, into 015F's interior).
var eyeOffPlane = eyeEdgeOn + new Vector3(0f, 0f, -0.40f);
foreach (var (label, eye) in new[] { ("edge-on(captured)", eyeEdgeOn), ("off-plane(-0.4z)", eyeOffPlane) })
{
float d = PortalPlaneDistance(root, upIdx, eye);
var frame = PortalVisibilityBuilder.Build(
root, eye, lookup, ViewProj(eye, gaze),
buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
var vis = frame.OrderedVisibleCells.OrderBy(v => v).ToList();
bool up = vis.Contains(upperId);
string admit = up ? "ADMITTED" : "DROPPED";
_out.WriteLine(FormattableString.Invariant(
$"{label,-18} eye=({eye.X,6:F2},{eye.Y,6:F2},{eye.Z,6:F2}) D->01C1={d,6:F3} 01C1={admit} flood={vis.Count} [{CellSet(vis)}]"));
}
// #177 CHARACTERIZATION (fix#1 reverted — this edge-on-in-plane case was NOT the
// production mechanism; the real vanish is a grazing/sliver flood collapse in the
// spiral, root 0x01C8, portals off-screen/sliver, camera NOT collision-jammed).
// Kept as a mechanism pin: an eye exactly in the ceiling-portal plane drops the upper
// cell (edge-on → <3 clip), an eye 0.4 m off admits it via the normal clip.
var fEdge = PortalVisibilityBuilder.Build(root, eyeEdgeOn, lookup, ViewProj(eyeEdgeOn, gaze), null, PortalVisibilityBuilder.ShellDrawLiftZ);
var fOff = PortalVisibilityBuilder.Build(root, eyeOffPlane, lookup, ViewProj(eyeOffPlane, gaze), null, PortalVisibilityBuilder.ShellDrawLiftZ);
Assert.DoesNotContain(upperId, fEdge.OrderedVisibleCells); // edge-on drops (mechanism)
Assert.Contains(upperId, fOff.OrderedVisibleCells); // off-plane admits (control)
}
/// <summary>
/// #177 sweep characterization: place the player on the 015F staircase and drive the
/// REAL chase camera + collision sweep, reporting where the eye lands relative to the
/// 015F ceiling-portal plane (world z=0). If the eye rests AT the plane (|D|~0) the
/// flood collapses (see EdgeOnCeilingPortal test); this shows whether that is a swept
/// contact leaving the eye on the surface vs free-space boom geometry.
/// </summary>
[Fact]
public void StaircaseSweep_EyeClearanceFromCeilingPortal()
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var renderCells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
var engine = BuildHubEngine(dats);
var root015F = renderCells[FacilityHub | 0x015Fu];
int upIdx = 0; // portal[0]->01C1 (ceiling)
bool sC = CameraDiagnostics.CollideCamera; bool sA = CameraDiagnostics.AlignToSlope;
try
{
CameraDiagnostics.CollideCamera = true; CameraDiagnostics.AlignToSlope = true;
CameraDiagnostics.TranslationStiffness = 0.45f; CameraDiagnostics.RotationStiffness = 0.45f;
// Player on the 015F stairs (world y~-46, ceiling portal at world z~0), swept
// at a range of heights + facings (up-stairs = -Y, down-stairs = +Y).
foreach (float pz in new[] { -1.0f, -1.5f, -2.0f, -2.5f, -3.0f, -3.5f })
foreach (var (fLabel, yaw) in new (string, float)[] { ("up(-Y)", -MathF.PI / 2f), ("down(+Y)", MathF.PI / 2f) })
{
var playerPos = new Vector3(60.5f, -46f, pz);
var (pcell, _) = engine.AdjustPosition(FacilityHub | 0x015Fu, playerPos + new Vector3(0, 0, 0.1f));
var cam = new RetailChaseCamera
{
CollisionProbe = new PhysicsCameraCollisionProbe(engine),
FovY = MathF.PI / 3f, Aspect = 16f / 9f,
};
for (int i = 0; i < 300; i++)
cam.Update(playerPos, yaw, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f, pcell, 0x5000000A);
var eye = cam.Position;
uint viewer = cam.ViewerCellId;
// D from the eye to 015F's ceiling-portal plane (only meaningful if viewer==015F).
float dCeil = PortalPlaneDistance(root015F, upIdx, eye);
_out.WriteLine(FormattableString.Invariant(
$"playerZ={pz,5:F1} face={fLabel,-8} pcell=0x{pcell & 0xFFFF:X4} eye=({eye.X,6:F2},{eye.Y,6:F2},{eye.Z,6:F2}) viewer=0x{viewer & 0xFFFF:X4} D->ceilPortal={dCeil,6:F3}"));
}
}
finally { CameraDiagnostics.CollideCamera = sC; CameraDiagnostics.AlignToSlope = sA; }
}
[Theory]
// spot label, player x, player cell, description
[InlineData(90.0f, 0x0178u, "TOP corridor 0178 (approach view)")]
[InlineData(100.0f, 0x0183u, "BOTTOM room 0183 (look-back view)")]
public void ParkedYawZoomSweep_StairAdmission(float px, uint pcell, string label)
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var renderCells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
var engine = BuildHubEngine(dats);
Func<uint, LoadedCell?> lookup = id => renderCells.TryGetValue(id, out var c) ? c : null;
bool sC = CameraDiagnostics.CollideCamera; bool sA = CameraDiagnostics.AlignToSlope;
float sT = CameraDiagnostics.TranslationStiffness, sR = CameraDiagnostics.RotationStiffness;
try
{
CameraDiagnostics.CollideCamera = true; CameraDiagnostics.AlignToSlope = true;
CameraDiagnostics.TranslationStiffness = 0.45f; CameraDiagnostics.RotationStiffness = 0.45f;
var playerPos = new Vector3(px, -40f, FloorZ(px));
uint playerCell = FacilityHub | pcell;
_out.WriteLine($"=== {label} @ ({px:F1},-40,{FloorZ(px):F2}) ===");
foreach (float dist in new[] { 2.61f, 8.0f })
{
_out.WriteLine($" -- zoom Distance={dist} --");
var cam = new RetailChaseCamera
{
CollisionProbe = new PhysicsCameraCollisionProbe(engine),
FovY = MathF.PI / 3f, Aspect = 16f / 9f, Distance = dist,
};
for (float yawDeg = 0f; yawDeg < 360f; yawDeg += 30f)
{
float yaw = yawDeg * MathF.PI / 180f;
// Settle at this yaw (static player).
for (int i = 0; i < 200; i++)
cam.Update(playerPos, yaw, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f, playerCell, 0x5000000A);
Vector3 eye = cam.Position; uint viewer = cam.ViewerCellId;
var vp = cam.View * cam.Projection;
if (viewer != 0u && renderCells.TryGetValue(viewer, out var rootCell))
{
var f = PortalVisibilityBuilder.Build(rootCell, eye, lookup, vp, null, PortalVisibilityBuilder.ShellDrawLiftZ);
var vis = f.OrderedVisibleCells.OrderBy(v => v).ToList();
string flags = $"0178={(vis.Contains(FacilityHub | 0x0178u) ? "Y" : "-")} 0182={(vis.Contains(FacilityHub | 0x0182u) ? "Y" : "-")} 0183={(vis.Contains(FacilityHub | 0x0183u) ? "Y" : "-")} 0181={(vis.Contains(FacilityHub | 0x0181u) ? "Y" : "-")}";
_out.WriteLine(FormattableString.Invariant(
$" yaw={yawDeg,3:F0} eye=({eye.X,6:F2},{eye.Y,6:F2},{eye.Z,6:F2}) viewer=0x{viewer & 0xFFFF:X4} {flags} flood={vis.Count} [{CellSet(vis)}]"));
}
else
_out.WriteLine(FormattableString.Invariant($" yaw={yawDeg,3:F0} eye=({eye.X,6:F2},{eye.Y,6:F2},{eye.Z,6:F2}) viewer=0x{viewer:X8} ROOT-MISSING"));
}
}
}
finally
{
CameraDiagnostics.CollideCamera = sC; CameraDiagnostics.AlignToSlope = sA;
CameraDiagnostics.TranslationStiffness = sT; CameraDiagnostics.RotationStiffness = sR;
}
}
[Fact]
public void Descent_RealCameraSweep_StairCellRetention()
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var renderCells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
var engine = BuildHubEngine(dats);
Func<uint, LoadedCell?> lookup = id => renderCells.TryGetValue(id, out var c) ? c : null;
bool savedColl = CameraDiagnostics.CollideCamera;
bool savedAlign = CameraDiagnostics.AlignToSlope;
float savedT = CameraDiagnostics.TranslationStiffness;
float savedR = CameraDiagnostics.RotationStiffness;
try
{
CameraDiagnostics.CollideCamera = true;
CameraDiagnostics.AlignToSlope = true;
CameraDiagnostics.TranslationStiffness = 0.45f;
CameraDiagnostics.RotationStiffness = 0.45f;
var cam = new RetailChaseCamera
{
CollisionProbe = new PhysicsCameraCollisionProbe(engine),
FovY = MathF.PI / 3f,
Aspect = 16f / 9f,
};
const float yaw = 0f; // facing +X, descending
const float speed = 3.0f; // m/s run
const float dt = 1f / 60f;
float startX = 88f, endX = 101.5f;
// Seat the player + settle the camera at the start pose.
uint playerCell = FacilityHub | 0x0178u;
Vector3 P(float x) => new Vector3(x, -40f, FloorZ(x));
for (int i = 0; i < 400; i++)
cam.Update(P(startX), yaw, Vector3.Zero, true, Vector3.UnitZ, dt, playerCell, 0x5000000A);
_out.WriteLine("x playerCell eye=(x,y,z) viewerCell coh flood stairs rootPortalsPastPlane");
float x = startX;
uint prevViewer = cam.ViewerCellId;
int collapses = 0, incoherentFrames = 0;
while (x <= endX)
{
var playerPos = P(x);
// Track player membership like production: AdjustPosition carry-forward.
var (pc, found) = engine.AdjustPosition(playerCell, playerPos + new Vector3(0, 0, 0.1f));
if (found) playerCell = pc;
cam.Update(playerPos, yaw, new Vector3(speed, 0, 0), true, Vector3.UnitZ, dt, playerCell, 0x5000000A);
Vector3 eye = cam.Position;
uint viewer = cam.ViewerCellId;
var viewProj = cam.View * cam.Projection;
string floodStr, stairs, coh, past;
if (viewer != 0u && renderCells.TryGetValue(viewer, out var rootCell))
{
var frame = PortalVisibilityBuilder.Build(
rootCell, eye, lookup, viewProj,
buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
var vis = frame.OrderedVisibleCells.OrderBy(v => v).ToList();
floodStr = $"{vis.Count,2} [{CellSet(vis)}]";
bool ramp = vis.Contains(FacilityHub | 0x0182u);
bool lower = vis.Contains(FacilityHub | 0x0183u);
stairs = $"r{(ramp ? "Y" : "-")}l{(lower ? "Y" : "-")}";
// Coherence: is the eye on the interior side of ALL of root's portals?
// If not, the eye is outside root -> the crossed portal's subtree drops.
var pastList = new List<string>();
bool coherent = true;
for (int i = 0; i < rootCell.Portals.Count && i < rootCell.ClipPlanes.Count; i++)
{
if (!EyeInteriorSide(rootCell, i, eye))
{
coherent = false;
pastList.Add($"p{i}->0x{rootCell.Portals[i].OtherCellId:X4}(D={SideD(rootCell, i, eye):F3})");
}
}
coh = coherent ? "IN " : "OUT";
past = pastList.Count == 0 ? "-" : string.Join(",", pastList);
if (!coherent) incoherentFrames++;
if (vis.Count <= 1) collapses++;
}
else
{
floodStr = "root-missing/0"; stairs = "--"; coh = "?"; past = $"viewer=0x{viewer:X8}";
}
bool viewerFlip = viewer != prevViewer;
prevViewer = viewer;
_out.WriteLine(FormattableString.Invariant(
$"{x,6:F2} 0x{playerCell:X8} ({eye.X,7:F3},{eye.Y,7:F3},{eye.Z,7:F3}) 0x{viewer:X8} {coh} {floodStr} {stairs} {(viewerFlip ? "FLIP " : "")}{past}"));
x += speed * dt;
}
_out.WriteLine($"SUMMARY: collapses(flood<=1)={collapses} incoherentFrames(eye-outside-root)={incoherentFrames}");
}
finally
{
CameraDiagnostics.CollideCamera = savedColl;
CameraDiagnostics.AlignToSlope = savedAlign;
CameraDiagnostics.TranslationStiffness = savedT;
CameraDiagnostics.RotationStiffness = savedR;
}
}
}

View file

@ -0,0 +1,100 @@
using System;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.Rendering;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #181 excitation probe — the live parked camera wanders ~0.9 mm/frame
/// (launch-176-leakfix.log [flap-sweep]: 19,889 distinct sought values in 20k
/// parked sweeps), which keeps the portal flood's knife-edge admissions
/// flapping. Retail's parked viewer is a bit-exact fixed point (UpdateCamera
/// dead-band `return viewer`).
///
/// This test drives RetailChaseCamera.Update with BIT-IDENTICAL inputs at the
/// live frame rate: if the camera parks bit-stable here, the live wobble comes
/// from its INPUTS (player position/yaw jitter out of GameWindow); if it
/// wobbles here, the camera loop itself fails to reach the fixed point.
/// </summary>
public class Issue181CameraParkStabilityTests
{
private readonly ITestOutputHelper _out;
public Issue181CameraParkStabilityTests(ITestOutputHelper output) => _out = output;
private sealed class PassthroughProbe : ICameraCollisionProbe
{
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos)
=> new(desiredEye, cellId);
}
[Fact]
public void ParkedCamera_StaticInputs_ReachesBitStableFixedPoint()
{
bool savedAlign = CameraDiagnostics.AlignToSlope;
bool savedColl = CameraDiagnostics.CollideCamera;
float savedT = CameraDiagnostics.TranslationStiffness;
float savedR = CameraDiagnostics.RotationStiffness;
try
{
CameraDiagnostics.AlignToSlope = true; // production default
CameraDiagnostics.CollideCamera = true;
CameraDiagnostics.TranslationStiffness = 0.45f;
CameraDiagnostics.RotationStiffness = 0.45f;
var cam = new RetailChaseCamera { CollisionProbe = new PassthroughProbe() };
// Live-like parked pose: static player, static yaw, zero velocity,
// grounded on a flat contact plane, ~1500 fps.
var playerPos = new Vector3(49.5f, -39.9f, -5.9f);
float yaw = 1.83f;
float dt = 1f / 1500f;
void Step() => cam.Update(
playerPosition: playerPos,
playerYaw: yaw,
playerVelocity: Vector3.Zero,
isOnGround: true,
contactPlaneNormal: Vector3.UnitZ,
dt: dt,
cellId: 0x8A020142u,
selfEntityId: 0x5);
// Converge: at α≈0.003/frame the boom needs a few thousand frames
// to settle from the init pose into the dead-band.
for (int i = 0; i < 20000; i++) Step();
Vector3 a = cam.Position;
var fwdA = cam.View; // full view matrix — includes the forward half
// 2000 further frames with bit-identical inputs: every one must be
// the exact fixed point (retail parks verbatim).
float maxDelta = 0f;
Vector3 prev = a;
bool viewChanged = false;
for (int i = 0; i < 2000; i++)
{
Step();
maxDelta = MathF.Max(maxDelta, Vector3.Distance(cam.Position, prev));
prev = cam.Position;
if (cam.View != fwdA) viewChanged = true;
}
_out.WriteLine(FormattableString.Invariant(
$"post-convergence maxConsecDelta={maxDelta * 1e6f:F2}um viewChanged={viewChanged} pos=({cam.Position.X:F7},{cam.Position.Y:F7},{cam.Position.Z:F7})"));
Assert.True(maxDelta == 0f,
$"parked camera must be a bit-exact fixed point, wandered up to {maxDelta * 1e6f:F1}um/frame");
Assert.False(viewChanged, "view matrix must be frozen at park");
}
finally
{
CameraDiagnostics.AlignToSlope = savedAlign;
CameraDiagnostics.CollideCamera = savedColl;
CameraDiagnostics.TranslationStiffness = savedT;
CameraDiagnostics.RotationStiffness = savedR;
}
}
}

View file

@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #181 — the portal-flood admitted set flaps 31↔32 on MICRON eye noise at a
/// parked camera (live: launch-176-leakfix.log, root 0x8A020142, vis flips
/// every ~100200 frames for 517k frames; the swept eye carries ~7 µm
/// float-roundtrip noise). The flapping cell's visibility-scoped lights + the
/// union-AABB scissor rect strobe = the #176 washed-region flicker.
///
/// This replay hunts the knife edge headlessly: at the live parked eye
/// (49.15, 38.62, 3.98), sweep gazes and perturb the eye by ±0.5 mm per
/// axis; ANY admitted-set difference at sub-mm perturbation is the #181
/// instability, and the symmetric difference names the flapping cell.
/// </summary>
public class Issue181VisFlapReplayTests
{
private const uint FacilityHub = 0x8A020000u;
private const uint LiveRoot = FacilityHub | 0x0142u;
private static readonly Vector3 LiveEye = new(49.15f, -38.62f, -3.98f);
private readonly ITestOutputHelper _out;
public Issue181VisFlapReplayTests(ITestOutputHelper output) => _out = output;
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 gazeDir)
{
var view = Matrix4x4.CreateLookAt(eye, eye + gazeDir, Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 0.1f, 5000f);
return view * proj;
}
private static List<uint> Flood(
Dictionary<uint, LoadedCell> cells, uint rootId, Vector3 eye, Vector3 gazeDir)
{
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
var frame = PortalVisibilityBuilder.Build(
cells[rootId], eye, lookup, ViewProjFor(eye, gazeDir),
buildingMembership: null,
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
var result = new List<uint>(frame.OrderedVisibleCells);
result.Sort();
return result;
}
private static string CellSetString(IEnumerable<uint> ids)
=> string.Join(" ", ids.Select(id => $"{id & 0xFFFFu:X4}"));
[Fact]
public void Diagnostic_FlappingCellViewRegion_SliverOrLarge()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
uint flapper = FacilityHub | 0x0181u;
// The most unstable pose from the perturbation sweep.
float yaw = 15f * MathF.PI / 180f, pitch = -20f * MathF.PI / 180f;
var gaze = new Vector3(
MathF.Cos(pitch) * MathF.Cos(yaw),
MathF.Cos(pitch) * MathF.Sin(yaw),
MathF.Sin(pitch));
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
foreach (var (label, eye) in new (string, Vector3)[]
{
("base ", LiveEye),
("+0.5mm x ", LiveEye + new Vector3(0.0005f, 0, 0)),
("-0.5mm x ", LiveEye - new Vector3(0.0005f, 0, 0)),
("+5mm x ", LiveEye + new Vector3(0.005f, 0, 0)),
("-5mm x ", LiveEye - new Vector3(0.005f, 0, 0)),
("+2cm z ", LiveEye + new Vector3(0, 0, 0.02f)),
("-2cm z ", LiveEye - new Vector3(0, 0, 0.02f)),
})
{
var frame = PortalVisibilityBuilder.Build(
cells[LiveRoot], eye, lookup, ViewProjFor(eye, gaze),
buildingMembership: null,
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
if (!frame.CellViews.TryGetValue(flapper, out var view) || view.IsEmpty)
{
_out.WriteLine($"{label}: 0181 NOT ADMITTED");
continue;
}
// NDC shoelace area of the admitted region (screen is 4.0 NDC-units total).
float area = 0f; int polys = 0, verts = 0;
foreach (var vp in view.Polygons)
{
var v = vp.Vertices;
polys++; verts += v.Length;
float a = 0f;
for (int i = 0; i < v.Length; i++)
{
var p = v[i]; var q = v[(i + 1) % v.Length];
a += p.X * q.Y - q.X * p.Y;
}
area += MathF.Abs(a) * 0.5f;
}
_out.WriteLine(FormattableString.Invariant(
$"{label}: 0181 admitted polys={polys} verts={verts} ndcArea={area:F4} ({area / 4f * 100f:F1}% of screen)"));
}
}
[Fact]
public void Diagnostic_SubMillimeterEyePerturbation_MustNotChangeAdmission()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
Assert.True(cells.ContainsKey(LiveRoot), "live root 0x0142 not loaded");
// ±0.5 mm per axis — an order of magnitude above the live µm noise, well
// below anything visually meaningful.
var perturbations = new[]
{
new Vector3( 0.0005f, 0, 0), new Vector3(-0.0005f, 0, 0),
new Vector3(0, 0.0005f, 0), new Vector3(0, -0.0005f, 0),
new Vector3(0, 0, 0.0005f), new Vector3(0, 0, -0.0005f),
};
int unstableGazes = 0;
for (int yawDeg = 0; yawDeg < 360; yawDeg += 15)
{
foreach (float pitchDeg in new[] { 0f, -20f, -35f })
{
float yaw = yawDeg * MathF.PI / 180f, pitch = pitchDeg * MathF.PI / 180f;
var gaze = new Vector3(
MathF.Cos(pitch) * MathF.Cos(yaw),
MathF.Cos(pitch) * MathF.Sin(yaw),
MathF.Sin(pitch));
var baseline = Flood(cells, LiveRoot, LiveEye, gaze);
foreach (var d in perturbations)
{
var perturbed = Flood(cells, LiveRoot, LiveEye + d, gaze);
if (perturbed.SequenceEqual(baseline)) continue;
unstableGazes++;
var removed = baseline.Except(perturbed).ToList();
var added = perturbed.Except(baseline).ToList();
_out.WriteLine(FormattableString.Invariant(
$"UNSTABLE yaw={yawDeg} pitch={pitchDeg} d=({d.X * 1000:F1},{d.Y * 1000:F1},{d.Z * 1000:F1})mm base={baseline.Count} pert={perturbed.Count} removed=[{CellSetString(removed)}] added=[{CellSetString(added)}]"));
}
}
}
_out.WriteLine($"unstable (gaze, perturbation) pairs: {unstableGazes}");
// Diagnostic first: report, don't assert — the fix turns this into a hard pin.
}
}

View file

@ -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;
/// <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;
}
}
}

View file

@ -579,31 +579,233 @@ public class RetailChaseCameraTests
}
[Fact]
public void Update_CollisionDoesNotCorruptDampedState()
public void Update_AfterClampReleases_EyeReExtendsGradually()
{
// Regression for the wall-press vibration: the sweep must NOT write its
// clamped result back into the damped "sought" eye (retail keeps
// viewer_sought_position separate from viewer). Frame 1 clamps the eye
// near the pivot; frame 2 releases. With the damp state kept clean, the
// published eye returns straight to the (constant) target on frame 2; if
// it were corrupted, frame 2 would only lerp ~7.5% back from the clamp
// and stay pinned near it.
CameraDiagnostics.CollideCamera = true;
var probe = new ClampThenReleaseProbe { ClampEye = new Vector3(0f, 0f, 2f) };
var cam = new RetailChaseCamera { CollisionProbe = probe };
// #180: retail's sought position is STATEFUL — CameraManager::UpdateCamera
// (0x00456660) interpolates from the CURRENT SWEPT VIEWER toward the desired
// pose, so after an obstruction clears the eye re-extends at the stiffness
// rate (τ ≈ 0.22 s), NOT in one jump. (The previous pin here asserted the
// instant full re-extension — the exact divergence that re-rolled the
// full-length knife-edge boom ray per frame and produced the strobe.)
bool savedColl = CameraDiagnostics.CollideCamera;
float savedT = CameraDiagnostics.TranslationStiffness;
float savedR = CameraDiagnostics.RotationStiffness;
try
{
CameraDiagnostics.CollideCamera = true;
CameraDiagnostics.TranslationStiffness = 0.45f;
CameraDiagnostics.RotationStiffness = 0.45f;
void Step() => cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
var probe = new ClampThenReleaseProbe { ClampEye = new Vector3(0f, 0f, 2f) };
var cam = new RetailChaseCamera { CollisionProbe = probe };
Step(); // frame 1: clamps to (0,0,2)
Step(); // frame 2: releases
void Step() => cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
// Constant pose → target eye ≈ (-2.5, 0, 2.25). Full recovery means
// Position.X is near the target (< -2), not pinned near the clamp (X≈0).
Assert.True(cam.Position.X < -2f,
$"published eye should fully recover to the target after release, got {cam.Position}");
Step(); // frame 1: clamps to (0,0,2) — the viewer sits at the clamp
Step(); // frame 2: releases
// Constant pose → target eye ≈ (-2.5, 0, 2.25). Frame 2's eye is one
// 7.5% lerp step off the clamp (X ≈ -0.19) — near the clamp, NOT the
// full target.
Assert.True(cam.Position.X > -0.5f,
$"eye must re-extend gradually from the contact, got {cam.Position}");
// ...and converges onto the target over the following seconds.
for (int i = 0; i < 200; i++) Step();
Assert.True(cam.Position.X < -2.4f,
$"eye should converge to the target after release, got {cam.Position}");
}
finally
{
CameraDiagnostics.CollideCamera = savedColl;
CameraDiagnostics.TranslationStiffness = savedT;
CameraDiagnostics.RotationStiffness = savedR;
}
}
// Probe that records every requested sweep target and clamps the eye to a
// fixed contact point (a wall the boom is pressed into).
private sealed class RecordingClampProbe : ICameraCollisionProbe
{
public readonly System.Collections.Generic.List<Vector3> Requests = new();
public Vector3 ClampEye;
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos)
{
Requests.Add(desiredEye);
return new CameraSweepResult(ClampEye, cellId);
}
}
[Fact]
public void Update_SweepTargetConvergesOntoContact_NotTheFullBoom()
{
// THE #180 structural pin. Retail sweeps pivot → viewer_sought_position
// (SmartBox::update_viewer 0x00453ce0), and the sought derives from the
// swept viewer — so while pressed against a wall the requested sweep
// target sits ONE interpolation step past the contact. acdream's old shape
// re-requested the full-length ideal boom every frame; with mm input drift
// that full ray grazed distant geometry at r±ε and flipped its first-contact
// solution 0.27 m along the boom every few frames (the #176 stripes).
bool savedColl = CameraDiagnostics.CollideCamera;
float savedT = CameraDiagnostics.TranslationStiffness;
float savedR = CameraDiagnostics.RotationStiffness;
try
{
CameraDiagnostics.CollideCamera = true;
CameraDiagnostics.TranslationStiffness = 0.45f;
CameraDiagnostics.RotationStiffness = 0.45f;
var wall = new Vector3(0f, 0f, 2f);
var probe = new RecordingClampProbe { ClampEye = wall };
var cam = new RetailChaseCamera { CollisionProbe = probe };
void Step() => cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
Step(); // frame 1: init requests the full boom (AD-38), eye clamps to the wall
Step(); // frame 2: the request must now derive from the CLAMPED viewer
// Frame 1 documents the contrast: the init request is the full-length
// target, ~2.5 m from the contact.
Assert.True(Vector3.Distance(probe.Requests[0], wall) > 2f,
$"frame-1 init request should be the full boom, got {probe.Requests[0]}");
// Frame 2's request = one 7.5% lerp step off the wall (~0.19 m) — the
// knife-edge full-length ray is never re-rolled.
float reach = Vector3.Distance(probe.Requests[1], wall);
Assert.True(reach < 0.3f,
$"frame-2 sweep target must sit one step past the contact, got {reach:F3} m past it");
}
finally
{
CameraDiagnostics.CollideCamera = savedColl;
CameraDiagnostics.TranslationStiffness = savedT;
CameraDiagnostics.RotationStiffness = savedR;
}
}
// Probe that fails entirely on the FIRST call (retail set_viewer(player_pos, 1):
// eye = player, viewer_cell = 0), then releases; records requests.
private sealed class FallbackThenReleaseProbe : ICameraCollisionProbe
{
public readonly System.Collections.Generic.List<Vector3> Requests = new();
public int Calls;
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos)
{
Calls++;
Requests.Add(desiredEye);
return Calls == 1
? new CameraSweepResult(playerPos, 0u) // total fallback (pc:92886)
: new CameraSweepResult(desiredEye, cellId);
}
}
[Fact]
public void Update_TotalFallback_ReExtendsFromThePlayer()
{
// After the total sweep failure retail resets viewer AND sought to the
// player's position (set_viewer(player_pos, reset_sought=1)) — the camera
// re-extends outward from the head, so the next sweep target is NEAR THE
// PLAYER, not the full-length boom.
bool savedColl = CameraDiagnostics.CollideCamera;
float savedT = CameraDiagnostics.TranslationStiffness;
float savedR = CameraDiagnostics.RotationStiffness;
try
{
CameraDiagnostics.CollideCamera = true;
CameraDiagnostics.TranslationStiffness = 0.45f;
CameraDiagnostics.RotationStiffness = 0.45f;
var probe = new FallbackThenReleaseProbe();
var cam = new RetailChaseCamera { CollisionProbe = probe };
void Step() => cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
Step(); // frame 1: total fallback — viewer snaps to the player, cell 0
Assert.Equal(Vector3.Zero, cam.Position);
Assert.Equal(0u, cam.ViewerCellId);
Step(); // frame 2: the sweep target re-extends FROM the player
float reach = Vector3.Distance(probe.Requests[1], Vector3.Zero);
Assert.True(reach < 0.3f,
$"post-fallback sweep target must re-extend from the player, got {reach:F3} m out");
}
finally
{
CameraDiagnostics.CollideCamera = savedColl;
CameraDiagnostics.TranslationStiffness = savedT;
CameraDiagnostics.RotationStiffness = savedR;
}
}
// First-contact model of an infinite wall plane at X = WallX: requests whose
// ray from the pivot crosses the plane stop AT the plane; others pass through.
private sealed class WallPlaneProbe : ICameraCollisionProbe
{
public float WallX = -1f;
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos)
{
if (desiredEye.X >= WallX || desiredEye.X - pivot.X >= -1e-6f)
return new CameraSweepResult(desiredEye, cellId);
float t = (WallX - pivot.X) / (desiredEye.X - pivot.X);
return new CameraSweepResult(pivot + (desiredEye - pivot) * t, cellId);
}
}
[Fact]
public void Update_PressedAgainstWall_EyeGlidesStably()
{
// The wall-press equilibrium: the sought parks one step past the contact,
// the sweep clips it back, and the published eye stays glued to the wall
// with no oscillation — retail's glide. (This was the fear behind the old
// "must not feed back" comment; the retail shape is stable by construction
// because the sweep target converges instead of fighting the full boom.)
bool savedColl = CameraDiagnostics.CollideCamera;
float savedT = CameraDiagnostics.TranslationStiffness;
float savedR = CameraDiagnostics.RotationStiffness;
try
{
CameraDiagnostics.CollideCamera = true;
CameraDiagnostics.TranslationStiffness = 0.45f;
CameraDiagnostics.RotationStiffness = 0.45f;
var cam = new RetailChaseCamera { CollisionProbe = new WallPlaneProbe { WallX = -1f } };
void Step() => cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
// Settle a few frames, then watch 30 frames for per-frame jumps.
for (int i = 0; i < 5; i++) Step();
Vector3 prev = cam.Position;
float maxDelta = 0f;
for (int i = 0; i < 30; i++)
{
Step();
maxDelta = MathF.Max(maxDelta, Vector3.Distance(cam.Position, prev));
prev = cam.Position;
}
Assert.True(MathF.Abs(cam.Position.X - (-1f)) < 1e-3f,
$"eye should sit on the wall plane, got {cam.Position}");
Assert.True(maxDelta < 1e-3f,
$"pressed against a wall the eye must not jump frame-to-frame, max delta {maxDelta:F5} m");
}
finally
{
CameraDiagnostics.CollideCamera = savedColl;
CameraDiagnostics.TranslationStiffness = savedT;
CameraDiagnostics.RotationStiffness = savedR;
}
}
// ── Convergence snap (Part 1: kills the at-rest boom drift) ────────