diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 231bef55..bf4cfaa5 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -106,23 +106,34 @@ corridor, and (c) pops into existence on entering the room. Classic portal-visibility miss: the stair geometry's cell is not reached by the portal flood from the viewer's cell until the viewer crosses into it. -**Status update:** 🟑 FIX SHIPPED 2026-07-06 β€” pending user gate. -**Root cause (found via the probe launch):** the geometry never vanished β€” -its LIGHTS did. `BuildPointLightSnapshot` kept only the `MaxGlobalLights=128` -point lights nearest THE CAMERA; the Facility Hub registers 366 fixtures, so -238 were evicted per frame by camera distance. A room whose torches all -ranked past the cap rendered at bare 0.2 ambient (near-black in a dungeon = -"not visible"); approaching re-admitted them ("pops into existence"); the -eviction boundary sweeping with the camera dropped the ramp's lights -mid-descent ("disappears on the last step"). Retail's -`minimize_object_lighting` (0x0054d480) has no global camera-nearest cap. -**FIX:** cap raised to a non-biting 1024 safety valve (register row AP-85); -pin `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` -(RED at 128, GREEN at 1024). Investigation ledger (12 refuted mechanisms + -the probe run that discriminated): +**Status:** OPEN β€” root cause CONFIRMED; fix DEFERRED to the A7 +dungeon-lighting arc (the cap-raise fix was live-tested and REVERTED, +see below). +**Root cause (confirmed via the probe launches):** the geometry never +vanishes β€” its LIGHTS do. `BuildPointLightSnapshot` keeps only the +`MaxGlobalLights=128` point lights nearest THE CAMERA; the Facility Hub +registers 366 fixtures, so 238 are evicted per frame by camera distance. +A room whose torches all rank past the cap renders at bare 0.2 ambient +(near-black in a dungeon = "not visible"); approaching re-admits them +("pops into existence"); the eviction boundary sweeping with the camera +drops the ramp's lights mid-descent ("disappears on the last step"). +Retail's `minimize_object_lighting` (0x0054d480) has no global +camera-nearest cap. +**Why the fix is deferred:** raising the cap to 1024 (commit `4d25e04d`) +made the pops stop but exposed three unported retail lighting semantics +that DOMINATE the frame with the full pool active: (a) lights reach +through solid floors/walls β€” retail registers lights per-CELL +(`insert_light` 0x0054d1b0) so the under-room portal light never touches +the corridor above; our flat sphere-overlap has no reach notion; (b) +stationary weenie fixtures ride the DYNAMIC 1/d falloff (~9Γ— retail's +static 1/dΒ³ at 3 m β€” the #143 isDynamic misassignment for ACE-served +fixtures); (c) an unexplained striped z-fight-like artifact on lit floor +regions (user screenshot). Reverted to 128 (`AP-85` documents the +stopgap; the desired-end-state pin is Skip'd in LightManagerTests). +**A7 fix shape:** per-cell light registration (insert_light port) + +static curve for stationary fixtures + the stripe hunt, THEN uncap. +Full investigation ledger: `docs/research/2026-07-06-176-177-render-pair-investigation.md`. -**Gate:** stairs/stair room stay visible (lit) from the corridor, through -the descent, at all approach angles. **Acceptance:** the staircase renders whenever its room is visible through the connecting opening, and stays rendered through the full descent. @@ -143,25 +154,28 @@ render; the physics fix landed (seam shake gone, user-gated) and the flash REMAINS β€” so it is a render-side issue in its own right, correlated with camera angle. -**Status update:** 🟑 FIX SHIPPED 2026-07-06 β€” pending user gate. -**Root cause (found via the probe launch):** per-cell LIGHTING pops, not a -draw failure. The probe run reproduced the flash while the ambient branch -([light] insideCell/0.2 grey β€” stable) and the portal flood ([pv-input] β€” -zero drops in 54k frames) were provably healthy β€” which eliminated the -last CPU-side theories and exposed the one channel the probes could not -see: per-cell 8-light SET COMPOSITION. `BuildPointLightSnapshot` kept the -128 lights nearest THE CAMERA of the Hub's 366 registered fixtures; an -in-range torch of a visible cell that ranked past the cap was evicted, so -that CELL's Gouraud lighting flipped as the camera moved β€” discontinuity -lines at exactly cell-seam granularity, camera-angle dependent (the chase -boom swings the camera position, re-ranking the 128), and a torch-losing -floor drops to dim blue-grey stone (the perceived purple flash). Twelve -other mechanisms refuted first β€” ledger in +**Status:** OPEN β€” root cause CONFIRMED; fix DEFERRED to the A7 +dungeon-lighting arc (see #177 for the revert story β€” same mechanism, +same deferral). +**Root cause (confirmed via the probe launches):** per-cell LIGHTING pops, +not a draw failure. The probe run reproduced the flash while the ambient +branch ([light] β€” stable 0.2 grey) and the portal flood ([pv-input] β€” +zero drops in 54k frames) were provably healthy, which eliminated the +last CPU-side theories and left the one channel the probes cannot see: +per-cell 8-light SET COMPOSITION. The camera-capped snapshot (128 of the +Hub's 366 fixtures, nearest-to-camera) evicts in-range lights of visible +cells; the flipping unit is a CELL, so the discontinuities sit at exactly +cell-seam granularity, swing with the camera position (the chase boom), +and the dominant flipping light is the under-room PORTALS' purple β€” +hence purple flashes on the floor. Twelve other mechanisms were refuted +first β€” ledger in `docs/research/2026-07-06-176-177-render-pair-investigation.md`. -**FIX:** `MaxGlobalLights` 128 β†’ 1024 non-biting safety valve (AP-85); -pin `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`. -**Gate:** no purple/lighting flashes on floors at corridor seams from any -camera angle while running the 015E↔017A loop. +**Deferral:** the uncapped pool (live-tested `4d25e04d`, reverted) +stabilizes the pops but floods rooms with through-floor portal light +(no per-cell reach semantics), over-strong dynamic-curve fixture light, +and a striped floor artifact β€” the A7 arc owns the real fix (per-cell +`insert_light` registration + static fixture curve + stripe hunt, then +uncap). Register row AP-85. **Acceptance:** no purple/placeholder flashes on dungeon floors from any camera angle at the corridor seams. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 4766abe6..baeab278 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -179,7 +179,7 @@ accepted-divergence entries (#96, #49, #50). | AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone β€” without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions β€” a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) | | AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 β€” which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) | | AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speedΓ—quantum < \|dist\|`, `dist < 0`), acdream applies `delta = βˆ’(speedΓ—quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there β€” a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDistβ‰ˆ0 β€” "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum β‰₯1/30 β†’ threshold ~1 m; render-rate quanta β†’ ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) | -| AP-85 | **Per-frame flat point-light snapshot with a 1024 nearest-to-camera safety cap** (#176/#177 fix, 2026-07-06): acdream collects ALL lit point/spot lights into one flat per-frame snapshot (`BuildPointLightSnapshot`) that per-cell/per-object selection (`SelectForObject`, the faithful AP-16 8-cap) draws from; retail registers lights per-CELL (`insert_light` 0x0054d1b0) and consults the reaching set with NO global pool cap. The previous cap of 128 BIT in the Facility Hub (366 registered fixtures β†’ 238 camera-distance evictions/frame β†’ in-range torches of VISIBLE cells dropped from their 8-sets β†’ per-cell Gouraud lighting popped with camera movement = the #176 purple seam flash + the #177 stair-room light pop-in) | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights`, `BuildPointLightSnapshot`); pin `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` | The flat scan is a perf-shape adaptation (feel-identical while the cap never bites); per-object selection stays retail's `minimize_object_lighting` 8-nearest | If content ever registers >1024 lit lights in relevance range, per-cell sets silently become camera-dependent again β€” the #176/#177 pop class returns. Raise the cap, don't tune it | `minimize_object_lighting` 0x0054d480 (no global pool cap); `insert_light` 0x0054d1b0 (per-cell registration) | +| AP-85 | **Per-frame flat point-light snapshot capped at the 128 lights nearest THE CAMERA** (`BuildPointLightSnapshot`); retail registers lights per-CELL (`insert_light` 0x0054d1b0) and `minimize_object_lighting` (0x0054d480) consults the reaching set with NO global pool cap. The cap BITES in the Facility Hub (366 registered fixtures β†’ 238 evictions/frame) and the eviction is the CONFIRMED mechanism of #176 (purple seam flash β€” an in-range torch of a visible cell ranks past the cap and drops from that cell's 8-set; per-cell Gouraud pops as the camera moves) + #177 (a stair room's fixtures all past the cap render it 0.2-ambient-dark until approach). ⚠️ Raising to 1024 was live-tested 2026-07-06 and REVERTED: the uncapped pool exposes (a) light-through-solid-floors (no per-cell reach/occlusion β€” the under-room portal light washes the corridor above), (b) stationary weenie fixtures on the DYNAMIC 1/d falloff (~9Γ— retail's static 1/dΒ³ at 3 m; #143 misassignment for ACE-served fixtures), (c) an unexplained striped floor artifact. Fix = the A7 arc: per-cell light registration + static curve for fixtures + the stripe hunt, THEN uncap | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights` β€” the load-bearing-stopgap comment); desired-end-state pin (Skip) `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` | The 128 cap keeps the light pool local to the camera, which accidentally APPROXIMATES per-cell reach (far lights can't leak through floors into view) β€” the least-wrong state until A7 ports real per-cell registration | The #176/#177 pop class stays live until A7 (purple flashes at seams; unlit rooms popping lit on approach); any dungeon with >128 fixtures has camera-dependent per-cell lighting | `minimize_object_lighting` 0x0054d480 (no global pool cap); `insert_light` 0x0054d1b0 (per-cell registration); `calc_point_light` 0x0059c8b0 (static 1/dΒ³ bake curve) | | AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors β€” `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case β€” doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities | | AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits β€” the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path β€” SetCollisionNormal + Collided β€” is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp β€” retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` Β§7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail β€” clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` | diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index 570669de..3ba78d16 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -176,18 +176,28 @@ public sealed class LightManager public const int MaxLightsPerObject = 8; /// Hard cap on the per-frame global point-light snapshot the shader - /// indexes. #176/#177 (2026-07-06): the old value of 128 BIT in the Facility - /// Hub β€” 366 registered fixtures, so 238 were evicted per frame by camera - /// distance. An in-range torch of a VISIBLE cell that ranked past the cap - /// dropped out of the cell's 8-light set, so per-cell Gouraud lighting popped - /// as the camera moved (the purple seam flash / the stair-room pop-in). - /// Retail's minimize_object_lighting (0x0054d480) has NO global - /// camera-nearest cap β€” every registered light reaching an object is a - /// candidate. 1024 is a pure safety valve (the whole Facility Hub registers - /// 366; GlobalLightPacker grows to fit, 64 B/light on the GPU) and must never - /// bite at real content scale β€” if it ever does, raise it, don't tune it. - /// Pin: LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant. - public const int MaxGlobalLights = 1024; + /// indexes. ⚠️ LOAD-BEARING STOPGAP β€” read before touching (#176/#177, + /// 2026-07-06): this cap BITES in the Facility Hub (366 registered fixtures β†’ + /// 238 camera-distance evictions/frame), and the eviction is the CONFIRMED + /// mechanism of the #176 purple seam flash + the #177 stair-room light + /// pop-in β€” an in-range torch of a visible cell that ranks past the cap + /// drops out of that cell's 8-set, so per-cell Gouraud lighting pops as the + /// camera moves. Retail's minimize_object_lighting (0x0054d480) has + /// NO global camera-nearest cap. HOWEVER: raising the cap to 1024 was + /// live-tested 2026-07-06 and REVERTED β€” with the full pool active, three + /// unported retail lighting semantics dominate the frame: (a) lights reach + /// THROUGH solid floors/walls (retail registers lights per-CELL via + /// insert_light 0x0054d1b0 β€” a portal's purple light below never + /// touches the corridor above; our flat sphere-overlap selection has no + /// reach/occlusion notion), (b) stationary weenie fixtures ride the DYNAMIC + /// 1/d falloff (~9Γ— stronger at 3 m than retail's static 1/dΒ³ bake curve), + /// (c) an unexplained striped z-fight-like artifact on lit floor regions + /// (user screenshot, launch-176-texflush session). The proper fix is the + /// A7 dungeon-lighting arc: per-cell light registration + the static curve + /// for fixtures + the stripe hunt, THEN uncap. Register row AP-85; desired + /// end-state pin (currently Skip): + /// LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant. + public const int MaxGlobalLights = 128; private readonly List _pointSnapshot = new(); diff --git a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs index 0bf02967..801b50d7 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs @@ -271,7 +271,13 @@ public sealed class LightManagerTests /// end-to-end property: a light in range of an object stays selected no /// matter where the camera is, at Facility-Hub-scale light counts. /// - [Fact] + [Fact(Skip = "#176/#177: the camera-invariant pool is the DESIRED retail end-state " + + "(minimize_object_lighting has no global cap), but uncapping was live-tested " + + "2026-07-06 and reverted β€” it exposes unported per-cell light-reach semantics " + + "(through-floor light), the dynamic-vs-static falloff misassignment for weenie " + + "fixtures, and an unexplained striped floor artifact. Un-skip when the A7 " + + "dungeon-lighting arc lands per-cell registration (insert_light 0x0054d1b0) " + + "and raises MaxGlobalLights. See ISSUES #176/#177 + register row AP-85.")] public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant() { var mgr = new LightManager(); diff --git a/tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs b/tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs index ff6f4633..e861f5fc 100644 --- a/tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs @@ -233,6 +233,94 @@ public class Issue176177DungeonSeamInspectionTests } } + /// + /// #176 THE STRIPES (user screenshot, 2026-07-06 evening): a floor region + /// z-fights in regular bands between a purple-lit copy and an unlit copy β€” + /// two COINCIDENT DRAWN surfaces with different per-cell light sets. This + /// sweep hunts the pair in the dat: every pair of DRAWN polys across the + /// corridor neighborhood that is coplanar AND overlapping in area. Before + /// the light-cap fix both copies were usually equally unlit (the purple + /// portal light was cap-evicted) so the fight was invisible; the stable + /// light exposed it. + /// + [Fact] + public void CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs() + { + var datDir = ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + // Seed cells around the screenshot location (the 016E/017A seam) + + // one portal ring. + var cellIds = new HashSet { 0x8A020165u, 0x8A02016Eu, 0x8A02017Au }; + foreach (var seed in new List(cellIds)) + { + var seedCell = dats.Get(seed); + if (seedCell is null) continue; + foreach (var p in seedCell.CellPortals) + cellIds.Add(0x8A020000u | p.OtherCellId); + } + + // Collect all DRAWN polys world-space per cell. + var drawn = new List<(uint CellId, ushort PolyId, Vector3 N, float D, + Vector3 Min, Vector3 Max, uint SurfaceId)>(); + foreach (var cellId in cellIds) + { + var loaded = LoadCell(dats, cellId); + if (loaded is null) continue; + var (cell, cs) = loaded.Value; + var world = WorldTransform(cell); + + foreach (var (id, poly) in cs.Polygons) + { + if (!WouldDraw(poly, cell)) continue; + var w = WorldVerts(cs, poly, world); + if (w.Count < 3) continue; + var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0])); + float d = Vector3.Dot(n, w[0]); + var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue); + foreach (var v in w) { min = Vector3.Min(min, v); max = Vector3.Max(max, v); } + uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface]; + drawn.Add((cellId, id, n, d, min, max, surfaceId)); + } + } + _out.WriteLine($"cells={cellIds.Count} drawnPolys={drawn.Count}"); + + int pairs = 0; + for (int i = 0; i < drawn.Count; i++) + { + for (int j = i + 1; j < drawn.Count; j++) + { + var a = drawn[i]; var b = drawn[j]; + if (a.CellId == b.CellId && a.PolyId == b.PolyId) continue; + + float align = Vector3.Dot(a.N, b.N); + if (MathF.Abs(align) < 0.999f) continue; + float dB = align > 0 ? b.D : -b.D; + if (MathF.Abs(a.D - dB) > 0.02f) continue; // same plane within 2 cm + + // Overlap in world AABB, with meaningful area in the plane. + float ox = MathF.Min(a.Max.X, b.Max.X) - MathF.Max(a.Min.X, b.Min.X); + float oy = MathF.Min(a.Max.Y, b.Max.Y) - MathF.Max(a.Min.Y, b.Min.Y); + float oz = MathF.Min(a.Max.Z, b.Max.Z) - MathF.Max(a.Min.Z, b.Min.Z); + if (ox < 0.05f || oy < 0.05f) continue; + // For horizontal planes require XY overlap area; for walls allow thin Z. + bool horizontal = MathF.Abs(a.N.Z) > 0.85f; + if (horizontal && ox * oy < 0.05f) continue; + if (!horizontal && oz < 0.05f) continue; + + pairs++; + _out.WriteLine( + $">>> COPLANAR-OVERLAP {(a.CellId == b.CellId ? "SAME-CELL" : "CROSS-CELL")}: " + + $"0x{a.CellId:X8} poly {a.PolyId} (surf 0x{a.SurfaceId:X8}) <-> " + + $"0x{b.CellId:X8} poly {b.PolyId} (surf 0x{b.SurfaceId:X8}) " + + $"n=({a.N.X:F2},{a.N.Y:F2},{a.N.Z:F2}) align={align:F3} " + + $"overlap x={ox:F2} y={oy:F2} z=[{MathF.Max(a.Min.Z, b.Min.Z):F2}..{MathF.Min(a.Max.Z, b.Max.Z):F2}]"); + } + } + _out.WriteLine($"coplanar overlapping drawn pairs: {pairs}"); + } + /// /// #176 candidate (A2C): the opaque pass derives GL_SAMPLE_ALPHA_TO_COVERAGE /// from the sampled texture alpha (mesh_modern.frag uRenderPass==0 keeps @@ -379,6 +467,9 @@ public class Issue176177DungeonSeamInspectionTests [InlineData(0x8A02011Du)] [InlineData(0x8A020122u)] [InlineData(0x8A02011Fu)] + [InlineData(0x8A02016Eu)] // corridor cells β€” the striped-floor screenshot area + [InlineData(0x8A02017Au)] + [InlineData(0x8A020165u)] public void UnderHall_DrawnPolys_SurfaceColors(uint cellId) { var datDir = ResolveDatDir();