diff --git a/docs/plans/2026-06-11-building-render-port-plan.md b/docs/plans/2026-06-11-building-render-port-plan.md index 99e849d8..17a1de6f 100644 --- a/docs/plans/2026-06-11-building-render-port-plan.md +++ b/docs/plans/2026-06-11-building-render-port-plan.md @@ -57,28 +57,42 @@ gate) and the client stays playable after every phase. Conformance pins come from the dat harness + the flood replay harnesses; retail constants are cited inline when ported. -### BR-1 — The draw-time surface gate (kills the phantom class) +### BR-1 — The surface gate — ✅ RESOLVED AS ALREADY-EQUIVALENT (2026-06-11, execution day 1) -**What:** classify every mesh batch at decode by surface texturedness -(`Surface.Type & (Base1Image|Base1ClipMap)`); at draw, skip untextured -batches for **building-shell entities and cell meshes only** (plain objects -keep drawing them — retail's bypass). Align the cell-side build-time -`NoPos`/`NoNeg` drop with this rule: run a dat-wide sweep (all CellStructs + -all building models in the populated landblocks) proving -`portal-fill ⇔ untextured`; keep the cheaper build-time drop only where the -sweep proves equivalence, otherwise move to the draw-time gate. GfxObj-side: -fills stay in the mesh but never draw (matches retail exactly). +**Premise falsified before implementation (the BR-1 pre-check, +`ReplicateProductionEmission_OnPortalFills`):** acdream **already suppresses +every portal fill** — all four extraction paths skip `Stippling.NoPos` +positive sides (`ObjectMeshManager.PrepareGfxObjMeshData:1046`, +`PrepareCellStructMeshData:1394`, `CellMesh.Build:44`, `GfxObjMesh.Build:71`), +and the Holtburg fills have no negative surface. The planned "draw-time +surface gate" has nothing to gate. -- **Closes:** #113 phantom staircase class (hall ramp, cottage "flying - stairs", every building's baked fills) — without touching doors (entities). -- **Acceptance:** `DumpPortalFillSurfaceTypes`-derived conformance sweep - green; hall + hill-cottage phantom gone and doors/windows intact at - Holtburg (user gate); all suites green. -- **Risk note:** apertures whose flood fails become true holes - (retail-identical); per-building floods + DrawPortal look-in already cover - the visible cases. If a hole shows at an unflooded aperture, that is BR-2/ - BR-4 evidence, not a BR-1 regression. -- **Size:** ~2 commits (batch metadata plumb + draw gate; sweep test). +**What shipped instead — the equivalence pin** +(`StipplingSurfaceEquivalenceTests`): 2,607 polys across 13 building models + +13 environments, **zero violations both directions** — `NoPos ⇔ untextured +surface`. Our build-time skip is therefore *proven equivalent* to retail's +draw-time `skipNoTexture` rule on this content; the +`portal-poly-suppression-criterion` divergence closes as +equivalent-with-proof. The pin fails loudly if future content breaks the +invariant (the cue to implement the draw-time gate then). + +**Consequences (the honest part):** +- The **#113 phantom residual is NOT GfxObj fills** — it cannot be, they + never reach a vertex buffer. The "root cause #2" attribution from the + e46d3d9 session is corrected; the e46d3d9 user-gate observations (filter + removed phantom/doors) were confounded — the filter was a provable mesh + no-op on both shells and door parts. +- The phantom's plausible true sites are cell-side: flood-admitted stair + CELLS drawn with a pass-all slice when the assembler hands them no slot + (`RetailPViewRenderer.cs:71` draws ALL visible cells; `NoClipSlice` + default), and/or stair-cell STATICS drawn unclipped + un-viewcone'd by + design (`object-lists-skip-portal-view-gate`, confirmed). **BR-2's first + task is a 10-minute probe at the hall bisect spot pinning which** — + the closure moves to BR-2/BR-3 (shells) and BR-5 (statics). +- **Closes:** the `portal-poly-suppression-criterion` divergence (as + proven-equivalent); #113's closure moves to BR-2/BR-3/BR-5. +- **Shipped:** the pre-check + equivalence pin tests; no production code + (none needed). ### BR-2 — Aperture depth machinery (punch / seal / clear) @@ -96,12 +110,20 @@ interior stage, gated on whether any seal was drawn (`portalsDrawnCount`); mesh (retail `DrawBuilding` order) so the shell's depth closes everything outside the punch. +- **First task (from BR-1's falsification):** the 10-minute probe at the + hall bisect spot — when the phantom is visible, log per stair cell + (0x100..0x106) whether it drew with a real clip slot or the pass-all + `NoClipSlice`, and whether its statics drew — pinning the phantom's true + draw site (shells → fixed here/BR-3; statics → BR-5). - **Closes:** #108 (outdoor terrain sweeping across the upstairs door — the missing true-depth seal is the confirmed `missing-portal-depth-fence` - divergence); the outdoor-root depth-discipline gap; part of #109. + divergence); the outdoor-root depth-discipline gap; part of #109; the + #113 phantom residual if the probe pins it on pass-all shell slices. - **Acceptance:** cellar↔main-floor walk shows no grass sweep (user gate); - new harness fact: seal depth = portal plane depth inside the clipped - aperture polygon (GL readback test or probe assertion); suites green. + phantom-spot check at the hall (user gate, replaces the old BR-1 + acceptance); new harness fact: seal depth = portal plane depth inside the + clipped aperture polygon (GL readback test or probe assertion); suites + green. - **Size:** ~3 commits (~80 lines of GL + clipper reuse per the area estimate, plus the clear re-shape and order swap). @@ -285,11 +307,15 @@ issue, none blocks BR-1…BR-8. ## 5. Sequencing summary ``` -BR-1 (surface gate) — first; standalone visual win, lowest risk -BR-2 (depth punch/seal) — second; enables BR-3 +BR-1 (surface gate) — ✅ RESOLVED as already-equivalent (pin shipped, + no production code; #113 closure moved to + BR-2/3/5 — see BR-1 section) +BR-2 (depth punch/seal) — FIRST implementation phase; opens with the + phantom-site probe; enables BR-3 BR-3 (delete shell chop) — closes #114 with BR-2 BR-4 (draw-driven floods) — closes #109; flood fidelity -BR-5 (viewconeCheck) — particles/objects through the same gate +BR-5 (viewconeCheck) — particles/objects through the same gate; + closes the phantom if it is statics-side BR-6 (one gate + deletions) — consolidation after the discipline is in BR-7 (collision A6.P4) — independent track; may interleave with BR-2..5 BR-8 (camera/lighting/LOD) — feel tier; BR-8a may land early diff --git a/docs/research/2026-06-11-building-render-acdream-vs-retail-comparison.md b/docs/research/2026-06-11-building-render-acdream-vs-retail-comparison.md index 5c815633..2e5011b1 100644 --- a/docs/research/2026-06-11-building-render-acdream-vs-retail-comparison.md +++ b/docs/research/2026-06-11-building-render-acdream-vs-retail-comparison.md @@ -44,8 +44,10 @@ plus three mechanisms it doesn't use. The headline findings: quad on all 13 Holtburg-area building models — door fills, window fills, *and* the meeting-hall phantom stair-ramp — is `Base1Solid` (untextured). Retail draws none of them, ever. The doors players see are **door - entities**; we draw the solid fills as colored geometry, which is why - removing them read as "doors disappeared." + entities**. *(Execution-day correction, §5: acdream's extractors already + skip all of these via `NoPos` — proven equivalent to retail's rule by + `StipplingSurfaceEquivalenceTests`. The phantom residual is cell-side, + not these fills; see the §5 banner.)* 3. **Retail never geometrically clips cell or shell geometry. Pixel exactness at apertures is a DEPTH discipline.** Production cell draws are whole @@ -254,7 +256,7 @@ area files. | Sev | Verdict | Divergence | |---|---|---| | CRIT | adjusted | `portal-poly-conditional-pass-missing` — no per-frame z-punch/z-seal/ConstructView pass on portal polys | -| HIGH | adjusted | `solid-surface-skip-missing` — untextured (solid) batches drawn on building/cell meshes retail skips | +| HIGH | **REFUTED for fills** (BR-1 pre-check, §5 banner) | `solid-surface-skip-missing` — acdream's NoPos build-time skip already covers them, proven equivalent | | MED | confirmed | `degrade-lod-scoped-to-humanoids` — retail degrades every non-player part per frame | | MED | adjusted | `no-per-view-entity-pass` — no per-portal-view re-cull of objects | | MED | confirmed | `stippling-semantics-divergence` — WB's NoPos/NoNeg side-drop vs retail batch flag + sides_type | @@ -263,7 +265,7 @@ area files. **Area 2 — Building shells** (`wf1-building-shells.md`) | Sev | Verdict | Divergence | |---|---|---| -| CRIT | unverified | `portal-polys-baked-unconditional` — fills baked + drawn; PortalIndex→CBldPortal pairing never consumed | +| CRIT | **acdream half REFUTED** (BR-1 pre-check); retail half + missing PortalIndex→CBldPortal pairing stand | `portal-polys-baked-unconditional` — fills are NOT drawn (NoPos skip); the un-consumed pairing remains real (BR-4) | | CRIT | unverified | `no-per-slot-building-draw` — building never draws per view slot; floods not shell-draw-driven | | HIGH | adjusted | `flood-gate-shape` — 48 m seed + 0.01 ε + eye-inside rescue vs retail's no-distance chain (analogues otherwise faithful) | | HIGH | unverified | `aperture-depth-machinery` — far-Z punch missing; particles scissor-only | @@ -363,13 +365,35 @@ area files. ## 5. Mysteries resolved this session +> **⚠ EXECUTION-DAY CORRECTION (2026-06-11, BR-1 pre-check).** The claim that +> acdream *draws* the solid portal fills is **FALSE** — all four extraction +> paths skip `Stippling.NoPos` positive sides +> (`ObjectMeshManager.PrepareGfxObjMeshData:1046`, +> `PrepareCellStructMeshData:1394`, `CellMesh.Build:44`, +> `GfxObjMesh.Build:71`), and the fills have no negative surface +> (`ReplicateProductionEmission_OnPortalFills`: pos=False/neg=False for every +> fill). The equivalence pin (`StipplingSurfaceEquivalenceTests`, 2,607 +> polys, 0 violations) proves our build-time skip ⇔ retail's draw-time +> `skipNoTexture` on this content. Consequences: the ledger rows +> `solid-surface-skip-missing` (Area 1) and the acdream half of +> `portal-polys-baked-unconditional` (Area 2) are **REFUTED for the fills** +> (the retail-side mechanism descriptions stand); the e46d3d9 user-gate +> observations were confounded (the filter was a provable mesh no-op on +> shells and doors); and the **#113 phantom residual is cell-side** — +> flood-admitted cells drawn with the pass-all `NoClipSlice` when slot-less +> (`RetailPViewRenderer.cs:71`) and/or unclipped un-viewcone'd cell statics +> (`object-lists-skip-portal-view-gate`, confirmed). BR-2 opens with the +> probe that pins which. The mapping agents missed the `:1046` skip — score +> one for "verify what call sites actually pass." + 1. **Door-vanish (charter §4.1)** — SOLVED, dat-proven (`e223325` + `DumpPortalFillSurfaceTypes`): the e46d3d9 filter walked only `node.Polygons`, never `node.Portals` (`PortalRef`); every dropped poly was a portal fill; all fills are `Base1Solid`; retail skips them via `skipNoTexture`; visible doors are entities. **No static filter can be - correct** — but the correct rule is a *surface-type draw gate*, which is - nearly as simple. + correct** — and per the execution-day correction above, no filter was + *needed*: the fills were never drawn, and the gate's "doors vanished" + observation was confounded. 2. **#114's real shape (charter §4.2)** — retail does not crop indoor geometry; it punches/seals depth at apertures and draws far→near. The "admission-quality vs draw-quality regions" framing dissolves: regions diff --git a/tests/AcDream.Core.Tests/Conformance/Issue113DoorVanishDiagnosticTests.cs b/tests/AcDream.Core.Tests/Conformance/Issue113DoorVanishDiagnosticTests.cs index 78420fa2..90f37b7f 100644 --- a/tests/AcDream.Core.Tests/Conformance/Issue113DoorVanishDiagnosticTests.cs +++ b/tests/AcDream.Core.Tests/Conformance/Issue113DoorVanishDiagnosticTests.cs @@ -181,6 +181,52 @@ public sealed class Issue113DoorVanishDiagnosticTests return n.LengthSquared() < 1e-10f ? Vector3.Zero : Vector3.Normalize(n); } + /// + /// BR-1 pre-check: replicate ObjectMeshManager.PrepareGfxObjMeshData's + /// EXACT emission conditions (lines 1040-1058: pos side emitted unless + /// Stippling.NoPos; neg side if Negative/Both or (!NoNeg and SidesType == + /// Clockwise)) on the portal-fill polys, printing raw enum values. The + /// #113 evidence says these fills RENDER (the phantom) — if the replica + /// says "skipped", the fills reach the screen another way and BR-1's gate + /// must move. + /// + [Fact] + public void ReplicateProductionEmission_OnPortalFills() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + foreach (uint mid in new uint[] { 0x010014C3u, 0x01000827u }) + { + var gfx = dats.Get(mid)!; + var walked = new HashSet(); + void Walk(DatReaderWriter.Types.DrawingBSPNode? n) + { + if (n is null) return; + if (n.Polygons is not null) foreach (var pid in n.Polygons) walked.Add((ushort)pid); + Walk(n.PosNode); Walk(n.NegNode); + } + Walk(gfx.DrawingBSP!.Root); + var fills = gfx.Polygons.Keys.Where(k => !walked.Contains(k)).OrderBy(k => k).ToList(); + + _out.WriteLine($"=== model 0x{mid:X8} ==="); + foreach (var pid in fills) + { + var poly = gfx.Polygons[pid]; + bool noPos = poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.NoPos); + bool hasNeg = poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.Negative) + || poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.Both) + || (!poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.NoNeg) + && poly.SidesType == DatReaderWriter.Enums.CullMode.Clockwise); + _out.WriteLine( + $" poly {pid,3}: stip={poly.Stippling}(raw={(int)poly.Stippling}) sides={poly.SidesType}(raw={(int)poly.SidesType}) " + + $"posSurf={poly.PosSurface} negSurf={poly.NegSurface} " + + $"-> production emits: pos={!noPos} neg={hasNeg && poly.NegSurface >= 0}"); + } + } + } + /// /// Phase A confirmation: retail's building/cell mesh pass skips surface /// batches whose CSurface type has neither Base1Image (0x2) nor diff --git a/tests/AcDream.Core.Tests/Conformance/StipplingSurfaceEquivalenceTests.cs b/tests/AcDream.Core.Tests/Conformance/StipplingSurfaceEquivalenceTests.cs new file mode 100644 index 00000000..721a0ca1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Conformance/StipplingSurfaceEquivalenceTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Conformance; + +/// +/// BR-1 conformance pin (holistic building-render port, plan §BR-1). +/// +/// Retail suppresses portal-fill drawing at DRAW time via the skipNoTexture +/// rule: building/cell surface batches whose CSurface.type lacks BASE1_IMAGE +/// (0x2) and BASE1_CLIPMAP (0x4) are skipped (D3DPolyRender inner draw, +/// Ghidra 0x0059d4a0; default on @0x00820e30). acdream suppresses them at +/// BUILD time via Stippling.NoPos in all four extraction paths +/// (ObjectMeshManager.PrepareGfxObjMeshData:1046 + PrepareCellStructMeshData +/// :1394, CellMesh.Build:44, GfxObjMesh.Build:71). +/// +/// These criteria are equivalent ONLY if NoPos ⇔ untextured-surface holds on +/// the content. This sweep pins both directions across the populated +/// Holtburg-area landblocks (building shell models + every Environment +/// CellStruct their cells reference + the door setup parts): +/// (a) every NoPos poly's positive surface is untextured (else our skip +/// drops something retail draws), and +/// (b) every untextured-surface poly is NoPos (else we draw something +/// retail skips on building/cell passes — the would-be phantom class). +/// Violations of (b) on PLAIN OBJECT GfxObjs are allowed — retail's bypass +/// draws solid batches for non-building/non-cell meshes, and so do we. +/// +public sealed class StipplingSurfaceEquivalenceTests +{ + private readonly ITestOutputHelper _out; + public StipplingSurfaceEquivalenceTests(ITestOutputHelper output) => _out = output; + + private static readonly uint[] Landblocks = + { + 0xA9B40000u, // Holtburg town + 0xA9B30000u, // hill cottage block + 0xAAB30000u, // meeting hall block + 0xA9B50000u, + 0xAAB40000u, + }; + + [Fact] + public void NoPosStippling_Equals_UntexturedSurface_OnBuildingsAndCells() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + int polysChecked = 0; + var aViolations = new List(); // NoPos but TEXTURED (our skip would drop a retail-drawn poly) + var bViolations = new List(); // untextured but NOT NoPos (we'd draw what retail skips) + + bool IsTextured(DatReaderWriter.DBObjs.Surface? s) => + s is not null && + (s.Type.HasFlag(DatReaderWriter.Enums.SurfaceType.Base1Image) + || s.Type.HasFlag(DatReaderWriter.Enums.SurfaceType.Base1ClipMap)); + + // ---- building shell GfxObjs ---- + var buildingModels = new SortedSet(); + var environments = new SortedSet(); + foreach (uint lb in Landblocks) + { + var lbi = dats.Get(lb | 0xFFFEu); + if (lbi is null) continue; + foreach (var b in lbi.Buildings ?? new()) buildingModels.Add(b.ModelId); + for (uint low = 0x0100; low < 0x0100 + lbi.NumCells; low++) + { + var dc = dats.Get(lb | low); + if (dc is not null) environments.Add(0x0D000000u | dc.EnvironmentId); + } + } + Assert.NotEmpty(buildingModels); + Assert.NotEmpty(environments); + + foreach (var mid in buildingModels) + { + var gfx = dats.Get(mid); + if (gfx is null) continue; + foreach (var kv in gfx.Polygons) + { + var poly = kv.Value; + polysChecked++; + bool noPos = poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.NoPos); + DatReaderWriter.DBObjs.Surface? surf = null; + if (poly.PosSurface >= 0 && poly.PosSurface < gfx.Surfaces.Count) + surf = dats.Get(gfx.Surfaces[poly.PosSurface]); + if (noPos && IsTextured(surf)) + aViolations.Add($"gfx 0x{mid:X8} poly {kv.Key}: NoPos but textured surface"); + if (!noPos && surf is not null && !IsTextured(surf)) + bViolations.Add($"gfx 0x{mid:X8} poly {kv.Key}: textured-less surface without NoPos (type={surf.Type})"); + } + } + + // ---- cell structs referenced by those landblocks' interior cells ---- + foreach (var envId in environments) + { + var env = dats.Get(envId); + if (env is null) continue; + foreach (var (csId, cs) in env.Cells) + { + foreach (var kv in cs.Polygons) + { + var poly = kv.Value; + polysChecked++; + bool noPos = poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.NoPos); + // CellStruct polys resolve surfaces through the EnvCell's + // surface list at runtime; the struct itself stores only the + // index. Direction (a) can't be evaluated without a specific + // EnvCell, so for structs we pin only the NoPos→portal-poly + // correspondence: every NoPos poly must be a portal polygon + // (referenced by the struct's Portals list), i.e. our skip + // removes only aperture fills, never wall geometry. + if (noPos) + { + bool isPortalPoly = cs.Portals.Any(p => p == kv.Key); + if (!isPortalPoly) + aViolations.Add($"env 0x{envId:X8} struct {csId} poly {kv.Key}: NoPos but not a portal poly"); + } + } + } + } + + _out.WriteLine($"checked {polysChecked} polys across {buildingModels.Count} building models + {environments.Count} environments"); + _out.WriteLine($"(a) NoPos-but-textured (skip would drop retail-drawn): {aViolations.Count}"); + foreach (var v in aViolations.Take(20)) _out.WriteLine($" {v}"); + _out.WriteLine($"(b) untextured-but-not-NoPos on buildings (we'd draw what retail skips): {bViolations.Count}"); + foreach (var v in bViolations.Take(20)) _out.WriteLine($" {v}"); + + Assert.Empty(aViolations); + Assert.Empty(bViolations); + } +}