diff --git a/src/AcDream.App/Rendering/Walk/WalkBuilding.cs b/src/AcDream.App/Rendering/Walk/WalkBuilding.cs index b15a18d8..0f150262 100644 --- a/src/AcDream.App/Rendering/Walk/WalkBuilding.cs +++ b/src/AcDream.App/Rendering/Walk/WalkBuilding.cs @@ -77,23 +77,38 @@ public sealed class WalkBuilding /// client; registry-configurable). public const float DefaultDegradeDistance = 100f; + /// The live capture client's Render::deg_mul 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. + public const float DefaultDegradeMultiplier = 0.99f; + /// - /// GfxObjDegradeInfo::get_degrade @0x0051e4b0 (the live client's - /// arm: auto_update_deg_mul with a non-positive multiplier → - /// thresholds are the levels' IDEAL distances): effective = - /// max(0, distance − degradeDistance); the FIRST level with - /// effective < IdealDist wins; no match → the LAST level. The - /// positive-bias arm (threshold = ideal − (ideal−max)·mul) is a port - /// follow-up if a fixture ever pins a positive bias. + /// GfxObjDegradeInfo::get_degrade @0x0051e4b0 (BN flag-mush on + /// the FPU compares — cross-checked against the Ghidra decomp, which is + /// clean): effective = max(0, |distance| − degradeDistance), then the + /// FIRST level whose threshold exceeds effective wins; no match → the + /// LAST level. The threshold depends on the multiplier's sign: + /// mul ≥ 0 → ideal − (ideal − max)·mul (ideal→max as mul→1); + /// mul < 0 → ideal + (ideal − min)·mul (ideal→min as mul→−1). + /// mul is Render::deg_mul when auto_update_deg_mul is on, + /// else s_rUserSuppliedDegradeBias. /// public WalkBspNode? SelectDrawingBsp( - float viewerDistance, float degradeDistance = DefaultDegradeDistance) + float viewerDistance, + float degradeDistance = DefaultDegradeDistance, + float degradeMultiplier = DefaultDegradeMultiplier) { if (DegradeLevels.Length == 0) return DrawingBsp; float effective = MathF.Max(0f, MathF.Abs(viewerDistance) - degradeDistance); 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 DegradeLevels[^1].DrawingBsp; @@ -130,10 +145,16 @@ public static class WalkBuildingPortals /// /// BSPTREE::build_draw_portals_only @0x00539860 + the node/portal - /// walkers: 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 and NEGATIVE arms; the IN_PLANE - /// arm (|d| ≤ ε) visits the positive child and emits NOTHING. + /// walkers (BSPNODE @0x0053c100, BSPPORTAL @0x0053d870; + /// side arms verified against the GHIDRA decomp 2026-08-30 — BN's + /// FPU-flag pseudo-C reads the negative/in-plane split ambiguously, and + /// a "corrected" swap of these arms broke four fixtures before being + /// falsified; Ghidra: d ≤ ε → side=1 unless −ε ≤ d → side=2): + /// 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. /// public static void BuildDrawPortalsOnly( WalkBspNode? root, int pass, Vector3 viewpointInBuilding, diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingPortalTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingPortalTests.cs index 88ed24d0..ad790ba2 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingPortalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingPortalTests.cs @@ -106,6 +106,11 @@ public sealed class WalkBuildingPortalTests [Fact] 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) }; // Viewer exactly on the node's splitting plane (|d| <= epsilon). WalkBspNode root = PortalNode(new WalkPlane(new Vector3(1, 0, 0), 0f), portal); diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkPortalGateDumpTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkPortalGateDumpTests.cs index 06127420..3710e3e5 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkPortalGateDumpTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkPortalGateDumpTests.cs @@ -85,4 +85,145 @@ public sealed class WalkPortalGateDumpTests Collect(node.PosNode, into); Collect(node.NegNode, into); } + + private sealed class NullSink : IWalkEventSink + { + public void Emit(in WalkEvent walkEvent) { } + } + + /// + /// 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). + /// + [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 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(); + 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; + } } diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs index 64e84d10..488bf9a1 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs @@ -61,7 +61,12 @@ public sealed class WalkTraceConformanceTests $"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() { // 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}"); } - [Theory(Skip = "FW1 moving tail (2026-08-30 v2): six still fixtures frame-exact; " - + "the moving four diverge at punch-edge frames under BOTH adjacent poses " - + "(walkout/transitions F2 - retail punches 001e from deeper in the " - + "cottage; walkabout F9; foundry-entry F67). FALSIFIED: pose-lag " - + "pairing, gate polarity swaps (raw+flipped-gates broke 3 still " - + "fixtures - reverted). Next: znear (CY d guess 0.1), per-view punch " - + "ordering inside DrawMesh, and exit-view precision at edge angles.")] + [Theory(Skip = "FW1 residue (2026-08-30 v3): F67 floods the right SET through " + + "building a9b40036 but orders 11d,11b before 116/118 — retail (F67+F68, " + + "identical camera) orders 116,118,11d. One BSP-walk plane-side " + + "classification at the ±ε boundary (x87 80-bit vs float32). The walker " + + "arms are GHIDRA-VERIFIED correct — do NOT swap them again (a BN-driven " + + "swap broke four fixtures and was falsified); adjudicate the boundary " + + "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-transitions")] [InlineData("posed/holtburg-walkabout")] - [InlineData("posed/foundry-entry")] 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 // frame N drew with (fixture README) — pair events(N) with