docs+test #176/#177: 12 mechanisms refuted, apparatus shipped, probe protocol staged

The dungeon render pair (purple seam flash + stair pop-in) resisted
desk root-causing, but the investigation narrowed the space to two
live theories and shipped permanent apparatus:

- Issue176177DungeonSeamInspectionTests: dat truth — corridor floors
  ARE textured drawn PortalSide portal polys; the reciprocal is NoPos;
  the 'stairs' are 0x8A020182's ramp shell (vertical portals, zero
  statics); CellBSP partitions exactly at portal planes; DXT1 textures
  carry zero transparent-mode texels.
- Issue176177FacilityHubFloodReplayTests: production-matched flood
  replays — approach/descent/gaze-sweep/walk all healthy with coherent
  inputs; ScenarioE pins the flood's collapse-to-root sensitivity under
  incoherent (root, eye) pairs.
- Issue176177SeamTransitLagTests: the resolver flips cells within one
  tick-step of the portal plane — the 0.33-0.47m [cell-transit] 'lag'
  in the gate logs is speed*tick quantization, not membership error.

Refuted (do NOT retry — ledger in the research doc): placeholder
texture, reciprocal z-fight, seal z-fight (seals only fire for
OtherCellId==0xFFFF), root/eye incoherence (production camera sweep is
mm-exact at planes), flood bistability, #119-class statics, undefined
DXT mips (both paths decode DXT->RGBA8; the compressed-array branch is
dead code), DXT1 alpha, fog mix (ramp ~538m), lightning leak (flash==0
in production), viewer-light pops (smooth (1-d/range) ramp).

Filed #178 (A8 double-sided shell stopgap still live) and #179
(lightning flash lacks an indoor gate — dormant). The purple can only
be the fog clear color (undrawn pixels) or the outdoor ambient+sun
tint; discrimination needs ONE probe launch (ACDREAM_PROBE_LIGHT +
ACDREAM_PROBE_PVINPUT + ACDREAM_PROBE_CELL) — protocol in
docs/research/2026-07-06-176-177-render-pair-investigation.md.

Suites: Core 2587 / App 719 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-05 21:20:03 +02:00
parent 544d4d3eef
commit b8e9e204ad
5 changed files with 1176 additions and 19 deletions

View file

@ -46,6 +46,52 @@ Copy this block when adding a new issue:
--- ---
## #179 — Lightning flash has no indoor gate (dormant until weather strobes ship)
**Status:** OPEN (dormant — zero production impact today)
**Severity:** LOW (latent)
**Filed:** 2026-07-06
**Component:** render — scene lighting UBO / weather
**Description:** `mesh_modern.frag` adds `uFogParams.z × vec3(0.6, 0.6, 0.75)`
(the lightning-flash bump) to EVERY fragment, and `SceneLightingUbo.Build`
copies `atmo.LightningFlash` with no indoor gating (the GameWindow fog
override explicitly preserves `.z`). Today `WeatherState._flashLevel` is 0
in production ("Production never TriggerFlashes" — test hook only), so
nothing is visible. The moment storm strobes ship, sealed dungeons will
flash blue-violet with every strike. Retail's indoor lighting path (flat
ambient, sun killed via the seen_outside gate) carries no storm terms.
**Acceptance:** the flash term is zeroed for `playerInsideCell` frames (or
gated at the UBO build), verified by a storm-in-dungeon probe when weather
strobes land. Found during the #176/#177 investigation
(`docs/research/2026-07-06-176-177-render-pair-investigation.md`).
---
## #178 — Retire the A8 double-sided cell-shell stopgap (CullMode.Landblock → None)
**Status:** OPEN
**Severity:** LOW-MEDIUM (correctness/perf debt; 2× shell fragment load)
**Filed:** 2026-07-06
**Component:** render — EnvCellRenderer MDI draw
**Description:** `EnvCellRenderer.RenderModernMDIInternal` still carries the
Phase A8 visual-gate stopgap: `if (cullMode == CullMode.Landblock) cullMode
= CullMode.None;` — "render cell polys double-sided while the architectural
cause is isolated." Every cell shell draws two-sided to this day. Retail
draws cell polygons single-sided (the drawing BSP + winding decide facing).
The "architectural cause" (winding convention vs the frame-global CW
front-face) was never isolated; the stopgap outlived its gate. Retiring it
needs the winding audit (which side do CellStruct polys wind under our
extraction?) + a visual gate — walls/floors must not vanish.
**Acceptance:** cell shells draw with proper backface culling, no missing
surfaces at the Holtburg + Facility Hub gates. Found during the #176/#177
investigation (`docs/research/2026-07-06-176-177-render-pair-investigation.md`).
---
## #177 — Dungeon stairs pop in/out across levels (invisible until entering the room; last step vanishes running down) ## #177 — Dungeon stairs pop in/out across levels (invisible until entering the room; last step vanishes running down)
**Status:** OPEN **Status:** OPEN
@ -60,15 +106,18 @@ 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-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. portal flood from the viewer's cell until the viewer crosses into it.
**Root cause / status:** unknown; the #119 class (visibility volumes / **Root cause / status:** INVESTIGATED 2026-07-06 (session 2) — the #119
portal flood), dungeon edition. Level connections here go through class is REFUTED for this issue: the "stairs" are a RAMP owned by shell
floor-portal slabs (see the #137 topology notes — corridor floors are cell 0x8A020182 (no statics anywhere near), connected by VERTICAL portals
portal polygons to under-rooms), which the render flood may treat (not floor-portals), and the portal flood admits 0x0182/0x0183 correctly
differently from wall portals. Read from every tested corridor eye/pitch (headless replays
`claude-memory/project_render_pipeline_digest.md` FIRST (Option A: one `Issue176177FacilityHubFloodReplayTests`). Membership transits are
DrawInside(viewer_cell); DO-NOT-RETRY table applies). Log plane-exact; mesh/frustum/texture layers verified healthy. Twelve
`launch-137-gate2.log` covers the session (cells around candidate mechanisms refuted — full ledger + the surviving theories +
`0x8A020182 → 0x8A020183` show a 6 → 9 level transition on stairs). the ONE-launch probe protocol in
`docs/research/2026-07-06-176-177-render-pair-investigation.md`.
NEXT: run the probe protocol (ACDREAM_PROBE_LIGHT + ACDREAM_PROBE_PVINPUT
+ ACDREAM_PROBE_CELL) during the pending #175 door-gate launch.
**Acceptance:** the staircase renders whenever its room is visible through **Acceptance:** the staircase renders whenever its room is visible through
the connecting opening, and stays rendered through the full descent. the connecting opening, and stays rendered through the full descent.
@ -89,16 +138,20 @@ 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 REMAINS — so it is a render-side issue in its own right, correlated with
camera angle. camera angle.
**Root cause / status:** unknown. Suggestive topology fact from the #137 **Root cause / status:** INVESTIGATED 2026-07-06 (session 2) — the
work: the corridor floor tiles ARE portal polygons (PortalSide floor placeholder-texture theory is REFUTED (all surfaces resolve; the floor
portals to under-rooms — e.g. 0x8A02016E polys 1/3/5 → 0x011E, spans in IS drawn: the textured PortalSide floor-portal strips are included by
`Issue137CorridorSeamInspectionTests.CorridorCell_PortalPolygonWorldSpans`), CellMesh.Build; the under-hall reciprocal is NoPos). So are eleven other
so "the floor at the seams" is exactly where portal surfaces live — mechanisms (seal z-fight, root/eye incoherence, flood bistability, DXT
suspect the portal polygon being drawn with a placeholder/uninitialized alpha/undefined mips, fog mix, lightning leak, viewer-light pops…) —
surface under some flood/culling state ([[feedback_ui_resolve_zero_magenta]]: full refutation ledger, the two surviving theories (T-A outdoor-ambient
magenta = the 1×1 placeholder texture class). Angle-dependence points at flip on playerRoot gaps: sun-on-floors explains the FLOOR selectivity;
the visibility/stencil state, not geometry. Read T-B flood misbehavior under real production inputs), and the ONE-launch
`claude-memory/project_render_pipeline_digest.md` FIRST. probe protocol in
`docs/research/2026-07-06-176-177-render-pair-investigation.md`.
The purple itself can only be the fog-tinted clear color (undrawn
pixels) or the outdoor ambient+sun tint — every other purple source in
the pipeline is now excluded. NEXT: the probe launch (see #177).
**Acceptance:** no purple/placeholder flashes on dungeon floors from any **Acceptance:** no purple/placeholder flashes on dungeon floors from any
camera angle at the corridor seams. camera angle at the corridor seams.

View file

@ -0,0 +1,134 @@
# #176/#177 render pair — investigation ledger (2026-07-06, session 2)
**Status: mechanism NOT yet pinned — but the hypothesis space is now razor-thin.**
Twelve candidate mechanisms refuted by direct evidence (dat dumps, headless
replays, production-log analysis, code reads). Every layer that can be checked
offline is verified HEALTHY at the anchor cells. The surviving discriminator
requires ONE live probe launch (protocol at the bottom — piggyback on the
pending #175 door gate).
## The issues
- **#176** — purple flashing on dungeon floors at cell seams, camera-angle
dependent (Facility Hub).
- **#177** — stairs pop in/out across levels: (a) vanish on the last step
running down, (b) invisible looking into the stair room from the corridor,
(c) pop into existence on entering.
## Anchor-cell dat truth (Issue176177DungeonSeamInspectionTests)
- Corridor `0x8A02016E` floor = three abutting TEXTURED drawn portal polys
(polys 1/3/5, surface 0x08000377 → DXT1 tex 0x050026F7, `[PortalSide]`) →
under-hall `0x011E` (z=12). Reciprocal ceiling poly = NoPos (not drawn).
- The "stairs" = a RAMP owned by `0x8A020182` (inclined drawn polys, z 9…6
floor + 6…3 ceiling); `0x0183` = flat lower cell. Connections are
VERTICAL wall portals (NOT floor-portals). PortalSide flag asymmetry:
0x0182→0x0183 carries PortalSide; the back-portal does not.
- **Zero StaticObjects in all five anchor cells** (no #119-class statics, no
torch-bearing stabs → no registered point lights except the viewer fill).
- CellBSP volumes partition EXACTLY at the portal planes (no overlap zone).
- All surfaces resolve; DXT1 textures contain **zero** transparent-mode
texels (all 3-color-mode blocks, index 3 never used).
## REFUTED mechanisms (each by direct evidence — do NOT retry)
| # | Hypothesis | Killed by |
|---|---|---|
| 1 | Placeholder/missing texture (magenta class) | All surfaces resolve; drawn-poly sweep 0 misses |
| 2 | Reciprocal portal-poly z-fight | Reciprocal is NoPos (never drawn) |
| 3 | Seal depth-stamp z-fights the drawn floor-portal | Seals fire ONLY for `OtherCellId==0xFFFF` (GameWindow:11437); a sealed dungeon draws zero seals |
| 4 | Root/eye incoherence (viewer root lags the eye across portal planes) | Production camera sweep publishes coherent pairs; out-cell flips at x=85.001/88.335 — mm-exact at the planes (gate2 log). The flood DOES collapse to 1 cell under artificially incoherent inputs (ScenarioE pin) — but production inputs are coherent |
| 5 | Membership transit lag (0.330.47 m in [cell-transit]) as the render-root lag | The resolver flips within one tick-step of the plane in the harness (Issue176177SeamTransitLagTests); the logged "lag" is speed×tick quantization of the PLAYER probe, and the camera root (probe out-cell) is plane-exact |
| 6 | Flood bistability at the anchors | ScenarioC gaze sweep (2° steps, 4 pitches): 0 one-step drops; ScenarioA stair approach: ramp+lower admitted at all tested eyes/pitches |
| 7 | Staircase = EnvCell static culled by viewcone (#119 class) | Zero statics in the anchor cells; the stairs are shell geometry |
| 8 | Undefined DXT mip levels (compressed arrays skip GenerateMipmap) | Both ObjectMeshManager texture paths DECODE DXT→RGBA8 (BcDecoder) — the compressed-array branch of ManagedGLTextureArray is dead WB-heritage code; RGBA8 arrays get real mips |
| 9 | DXT1 3-color-mode alpha=0 texels + opaque-pass `discard a<0.05` / A2C | Block histogram: 0 transparent texels in all Hub floor/wall textures |
| 10 | Fog mix toward purple FogColor at distance | Fog ramp starts at `_nearRadius×192×0.7 ≈ 538 m` (radii stay 4/12 in dungeon mode); Hub sightlines ≤ ~100 m → fog term ≡ 0 |
| 11 | Lightning-flash additive (`uFogParams.z × (0.6,0.6,0.75)`) leaking indoors | `WeatherState._flashLevel` is 0 in production ("Production never TriggerFlashes") — dormant test hook. (The missing indoor gate is still real debt for when storm strobes ship.) |
| 12 | Viewer-light per-cell/per-vertex pops (hard range edge or 8-set membership flips) | The point ramp is `(1d/range)` — smoothly ZERO at the range boundary; set-membership beyond range is a zero-contribution no-op. No torches exist in the Hub cells to churn the 8-cap |
## VERIFIED-HEALTHY layers (offline pins, keep as regression assets)
- `PortalVisibilityBuilder` at the anchors: approach/descent/gaze-sweep/walk
scenarios (`Issue176177FacilityHubFloodReplayTests`) — admissions correct
and stable with coherent inputs; the ScenarioE incoherent-input collapse is
the sensitivity pin (1-cell flood when root≠eye side).
- Membership: `ResolveWithTransition` flips cells within one tick of the
portal plane, both directions (`Issue176177SeamTransitLagTests`).
- Mesh path: `CellMesh.Build` (production, GameWindow:7013) draws the
textured floor-portal strips; snapshot frustum gate (WbFrustum) is the
standard conservative p-vertex test; per-cell AABBs are vertex-derived,
8-corner transformed.
- Per-instance light sets are truly per-instance (EnvCellRenderer MDI).
## SURVIVING theories (need the live discriminator)
- **T-A (ambient flip):** any frame where `playerRoot` resolves null (or
`playerSeenOutside` defaults true) runs the OUTDOOR lighting branch —
purple sky ambient + full sun. Sun is directional-from-above → floors
(N·L≈1) catch it, walls (N·L≈0) barely → a FLOOR-selective purple-bright
flash, temporally lockable to whatever gaps CurrCell/TryGetCell. Desk
analysis found no per-frame gap trigger during plain corridor runs
(UpdatePlayerCurrCell is stale-beats-null; the registry is stable in
AP-36 dungeon mode) — but the branch inputs are exactly probed by
`[light] insideCell=` lines, so one run settles it.
- **T-B (flood with REAL production inputs):** my harness feeds synthetic
viewProj/eye. If the real per-frame inputs at the artifact moments differ
(collided camera pressed into walls near seams, damped-forward gaze), the
flood could still misbehave in configurations the sweeps missed.
`[pv-input]` prints the exact inputs + flood count per frame.
- **T-C:** unknown-unknown (GPU state, driver, MSAA resolve…). If T-A/T-B
both read clean at a flash moment → RenderDoc frame capture next.
## Also found (real, filed, not these bugs)
- **A8 double-sided shells stopgap still live**: `EnvCellRenderer.cs`
RenderModernMDIInternal maps `CullMode.Landblock → CullMode.None`
("while the architectural cause is isolated") — all cell shells draw
two-sided. Perf + correctness debt; retire under the A7/A8 lighting arc.
- **Lightning indoor gate missing** (dormant): when weather strobes ship,
`SceneLightingUbo.Build` needs the `playerInsideCell` gate or dungeons
will strobe blue-violet.
- **Flood follows floor-portals DOWNWARD from above** (ScenarioC: under-hall
network admitted at down-pitches). Retail's `portal_side` side test
(0x005a59a0: portal_side≠0 → NEGATIVE side only; ==0 → POSITIVE only;
IN_PLANE(±0.0002) → refused) *appears* to refuse this direction, but my
plane-sign reading had unresolved contradictions (CCellPortal::UnPack
normalizes portal_side at load, 0x0053ba1c/0x0053bc6a). OPEN QUESTION —
harmless-looking (extra admitted cells draw below the opaque floor,
z-buffer wins) but worth settling when the flood is next touched.
## The live probe protocol (piggyback on the #175 door gate launch)
Env (add to the standard launch block):
```
ACDREAM_PROBE_LIGHT=1 # [light] insideCell/ambient/sun — rate-limited
ACDREAM_PROBE_PVINPUT=1 # [pv-input] one line/frame: exact flood inputs + count
ACDREAM_PROBE_CELL=1 # [cell-transit] timeline anchors
```
User reproduces in Facility Hub (~2 min): run the corridor across several
seams until the purple flash shows; approach the stair room from the
corridor, walk in, run down. Then close. Discrimination:
- Flash moments + `[light]` shows `insideCell=False` blips or ambient jumps
**T-A confirmed** (then root-cause the gap trigger from the same log).
- `[pv-input]` flood count drops (e.g. 12→1) at flash/pop moments while
`[light]` stays clean → **T-B confirmed** (the line carries the exact
inputs to replay in `Issue176177FacilityHubFloodReplayTests`).
- Stairs invisible while the flood contains 0x0182/0x0183 → draw-side hunt
(RenderDoc next); absent → flood-side with the printed inputs.
- All clean → T-C: RenderDoc frame capture of a flash frame.
## Apparatus shipped this session
- `tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs`
— dat truth: portal polys/surfaces/draw verdicts, reciprocal coincidence,
stair geometry owner, CellBSP containment, under-hall surface colors,
DXT1 alpha histograms.
- `tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs`
— production-matched flood replays: stair approach, descent, gaze sweep,
corridor walk, and the ScenarioE incoherent-root sensitivity pin.
- `tests/AcDream.Core.Tests/Physics/Issue176177SeamTransitLagTests.cs`
— resolver cell-flip position at the seam (plane-exact pin).

View file

@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #176 (purple flashing at corridor seams, camera-angle dependent) + #177
/// (stairs pop in/out) — headless portal-flood replay in the Facility Hub
/// landblock 0x8A02. The unified hypothesis after the dat + draw-path reads:
/// both artifacts are FLOOD ADMISSION instability (a cell dropping out of
/// PortalVisibilityBuilder's admitted set paints the fog-purple clear color
/// where its geometry was; stair cells failing admission = the pop).
///
/// Production-matched inputs: Build(root, eye, lookup, viewProj,
/// buildingMembership: null, drawLiftZ: ShellDrawLiftZ) — the drawLiftZ
/// mirrors RetailPViewRenderer.DrawInside.
///
/// Scenarios:
/// A. #177 approach — stand in corridor 0x0178, look +X at the stair ramp
/// (0x0182) and the lower cell (0x0183): are they admitted?
/// B. #177 descent — eye path down the ramp crossing into 0x0183: does
/// 0x0182 (the ramp geometry's owner) drop near the transit?
/// C. #176 gaze sweep — eye parked in 0x016E near the 0x017A seam, yaw
/// sweep at several pitches: any cell admitted at angle k, gone at k+1,
/// back at k+2 (the bistability signature)?
/// D. #176 walk — eye tracks down the corridor across two seams, gaze
/// locked +X: per-step admitted-set diffs (drop-for-one-step churn).
/// </summary>
public class Issue176177FacilityHubFloodReplayTests
{
private const uint FacilityHub = 0x8A020000u;
private readonly ITestOutputHelper _out;
public Issue176177FacilityHubFloodReplayTests(ITestOutputHelper output) => _out = output;
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 gazeDir)
{
var view = Matrix4x4.CreateLookAt(eye, eye + gazeDir, Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f);
return view * proj;
}
private static List<uint> Flood(
Dictionary<uint, LoadedCell> cells, uint rootId, Vector3 eye, Vector3 gazeDir)
{
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
var frame = PortalVisibilityBuilder.Build(
cells[rootId], eye, lookup, ViewProjFor(eye, gazeDir),
buildingMembership: null,
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
var result = new List<uint>(frame.OrderedVisibleCells);
result.Sort();
return result;
}
private static string CellSetString(IEnumerable<uint> ids)
=> string.Join(" ", ids.Select(id => $"{id & 0xFFFFu:X4}"));
[Fact]
public void ScenarioA_StairApproach_AdmissionsFromCorridor()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
Assert.True(cells.ContainsKey(FacilityHub | 0x0178u), "0x0178 not loaded");
// 0x0178 spans x<=95 at z 6..3 (floor 6); the ramp cell 0x0182 runs
// x 95→98.33 descending; 0x0183 continues at z 9 beyond x=98.33.
// Eye at standing height (~1.7 m above the 6 floor), approaching the
// stair portal at x=95, gazing +X with a slight downward pitch (the
// natural look at a descending stair).
foreach (float eyeX in new[] { 88f, 90f, 92f, 94f, 94.9f })
{
var eye = new Vector3(eyeX, -40f, -4.3f);
foreach (var (gaze, label) in new (Vector3, string)[]
{
(new Vector3(1f, 0f, 0f), "level"),
(Vector3.Normalize(new Vector3(1f, 0f, -0.35f)), "pitch-19"),
(Vector3.Normalize(new Vector3(1f, 0f, -0.7f)), "pitch-35"),
})
{
var visible = Flood(cells, FacilityHub | 0x0178u, eye, gaze);
bool ramp = visible.Contains(FacilityHub | 0x0182u);
bool lower = visible.Contains(FacilityHub | 0x0183u);
_out.WriteLine($"eyeX={eyeX,5:F1} gaze={label,-8} flood={visible.Count,2} " +
$"ramp0182={(ramp ? "Y" : "MISSING")} lower0183={(lower ? "Y" : "MISSING")} " +
$"[{CellSetString(visible)}]");
}
}
}
[Fact]
public void ScenarioB_StairDescent_RampCellRetention()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
// Descend the ramp: floor z goes 6 (x=95) → 9 (x=98.33), then flat.
// Eye rides ~1.7 m above the local floor. Root = the cell containing
// the eye by x-range (0x0178 x<95, ramp 0x0182 95..98.33, 0x0183 after).
// Gaze: forward and slightly down (running down stairs).
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.4f));
List<uint>? prev = null;
for (float x = 94.0f; x <= 100.5f; x += 0.1f)
{
float floorZ = x < 95f ? -6f
: x < 98.333f ? -6f - 3f * (x - 95f) / 3.333f
: -9f;
var eye = new Vector3(x, -40f, floorZ + 1.7f);
uint rootId = x < 95f ? FacilityHub | 0x0178u
: x < 98.333f ? FacilityHub | 0x0182u
: FacilityHub | 0x0183u;
var visible = Flood(cells, rootId, eye, gazeDir);
bool ramp = visible.Contains(FacilityHub | 0x0182u);
bool upper = visible.Contains(FacilityHub | 0x0178u);
string diff = "";
if (prev is not null)
{
var removed = prev.Except(visible).ToList();
var added = visible.Except(prev).ToList();
if (removed.Count > 0) diff += $" REMOVED=[{CellSetString(removed)}]";
if (added.Count > 0) diff += $" added=[{CellSetString(added)}]";
}
_out.WriteLine($"x={x,6:F1} root={rootId & 0xFFFFu:X4} eyeZ={eye.Z,6:F2} flood={visible.Count,2} " +
$"ramp0182={(ramp ? "Y" : "MISSING")} up0178={(upper ? "Y" : "-")}{diff}");
prev = visible;
}
}
[Fact]
public void ScenarioC_CorridorSeamGazeSweep_Bistability()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
// Parked eye in 0x016E near the 0x017A seam (x=85), standing height.
var eye = new Vector3(83.5f, -40f, -4.3f);
uint rootId = FacilityHub | 0x016Eu;
int churnEvents = 0;
foreach (float pitchZ in new[] { 0f, -0.35f, -0.75f, -1.2f })
{
List<uint>? prevSet = null;
float prevYaw = 0f;
var perYawSets = new List<(float yaw, List<uint> set)>();
for (float yawDeg = -180f; yawDeg <= 180f; yawDeg += 2f)
{
float rad = yawDeg * MathF.PI / 180f;
var gaze = Vector3.Normalize(new Vector3(MathF.Cos(rad), MathF.Sin(rad), pitchZ));
var visible = Flood(cells, rootId, eye, gaze);
perYawSets.Add((yawDeg, visible));
if (prevSet is not null)
{
var removed = prevSet.Except(visible).ToList();
var added = visible.Except(prevSet).ToList();
if (removed.Count > 0 || added.Count > 0)
{
_out.WriteLine($"pitch={pitchZ,5:F2} yaw {prevYaw,6:F0}->{yawDeg,6:F0}: " +
$"flood {prevSet.Count}->{visible.Count}" +
(removed.Count > 0 ? $" REMOVED=[{CellSetString(removed)}]" : "") +
(added.Count > 0 ? $" added=[{CellSetString(added)}]" : ""));
}
}
prevSet = visible;
prevYaw = yawDeg;
}
// Bistability: a cell present at yaw k, absent at k+1, present at k+2.
for (int i = 2; i < perYawSets.Count; i++)
{
var flicker = perYawSets[i - 2].set
.Intersect(perYawSets[i].set)
.Except(perYawSets[i - 1].set)
.ToList();
if (flicker.Count > 0)
{
churnEvents++;
_out.WriteLine($">>> BISTABLE pitch={pitchZ:F2} yaw={perYawSets[i - 1].yaw:F0}: " +
$"cells [{CellSetString(flicker)}] dropped for ONE 2-degree step");
}
}
}
_out.WriteLine($"bistable one-step drop events: {churnEvents}");
}
/// <summary>
/// THE production lag-window scenario (from launch-137-gate2.log
/// [cell-transit] lines): membership transits fire 0.10.6 m PAST the
/// portal plane in the travel direction (016E→017A at x=85.3385.47 vs
/// the plane at x=85.00; 0182→0183 at 98.5698.64 vs 98.33). The render
/// root (viewer cell, same membership machinery) therefore holds the OLD
/// cell while the camera eye is already beyond the boundary portal's
/// plane. This scenario reproduces exactly that window: root=old cell,
/// eye stepped across and past the plane, gaze forward. If the forward
/// chain (the next corridor cells) drops inside the window, that is #176
/// (purple = fog clear color where the forward cells' geometry was) and
/// #177(a)/(c) at the stair transit.
/// </summary>
[Theory]
[InlineData(0x016Eu, 0x017Au, 85.00f, -4.3f)] // corridor seam, plane x=85
[InlineData(0x0182u, 0x0183u, 98.333f, -7.3f)] // stair-bottom transit, plane x=98.33
public void ScenarioE_RootLagWindow_ForwardChainRetention(
uint rootLow, uint forwardLow, float planeX, float eyeZ)
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
uint rootId = FacilityHub | rootLow;
uint forwardId = FacilityHub | forwardLow;
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.3f));
_out.WriteLine($"root=0x{rootLow:X4} forward=0x{forwardLow:X4} plane x={planeX:F2} " +
"(eye sweeps across; root HELD at the old cell = the production lag window)");
foreach (float dx in new[] { -0.30f, -0.10f, -0.02f, 0.00f, 0.02f, 0.05f, 0.10f, 0.20f, 0.30f, 0.45f, 0.60f })
{
var eye = new Vector3(planeX + dx, -40f, eyeZ);
var visible = Flood(cells, rootId, eye, gazeDir);
bool fwd = visible.Contains(forwardId);
_out.WriteLine($" eyeX=plane{(dx >= 0 ? "+" : "")}{dx:F2} flood={visible.Count,2} " +
$"forward={(fwd ? "Y" : ">>> DROPPED <<<")} [{CellSetString(visible)}]");
}
}
[Fact]
public void ScenarioD_CorridorWalk_PerStepChurn()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
// Walk 0x0165 → 0x016E → 0x017A along y=40 (seams at x=75 and x=85),
// gaze locked +X, slight downward pitch (the running view). Root flips
// by x-range at the seams (the camera transits the same portals).
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.3f));
List<uint>? prev = null;
int churn = 0;
for (float x = 71f; x <= 89f; x += 0.05f)
{
uint rootId = x < 75f ? FacilityHub | 0x0165u
: x < 85f ? FacilityHub | 0x016Eu
: FacilityHub | 0x017Au;
if (!cells.ContainsKey(rootId)) continue;
var eye = new Vector3(x, -40f, -4.3f);
var visible = Flood(cells, rootId, eye, gazeDir);
if (prev is not null)
{
var removed = prev.Except(visible).ToList();
var added = visible.Except(prev).ToList();
if (removed.Count > 0 || added.Count > 0)
{
churn++;
_out.WriteLine($"x={x,6:F2} root={rootId & 0xFFFFu:X4} flood={visible.Count,2}" +
(removed.Count > 0 ? $" REMOVED=[{CellSetString(removed)}]" : "") +
(added.Count > 0 ? $" added=[{CellSetString(added)}]" : ""));
}
}
prev = visible;
}
_out.WriteLine($"admitted-set change events over the walk: {churn}");
}
}

View file

@ -0,0 +1,157 @@
using System;
using System.IO;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
using Env = System.Environment;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #176/#177 membership half: production [cell-transit] lines
/// (launch-137-gate2.log) fire 0.10.6 m PAST the portal plane in the travel
/// direction (016E→017A at x=85.3385.47 vs the plane at x=85.00), while the
/// dat CellBSP volumes partition EXACTLY at the plane
/// (Issue176177DungeonSeamInspectionTests.SeamCells_CellBspContainment) —
/// retail's center-only point_in_cell flips at the plane. The render root
/// (viewer cell) resolves through the same machinery; while it lags, the
/// portal flood correctly refuses the boundary portal the eye has already
/// crossed and the whole forward chain drops (the purple seam flash /
/// stair pop). This replay measures OUR resolver's flip point across the
/// x=85 seam in a controlled run.
/// </summary>
public class Issue176177SeamTransitLagTests
{
private const uint SeamCellWest = 0x8A02016Eu; // x 75..85
private const uint SeamCellEast = 0x8A02017Au; // x 85..88.33
private readonly ITestOutputHelper _out;
public Issue176177SeamTransitLagTests(ITestOutputHelper output) => _out = output;
private static string? ResolveDatDir()
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(datDir) ? datDir : null;
}
private static PhysicsEngine BuildEngine(DatCollection dats)
{
var engine = new PhysicsEngine();
engine.DataCache = new PhysicsDataCache();
var toLoad = new System.Collections.Generic.HashSet<uint> { SeamCellWest, SeamCellEast };
for (int ring = 0; ring < 3; ring++)
{
foreach (var known in new System.Collections.Generic.List<uint>(toLoad))
{
var cell = dats.Get<EnvCell>(known);
if (cell is null) continue;
foreach (var p in cell.CellPortals)
toLoad.Add(0x8A020000u | p.OtherCellId);
}
}
foreach (var cellId in toLoad)
{
var envCell = dats.Get<EnvCell>(cellId);
if (envCell is null) continue;
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
if (environment is null) continue;
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) continue;
var rot = new Quaternion(
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
var world = Matrix4x4.CreateFromQuaternion(rot)
* Matrix4x4.CreateTranslation(
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
engine.DataCache.CacheCellStruct(cellId, envCell, cs!, world);
}
return engine;
}
private static PhysicsBody GroundedBody()
{
var body = new PhysicsBody();
body.ContactPlaneValid = true;
body.ContactPlane = new Plane(Vector3.UnitZ, 6f);
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
body.WalkablePolygonValid = true;
body.WalkablePlane = new Plane(Vector3.UnitZ, 6f);
body.WalkableUp = Vector3.UnitZ;
body.WalkableVertices = new[]
{
new Vector3(75f, -41.67f, -6f),
new Vector3(85f, -41.67f, -6f),
new Vector3(85f, -38.33f, -6f),
new Vector3(75f, -38.33f, -6f),
};
return body;
}
/// <summary>
/// Run +X across the x=85 seam at run-speed tick steps (13.5 cm/tick ≈
/// 4 m/s at 30 Hz) and record where ResolveWithTransition's CellId flips.
/// Retail (center-only point_in_cell, exact-partition CellBSP) flips on
/// the first tick whose END position is past x=85.00 — any flip later
/// than one step past the plane is OUR lag.
/// </summary>
[Theory]
[InlineData(+1)] // west → east across x=85
[InlineData(-1)] // east → west back across
public void RunAcrossSeam_CellFlipPosition(int direction)
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var engine = BuildEngine(dats);
var body = GroundedBody();
const float step = 0.135f;
float startX = direction > 0 ? 83.8f : 86.2f;
uint cell = direction > 0 ? SeamCellWest : SeamCellEast;
var pos = new Vector3(startX, -40f, -6f);
float? flipX = null;
for (int tick = 0; tick < 26; tick++)
{
var target = pos + new Vector3(direction * step, 0f, 0f);
var r = engine.ResolveWithTransition(
currentPos: pos,
targetPos: target,
cellId: cell,
sphereRadius: 0.48f,
sphereHeight: 1.835f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
isOnGround: true,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
bool flipped = r.CellId != cell;
_out.WriteLine($"tick={tick,2} pos=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " +
$"cell=0x{r.CellId:X8} ok={r.Ok}{(flipped ? " <<< FLIP" : "")}");
if (flipped && flipX is null)
flipX = r.Position.X;
cell = r.CellId;
pos = r.Position;
if (direction > 0 && pos.X > 86.4f) break;
if (direction < 0 && pos.X < 83.6f) break;
}
Assert.NotNull(flipX);
float lag = direction > 0 ? flipX!.Value - 85.00f : 85.00f - flipX!.Value;
_out.WriteLine($"flip at x={flipX:F3} → lag past the plane = {lag:F3} m " +
$"(one-tick quantization bound = {step:F3} m)");
// Diagnostic, not a pin: the finding is the printed lag. A lag beyond
// one tick step is the divergence under investigation.
}
}

View file

@ -0,0 +1,533 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
using Env = System.Environment;
namespace AcDream.Core.Tests.Rendering;
/// <summary>
/// #176 (purple flashing on dungeon floors at cell seams) + #177 (stairs pop
/// in/out across levels) — dat-truth inspection for the Facility Hub anchor
/// cells. The load-bearing topology fact from the #137 arc: corridor FLOORS
/// are portal polygons (PortalSide floor-portals to under-rooms, e.g.
/// 0x8A02016E visual polys 1/3/5 → 0x011E). These dumps answer:
///
/// (a) are the floor-portal VISUAL polys textured (drawn by CellMesh.Build)
/// or NoPos-stippled (skipped)? Same question for the RECIPROCAL portal
/// polys in the other cell — two textured coincident planes would
/// z-fight (#176's angle-dependent flash candidate);
/// (b) which cell owns the actual stair-step geometry at the
/// 0x8A020182 → 0x8A020183 level transit (#177's pop-in subject);
/// (c) do any drawn polys reference surfaces that fail to resolve
/// (the magenta-placeholder class)?
///
/// ⚠️ id-space trap (cost the #137 saga a wrong mechanism):
/// CellPortal.PolygonId indexes CellStruct.Polygons (VISUAL), never
/// PhysicsPolygons — same ids in both tables are unrelated polygons.
/// </summary>
public class Issue176177DungeonSeamInspectionTests
{
private readonly ITestOutputHelper _out;
public Issue176177DungeonSeamInspectionTests(ITestOutputHelper output) => _out = output;
private static string? ResolveDatDir()
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(datDir) ? datDir : null;
}
private static Matrix4x4 WorldTransform(EnvCell cell)
{
var rot = new Quaternion(
cell.Position.Orientation.X, cell.Position.Orientation.Y,
cell.Position.Orientation.Z, cell.Position.Orientation.W);
return Matrix4x4.CreateFromQuaternion(rot)
* Matrix4x4.CreateTranslation(
cell.Position.Origin.X, cell.Position.Origin.Y, cell.Position.Origin.Z);
}
private static (EnvCell cell, DatReaderWriter.Types.CellStruct cs)? LoadCell(DatCollection dats, uint cellId)
{
var envCell = dats.Get<EnvCell>(cellId);
if (envCell is null) return null;
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
if (environment is null) return null;
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) return null;
return (envCell, cs!);
}
private static List<Vector3> WorldVerts(
DatReaderWriter.Types.CellStruct cs, DatReaderWriter.Types.Polygon poly, Matrix4x4 world)
{
var result = new List<Vector3>(poly.VertexIds.Count);
foreach (var vid in poly.VertexIds)
if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v))
result.Add(Vector3.Transform(v.Origin, world));
return result;
}
/// <summary>
/// Mirror of CellMesh.Build's inclusion rules (verts ≥ 3, no NoPos
/// stippling, PosSurface index in range) — the DRAWN verdict per poly.
/// </summary>
private static bool WouldDraw(DatReaderWriter.Types.Polygon poly, EnvCell cell) =>
poly.VertexIds.Count >= 3
&& !poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.NoPos)
&& poly.PosSurface >= 0
&& poly.PosSurface < cell.Surfaces.Count;
/// <summary>
/// (a)+(c): every CellPortal of the cell — the visual portal poly's
/// stippling/sides/surface, world plane, span, DRAWN verdict, and whether
/// the referenced Surface resolves in the dat.
/// </summary>
[Theory]
[InlineData(0x8A02016Eu)] // corridor with floor-portals 1/3/5 → 0x011E (#176 anchor)
[InlineData(0x8A02011Eu)] // the under-hall at z=12 those portals lead to
[InlineData(0x8A02017Au)] // adjacent corridor cell (the #137 seam partner)
[InlineData(0x8A020182u)] // stair transit upper cell, z 6 (#177 anchor)
[InlineData(0x8A020183u)] // stair transit lower cell, z 9 (#177 anchor)
public void PortalPolys_SurfaceAndDrawVerdict_Dump(uint cellId)
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var loaded = LoadCell(dats, cellId);
Assert.NotNull(loaded);
var (cell, cs) = loaded!.Value;
var world = WorldTransform(cell);
_out.WriteLine($"=== 0x{cellId:X8} Env=0x{cell.EnvironmentId:X4} struct={cell.CellStructure} " +
$"pos=({cell.Position.Origin.X:F2},{cell.Position.Origin.Y:F2},{cell.Position.Origin.Z:F2}) ===");
_out.WriteLine($" Surfaces ({cell.Surfaces.Count}): " +
string.Join(" ", cell.Surfaces.ConvertAll(s => $"0x{0x08000000u | (uint)s:X8}")));
_out.WriteLine($" visualPolys={cs.Polygons.Count} physicsPolys={cs.PhysicsPolygons.Count} portals={cell.CellPortals.Count}");
// #177 pivot check: dungeon staircases are often EnvCell STATICS (the
// #119 tower class) — if one lives here, the vanish subject is the
// static's cull, not the shell flood.
_out.WriteLine($" StaticObjects={cell.StaticObjects.Count}");
foreach (var so in cell.StaticObjects)
_out.WriteLine($" static id=0x{so.Id:X8} at ({so.Frame.Origin.X:F2},{so.Frame.Origin.Y:F2},{so.Frame.Origin.Z:F2})");
foreach (var p in cell.CellPortals)
{
if (!cs.Polygons.TryGetValue((ushort)p.PolygonId, out var poly))
{
_out.WriteLine($" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] NOT IN VISUAL TABLE");
continue;
}
var w = WorldVerts(cs, poly, world);
var n = w.Count >= 3
? Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0]))
: Vector3.Zero;
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); }
bool drawn = WouldDraw(poly, cell);
string surfInfo = "posSurf=OUT-OF-RANGE";
if (poly.PosSurface >= 0 && poly.PosSurface < cell.Surfaces.Count)
{
uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface];
var surface = dats.Get<Surface>(surfaceId);
surfInfo = surface is null
? $"posSurf[{poly.PosSurface}]=0x{surfaceId:X8} SURFACE-MISS"
: $"posSurf[{poly.PosSurface}]=0x{surfaceId:X8} type={surface.Type} origTex=0x{(uint)surface.OrigTextureId:X8}";
}
_out.WriteLine(
$" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] " +
$"stip={poly.Stippling} sides={poly.SidesType} verts={poly.VertexIds.Count} " +
$"n=({n.X:F2},{n.Y:F2},{n.Z:F2}) " +
$"x=[{min.X:F2},{max.X:F2}] y=[{min.Y:F2},{max.Y:F2}] z=[{min.Z:F2},{max.Z:F2}] " +
$"{surfInfo} DRAWN={drawn}");
}
// (c) sweep: any DRAWN poly in the whole cell whose surface misses.
int drawnCount = 0, missCount = 0;
foreach (var (id, poly) in cs.Polygons)
{
if (!WouldDraw(poly, cell)) continue;
drawnCount++;
uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface];
if (dats.Get<Surface>(surfaceId) is null)
{
missCount++;
_out.WriteLine($" >>> DRAWN poly {id} has MISSING surface 0x{surfaceId:X8}");
}
}
_out.WriteLine($" drawn-poly sweep: {drawnCount} drawn, {missCount} with missing surfaces");
}
/// <summary>
/// (a) reciprocal check: for each anchor pair, world-transform BOTH
/// sides' portal polys and test plane coincidence + both-drawn — the
/// #176 z-fight candidate is a coincident pair with DRAWN=true twice.
/// </summary>
[Theory]
[InlineData(0x8A02016Eu, 0x8A02011Eu)]
[InlineData(0x8A02016Eu, 0x8A02017Au)]
[InlineData(0x8A020182u, 0x8A020183u)]
public void ReciprocalPortalPolys_CoincidenceAndDrawVerdict(uint cellA, uint cellB)
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var la = LoadCell(dats, cellA);
var lb = LoadCell(dats, cellB);
Assert.NotNull(la);
Assert.NotNull(lb);
var (ca, csa) = la!.Value;
var (cb, csb) = lb!.Value;
var wa = WorldTransform(ca);
var wb = WorldTransform(cb);
ushort lowA = (ushort)(cellA & 0xFFFFu);
ushort lowB = (ushort)(cellB & 0xFFFFu);
_out.WriteLine($"=== reciprocal pair 0x{cellA:X8} <-> 0x{cellB:X8} ===");
foreach (var pa in ca.CellPortals)
{
if (pa.OtherCellId != lowB) continue;
if (!csa.Polygons.TryGetValue((ushort)pa.PolygonId, out var polyA)) continue;
var va = WorldVerts(csa, polyA, wa);
if (va.Count < 3) continue;
var na = Vector3.Normalize(Vector3.Cross(va[1] - va[0], va[2] - va[0]));
float da = Vector3.Dot(na, va[0]);
bool drawnA = WouldDraw(polyA, ca);
_out.WriteLine($" A poly={pa.PolygonId} [{pa.Flags}] n=({na.X:F2},{na.Y:F2},{na.Z:F2}) planeD={da:F2} " +
$"stip={polyA.Stippling} sides={polyA.SidesType} DRAWN={drawnA}");
foreach (var pb in cb.CellPortals)
{
if (pb.OtherCellId != lowA) continue;
if (!csb.Polygons.TryGetValue((ushort)pb.PolygonId, out var polyB)) continue;
var vb = WorldVerts(csb, polyB, wb);
if (vb.Count < 3) continue;
var nb = Vector3.Normalize(Vector3.Cross(vb[1] - vb[0], vb[2] - vb[0]));
float db = Vector3.Dot(nb, vb[0]);
bool drawnB = WouldDraw(polyB, cb);
float align = Vector3.Dot(na, nb);
// Coincident planes: |align|≈1 and same plane offset (sign per normal direction).
bool coincident = MathF.Abs(align) > 0.99f
&& MathF.Abs(MathF.Abs(da) - MathF.Abs(db)) < 0.05f;
_out.WriteLine($" B poly={pb.PolygonId} [{pb.Flags}] n=({nb.X:F2},{nb.Y:F2},{nb.Z:F2}) planeD={db:F2} " +
$"stip={polyB.Stippling} sides={polyB.SidesType} DRAWN={drawnB} " +
$"align={align:F3} coincident={coincident}" +
(coincident && drawnA && drawnB ? " >>> Z-FIGHT CANDIDATE (both drawn, same plane)" : ""));
}
}
}
/// <summary>
/// #176 candidate (A2C): the opaque pass derives GL_SAMPLE_ALPHA_TO_COVERAGE
/// from the sampled texture alpha (mesh_modern.frag uRenderPass==0 keeps
/// alpha as-sampled). If the corridor floor texture decodes with alpha
/// below 1.0, MSAA coverage punches see-through holes in the floor —
/// fog-purple clear color — worst at grazing angles (mip-level dependent
/// → camera-angle dependent, far floor = at seams). Decode the floor
/// surface chain and histogram the alpha channel.
/// </summary>
[Theory]
[InlineData(0x08000377u)] // corridor floor (portal strips + plain floors)
[InlineData(0x08000376u)]
[InlineData(0x08000375u)]
[InlineData(0x08000378u)]
[InlineData(0x08000379u)] // under-level walls
[InlineData(0x080000DFu)] // stair-transit cells' 5th surface
public void FloorSurface_DecodedAlphaHistogram(uint surfaceId)
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var surface = dats.Get<Surface>(surfaceId);
Assert.NotNull(surface);
_out.WriteLine($"Surface 0x{surfaceId:X8}: type={surface!.Type} origTex=0x{(uint)surface.OrigTextureId:X8} " +
$"transl={surface.Translucency:F2}");
if (surface.OrigTextureId == 0) { _out.WriteLine(" (no texture — solid color surface)"); return; }
var surfTex = dats.Get<SurfaceTexture>((uint)surface.OrigTextureId);
Assert.NotNull(surfTex);
_out.WriteLine($" SurfaceTexture 0x{(uint)surface.OrigTextureId:X8}: {surfTex!.Textures.Count} textures " +
$"[{string.Join(" ", surfTex.Textures.ConvertAll(t => $"0x{t:X8}"))}]");
foreach (var texId in surfTex.Textures)
{
var rs = dats.Get<RenderSurface>((uint)texId);
if (rs is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: MISS"); continue; }
// Decode with the production Core helpers (same paths the WB atlas uses).
var data = new byte[rs.Width * rs.Height * 4];
bool decodedOk = true;
switch (rs.Format)
{
case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16:
{
var pal = dats.Get<Palette>(rs.DefaultPaletteId);
if (pal is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: INDEX16 with no palette 0x{rs.DefaultPaletteId:X8}"); decodedOk = false; break; }
AcDream.Core.Rendering.Wb.TextureHelpers.FillIndex16(rs.SourceData, pal, data, rs.Width, rs.Height);
break;
}
case DatReaderWriter.Enums.PixelFormat.PFID_P8:
{
var pal = dats.Get<Palette>(rs.DefaultPaletteId);
if (pal is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: P8 with no palette"); decodedOk = false; break; }
AcDream.Core.Rendering.Wb.TextureHelpers.FillP8(rs.SourceData, pal, data, rs.Width, rs.Height);
break;
}
case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5:
AcDream.Core.Rendering.Wb.TextureHelpers.FillR5G6B5(rs.SourceData, data, rs.Width, rs.Height);
break;
case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4:
AcDream.Core.Rendering.Wb.TextureHelpers.FillA4R4G4B4(rs.SourceData, data, rs.Width, rs.Height);
break;
case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8:
AcDream.Core.Rendering.Wb.TextureHelpers.FillA8R8G8B8(rs.SourceData, data, rs.Width, rs.Height);
break;
case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8:
AcDream.Core.Rendering.Wb.TextureHelpers.FillR8G8B8(rs.SourceData, data, rs.Width, rs.Height);
break;
case DatReaderWriter.Enums.PixelFormat.PFID_DXT1:
{
// DXT1/BC1: 8-byte blocks — c0 (u16 LE), c1 (u16 LE), 16×2-bit
// indices. c0 <= c1 selects 3-COLOR mode where index 3 decodes
// to TRANSPARENT BLACK (alpha=0). Our atlas uploads DXT1 as the
// RGBA variant (TextureFormatExtensions.ToCompressedGL), so any
// such texel reaches the shader with alpha=0 — and the opaque
// pass discards alpha<0.05 fragments. Count them.
int blocks = rs.SourceData.Length / 8;
int threeColorBlocks = 0;
long transparentTexels = 0;
for (int b = 0; b < blocks; b++)
{
int o = b * 8;
ushort c0 = (ushort)(rs.SourceData[o] | (rs.SourceData[o + 1] << 8));
ushort c1 = (ushort)(rs.SourceData[o + 2] | (rs.SourceData[o + 3] << 8));
if (c0 > c1) continue; // 4-color opaque mode
threeColorBlocks++;
for (int bi = 0; bi < 4; bi++)
{
byte row = rs.SourceData[o + 4 + bi];
for (int t = 0; t < 4; t++)
if (((row >> (t * 2)) & 0x3) == 3)
transparentTexels++;
}
}
_out.WriteLine($" RenderSurface 0x{texId:X8} DXT1 {rs.Width}x{rs.Height}: blocks={blocks} " +
$"threeColorBlocks={threeColorBlocks} alpha0Texels={transparentTexels}" +
(transparentTexels > 0
? " >>> ALPHA=0 TEXELS PRESENT (opaque-pass discard punches holes)"
: " (no transparent-mode texels)"));
decodedOk = false; // histogram printed above; skip the RGBA path
break;
}
default:
_out.WriteLine($" RenderSurface 0x{texId:X8}: fmt={rs.Format} (not decoded by this test)");
decodedOk = false;
break;
}
if (!decodedOk) continue;
// Alpha histogram over the decoded RGBA bytes (stride 4, alpha at +3).
int n = data.Length / 4;
int a255 = 0, aHigh = 0, aMid = 0, aLow = 0, a0 = 0;
byte minA = 255;
for (int i = 0; i < n; i++)
{
byte a = data[i * 4 + 3];
if (a < minA) minA = a;
if (a == 255) a255++;
else if (a >= 243) aHigh++; // ≥0.95 — safe for A2C
else if (a >= 13) aMid++; // 0.05..0.95 — partial coverage
else if (a > 0) aLow++;
else a0++;
}
_out.WriteLine($" RenderSurface 0x{texId:X8} fmt={rs.Format} {rs.Width}x{rs.Height}: " +
$"alpha histogram n={n} a=255:{a255} 243-254:{aHigh} 13-242:{aMid} 1-12:{aLow} 0:{a0} minA={minA}" +
(aMid + aLow + a0 > 0 ? " >>> SUB-OPAQUE ALPHA PRESENT (A2C hole candidate)" : " (fully opaque)"));
}
}
/// <summary>
/// #176 candidate: the under-hall 0x011E floods in at down-pitches and
/// its surface list carries 0x08000034 (Base1Solid|Translucent — a
/// COLORED translucent solid). If its drawn polys sit at z=6 (coplanar
/// with the corridor floor), the transparent pass blends that color over
/// the floor whenever the under-hall is admitted — angle-dependent
/// purple at seams. Dump every DRAWN poly (plane, z-span, surface, and
/// the surface's ColorValue) of the under-hall + its under-level
/// neighbors.
/// </summary>
[Theory]
[InlineData(0x8A02011Eu)]
[InlineData(0x8A020119u)]
[InlineData(0x8A02011Du)]
[InlineData(0x8A020122u)]
[InlineData(0x8A02011Fu)]
public void UnderHall_DrawnPolys_SurfaceColors(uint cellId)
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var loaded = LoadCell(dats, cellId);
if (loaded is null) { _out.WriteLine($"0x{cellId:X8} NOT FOUND"); return; }
var (cell, cs) = loaded.Value;
var world = WorldTransform(cell);
_out.WriteLine($"=== 0x{cellId:X8} drawn polys ===");
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 minZ = float.MaxValue, maxZ = float.MinValue;
foreach (var v in w) { minZ = MathF.Min(minZ, v.Z); maxZ = MathF.Max(maxZ, v.Z); }
uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface];
var surface = dats.Get<Surface>(surfaceId);
string surfInfo = surface is null
? $"0x{surfaceId:X8} MISS"
: $"0x{surfaceId:X8} type={surface.Type} color=0x{surface.ColorValue:X8} origTex=0x{(uint)surface.OrigTextureId:X8} lum={surface.Luminosity:F2} transl={surface.Translucency:F2}";
_out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{minZ:F2},{maxZ:F2}] " +
$"verts={poly.VertexIds.Count} surf={surfInfo}");
}
}
/// <summary>
/// The transit-lag question (#176/#177 root-cause fork): production
/// [cell-transit] lines fire 0.10.6 m PAST the portal plane. Is that
/// (a) dat-real — the cells' CellBSP volumes OVERLAP past the plane, so
/// retail's point_in_cell (same dat, same walk) keeps the old cell too —
/// or (b) our membership's bug? Probe raw CellBSP containment for both
/// cells of each seam across the portal plane.
/// </summary>
[Theory]
[InlineData(0x8A02016Eu, 0x8A02017Au, 85.00f, -40f, -5.0f)]
[InlineData(0x8A020182u, 0x8A020183u, 98.333f, -40f, -7.5f)]
public void SeamCells_CellBspContainment_AcrossPortalPlane(
uint cellAId, uint cellBId, float planeX, float y, float z)
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var la = LoadCell(dats, cellAId);
var lb = LoadCell(dats, cellBId);
Assert.NotNull(la);
Assert.NotNull(lb);
var (ca, csa) = la!.Value;
var (cb, csb) = lb!.Value;
Matrix4x4.Invert(WorldTransform(ca), out var invA);
Matrix4x4.Invert(WorldTransform(cb), out var invB);
_out.WriteLine($"=== CellBSP containment across plane x={planeX:F2} " +
$"(A=0x{cellAId:X8}, B=0x{cellBId:X8}) ===");
for (float dx = -0.6f; dx <= 0.65f; dx += 0.05f)
{
var world = new Vector3(planeX + dx, y, z);
bool inA = csa.CellBSP?.Root is not null
&& Physics.PointInCellBspViaBspQuery(csa.CellBSP.Root, Vector3.Transform(world, invA));
bool inB = csb.CellBSP?.Root is not null
&& Physics.PointInCellBspViaBspQuery(csb.CellBSP.Root, Vector3.Transform(world, invB));
_out.WriteLine($" x=plane{(dx >= 0 ? "+" : "")}{dx:F2} inA={(inA ? "Y" : "-")} inB={(inB ? "Y" : "-")}" +
(inA && inB ? " <<< OVERLAP" : !inA && !inB ? " <<< NEITHER" : ""));
}
}
private static class Physics
{
// Thin forwarder so this Rendering-side test reads clearly; the walk
// is the production BSPQuery.PointInsideCellBsp.
public static bool PointInCellBspViaBspQuery(
DatReaderWriter.Types.CellBSPNode node, Vector3 localPoint)
=> AcDream.Core.Physics.BSPQuery.PointInsideCellBsp(node, localPoint);
}
/// <summary>
/// (b) #177: which cell owns the stair-step geometry? Histogram of DRAWN
/// visual polys by normal class + the z-ladder of horizontal polys
/// (stair steps show as a ladder of small floor polys at stepped
/// z-levels). Also lists every portal with its plane orientation —
/// is the 0x0182↔0x0183 connection a floor-portal or a wall-portal?
/// </summary>
[Theory]
[InlineData(0x8A020182u)]
[InlineData(0x8A020183u)]
public void StairTransit_GeometryOwnerAndPortalOrientation(uint cellId)
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var loaded = LoadCell(dats, cellId);
Assert.NotNull(loaded);
var (cell, cs) = loaded!.Value;
var world = WorldTransform(cell);
int floors = 0, ceilings = 0, walls = 0, inclined = 0;
var floorZLevels = new SortedDictionary<int, int>(); // z rounded to 0.1 m → count
foreach (var (id, poly) in cs.Polygons)
{
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 az = MathF.Abs(n.Z);
if (az > 0.85f)
{
// Horizontal poly — bucket by mean z.
float meanZ = 0; foreach (var v in w) meanZ += v.Z; meanZ /= w.Count;
int zKey = (int)MathF.Round(meanZ * 10f);
floorZLevels.TryGetValue(zKey, out var c);
floorZLevels[zKey] = c + 1;
if (n.Z > 0) floors++; else ceilings++;
}
else if (az < 0.25f) walls++;
else
{
inclined++;
float minZ = float.MaxValue, maxZ = float.MinValue;
foreach (var v in w) { minZ = MathF.Min(minZ, v.Z); maxZ = MathF.Max(maxZ, v.Z); }
_out.WriteLine($" INCLINED poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{minZ:F2},{maxZ:F2}] " +
$"verts={poly.VertexIds.Count} stip={poly.Stippling} DRAWN={WouldDraw(poly, cell)}");
}
}
_out.WriteLine($"=== 0x{cellId:X8} poly histogram: floors={floors} ceilings={ceilings} walls={walls} inclined={inclined} ===");
_out.WriteLine(" horizontal-poly z-ladder (z → count): " +
string.Join(" ", System.Linq.Enumerable.Select(floorZLevels, kv => $"{kv.Key / 10f:F1}:{kv.Value}")));
foreach (var p in cell.CellPortals)
{
if (!cs.Polygons.TryGetValue((ushort)p.PolygonId, out var poly)) 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]));
string orient = MathF.Abs(n.Z) > 0.85f ? "HORIZONTAL (floor/ceiling portal)"
: MathF.Abs(n.Z) < 0.25f ? "vertical (wall portal)"
: "INCLINED portal";
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); }
_out.WriteLine($" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] {orient} " +
$"n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{min.Z:F2},{max.Z:F2}]");
}
}
}