WalkProductionWorldData no longer floods; its indoor and outdoor static sweeps read ShadowObjectRegistry.TryGetRetailCellArray (retail's calc_cross_cells_static @0x00515160 -> AddPartsShadow @0x00517e40 CELLARRAY, computed once at registration). The App-owned render flood (ResolveStaticRenderCells, its fingerprint cache, the primitive-Setup special case) and Core's ComputeStaticRenderCells are deleted. The one remaining fallback, an entity the physics publisher has not registered yet while the projection journal already published it, buckets to the authored parent cell and is counted per frame (UnregisteredStaticRenderFallbackCount) for chunk 5 to judge on the connected route. The Facility stair pin now registers at the projection's own entity id (the old pure-function test never carried identity) and reads the retail array; the installed-DAT comparator compares retail against collision. Gates (run in the implementer's isolated worktree at identical content): Release build 0/0; Core Physics 2,202/2,202; App hermetic 6,760/6,760 (the two added WalkProductionWorldData tests); installed-DAT walk/flood/stair family 24/24; Runtime 1,884/1,884. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
727 lines
38 KiB
C#
727 lines
38 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Scene;
|
|
using AcDream.App.Rendering.Scene.Arch;
|
|
using AcDream.App.Rendering.Walk;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Rendering;
|
|
using AcDream.Core.World;
|
|
using AcDream.Core.Meshing;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.Options;
|
|
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
|
|
using DatEnvironment = DatReaderWriter.DBObjs.Environment;
|
|
using DatGfxObj = DatReaderWriter.DBObjs.GfxObj;
|
|
using DatSetup = DatReaderWriter.DBObjs.Setup;
|
|
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>
|
|
[Collection(CameraDiagnosticsCollection.Name)]
|
|
[Trait("Lane", "InstalledDat")]
|
|
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]
|
|
[Trait("Purpose", "Diagnostic")]
|
|
public void Diagnostic_StairCellComposition_ShellVsStatics()
|
|
{
|
|
var datDir = ResolveDatDir();
|
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
|
|
foreach (uint low in new uint[] { 0x015E, 0x015F, 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>
|
|
/// Campaign OVERHAUL S2 chunk 2: retargeted from the deleted
|
|
/// <c>ShadowObjectRegistry.ComputeStaticRenderCells</c> pure-function
|
|
/// side channel to the production
|
|
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> transaction
|
|
/// (the SAME BSP-collision + <c>partArray:</c> recipe
|
|
/// <c>LandblockPhysicsPublisher.PublishStaticEntity</c> uses, and the
|
|
/// same one <c>RetailCellArrayComparatorInstalledDatTests</c>' fixture 1
|
|
/// exercises for this exact Setup) plus
|
|
/// <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>. The entity
|
|
/// id now MUST match the projection's
|
|
/// <see cref="RenderSourceMetadata.LocalEntityId"/> — chunk 2's
|
|
/// <see cref="WalkProductionWorldData"/> indoor sweep looks the retail
|
|
/// CELLARRAY up BY that id, unlike the deleted method's seed+position
|
|
/// pure function, which never carried entity identity. The former
|
|
/// "WithoutCollisionRows" name no longer describes production: a part
|
|
/// array is always supplied at the SAME registration as the collision
|
|
/// shapes now (chunk 1b), so <c>GetOwnerCells</c> for this BSP-bearing
|
|
/// Setup reflects that registration — and, per the comparator's fixture-1
|
|
/// evidence, equals the retail CELLARRAY exactly.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FacilityStairAssembly_RegisterAcross015FTo015E()
|
|
{
|
|
string? datDir = ResolveDatDir();
|
|
if (datDir is null)
|
|
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
|
|
const uint setupId = 0x02000623u;
|
|
DatSetup setup = Assert.IsType<DatSetup>(dats.Get<DatSetup>(setupId));
|
|
PhysicsEngine engine = BuildHubEngine(dats);
|
|
PhysicsDataCache cache = Assert.IsType<PhysicsDataCache>(engine.DataCache);
|
|
IReadOnlyList<MeshRef> meshRefs = SetupMesh.Flatten(setup);
|
|
foreach (MeshRef meshRef in meshRefs)
|
|
{
|
|
DatGfxObj gfxObj = Assert.IsType<DatGfxObj>(
|
|
dats.Get<DatGfxObj>(meshRef.GfxObjId));
|
|
cache.CacheGfxObj(meshRef.GfxObjId, gfxObj);
|
|
}
|
|
|
|
DatEnvCell parent = Assert.IsType<DatEnvCell>(
|
|
dats.Get<DatEnvCell>(FacilityHub | 0x015Fu));
|
|
var stair = Assert.Single(
|
|
parent.StaticObjects,
|
|
staticObject =>
|
|
staticObject.Id == setupId);
|
|
var rootPosition = new Vector3(
|
|
stair.Frame.Origin.X,
|
|
stair.Frame.Origin.Y,
|
|
stair.Frame.Origin.Z);
|
|
Quaternion rootRotation = stair.Frame.Orientation;
|
|
|
|
List<ShadowShape> bspShapes = ShadowShapeBuilder.FromLandblockBspParts(
|
|
meshRefs, isBuildingShell: false, cache.GetGfxObj);
|
|
List<ShadowShape> parts = ShadowShapeBuilder.FromStaticRenderParts(
|
|
meshRefs,
|
|
cache.GetGfxObj,
|
|
cache.GetVisualBounds,
|
|
out bool hasPhysicsBsp);
|
|
Assert.True(hasPhysicsBsp);
|
|
Assert.NotEmpty(parts);
|
|
Assert.NotEmpty(bspShapes);
|
|
|
|
const uint entityId = 0x48A02000u;
|
|
engine.ShadowObjects.RegisterMultiPart(
|
|
entityId,
|
|
rootPosition,
|
|
rootRotation,
|
|
bspShapes,
|
|
state: 0u,
|
|
flags: EntityCollisionFlags.None,
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f,
|
|
landblockId: FacilityHub,
|
|
seedCellId: FacilityHub | 0x015Fu,
|
|
isStatic: true,
|
|
partArray: parts);
|
|
|
|
Assert.True(engine.ShadowObjects.TryGetRetailCellArray(
|
|
entityId, out IReadOnlyList<uint> cells));
|
|
|
|
ShadowShape part = parts[0];
|
|
ShadowPartBox box = ShadowPartBox.FromShape(
|
|
part,
|
|
rootPosition,
|
|
rootRotation);
|
|
LoadedCell rootCell = CornerFloodReplayTests.LoadCell(
|
|
dats,
|
|
FacilityHub | 0x015Fu);
|
|
box.RefitToLocal(rootCell.InverseWorldTransform, out Vector3 boxMin, out Vector3 boxMax);
|
|
_out.WriteLine(FormattableString.Invariant(
|
|
$"stair root=({rootPosition.X:F3},{rootPosition.Y:F3},{rootPosition.Z:F3}) localBox=({boxMin.X:F3},{boxMin.Y:F3},{boxMin.Z:F3})..({boxMax.X:F3},{boxMax.Y:F3},{boxMax.Z:F3}) cells=[{string.Join(',', cells.Select(static id => $"0x{id:X8}"))}]"));
|
|
|
|
Assert.Contains(FacilityHub | 0x015Fu, cells);
|
|
Assert.Contains(FacilityHub | 0x015Eu, cells);
|
|
|
|
// The registration carries collision shapes at the SAME transaction
|
|
// now (production always does — chunk 1b threaded the part array
|
|
// through the SAME RegisterMultiPart call the collision dispatch
|
|
// uses), so GetOwnerCells reflects that registration instead of
|
|
// staying empty; retail == collision for this BSP-bearing Setup
|
|
// (RetailCellArrayComparatorInstalledDatTests fixture 1), so the two
|
|
// sets agree exactly.
|
|
Assert.Equal(
|
|
cells.OrderBy(id => id),
|
|
engine.ShadowObjects.GetOwnerCells(entityId).OrderBy(id => id));
|
|
|
|
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
|
|
using var scene = new ArchRenderScene(generation);
|
|
RenderProjectionId projectionId = RenderProjectionId.FromRaw(entityId);
|
|
RenderTransform transform = RenderTransform.FromRoot(
|
|
rootPosition,
|
|
rootRotation,
|
|
1f);
|
|
RenderProjectionRecord projection = new RenderProjectionRecord() with
|
|
{
|
|
Id = projectionId,
|
|
ProjectionClass = RenderProjectionClass.IndoorCellStatic,
|
|
OwnerIncarnation = RenderOwnerIncarnation.FromRaw(1),
|
|
Transform = transform,
|
|
PreviousTransform = new PreviousRenderTransform(transform.LocalToWorld),
|
|
Residency = new RenderSpatialResidency(
|
|
RenderSpatialBucket.FromRaw(FacilityHub | 0x015Fu),
|
|
FacilityHub,
|
|
FacilityHub | 0x015Fu),
|
|
Flags = RenderProjectionFlags.Draw,
|
|
Source = new RenderSourceMetadata() with
|
|
{
|
|
LocalEntityId = entityId,
|
|
SourceId = setupId,
|
|
ParentCellId = FacilityHub | 0x015Fu,
|
|
},
|
|
EntityPayload = new RenderEntityPayload(meshRefs, null, false),
|
|
};
|
|
scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]);
|
|
|
|
var worldData = new WalkProductionWorldData(
|
|
new WalkBuildingRegistry(),
|
|
engine.ShadowObjects);
|
|
worldData.BeginFrame(
|
|
scene.OpenQuery(),
|
|
FacilityHub,
|
|
renderCenterLbX: 0x8A,
|
|
renderCenterLbY: 0x02);
|
|
|
|
Assert.Contains(
|
|
worldData.GetCellStatics(FacilityHub | 0x015Eu).Records,
|
|
record => record.Id == projectionId);
|
|
Assert.Equal(0, worldData.UnregisteredStaticRenderFallbackCount);
|
|
}
|
|
|
|
/// <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]
|
|
[Trait("Purpose", "Diagnostic")]
|
|
public void Diagnostic_RealStaircase_FineYawZoomSweep_FindKnifeEdge()
|
|
{
|
|
var datDir = ResolveDatDir();
|
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
|
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);
|
|
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]
|
|
[Trait("Purpose", "Diagnostic")]
|
|
public void Diagnostic_FloodDepthFrom015E_VsRetail26()
|
|
{
|
|
var datDir = ResolveDatDir();
|
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
|
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);
|
|
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"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
|
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);
|
|
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);
|
|
var fOff = PortalVisibilityBuilder.Build(root, eyeOffPlane, lookup, ViewProj(eyeOffPlane, gaze), null);
|
|
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]
|
|
[Trait("Purpose", "Diagnostic")]
|
|
public void Diagnostic_StaircaseSweep_EyeClearanceFromCeilingPortal()
|
|
{
|
|
var datDir = ResolveDatDir();
|
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
|
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]
|
|
[Trait("Purpose", "Diagnostic")]
|
|
// 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 Diagnostic_ParkedYawZoomSweep_StairAdmission(float px, uint pcell, string label)
|
|
{
|
|
var datDir = ResolveDatDir();
|
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
|
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);
|
|
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]
|
|
[Trait("Purpose", "Diagnostic")]
|
|
public void Diagnostic_Descent_RealCameraSweep_StairCellRetention()
|
|
{
|
|
var datDir = ResolveDatDir();
|
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
|
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);
|
|
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;
|
|
}
|
|
}
|
|
}
|