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:
parent
544d4d3eef
commit
b8e9e204ad
5 changed files with 1176 additions and 19 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
134
docs/research/2026-07-06-176-177-render-pair-investigation.md
Normal file
134
docs/research/2026-07-06-176-177-render-pair-investigation.md
Normal 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.33–0.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 `(1−d/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).
|
||||||
|
|
@ -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.1–0.6 m PAST the
|
||||||
|
/// portal plane in the travel direction (016E→017A at x=85.33–85.47 vs
|
||||||
|
/// the plane at x=85.00; 0182→0183 at 98.56–98.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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.1–0.6 m PAST the portal plane in the travel
|
||||||
|
/// direction (016E→017A at x=85.33–85.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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.1–0.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}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue