diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 4868d529..1a25c953 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -4304,28 +4304,48 @@ of which draw list the building's shell left.
## #124 — Looking out through an opening: far buildings with openings show missing/transparent back walls
-**Status:** OPEN
+**Status:** FIX SHIPPED — awaiting user visual gate
**Severity:** MEDIUM
-**Filed:** 2026-06-11 (re-gate; pre-existing — "still have that issue")
+**Filed:** 2026-06-11 (re-gate; pre-existing — "still have that issue";
+user 2026-06-12: "especially visible when I look out through a door
+opening when inside a building")
**Component:** render — per-building look-in floods under INTERIOR roots
From inside a building, looking out through a door/window at ANOTHER
building that has an opening: the far building's back walls are
-missing/transparent (see the world through it). **Lead (by read):** the
-per-building look-in floods (`MergeNearbyBuildingFloods`) run ONLY for
-outdoor roots — `RetailPViewDrawContext.NearbyBuildingCells` is
-documented "Null for interior roots." So under an interior root the far
-building's INTERIOR never floods: through its window you see the shell
-only, and a shell has no interior back-wall faces → transparent.
-Retail runs the building look-in inside `LScape::draw` (DrawBlock →
-DrawPortal → ConstructView(CBldPortal)), which executes for ANY root
-whose outside view is non-empty — including interior roots looking out
-a doorway. Fix shape: provide the nearby-building gather + per-building
-floods for interior roots too, with look-in apertures getting PUNCH
-semantics (the `forceFarZ` selector currently keys on
-`clipRoot.IsOutdoorNode`, which under-punches this case). Needs its own
-focused pass — touches the gather, the merge, and the depth-mask
-selector.
+missing/transparent. The lead confirmed by decomp: retail runs the
+look-in INSIDE the landscape stage for ANY root — `LScape::draw` is the
+FIRST call of `PView::DrawCells`' outside-view branch (pc:432719),
+strictly BEFORE the depth clear (pc:432732) and the seals (pc:432785);
+`ConstructView(CBldPortal)`'s GetClip runs under the INSTALLED view
+(the doorway region), and all apertures far-Z punch (pass 1) before any
+interior cell draws (pass 2).
+
+**Fix (2026-06-12):**
+- The per-building gather (frustum pre-gate on `Building.PortalBounds`)
+ now runs for interior roots too; the root's own doorway self-excludes
+ via the seed eye-side test.
+- `BuildFromExterior` gained `seedRegion` — the port of retail's
+ installed-view clip: interior-root look-ins seed clipped against the
+ OutsideView (doorway) polygons, so a building not visible through the
+ doorway never floods. Outdoor roots keep the full-screen default.
+- NEW `DrawBuildingLookIns` sub-pass inside the LANDSCAPE stage (before
+ the depth clear + seals): per building, punch ALL apertures
+ (`DrawLookInPortalPunch`, always far-Z), then draw the flooded cells'
+ shells + statics far→near. NOT merged into the main frame — a merged
+ cell would draw post-clear and z-fail against the root's seal.
+- Look-in cells join the Prepare/partition set (shells get batches,
+ statics route to ByCell, consumed only by the sub-pass).
+
+Pins: `Issue124LookInSeedRegionTests` (containing region floods ⊆
+full-screen flood; disjoint region floods nothing; interior-side eye
+never seeds its own exit door). Register: AP-33 (look-in statics drawn
+whole — no per-part viewcone; look-in DYNAMICS deferred — an NPC inside
+a far building stays invisible; both documented).
+
+**Gate:** from inside a building, look out the door at another building
+with an open door/window — its interior/back walls render through its
+aperture instead of see-through to the world behind.
---
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 631ccc7e..00c33ca3 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -92,7 +92,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 3. Documented approximation (AP) — 32 rows
+## 3. Documented approximation (AP) — 33 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -128,6 +128,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
+| AP-33 | Interior-root look-in statics (**#124** sub-pass) draw WHOLE — no per-part viewcone check; retail viewconeCheck's each part vs the installed view. Look-in DYNAMICS are not drawn at all (deferred; retail draws objects per overlapped cell in the landscape stage) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | Statics: a few wasted draws only. Dynamics: an NPC inside a far building seen through two openings is invisible where retail shows it | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
---
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index f352c009..0202ec5b 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -7628,9 +7628,9 @@ public sealed class GameWindow : IDisposable
// OutdoorCellNode.Build filters to exit portals internally. The clipRoot flip +
// OutsideView terrain integration that consumes this is the next (cutover) step.
_outdoorNode = null;
- if (viewerRoot is null && viewerCellId != 0u)
+ _outdoorNodeBuildingCells.Clear();
+ if (viewerRoot is not null || viewerCellId != 0u)
{
- _outdoorNodeBuildingCells.Clear();
// T2 (BR-4): draw-driven flood gating. Retail floods a building's
// interior exactly when its shell DRAWS and an aperture survives
// the view (DrawBuilding Ghidra 0x0059f2a0: per-view viewconeCheck
@@ -7645,6 +7645,12 @@ public sealed class GameWindow : IDisposable
// Per-building iteration is also the FPS fix the 2026-06-07
// Chebyshev hack approximated: dozens of AABB tests instead of an
// O(all loaded cells) portal sweep.
+ // #124: the gather now runs for INTERIOR roots too — retail's
+ // look-in executes inside LScape::draw for ANY root with a
+ // non-empty outside view (DrawCells pc:432719). The renderer
+ // routes interior-root look-ins to its landscape-stage sub-pass
+ // (DrawBuildingLookIns); the root's own building self-excludes
+ // via the seed eye-side test.
foreach (var registry in _buildingRegistries.Values)
{
foreach (var b in registry.All())
@@ -7659,10 +7665,11 @@ public sealed class GameWindow : IDisposable
_outdoorNodeBuildingCells.Add(bc);
}
}
- _outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId);
+ if (viewerRoot is null)
+ _outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId);
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
Console.WriteLine(System.FormattableString.Invariant(
- $"[outdoor-node] cell=0x{viewerCellId:X8} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)"));
+ $"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)"));
}
uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u;
@@ -7788,10 +7795,10 @@ public sealed class GameWindow : IDisposable
var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewDrawContext
{
RootCell = clipRoot,
- // R-A2: outdoor root floods each nearby building per-building (not via the root). The
- // gather above populates _outdoorNodeBuildingCells only on outdoor-node frames, so it
- // is fresh here exactly when clipRoot.IsOutdoorNode; null for interior roots.
- NearbyBuildingCells = clipRoot.IsOutdoorNode ? _outdoorNodeBuildingCells : null,
+ // R-A2: outdoor root floods each nearby building per-building (not via the root).
+ // #124: interior roots get the gather too — the renderer routes them to the
+ // landscape-stage look-in sub-pass instead of the merge.
+ NearbyBuildingCells = _outdoorNodeBuildingCells,
ViewerEyePos = viewerEyePos,
ViewProjection = envCellViewProj,
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
@@ -7837,6 +7844,11 @@ public sealed class GameWindow : IDisposable
DrawExitPortalMasks = sliceCtx =>
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
forceFarZ: clipRoot.IsOutdoorNode),
+ // #124: look-in apertures are ALWAYS the punch (retail
+ // maxZ1), independent of the root-keyed selector above.
+ DrawLookInPortalPunch = sliceCtx =>
+ DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
+ forceFarZ: true),
DrawCellParticles = sliceCtx =>
DrawRetailPViewCellParticles(sliceCtx, camera, camPos),
DrawDynamicsParticles = survivors =>
diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
index 6f26d7d5..38f263b8 100644
--- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
+++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
@@ -480,12 +480,18 @@ public static class PortalVisibilityBuilder
/// camera cell. It keeps the same retail distance-priority traversal and
/// neighbour reciprocal clipping once inside the building.
///
+ /// Optional NDC region the seed apertures clip against —
+ /// retail's GetClip runs under the CURRENTLY INSTALLED view (PView::GetClip
+ /// 0x005a4320): full screen when the viewer is outdoors, the accumulated
+ /// outside (doorway) view when a building is looked into from an interior
+ /// root (#124). Null = full screen (the outdoor-root behavior).
public static PortalVisibilityFrame BuildFromExterior(
IEnumerable candidateCells,
Vector3 cameraPos,
Func lookup,
Matrix4x4 viewProj,
- float maxSeedDistance = float.PositiveInfinity)
+ float maxSeedDistance = float.PositiveInfinity,
+ IReadOnlyList? seedRegion = null)
{
var frame = new PortalVisibilityFrame();
var todo = new CellTodoList();
@@ -532,7 +538,7 @@ public static class PortalVisibilityBuilder
poly,
cell.WorldTransform,
viewProj,
- FullScreenRegion,
+ seedRegion ?? FullScreenRegion,
out _);
// T2 (BR-4): empty clip = no seed, no exceptions (retail's
@@ -662,8 +668,9 @@ public static class PortalVisibilityBuilder
Vector3 cameraPos,
Func lookup,
Matrix4x4 viewProj,
- float maxSeedDistance = float.PositiveInfinity)
- => BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance);
+ float maxSeedDistance = float.PositiveInfinity,
+ IReadOnlyList? seedRegion = null)
+ => BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance, seedRegion);
// The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute.
private static readonly Vector2[] FullScreenQuad =
diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
index 29996bf1..772e77f4 100644
--- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs
+++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
@@ -27,6 +27,12 @@ public sealed class RetailPViewRenderer
// R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame).
private readonly Dictionary> _buildingGroups = new();
+ // #124: per-building look-in frames under an INTERIOR root, drawn as a
+ // landscape-stage sub-pass (DrawBuildingLookIns) — never merged into the
+ // main frame (see DrawInside). Rebuilt each interior-root frame.
+ private readonly List _lookInFrames = new();
+ private readonly HashSet _lookInPrepareScratch = new();
+
// T2 (BR-4): retail has NO distance constant on the flood-admission chain
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
// GetClip + GetVisible only). The old 48 m seed cap is replaced by the
@@ -67,6 +73,26 @@ public sealed class RetailPViewRenderer
if (ctx.RootCell.IsOutdoorNode && ctx.NearbyBuildingCells is not null)
MergeNearbyBuildingFloods(ctx, pvFrame);
+ // #124: interior-root building look-ins. Retail runs the look-in INSIDE
+ // the landscape stage for ANY root — LScape::draw is the FIRST call of
+ // DrawCells' outside-view branch (pc:432719), strictly BEFORE the depth
+ // clear (pc:432732) and the exit-portal seals (pc:432785); a far
+ // building seen through our doorway floods clipped to the INSTALLED
+ // outside view (GetClip vs current view, ConstructView(CBldPortal)
+ // 0x005a59a0). These frames therefore draw in DrawBuildingLookIns
+ // (inside the landscape stage), NEVER merged into the main frame — a
+ // merged cell would draw post-clear and z-fail against the root's seal
+ // (its geometry is beyond the door plane). The eye-side seed test
+ // self-excludes the root's own building (the eye is on its interior
+ // side). Outdoor roots keep the MergeNearbyBuildingFloods path above
+ // (no depth clear under outdoor roots — the merged form is equivalent
+ // there).
+ _lookInFrames.Clear();
+ if (!ctx.RootCell.IsOutdoorNode
+ && ctx.NearbyBuildingCells is not null
+ && pvFrame.OutsideView.Polygons.Count > 0)
+ BuildInteriorRootLookIns(ctx, pvFrame);
+
var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame);
UploadClipFrame(ctx.SetTerrainClipUbo);
@@ -78,15 +104,31 @@ public sealed class RetailPViewRenderer
var drawableCells = new HashSet(pvFrame.OrderedVisibleCells);
UseIndoorMembershipOnlyRouting();
+ // #124: look-in cells need prepared shell batches + their statics routed
+ // into partition.ByCell (consumed ONLY by DrawBuildingLookIns — the main
+ // cell-object pass iterates pvFrame.OrderedVisibleCells, which never
+ // contains them). drawableCells itself stays the MAIN flood: it feeds the
+ // seals, the outside-stage predicate, and the frame result.
+ var prepareCells = drawableCells;
+ if (_lookInFrames.Count > 0)
+ {
+ _lookInPrepareScratch.Clear();
+ _lookInPrepareScratch.UnionWith(drawableCells);
+ foreach (var f in _lookInFrames)
+ foreach (uint c in f.OrderedVisibleCells)
+ _lookInPrepareScratch.Add(c);
+ prepareCells = _lookInPrepareScratch;
+ }
+
_envCells.PrepareRenderBatches(
ctx.ViewProjection,
ctx.CameraWorldPosition,
- filter: drawableCells,
+ filter: prepareCells,
centerLbX: ctx.RenderCenterLbX,
centerLbY: ctx.RenderCenterLbY,
renderRadius: ctx.RenderRadius);
- var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries);
+ var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries);
var result = new RetailPViewFrameResult
{
PortalFrame = pvFrame,
@@ -215,6 +257,105 @@ public sealed class RetailPViewRenderer
}
}
+ // #124: per-building look-in floods for an INTERIOR root, seeded clipped
+ // against the OutsideView (retail: GetClip runs under the INSTALLED view —
+ // the accumulated doorway region — so a far building floods only within the
+ // doorway, ConstructView(CBldPortal) 0x005a59a0 via PView::GetClip
+ // 0x005a4320). Same grouping as MergeNearbyBuildingFloods; the root's own
+ // building self-excludes via the seed eye-side test.
+ private void BuildInteriorRootLookIns(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame)
+ {
+ foreach (var group in _buildingGroups.Values)
+ group.Clear();
+
+ foreach (var cell in ctx.NearbyBuildingCells!)
+ {
+ uint groupKey = cell.BuildingId ?? cell.CellId;
+ if (!_buildingGroups.TryGetValue(groupKey, out var group))
+ {
+ group = new List();
+ _buildingGroups[groupKey] = group;
+ }
+ group.Add(cell);
+ }
+
+ foreach (var group in _buildingGroups.Values)
+ {
+ if (group.Count == 0)
+ continue;
+ var frame = PortalVisibilityBuilder.ConstructViewBuilding(
+ group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection,
+ OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons);
+ if (frame.OrderedVisibleCells.Count > 0)
+ _lookInFrames.Add(frame);
+ }
+ }
+
+ // #124: draw the interior-root look-ins INSIDE the landscape stage —
+ // retail's placement (LScape::draw → DrawBlock → DrawSortCell →
+ // DrawBuilding runs as the FIRST call of DrawCells' outside-view branch,
+ // pc:432719, before the depth clear + seals). Per building: punch ALL
+ // apertures first (retail finishes build_draw_portals_only pass 1 — the
+ // far-Z maxZ1 punch — across the whole building BSP before pass 2 floods),
+ // then draw the flooded cells' shells + statics far→near (the nested
+ // DrawCells' DrawEnvCell + DrawObjCellForDummies; its outside_view is
+ // empty by construction — PView ctor draw_landscape=0 — so no recursive
+ // landscape/clear/seal). Anything rasterized outside an aperture is
+ // repainted by the root's own shells after the depth clear, so over-draw
+ // here is color-safe; statics draw whole (the main viewcone has no entry
+ // for look-in cells; over-include is the safe direction).
+ private void DrawBuildingLookIns(RetailPViewDrawContext ctx, InteriorEntityPartition.Result partition)
+ {
+ if (_lookInFrames.Count == 0)
+ return;
+
+ foreach (var frame in _lookInFrames)
+ {
+ // Pass 1: far-Z punch every aperture of this building.
+ if (ctx.DrawLookInPortalPunch is not null)
+ {
+ foreach (uint cellId in frame.OrderedVisibleCells)
+ {
+ if (!frame.CellViews.TryGetValue(cellId, out var view))
+ continue;
+ foreach (var poly in view.Polygons)
+ {
+ var single = new CellView();
+ single.Add(poly);
+ var cps = ClipPlaneSet.From(single);
+ if (cps.IsNothingVisible)
+ continue;
+ var planes = new Vector4[cps.Count];
+ for (int p = 0; p < cps.Count; p++)
+ planes[p] = cps.Planes[p];
+ ctx.DrawLookInPortalPunch(new RetailPViewCellSliceContext(
+ cellId,
+ new ClipViewSlice(0, new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), planes),
+ Array.Empty()));
+ }
+ }
+ }
+
+ // Pass 2: shells + statics, far→near.
+ UseIndoorMembershipOnlyRouting();
+ for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
+ {
+ uint cellId = frame.OrderedVisibleCells[i];
+ _oneCell.Clear();
+ _oneCell.Add(cellId);
+ _envCells.Render(WbRenderPass.Opaque, _oneCell);
+ _envCells.Render(WbRenderPass.Transparent, _oneCell);
+
+ if (partition.ByCell.TryGetValue(cellId, out var bucket) && bucket.Count > 0)
+ {
+ _cellStaticScratch.Clear();
+ _cellStaticScratch.AddRange(bucket);
+ DrawEntityBucket(ctx, _cellStaticScratch, _oneCell);
+ }
+ }
+ }
+ }
+
private void DrawLandscapeThroughOutsideView(
RetailPViewDrawContext ctx,
ClipFrameAssembly clipAssembly,
@@ -260,6 +401,12 @@ public sealed class RetailPViewRenderer
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
}
+ // #124: far-building look-ins draw HERE — still inside the landscape
+ // stage (their punches mark against the terrain/exterior depth just
+ // drawn), strictly BEFORE the depth clear + seals below, matching
+ // retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785).
+ DrawBuildingLookIns(ctx, partition);
+
// T1: retail clears the FULL depth buffer ONCE between the outside
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
// Clear gated on portalsDrawnCount; exact gate semantics is a plan
@@ -667,6 +814,12 @@ public interface IRetailPViewCellDrawCallbacks
{
public Action? DrawExitPortalMasks { get; }
public Action? DrawCellParticles { get; }
+
+ /// #124: far-Z punch one look-in aperture (a clipped view polygon
+ /// of a looked-into building cell) — always the PUNCH variant regardless
+ /// of root kind (retail maxZ1; the root-keyed forceFarZ selector only
+ /// governs the MAIN frame's exit-portal masks).
+ public Action? DrawLookInPortalPunch { get; }
}
public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks
@@ -713,6 +866,7 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
public Action? ClearDepthForInterior { get; init; }
public Action? DrawExitPortalMasks { get; init; }
public Action? DrawCellParticles { get; init; }
+ public Action? DrawLookInPortalPunch { get; init; }
public Action>? DrawDynamicsParticles { get; init; }
public Action? EmitDiagnostics { get; init; }
}
diff --git a/tests/AcDream.App.Tests/Rendering/Issue124LookInSeedRegionTests.cs b/tests/AcDream.App.Tests/Rendering/Issue124LookInSeedRegionTests.cs
new file mode 100644
index 00000000..75a964b1
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Issue124LookInSeedRegionTests.cs
@@ -0,0 +1,153 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.App.Rendering;
+using DatReaderWriter;
+using DatReaderWriter.Options;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace AcDream.App.Tests.Rendering;
+
+///
+/// #124 — far-building interiors under an INTERIOR root. Retail seeds the
+/// look-in flood by clipping a building's aperture against the CURRENTLY
+/// INSTALLED view (PView::GetClip 0x005a4320 inside ConstructView(CBldPortal)
+/// 0x005a59a0): full screen outdoors, the accumulated doorway (outside) view
+/// when looked into from inside. These tests pin BuildFromExterior's
+/// seedRegion parameter — the port of that installed-view clip — against the
+/// real Holtburg corner-building door.
+///
+public class Issue124LookInSeedRegionTests
+{
+ private readonly ITestOutputHelper _out;
+ public Issue124LookInSeedRegionTests(ITestOutputHelper output) => _out = output;
+
+ private const uint ExitCellId = CornerFloodReplayTests.Landblock | 0x0170u;
+
+ private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt)
+ {
+ var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ);
+ var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 1f, 5000f);
+ return view * proj;
+ }
+
+ private static (Dictionary cells, LoadedCell exitCell, int exitIdx,
+ Vector3 centroid, Vector3 outward) LoadFixture(DatCollection dats)
+ {
+ var cells = CornerFloodReplayTests.LoadBuilding(dats);
+ var exitCell = cells[ExitCellId];
+
+ int exitIdx = -1;
+ for (int i = 0; i < exitCell.Portals.Count; i++)
+ {
+ if (exitCell.Portals[i].OtherCellId == 0xFFFF && i < exitCell.PortalPolygons.Count
+ && exitCell.PortalPolygons[i].Length >= 3)
+ { exitIdx = i; break; }
+ }
+ Assert.True(exitIdx >= 0);
+
+ var localPoly = exitCell.PortalPolygons[exitIdx];
+ Vector3 centroid = Vector3.Zero;
+ foreach (var lp in localPoly)
+ centroid += Vector3.Transform(lp, exitCell.WorldTransform);
+ centroid /= localPoly.Length;
+
+ var plane = exitCell.ClipPlanes[exitIdx];
+ var normal = Vector3.TransformNormal(plane.Normal, exitCell.WorldTransform);
+ var cellCenter = Vector3.Transform(
+ (exitCell.LocalBoundsMin + exitCell.LocalBoundsMax) * 0.5f, exitCell.WorldTransform);
+ // outward = away from the cell interior.
+ if (Vector3.Dot(normal, cellCenter - centroid) > 0)
+ normal = -normal;
+ return (cells, exitCell, exitIdx, centroid, Vector3.Normalize(normal));
+ }
+
+ private static Vector4 ApertureNdcAabb(LoadedCell cell, int idx, Matrix4x4 viewProj)
+ {
+ float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
+ foreach (var lp in cell.PortalPolygons[idx])
+ {
+ var w = Vector3.Transform(lp, cell.WorldTransform);
+ var c = Vector4.Transform(new Vector4(w, 1f), viewProj);
+ Assert.True(c.W > 0.05f, "fixture eye must keep the aperture fully in front");
+ minX = MathF.Min(minX, c.X / c.W); maxX = MathF.Max(maxX, c.X / c.W);
+ minY = MathF.Min(minY, c.Y / c.W); maxY = MathF.Max(maxY, c.Y / c.W);
+ }
+ return new Vector4(minX, minY, maxX, maxY);
+ }
+
+ private static ViewPolygon Quad(float minX, float minY, float maxX, float maxY) =>
+ new(new[]
+ {
+ new Vector2(minX, minY), new Vector2(maxX, minY),
+ new Vector2(maxX, maxY), new Vector2(minX, maxY),
+ });
+
+ [Fact]
+ public void SeedRegion_ContainingAperture_Floods_DisjointRegion_DoesNot()
+ {
+ var datDir = CornerFloodReplayTests.ResolveDatDir();
+ if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ var (cells, exitCell, exitIdx, centroid, outward) = LoadFixture(dats);
+ LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null;
+
+ // Eye OUTSIDE the building, 3 m in front of the exit door, gaze at it
+ // — the look-in geometry of a viewer peering at this building through
+ // some other opening.
+ var eye = centroid + outward * 3f;
+ var viewProj = ViewProjFor(eye, centroid);
+ var ap = ApertureNdcAabb(exitCell, exitIdx, viewProj);
+ _out.WriteLine(FormattableString.Invariant(
+ $"aperture ndc=({ap.X:F3},{ap.Y:F3},{ap.Z:F3},{ap.W:F3})"));
+
+ // Sanity: the full-screen (outdoor-root) seed floods.
+ var full = PortalVisibilityBuilder.BuildFromExterior(
+ cells.Values, eye, Lookup, viewProj);
+ Assert.True(full.OrderedVisibleCells.Count > 0, "full-screen seed must flood");
+
+ // A region containing the aperture floods — and never MORE than the
+ // full-screen seed (region-restricting can only shrink the flood).
+ var containing = new[] { Quad(ap.X - 0.05f, ap.Y - 0.05f, ap.Z + 0.05f, ap.W + 0.05f) };
+ var seeded = PortalVisibilityBuilder.BuildFromExterior(
+ cells.Values, eye, Lookup, viewProj, float.PositiveInfinity, containing);
+ Assert.True(seeded.OrderedVisibleCells.Count > 0, "containing region must flood");
+ Assert.True(seeded.OrderedVisibleCells.Count <= full.OrderedVisibleCells.Count);
+
+ // A region strictly disjoint from the aperture must not flood — the
+ // doorway doesn't show this building, so its interior never builds
+ // (retail: GetClip vs the installed view returns empty → no look-in).
+ Assert.True(ap.Z < 0.70f || ap.X > -0.70f, "fixture aperture unexpectedly fills the screen");
+ var disjoint = ap.Z < 0.70f
+ ? new[] { Quad(0.75f, 0.75f, 0.99f, 0.99f) }
+ : new[] { Quad(-0.99f, -0.99f, -0.75f, -0.75f) };
+ var none = PortalVisibilityBuilder.BuildFromExterior(
+ cells.Values, eye, Lookup, viewProj, float.PositiveInfinity, disjoint);
+ Assert.True(none.OrderedVisibleCells.Count == 0,
+ FormattableString.Invariant($"disjoint region flooded {none.OrderedVisibleCells.Count} cells"));
+ }
+
+ [Fact]
+ public void EyeOnInteriorSide_ExitDoorNeverSeeds()
+ {
+ // The root's own doorway must not look-in on itself: the seed eye-side
+ // test (retail ConstructView's sidedness vs portal_side) excludes any
+ // aperture the eye is on the interior side of — this is what lets the
+ // interior-root gather pass ALL nearby buildings including the
+ // viewer's own without special-casing.
+ var datDir = CornerFloodReplayTests.ResolveDatDir();
+ if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ var (cells, exitCell, _, centroid, outward) = LoadFixture(dats);
+ LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null;
+
+ var eye = centroid - outward * 2f; // 2 m INSIDE the doorway
+ var viewProj = ViewProjFor(eye, centroid);
+
+ var frame = PortalVisibilityBuilder.BuildFromExterior(
+ new[] { exitCell }, eye, Lookup, viewProj);
+ Assert.True(frame.OrderedVisibleCells.Count == 0,
+ "an interior-side eye must not seed its own cell's exit portal");
+ }
+}