The dungeon render pair (purple seam flash + stair pop-in) resisted desk root-causing, but the investigation narrowed the space to two live theories and shipped permanent apparatus: - Issue176177DungeonSeamInspectionTests: dat truth — corridor floors ARE textured drawn PortalSide portal polys; the reciprocal is NoPos; the 'stairs' are 0x8A020182's ramp shell (vertical portals, zero statics); CellBSP partitions exactly at portal planes; DXT1 textures carry zero transparent-mode texels. - Issue176177FacilityHubFloodReplayTests: production-matched flood replays — approach/descent/gaze-sweep/walk all healthy with coherent inputs; ScenarioE pins the flood's collapse-to-root sensitivity under incoherent (root, eye) pairs. - Issue176177SeamTransitLagTests: the resolver flips cells within one tick-step of the portal plane — the 0.33-0.47m [cell-transit] 'lag' in the gate logs is speed*tick quantization, not membership error. Refuted (do NOT retry — ledger in the research doc): placeholder texture, reciprocal z-fight, seal z-fight (seals only fire for OtherCellId==0xFFFF), root/eye incoherence (production camera sweep is mm-exact at planes), flood bistability, #119-class statics, undefined DXT mips (both paths decode DXT->RGBA8; the compressed-array branch is dead code), DXT1 alpha, fog mix (ramp ~538m), lightning leak (flash==0 in production), viewer-light pops (smooth (1-d/range) ramp). Filed #178 (A8 double-sided shell stopgap still live) and #179 (lightning flash lacks an indoor gate — dormant). The purple can only be the fog clear color (undrawn pixels) or the outdoor ambient+sun tint; discrimination needs ONE probe launch (ACDREAM_PROBE_LIGHT + ACDREAM_PROBE_PVINPUT + ACDREAM_PROBE_CELL) — protocol in docs/research/2026-07-06-176-177-render-pair-investigation.md. Suites: Core 2587 / App 719 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
157 lines
6.5 KiB
C#
157 lines
6.5 KiB
C#
using System;
|
||
using System.IO;
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
using DatReaderWriter;
|
||
using DatReaderWriter.DBObjs;
|
||
using DatReaderWriter.Options;
|
||
using Xunit;
|
||
using Xunit.Abstractions;
|
||
using Env = System.Environment;
|
||
|
||
namespace AcDream.Core.Tests.Physics;
|
||
|
||
/// <summary>
|
||
/// #176/#177 membership half: production [cell-transit] lines
|
||
/// (launch-137-gate2.log) fire 0.1–0.6 m PAST the portal plane in the travel
|
||
/// direction (016E→017A at x=85.33–85.47 vs the plane at x=85.00), while the
|
||
/// dat CellBSP volumes partition EXACTLY at the plane
|
||
/// (Issue176177DungeonSeamInspectionTests.SeamCells_CellBspContainment) —
|
||
/// retail's center-only point_in_cell flips at the plane. The render root
|
||
/// (viewer cell) resolves through the same machinery; while it lags, the
|
||
/// portal flood correctly refuses the boundary portal the eye has already
|
||
/// crossed and the whole forward chain drops (the purple seam flash /
|
||
/// stair pop). This replay measures OUR resolver's flip point across the
|
||
/// x=85 seam in a controlled run.
|
||
/// </summary>
|
||
public class Issue176177SeamTransitLagTests
|
||
{
|
||
private const uint SeamCellWest = 0x8A02016Eu; // x 75..85
|
||
private const uint SeamCellEast = 0x8A02017Au; // x 85..88.33
|
||
|
||
private readonly ITestOutputHelper _out;
|
||
public Issue176177SeamTransitLagTests(ITestOutputHelper output) => _out = output;
|
||
|
||
private static string? ResolveDatDir()
|
||
{
|
||
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||
"Documents", "Asheron's Call");
|
||
return Directory.Exists(datDir) ? datDir : null;
|
||
}
|
||
|
||
private static PhysicsEngine BuildEngine(DatCollection dats)
|
||
{
|
||
var engine = new PhysicsEngine();
|
||
engine.DataCache = new PhysicsDataCache();
|
||
|
||
var toLoad = new System.Collections.Generic.HashSet<uint> { SeamCellWest, SeamCellEast };
|
||
for (int ring = 0; ring < 3; ring++)
|
||
{
|
||
foreach (var known in new System.Collections.Generic.List<uint>(toLoad))
|
||
{
|
||
var cell = dats.Get<EnvCell>(known);
|
||
if (cell is null) continue;
|
||
foreach (var p in cell.CellPortals)
|
||
toLoad.Add(0x8A020000u | p.OtherCellId);
|
||
}
|
||
}
|
||
|
||
foreach (var cellId in toLoad)
|
||
{
|
||
var envCell = dats.Get<EnvCell>(cellId);
|
||
if (envCell is null) continue;
|
||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
|
||
if (environment is null) continue;
|
||
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) continue;
|
||
|
||
var rot = new Quaternion(
|
||
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
|
||
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
|
||
var world = Matrix4x4.CreateFromQuaternion(rot)
|
||
* Matrix4x4.CreateTranslation(
|
||
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
|
||
|
||
engine.DataCache.CacheCellStruct(cellId, envCell, cs!, world);
|
||
}
|
||
return engine;
|
||
}
|
||
|
||
private static PhysicsBody GroundedBody()
|
||
{
|
||
var body = new PhysicsBody();
|
||
body.ContactPlaneValid = true;
|
||
body.ContactPlane = new Plane(Vector3.UnitZ, 6f);
|
||
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||
body.WalkablePolygonValid = true;
|
||
body.WalkablePlane = new Plane(Vector3.UnitZ, 6f);
|
||
body.WalkableUp = Vector3.UnitZ;
|
||
body.WalkableVertices = new[]
|
||
{
|
||
new Vector3(75f, -41.67f, -6f),
|
||
new Vector3(85f, -41.67f, -6f),
|
||
new Vector3(85f, -38.33f, -6f),
|
||
new Vector3(75f, -38.33f, -6f),
|
||
};
|
||
return body;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Run +X across the x=85 seam at run-speed tick steps (13.5 cm/tick ≈
|
||
/// 4 m/s at 30 Hz) and record where ResolveWithTransition's CellId flips.
|
||
/// Retail (center-only point_in_cell, exact-partition CellBSP) flips on
|
||
/// the first tick whose END position is past x=85.00 — any flip later
|
||
/// than one step past the plane is OUR lag.
|
||
/// </summary>
|
||
[Theory]
|
||
[InlineData(+1)] // west → east across x=85
|
||
[InlineData(-1)] // east → west back across
|
||
public void RunAcrossSeam_CellFlipPosition(int direction)
|
||
{
|
||
var datDir = ResolveDatDir();
|
||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||
var engine = BuildEngine(dats);
|
||
var body = GroundedBody();
|
||
|
||
const float step = 0.135f;
|
||
float startX = direction > 0 ? 83.8f : 86.2f;
|
||
uint cell = direction > 0 ? SeamCellWest : SeamCellEast;
|
||
var pos = new Vector3(startX, -40f, -6f);
|
||
|
||
float? flipX = null;
|
||
for (int tick = 0; tick < 26; tick++)
|
||
{
|
||
var target = pos + new Vector3(direction * step, 0f, 0f);
|
||
var r = engine.ResolveWithTransition(
|
||
currentPos: pos,
|
||
targetPos: target,
|
||
cellId: cell,
|
||
sphereRadius: 0.48f,
|
||
sphereHeight: 1.835f,
|
||
stepUpHeight: 0.4f,
|
||
stepDownHeight: 0.4f,
|
||
isOnGround: true,
|
||
body: body,
|
||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
|
||
|
||
bool flipped = r.CellId != cell;
|
||
_out.WriteLine($"tick={tick,2} pos=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " +
|
||
$"cell=0x{r.CellId:X8} ok={r.Ok}{(flipped ? " <<< FLIP" : "")}");
|
||
if (flipped && flipX is null)
|
||
flipX = r.Position.X;
|
||
|
||
cell = r.CellId;
|
||
pos = r.Position;
|
||
if (direction > 0 && pos.X > 86.4f) break;
|
||
if (direction < 0 && pos.X < 83.6f) break;
|
||
}
|
||
|
||
Assert.NotNull(flipX);
|
||
float lag = direction > 0 ? flipX!.Value - 85.00f : 85.00f - flipX!.Value;
|
||
_out.WriteLine($"flip at x={flipX:F3} → lag past the plane = {lag:F3} m " +
|
||
$"(one-tick quantization bound = {step:F3} m)");
|
||
// Diagnostic, not a pin: the finding is the printed lag. A lag beyond
|
||
// one tick step is the divergence under investigation.
|
||
}
|
||
}
|