diff --git a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs
index 2f26f277..94213e52 100644
--- a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs
+++ b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs
@@ -136,6 +136,11 @@ public sealed class RetailFrameWalk
{
sink.Emit(WalkEvent.Building(building.PositionCellId));
if (!building.HasGeometry) return;
+ // Retail walks the CURRENT degrade level's drawing BSP
+ // (part->gfxobj[deg_level]); a degraded-out slot skips everything
+ // after publishing the portal list.
+ WalkBspNode? bsp = building.SelectDrawingBsp(ctx.ViewerDistanceTo(building));
+ if (bsp is null) return;
int viewCount = Math.Max(activeViews.ViewCount, 0);
var passSink = new PortalPassSink(sink);
@@ -144,11 +149,11 @@ public sealed class RetailFrameWalk
{
ctx.SetActiveView(activeViews, v);
WalkBuildingPortals.BuildDrawPortalsOnly(
- building.DrawingBsp, 1, viewpoint,
+ bsp, 1, viewpoint,
(portalRef, pass) => WalkBuildingPortals.DrawPortal(
_outdoorPView, building, portalRef, pass, ctx, passSink));
WalkBuildingPortals.BuildDrawPortalsOnly(
- building.DrawingBsp, 2, viewpoint,
+ bsp, 2, viewpoint,
(portalRef, pass) => WalkBuildingPortals.DrawPortal(
_outdoorPView, building, portalRef, pass, ctx, passSink));
}
diff --git a/src/AcDream.App/Rendering/Walk/WalkBuilding.cs b/src/AcDream.App/Rendering/Walk/WalkBuilding.cs
index a43727bd..6a4d278b 100644
--- a/src/AcDream.App/Rendering/Walk/WalkBuilding.cs
+++ b/src/AcDream.App/Rendering/Walk/WalkBuilding.cs
@@ -37,6 +37,10 @@ public sealed class WalkBspNode
public bool IsPortal => InPortals is not null;
}
+/// One degrade-ladder level: the drawing BSP of that level's
+/// GfxObj (portal-only view) and the level's far distance bound.
+public readonly record struct WalkBuildingDegradeLevel(float MaxDist, WalkBspNode? DrawingBsp);
+
/// The walk's building model (retail CBuildingObj +
/// BuildInfo as the frame walk consumes them).
public sealed class WalkBuilding
@@ -47,12 +51,35 @@ public sealed class WalkBuilding
public WalkBldPortal[] Portals = [];
- /// The drawing BSP of part 0's GfxObj (portal-only view).
+ /// The drawing BSP of part 0's BASE GfxObj (portal-only view).
+ /// Used directly when the model has no degrade ladder.
public WalkBspNode? DrawingBsp;
+ /// The degrade ladder (near→far). Retail walks the CURRENT
+ /// level's drawing BSP (part->gfxobj[deg_level]) — 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).
+ public WalkBuildingDegradeLevel[] DegradeLevels = [];
+
/// part->gfxobj[deg_level] != 0 — a degraded-out slot
/// skips the whole building AFTER publishing the portal list.
public bool HasGeometry = true;
+
+ /// Level selection by viewer distance. Simplified band pick
+ /// (first level whose MaxDist covers the distance) — the faithful
+ /// hysteresis of CPhysicsPart::UpdateViewerDistance is a port
+ /// TODO; still fixtures are insensitive to hysteresis.
+ 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
+ }
}
///
@@ -246,6 +273,11 @@ public interface IWalkBuildingFrameContext
{
Vector3 ViewpointInBuilding(WalkBuilding building);
+ /// The viewer's distance to the building
+ /// (CPhysicsPart::UpdateViewerDistance's input) — selects the
+ /// degrade level whose drawing BSP the portal pass walks.
+ float ViewerDistanceTo(WalkBuilding building);
+
/// 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.
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs
index 471dfd66..a969c34f 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs
@@ -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);
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingPortalTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingPortalTests.cs
index 631a5917..88ed24d0 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingPortalTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingPortalTests.cs
@@ -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(
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkLandscapeDatBuilder.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkLandscapeDatBuilder.cs
index 8f8aab54..f858150c 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkLandscapeDatBuilder.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkLandscapeDatBuilder.cs
@@ -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;
}
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkLookInGateSweepTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkLookInGateSweepTests.cs
index fa393784..6ca37bdc 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkLookInGateSweepTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkLookInGateSweepTests.cs
@@ -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();
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceReplay.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceReplay.cs
index 63396e8b..a0821a2b 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceReplay.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceReplay.cs
@@ -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 output)
{
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapter.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapter.cs
index 907e31bc..fcd097e8 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapter.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapter.cs
@@ -148,8 +148,26 @@ public static class WalkWorldDatAdapter
}
WalkBspNode? bsp = null;
+ var degradeLevels = new List();
if (dats.Get(buildingInfo.ModelId) is GfxObj gfxObj)
+ {
bsp = ConvertDrawingBsp(gfxObj, gfxObj.DrawingBSP?.Root);
+ if (gfxObj.DIDDegrade != 0
+ && dats.Get(gfxObj.DIDDegrade)
+ is GfxObjDegradeInfo degradeInfo)
+ {
+ foreach (GfxObjInfo level in degradeInfo.Degrades)
+ {
+ WalkBspNode? levelBsp = null;
+ if (level.Id != 0
+ && dats.Get((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));