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>
This commit is contained in:
Erik 2026-08-30 10:51:05 +02:00
parent e5cdd2364e
commit b3ac5872e9
8 changed files with 90 additions and 12 deletions

View file

@ -50,6 +50,7 @@ public sealed class RetailFrameWalkTests
public float ViewportHeight => 480f;
public Vector3 ViewpointInBuilding(WalkBuilding building) => Vector3.Zero;
public float ViewerDistanceTo(WalkBuilding building) => 0f;
public IWalkFrameContext CellContext => this;
// Permissive near plane: every column wholly inside.
public WalkPlane CyPlane => new(new Vector3(0, 0, 1), 0f);

View file

@ -48,6 +48,7 @@ public sealed class WalkBuildingPortalTests
public float ViewportHeight => 480f;
public Vector3 ViewpointInBuilding(WalkBuilding building) => Vector3.Zero;
public float ViewerDistanceTo(WalkBuilding building) => 0f;
public IWalkFrameContext CellContext => this;
public int ClipBuildingPolygon(

View file

@ -108,9 +108,31 @@ public static class WalkLandscapeDatBuilder
int cellIndex = (int)(entry.Building.PositionCellId & 0xFFFFu) - 1;
if (cellIndex >= 0 && cellIndex < 64)
block.CellBuildings[cellIndex] = entry.Building;
// Retail's loaded-interior rule (CLandBlock::init_buildings
// @0052fd80 → add_to_stablist → grab_visible_cells): a
// full-res block loads exactly its buildings' portal
// stab cells — NOT every interior. The flood halts at
// unloaded cells (GetVisible null), which is what
// limits retail's look-in punches to nearby doorways.
foreach (WalkBldPortal portal in entry.Building.Portals)
{
if (portal.OtherCellId != 0xFFFFFFFFu
&& !cells.ContainsKey(portal.OtherCellId))
{
WalkCell? c = WalkWorldDatAdapter.BuildCell(
dats, portal.OtherCellId, blockOffset);
if (c is not null) cells[c.CellId] = c;
}
foreach (uint stab in portal.StabList)
{
if (cells.ContainsKey(stab)) continue;
WalkCell? c = WalkWorldDatAdapter.BuildCell(
dats, stab, blockOffset);
if (c is not null) cells[c.CellId] = c;
}
}
}
WalkWorldDatAdapter.BuildInteriorCells(
dats, landblockId, blockOffset, cells);
}
landscape.Blocks[gx * midWidth + gy] = block;
}

View file

@ -24,13 +24,7 @@ public sealed class WalkLookInGateSweepTests
public void Emit(in WalkEvent walkEvent) => Events.Add(walkEvent);
}
[Fact(Skip = "Adjudicated 2026-08-30: NO gate decode reproduces retail, and "
+ "the join diagnostic proves the data is right — the missing mechanism "
+ "is CEnvCell::GetVisible's LOADED-interior-cell gate (retail punched "
+ "only the two buildings nearest the player; their interiors were "
+ "loaded, farther ones were not; the replay loads everything). Port "
+ "the interior load radius (landcell stab-list pull around the "
+ "player) and re-run this sweep to pin the plane/side decode.")]
[Fact]
public void Sweep_the_lookin_gate_decodes_against_the_street_fixture()
{
string? datDir = CornerFloodReplayTests.ResolveDatDir();

View file

@ -112,6 +112,10 @@ public sealed class WalkTraceReplayContext : IWalkFrameContext, IRetailFrameWalk
public Vector3 ViewpointInBuilding(WalkBuilding building)
=> Vector3.Transform(WorldViewpoint, Buildings[building].InverseWorldTransform);
public float ViewerDistanceTo(WalkBuilding building)
=> Vector3.Distance(
WorldViewpoint, Buildings[building].WorldTransform.Translation);
public int ClipBuildingPolygon(
WalkBuilding building, WalkPolygon polygon, int side, Span<WalkScreenPoint> output)
{

View file

@ -148,8 +148,26 @@ public static class WalkWorldDatAdapter
}
WalkBspNode? bsp = null;
var degradeLevels = new List<WalkBuildingDegradeLevel>();
if (dats.Get<GfxObj>(buildingInfo.ModelId) is GfxObj gfxObj)
{
bsp = ConvertDrawingBsp(gfxObj, gfxObj.DrawingBSP?.Root);
if (gfxObj.DIDDegrade != 0
&& dats.Get<GfxObjDegradeInfo>(gfxObj.DIDDegrade)
is GfxObjDegradeInfo degradeInfo)
{
foreach (GfxObjInfo level in degradeInfo.Degrades)
{
WalkBspNode? levelBsp = null;
if (level.Id != 0
&& dats.Get<GfxObj>((uint)level.Id) is GfxObj levelGfx)
{
levelBsp = ConvertDrawingBsp(levelGfx, levelGfx.DrawingBSP?.Root);
}
degradeLevels.Add(new WalkBuildingDegradeLevel(level.MaxDist, levelBsp));
}
}
}
Matrix4x4 worldTransform =
Matrix4x4.CreateFromQuaternion(buildingInfo.Frame.Orientation)
@ -161,6 +179,7 @@ public static class WalkWorldDatAdapter
PositionCellId = positionCellId,
Portals = portals,
DrawingBsp = bsp,
DegradeLevels = degradeLevels.ToArray(),
},
worldTransform,
inverse));