#119-residual ROOT CAUSE: the +0.02 m render lift leaked into the portal-visibility graph - horizontal portals side-culled anyone standing on them
The live capture pinned it end to end. BuildInteriorEntitiesForStreaming lifts the render-side cell transform +0.02 m Z (shell z-fighting vs terrain - a DRAW concern) and passed that LIFTED transform to BuildLoadedCell, so every plane in the visibility graph sat 2 cm high. The portal side test's in-plane window is +-10 mm: an eye standing ON a floor containing a HORIZONTAL portal (the tower's deck lip 010A->0107, stair landings, cellar mouths) sits 0-10 mm above the TRUE plane = 10-20 mm BELOW the lifted plane -> outside the window -> the cell behind the portal side-culled out of the flood. Captured live at the stair top: root=0xAAB3010A eye z=126.803 vs the portal plane at 126.80, flood=1, 0x0107 (the whole tower interior incl. the staircase) dropped WHILE THE GAZE LOOKED STRAIGHT AT IT - "stairs disappear and you can walk on them", and the roof/edge flap as the gaze swung the marginal admissions. Vertical doorways were immune (the lift slides their planes along themselves) - exactly why this hit stairs/decks/floors and not doors. Chase chain (the apparatus did all the work): [viewer] print-on-change probe with eye@mm -> the user's climb capture -> [viewer-diff] naming the dropped cells per flip -> headless replay of the exact captured (eye,fwd) frame: healthy UNLIFTED, reproduces ONLY with the production lift -> gate-by-gate diagnostic (side test dot=+0.003 unlifted vs -0.017 lifted; clip + rescue exonerated; knife-edge z-sweep all-stable, killing the float-chaos theory). Fix: BuildLoadedCell receives the PHYSICS (unlifted) transform; the drawn shells keep their lift. The seal/punch fans (which read the visibility LoadedCell's WorldTransform) now stamp TRUE depth - MORE consistent with the unlifted terrain they protect. Pins: CapturedTopOfStairs_MainCellStaysInFlood - arm 1 (unlifted = post-fix production) asserts the main cell admitted at the captured frame; arm 2 (lifted) is the mechanism canary asserting the drop, with instructions if it ever starts passing. Plus the gate-by-gate diagnostic + knife-edge sweep as the investigation record. Also this session: Issue127FloodFlipReplayTests (the captured 4 cm outdoor flip pair replays STABLE across fovs/pre-gate arms - the outdoor churn is NOT the flood math; remaining #127 = distant-building admission churn, lower priority now that the tower-cell drops are explained by the lift), and the [viewer-diff] probe (per-flip added/ removed cell naming - keep, it found this). Suites: App 242+1skip, Core 1422+2skip, UI 420, Net 294. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
cd12d3dbbc
commit
f35cb8b164
4 changed files with 448 additions and 4 deletions
|
|
@ -5585,8 +5585,21 @@ public sealed class GameWindow : IDisposable
|
|||
cellRotation: envCell.Position.Orientation,
|
||||
staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>());
|
||||
|
||||
// Step 4: build LoadedCell for portal visibility (UNCHANGED from pre-A8).
|
||||
BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform);
|
||||
// Step 4: build LoadedCell for portal visibility — with the
|
||||
// PHYSICS (unlifted) transform. The +0.02 m render lift above
|
||||
// is a DRAW concern (shell z-fighting vs terrain); feeding it
|
||||
// into the visibility graph shifted every HORIZONTAL portal
|
||||
// plane 2 cm up, putting an eye standing on a deck/landing
|
||||
// 10–20 mm BELOW the lifted plane — outside the side test's
|
||||
// ±10 mm in-plane window — so the cell behind the portal was
|
||||
// side-culled: the tower-top staircase vanish + roof flap
|
||||
// (#119-residual; captured live at eye z=126.803 vs the
|
||||
// 010A→0107 plane at 126.80, reproduced ONLY with the lift in
|
||||
// TowerAscentReplayTests.CapturedTopOfStairs_*). Vertical
|
||||
// doorways were immune (the lift slides their planes along
|
||||
// themselves), which is why this hit exactly stairs, decks,
|
||||
// and cellar mouths.
|
||||
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
|
||||
|
||||
// Cache CellStruct physics BSP for indoor collision (UNCHANGED).
|
||||
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
|
||||
|
|
@ -9672,6 +9685,10 @@ public sealed class GameWindow : IDisposable
|
|||
// #119-residual [viewer] probe state: print-on-change signature so a
|
||||
// stable climb is silent and every flood/root flip emits exactly one line.
|
||||
private string? _lastViewerProbeSig;
|
||||
// #127: previous frame's admitted cell set — the [viewer-diff] line names
|
||||
// exactly which cells entered/left the flood when the signature changes.
|
||||
private readonly HashSet<uint> _lastViewerFloodCells = new();
|
||||
private readonly List<uint> _viewerDiffScratch = new();
|
||||
|
||||
private void EmitRetailPViewDiagnostics(
|
||||
AcDream.App.Rendering.RetailPViewFrameResult result,
|
||||
|
|
@ -9698,6 +9715,37 @@ public sealed class GameWindow : IDisposable
|
|||
var v = _cameraController?.Active.View ?? System.Numerics.Matrix4x4.Identity;
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[viewer] {sig} eye=({camPos.X:F3},{camPos.Y:F3},{camPos.Z:F3}) fwd=({-v.M13:F4},{-v.M23:F4},{-v.M33:F4}) viewerCell=0x{viewerCellId:X8}"));
|
||||
|
||||
// #127 [viewer-diff]: name the cells that entered/left since the
|
||||
// last emitted signature — the bistable admission self-attributes.
|
||||
_viewerDiffScratch.Clear();
|
||||
var cur = result.PortalFrame.OrderedVisibleCells;
|
||||
var sb = new System.Text.StringBuilder(96);
|
||||
sb.Append("[viewer-diff] added=[");
|
||||
bool first = true;
|
||||
foreach (uint c in cur)
|
||||
{
|
||||
if (_lastViewerFloodCells.Contains(c)) continue;
|
||||
if (!first) sb.Append(',');
|
||||
sb.Append("0x").Append(c.ToString("X8"));
|
||||
first = false;
|
||||
}
|
||||
sb.Append("] removed=[");
|
||||
first = true;
|
||||
foreach (uint c in _lastViewerFloodCells)
|
||||
{
|
||||
bool present = false;
|
||||
for (int ci = 0; ci < cur.Count; ci++)
|
||||
if (cur[ci] == c) { present = true; break; }
|
||||
if (present) continue;
|
||||
if (!first) sb.Append(',');
|
||||
sb.Append("0x").Append(c.ToString("X8"));
|
||||
first = false;
|
||||
}
|
||||
sb.Append(']');
|
||||
Console.WriteLine(sb.ToString());
|
||||
_lastViewerFloodCells.Clear();
|
||||
foreach (uint c in cur) _lastViewerFloodCells.Add(c);
|
||||
}
|
||||
}
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled)
|
||||
|
|
|
|||
|
|
@ -56,6 +56,11 @@ public class CornerFloodReplayTests
|
|||
/// cell-local space, local AABB, world transform from EnvCell.Position.
|
||||
/// </summary>
|
||||
internal static LoadedCell LoadCell(DatCollection dats, uint cellId)
|
||||
=> LoadCell(dats, cellId, Vector3.Zero);
|
||||
|
||||
/// <summary>worldOffset: block offset for multi-landblock fixtures (dat cell
|
||||
/// positions are landblock-local; a neighbour block needs ±192 per axis).</summary>
|
||||
internal static LoadedCell LoadCell(DatCollection dats, uint cellId, Vector3 worldOffset)
|
||||
{
|
||||
var envCell = dats.Get<DatEnvCell>(cellId)
|
||||
?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found");
|
||||
|
|
@ -66,7 +71,7 @@ public class CornerFloodReplayTests
|
|||
|
||||
var cellTransform =
|
||||
Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
Matrix4x4.CreateTranslation(envCell.Position.Origin);
|
||||
Matrix4x4.CreateTranslation(envCell.Position.Origin + worldOffset);
|
||||
Matrix4x4.Invert(cellTransform, out var inverse);
|
||||
|
||||
var boundsMin = new Vector3(float.MaxValue);
|
||||
|
|
@ -139,7 +144,7 @@ public class CornerFloodReplayTests
|
|||
return new LoadedCell
|
||||
{
|
||||
CellId = cellId,
|
||||
WorldPosition = envCell.Position.Origin,
|
||||
WorldPosition = envCell.Position.Origin + worldOffset,
|
||||
WorldTransform = cellTransform,
|
||||
InverseWorldTransform = inverse,
|
||||
LocalBoundsMin = boundsMin,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,221 @@
|
|||
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;
|
||||
using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #127 — per-building flood admissions are BISTABLE per frame under the
|
||||
/// outdoor root (the building-flap mechanism: tower roof/edges flap, #123
|
||||
/// buildings vanishing when running past). Captured live with the [viewer]
|
||||
/// fwd= probe (flap-fwd-capture2.log): a ~4 cm eye wobble flips the merged
|
||||
/// flood 24↔25 with a byte-identical gaze:
|
||||
/// flood=25 eye=(129.051,-15.636,99.512) fwd=(-0.7565,0.5877,-0.2869)
|
||||
/// flood=24 eye=(129.009,-15.610,99.503) fwd=(same)
|
||||
/// flood=25 eye=(129.051,-15.642,99.514) fwd=(same)
|
||||
/// (player parked at pCell=0xA9B40019, Holtburg town, A9B4-anchored world
|
||||
/// frame — A9B4-local == world; A9B3 cells get a (0,-192,0) block offset.)
|
||||
///
|
||||
/// This harness replays the captured (eye, fwd) pairs through the
|
||||
/// production outdoor pipeline — Build(outdoorNode) + per-building
|
||||
/// ConstructViewBuilding merged (RetailPViewRenderer.MergeNearbyBuildingFloods
|
||||
/// shape) — and DIFFS the admitted cell sets: the flipping cell names the
|
||||
/// bistable admission, and the bisect along the eye segment names the knife
|
||||
/// edge.
|
||||
/// </summary>
|
||||
public class Issue127FloodFlipReplayTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue127FloodFlipReplayTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
// The captured flip pair (A9B4-anchored world frame).
|
||||
private static readonly Vector3 EyeA = new(129.051f, -15.636f, 99.512f);
|
||||
private static readonly Vector3 EyeB = new(129.009f, -15.610f, 99.503f);
|
||||
private static readonly Vector3 Fwd = Vector3.Normalize(new Vector3(-0.7565f, 0.5877f, -0.2869f));
|
||||
|
||||
private sealed record World(
|
||||
Dictionary<uint, LoadedCell> Cells,
|
||||
List<List<LoadedCell>> BuildingGroups,
|
||||
Func<uint, LoadedCell?> Lookup);
|
||||
|
||||
private static void LoadLandblockBuildings(
|
||||
DatCollection dats, uint landblock, Vector3 offset,
|
||||
Dictionary<uint, LoadedCell> cells, List<List<LoadedCell>> groups)
|
||||
{
|
||||
var lbi = dats.Get<DatLandBlockInfo>(landblock | 0xFFFEu);
|
||||
if (lbi is null) return;
|
||||
|
||||
// all interior cells (the lookup needs every portal-reachable cell)
|
||||
for (uint low = 0x0100u; low < 0x0100u + lbi.NumCells; low++)
|
||||
{
|
||||
uint id = landblock | low;
|
||||
try { cells[id] = CornerFloodReplayTests.LoadCell(dats, id, offset); }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
|
||||
// per-building groups from the bldPortal stab lists (the dat's own
|
||||
// building→cells mapping; mirrors BuildingLoader's seed+walk result)
|
||||
if (lbi.Buildings is null) return;
|
||||
foreach (var bld in lbi.Buildings)
|
||||
{
|
||||
var set = new HashSet<uint>();
|
||||
if (bld.Portals is not null)
|
||||
foreach (var bp in bld.Portals)
|
||||
if (bp.StabList is not null)
|
||||
foreach (ushort low in bp.StabList)
|
||||
set.Add(landblock | low);
|
||||
var group = new List<LoadedCell>();
|
||||
foreach (uint id in set)
|
||||
if (cells.TryGetValue(id, out var c))
|
||||
group.Add(c);
|
||||
if (group.Count > 0)
|
||||
groups.Add(group);
|
||||
}
|
||||
}
|
||||
|
||||
private World? LoadWorld()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return null; }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = new Dictionary<uint, LoadedCell>();
|
||||
var groups = new List<List<LoadedCell>>();
|
||||
// A9B4 = the anchor (world == local); the live gather spans the
|
||||
// streaming near window, so load a 5x5 block neighbourhood with
|
||||
// block offsets relative to the anchor.
|
||||
for (int dx = -2; dx <= 2; dx++)
|
||||
for (int dy = -2; dy <= 2; dy++)
|
||||
{
|
||||
uint lbX = (uint)(0xA9 + dx);
|
||||
uint lbY = (uint)(0xB4 + dy);
|
||||
uint lb = (lbX << 24) | (lbY << 16);
|
||||
LoadLandblockBuildings(dats, lb, new Vector3(dx * 192f, dy * 192f, 0f), cells, groups);
|
||||
}
|
||||
_out.WriteLine($"loaded {cells.Count} cells in {groups.Count} building groups");
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
return new World(cells, groups, lookup);
|
||||
}
|
||||
|
||||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 fwd, float fovY)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, eye + fwd * 3f, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(fovY, 1280f / 720f, 0.1f, 5000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
// PortalBounds per group: the union AABB of the group's exit-portal
|
||||
// polygons in world space — exactly BuildingLoader's construction
|
||||
// (BuildingLoader.cs:114-144).
|
||||
private static (bool Has, Vector3 Min, Vector3 Max) PortalBoundsFor(List<LoadedCell> group)
|
||||
{
|
||||
bool has = false;
|
||||
var min = new Vector3(float.MaxValue);
|
||||
var max = new Vector3(float.MinValue);
|
||||
foreach (var cell in group)
|
||||
{
|
||||
int n = Math.Min(cell.Portals.Count, cell.PortalPolygons.Count);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (cell.Portals[i].OtherCellId != 0xFFFF) continue;
|
||||
var poly = cell.PortalPolygons[i];
|
||||
if (poly is null) continue;
|
||||
foreach (var v in poly)
|
||||
{
|
||||
var world = Vector3.Transform(v, cell.WorldTransform);
|
||||
has = true;
|
||||
min = Vector3.Min(min, world);
|
||||
max = Vector3.Max(max, world);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (has, min, max);
|
||||
}
|
||||
|
||||
// The production outdoor pipeline: full-screen outdoor root + per-building
|
||||
// exterior floods merged as a union (RetailPViewRenderer.DrawInside +
|
||||
// MergeNearbyBuildingFloods + MergeBuildingFrame), INCLUDING the gather's
|
||||
// per-building frustum pre-gate on PortalBounds (GameWindow:7542-7545) —
|
||||
// controlled by <paramref name="withPreGate"/> so the gate itself can be
|
||||
// implicated or exonerated.
|
||||
private static HashSet<uint> AdmittedCells(World w, Vector3 eye, Vector3 fwd, float fovY, bool withPreGate)
|
||||
{
|
||||
var viewProj = ViewProjFor(eye, fwd, fovY);
|
||||
var frustum = FrustumPlanes.FromViewProjection(viewProj);
|
||||
var outdoor = OutdoorCellNode.Build(0xA9B40019u);
|
||||
var pv = PortalVisibilityBuilder.Build(outdoor, eye, w.Lookup, viewProj);
|
||||
foreach (var group in w.BuildingGroups)
|
||||
{
|
||||
if (withPreGate)
|
||||
{
|
||||
var (has, min, max) = PortalBoundsFor(group);
|
||||
if (has && !FrustumCuller.IsAabbVisible(frustum, min, max))
|
||||
continue;
|
||||
}
|
||||
var bf = PortalVisibilityBuilder.ConstructViewBuilding(group, eye, w.Lookup, viewProj);
|
||||
foreach (uint cellId in bf.OrderedVisibleCells)
|
||||
{
|
||||
if (!bf.CellViews.TryGetValue(cellId, out var srcView)) continue;
|
||||
if (pv.CellViews.TryGetValue(cellId, out var existing))
|
||||
{
|
||||
foreach (var p in srcView.Polygons) existing.Add(p);
|
||||
continue;
|
||||
}
|
||||
pv.CellViews[cellId] = srcView;
|
||||
pv.OrderedVisibleCells.Add(cellId);
|
||||
}
|
||||
}
|
||||
return new HashSet<uint>(pv.OrderedVisibleCells);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reproduce + name: the captured 4 cm pair must admit the SAME cell set
|
||||
/// (a stable world does not flap a building interior for a 4 cm eye
|
||||
/// wobble at identical gaze). The diff names the bistable cell(s); the
|
||||
/// bisect prints the knife-edge location and transition count.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CapturedFlipPair_AdmissionIsStable()
|
||||
{
|
||||
var w = LoadWorld();
|
||||
if (w is null) return;
|
||||
|
||||
foreach (bool preGate in new[] { false, true })
|
||||
foreach (float fov in new[] { MathF.PI / 3f, 1.2f, MathF.PI / 2f, 0.9f })
|
||||
{
|
||||
var a = AdmittedCells(w, EyeA, Fwd, fov, preGate);
|
||||
var b = AdmittedCells(w, EyeB, Fwd, fov, preGate);
|
||||
var onlyA = a.Except(b).OrderBy(x => x).ToList();
|
||||
var onlyB = b.Except(a).OrderBy(x => x).ToList();
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"preGate={preGate} fov={fov:F2}: |A|={a.Count} |B|={b.Count} onlyA=[{string.Join(",", onlyA.Select(x => $"0x{x:X8}"))}] onlyB=[{string.Join(",", onlyB.Select(x => $"0x{x:X8}"))}]"));
|
||||
|
||||
if (onlyA.Count == 0 && onlyB.Count == 0) continue;
|
||||
|
||||
// bisect the segment: count admission transitions per flipping cell
|
||||
foreach (uint cell in onlyA.Concat(onlyB))
|
||||
{
|
||||
int transitions = 0;
|
||||
bool? prev = null;
|
||||
for (int i = 0; i <= 40; i++)
|
||||
{
|
||||
var eye = Vector3.Lerp(EyeA, EyeB, i / 40f);
|
||||
bool admitted = AdmittedCells(w, eye, Fwd, fov, preGate).Contains(cell);
|
||||
if (prev.HasValue && admitted != prev.Value) transitions++;
|
||||
prev = admitted;
|
||||
}
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$" cell 0x{cell:X8}: {transitions} admission transition(s) along the 4 cm segment"));
|
||||
}
|
||||
|
||||
Assert.Fail($"flood admission differs across the captured 4 cm pair (preGate={preGate}, fov={fov:F2}) — see output for the flipping cells");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -247,6 +247,176 @@ public class TowerAscentReplayTests
|
|||
$"{flips.Count} root ping-pongs along a monotone ascent — knife-edge root decisions (see output)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// THE captured top-of-stairs vanish (flap-diff-capture.log, live user
|
||||
/// pan): root=0xAAB3010A (the roof-lip cell), eye 3 mm above its floor
|
||||
/// portal plane, gazing DOWN at the staircase — and the flood admitted
|
||||
/// ONLY {010A}: 0x0107 (the whole tower interior, staircase included)
|
||||
/// dropped while being looked straight at. Exact production inputs:
|
||||
/// eye=(301.448,-128.448,126.803) fwd=(-0.9361,-0.2459,-0.2514)
|
||||
/// eye=(301.371,-128.401,126.807) fwd=(-0.8938,-0.3596,-0.2678)
|
||||
/// The 010A→0107 portal is HORIZONTAL at z≈126.8 — the eye rides within
|
||||
/// millimetres of its plane at the stair top, knife-edge projection
|
||||
/// territory (the #120 family's geometry, admission-side).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(301.448f, -128.448f, 126.803f, -0.9361f, -0.2459f, -0.2514f)]
|
||||
[InlineData(301.371f, -128.401f, 126.807f, -0.8938f, -0.3596f, -0.2678f)]
|
||||
public void CapturedTopOfStairs_MainCellStaysInFlood(
|
||||
float ex, float ey, float ez, float fx, float fy, float fz)
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
// The capture is A9B4-anchored world; the loaded cells are AAB3-local
|
||||
// (LoadCell without offset). AAB3 = A9B4 + (1,-1) blocks → local =
|
||||
// world - (192,-192,0).
|
||||
var eye = new Vector3(ex - 192f, ey + 192f, ez);
|
||||
var fwd = Vector3.Normalize(new Vector3(fx, fy, fz));
|
||||
|
||||
// ROOT CAUSE (found via this capture): production fed BuildLoadedCell
|
||||
// the +0.02 m RENDER-LIFTED transform ("keep the small render lift out
|
||||
// of physics", GameWindow:5567) — the visibility graph's HORIZONTAL
|
||||
// portal planes all sat 2 cm high, so an eye standing on the deck
|
||||
// (3 mm above the TRUE plane) was 17 mm BELOW the lifted plane —
|
||||
// outside the side test's ±10 mm in-plane window → 010A→0107
|
||||
// side-culled → the staircase vanished while walkable. FIXED: the
|
||||
// visibility LoadedCell now gets the PHYSICS (unlifted) transform.
|
||||
//
|
||||
// Arm 1 (unlifted = post-fix production): the main cell must be
|
||||
// admitted. Arm 2 (lifted = the old bug, kept as the mechanism
|
||||
// canary): the main cell is dropped — if this arm ever starts
|
||||
// PASSING, the in-plane window has changed enough to absorb a 2 cm
|
||||
// lift; re-evaluate both this canary and the no-lift rule.
|
||||
foreach (bool lift in new[] { false, true })
|
||||
{
|
||||
var armCells = lift ? new Dictionary<uint, LoadedCell>() : cells;
|
||||
if (lift)
|
||||
{
|
||||
var datDir2 = CornerFloodReplayTests.ResolveDatDir()!;
|
||||
using var dats2 = new DatCollection(datDir2, DatAccessType.Read);
|
||||
var lbi = dats2.Get<DatReaderWriter.DBObjs.LandBlockInfo>(Landblock | 0xFFFEu)!;
|
||||
for (uint low = 0x0100u; low < 0x0100u + lbi.NumCells; low++)
|
||||
{
|
||||
try { armCells[Landblock | low] = CornerFloodReplayTests.LoadCell(dats2, Landblock | low, new Vector3(0f, 0f, 0.02f)); }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
}
|
||||
Func<uint, LoadedCell?> armLookup = id => armCells.TryGetValue(id, out var c) ? c : null;
|
||||
var root = armCells[Landblock | 0x010Au];
|
||||
|
||||
foreach (float fov in new[] { MathF.PI / 3f, 1.2f, MathF.PI / 2f })
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, eye + fwd * 3f, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(fov, 1280f / 720f, 0.1f, 5000f);
|
||||
var pv = PortalVisibilityBuilder.Build(root, eye, armLookup, view * proj);
|
||||
bool hasMain = pv.CellViews.ContainsKey(MainCell);
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"lift={lift} fov={fov:F2}: flood={pv.OrderedVisibleCells.Count} hasMain={hasMain}"));
|
||||
if (!lift)
|
||||
Assert.True(hasMain,
|
||||
$"fov={fov:F2}: the tower main cell 0x{MainCell:X8} is NOT in the flood from the roof-lip root (unlifted = post-fix production)");
|
||||
else
|
||||
Assert.False(hasMain,
|
||||
$"fov={fov:F2}: the LIFTED canary unexpectedly admits the main cell — the in-plane window now absorbs a 2 cm lift; re-evaluate the canary + the no-lift rule");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate-by-gate diagnosis of the captured drop: for the 010A→0107 portal
|
||||
/// at the exact captured eye/gaze, print each admission gate's verdict —
|
||||
/// side test (CameraOnInteriorSide replica), homogeneous clip vs the
|
||||
/// full-screen view (ProjectToClip + ClipToRegion, the forward-hop
|
||||
/// pipeline), and the eye-in-opening rescue inputs (perp distance +
|
||||
/// footprint containment).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Diagnostic_TopOfStairs_GateByGate()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock);
|
||||
var root = cells[Landblock | 0x010Au];
|
||||
|
||||
// capture eye converted to the cells' AAB3-local frame (world - (192,-192,0))
|
||||
var eye = new Vector3(301.448f - 192f, -128.448f + 192f, 126.803f);
|
||||
var fwd = Vector3.Normalize(new Vector3(-0.9361f, -0.2459f, -0.2514f));
|
||||
var view = Matrix4x4.CreateLookAt(eye, eye + fwd * 3f, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 1280f / 720f, 0.1f, 5000f);
|
||||
var viewProj = view * proj;
|
||||
|
||||
for (int i = 0; i < root.Portals.Count; i++)
|
||||
{
|
||||
var portal = root.Portals[i];
|
||||
var poly = i < root.PortalPolygons.Count ? root.PortalPolygons[i] : null;
|
||||
_out.WriteLine($"portal[{i}] -> 0x{portal.OtherCellId:X4} polyLen={poly?.Length ?? -1}");
|
||||
if (poly is null || poly.Length < 3) continue;
|
||||
|
||||
// gate 1: side test (CameraOnInteriorSide replica, ε=0.01 in-plane-allowed)
|
||||
string side = "no-plane(allow)";
|
||||
if (i < root.ClipPlanes.Count && root.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f)
|
||||
{
|
||||
var plane = root.ClipPlanes[i];
|
||||
var localCam = Vector3.Transform(eye, root.InverseWorldTransform);
|
||||
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
|
||||
bool pass = plane.InsideSide == 0 ? dot >= -0.01f : dot <= 0.01f;
|
||||
side = FormattableString.Invariant($"dot={dot:F4} insideSide={plane.InsideSide} -> {(pass ? "TRAVERSE" : "CULL")}");
|
||||
}
|
||||
_out.WriteLine($" side: {side}");
|
||||
|
||||
// gate 2: homogeneous clip vs the full-screen view
|
||||
var clip = PortalProjection.ProjectToClip(poly, root.WorldTransform, viewProj);
|
||||
var fullScreen = new[] { new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
|
||||
var region = clip.Length >= 3 ? PortalProjection.ClipToRegion(clip, fullScreen) : Array.Empty<Vector2>();
|
||||
_out.WriteLine($" clip: projVerts={clip.Length} regionVerts={region.Length}");
|
||||
|
||||
// gate 3: rescue inputs — perp distance to the portal plane + the
|
||||
// perpendicular foot inside the opening's footprint
|
||||
var centroid = Vector3.Zero;
|
||||
foreach (var v in poly) centroid += Vector3.Transform(v, root.WorldTransform);
|
||||
centroid /= poly.Length;
|
||||
var n0 = Vector3.Transform(poly[0], root.WorldTransform);
|
||||
var n1 = Vector3.Transform(poly[1], root.WorldTransform);
|
||||
var n2 = Vector3.Transform(poly[2], root.WorldTransform);
|
||||
var planeN = Vector3.Normalize(Vector3.Cross(n1 - n0, n2 - n0));
|
||||
float perp = MathF.Abs(Vector3.Dot(planeN, eye - centroid));
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$" rescue: perpDist={perp:F4} centroid=({centroid.X:F2},{centroid.Y:F2},{centroid.Z:F2}) planeN=({planeN.X:F2},{planeN.Y:F2},{planeN.Z:F2})"));
|
||||
}
|
||||
|
||||
// knife-edge sweep: the probe rounds the eye to mm — sweep z within the
|
||||
// rounding envelope and count clip flips for the 010A→0107 portal. A
|
||||
// chaotic 6↔0 region inside ±2 mm proves the live drop is the same
|
||||
// frame at sub-mm precision.
|
||||
{
|
||||
var poly = root.PortalPolygons[0];
|
||||
var fullScreen = new[] { new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
|
||||
int flips = 0;
|
||||
bool? prev = null;
|
||||
var results = new System.Text.StringBuilder();
|
||||
for (int i = -20; i <= 20; i++)
|
||||
{
|
||||
var e2 = eye with { Z = eye.Z + i * 0.0002f };
|
||||
var v2 = Matrix4x4.CreateLookAt(e2, e2 + fwd * 3f, Vector3.UnitZ);
|
||||
var vp2 = v2 * proj;
|
||||
var clip2 = PortalProjection.ProjectToClip(poly, root.WorldTransform, vp2);
|
||||
var region2 = clip2.Length >= 3 ? PortalProjection.ClipToRegion(clip2, fullScreen) : Array.Empty<Vector2>();
|
||||
bool ok = region2.Length >= 3;
|
||||
if (prev.HasValue && ok != prev.Value) flips++;
|
||||
prev = ok;
|
||||
results.Append(ok ? '1' : '0');
|
||||
}
|
||||
_out.WriteLine($" knife-edge sweep (z ±4 mm, 0.2 mm steps): {results} flips={flips}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Full per-step table for the investigation record.</summary>
|
||||
[Fact]
|
||||
public void Diagnostic_TowerAscent_PerStepTable()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue