feat(render) Campaign FW1: NINE of ten fixtures conformant - the degrade arm
The moving-fixture divergence was the building degrade ladder: GfxObjDegradeInfo::get_degrade @0x0051e4b0 (Ghidra-verified - BN's FPU-flag pseudo-C misread BOTH arm selection and one formula) slides each level's threshold from ideal toward MAX as the multiplier approaches 1, and the live client runs the positive arm at ~0.99, so level 0's portal-bearing BSP survives to ~max_dist (48 for the Holtburg cottages), not ideal (24). The recon note's "deg_mul = -0.99" was a sign misread; the negative arm's threshold slides toward MIN and contradicts the fixtures from both directions. With the two-arm port, holtburg-walkout, holtburg-transitions, and holtburg-walkabout pass every pairable frame - the walkout-F2 microscope's prediction (103,100 | 100x3 | 124 through the cottage exit views) landed exactly. Also this round, falsified and reverted: a BN-driven swap of the portal walker's negative/in-plane arms (Ghidra shows side 1 = negative EMITS, side 2 = in-plane does not - the original port was correct; the swap broke four fixtures). The walker docs and unit tests now pin the Ghidra-verified truth table. Parked with findings: foundry-entry F67 (right flood set, one plane-side classification at the +/-eps boundary orders 11d before 116/118) and doorway-still (retail shows zero floods at a pose one meter from walkout-F2's flooding pose; multi-portal clip boundary). Suites: Walk 124/3 skips; hermetic 6,687/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
17ee543cb1
commit
c1a029edf9
4 changed files with 201 additions and 22 deletions
|
|
@ -77,23 +77,38 @@ public sealed class WalkBuilding
|
||||||
/// client; registry-configurable).</summary>
|
/// client; registry-configurable).</summary>
|
||||||
public const float DefaultDegradeDistance = 100f;
|
public const float DefaultDegradeDistance = 100f;
|
||||||
|
|
||||||
|
/// <summary>The live capture client's <c>Render::deg_mul</c> arm:
|
||||||
|
/// magnitude 0.99 pinned by the walkout-F2 fixture (building a9b4001e,
|
||||||
|
/// effective 30.3, level 0 ideal/max 24/48 — retail floods, so its
|
||||||
|
/// threshold ≈ max ⇒ the POSITIVE arm; the recon note's "−0.99" sign was
|
||||||
|
/// a misread — the negative arm's threshold ≈ min contradicts the
|
||||||
|
/// fixture from both directions). Re-dump at the next retail session.</summary>
|
||||||
|
public const float DefaultDegradeMultiplier = 0.99f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <c>GfxObjDegradeInfo::get_degrade</c> @0x0051e4b0 (the live client's
|
/// <c>GfxObjDegradeInfo::get_degrade</c> @0x0051e4b0 (BN flag-mush on
|
||||||
/// arm: <c>auto_update_deg_mul</c> with a non-positive multiplier →
|
/// the FPU compares — cross-checked against the Ghidra decomp, which is
|
||||||
/// thresholds are the levels' IDEAL distances): effective =
|
/// clean): effective = max(0, |distance| − degradeDistance), then the
|
||||||
/// max(0, distance − degradeDistance); the FIRST level with
|
/// FIRST level whose threshold exceeds effective wins; no match → the
|
||||||
/// effective < IdealDist wins; no match → the LAST level. The
|
/// LAST level. The threshold depends on the multiplier's sign:
|
||||||
/// positive-bias arm (threshold = ideal − (ideal−max)·mul) is a port
|
/// mul ≥ 0 → <c>ideal − (ideal − max)·mul</c> (ideal→max as mul→1);
|
||||||
/// follow-up if a fixture ever pins a positive bias.
|
/// mul < 0 → <c>ideal + (ideal − min)·mul</c> (ideal→min as mul→−1).
|
||||||
|
/// mul is <c>Render::deg_mul</c> when <c>auto_update_deg_mul</c> is on,
|
||||||
|
/// else <c>s_rUserSuppliedDegradeBias</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public WalkBspNode? SelectDrawingBsp(
|
public WalkBspNode? SelectDrawingBsp(
|
||||||
float viewerDistance, float degradeDistance = DefaultDegradeDistance)
|
float viewerDistance,
|
||||||
|
float degradeDistance = DefaultDegradeDistance,
|
||||||
|
float degradeMultiplier = DefaultDegradeMultiplier)
|
||||||
{
|
{
|
||||||
if (DegradeLevels.Length == 0) return DrawingBsp;
|
if (DegradeLevels.Length == 0) return DrawingBsp;
|
||||||
float effective = MathF.Max(0f, MathF.Abs(viewerDistance) - degradeDistance);
|
float effective = MathF.Max(0f, MathF.Abs(viewerDistance) - degradeDistance);
|
||||||
foreach (WalkBuildingDegradeLevel level in DegradeLevels)
|
foreach (WalkBuildingDegradeLevel level in DegradeLevels)
|
||||||
{
|
{
|
||||||
if (effective < level.IdealDist)
|
float threshold = degradeMultiplier >= 0f
|
||||||
|
? level.IdealDist - (level.IdealDist - level.MaxDist) * degradeMultiplier
|
||||||
|
: level.IdealDist + (level.IdealDist - level.MinDist) * degradeMultiplier;
|
||||||
|
if (effective < threshold)
|
||||||
return level.DrawingBsp;
|
return level.DrawingBsp;
|
||||||
}
|
}
|
||||||
return DegradeLevels[^1].DrawingBsp;
|
return DegradeLevels[^1].DrawingBsp;
|
||||||
|
|
@ -130,10 +145,16 @@ public static class WalkBuildingPortals
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <c>BSPTREE::build_draw_portals_only</c> @0x00539860 + the node/portal
|
/// <c>BSPTREE::build_draw_portals_only</c> @0x00539860 + the node/portal
|
||||||
/// walkers: dispatch the root, then walk plane-side ordered — the child
|
/// walkers (<c>BSPNODE</c> @0x0053c100, <c>BSPPORTAL</c> @0x0053d870;
|
||||||
/// OPPOSITE the viewer first, so portals emit far-to-near. PORT nodes
|
/// side arms verified against the GHIDRA decomp 2026-08-30 — BN's
|
||||||
/// emit every in_portal on the POSITIVE and NEGATIVE arms; the IN_PLANE
|
/// FPU-flag pseudo-C reads the negative/in-plane split ambiguously, and
|
||||||
/// arm (|d| ≤ ε) visits the positive child and emits NOTHING.
|
/// a "corrected" swap of these arms broke four fixtures before being
|
||||||
|
/// falsified; Ghidra: <c>d ≤ ε → side=1 unless −ε ≤ d → side=2</c>):
|
||||||
|
/// dispatch the root, then walk plane-side ordered — the child OPPOSITE
|
||||||
|
/// the viewer first, so portals emit far-to-near. PORT nodes emit every
|
||||||
|
/// in_portal on the POSITIVE (d > ε) and NEGATIVE (d < −ε) arms;
|
||||||
|
/// the IN_PLANE arm (|d| ≤ ε) visits the positive child and emits
|
||||||
|
/// NOTHING. Plain nodes group IN_PLANE with NEGATIVE.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void BuildDrawPortalsOnly(
|
public static void BuildDrawPortalsOnly(
|
||||||
WalkBspNode? root, int pass, Vector3 viewpointInBuilding,
|
WalkBspNode? root, int pass, Vector3 viewpointInBuilding,
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,11 @@ public sealed class WalkBuildingPortalTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void In_plane_portal_node_emits_nothing()
|
public void In_plane_portal_node_emits_nothing()
|
||||||
{
|
{
|
||||||
|
// BSPPORTAL::portal_draw_portals_only @0053d870, GHIDRA-verified
|
||||||
|
// 2026-08-30: |d| ≤ ε (side 2) visits POS and emits NOTHING; the
|
||||||
|
// NEGATIVE arm (side 1) is the one that emits. (A BN flag-mush
|
||||||
|
// re-read briefly swapped these arms and broke four fixtures —
|
||||||
|
// never swap them again without Ghidra + fixture proof.)
|
||||||
var portal = new WalkPortalRef { PortalIndex = 0, Polygon = Quad(-2f) };
|
var portal = new WalkPortalRef { PortalIndex = 0, Polygon = Quad(-2f) };
|
||||||
// Viewer exactly on the node's splitting plane (|d| <= epsilon).
|
// Viewer exactly on the node's splitting plane (|d| <= epsilon).
|
||||||
WalkBspNode root = PortalNode(new WalkPlane(new Vector3(1, 0, 0), 0f), portal);
|
WalkBspNode root = PortalNode(new WalkPlane(new Vector3(1, 0, 0), 0f), portal);
|
||||||
|
|
|
||||||
|
|
@ -85,4 +85,145 @@ public sealed class WalkPortalGateDumpTests
|
||||||
Collect(node.PosNode, into);
|
Collect(node.PosNode, into);
|
||||||
Collect(node.NegNode, into);
|
Collect(node.NegNode, into);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class NullSink : IWalkEventSink
|
||||||
|
{
|
||||||
|
public void Emit(in WalkEvent walkEvent) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walkout-F2 microscope: retail floods buildings a9b4001e (×4) and
|
||||||
|
/// a9b40026 (×1) through the cottage's exit views; the replay floods
|
||||||
|
/// neither, while the SAME machinery against the root view passes the
|
||||||
|
/// street fixture. Differential per portal: clip count against the
|
||||||
|
/// full-viewport window vs each exit-view window, plus each window's
|
||||||
|
/// vertices and signed area (winding).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Dump_the_exit_view_look_in_inputs_for_walkout_f2()
|
||||||
|
{
|
||||||
|
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||||
|
if (datDir is null)
|
||||||
|
{
|
||||||
|
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||||
|
}
|
||||||
|
IReadOnlyList<WalkOracleFrame> frames =
|
||||||
|
WalkOracleTrace.Load("posed/holtburg-walkout");
|
||||||
|
var dump = new StringBuilder();
|
||||||
|
using var dats = new DatCollection(datDir!, DatAccessType.Read);
|
||||||
|
// Marker lag: F2's true camera lies between pose(F2) and pose(F3).
|
||||||
|
foreach (WalkOraclePose pose in new[] { frames[2].Pose!, frames[1].Pose! })
|
||||||
|
{
|
||||||
|
WalkLandscapeDatBuilder.BuiltWorld world =
|
||||||
|
WalkLandscapeDatBuilder.Build(dats, pose.CellId, pose.Origin);
|
||||||
|
var ctx = new WalkTraceReplayContext(pose, world.Cells)
|
||||||
|
{
|
||||||
|
Buildings = world.Buildings,
|
||||||
|
};
|
||||||
|
WalkCell camera = world.Cells[pose.CellId];
|
||||||
|
var walk = new RetailFrameWalk();
|
||||||
|
walk.WalkFrame(pose.CellId, camera, world.Landscape, ctx, new NullSink());
|
||||||
|
|
||||||
|
WalkPortalView outside = walk.InteriorPView.OutsideView;
|
||||||
|
dump.AppendLine(
|
||||||
|
$"pose cell={pose.CellId:x8} origin={pose.Origin} ov={outside.ViewCount}");
|
||||||
|
for (int v = 0; v < outside.ViewCount; v++)
|
||||||
|
{
|
||||||
|
WalkViewPoly poly = outside.View.Polys[v];
|
||||||
|
var verts = new Vector2[poly.VertexCount];
|
||||||
|
for (int k = 0; k < poly.VertexCount; k++)
|
||||||
|
verts[k] = outside.View.Vertices[poly.VertexIndex + k].Point;
|
||||||
|
dump.AppendLine(
|
||||||
|
$" exit view {v}: n={poly.VertexCount} area={SignedArea(verts):f1} "
|
||||||
|
+ $"verts={string.Join(' ', verts.Select(p => $"({p.X:f1},{p.Y:f1})"))}");
|
||||||
|
}
|
||||||
|
var defaultView = new WalkPortalView();
|
||||||
|
WalkCopyView.AppendFullViewportQuad(
|
||||||
|
defaultView, ctx.Rays, ctx.WorldViewpoint,
|
||||||
|
ctx.ViewportWidth, ctx.ViewportHeight);
|
||||||
|
{
|
||||||
|
WalkViewPoly rootPoly = defaultView.View.Polys[0];
|
||||||
|
var rootVerts = new Vector2[rootPoly.VertexCount];
|
||||||
|
for (int k = 0; k < rootPoly.VertexCount; k++)
|
||||||
|
rootVerts[k] = defaultView.View.Vertices[rootPoly.VertexIndex + k].Point;
|
||||||
|
dump.AppendLine(
|
||||||
|
$" root view: n={rootPoly.VertexCount} area={SignedArea(rootVerts):f1} "
|
||||||
|
+ $"verts={string.Join(' ', rootVerts.Select(p => $"({p.X:f1},{p.Y:f1})"))}");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (uint id in new[] { 0xA9B4001Eu, 0xA9B40026u })
|
||||||
|
{
|
||||||
|
WalkBuilding building =
|
||||||
|
world.Buildings.Keys.Single(b => b.PositionCellId == id);
|
||||||
|
float dist = ctx.ViewerDistanceTo(building);
|
||||||
|
Vector3 eyeInBuilding = ctx.ViewpointInBuilding(building);
|
||||||
|
WalkBspNode? bsp = building.SelectDrawingBsp(dist);
|
||||||
|
dump.AppendLine(
|
||||||
|
$" building {id:x8}: dist={dist:f1} eff={MathF.Max(0f, dist - 100f):f1} "
|
||||||
|
+ $"bsp={(bsp is null ? "NULL" : "selected")} ports={CountPorts(bsp)}");
|
||||||
|
for (int li = 0; li < building.DegradeLevels.Length; li++)
|
||||||
|
{
|
||||||
|
WalkBuildingDegradeLevel lv = building.DegradeLevels[li];
|
||||||
|
dump.AppendLine(
|
||||||
|
$" level {li}: min={lv.MinDist:f1} ideal={lv.IdealDist:f1} "
|
||||||
|
+ $"max={lv.MaxDist:f1} ports={CountPorts(lv.DrawingBsp)} "
|
||||||
|
+ $"idealArmThr={lv.IdealDist:f1} "
|
||||||
|
+ $"negMulThr={lv.IdealDist - (lv.IdealDist - lv.MaxDist) * -0.99f:f1}");
|
||||||
|
}
|
||||||
|
if (bsp is null) continue;
|
||||||
|
var refs = new List<WalkPortalRef>();
|
||||||
|
WalkBuildingPortals.BuildDrawPortalsOnly(
|
||||||
|
bsp, 1, eyeInBuilding, (portalRef, _) => refs.Add(portalRef));
|
||||||
|
var clipped = new WalkScreenPoint[64];
|
||||||
|
foreach (WalkPortalRef portalRef in refs)
|
||||||
|
{
|
||||||
|
ref WalkBldPortal bp = ref building.Portals[portalRef.PortalIndex];
|
||||||
|
float d = Vector3.Dot(portalRef.Polygon.Plane.Normal, eyeInBuilding)
|
||||||
|
+ portalRef.Polygon.Plane.D;
|
||||||
|
int side = d > WalkVisibilityMath.Epsilon ? 0
|
||||||
|
: d < -WalkVisibilityMath.Epsilon ? 1 : 2;
|
||||||
|
ctx.SetActiveView(defaultView, 0);
|
||||||
|
int rootN = ctx.ClipBuildingPolygon(
|
||||||
|
building, portalRef.Polygon, side, clipped);
|
||||||
|
string rootVerts = string.Join(
|
||||||
|
' ',
|
||||||
|
clipped.Take(rootN).Select(p => $"({p.X:f1},{p.Y:f1},w={p.W:f3})"));
|
||||||
|
var perView = new StringBuilder();
|
||||||
|
for (int v = 0; v < outside.ViewCount; v++)
|
||||||
|
{
|
||||||
|
ctx.SetActiveView(outside, v);
|
||||||
|
int n = ctx.ClipBuildingPolygon(
|
||||||
|
building, portalRef.Polygon, side, clipped);
|
||||||
|
perView.Append($" view{v}N={n}");
|
||||||
|
}
|
||||||
|
dump.AppendLine(
|
||||||
|
$" ref idx={portalRef.PortalIndex} other={bp.OtherCellId:x8} "
|
||||||
|
+ $"rawSide={bp.PortalSide} eyeSide={side} "
|
||||||
|
+ $"gate={(side == bp.PortalSide ? "PASS" : "REJECT")} "
|
||||||
|
+ $"rootN={rootN}{perView}");
|
||||||
|
if (rootN > 0)
|
||||||
|
dump.AppendLine($" root-clipped: {rootVerts}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
string path = Path.Combine(Path.GetTempPath(), "fw1-walkout-f2-lookin-dump.txt");
|
||||||
|
File.WriteAllText(path, dump.ToString());
|
||||||
|
Assert.True(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountPorts(WalkBspNode? node)
|
||||||
|
=> node is null ? 0
|
||||||
|
: (node.IsPortal ? 1 : 0) + CountPorts(node.PosNode) + CountPorts(node.NegNode);
|
||||||
|
|
||||||
|
private static float SignedArea(Vector2[] verts)
|
||||||
|
{
|
||||||
|
float sum = 0f;
|
||||||
|
for (int i = 0; i < verts.Length; i++)
|
||||||
|
{
|
||||||
|
Vector2 a = verts[i];
|
||||||
|
Vector2 b = verts[(i + 1) % verts.Length];
|
||||||
|
sum += a.X * b.Y - b.X * a.Y;
|
||||||
|
}
|
||||||
|
return 0.5f * sum;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,12 @@ public sealed class WalkTraceConformanceTests
|
||||||
$"walk diverged from retail\nEXPECTED: {expected}\nACTUAL: {actual}");
|
$"walk diverged from retail\nEXPECTED: {expected}\nACTUAL: {actual}");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact(Skip = "FW1 residue (2026-08-30 v3): at the doorway pose the replay "
|
||||||
|
+ "floods 001e(103,100×4)/0026(124)/002f — the EXACT set retail shows at "
|
||||||
|
+ "walkout F2 one meter away — but retail shows ZERO floods here (F2–F6, "
|
||||||
|
+ "static pose). Walkout/transitions pass every frame, so the machinery "
|
||||||
|
+ "is right; the doorway pose sits on a multi-portal clip boundary. "
|
||||||
|
+ "Adjudicate the exit-view extents vs the portal projections.")]
|
||||||
public void Doorway_still_first_frame_diff()
|
public void Doorway_still_first_frame_diff()
|
||||||
{
|
{
|
||||||
// Interior flood adjudication: DI + DC(ov=2, n=3) + the landscape
|
// Interior flood adjudication: DI + DC(ov=2, n=3) + the landscape
|
||||||
|
|
@ -124,18 +129,25 @@ public sealed class WalkTraceConformanceTests
|
||||||
$"walk diverged from retail ({fixture})\nEXPECTED: {expected}\nACTUAL: {actual}");
|
$"walk diverged from retail ({fixture})\nEXPECTED: {expected}\nACTUAL: {actual}");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory(Skip = "FW1 moving tail (2026-08-30 v2): six still fixtures frame-exact; "
|
[Theory(Skip = "FW1 residue (2026-08-30 v3): F67 floods the right SET through "
|
||||||
+ "the moving four diverge at punch-edge frames under BOTH adjacent poses "
|
+ "building a9b40036 but orders 11d,11b before 116/118 — retail (F67+F68, "
|
||||||
+ "(walkout/transitions F2 - retail punches 001e from deeper in the "
|
+ "identical camera) orders 116,118,11d. One BSP-walk plane-side "
|
||||||
+ "cottage; walkabout F9; foundry-entry F67). FALSIFIED: pose-lag "
|
+ "classification at the ±ε boundary (x87 80-bit vs float32). The walker "
|
||||||
+ "pairing, gate polarity swaps (raw+flipped-gates broke 3 still "
|
+ "arms are GHIDRA-VERIFIED correct — do NOT swap them again (a BN-driven "
|
||||||
+ "fixtures - reverted). Next: znear (CY d guess 0.1), per-view punch "
|
+ "swap broke four fixtures and was falsified); adjudicate the boundary "
|
||||||
+ "ordering inside DrawMesh, and exit-view precision at edge angles.")]
|
+ "node's d value instead.")]
|
||||||
|
[InlineData("posed/foundry-entry")]
|
||||||
|
public void Moving_fixture_parked_residue(string fixture)
|
||||||
|
=> MovingFixtureReplay(fixture);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
[InlineData("posed/holtburg-walkout")]
|
[InlineData("posed/holtburg-walkout")]
|
||||||
[InlineData("posed/holtburg-transitions")]
|
[InlineData("posed/holtburg-transitions")]
|
||||||
[InlineData("posed/holtburg-walkabout")]
|
[InlineData("posed/holtburg-walkabout")]
|
||||||
[InlineData("posed/foundry-entry")]
|
|
||||||
public void Moving_fixture_reproduces_every_pairable_frame(string fixture)
|
public void Moving_fixture_reproduces_every_pairable_frame(string fixture)
|
||||||
|
=> MovingFixtureReplay(fixture);
|
||||||
|
|
||||||
|
private void MovingFixtureReplay(string fixture)
|
||||||
{
|
{
|
||||||
// Marker timing: the pose stamped at frame N+1 is the camera state
|
// Marker timing: the pose stamped at frame N+1 is the camera state
|
||||||
// frame N drew with (fixture README) — pair events(N) with
|
// frame N drew with (fixture README) — pair events(N) with
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue