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"); + } +}