Chunk 5 (consumer cutover): WalkProductionWorldData's per-cell views are borrowed from ShadowObjectRegistry.GetRetailPartEntriesInCell and resolved through RenderSceneQuery.TryGetByLocalEntityId; every render-side sweep, bucket, parent-cell and root-position fallback is deleted (AD-116 for the one-frame registry→scene window, counted in UnregisteredRenderMembershipCount). A live entity with visual parts but no collision geometry registers render-only (LiveEntityCollisionBuilder computes the part array before the empty-shapes gate). Closeout fixes found while landing it: - RefloodOwnerForLandblock forwards the retained part array — a reflood is retail's recalc_cross_cells over the SAME CPartArray; without it every owner touched by a landblock replacement commit lost its render membership. - Non-colliding DAT statics register render-only from BOTH publishers (LandblockPhysicsPublisher.PublishStaticEntity, LandblockPhysicsContentBuilder.RegisterRenderOnlyStatic). The G2 self-gate pixel diff caught them vanishing (Facility Hub wall panels): retail floods every object regardless of collision (CEnvCell::init_static_objects 0x0052c350, add_shadows_to_cells 0x00514ae0). - S2 dual review fix batch (arch + retail lens, lead-verified): Suspend clears the retail product (remove_shadows_from_cells 0x00511230 is one transaction); AttachChild/DetachChild advance the mutation revision so a prepared SetPosition cannot clobber a child's rows; an attached child never floods on its own re-registration; RemoveLandblock and the non-rooted RetireOwnerFromLandblock prune retail rows (render-only statics end with their landblock); a render-only owner's no-cell-array commit republishes at its destination cell (AD-117); an empty non-null part array is treated as null; per-move closures/LINQ replaced by index loops; EnvCell shells stay out of the scene's LocalEntityId index (payload-less records); the index predicate compares the id; the dead per-cell scene indices are deleted. Register: AD-116 (chunk 5), AD-117 (four residual Contract A/B readings). Evidence: s2-membership-ownership-map.md §8 (chunk 5) and §9 (closeout). Gates (Release): Core 4,984/4,984; Content 214/214; Runtime 1,884/1,884; App hermetic lane 6,760/6,760; App InstalledDat lane 217 pass / 1 skip / 2 pre-existing #383 layout-fixture failures; App Windows lane 1/1. 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.UnregisteredRenderMembershipCount);
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|
|
}
|