acdream/src/AcDream.App/Rendering/Walk/WalkBuilding.cs
Erik b3ac5872e9 feat(render) Campaign FW1: the degrade-level BSP gate kills the wrong punches
The offline degrade probe proved the mechanism: every Holtburg building
carries PORT nodes ONLY in its level-0 GfxObj (out to ~24-48 m); every
degraded level has zero. Retail walks the CURRENT degrade level BSP
(part->gfxobj[deg_level]) - that is what limits look-in punches to the
nearest buildings. WalkBuilding gains the degrade ladder +
SelectDrawingBsp (band pick; UpdateViewerDistance hysteresis is a port
TODO), the walk selects per viewer distance, the adapter builds
per-level BSPs, and the stab-list load rule (CLandBlock::init_buildings
@0052fd80: a full-res block loads exactly its buildings portal stab
cells) replaces load-everything in the landscape builder. The sweep now
shows clean rosters with all far-building punches gone; remaining
deltas: the near buildings 001a/0022 (50 m/28 m center distance vs the
48 m band edge - sphere-adjusted distance/hysteresis to port) and the
one ring-1 frustum boundary pair (aab50002/a9b3003c).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 10:51:05 +02:00

291 lines
12 KiB
C#

using System.Numerics;
namespace AcDream.App.Rendering.Walk;
/// <summary>Retail <c>CBldPortal</c>: one building portal — the authored
/// side gate, the destination interior cell, its reciprocal portal index,
/// and the stab list of cells whose view stacks are pushed around the
/// look-in (<c>PView::add_views</c>/<c>remove_views</c>).</summary>
public struct WalkBldPortal
{
public int PortalSide;
public uint OtherCellId;
public int OtherPortalId;
public bool ExactMatch;
public uint[] StabList;
}
/// <summary>One <c>CPortalPoly</c> reference on a PORT BSP node: the index
/// into the building's portal list plus the portal polygon (building-local).</summary>
public struct WalkPortalRef
{
public int PortalIndex;
public WalkPolygon Polygon;
}
/// <summary>A node of the building's drawing BSP as the portal-only walk
/// sees it (retail tags: 'FAIL' leaf = stop, 'PORT' = portal node with
/// in_portals, anything else = plain splitting node).</summary>
public sealed class WalkBspNode
{
public WalkPlane SplittingPlane;
public WalkBspNode? PosNode;
public WalkBspNode? NegNode;
public bool IsFail;
public WalkPortalRef[]? InPortals; // non-null = PORT node
public bool IsPortal => InPortals is not null;
}
/// <summary>One degrade-ladder level: the drawing BSP of that level's
/// GfxObj (portal-only view) and the level's far distance bound.</summary>
public readonly record struct WalkBuildingDegradeLevel(float MaxDist, WalkBspNode? DrawingBsp);
/// <summary>The walk's building model (retail <c>CBuildingObj</c> +
/// <c>BuildInfo</c> as the frame walk consumes them).</summary>
public sealed class WalkBuilding
{
/// <summary><c>Position.objcell_id</c> — the id the oracle's BLD events
/// carry (CBuildingObj+0x4C).</summary>
public uint PositionCellId;
public WalkBldPortal[] Portals = [];
/// <summary>The drawing BSP of part 0's BASE GfxObj (portal-only view).
/// Used directly when the model has no degrade ladder.</summary>
public WalkBspNode? DrawingBsp;
/// <summary>The degrade ladder (near→far). Retail walks the CURRENT
/// level's drawing BSP (<c>part->gfxobj[deg_level]</c>) — and building
/// degrade models beyond the first band carry NO portal nodes, which is
/// what limits look-in punches to nearby buildings (probed 2026-08-30:
/// every Holtburg building has ports at level 0 only).</summary>
public WalkBuildingDegradeLevel[] DegradeLevels = [];
/// <summary><c>part->gfxobj[deg_level] != 0</c> — a degraded-out slot
/// skips the whole building AFTER publishing the portal list.</summary>
public bool HasGeometry = true;
/// <summary>Level selection by viewer distance. Simplified band pick
/// (first level whose MaxDist covers the distance) — the faithful
/// hysteresis of <c>CPhysicsPart::UpdateViewerDistance</c> is a port
/// TODO; still fixtures are insensitive to hysteresis.</summary>
public WalkBspNode? SelectDrawingBsp(float viewerDistance)
{
if (DegradeLevels.Length == 0) return DrawingBsp;
foreach (WalkBuildingDegradeLevel level in DegradeLevels)
{
if (viewerDistance <= level.MaxDist)
return level.DrawingBsp;
}
return null; // degraded out entirely
}
}
/// <summary>
/// Campaign FW1 — the building look-in machinery, ported from the first
/// decomp appendix report 3 (docs/research/2026-08-30-fw-walk-pseudocode-appendix.md):
/// <c>BSPTREE/BSPNODE::build_draw_portals_only</c> @0x00539860/@0x0053c100,
/// <c>BSPPORTAL::portal_draw_portals_only</c> @0x0053d870,
/// <c>PView::DrawPortal</c> @0x005a5ab0, and the CBldPortal
/// <c>PView::ConstructView</c> overload @0x005a59a0.
///
/// The invisible-panel primitive (<c>DrawPortalPolyInternal</c> @0x0059bc90
/// — punch far-Z / seal own-depth) is a GPU submission and belongs to FW2's
/// ordered stream; here it surfaces as the <see cref="IWalkPortalPassSink"/>
/// punch callback so FW1 conformance can observe when retail would draw it.
/// </summary>
public static class WalkBuildingPortals
{
/// <summary>What the portal passes report outward: punches (pass 1) and
/// look-in cell floods (pass 2, the oracle's <c>DC ov=…</c> events).</summary>
public interface IWalkPortalPassSink
{
/// <summary>Pass 1 drew the portal polygon as a far-Z punch
/// (<c>DrawPortalPolyInternal(poly, 1)</c>).</summary>
void OnPunch(WalkPolygon polygon);
/// <summary>Pass 2 completed a look-in flood and retail called
/// <c>PView::DrawCells</c> — the DC event with the flood's list.</summary>
void OnDrawCells(WalkPView pview);
}
/// <summary>
/// <c>BSPTREE::build_draw_portals_only</c> @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.
/// </summary>
public static void BuildDrawPortalsOnly(
WalkBspNode? root, int pass, Vector3 viewpointInBuilding,
Action<WalkPortalRef, int> emitPortal)
{
if (root is null || root.IsFail) return;
Walk(root, pass, viewpointInBuilding, emitPortal);
}
private static void Walk(
WalkBspNode node, int pass, Vector3 viewpoint,
Action<WalkPortalRef, int> emitPortal)
{
while (true)
{
float d = Vector3.Dot(node.SplittingPlane.Normal, viewpoint) + node.SplittingPlane.D;
int side = d > WalkVisibilityMath.Epsilon ? 0
: d < -WalkVisibilityMath.Epsilon ? 1 : 2;
WalkBspNode? next;
if (node.IsPortal)
{
if (side == 0)
{
Visit(node.NegNode, pass, viewpoint, emitPortal);
foreach (WalkPortalRef portal in node.InPortals!)
emitPortal(portal, pass);
next = node.PosNode;
}
else if (side == 1)
{
Visit(node.PosNode, pass, viewpoint, emitPortal);
foreach (WalkPortalRef portal in node.InPortals!)
emitPortal(portal, pass);
next = node.NegNode;
}
else
{
Visit(node.PosNode, pass, viewpoint, emitPortal);
next = node.NegNode;
}
}
else
{
if (side == 0)
{
Visit(node.NegNode, pass, viewpoint, emitPortal);
next = node.PosNode;
}
else
{
Visit(node.PosNode, pass, viewpoint, emitPortal);
next = node.NegNode;
}
}
if (next is null || next.IsFail) return;
node = next; // retail's tail-continue
}
}
private static void Visit(
WalkBspNode? child, int pass, Vector3 viewpoint,
Action<WalkPortalRef, int> emitPortal)
{
if (child is null || child.IsFail) return;
Walk(child, pass, viewpoint, emitPortal);
}
/// <summary>
/// <c>PView::DrawPortal</c> @0x005a5ab0 for one emitted portal polygon:
/// resolve the CBldPortal, push view slots on its stab cells
/// (<c>add_views</c>), run the CBldPortal ConstructView, on pass 2
/// success run the look-in DrawCells, then pop the stab views. The
/// GPU-state backup/restore and the building-frame re-push are
/// submission concerns (FW2); the CPU state here is complete.
/// </summary>
public static bool DrawPortal(
WalkPView pview, WalkBuilding building, in WalkPortalRef portalRef,
int pass, IWalkBuildingFrameContext ctx, IWalkPortalPassSink sink)
{
ref readonly WalkBldPortal bldPortal = ref building.Portals[portalRef.PortalIndex];
AddViews(bldPortal.StabList, ctx);
bool ok = ConstructBuildingView(
pview, building, in bldPortal, portalRef.Polygon, pass, ctx, sink);
if (ok && pass != 1)
sink.OnDrawCells(pview);
RemoveViews(bldPortal.StabList, ctx);
return ok;
}
/// <summary>
/// The CBldPortal <c>PView::ConstructView</c> overload @0x005a59a0:
/// the viewer's side of the portal plane must EQUAL the authored
/// portal_side (IN_PLANE within ±ε fails both gates); the polygon must
/// survive the active-view clip with ≥3 points; the destination cell
/// must be Visible; the clipped view is appended to its top slot. Pass 1
/// punches the polygon; pass ≠ 1 recurses into the interior flood.
/// </summary>
public static bool ConstructBuildingView(
WalkPView pview, WalkBuilding building, in WalkBldPortal bldPortal,
WalkPolygon polygon, int pass, IWalkBuildingFrameContext ctx,
IWalkPortalPassSink sink)
{
Vector3 viewpoint = ctx.ViewpointInBuilding(building);
float d = Vector3.Dot(polygon.Plane.Normal, viewpoint) + polygon.Plane.D;
int side = d > WalkVisibilityMath.Epsilon ? 0
: d < -WalkVisibilityMath.Epsilon ? 1 : 2;
if (bldPortal.PortalSide != 0)
{
if (side != 1) return false;
}
else if (side != 0)
{
return false;
}
Span<WalkScreenPoint> clipped = stackalloc WalkScreenPoint[64];
int n = ctx.ClipBuildingPolygon(building, polygon, side, clipped);
if (n == 0) return false;
WalkCell? cell = ctx.GetVisible(bldPortal.OtherCellId);
if (cell is null) return false;
if (!WalkCopyView.Append(
cell.TopView, clipped[..n], ctx.Rays, ctx.WorldViewpoint))
return false;
if (pass != 2)
sink.OnPunch(polygon); // DrawPortalPolyInternal(poly, pass == 1)
if (pass != 1)
pview.ConstructView(cell, ToEntryIndex(bldPortal.OtherPortalId), ctx.CellContext);
return true;
}
private static int ToEntryIndex(int otherPortalId)
=> otherPortalId < 0 ? 0xFFFF : otherPortalId;
private static void AddViews(uint[] stabList, IWalkBuildingFrameContext ctx)
{
foreach (uint id in stabList)
ctx.GetVisible(id)?.PushView();
}
private static void RemoveViews(uint[] stabList, IWalkBuildingFrameContext ctx)
{
foreach (uint id in stabList)
ctx.GetVisible(id)?.PopView();
}
}
/// <summary>The building half of the frame context: building-local
/// viewpoint and projection (retail pushes the building's object frame
/// before the portal pass), plus the shared cell context.</summary>
public interface IWalkBuildingFrameContext
{
Vector3 ViewpointInBuilding(WalkBuilding building);
/// <summary>The viewer's distance to the building
/// (<c>CPhysicsPart::UpdateViewerDistance</c>'s input) — selects the
/// degrade level whose drawing BSP the portal pass walks.</summary>
float ViewerDistanceTo(WalkBuilding building);
/// <summary>Project + clip one building-local portal polygon against
/// the ACTIVE view (retail: GetClip with do_clip=1 in the building
/// frame). Returns the surviving count.</summary>
int ClipBuildingPolygon(
WalkBuilding building, WalkPolygon polygon, int side, Span<WalkScreenPoint> output);
WalkCell? GetVisible(uint cellId);
IWalkRayCaster Rays { get; }
Vector3 WorldViewpoint { get; }
IWalkFrameContext CellContext { get; }
}