From 0cb97aa59436021cedeac3f450270f89fe294ba6 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 13:17:50 +0200 Subject: [PATCH 01/65] UN-2 RESOLVED: GetMaxSpeed x4 is byte-verified retail; doc-comment was the misread The register's UN-2 row recorded a contradiction: the GetMaxSpeed XML doc claimed the bare run rate was retail-correct (~5.9 m/s catch-up, calling the xRunAnimSpeed multiply a misread), while the implementation multiplied by RunAnimSpeed citing ACE. Settled against the binary, not the pseudo-C: - BN pseudo-C (acclient_2013_pseudo_c.txt:305127) renders get_max_speed as void with a bare `this->my_run_rate;` because it DROPS x87 instructions. - Disassembling the PDB-matched v11.4186 binary at VA 0x00527cb0: all THREE return paths end `fld ; fmul dword ptr [0x007C8918]; ret`, and the .rdata dword at 0x007C8918 is 4.0f. Sibling get_adjusted_max_speed (0x00527d00) carries the same trailing fmul. Verifier committed at tools/verify_un2_fmul.py (PE parse + byte decode, rerunnable). - Retail paths: weenie null -> 1.0 x4; InqRunRate ok -> queried x4; InqRunRate failed -> my_run_rate x4. ACE MotionInterp.cs:665-676 matches. Changes: - Doc-comment rewritten: the implementation is retail-correct; the catch-up speed 2 x get_max_speed ~= 23.5 m/s at run 200 IS retail. The 1-Hz remote-blip symptom the old comment attributed to this multiply is therefore UNEXPLAINED by it (if it recurs: #41 family, not this). - Weenie-null path aligned to retail's LITERAL 1.0 default (was MyRunRate). - Tests re-pinned to the three retail paths (the old NoWeenie test pinned the non-retail fallback). - Register: UN-2 row deleted per the retire rule (6 -> 5 UN rows); shortlist renumbered. This is the 2nd confirmed instance of the BN x87-dropout artifact class (memory: feedback_bn_decomp_field_names) deciding a register row. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 30 ++++---- src/AcDream.Core/Physics/MotionInterpreter.cs | 69 +++++++++---------- .../Physics/MotionInterpreterTests.cs | 41 +++++++---- tools/verify_un2_fmul.py | 40 +++++++++++ 4 files changed, 114 insertions(+), 66 deletions(-) create mode 100644 tools/verify_un2_fmul.py diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 91bde7ea..f7612b9a 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -167,7 +167,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 5. Unclear (UN) — 6 rows +## 5. Unclear (UN) — 5 rows These rows have a missing, contradictory, or never-argued justification. They are the highest-priority audits: each needs either a recorded @@ -176,7 +176,6 @@ equivalence argument (promote to AD/AP) or a fix. | # | Divergence | Where (file:line) | Recorded justification (deficient) | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | UN-1 | `CheckOtherCells` iterates the overlap set SORTED by cell id; retail walks the CELLARRAY in build order — and the loop halts on the first non-OK result, so order is behavior-bearing | `src/AcDream.Core/Physics/CellTransit.cs:1718` | Justified only as "deterministic order for greppable probe logs" — no equivalence argument vs retail's array order recorded | A sphere straddling two cells that would each return a different non-OK result halts on a different cell than retail — different collision normal / slide direction at multi-cell straddles | `CTransition::check_other_cells` pc:272717-272798 | -| UN-2 | `GetMaxSpeed`: XML doc asserts the bare run rate is retail-correct (~5.9 m/s catch-up; the ×RunAnimSpeed multiply "a misread" → ~23.5 m/s), yet the implementation multiplies by RunAnimSpeed citing ACE as retail-verified. The two recorded justifications CONTRADICT — one describes the current code as known-wrong | `src/AcDream.Core/Physics/MotionInterpreter.cs:972` | None coherent — doc and code disagree about which behavior is retail | If the bare-rate reading is right, remote-entity catch-up runs ~4× retail speed — the multi-second 1-Hz blip / racing-remote symptom the doc itself records | `CMotionInterp::get_max_speed` pc:305127; catch-up :353122 | | UN-3 | AdminEnvirons fog-override RGB tints hardcoded with no retail constant cited (RedFog 0.60/0.05/0.05 etc.); Snapshot replaces fog COLOR only, keeping keyframe distances on an unverified assumption | `src/AcDream.Core/World/WeatherState.cs:350` | Enum semantics cite ACE EnvironChangeType + r12 §5.2; no source for the RGB values or the color-only override scope | A server-forced fog event renders the wrong hue and/or wrong density vs what retail clients showed for the same packet | AdminEnvirons 0xEA60; ACE EnvironChangeType.cs | | UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 | | UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) | @@ -192,20 +191,19 @@ phase-gated — they carry their trigger in their row and should land WITH that phase, not before. 1. **TS-20 — GfxObj DrawingBSP traversal (#113)** — phantom geometry is visible in Holtburg RIGHT NOW; the holistic port handoff already specs the fix; first diagnose the id filter against a door GfxObj. -2. **UN-2 — GetMaxSpeed contradiction** — the file argues against its own implementation; if the bare-rate reading is right, remote catch-up runs ~4× retail. Settle with one decomp re-read + a cdb catch-up trace; cheap to resolve, expensive to leave. -3. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there. -4. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis. -5. **UN-5 — Backward/strafe run multiplier** — potential ~2.4× local-vs-wire speed mismatch on a common input (S at run); one cdb session against retail answers it. -6. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output). -7. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing. -8. **TS-22 — adjust_motion port** — active bug-class generator: any new `get_state_velocity` consumer during backward/strafe silently gets zero velocity. -9. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass. -10. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check. -11. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it. -12. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger. -13. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now. -14. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler. -15. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging. +2. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there. +3. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis. +4. **UN-5 — Backward/strafe run multiplier** — potential ~2.4× local-vs-wire speed mismatch on a common input (S at run); one cdb session against retail answers it. +5. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output). +6. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing. +7. **TS-22 — adjust_motion port** — active bug-class generator: any new `get_state_velocity` consumer during backward/strafe silently gets zero velocity. +8. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass. +9. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check. +10. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it. +11. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger. +12. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now. +13. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler. +14. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging. **Phase-gated (do WITH the phase, flagged here so they aren't forgotten):** M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating), diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index c82ce2b9..ec1006e1 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -935,52 +935,47 @@ public sealed class MotionInterpreter // ── CMotionInterp::get_max_speed (0x00527cb0) ───────────────────────────── /// - /// Return the run rate. Mirrors retail - /// CMotionInterp::get_max_speed at 0x00527cb0. + /// Return the maximum movement speed in m/s: run rate × RunAnimSpeed (4.0). + /// Mirrors retail CMotionInterp::get_max_speed at 0x00527cb0. /// /// - /// Decomp (named-retail/acclient_2013_pseudo_c.txt:305127): - /// - /// void get_max_speed(this) { - /// weenie_obj = this->weenie_obj; - /// this_1 = nullptr; - /// if (weenie_obj == 0) return; - /// if (weenie_obj->vtable->InqRunRate(&this_1) != 0) return; - /// this->my_run_rate; // x87 fld leaves my_run_rate on FPU stack - /// } - /// - /// Binary Ninja shows the return type as void because the float - /// return rides the x87 FPU stack rather than EAX. Both branches - /// emit an fld of either this_1 (the InqRunRate - /// out-param value) or my_run_rate, leaving the run rate on - /// ST0 as the return value. + /// The ×4.0 is byte-verified retail (UN-2 resolved 2026-06-12). + /// The Binary Ninja pseudo-C (named-retail/acclient_2013_pseudo_c.txt:305127) + /// renders this function as void with a bare this->my_run_rate; + /// statement because it drops x87 instructions — a known BN artifact class. + /// Disassembling the PDB-matched v11.4186 binary at VA 0x00527cb0 + /// shows all THREE return paths end with + /// fmul dword ptr [0x007C8918], and the .rdata dword at + /// 0x007C8918 is 0x40800000 = 4.0f (the sibling + /// get_adjusted_max_speed 0x00527d00 carries the same trailing + /// fmul). Re-derive with py tools/verify_un2_fmul.py. The three + /// retail paths: weenie_obj == null → 1.0×4; InqRunRate success → + /// queried×4; InqRunRate failure → my_run_rate×4. ACE's + /// MotionInterp.cs:665-676 ports it identically (RunAnimSpeed = 4.0f). /// /// /// - /// Critical: this returns the BARE run rate (typically 1.0 to - /// ~3.0), NOT a velocity in m/s. We previously multiplied by - /// RunAnimSpeed to get a m/s value, reasoning that - /// 2 × bare_rate would be too slow a catch-up speed for the - /// caller (InterpolationManager::adjust_offset). That was a - /// misread of the decomp — retail's catch-up IS that slow on purpose. - /// The multi-second 1-Hz blip the user reported when observing retail - /// remotes from acdream traced to body racing at the wrong (overshot) - /// catch-up speed (~23.5 m/s instead of the retail-correct ~5.9 m/s - /// for a run-skill-200 char). + /// Consequence: the dead-reckoning catch-up speed + /// (InterpolationManager::adjust_offset 0x00555d30, pc:353122) + /// is 2 × get_max_speed() ≈ 23.5 m/s for a run-rate-2.94 + /// (run-skill-200) character — that IS retail's value. An earlier + /// doc-comment here claimed the bare rate (~5.9 m/s catch-up) was + /// retail-correct and blamed the ×4 for the multi-second 1-Hz blip on + /// observed retail remotes; that reading trusted the BN x87 dropout + /// and is refuted by the binary. If the blip recurs, its root cause is + /// elsewhere (node-fail handling / progress-quantum abandonment / + /// position-queue feed — the #41 family), NOT this multiply. /// /// public float GetMaxSpeed() { - // Resolve current run rate: prefer WeenieObj.InqRunRate, fall back to MyRunRate. - // Then multiply by RunAnimSpeed (4.0). Matches ACE's MotionInterp.cs:670-678 - // which is verified against retail (the ACE MotionInterp file is a - // line-by-line port). Returns the maximum world-space velocity in m/s - // — for run skill 200 with rate ≈ 2.94, this is ≈ 11.76 m/s. Used by - // InterpolationManager.AdjustOffset to compute the catch-up speed - // (= 2 × maxSpeed). - float rate = MyRunRate; - if (WeenieObj is not null && WeenieObj.InqRunRate(out float queried)) - rate = queried; + // Retail 0x00527cb0: weenie null → 1.0; InqRunRate ok → queried; + // InqRunRate failed → my_run_rate. Every path × RunAnimSpeed (4.0, + // .rdata 0x007C8918). Note the weenie-null default is the LITERAL 1.0 + // (.rdata 0x007928B0), not my_run_rate. + float rate = 1.0f; + if (WeenieObj is not null && !WeenieObj.InqRunRate(out rate)) + rate = MyRunRate; return RunAnimSpeed * rate; } diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs index 251b0570..e2c4f896 100644 --- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs +++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs @@ -845,15 +845,14 @@ public sealed class MotionInterpreterTests [InlineData(MotionCommand.RunForward)] public void GetMaxSpeed_IgnoresForwardCommand_AlwaysReturnsRunRate(uint command) { - // GetMaxSpeed is the InterpolationManager.AdjustOffset catch-up speed — it deliberately - // returns RunAnimSpeed × run-rate REGARDLESS of the current ForwardCommand (see GetMaxSpeed's - // doc comment: the bare run rate × RunAnimSpeed, ACE MotionInterp.cs:670-678, retail-verified - // — the slow catch-up is intentional, it fixed the 1-Hz remote-blip). It does NOT branch - // per-command. These previously asserted a REMOVED command-branching design (WalkForward → - // WalkAnimSpeed, WalkBackward → ×0.65, Idle → 0); that contract no longer exists, so they are - // consolidated here to PIN the no-branch contract across commands (Phase W green-tests triage). - var interp = MakeInterp(); - interp.MyRunRate = 1.75f; + // GetMaxSpeed is the InterpolationManager.AdjustOffset catch-up speed — it + // returns RunAnimSpeed × run-rate REGARDLESS of the current ForwardCommand + // (retail 0x00527cb0 never reads interpreted_state; UN-2 byte verification + // 2026-06-12, tools/verify_un2_fmul.py). These previously asserted a REMOVED + // command-branching design (WalkForward → WalkAnimSpeed, WalkBackward → + // ×0.65, Idle → 0); they PIN the no-branch contract across commands. + var weenie = new FakeWeenie { RunRate = 1.75f }; + var interp = MakeInterp(weenie: weenie); interp.InterpretedState.ForwardCommand = command; float speed = interp.GetMaxSpeed(); @@ -862,17 +861,33 @@ public sealed class MotionInterpreterTests } [Fact] - public void GetMaxSpeed_RunForward_NoWeenie_FallsBackToMyRunRate() + public void GetMaxSpeed_NoWeenie_ReturnsLiteralOneTimesRunAnimSpeed() { - // WeenieObj is null (MakeInterp with no weenie argument); MyRunRate - // is set explicitly. GetMaxSpeed must use MyRunRate as the run-rate - // source when InqRunRate is unavailable. + // Retail 0x00527cb0 weenie_obj == null path: fld 1.0 (.rdata 0x007928B0), + // fmul 4.0 (.rdata 0x007C8918) — the LITERAL 1.0, NOT my_run_rate (UN-2 + // byte verification 2026-06-12). MyRunRate is set to a different value to + // prove it is not consulted on this path. var interp = MakeInterp(); interp.MyRunRate = 1.75f; interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; float speed = interp.GetMaxSpeed(); + Assert.Equal(MotionInterpreter.RunAnimSpeed * 1.0f, speed, precision: 4); + } + + [Fact] + public void GetMaxSpeed_InqRunRateFails_FallsBackToMyRunRate() + { + // Retail 0x00527cb0 InqRunRate-failure path: fld [esi+0x7c] (my_run_rate), + // fmul 4.0. The InqRunRate out-value is discarded on failure. + var weenie = new FakeWeenie { RunRate = 9.9f, InqRunRateResult = false }; + var interp = MakeInterp(weenie: weenie); + interp.MyRunRate = 1.75f; + interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; + + float speed = interp.GetMaxSpeed(); + Assert.Equal(MotionInterpreter.RunAnimSpeed * 1.75f, speed, precision: 4); } } diff --git a/tools/verify_un2_fmul.py b/tools/verify_un2_fmul.py new file mode 100644 index 00000000..d5b6eb8c --- /dev/null +++ b/tools/verify_un2_fmul.py @@ -0,0 +1,40 @@ +# UN-2 verification: prove/disprove that retail CMotionInterp::get_max_speed +# (VA 0x00527cb0) multiplies by the 4.0f constant at VA 0x007C8918 on its +# return paths (the fmul the BN pseudo-C drops). Throwaway apparatus. +import struct + +p = r"C:\Turbine\Asheron's Call\acclient.exe" +data = open(p, 'rb').read() + +pe_off = struct.unpack_from(' file', hex(off), 'sec', sec) +code = data[off:off + 0x50] +print('bytes:', code.hex()) +FMUL = bytes.fromhex('d80d18897c00') # fmul dword ptr [0x007C8918] +print('fmul [0x7C8918] count in get_max_speed:', code.count(FMUL)) + +off2, sec2 = va2off(0x007C8918) +print('dword @0x7C8918 sec', sec2, '=', struct.unpack_from(' Date: Fri, 12 Jun 2026 13:31:43 +0200 Subject: [PATCH 02/65] fix #130: doorway-slice scissor cut the aperture's top/right pixel row The user's "thin strip of background color along the TOP outer edge of a doorway, looking out from inside" is the landscape-slice scissor box, not the W=0 clip port. Mechanism (pinned headlessly, Issue130DoorwayStripTests, 147 eye/gaze combos at the real Holtburg A9B4 0x0170 exit door): - BeginDoorwayScissor converted the slice NDC AABB to pixels as Floor(origin) + Ceiling(size). The far edge floor(min)+ceil(max-min) lands up to ONE PIXEL SHORT of the true top/right edge at unlucky fractional alignments (captured: top edge y=0.7938 @1080p -> row 968 cut; right edge column 1296 @1920 cut). - The scissor brackets the ENTIRE landscape slice (sky, terrain, outdoor statics, weather). The exit-portal SEAL stamps the full raw aperture at true depth and the shell wall ends at the aperture edge, so the cut row never receives any color write -> clear color, flickering with eye movement as the fractional alignment shifts. - This violated AD-17's own invariant (over-inclusion is safe, UNDER-inclusion is the bug class). No register change: the fix restores the row's documented doctrine. Lead 1 (987313a W=0 clip port regression) REFUTED by the same harness: the CPU polygon pipeline (ProjectToClip -> ClipToRegion merges -> ClipPlaneSet planes) is sub-pixel exact against the raw aperture projection (worst 0.54 px, 0.00 px aligned). For an all-in-front doorway polygon the port is bit-identical to the old 1e-4 path by construction. The EyeInsidePortalOpening rescue stays deleted. Fix: conservative outer bound floor(min)/ceil(max) extracted to NdcScissorRect.ToPixels (GL-free; containment property proven in the header comment); BeginDoorwayScissor delegates. Pins: - NdcScissorRectTests: center-inside containment across 251 fractional alignments x 2 framebuffer sizes + both captured regression cases. - Issue130DoorwayStripTests: production flood + assembler at the real exit door; asserts the scissor never cuts a plane-admitted fragment (worstScissorGap 0.00 px post-fix, was 10.8 px capped) and the CPU pipeline stays sub-pixel exact (canary 1.2 px). Suites: App 252+1skip / Core 1439+2skip / UI 420 / Net 294 green. Awaiting the user visual gate at a cottage doorway. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 57 +-- src/AcDream.App/Rendering/GameWindow.cs | 22 +- src/AcDream.App/Rendering/NdcScissorRect.cs | 45 +++ .../Rendering/Issue130DoorwayStripTests.cs | 330 ++++++++++++++++++ .../Rendering/NdcScissorRectTests.cs | 80 +++++ 5 files changed, 494 insertions(+), 40 deletions(-) create mode 100644 src/AcDream.App/Rendering/NdcScissorRect.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/NdcScissorRectTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 4938d1ab..fee8cf6c 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -4512,38 +4512,45 @@ math). ## #130 — Background-color strip along the TOP outer edge of a doorway when looking out from inside -**Status:** OPEN +**Status:** FIX SHIPPED — awaiting user visual gate **Severity:** LOW-MEDIUM (small strip, but on the most-stared-at pixels in the game) -**Filed:** 2026-06-12 (user report, post-#119-close session; "also NOW" — -possibly new since the W=0 clip port `987313a`) -**Component:** render — doorway aperture edge (seal/punch/OutsideView seam) +**Filed:** 2026-06-12 (user report, post-#119-close session) +**Component:** render — doorway-slice scissor box math (AD-17 family) **Symptom (user):** standing inside looking out through a doorway, a thin strip of background (clear/world) color runs along the OUTER edge of the TOP of the doorway opening. -**Leads (capture first — plausibly a `987313a` regression):** -1. The W=0 port changed `ProjectToClip` (exact w>=0, no 1e-4 epsilon) - and DELETED the `EyeInsidePortalOpening` rescue — the OutsideView - region through a near doorway is computed slightly differently now. - If the OutsideView's top edge sits ~1 px BELOW the aperture's drawn - shell edge, terrain/outdoor geometry isn't drawn in that strip while - the interior seal/punch still cleared it → background color. - Suspects within the port: `MergeSubPixelVertices` shaving a top - vertex; the exact-w boundary vs the old epsilon shifting the - projected edge; the deleted rescue no longer substituting the full - view for an eye-pressed doorway. -2. The interior SEAL depth vs the shell top edge (the #118-era - machinery) — a 1-px mismatch between the seal polygon and the shell - aperture would show the clear color exactly at an edge. +**Root cause (pinned headlessly 2026-06-12, `Issue130DoorwayStripTests` +— 147 eye/gaze combos at the real A9B4 0x0170 exit door):** the +`BeginDoorwayScissor` NDC→pixel conversion (`Floor(origin) + +Ceiling(size)`) put the box's far edge at `floor(min)+ceil(max−min)` — +up to ONE PIXEL SHORT of the true top/right edge at unlucky fractional +alignments. The scissor brackets the ENTIRE landscape slice (sky, +terrain, statics, weather), the seal stamps the full aperture at true +depth, and the shell ends at the aperture edge — so the cut pixel row +never receives color: a background strip along the top edge that comes +and goes as the eye moves (alignment shifts). Captured live by the +harness: top edge y=0.7938 at 1080p → row 968 cut; right edge column +1296 cut at 1920. This violated AD-17's own doctrine (over-inclusion +safe, under-inclusion is the bug class). -**Next:** screenshot + [viewer]/[pv-dump] capture at a doorway showing -the strip; diff the OutsideView top edge NDC vs the aperture polygon's -projected top edge for that frame (the CornerFloodReplay harness -machinery can replay the frame headlessly once the eye/cell are -captured). If it reproduces at the same doorway with `987313a` reverted -locally, it's the port's edge math; fix the math, never re-add the -rescue. +**Lead 1 REFUTED:** the W=0 clip port `987313a` is exonerated by the +same harness — the CPU polygon pipeline (ProjectToClip → ClipToRegion +merges → ClipPlaneSet planes) is sub-pixel exact against the raw +aperture projection (worst 0.54 px; 0.00 px in the aligned case). For +an all-in-front doorway polygon the port is bit-identical to the old +path by construction (the W clip pass only runs when a vertex has +w < 0). + +**Fix:** conservative outer bound `floor(min)/ceil(max)` extracted to +`NdcScissorRect.ToPixels` (GL-free, unit-tested); `BeginDoorwayScissor` +delegates. Pins: `NdcScissorRectTests` (containment property + both +captured alignments) + `Issue130DoorwayStripTests` (scissor never cuts +plane-admitted fragments; CPU-pipeline exactness canary ≤1.2 px). + +**Gate:** stand inside any cottage, look out the door, sweep the gaze — +no background strip at the top edge at any alignment. --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 59f0f83c..d09cf23d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -9954,26 +9954,18 @@ public sealed class GameWindow : IDisposable // Phase W Stage 4: set a glScissor to an NDC AABB (the doorway / OutsideView region) in // framebuffer pixels and enable the scissor test; returns true iff applied (the caller then - // disables EnableCap.ScissorTest after its draw/clear). Mirrors the terrain Scissor-mode - // NDC→pixel conversion (one source for the box math). Used to confine the sky/weather particle - // passes (particle.vert has no gl_ClipDistance) and the conditional doorway depth-only Z-clear - // to the doorway opening. Returns false (no scissor) when not applied (outdoor / no window). + // disables EnableCap.ScissorTest after its draw/clear). Used to bracket the landscape slice + // (sky, terrain, statics, weather — particle.vert has no gl_ClipDistance). Returns false + // (no scissor) when not applied (outdoor / no window). The box is the CONSERVATIVE outer + // bound (NdcScissorRect): the previous Floor(origin)+Ceiling(size) form cut up to one pixel + // off the TOP/RIGHT edges at unlucky alignments — the #130 doorway top-edge background strip. private bool BeginDoorwayScissor(bool apply, System.Numerics.Vector4 ndcAabb) { if (!apply || _window is null) return false; var fb = _window.FramebufferSize; - // NDC [-1,1] → window pixels. Clamp so a doorway opening that extends past a screen edge - // still yields a valid box (same clamp the terrain Scissor path uses). - float nx0 = System.Math.Clamp(ndcAabb.X, -1f, 1f); - float ny0 = System.Math.Clamp(ndcAabb.Y, -1f, 1f); - float nx1 = System.Math.Clamp(ndcAabb.Z, -1f, 1f); - float ny1 = System.Math.Clamp(ndcAabb.W, -1f, 1f); - int px = (int)System.MathF.Floor((nx0 * 0.5f + 0.5f) * fb.X); - int py = (int)System.MathF.Floor((ny0 * 0.5f + 0.5f) * fb.Y); - int pw = (int)System.MathF.Ceiling((nx1 - nx0) * 0.5f * fb.X); - int ph = (int)System.MathF.Ceiling((ny1 - ny0) * 0.5f * fb.Y); + var box = NdcScissorRect.ToPixels(ndcAabb, fb.X, fb.Y); _gl!.Enable(EnableCap.ScissorTest); - _gl.Scissor(px, py, (uint)System.Math.Max(1, pw), (uint)System.Math.Max(1, ph)); + _gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height); return true; } diff --git a/src/AcDream.App/Rendering/NdcScissorRect.cs b/src/AcDream.App/Rendering/NdcScissorRect.cs new file mode 100644 index 00000000..f26eb0c6 --- /dev/null +++ b/src/AcDream.App/Rendering/NdcScissorRect.cs @@ -0,0 +1,45 @@ +// NdcScissorRect.cs +// +// NDC AABB → framebuffer-pixel scissor box, CONSERVATIVE (outer bound). +// The scissor that brackets a landscape/doorway slice is a fallback BOUND on +// the slice's view region (AD-17 in the divergence register): it must CONTAIN +// every fragment the per-fragment plane clip would keep. Under-inclusion is +// the bug class — the #130 doorway top-edge background strip was this box +// computed as Floor(origin) + Ceiling(size), whose far edge +// floor(min)+ceil(max−min) lands up to one pixel SHORT of the true max edge +// at unlucky fractional alignments, scissoring away the aperture's top/right +// pixel row for the whole slice (sky, terrain, statics, weather) while the +// seal still stamps it — a strip of clear color no later pass can fill. +// +// Correct outer bound: floor both mins, ceil both maxes, width = difference. +// A fragment at pixel (i,j) rasterizes iff its CENTER (i+0.5, j+0.5) lies in +// the region ⊆ the NDC box [X0,X1]×[Y0,Y1] (pixel units). Center-inside ⇒ +// i ≥ X0−0.5 ⇒ i ≥ floor(X0) and i ≤ X1−0.5 ⇒ i < ceil(X1). So +// [floor(X0), ceil(X1)) admits every center-inside pixel, over-including by +// at most one pixel per edge — safe per AD-17's doctrine (the wall shell / +// plane clip repaints or kills the surplus). +using System; +using System.Numerics; + +namespace AcDream.App.Rendering; + +public static class NdcScissorRect +{ + /// Convert an NDC AABB (minX, minY, maxX, maxY in [-1,1]) to a + /// framebuffer-pixel scissor box that CONTAINS it. Inputs are clamped to + /// the screen so a region extending past an edge still yields a valid box. + /// Width/height are at least 1. + public static (int X, int Y, int Width, int Height) ToPixels( + Vector4 ndcAabb, int fbWidth, int fbHeight) + { + float nx0 = Math.Clamp(ndcAabb.X, -1f, 1f); + float ny0 = Math.Clamp(ndcAabb.Y, -1f, 1f); + float nx1 = Math.Clamp(ndcAabb.Z, -1f, 1f); + float ny1 = Math.Clamp(ndcAabb.W, -1f, 1f); + int px0 = (int)MathF.Floor((nx0 * 0.5f + 0.5f) * fbWidth); + int py0 = (int)MathF.Floor((ny0 * 0.5f + 0.5f) * fbHeight); + int px1 = (int)MathF.Ceiling((nx1 * 0.5f + 0.5f) * fbWidth); + int py1 = (int)MathF.Ceiling((ny1 * 0.5f + 0.5f) * fbHeight); + return (px0, py0, Math.Max(1, px1 - px0), Math.Max(1, py1 - py0)); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs b/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs new file mode 100644 index 00000000..966a4e8d --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.App.Tests.Rendering; + +/// +/// #130 — background-color strip along the TOP outer edge of a doorway when +/// looking out from inside. Mechanism model (2026-06-12 evidence sweep): for +/// an interior root the SEAL stamps the FULL raw dat portal polygon at true +/// depth (PortalDepthMaskRenderer, root-cell slice = full screen), while +/// terrain/sky COLOR is gated per fragment by the OutsideView region — the +/// same dat polygon run through ProjectToClip → ClipToRegion (1-px +/// MergeSubPixelVertices) → ClipPlaneSet.From (0.5° collinear merge) → planes, +/// with a Floor/Ceil pixel scissor (BeginDoorwayScissor) on the slice AABB on +/// top. Every one of those passes can only SHRINK the gate, so any shave shows +/// as a strip of clear color between the gate's top edge and the aperture's +/// rasterized top edge (the shell wall starts above it; the seal z-kills +/// everything beyond; nothing re-covers). +/// +/// This harness measures that gap headlessly at the real Holtburg corner +/// building exit door (A9B4 0x0170, the HouseExitWalkReplay door): project the +/// aperture, run the production flood + assembler, then walk sample points +/// just inside the aperture's top edge downward until the gate admits them. +/// Plane-gap and scissor-gap are measured separately (mechanism attribution). +/// +/// VERDICT (2026-06-12, 147 eye/gaze combos): the CPU polygon pipeline is +/// sub-pixel exact (worst 0.54 px) — the W=0 clip port 987313a and both merge +/// passes are EXONERATED. The strip was the scissor box: the old +/// Floor(origin)+Ceiling(size) form cut up to 1 px off the TOP/RIGHT edges at +/// unlucky fractional alignments (captured live by this harness: top edge +/// y=0.7938 at 1080p → row 968 cut; right edge x=0.3503 at 1920 → column 1296 +/// cut). Fixed by the conservative NdcScissorRect bound; the assertions below +/// pin both properties. +/// +public class Issue130DoorwayStripTests +{ + private readonly ITestOutputHelper _out; + public Issue130DoorwayStripTests(ITestOutputHelper output) => _out = output; + + private const uint ExitCellId = CornerFloodReplayTests.Landblock | 0x0170u; + + // Production projection convention (CornerFloodReplayTests.ViewProjFor): + // FovY 1.2 rad, 1280x720 viewport, near 1, far 5000. The flood clip is + // near-independent so near/far exactness is not load-bearing. + private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt) + { + var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 1f, 5000f); + return view * proj; + } + + [Fact] + public void Diagnostic_ExitDoorTopEdge_GateVsAperture() + { + var datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cells = CornerFloodReplayTests.LoadBuilding(dats); + var root = cells[ExitCellId]; + LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null; + + // Find the exit portal (OtherCellId == 0xFFFF) and its world polygon. + int exitIdx = -1; + for (int i = 0; i < root.Portals.Count; i++) + { + if (root.Portals[i].OtherCellId == 0xFFFF && i < root.PortalPolygons.Count + && root.PortalPolygons[i].Length >= 3) + { exitIdx = i; break; } + } + Assert.True(exitIdx >= 0, "0x0170 has no exit portal polygon"); + + var localPoly = root.PortalPolygons[exitIdx]; + var worldPoly = new Vector3[localPoly.Length]; + for (int i = 0; i < localPoly.Length; i++) + worldPoly[i] = Vector3.Transform(localPoly[i], root.WorldTransform); + + Vector3 centroid = Vector3.Zero; + foreach (var w in worldPoly) centroid += w; + centroid /= worldPoly.Length; + + // Inward direction: the portal plane normal signed toward the cell + // interior (ClipPlanes carries InsideSide from the load). + var plane = root.ClipPlanes[exitIdx]; + var worldNormal = Vector3.TransformNormal(plane.Normal, root.WorldTransform); + var cellCenterWorld = Vector3.Transform( + (root.LocalBoundsMin + root.LocalBoundsMax) * 0.5f, root.WorldTransform); + if (Vector3.Dot(worldNormal, cellCenterWorld - centroid) < 0) + worldNormal = -worldNormal; + worldNormal = Vector3.Normalize(worldNormal); + + _out.WriteLine(FormattableString.Invariant( + $"exit portal idx={exitIdx} verts={localPoly.Length} centroid=({centroid.X:F2},{centroid.Y:F2},{centroid.Z:F2}) inward=({worldNormal.X:F2},{worldNormal.Y:F2},{worldNormal.Z:F2})")); + for (int i = 0; i < worldPoly.Length; i++) + _out.WriteLine(FormattableString.Invariant( + $" poly[{i}] world=({worldPoly[i].X:F3},{worldPoly[i].Y:F3},{worldPoly[i].Z:F3})")); + + float worstPlaneGapPx = 0f, worstScissorGapPx = 0f; + string worstDesc = "(none)"; + + // Eye sweep: back off the doorway along the inward normal at several + // distances/heights/lateral offsets; gaze at the centroid plus raised / + // lowered targets (NDC alignment of the top edge varies with gaze). + var lateral = Vector3.Normalize(Vector3.Cross(worldNormal, Vector3.UnitZ)); + float[] dists = { 0.6f, 1.0f, 1.6f, 2.4f, 3.5f }; + float[] heights = { 0.9f, 1.4f, 1.7f }; + float[] laterals = { -0.8f, 0f, 0.8f }; + float[] gazeRaise = { -0.4f, 0f, 0.4f, 0.9f }; + + int evaluated = 0; + foreach (float d in dists) + foreach (float h in heights) + foreach (float lat in laterals) + foreach (float gz in gazeRaise) + { + var eye = centroid + worldNormal * d + lateral * lat; + eye.Z = centroid.Z - 1.0f + h; // door centroid sits mid-opening; bias to floor-ish + var look = centroid + new Vector3(0, 0, gz); + var viewProj = ViewProjFor(eye, look); + + // Aperture truth: the seal's footprint = the raw polygon's projection. + var clip = new Vector4[worldPoly.Length]; + float minW = float.MaxValue; + for (int i = 0; i < worldPoly.Length; i++) + { + clip[i] = Vector4.Transform(new Vector4(worldPoly[i], 1f), viewProj); + minW = MathF.Min(minW, clip[i].W); + } + if (minW <= 0.05f) continue; // eye in/behind the door plane — out of #130's scenario + var aperture = new Vector2[clip.Length]; + for (int i = 0; i < clip.Length; i++) + aperture[i] = new Vector2(clip[i].X / clip[i].W, clip[i].Y / clip[i].W); + + var pv = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj); + var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); + if (asm.OutsideViewSlices.Length == 0) + { + _out.WriteLine(FormattableString.Invariant( + $"d={d} h={h} lat={lat} gz={gz}: NO outside slice (outPolys={pv.OutsideView.Polygons.Count})")); + continue; + } + evaluated++; + + (float planeGapPx, float scissorGapPx, float atX) = + MeasureTopEdgeGap(aperture, asm.OutsideViewSlices, 1920, 1080); + + if (planeGapPx > worstPlaneGapPx || scissorGapPx > worstScissorGapPx) + { + worstDesc = FormattableString.Invariant( + $"d={d} h={h} lat={lat} gz={gz} minW={minW:F2} atX={atX:F3} slices={asm.OutsideViewSlices.Length} mode={asm.TerrainMode} outVerts={DescribePolys(pv.OutsideView)} apVerts={aperture.Length}"); + worstPlaneGapPx = MathF.Max(worstPlaneGapPx, planeGapPx); + worstScissorGapPx = MathF.Max(worstScissorGapPx, scissorGapPx); + } + + if (planeGapPx > 0.55f || scissorGapPx > 0.55f) + { + _out.WriteLine(FormattableString.Invariant( + $"GAP d={d} h={h} lat={lat} gz={gz}: planeGap={planeGapPx:F2}px scissorGap={scissorGapPx:F2}px atX={atX:F3} mode={asm.TerrainMode} outVerts={DescribePolys(pv.OutsideView)}")); + float apTop = TopBoundaryY(aperture, atX); + foreach (var slice in asm.OutsideViewSlices) + _out.WriteLine(FormattableString.Invariant( + $" slice slot={slice.Slot} planes={slice.Planes.Length} aabb=({slice.NdcAabb.X:F4},{slice.NdcAabb.Y:F4},{slice.NdcAabb.Z:F4},{slice.NdcAabb.W:F4}) apTopAtX={apTop:F4}")); + foreach (var poly in pv.OutsideView.Polygons) + { + var sb = new System.Text.StringBuilder(" outPoly:"); + foreach (var v in poly.Vertices) + sb.Append(FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})")); + _out.WriteLine(sb.ToString()); + } + } + } + + _out.WriteLine(FormattableString.Invariant( + $"evaluated={evaluated} worstPlaneGapPx={worstPlaneGapPx:F2} worstScissorGapPx={worstScissorGapPx:F2} @ {worstDesc}")); + + Assert.True(evaluated > 100, $"sweep degenerated: only {evaluated} eye/gaze combos evaluated"); + // PIN 1 (#130): the scissor box never cuts a fragment the plane gate + // admits — conservative containment (AD-17's over-include doctrine). + // One probe step is ~0.11 px; anything beyond it is a real cut row. + Assert.True(worstScissorGapPx <= 0.15f, FormattableString.Invariant( + $"scissor under-covers the plane-admitted region by {worstScissorGapPx:F2}px @ {worstDesc}")); + // PIN 2 (canary): the CPU polygon pipeline (ProjectToClip → ClipToRegion + // merges → ClipPlaneSet planes) stays sub-pixel exact against the raw + // aperture projection. Observed 0.54 px worst (2026-06-12); the + // production vertex-merge floor is ~1 px — beyond 1.2 px means a new + // under-inclusion shaver entered the pipeline. + Assert.True(worstPlaneGapPx <= 1.2f, FormattableString.Invariant( + $"plane gate under-covers the aperture top edge by {worstPlaneGapPx:F2}px @ {worstDesc}")); + } + + private static string DescribePolys(CellView view) + { + var parts = new List(); + foreach (var p in view.Polygons) parts.Add(p.Vertices.Length.ToString()); + return $"[{string.Join(",", parts)}]"; + } + + /// + /// For sample x positions across the aperture's projected top edge, find the + /// aperture boundary's top y, then walk downward until the gate admits the + /// point. Returns the worst gaps in 1080p pixels (plane gate and modeled + /// scissor gate measured independently), and the x of the worst plane gap. + /// + private static (float planeGapPx, float scissorGapPx, float atX) MeasureTopEdgeGap( + Vector2[] aperture, ClipViewSlice[] slices, int fbW, int fbH, + ITestOutputHelper? debug = null) + { + const float Inset = 1e-4f; // dodge exact-boundary ambiguity + const float StepY = 0.0002f; // ~0.1 px at 1080p + const float CapY = 0.02f; // stop searching beyond ~10 px + + float minX = float.MaxValue, maxX = float.MinValue; + foreach (var v in aperture) { minX = MathF.Min(minX, v.X); maxX = MathF.Max(maxX, v.X); } + float span = maxX - minX; + if (span <= 0.01f) return (0, 0, 0); + + float worstPlane = 0, worstScissor = 0, atX = 0; + const int Samples = 160; + for (int s = 0; s <= Samples; s++) + { + float x = minX + span * (0.01f + 0.98f * s / Samples); + if (MathF.Abs(x) > 0.98f) continue; // off screen — no pixel exists there + float topY = TopBoundaryY(aperture, x); + if (float.IsNaN(topY) || MathF.Abs(topY) > 0.98f) continue; // off screen / no boundary + + var p = new Vector2(x, topY - Inset); + + float planeGap = GapBelow(p, q => AnySliceAdmitsPlanes(slices, q), StepY, CapY); + // The scissor question is "does the box cut pixels the PLANES would + // draw" — measure it from the planes-admitted top, not the aperture + // top (at slanted corners the aperture top can sit legitimately + // outside the gate polygon's column). + var pPlanes = new Vector2(p.X, p.Y - planeGap - Inset); + float scissorGap = GapBelow(pPlanes, q => AnySliceAdmitsScissor(slices, q, fbW, fbH), StepY, CapY); + + if (debug is not null && scissorGap > 0.005f) + debug.WriteLine(FormattableString.Invariant( + $" sample x={x:F4} apTop={topY:F4} planeGap={planeGap * fbH / 2f:F2}px pPlanes=({pPlanes.X:F4},{pPlanes.Y:F4}) scissorGap={scissorGap * fbH / 2f:F2}px")); + + if (planeGap > worstPlane) { worstPlane = planeGap; atX = x; } + worstScissor = MathF.Max(worstScissor, scissorGap); + } + // NDC y → pixels at the given framebuffer height. + return (worstPlane * fbH / 2f, worstScissor * fbH / 2f, atX); + } + + private static float GapBelow(Vector2 start, Func admitted, float step, float cap) + { + if (admitted(start)) return 0f; + for (float dy = step; dy <= cap; dy += step) + { + if (admitted(new Vector2(start.X, start.Y - dy))) + return dy; + } + return cap; + } + + // Production semantics: each OutsideView polygon is one slice; the union of + // slices is drawn. A slice with planes gates per fragment via + // gl_ClipDistance (dot((nx,ny,0,d),(x,y,z,1)) >= 0 for an NDC point); + // a planeless slice (scissor fallback) admits its whole NDC AABB. + private static bool AnySliceAdmitsPlanes(ClipViewSlice[] slices, Vector2 p) + { + foreach (var slice in slices) + { + if (slice.Planes.Length == 0) + { + if (p.X >= slice.NdcAabb.X && p.Y >= slice.NdcAabb.Y + && p.X <= slice.NdcAabb.Z && p.Y <= slice.NdcAabb.W) + return true; + continue; + } + bool inside = true; + foreach (var pl in slice.Planes) + { + if (pl.X * p.X + pl.Y * p.Y + pl.W < 0f) { inside = false; break; } + } + if (inside) return true; + } + return false; + } + + // Production scissor (BeginDoorwayScissor → NdcScissorRect.ToPixels): a + // point is admitted when its pixel falls inside some slice's scissor box. + private static bool AnySliceAdmitsScissor(ClipViewSlice[] slices, Vector2 p, int fbW, int fbH) + { + int pixX = (int)MathF.Floor((p.X * 0.5f + 0.5f) * fbW); + int pixY = (int)MathF.Floor((p.Y * 0.5f + 0.5f) * fbH); + foreach (var slice in slices) + { + var box = NdcScissorRect.ToPixels(slice.NdcAabb, fbW, fbH); + if (pixX >= box.X && pixX < box.X + box.Width + && pixY >= box.Y && pixY < box.Y + box.Height) + return true; + } + return false; + } + + /// Highest boundary y of the polygon at vertical line x (NaN when + /// the line misses the polygon). + private static float TopBoundaryY(Vector2[] poly, float x) + { + float best = float.NaN; + for (int i = 0; i < poly.Length; i++) + { + var a = poly[i]; + var b = poly[(i + 1) % poly.Length]; + if (MathF.Abs(a.X - b.X) < 1e-9f) + { + if (MathF.Abs(a.X - x) < 1e-6f) + { + float hi = MathF.Max(a.Y, b.Y); + if (float.IsNaN(best) || hi > best) best = hi; + } + continue; + } + float t = (x - a.X) / (b.X - a.X); + if (t < 0f || t > 1f) continue; + float y = a.Y + t * (b.Y - a.Y); + if (float.IsNaN(best) || y > best) best = y; + } + return best; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/NdcScissorRectTests.cs b/tests/AcDream.App.Tests/Rendering/NdcScissorRectTests.cs new file mode 100644 index 00000000..2dc084ff --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/NdcScissorRectTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// #130: the doorway-slice scissor must be a CONSERVATIVE outer bound of its +/// NDC AABB (AD-17: over-inclusion safe, under-inclusion is the bug class). +/// The old Floor(origin)+Ceiling(size) form put the far edge at +/// floor(min)+ceil(max−min), up to one pixel short of the true max edge — +/// the doorway top-edge background strip. +/// +public class NdcScissorRectTests +{ + /// Containment property: every pixel whose CENTER lies inside the + /// NDC box is inside the scissor box, across a dense grid of fractional + /// alignments at two framebuffer sizes. + [Theory] + [InlineData(1920, 1080)] + [InlineData(2560, 1440)] + public void EveryCenterInsidePixel_IsInsideTheBox(int fbW, int fbH) + { + for (int i = 0; i < 251; i++) + { + // Sweep fractional alignments of all four edges. + float f = i / 251f; + float minX = -0.83f + f * 0.0031f; + float minY = -0.71f + f * 0.0047f; + float maxX = 0.339f + f * 0.0043f; + float maxY = 0.7938f + f * 0.0029f; + var box = NdcScissorRect.ToPixels(new Vector4(minX, minY, maxX, maxY), fbW, fbH); + + // Pixel-space extremes of center-inside pixels. + float x0 = (minX * 0.5f + 0.5f) * fbW, x1 = (maxX * 0.5f + 0.5f) * fbW; + float y0 = (minY * 0.5f + 0.5f) * fbH, y1 = (maxY * 0.5f + 0.5f) * fbH; + int loX = (int)MathF.Ceiling(x0 - 0.5f), hiX = (int)MathF.Floor(x1 - 0.5f); + int loY = (int)MathF.Ceiling(y0 - 0.5f), hiY = (int)MathF.Floor(y1 - 0.5f); + + Assert.True(box.X <= loX, $"left cut: box.X={box.X} > loX={loX} (minX={minX})"); + Assert.True(box.Y <= loY, $"bottom cut: box.Y={box.Y} > loY={loY} (minY={minY})"); + Assert.True(box.X + box.Width > hiX, $"right cut: box ends {box.X + box.Width} <= hiX={hiX} (maxX={maxX})"); + Assert.True(box.Y + box.Height > hiY, $"top cut: box ends {box.Y + box.Height} <= hiY={hiY} (maxY={maxY})"); + // Over-inclusion stays bounded (≤1 px per edge). + Assert.True(box.X >= loX - 1 && box.Y >= loY - 1); + Assert.True(box.X + box.Width <= hiX + 2 && box.Y + box.Height <= hiY + 2); + } + } + + [Fact] + public void CapturedRegression_TopEdgeRow968_At1080p() + { + // Issue130DoorwayStripTests live capture: aperture top y=0.7938 → + // pixel row 968 (center 968.5 < 968.65). The old formula ended the box + // at row 967 — the visible strip. + var box = NdcScissorRect.ToPixels(new Vector4(-0.339f, -0.743f, 0.339f, 0.7938f), 1920, 1080); + Assert.True(box.Y + box.Height > 968, $"top row 968 cut: box ends at {box.Y + box.Height}"); + } + + [Fact] + public void CapturedRegression_RightColumn1296_At1920() + { + // Issue130DoorwayStripTests live capture: gate right edge x=0.3507 → + // pixel column 1296 admitted by the plane gate; the old formula ended + // the box at column 1295. + var box = NdcScissorRect.ToPixels(new Vector4(-0.2845f, -1.0f, 0.3507f, 0.2630f), 1920, 1080); + Assert.True(box.X + box.Width > 1296, $"right column 1296 cut: box ends at {box.X + box.Width}"); + } + + [Fact] + public void DegenerateAndOffscreenBoxes_StayValid() + { + // Past-the-edge regions clamp to the screen and keep min 1 px size. + var box = NdcScissorRect.ToPixels(new Vector4(0.999f, 0.999f, 1.5f, 1.5f), 1920, 1080); + Assert.True(box.Width >= 1 && box.Height >= 1); + var inverted = NdcScissorRect.ToPixels(new Vector4(1f, 1f, -1f, -1f), 1920, 1080); + Assert.True(inverted.Width >= 1 && inverted.Height >= 1); + } +} From 4ba714835daca09aad80e67894266c4cff3c439e Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 13:38:59 +0200 Subject: [PATCH 03/65] fix #129: cap the punch mark bias's eye-space reach (was unbounded at distance) The user's "doors/doorways leak through terrain and houses over a landblock" is the #117 mark-pass bias evaluated in the wrong space. Mechanism (confirmed analytically, Issue129PunchBiasTests): the punch's pass-A stencil mark biased the aperture fan toward the viewer by a CONSTANT 0.0005 NDC. NDC depth is non-linear - a constant NDC bias b spans ~= b*d^2*(f-n)/(f*n) meters of eye depth at eye distance d. With retail's znear 0.1 (d4b5c71) that is 0.125 m at 5 m but ~190 m at one landblock: every hill/house in front of a distant aperture passed the LEQUAL mark and was far-Z punched -> door-shaped leak through the occluder. This is exactly the risk AD-18's register row recorded ("an occluder within ~bias in front of a distant aperture gets punched through") - the symptom-scan rule found it before instrumentation. Fix: cap the bias's EYE-SPACE span at 0.5 m - biasNdc(d) = min(0.0005, capMeters * near / d^2) in the mark-pass vertex shader (clipPos.w = eye depth), CPU-mirrored as PortalDepthMaskRenderer.MarkBiasNdc for tests. Below the ~10 m crossover the constant-NDC term is smaller and wins - bit-identical to the T5-validated close-range behavior, so the #108 grass coverage that justified the bias is untouched. Beyond it the punch can never reach an occluder more than 0.5 m in front of the aperture plane. Pins (Issue129PunchBiasTests): the old form spans >100 m of eye depth at a landblock (the leak, kept as documentation of the refuted shape); the capped form stays <= 0.5 m at every distance 1-400 m and matches the validated constant bit-for-bit below 10 m. AD-18 register row updated in the same commit (bias description + the #129 closure + the residual risk note: door-hugging geometry beyond the 0.5 m cap at >10 m viewing range re-occludes - the cap constant is the tuning knob if the gate shows residue). Suites: App 256+1skip / Core 1439+2skip / UI 420 / Net 294 green. Awaiting the user visual gate at the original spot (+ #108 cellar re-check up close). Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 44 ++++++------ .../retail-divergence-register.md | 2 +- .../Rendering/PortalDepthMaskRenderer.cs | 45 +++++++++++- .../Rendering/Issue129PunchBiasTests.cs | 68 +++++++++++++++++++ 4 files changed, 134 insertions(+), 25 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/Issue129PunchBiasTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index fee8cf6c..5656be52 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -4477,36 +4477,38 @@ staircase entity's per-frame draw decision. ## #129 — Doors/doorways leak through terrain and houses from over a landblock away -**Status:** OPEN +**Status:** FIX SHIPPED — awaiting user visual gate **Severity:** MEDIUM (visible at distance during normal outdoor play) **Filed:** 2026-06-12 (user report, post-#119-close session) -**Component:** render — aperture depth punch at distance (#117 family) +**Component:** render — aperture depth punch at distance (#117 family, AD-18) **Symptom (user):** "leakage of like doors and doorways through the terrain and houses over a landblock" — door/doorway-shaped patches visible THROUGH intervening terrain and nearer buildings when the source building is roughly a landblock (~192 m) or more away. -**Leads:** -1. **The #117 stencil depth-gate bias at long range (top suspect).** - #117's fix (`478c549`) marks aperture pixels at biased true depth - (LEQUAL, bias 0.0005 NDC) then far-Z punches only marked pixels. With - a non-linear depth buffer, 0.0005 NDC at ~200 m spans many METERS of - view depth — the bias can exceed the separation between the aperture - and a hill/house in front of it, marking occluder pixels and punching - them → the occluder shows the interior/background behind. The #108 - coverage constraint pulls the bias up; distance pulls it wrong — - re-derive the bias in eye-space (or scale by w) instead of constant - NDC. -2. Per-building look-in floods admitting distant buildings (the #127 - churn family) — would gate WHICH buildings punch, not the - through-occluder leak itself. +**Root cause (lead 1 confirmed analytically, `Issue129PunchBiasTests`):** +the #117 mark-pass bias was a CONSTANT 0.0005 NDC. NDC depth is +non-linear — a constant NDC bias `b` spans ≈ `b·d²/near` meters of eye +depth at distance `d`. With retail's znear 0.1 that is 0.125 m at 5 m +but **~190 m at a landblock**: every hill/house in front of a distant +aperture passed the LEQUAL mark and was far-Z punched → the door-shaped +leak. Exactly AD-18's recorded "Risk if assumption breaks". -**Next:** capture at the spot (ACDREAM_PROBE_VIEWER=1 + a screenshot + -player/eye position from [snap]/[viewer]); confirm whether the leak -patch matches an aperture polygon of the distant building; then test -the eye-space-bias hypothesis headlessly (the #117 commit has the bias -math). +**Fix (2026-06-12):** cap the bias's EYE-SPACE span — +`biasNdc(d) = min(0.0005, 0.5 m × near / d²)` +(`PortalDepthMaskRenderer.MarkBiasNdc`, mirrored in the vertex shader). +Below the ~10 m crossover the constant term wins, bit-identical to the +T5-validated behavior (#108 grass coverage untouched); beyond it the +punch can never reach an occluder more than 0.5 m in front of the +aperture plane. Pins: `Issue129PunchBiasTests` (old form spans >100 m +at a landblock; capped form ≤0.5 m at all distances; close range +unchanged). + +**Gate:** the original spot — distant building doors no longer show +through terrain/houses at ~a landblock; AND the #108 cellar grass-sweep +stays gone up close. If a >10 m-range #108-class residue appears, the +cap constant (0.5 m) is the tuning knob — see AD-18. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index f7612b9a..64cdf3fe 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -79,7 +79,7 @@ accepted-divergence entries (#96, #49, #50). | AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 | | AD-16 | Building-flood gate is a CPU frustum test on each building's `PortalBounds` AABB; retail floods exactly when the shell draws and an aperture survives (no bounds constant anywhere) | `src/AcDream.App/Rendering/GameWindow.cs:7634` | Documented as the tight equivalent of the shell viewconeCheck for flood purposes (the FPS fix the Chebyshev≤1 hack approximated); per-portal admission still goes through BuildFromExterior's screen clip; missing-bounds buildings always flood (safe over-include) | A too-small/stale PortalBounds AABB means the interior never floods — doorway shows a hole/black aperture from outside (inverse of the vanishing-staircase class) | `DrawBuilding` 0x0059f2a0; `BSPPORTAL::portal_draw_portals_only` 0x53d870 | | AD-17 | ≤8 GPU `gl_ClipDistance` half-planes per view region, degrading to a union-AABB scissor (over-include) on multi-polygon / >8-edge views; particles always scissor; scissor slices disable per-object viewcone culling. Retail CPU-clips against the exact portal polygon | `src/AcDream.App/Rendering/ClipPlaneSet.cs:23` | GL guarantees only 8 simultaneous clip planes; invariant documented: over-inclusion is safe, under-inclusion is the bug class | Fallback on complex multi-aperture views draws terrain/sky/particles/objects outside the true aperture but inside its AABB — background/interior bleed strips at doorways (the **#130** family) | `ACRender::polyClipFinish` decomp:702749; PView portal_view slices | -| AD-18 | Aperture far-Z punch is two-pass stencil-gated with invented `PunchMarkDepthBias = 0.0005` NDC; retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders (hills, closer buildings), painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Bias is depth-dependent: an occluder within ~bias in front of a distant aperture gets punched through; door-plane-hugging geometry just beyond it re-occludes the aperture (a **#108**-class regression) | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) | +| AD-18 | Aperture far-Z punch is two-pass stencil-gated with an invented mark bias: 0.0005 NDC capped to a 0.5 m EYE-SPACE span (`MarkBiasNdc`); retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders, painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. **#129** (2026-06-12): the constant-NDC bias spanned ~190 m of eye depth at a landblock (non-linear depth) → distant occluders punched; the eye-space cap bounds the reach (`Issue129PunchBiasTests`). DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Door-plane-hugging geometry beyond the 0.5 m cap re-occludes the aperture (a **#108**-class regression at >10 m viewing range); an occluder within the cap in front of a distant aperture still punches through | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) | | AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw` → `DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 | | AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 | | AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/GameWindow.cs:7671` | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 | diff --git a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs index 969396b8..4f0a6436 100644 --- a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs +++ b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs @@ -52,7 +52,8 @@ uniform mat4 uViewProjection; uniform int uPlaneCount; uniform vec4 uPlanes[8]; uniform int uForceFarZ; -uniform float uDepthBias; // NDC bias toward the viewer (mark pass only) +uniform float uDepthBias; // NDC bias toward the viewer (mark pass only) +uniform float uDepthBiasEyeCapN; // eye-span cap x near plane (#129; see MarkBiasNdc) out float gl_ClipDistance[8]; void main() { @@ -62,7 +63,14 @@ void main() if (uForceFarZ == 1) clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail) else if (uDepthBias > 0.0) - clipPos.z -= uDepthBias * clipPos.w; // #117 mark-pass bias (see DrawDepthFan) + { + // #117 mark-pass bias, #129 eye-space cap. clipPos.w = eye depth d; + // an NDC bias b spans ~b*d*d/near meters of eye depth, so the + // constant-NDC form alone reached METERS at distance (door-shaped + // leaks through hills/houses). Keep in sync with MarkBiasNdc. + float biasNdc = min(uDepthBias, uDepthBiasEyeCapN / max(clipPos.w * clipPos.w, 1e-6)); + clipPos.z -= biasNdc * clipPos.w; + } gl_Position = clipPos; }"; @@ -79,6 +87,7 @@ void main() { } // depth-only: color writes are masked off by the caller state private readonly int _locPlanes; private readonly int _locForceFarZ; private readonly int _locDepthBias; + private readonly int _locDepthBiasEyeCapN; private const int MaxFanVerts = 32; private readonly float[] _scratch = new float[MaxFanVerts * 3]; @@ -104,6 +113,7 @@ void main() { } // depth-only: color writes are masked off by the caller state _locPlanes = _gl.GetUniformLocation(_program, "uPlanes"); _locForceFarZ = _gl.GetUniformLocation(_program, "uForceFarZ"); _locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias"); + _locDepthBiasEyeCapN = _gl.GetUniformLocation(_program, "uDepthBiasEyeCapN"); _vao = _gl.GenVertexArray(); _vbo = _gl.GenBuffer(); @@ -144,10 +154,37 @@ void main() { } // depth-only: color writes are masked off by the caller state /// stencil below). The bias keeps the #108 case covered — terrain /// hugging the door plane (centimeters in front of the aperture) must /// still be punched; a hill or another house meters nearer must not. - /// 0.0005 NDC ≈ 6 cm at 5 m / ≈ 1 m at 20 m with znear=0.1. /// private const float PunchMarkDepthBias = 0.0005f; + /// + /// #129 (2026-06-12): NDC depth is non-linear — a constant NDC bias b + /// spans ≈ b·d²/near meters of eye depth at eye distance d. With + /// znear = 0.1, the 0.0005 constant alone spanned 0.125 m at 5 m but + /// ~190 m at a landblock away: every hill/house in front of a distant + /// aperture passed the mark and got far-Z punched — door-shaped leaks + /// through occluders. Fix: cap the bias's EYE-SPACE span at + /// . Below the ~10 m crossover + /// (sqrt(cap·near/0.0005)) the constant-NDC term is smaller and wins — + /// bit-identical to the T5-validated close-range behavior (#108 grass + /// coverage untouched); beyond it the punch can never reach an occluder + /// more than the cap in front of the aperture plane. + /// + public const float PunchMarkBiasEyeCapMeters = 0.5f; + + /// Retail Render::znear = 0.1 (decomp :342173, re-landed + /// d4b5c71). The cap conversion below assumes the production camera near + /// plane; the small f/(f−n) factor (~1.00002 at far 5000) is ignored. + public const float CameraNearPlaneMeters = 0.1f; + + /// CPU mirror of the vertex-shader mark-bias expression (keep in + /// sync with VertSrc): the NDC bias applied at eye depth + /// . + public static float MarkBiasNdc(float eyeDepthMeters) => + MathF.Min(PunchMarkDepthBias, + PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters + / MathF.Max(eyeDepthMeters * eyeDepthMeters, 1e-6f)); + /// /// Draw one portal polygon as an invisible depth write, clipped to the /// slice's clip-space half-planes. selects @@ -237,6 +274,8 @@ void main() { } // depth-only: color writes are masked off by the caller state _gl.DepthMask(false); _gl.Uniform1(_locForceFarZ, 0); _gl.Uniform1(_locDepthBias, PunchMarkDepthBias); + _gl.Uniform1(_locDepthBiasEyeCapN, + PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters); _gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n); // ── PUNCH pass B: far-Z write on marked pixels only; diff --git a/tests/AcDream.App.Tests/Rendering/Issue129PunchBiasTests.cs b/tests/AcDream.App.Tests/Rendering/Issue129PunchBiasTests.cs new file mode 100644 index 00000000..8686c1c0 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Issue129PunchBiasTests.cs @@ -0,0 +1,68 @@ +using System; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// #129 — doors/doorways leak through terrain and houses from over a landblock +/// away. The punch's mark pass (#117, AD-18) biased the aperture fan toward +/// the viewer by a CONSTANT 0.0005 NDC. NDC depth is non-linear: a constant +/// NDC bias b spans ≈ b·d²·(f−n)/(f·n) meters of eye depth at eye distance d +/// — 0.125 m at 5 m but ~190 m at a landblock (znear 0.1), so distant +/// occluders in front of an aperture passed the mark and were far-Z punched: +/// the door-shaped leak. The fix caps the bias's eye-space span +/// (PortalDepthMaskRenderer.MarkBiasNdc): identical to the validated constant +/// below the ~10 m crossover, never more than the cap beyond it. +/// +public class Issue129PunchBiasTests +{ + private const float Near = PortalDepthMaskRenderer.CameraNearPlaneMeters; // 0.1 (retail znear) + private const float Far = 5000f; + + /// Eye-depth span (meters) covered by an NDC depth bias b at eye + /// distance d: ndc(d) = f(d−n)/((f−n)d) ⇒ d(ndc) inverse ⇒ + /// span = b·d²·(f−n)/(f·n) (exact for small b via the derivative). + private static float EyeSpanMeters(float biasNdc, float d) => + biasNdc * d * d * (Far - Near) / (Far * Near); + + [Fact] + public void OldConstantBias_SpansMetersAtALandblock_TheLeak() + { + // The refuted form (documentation of WHY the constant was wrong): + // 0.0005 NDC at ~one landblock spans far more eye depth than any + // occluder separation — everything in front got punched. + Assert.True(EyeSpanMeters(0.0005f, 192f) > 100f); + // ...while at close range it was a sane sliver: + Assert.InRange(EyeSpanMeters(0.0005f, 5f), 0.05f, 0.30f); + } + + [Fact] + public void CappedBias_MatchesValidatedConstant_AtCloseRange() + { + // Below the crossover the T5-validated constant must win unchanged — + // this preserves the #108 grass coverage bit-for-bit. + foreach (float d in new[] { 0.5f, 1f, 3f, 5f, 8f, 9.9f }) + Assert.Equal(0.0005f, PortalDepthMaskRenderer.MarkBiasNdc(d), 6); + } + + [Fact] + public void CappedBias_EyeSpanNeverExceedsCap_AtAnyDistance() + { + for (float d = 1f; d <= 400f; d += 1f) + { + float span = EyeSpanMeters(PortalDepthMaskRenderer.MarkBiasNdc(d), d); + Assert.True(span <= PortalDepthMaskRenderer.PunchMarkBiasEyeCapMeters * 1.02f, + FormattableString.Invariant($"bias spans {span:F2} m of eye depth at d={d} m")); + } + } + + [Fact] + public void CappedBias_At200m_CannotReachOccluders() + { + // The reported #129 distance: occluder separations are tens of + // meters; the punch reach must stay under the 0.5 m cap. + float span = EyeSpanMeters(PortalDepthMaskRenderer.MarkBiasNdc(200f), 200f); + Assert.True(span <= 0.51f, FormattableString.Invariant($"span {span:F3} m at 200 m")); + } +} From 51350667334520257bfc5cd8e8ff97e8e653d75a Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 14:28:16 +0200 Subject: [PATCH 04/65] fix #130 (the real strip): drawn-shell lift vs draw-space portal consumers The user's re-gate refuted the scissor fix as THE strip (6c4b6d6 was a real but sub-pixel under-coverage): the strip survived, screenshot at a doorway, full width of the opening, top edge only, "very subtle". Root cause (pinned by Issue130DoorwayStripTests.UnliftedGate_*): the +0.02 m shell render lift. Cell shells DRAW 2 cm above the dat origin (z-fight vs coplanar terrain); f35cb8b (the #119-residual fix, 2026-06-11) deliberately reverted the VISIBILITY graph to the physics (unlifted) transform - but the OutsideView color gate (terrain/sky/ scissor through the doorway) and the seal/punch depth fans are DRAW-space consumers and kept projecting the unlifted polygons. The drawn lintel therefore sits one lift-projection above the gate's top edge - measured 6.7 px at a 2.4 m doorway - and that band never receives terrain/sky color while the seal also stamps 2 cm low. A regression from f35cb8b, NOT from the W=0 clip port (987313a stays exonerated). Vertical aperture edges are immune (the lift slides them along themselves) - top edge only, exactly as reported; explains the "also NOW" timing precisely. Fix - draw space draws lifted, visibility stays physics (the f35cb8b invariant, now symmetric): - PortalVisibilityBuilder.Build gains drawLiftZ: the exit-portal branch projects the OutsideView region with the lifted transform; flood admission, side tests, and CellViews are untouched (default 0 keeps every existing visibility test bit-identical). - The seal/punch fans (DrawRetailPViewPortalDepthWrite) lift their world verts to the drawn shell's space. - One shared constant PortalVisibilityBuilder.ShellDrawLiftZ feeds the shell registration (GameWindow:5604), the gate, and the fans. Register: AP-32 ADDED - the +0.02 lift had NO row (a pre-register deviation the 2026-06-12 sweep missed). The row records the split invariant both ways: a draw-space consumer that forgets the lift re-opens the #130 strip; a visibility consumer that picks the lifted transform re-opens the #119-residual side-cull. Pins: the lifted gate covers the drawn (lifted) aperture to 0.00 px across the 147-combo sweep; the unlifted gate shows the 6.7 px strip (sensitivity proof - if the lift is ever removed, this test says the drawLiftZ plumbing can go too). Suites: App 257+1skip / Core 1439+2skip / UI 420 / Net 294 green. Awaiting the user re-gate at a doorway with the lintel on screen. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 65 ++++++----- .../retail-divergence-register.md | 3 +- src/AcDream.App/Rendering/GameWindow.cs | 15 ++- .../Rendering/PortalVisibilityBuilder.cs | 35 +++++- .../Rendering/RetailPViewRenderer.cs | 4 +- .../Rendering/Issue130DoorwayStripTests.cs | 107 +++++++++++++++++- 6 files changed, 191 insertions(+), 38 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 5656be52..4868d529 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -4514,45 +4514,50 @@ cap constant (0.5 m) is the tuning knob — see AD-18. ## #130 — Background-color strip along the TOP outer edge of a doorway when looking out from inside -**Status:** FIX SHIPPED — awaiting user visual gate +**Status:** FIX 2 SHIPPED — awaiting user visual re-gate **Severity:** LOW-MEDIUM (small strip, but on the most-stared-at pixels in the game) **Filed:** 2026-06-12 (user report, post-#119-close session) -**Component:** render — doorway-slice scissor box math (AD-17 family) +**Component:** render — drawn-shell lift vs draw-space portal consumers (AP-32) **Symptom (user):** standing inside looking out through a doorway, a thin strip of background (clear/world) color runs along the OUTER edge -of the TOP of the doorway opening. +of the TOP of the doorway opening. Survived the scissor fix (`6c4b6d6`) +— user screenshot 2026-06-12 evening, "very subtle". -**Root cause (pinned headlessly 2026-06-12, `Issue130DoorwayStripTests` -— 147 eye/gaze combos at the real A9B4 0x0170 exit door):** the -`BeginDoorwayScissor` NDC→pixel conversion (`Floor(origin) + -Ceiling(size)`) put the box's far edge at `floor(min)+ceil(max−min)` — -up to ONE PIXEL SHORT of the true top/right edge at unlucky fractional -alignments. The scissor brackets the ENTIRE landscape slice (sky, -terrain, statics, weather), the seal stamps the full aperture at true -depth, and the shell ends at the aperture edge — so the cut pixel row -never receives color: a background strip along the top edge that comes -and goes as the eye moves (alignment shifts). Captured live by the -harness: top edge y=0.7938 at 1080p → row 968 cut; right edge column -1296 cut at 1920. This violated AD-17's own doctrine (over-inclusion -safe, under-inclusion is the bug class). +**Root cause (the REAL strip, pinned by +`Issue130DoorwayStripTests.UnliftedGate_LeavesTheStripAtTheDrawnTopEdge`): +the +0.02 m shell render lift.** Cell shells DRAW 2 cm above the dat +origin (z-fight vs terrain, AP-32); since `f35cb8b` (the #119-residual +fix) the visibility graph deliberately uses the PHYSICS (unlifted) +transform — but the OutsideView color gate and the seal fans, which are +DRAW-space consumers, kept the unlifted polygons. The drawn lintel +therefore sits one lift-projection ABOVE the gate's top edge — +**6.7 px at a 2.4 m doorway** (measured) — and that band gets no +terrain/sky color while the seal also stamps 2 cm low. Regression from +`f35cb8b` (2026-06-11), NOT from the W=0 clip port. Vertical edges are +immune (the lift slides them along themselves) — top edge only, exactly +as reported. -**Lead 1 REFUTED:** the W=0 clip port `987313a` is exonerated by the -same harness — the CPU polygon pipeline (ProjectToClip → ClipToRegion -merges → ClipPlaneSet planes) is sub-pixel exact against the raw -aperture projection (worst 0.54 px; 0.00 px in the aligned case). For -an all-in-front doorway polygon the port is bit-identical to the old -path by construction (the W clip pass only runs when a vertex has -w < 0). +**Fix 2:** draw-space consumers re-apply the lift — +`PortalVisibilityBuilder.Build(drawLiftZ:)` projects the exit-portal +OutsideView region with the lifted transform (flood admission, side +tests, CellViews stay physics-space per f35cb8b), and the seal/punch +fans lift their world verts. One shared constant +`PortalVisibilityBuilder.ShellDrawLiftZ` now feeds the shell +registration, the gate, and the fans. AP-32 register row added (the +lift had no row). Pins: the lifted gate covers the drawn aperture to +0.00 px across the 147-combo sweep; the unlifted gate shows the 6.7 px +strip (sensitivity). -**Fix:** conservative outer bound `floor(min)/ceil(max)` extracted to -`NdcScissorRect.ToPixels` (GL-free, unit-tested); `BeginDoorwayScissor` -delegates. Pins: `NdcScissorRectTests` (containment property + both -captured alignments) + `Issue130DoorwayStripTests` (scissor never cuts -plane-admitted fragments; CPU-pipeline exactness canary ≤1.2 px). +**Fix 1 (also real, sub-pixel): `6c4b6d6`** — the doorway-slice scissor +`Floor(origin)+Ceiling(size)` cut up to 1 px off the top/right edges; +now a conservative outer bound (`NdcScissorRect`, AD-17 doctrine). +The W=0 clip port `987313a` is exonerated (CPU pipeline sub-pixel exact +in like-for-like space). -**Gate:** stand inside any cottage, look out the door, sweep the gaze — -no background strip at the top edge at any alignment. +**Gate:** stand inside, look out the door with the lintel on screen, +sweep the gaze — no background strip at the top edge at any alignment +or distance. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 64cdf3fe..631ccc7e 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -92,7 +92,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 31 rows +## 3. Documented approximation (AP) — 32 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -127,6 +127,7 @@ accepted-divergence entries (#96, #49, #50). | AP-29 | Target-indicator fallback for entities with no baked selection sphere: invented 1.5 m × scale box + 16/12 px screen floors (primary path is a faithful `GetObjectBoundingBox` port) | `src/AcDream.App/UI/TargetIndicatorPanel.cs:86` | Fallback only fires when the Setup didn't bake a selection sphere — rare in practice | Sphere-less entities get a non-retail indicator size/placement; the pixel floors prevent retail's far-distance collapse | `SmartBox::GetObjectBoundingBox` 0x00452e20; `GetSelectionSphere` | | AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 | | AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter | +| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d09cf23d..f352c009 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5599,9 +5599,13 @@ public sealed class GameWindow : IDisposable _pendingCellMeshes[envCellId] = cellSubMeshes; // Keep the small render lift out of physics; retail BSP - // contact planes use the EnvCell origin verbatim. + // contact planes use the EnvCell origin verbatim. The lift + // constant is shared with every draw-space consumer of + // portal polygons (OutsideView gate, seal/punch fans) — + // see PortalVisibilityBuilder.ShellDrawLiftZ (#130). var physicsCellOrigin = envCell.Position.Origin + lbOffset; - var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(0f, 0f, 0.02f); + var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( + 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); var cellTransform = System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); @@ -9699,9 +9703,16 @@ public sealed class GameWindow : IDisposable if (localVerts.Length < 3) continue; + // cell.WorldTransform is the PHYSICS (unlifted) transform (f35cb8b); + // the shell that rasterizes this aperture draws +ShellDrawLiftZ + // higher. The seal/punch is a DRAW — stamp depth in the same lifted + // space or the stamp sits 2 cm below the drawn hole (#130 family). int n = System.Math.Min(localVerts.Length, world.Length); for (int v = 0; v < n; v++) + { world[v] = System.Numerics.Vector3.Transform(localVerts[v], cell.WorldTransform); + world[v].Z += AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ; + } _portalDepthMask.DrawDepthFan(world[..n], viewProjection, sliceCtx.Slice.Planes, forceFarZ); } diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index ba1cb700..6f26d7d5 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -97,16 +97,31 @@ public static class PortalVisibilityBuilder Console.WriteLine($"[pv-ERROR] chain tail(24):{tail}"); } + /// The +Z world lift applied to DRAWN cell shells (z-fighting vs + /// terrain; applied in GameWindow's cell registration). The visibility + /// graph stays in PHYSICS (unlifted) space — feeding the lift into portal + /// planes broke horizontal-portal side tests (#119-residual, f35cb8b). + /// Draw-space consumers of portal polygons (the OutsideView color gate + /// here, the seal/punch depth fans in GameWindow) must apply this lift so + /// they meet the drawn shell's aperture edge — the unlifted gate left a + /// 2 cm background strip under the drawn lintel (#130). + public const float ShellDrawLiftZ = 0.02f; + /// Resolve a full cell id to its LoadedCell, or null if not loaded. /// Optional: true if a cell id is in the camera building's cell /// set. When provided, a neighbour OUTSIDE the set routes to CrossBuildingViews instead of /// continuing the in-building BFS. Pass null to treat all reachable cells as in-building. + /// World +Z applied ONLY to the exit-portal projection feeding + /// (a draw-space region; see + /// ). Flood admission, side tests, and CellViews are unaffected. + /// Production passes ; tests replaying visibility semantics pass 0. public static PortalVisibilityFrame Build( LoadedCell cameraCell, Vector3 cameraPos, Func lookup, Matrix4x4 viewProj, - Func? buildingMembership = null) + Func? buildingMembership = null, + float drawLiftZ = 0f) { var frame = new PortalVisibilityFrame(); if (cameraCell == null) return frame; @@ -318,8 +333,22 @@ public static class PortalVisibilityBuilder Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]"); } // Exit portal -> outdoors visible through this (clipped) opening. - AddRegion(frame.OutsideView, clippedRegion); - trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts}"); + // OutsideView gates DRAWN color (terrain/sky/scissor), and the + // shell that rasterizes this aperture draws +drawLiftZ above + // the physics transform — project the region in the SAME + // lifted space or terrain stops a lift-height short of the + // drawn lintel (#130 strip). Flood semantics keep the + // unlifted clippedRegion path above. + var outsideRegion = drawLiftZ == 0f + ? clippedRegion + : ClipPortalAgainstView( + poly, + cell.WorldTransform * Matrix4x4.CreateTranslation(0f, 0f, drawLiftZ), + viewProj, + activeViewPolygons, + out _); + AddRegion(frame.OutsideView, outsideRegion); + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={outsideRegion.Count} clipVerts={clipVerts}"); continue; } diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index f42941be..29996bf1 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -54,7 +54,9 @@ public sealed class RetailPViewRenderer ctx.RootCell, ctx.ViewerEyePos, ctx.CellLookup, - ctx.ViewProjection); + ctx.ViewProjection, + buildingMembership: null, + drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); // R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge // the small (~2-cell) per-building views into the frame. Retail reaches building interiors via diff --git a/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs b/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs index 966a4e8d..2f779822 100644 --- a/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs @@ -77,9 +77,16 @@ public class Issue130DoorwayStripTests Assert.True(exitIdx >= 0, "0x0170 has no exit portal polygon"); var localPoly = root.PortalPolygons[exitIdx]; + // DRAWN space: the shell that rasterizes the aperture (and the seal fan) + // draws +ShellDrawLiftZ above the physics transform — the gate must be + // compared against the drawn hole, not the physics polygon (#130: the + // unlifted gate left a 2 cm background strip under the drawn lintel). var worldPoly = new Vector3[localPoly.Length]; for (int i = 0; i < localPoly.Length; i++) + { worldPoly[i] = Vector3.Transform(localPoly[i], root.WorldTransform); + worldPoly[i].Z += PortalVisibilityBuilder.ShellDrawLiftZ; + } Vector3 centroid = Vector3.Zero; foreach (var w in worldPoly) centroid += w; @@ -137,7 +144,8 @@ public class Issue130DoorwayStripTests for (int i = 0; i < clip.Length; i++) aperture[i] = new Vector2(clip[i].X / clip[i].W, clip[i].Y / clip[i].W); - var pv = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj); + var pv = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj, + buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); if (asm.OutsideViewSlices.Length == 0) { @@ -194,6 +202,103 @@ public class Issue130DoorwayStripTests $"plane gate under-covers the aperture top edge by {worstPlaneGapPx:F2}px @ {worstDesc}")); } + /// Sensitivity proof + regression documentation: a gate built in + /// PHYSICS space (drawLiftZ 0) against the DRAWN (lifted) aperture shows a + /// multi-pixel strip at a close doorway — the user-visible #130 strip + /// (f35cb8b split the lift out of the visibility transform; the OutsideView + /// kept gating drawn color in unlifted space). If this stops failing-by-gap, + /// the lift is gone and the production drawLiftZ plumbing can go too. + [Fact] + public void UnliftedGate_LeavesTheStripAtTheDrawnTopEdge() + { + var datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cells = CornerFloodReplayTests.LoadBuilding(dats); + var root = cells[ExitCellId]; + LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null; + + int exitIdx = -1; + for (int i = 0; i < root.Portals.Count; i++) + { + if (root.Portals[i].OtherCellId == 0xFFFF && i < root.PortalPolygons.Count + && root.PortalPolygons[i].Length >= 3) + { exitIdx = i; break; } + } + Assert.True(exitIdx >= 0); + + var localPoly = root.PortalPolygons[exitIdx]; + var worldPoly = new Vector3[localPoly.Length]; + Vector3 centroid = Vector3.Zero; + for (int i = 0; i < localPoly.Length; i++) + { + worldPoly[i] = Vector3.Transform(localPoly[i], root.WorldTransform); + worldPoly[i].Z += PortalVisibilityBuilder.ShellDrawLiftZ; // drawn space + centroid += worldPoly[i]; + } + centroid /= worldPoly.Length; + + var plane = root.ClipPlanes[exitIdx]; + var worldNormal = Vector3.TransformNormal(plane.Normal, root.WorldTransform); + var cellCenterWorld = Vector3.Transform( + (root.LocalBoundsMin + root.LocalBoundsMax) * 0.5f, root.WorldTransform); + if (Vector3.Dot(worldNormal, cellCenterWorld - centroid) < 0) + worldNormal = -worldNormal; + worldNormal = Vector3.Normalize(worldNormal); + + // d=2.4 m, eye low (0.9 m above the opening's base), gaze at the + // centroid — the main sweep's clean case, where the aperture top edge + // projects ON SCREEN (y≈0.79; a closer/higher eye pushes the lintel + // past the screen top and the seam becomes unmeasurable). + var eye = centroid + worldNormal * 2.4f; + eye.Z = centroid.Z - 1.0f + 0.9f; + var viewProj = ViewProjFor(eye, centroid); + + var clip = new Vector4[worldPoly.Length]; + for (int i = 0; i < worldPoly.Length; i++) + clip[i] = Vector4.Transform(new Vector4(worldPoly[i], 1f), viewProj); + var aperture = new Vector2[clip.Length]; + for (int i = 0; i < clip.Length; i++) + aperture[i] = new Vector2(clip[i].X / clip[i].W, clip[i].Y / clip[i].W); + + var pvUnlifted = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj); // drawLiftZ 0 + var asmUnlifted = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pvUnlifted); + Assert.True(asmUnlifted.OutsideViewSlices.Length > 0); + (float unliftedGapPx, _, _) = MeasureTopEdgeGap(aperture, asmUnlifted.OutsideViewSlices, 1920, 1080); + + var pvLifted = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj, + buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); + var asmLifted = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pvLifted); + Assert.True(asmLifted.OutsideViewSlices.Length > 0); + (float liftedGapPx, _, _) = MeasureTopEdgeGap(aperture, asmLifted.OutsideViewSlices, 1920, 1080); + + _out.WriteLine(FormattableString.Invariant( + $"top-edge gap vs the DRAWN aperture at d=2.4 m: unliftedGate={unliftedGapPx:F2}px liftedGate={liftedGapPx:F2}px")); + var dbg = new System.Text.StringBuilder(" aperture(LIFTED):"); + foreach (var v in aperture) dbg.Append(FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})")); + _out.WriteLine(dbg.ToString()); + foreach (var poly in pvUnlifted.OutsideView.Polygons) + { + var sb = new System.Text.StringBuilder(" unliftedGatePoly:"); + foreach (var v in poly.Vertices) sb.Append(FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})")); + _out.WriteLine(sb.ToString()); + } + foreach (var poly in pvLifted.OutsideView.Polygons) + { + var sb = new System.Text.StringBuilder(" liftedGatePoly:"); + foreach (var v in poly.Vertices) sb.Append(FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})")); + _out.WriteLine(sb.ToString()); + } + + // The strip the user saw: physics-space gate vs drawn hole, several px. + Assert.True(unliftedGapPx > 2.0f, FormattableString.Invariant( + $"expected the unlifted gate to show the strip (>2px), got {unliftedGapPx:F2}px")); + // The fix: a gate in drawn space covers the drawn hole. + Assert.True(liftedGapPx <= 1.2f, FormattableString.Invariant( + $"lifted gate still under-covers by {liftedGapPx:F2}px")); + } + private static string DescribePolys(CellView view) { var parts = new List(); From 77cef4cd8692f4c4d4561d62cfbcd08c7c7c7a98 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 15:59:29 +0200 Subject: [PATCH 05/65] fix #124: interior-root building look-ins as a landscape-stage sub-pass From inside a building, looking out at ANOTHER building with an opening showed its back walls missing (see-through to the world): per-building look-in floods only ran for outdoor roots; under an interior root the far building's interior never flooded. Decomp anchor (named-retail, this session's read): retail runs the look-in INSIDE the landscape stage for ANY root - LScape::draw is the FIRST call of PView::DrawCells' outside-view branch (pc:432719), strictly BEFORE the depth clear (pc:432732) and the exit-portal seals (pc:432785). ConstructView(CBldPortal) (0x005a59a0) clips each aperture via GetClip against the INSTALLED view - the accumulated doorway region when looked into from inside - and build_draw_portals_only pass 1 far-Z punches ALL apertures before pass 2 floods + draws any interior cell. The nested DrawCells has an empty outside view (PView ctor draw_landscape=0): no recursive landscape/clear/seal. Port: - GameWindow's per-building gather (frustum pre-gate on Building.PortalBounds) now runs for interior roots too; the root's own doorway self-excludes via the seed eye-side test (the eye is on its interior side). - PortalVisibilityBuilder.BuildFromExterior/ConstructViewBuilding gain seedRegion - the installed-view clip: interior-root look-ins seed against the OutsideView polygons (a building not visible through the doorway never floods); null = full screen (outdoor roots unchanged). - RetailPViewRenderer.DrawBuildingLookIns: a landscape-stage sub-pass (before ClearDepthForInterior + seals) - per building, punch ALL apertures (new DrawLookInPortalPunch callback, always forceFarZ=true, closing the ISSUES "forceFarZ keys on root kind, under-punches" gap), then draw the flooded cells' shells + statics far->near. Look-in frames are NEVER merged into the main frame: a merged cell would draw post-clear and z-fail against the root's seal (the old ledger portShape sketch was wrong on this point). - Look-in cells join the Prepare + partition set so shells have batches and statics route to ByCell (consumed only by the sub-pass; the main cell-object pass iterates the main flood's cells). Register: AP-33 added in the same commit - look-in statics draw WHOLE (no per-part viewcone; over-include is the safe direction) and look-in DYNAMICS are deferred (an NPC inside a far building stays invisible - retail draws objects per overlapped cell in the landscape stage). Pins: Issue124LookInSeedRegionTests on the real corner-building door - a seed region containing the aperture floods (and never more than the full-screen seed), a disjoint region floods NOTHING, and an interior-side eye never seeds its own exit portal. Suites: App 259+1skip / Core 1439+2skip / UI 420 / Net 294 green. Awaiting the user gate: far-building interiors visible through their apertures from inside; #130 re-gate (top-edge strip) rides the same launch. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 54 ++++-- .../retail-divergence-register.md | 3 +- src/AcDream.App/Rendering/GameWindow.cs | 28 +++- .../Rendering/PortalVisibilityBuilder.cs | 15 +- .../Rendering/RetailPViewRenderer.cs | 158 +++++++++++++++++- .../Issue124LookInSeedRegionTests.cs | 153 +++++++++++++++++ 6 files changed, 379 insertions(+), 32 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/Issue124LookInSeedRegionTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 4868d529..1a25c953 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -4304,28 +4304,48 @@ of which draw list the building's shell left. ## #124 — Looking out through an opening: far buildings with openings show missing/transparent back walls -**Status:** OPEN +**Status:** FIX SHIPPED — awaiting user visual gate **Severity:** MEDIUM -**Filed:** 2026-06-11 (re-gate; pre-existing — "still have that issue") +**Filed:** 2026-06-11 (re-gate; pre-existing — "still have that issue"; +user 2026-06-12: "especially visible when I look out through a door +opening when inside a building") **Component:** render — per-building look-in floods under INTERIOR roots From inside a building, looking out through a door/window at ANOTHER building that has an opening: the far building's back walls are -missing/transparent (see the world through it). **Lead (by read):** the -per-building look-in floods (`MergeNearbyBuildingFloods`) run ONLY for -outdoor roots — `RetailPViewDrawContext.NearbyBuildingCells` is -documented "Null for interior roots." So under an interior root the far -building's INTERIOR never floods: through its window you see the shell -only, and a shell has no interior back-wall faces → transparent. -Retail runs the building look-in inside `LScape::draw` (DrawBlock → -DrawPortal → ConstructView(CBldPortal)), which executes for ANY root -whose outside view is non-empty — including interior roots looking out -a doorway. Fix shape: provide the nearby-building gather + per-building -floods for interior roots too, with look-in apertures getting PUNCH -semantics (the `forceFarZ` selector currently keys on -`clipRoot.IsOutdoorNode`, which under-punches this case). Needs its own -focused pass — touches the gather, the merge, and the depth-mask -selector. +missing/transparent. The lead confirmed by decomp: retail runs the +look-in INSIDE the landscape stage for ANY root — `LScape::draw` is the +FIRST call of `PView::DrawCells`' outside-view branch (pc:432719), +strictly BEFORE the depth clear (pc:432732) and the seals (pc:432785); +`ConstructView(CBldPortal)`'s GetClip runs under the INSTALLED view +(the doorway region), and all apertures far-Z punch (pass 1) before any +interior cell draws (pass 2). + +**Fix (2026-06-12):** +- The per-building gather (frustum pre-gate on `Building.PortalBounds`) + now runs for interior roots too; the root's own doorway self-excludes + via the seed eye-side test. +- `BuildFromExterior` gained `seedRegion` — the port of retail's + installed-view clip: interior-root look-ins seed clipped against the + OutsideView (doorway) polygons, so a building not visible through the + doorway never floods. Outdoor roots keep the full-screen default. +- NEW `DrawBuildingLookIns` sub-pass inside the LANDSCAPE stage (before + the depth clear + seals): per building, punch ALL apertures + (`DrawLookInPortalPunch`, always far-Z), then draw the flooded cells' + shells + statics far→near. NOT merged into the main frame — a merged + cell would draw post-clear and z-fail against the root's seal. +- Look-in cells join the Prepare/partition set (shells get batches, + statics route to ByCell, consumed only by the sub-pass). + +Pins: `Issue124LookInSeedRegionTests` (containing region floods ⊆ +full-screen flood; disjoint region floods nothing; interior-side eye +never seeds its own exit door). Register: AP-33 (look-in statics drawn +whole — no per-part viewcone; look-in DYNAMICS deferred — an NPC inside +a far building stays invisible; both documented). + +**Gate:** from inside a building, look out the door at another building +with an open door/window — its interior/back walls render through its +aperture instead of see-through to the world behind. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 631ccc7e..00c33ca3 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -92,7 +92,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 32 rows +## 3. Documented approximation (AP) — 33 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -128,6 +128,7 @@ accepted-divergence entries (#96, #49, #50). | AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 | | AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter | | AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | +| AP-33 | Interior-root look-in statics (**#124** sub-pass) draw WHOLE — no per-part viewcone check; retail viewconeCheck's each part vs the installed view. Look-in DYNAMICS are not drawn at all (deferred; retail draws objects per overlapped cell in the landscape stage) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | Statics: a few wasted draws only. Dynamics: an NPC inside a far building seen through two openings is invisible where retail shows it | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index f352c009..0202ec5b 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7628,9 +7628,9 @@ public sealed class GameWindow : IDisposable // OutdoorCellNode.Build filters to exit portals internally. The clipRoot flip + // OutsideView terrain integration that consumes this is the next (cutover) step. _outdoorNode = null; - if (viewerRoot is null && viewerCellId != 0u) + _outdoorNodeBuildingCells.Clear(); + if (viewerRoot is not null || viewerCellId != 0u) { - _outdoorNodeBuildingCells.Clear(); // T2 (BR-4): draw-driven flood gating. Retail floods a building's // interior exactly when its shell DRAWS and an aperture survives // the view (DrawBuilding Ghidra 0x0059f2a0: per-view viewconeCheck @@ -7645,6 +7645,12 @@ public sealed class GameWindow : IDisposable // Per-building iteration is also the FPS fix the 2026-06-07 // Chebyshev hack approximated: dozens of AABB tests instead of an // O(all loaded cells) portal sweep. + // #124: the gather now runs for INTERIOR roots too — retail's + // look-in executes inside LScape::draw for ANY root with a + // non-empty outside view (DrawCells pc:432719). The renderer + // routes interior-root look-ins to its landscape-stage sub-pass + // (DrawBuildingLookIns); the root's own building self-excludes + // via the seed eye-side test. foreach (var registry in _buildingRegistries.Values) { foreach (var b in registry.All()) @@ -7659,10 +7665,11 @@ public sealed class GameWindow : IDisposable _outdoorNodeBuildingCells.Add(bc); } } - _outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId); + if (viewerRoot is null) + _outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId); if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) Console.WriteLine(System.FormattableString.Invariant( - $"[outdoor-node] cell=0x{viewerCellId:X8} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)")); + $"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)")); } uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u; @@ -7788,10 +7795,10 @@ public sealed class GameWindow : IDisposable var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewDrawContext { RootCell = clipRoot, - // R-A2: outdoor root floods each nearby building per-building (not via the root). The - // gather above populates _outdoorNodeBuildingCells only on outdoor-node frames, so it - // is fresh here exactly when clipRoot.IsOutdoorNode; null for interior roots. - NearbyBuildingCells = clipRoot.IsOutdoorNode ? _outdoorNodeBuildingCells : null, + // R-A2: outdoor root floods each nearby building per-building (not via the root). + // #124: interior roots get the gather too — the renderer routes them to the + // landscape-stage look-in sub-pass instead of the merge. + NearbyBuildingCells = _outdoorNodeBuildingCells, ViewerEyePos = viewerEyePos, ViewProjection = envCellViewProj, CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, @@ -7837,6 +7844,11 @@ public sealed class GameWindow : IDisposable DrawExitPortalMasks = sliceCtx => DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj, forceFarZ: clipRoot.IsOutdoorNode), + // #124: look-in apertures are ALWAYS the punch (retail + // maxZ1), independent of the root-keyed selector above. + DrawLookInPortalPunch = sliceCtx => + DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj, + forceFarZ: true), DrawCellParticles = sliceCtx => DrawRetailPViewCellParticles(sliceCtx, camera, camPos), DrawDynamicsParticles = survivors => diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index 6f26d7d5..38f263b8 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -480,12 +480,18 @@ public static class PortalVisibilityBuilder /// camera cell. It keeps the same retail distance-priority traversal and /// neighbour reciprocal clipping once inside the building. /// + /// Optional NDC region the seed apertures clip against — + /// retail's GetClip runs under the CURRENTLY INSTALLED view (PView::GetClip + /// 0x005a4320): full screen when the viewer is outdoors, the accumulated + /// outside (doorway) view when a building is looked into from an interior + /// root (#124). Null = full screen (the outdoor-root behavior). public static PortalVisibilityFrame BuildFromExterior( IEnumerable candidateCells, Vector3 cameraPos, Func lookup, Matrix4x4 viewProj, - float maxSeedDistance = float.PositiveInfinity) + float maxSeedDistance = float.PositiveInfinity, + IReadOnlyList? seedRegion = null) { var frame = new PortalVisibilityFrame(); var todo = new CellTodoList(); @@ -532,7 +538,7 @@ public static class PortalVisibilityBuilder poly, cell.WorldTransform, viewProj, - FullScreenRegion, + seedRegion ?? FullScreenRegion, out _); // T2 (BR-4): empty clip = no seed, no exceptions (retail's @@ -662,8 +668,9 @@ public static class PortalVisibilityBuilder Vector3 cameraPos, Func lookup, Matrix4x4 viewProj, - float maxSeedDistance = float.PositiveInfinity) - => BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance); + float maxSeedDistance = float.PositiveInfinity, + IReadOnlyList? seedRegion = null) + => BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance, seedRegion); // The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute. private static readonly Vector2[] FullScreenQuad = diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 29996bf1..772e77f4 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -27,6 +27,12 @@ public sealed class RetailPViewRenderer // R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame). private readonly Dictionary> _buildingGroups = new(); + // #124: per-building look-in frames under an INTERIOR root, drawn as a + // landscape-stage sub-pass (DrawBuildingLookIns) — never merged into the + // main frame (see DrawInside). Rebuilt each interior-root frame. + private readonly List _lookInFrames = new(); + private readonly HashSet _lookInPrepareScratch = new(); + // T2 (BR-4): retail has NO distance constant on the flood-admission chain // (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test + // GetClip + GetVisible only). The old 48 m seed cap is replaced by the @@ -67,6 +73,26 @@ public sealed class RetailPViewRenderer if (ctx.RootCell.IsOutdoorNode && ctx.NearbyBuildingCells is not null) MergeNearbyBuildingFloods(ctx, pvFrame); + // #124: interior-root building look-ins. Retail runs the look-in INSIDE + // the landscape stage for ANY root — LScape::draw is the FIRST call of + // DrawCells' outside-view branch (pc:432719), strictly BEFORE the depth + // clear (pc:432732) and the exit-portal seals (pc:432785); a far + // building seen through our doorway floods clipped to the INSTALLED + // outside view (GetClip vs current view, ConstructView(CBldPortal) + // 0x005a59a0). These frames therefore draw in DrawBuildingLookIns + // (inside the landscape stage), NEVER merged into the main frame — a + // merged cell would draw post-clear and z-fail against the root's seal + // (its geometry is beyond the door plane). The eye-side seed test + // self-excludes the root's own building (the eye is on its interior + // side). Outdoor roots keep the MergeNearbyBuildingFloods path above + // (no depth clear under outdoor roots — the merged form is equivalent + // there). + _lookInFrames.Clear(); + if (!ctx.RootCell.IsOutdoorNode + && ctx.NearbyBuildingCells is not null + && pvFrame.OutsideView.Polygons.Count > 0) + BuildInteriorRootLookIns(ctx, pvFrame); + var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame); UploadClipFrame(ctx.SetTerrainClipUbo); @@ -78,15 +104,31 @@ public sealed class RetailPViewRenderer var drawableCells = new HashSet(pvFrame.OrderedVisibleCells); UseIndoorMembershipOnlyRouting(); + // #124: look-in cells need prepared shell batches + their statics routed + // into partition.ByCell (consumed ONLY by DrawBuildingLookIns — the main + // cell-object pass iterates pvFrame.OrderedVisibleCells, which never + // contains them). drawableCells itself stays the MAIN flood: it feeds the + // seals, the outside-stage predicate, and the frame result. + var prepareCells = drawableCells; + if (_lookInFrames.Count > 0) + { + _lookInPrepareScratch.Clear(); + _lookInPrepareScratch.UnionWith(drawableCells); + foreach (var f in _lookInFrames) + foreach (uint c in f.OrderedVisibleCells) + _lookInPrepareScratch.Add(c); + prepareCells = _lookInPrepareScratch; + } + _envCells.PrepareRenderBatches( ctx.ViewProjection, ctx.CameraWorldPosition, - filter: drawableCells, + filter: prepareCells, centerLbX: ctx.RenderCenterLbX, centerLbY: ctx.RenderCenterLbY, renderRadius: ctx.RenderRadius); - var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries); + var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries); var result = new RetailPViewFrameResult { PortalFrame = pvFrame, @@ -215,6 +257,105 @@ public sealed class RetailPViewRenderer } } + // #124: per-building look-in floods for an INTERIOR root, seeded clipped + // against the OutsideView (retail: GetClip runs under the INSTALLED view — + // the accumulated doorway region — so a far building floods only within the + // doorway, ConstructView(CBldPortal) 0x005a59a0 via PView::GetClip + // 0x005a4320). Same grouping as MergeNearbyBuildingFloods; the root's own + // building self-excludes via the seed eye-side test. + private void BuildInteriorRootLookIns(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame) + { + foreach (var group in _buildingGroups.Values) + group.Clear(); + + foreach (var cell in ctx.NearbyBuildingCells!) + { + uint groupKey = cell.BuildingId ?? cell.CellId; + if (!_buildingGroups.TryGetValue(groupKey, out var group)) + { + group = new List(); + _buildingGroups[groupKey] = group; + } + group.Add(cell); + } + + foreach (var group in _buildingGroups.Values) + { + if (group.Count == 0) + continue; + var frame = PortalVisibilityBuilder.ConstructViewBuilding( + group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection, + OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons); + if (frame.OrderedVisibleCells.Count > 0) + _lookInFrames.Add(frame); + } + } + + // #124: draw the interior-root look-ins INSIDE the landscape stage — + // retail's placement (LScape::draw → DrawBlock → DrawSortCell → + // DrawBuilding runs as the FIRST call of DrawCells' outside-view branch, + // pc:432719, before the depth clear + seals). Per building: punch ALL + // apertures first (retail finishes build_draw_portals_only pass 1 — the + // far-Z maxZ1 punch — across the whole building BSP before pass 2 floods), + // then draw the flooded cells' shells + statics far→near (the nested + // DrawCells' DrawEnvCell + DrawObjCellForDummies; its outside_view is + // empty by construction — PView ctor draw_landscape=0 — so no recursive + // landscape/clear/seal). Anything rasterized outside an aperture is + // repainted by the root's own shells after the depth clear, so over-draw + // here is color-safe; statics draw whole (the main viewcone has no entry + // for look-in cells; over-include is the safe direction). + private void DrawBuildingLookIns(RetailPViewDrawContext ctx, InteriorEntityPartition.Result partition) + { + if (_lookInFrames.Count == 0) + return; + + foreach (var frame in _lookInFrames) + { + // Pass 1: far-Z punch every aperture of this building. + if (ctx.DrawLookInPortalPunch is not null) + { + foreach (uint cellId in frame.OrderedVisibleCells) + { + if (!frame.CellViews.TryGetValue(cellId, out var view)) + continue; + foreach (var poly in view.Polygons) + { + var single = new CellView(); + single.Add(poly); + var cps = ClipPlaneSet.From(single); + if (cps.IsNothingVisible) + continue; + var planes = new Vector4[cps.Count]; + for (int p = 0; p < cps.Count; p++) + planes[p] = cps.Planes[p]; + ctx.DrawLookInPortalPunch(new RetailPViewCellSliceContext( + cellId, + new ClipViewSlice(0, new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), planes), + Array.Empty())); + } + } + } + + // Pass 2: shells + statics, far→near. + UseIndoorMembershipOnlyRouting(); + for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) + { + uint cellId = frame.OrderedVisibleCells[i]; + _oneCell.Clear(); + _oneCell.Add(cellId); + _envCells.Render(WbRenderPass.Opaque, _oneCell); + _envCells.Render(WbRenderPass.Transparent, _oneCell); + + if (partition.ByCell.TryGetValue(cellId, out var bucket) && bucket.Count > 0) + { + _cellStaticScratch.Clear(); + _cellStaticScratch.AddRange(bucket); + DrawEntityBucket(ctx, _cellStaticScratch, _oneCell); + } + } + } + } + private void DrawLandscapeThroughOutsideView( RetailPViewDrawContext ctx, ClipFrameAssembly clipAssembly, @@ -260,6 +401,12 @@ public sealed class RetailPViewRenderer ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch)); } + // #124: far-building look-ins draw HERE — still inside the landscape + // stage (their punches mark against the terrain/exterior depth just + // drawn), strictly BEFORE the depth clear + seals below, matching + // retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785). + DrawBuildingLookIns(ctx, partition); + // T1: retail clears the FULL depth buffer ONCE between the outside // stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 — // Clear gated on portalsDrawnCount; exact gate semantics is a plan @@ -667,6 +814,12 @@ public interface IRetailPViewCellDrawCallbacks { public Action? DrawExitPortalMasks { get; } public Action? DrawCellParticles { get; } + + /// #124: far-Z punch one look-in aperture (a clipped view polygon + /// of a looked-into building cell) — always the PUNCH variant regardless + /// of root kind (retail maxZ1; the root-keyed forceFarZ selector only + /// governs the MAIN frame's exit-portal masks). + public Action? DrawLookInPortalPunch { get; } } public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks @@ -713,6 +866,7 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext public Action? ClearDepthForInterior { get; init; } public Action? DrawExitPortalMasks { get; init; } public Action? DrawCellParticles { get; init; } + public Action? DrawLookInPortalPunch { get; init; } public Action>? DrawDynamicsParticles { get; init; } public Action? EmitDiagnostics { get; init; } } diff --git a/tests/AcDream.App.Tests/Rendering/Issue124LookInSeedRegionTests.cs b/tests/AcDream.App.Tests/Rendering/Issue124LookInSeedRegionTests.cs new file mode 100644 index 00000000..75a964b1 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Issue124LookInSeedRegionTests.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.App.Tests.Rendering; + +/// +/// #124 — far-building interiors under an INTERIOR root. Retail seeds the +/// look-in flood by clipping a building's aperture against the CURRENTLY +/// INSTALLED view (PView::GetClip 0x005a4320 inside ConstructView(CBldPortal) +/// 0x005a59a0): full screen outdoors, the accumulated doorway (outside) view +/// when looked into from inside. These tests pin BuildFromExterior's +/// seedRegion parameter — the port of that installed-view clip — against the +/// real Holtburg corner-building door. +/// +public class Issue124LookInSeedRegionTests +{ + private readonly ITestOutputHelper _out; + public Issue124LookInSeedRegionTests(ITestOutputHelper output) => _out = output; + + private const uint ExitCellId = CornerFloodReplayTests.Landblock | 0x0170u; + + private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt) + { + var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 1f, 5000f); + return view * proj; + } + + private static (Dictionary cells, LoadedCell exitCell, int exitIdx, + Vector3 centroid, Vector3 outward) LoadFixture(DatCollection dats) + { + var cells = CornerFloodReplayTests.LoadBuilding(dats); + var exitCell = cells[ExitCellId]; + + int exitIdx = -1; + for (int i = 0; i < exitCell.Portals.Count; i++) + { + if (exitCell.Portals[i].OtherCellId == 0xFFFF && i < exitCell.PortalPolygons.Count + && exitCell.PortalPolygons[i].Length >= 3) + { exitIdx = i; break; } + } + Assert.True(exitIdx >= 0); + + var localPoly = exitCell.PortalPolygons[exitIdx]; + Vector3 centroid = Vector3.Zero; + foreach (var lp in localPoly) + centroid += Vector3.Transform(lp, exitCell.WorldTransform); + centroid /= localPoly.Length; + + var plane = exitCell.ClipPlanes[exitIdx]; + var normal = Vector3.TransformNormal(plane.Normal, exitCell.WorldTransform); + var cellCenter = Vector3.Transform( + (exitCell.LocalBoundsMin + exitCell.LocalBoundsMax) * 0.5f, exitCell.WorldTransform); + // outward = away from the cell interior. + if (Vector3.Dot(normal, cellCenter - centroid) > 0) + normal = -normal; + return (cells, exitCell, exitIdx, centroid, Vector3.Normalize(normal)); + } + + private static Vector4 ApertureNdcAabb(LoadedCell cell, int idx, Matrix4x4 viewProj) + { + float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue; + foreach (var lp in cell.PortalPolygons[idx]) + { + var w = Vector3.Transform(lp, cell.WorldTransform); + var c = Vector4.Transform(new Vector4(w, 1f), viewProj); + Assert.True(c.W > 0.05f, "fixture eye must keep the aperture fully in front"); + minX = MathF.Min(minX, c.X / c.W); maxX = MathF.Max(maxX, c.X / c.W); + minY = MathF.Min(minY, c.Y / c.W); maxY = MathF.Max(maxY, c.Y / c.W); + } + return new Vector4(minX, minY, maxX, maxY); + } + + private static ViewPolygon Quad(float minX, float minY, float maxX, float maxY) => + new(new[] + { + new Vector2(minX, minY), new Vector2(maxX, minY), + new Vector2(maxX, maxY), new Vector2(minX, maxY), + }); + + [Fact] + public void SeedRegion_ContainingAperture_Floods_DisjointRegion_DoesNot() + { + var datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + var (cells, exitCell, exitIdx, centroid, outward) = LoadFixture(dats); + LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null; + + // Eye OUTSIDE the building, 3 m in front of the exit door, gaze at it + // — the look-in geometry of a viewer peering at this building through + // some other opening. + var eye = centroid + outward * 3f; + var viewProj = ViewProjFor(eye, centroid); + var ap = ApertureNdcAabb(exitCell, exitIdx, viewProj); + _out.WriteLine(FormattableString.Invariant( + $"aperture ndc=({ap.X:F3},{ap.Y:F3},{ap.Z:F3},{ap.W:F3})")); + + // Sanity: the full-screen (outdoor-root) seed floods. + var full = PortalVisibilityBuilder.BuildFromExterior( + cells.Values, eye, Lookup, viewProj); + Assert.True(full.OrderedVisibleCells.Count > 0, "full-screen seed must flood"); + + // A region containing the aperture floods — and never MORE than the + // full-screen seed (region-restricting can only shrink the flood). + var containing = new[] { Quad(ap.X - 0.05f, ap.Y - 0.05f, ap.Z + 0.05f, ap.W + 0.05f) }; + var seeded = PortalVisibilityBuilder.BuildFromExterior( + cells.Values, eye, Lookup, viewProj, float.PositiveInfinity, containing); + Assert.True(seeded.OrderedVisibleCells.Count > 0, "containing region must flood"); + Assert.True(seeded.OrderedVisibleCells.Count <= full.OrderedVisibleCells.Count); + + // A region strictly disjoint from the aperture must not flood — the + // doorway doesn't show this building, so its interior never builds + // (retail: GetClip vs the installed view returns empty → no look-in). + Assert.True(ap.Z < 0.70f || ap.X > -0.70f, "fixture aperture unexpectedly fills the screen"); + var disjoint = ap.Z < 0.70f + ? new[] { Quad(0.75f, 0.75f, 0.99f, 0.99f) } + : new[] { Quad(-0.99f, -0.99f, -0.75f, -0.75f) }; + var none = PortalVisibilityBuilder.BuildFromExterior( + cells.Values, eye, Lookup, viewProj, float.PositiveInfinity, disjoint); + Assert.True(none.OrderedVisibleCells.Count == 0, + FormattableString.Invariant($"disjoint region flooded {none.OrderedVisibleCells.Count} cells")); + } + + [Fact] + public void EyeOnInteriorSide_ExitDoorNeverSeeds() + { + // The root's own doorway must not look-in on itself: the seed eye-side + // test (retail ConstructView's sidedness vs portal_side) excludes any + // aperture the eye is on the interior side of — this is what lets the + // interior-root gather pass ALL nearby buildings including the + // viewer's own without special-casing. + var datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + var (cells, exitCell, _, centroid, outward) = LoadFixture(dats); + LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null; + + var eye = centroid - outward * 2f; // 2 m INSIDE the doorway + var viewProj = ViewProjFor(eye, centroid); + + var frame = PortalVisibilityBuilder.BuildFromExterior( + new[] { exitCell }, eye, Lookup, viewProj); + Assert.True(frame.OrderedVisibleCells.Count == 0, + "an interior-side eye must not seed its own cell's exit portal"); + } +} From eeb1c59dedadfeae6bd26184cf3c0733f4e09f86 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 18:54:56 +0200 Subject: [PATCH 06/65] file #131 (portal swirl gone through doorways) + #132 (candle flame vs aperture background) + the [outstage] capture probe Two user reports from the #124 gate session, both axioms: - #131: "the portal swirl is missing, when I look out from inside a house. Appears when I walk out again." Mechanism frame: under an interior root an outdoor dynamic's particles draw ONLY via the landscape slice's Scene pass (#118 outside-stage routing; #121 excludes them from the last-pass particle callback) - if any link fails, the swirl draws nowhere exactly when indoors. Desk-exonerated already: filter key conventions uniform, the routing predicate correct, sphere from vertex bounds. - #132: "I have a candle ... when a wall is behind it it shows, but if I turn a bit and the opening through a house is behind it candle light disappears." Background-dependent => per-pixel depth/blend at the aperture region, not owner culling. Possible overlap with the #124 look-in sub-pass (new pre-clear content in those pixels) - the pre-77cef4c check is in the issue. Apparatus (env-gated, zero cost off): ACDREAM_PROBE_OUTSTAGE=1 -> [outstage] per-slice outside-stage routing + cone verdict per dynamic (print-on-change, RetailPViewRenderer) + [outstage-pt] slice Scene-particle id set + live attached-emitter match count (GameWindow). One capture standing inside looking at the portal pins which link breaks. Suites: App 259+1skip / Core 1439+2skip / UI 420 / Net 294 green. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 61 +++++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 24 ++++++++ .../Rendering/RetailPViewRenderer.cs | 28 +++++++++ .../Rendering/RenderingDiagnostics.cs | 14 +++++ 4 files changed, 127 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 1a25c953..67b42e63 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -4581,6 +4581,67 @@ or distance. --- +## #131 — Portal swirl invisible when viewed from inside a building through the doorway + +**Status:** OPEN +**Severity:** MEDIUM (portals are landmark objects; the through-door view is common) +**Filed:** 2026-06-12 (user report, #124 gate session) +**Component:** render — outside-stage dynamics' particles under interior roots (#118/#121 family) + +**Symptom (user, axiom):** "the portal swirl is missing, when I look out +from inside a house. Appears when I walk out again." + +**Mechanism frame:** under an interior root an outdoor dynamic routes to +the OUTSIDE stage (`_outsideStageDynamics`, #118) and its particles' +ONLY path is the landscape slice's Scene pass +(`_outdoorSceneParticleEntityIds`); the last-pass particle callback +deliberately excludes outside-stage entities (#121: "already drew in +the slice"). If any link fails (slice cone verdict, the id set, emitter +matching, draw order vs the slice's blend state), the swirl draws +NOWHERE exactly when indoors — and reappears outdoors where +DrawDynamicsLast + DrawDynamicsParticles take over. Matches the report +exactly. + +**Desk-exonerated (2026-06-12):** key conventions are uniform +(`ParticleEntityKey` = ServerGuid-first at all three filter sites); +`DynamicDrawsInOutsideStage` routes outdoor dynamics correctly; +`EntitySphere` uses the vertex-derived bounds. + +**Apparatus (shipped, env-gated):** `ACDREAM_PROBE_OUTSTAGE=1` — +`[outstage]` (per-slice routing + cone verdict per outside-stage +dynamic, print-on-change) + `[outstage-pt]` (slice Scene-particle id +set + live attached-emitter matched count). Capture: stand inside, +look at the portal through the door. + +--- + +## #132 — Candle flame disappears when the through-opening background is behind it + +**Status:** OPEN +**Severity:** LOW-MEDIUM +**Filed:** 2026-06-12 (user report, #124 gate session) +**Component:** render — cell-particle compositing vs aperture pixels + +**Symptom (user, axiom):** "I have a candle, when I look at the candle +when a wall is behind it it shows, but if I turn a bit and the opening +through a house is behind it candle light disappears." + +**Reading:** BACKGROUND-dependent disappearance — the candle (and its +owner static) stays in view; only what is behind it changes. That rules +out viewcone/owner culling (which keys on the candle's own position) +and points at per-pixel state in the aperture region: depth left by the +punch/seal/look-in machinery at those pixels, draw order of the cell +particle pass vs the aperture passes, or blend state. Candidate overlap +with the #124 look-in sub-pass (new pre-clear content in exactly those +pixels) — check whether the symptom predates `77cef4c` by looking at a +candle in front of a doorway WITHOUT a through-house view. + +**Next:** repro at the spot + `ACDREAM_PROBE_OUTSTAGE` lines for the +same frame; then a depth-state walkthrough of the aperture pixels for +the cell-particle pass. + +--- + # Recently closed ## #113 — Phantom staircase: REOPENED 2026-06-11, folded into the HOLISTIC BUILDING-RENDER PORT diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 0202ec5b..7877c3e3 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5094,6 +5094,9 @@ public sealed class GameWindow : IDisposable private static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) => entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id; + // #131 [outstage-pt] probe state (throwaway — strip when #131 closes). + private string? _lastOutStagePtSig; + private static System.Numerics.Vector3 SkyPesAnchor( AcDream.Core.World.SkyObjectData obj, System.Numerics.Vector3 cameraWorldPos) @@ -9638,6 +9641,27 @@ public sealed class GameWindow : IDisposable foreach (var entity in sliceCtx.OutdoorEntities) _outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity)); + // #131 [outstage-pt] probe: the slice Scene-particle id set + how many + // live emitters the filter would actually match. Print-on-change. + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled + && _particleSystem is not null) + { + int matched = 0, attached = 0; + foreach (var (emitter, _) in _particleSystem.EnumerateLive()) + { + if (emitter.AttachedObjectId == 0) continue; + attached++; + if (_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)) matched++; + } + string ptSig = System.FormattableString.Invariant( + $"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched}"); + if (ptSig != _lastOutStagePtSig) + { + _lastOutStagePtSig = ptSig; + Console.WriteLine("[outstage-pt] " + ptSig); + } + } + DisableClipDistances(); if (_outdoorSceneParticleEntityIds.Count > 0 && _particleSystem is not null diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 772e77f4..a3b7fc7d 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -397,6 +397,8 @@ public sealed class RetailPViewRenderer if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) _outdoorStaticScratch.Add(e); } + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled) + EmitOutStageProbe(probeSliceIndex, viewcone); probeSliceIndex++; ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch)); } @@ -420,6 +422,32 @@ public sealed class RetailPViewRenderer UseIndoorMembershipOnlyRouting(); } + // #131 [outstage] probe state (2026-06-12, throwaway): print-on-change — + // which outdoor dynamics were routed to the outside stage and which + // survived the slice viewcone. Strip with the probe when #131 closes. + private string? _lastOutStageSig; + + private void EmitOutStageProbe(int sliceIndex, ViewconeCuller viewcone) + { + var sb = new System.Text.StringBuilder(192); + sb.Append("slice=").Append(sliceIndex) + .Append(" outStage=").Append(_outsideStageDynamics.Count).Append(" ["); + for (int i = 0; i < _outsideStageDynamics.Count; i++) + { + var e = _outsideStageDynamics[i]; + EntitySphere(e, out var c, out float r); + bool pass = viewcone.SphereVisibleInOutsideSlice(sliceIndex, c, r); + if (i > 0) sb.Append(' '); + sb.Append(System.FormattableString.Invariant( + $"0x{(e.ServerGuid != 0 ? e.ServerGuid : e.Id):X8}:{(pass ? "PASS" : "CULL")}:r={r:F1}")); + } + sb.Append(']'); + string sig = sb.ToString(); + if (sig == _lastOutStageSig) return; + _lastOutStageSig = sig; + Console.WriteLine("[outstage] " + sig); + } + // §4 flap [clip-route] probe state (2026-06-10, throwaway): print-on-change signature + // monotonic sequence so held-flap vs healthy frames diff cleanly in one capture. private string? _lastClipRouteSig; diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 9c02119b..ba081f71 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -109,6 +109,20 @@ public static class RenderingDiagnostics public static bool ProbeViewerEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_VIEWER") == "1"; + /// + /// #131 (2026-06-12) outside-stage dynamics probe. When true, the renderer + /// emits one [outstage] line per CHANGE of the outside-stage + /// routing + per-slice cone verdict set under an interior root (which + /// outdoor dynamics were routed to the landscape slice, which survived the + /// slice viewcone), and GameWindow emits one [outstage-pt] line per + /// change of the slice Scene-particle id set + matched-emitter count. + /// Built for the portal-swirl-missing-through-doorway capture. Light: + /// silent while the set is stable. Initial state from + /// ACDREAM_PROBE_OUTSTAGE=1. + /// + public static bool ProbeOutStageEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_OUTSTAGE") == "1"; + /// /// Phase U.4c (2026-05-31) flap-convergence probe. When true, the portal /// visibility pass emits, EVERY frame the camera root is an indoor cell, a From 1d3f9a8c97e0d1501f4190d297022102d0bd1d8e Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 19:04:12 +0200 Subject: [PATCH 07/65] fix #131: unattached emitters had NO particle pass under interior roots The user's capture run + a code read pinned it in one step: every particle pass under an interior root is id-filtered (the landscape slice's Scene pass, the per-cell pass, and the dynamics pass all require AttachedObjectId != 0 plus owner-set membership). An UNATTACHED emitter - AttachedObjectId == 0: portal swirls, campfires, ground effects anchored at a position - drew NOWHERE when the viewer root was interior. The outdoor root has the dedicated T3 pass for exactly this class (its own comment records that "unattached ones had NO pass on outdoor-node frames"); the identical hole on interior-root frames was never plugged. Walking out flips to the outdoor root and the T3 pass picks the swirl up - "appears when I walk out again", verbatim. The [outstage] capture corroborated the rest of the chain healthy under the interior root: outside-stage routing correct, cone PASS for the portal-family dynamics, 57 attached emitters matched and drawn through the doorway. Only the unattached class was orphaned. Fix: RetailPViewDrawContext.DrawUnattachedSceneParticles - invoked ONCE per interior-root frame at the END of the landscape stage: - pre-clear, because drawn after the depth clear + seals an outdoor emitter beyond the door plane z-fails against the seal's door-plane stamp; - after the #124 look-in sub-pass, so swirls blend over far-building interiors; - once per frame, not per slice - alpha particles must not double-draw (the #121 lesson); - mutually exclusive with the outdoor T3 pass by root kind (interior invokes this; outdoor keeps T3). Residual (documented in the issue): unattached INDOOR emitters now draw pre-clear and get overpainted by the room's shells - the same invisibility they had before this fix; the proper per-emitter cell classification is a future port. [outstage-pt] probe extended with the unattached emitter count (the probe's blind spot was exactly where the bug hid). Suites: App 259+1skip / Core 1439+2skip / UI 420 / Net 294 green. Awaiting the user gate: the swirl through the doorway. #132 (candle flame vs through-opening background) remains open - different mechanism, background-dependent. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 51 +++++++++++-------- src/AcDream.App/Rendering/GameWindow.cs | 21 ++++++-- .../Rendering/RetailPViewRenderer.cs | 22 ++++++++ 3 files changed, 70 insertions(+), 24 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 67b42e63..7e785dc3 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -4583,35 +4583,44 @@ or distance. ## #131 — Portal swirl invisible when viewed from inside a building through the doorway -**Status:** OPEN +**Status:** FIX SHIPPED — awaiting user visual gate **Severity:** MEDIUM (portals are landmark objects; the through-door view is common) **Filed:** 2026-06-12 (user report, #124 gate session) -**Component:** render — outside-stage dynamics' particles under interior roots (#118/#121 family) +**Component:** render — UNATTACHED emitters have no pass under interior roots **Symptom (user, axiom):** "the portal swirl is missing, when I look out from inside a house. Appears when I walk out again." -**Mechanism frame:** under an interior root an outdoor dynamic routes to -the OUTSIDE stage (`_outsideStageDynamics`, #118) and its particles' -ONLY path is the landscape slice's Scene pass -(`_outdoorSceneParticleEntityIds`); the last-pass particle callback -deliberately excludes outside-stage entities (#121: "already drew in -the slice"). If any link fails (slice cone verdict, the id set, emitter -matching, draw order vs the slice's blend state), the swirl draws -NOWHERE exactly when indoors — and reappears outdoors where -DrawDynamicsLast + DrawDynamicsParticles take over. Matches the report -exactly. +**Root cause (confirmed by read + the [outstage] capture):** every +particle pass under an interior root is id-FILTERED: the landscape +slice's Scene pass and the cell/dynamics passes all require +`emitter.AttachedObjectId != 0` and membership in an owner set. An +UNATTACHED emitter (`AttachedObjectId == 0` — portal swirls, campfires, +ground effects anchored at a position) therefore draws NOWHERE when the +root is interior. The outdoor root has the dedicated T3 pass for +exactly this class (its own comment: "unattached ones had NO pass on +outdoor-node frames") — the identical hole on interior-root frames was +never plugged. Walk out → the T3 pass picks the swirl up → "appears +when I walk out again". The capture corroborated the rest of the chain +healthy: outside-stage routing + cone PASS for the dynamics, 57 +attached emitters matched and drawn through the doorway. -**Desk-exonerated (2026-06-12):** key conventions are uniform -(`ParticleEntityKey` = ServerGuid-first at all three filter sites); -`DynamicDrawsInOutsideStage` routes outdoor dynamics correctly; -`EntitySphere` uses the vertex-derived bounds. +**Fix (2026-06-12):** `DrawUnattachedSceneParticles` — invoked ONCE per +interior-root frame at the end of the landscape stage (pre-clear; drawn +later they would z-fail against the doorway seal), after the #124 +look-ins so swirls blend over far interiors, NOT per slice (alpha +particles must not double-draw — the #121 lesson). Mutually exclusive +with the outdoor T3 pass by root kind. Residual (documented): unattached +INDOOR emitters now draw pre-clear and are overpainted by the room's +shells — same invisibility as before this fix; the proper per-emitter +cell classification is a future port. -**Apparatus (shipped, env-gated):** `ACDREAM_PROBE_OUTSTAGE=1` — -`[outstage]` (per-slice routing + cone verdict per outside-stage -dynamic, print-on-change) + `[outstage-pt]` (slice Scene-particle id -set + live attached-emitter matched count). Capture: stand inside, -look at the portal through the door. +**Apparatus (kept, env-gated):** `ACDREAM_PROBE_OUTSTAGE=1` — +`[outstage]` (per-slice routing + cone verdicts) + `[outstage-pt]` +(slice id set, attached matched count, unattached count). + +**Gate:** stand inside, look out the doorway at the town portal — the +swirl renders through the door. --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7877c3e3..6ec354f8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7852,6 +7852,21 @@ public sealed class GameWindow : IDisposable DrawLookInPortalPunch = sliceCtx => DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj, forceFarZ: true), + // #131: unattached emitters under an interior root — the + // landscape-stage pass (the outdoor T3 pass below is gated + // IsOutdoorNode, so the two never both run). + DrawUnattachedSceneParticles = () => + { + if (_particleSystem is null || _particleRenderer is null) + return; + DisableClipDistances(); + _particleRenderer.Draw( + _particleSystem, + camera, + camPos, + AcDream.Core.Vfx.ParticleRenderPass.Scene, + emitter => emitter.AttachedObjectId == 0); + }, DrawCellParticles = sliceCtx => DrawRetailPViewCellParticles(sliceCtx, camera, camPos), DrawDynamicsParticles = survivors => @@ -9646,15 +9661,15 @@ public sealed class GameWindow : IDisposable if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled && _particleSystem is not null) { - int matched = 0, attached = 0; + int matched = 0, attached = 0, unattached = 0; foreach (var (emitter, _) in _particleSystem.EnumerateLive()) { - if (emitter.AttachedObjectId == 0) continue; + if (emitter.AttachedObjectId == 0) { unattached++; continue; } attached++; if (_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)) matched++; } string ptSig = System.FormattableString.Invariant( - $"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched}"); + $"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached}"); if (ptSig != _lastOutStagePtSig) { _lastOutStagePtSig = ptSig; diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index a3b7fc7d..e7ca5ef8 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -409,6 +409,22 @@ public sealed class RetailPViewRenderer // retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785). DrawBuildingLookIns(ctx, partition); + // #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls, + // campfires, ground effects anchored at a position) have no owner id + // to ride any of the id-filtered particle passes. The outdoor root + // has the dedicated T3 pass for them; an INTERIOR root had NO pass + // at all — the portal swirl vanished exactly when viewed through a + // doorway. Draw them ONCE per frame (not per slice — alpha particles + // must not double-draw, the #121 lesson), still inside the landscape + // stage: drawn after the clear they would z-fail against the doorway + // seal; here they composite against the slice depth, and anything on + // screen outside the apertures is overpainted by the root's shells + // after the clear. Mutually exclusive with the outdoor T3 pass + // (this method's caller is the interior path when slices exist; + // GameWindow gates the T3 pass on IsOutdoorNode). + if (!ctx.RootCell.IsOutdoorNode) + ctx.DrawUnattachedSceneParticles?.Invoke(); + // T1: retail clears the FULL depth buffer ONCE between the outside // stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 — // Clear gated on portalsDrawnCount; exact gate semantics is a plan @@ -895,6 +911,12 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext public Action? DrawExitPortalMasks { get; init; } public Action? DrawCellParticles { get; init; } public Action? DrawLookInPortalPunch { get; init; } + + /// #131: Scene-pass draw of UNATTACHED emitters + /// (AttachedObjectId == 0) for interior-root frames — invoked once at the + /// end of the landscape stage (pre-clear). Outdoor roots draw them via + /// GameWindow's dedicated post-frame pass instead. + public Action? DrawUnattachedSceneParticles { get; init; } public Action>? DrawDynamicsParticles { get; init; } public Action? EmitDiagnostics { get; init; } } From 20d17304d7b05aeb48605f2d17044617378af9d2 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 19:16:40 +0200 Subject: [PATCH 08/65] fix #131+#132: landscape translucents drawn AFTER the #124 look-ins (FlushAlphaList deferral) The user's screenshot pair re-attributed both reports to ONE mechanism - a compositing gap in the #124 look-in sub-pass: - #131: the portal swirl (a TRANSLUCENT MESH, not only particles) stood exactly in front of the hall's doorway. The slice drew it BEFORE the look-in sub-pass; translucents write no depth, so the hall's interior - drawn into its far-Z-punched aperture - overpainted the swirl. Outdoors the look-ins are the post-stage merge path, so the swirl survives ("stepping out it pops into existence"). - #132: the candle/lantern flame is an attached emitter in the slice's Scene-particle pass - same pre-look-in placement, same erasure whenever "the opening through a house" sat behind it; against a wall nothing overdraws it. Background-dependence explained exactly. Retail cannot exhibit this class: every alpha draw of the landscape stage is collected and flushed ONCE after LScape::draw (D3DPolyRender::FlushAlphaList, PView::DrawCells pc:432722) - i.e. after all building look-ins. Port (the two-phase split): DrawLandscapeThroughOutsideView now runs EARLY per slice (sky, terrain, outdoor STATIC meshes - the look-in punches need their depth to mark against, the #117 lesson), then the #124 look-ins, then LATE per slice (outside-stage dynamics' meshes + ALL attached scene particles + weather + SkyPostScene), then the #131 unattached pass. New RetailPViewLandscapeLateSliceContext carries the dynamics survivors + the particle-owner set (statics + dynamics cone survivors). GameWindow's slice handler split accordingly. Outdoor roots: no look-ins live in the stage, so the net order is unchanged (zero behavior change outdoors). Register: AP-34 added - the two-phase split vs retail's single deferred flush, with the residuals recorded (outdoor-root slice particles still draw before merged building interiors - the unreported outdoor sibling; building exteriors' own translucent batches draw early). The earlier #131 unattached-emitter pass (1d3f9a8) remains - it fixes an independent hole (that class had NO indoor pass at all) - and now runs at the end of the late phase. Suites: App 259+1skip / Core 1439+2skip / UI 420 / Net 294 green. Awaiting the user gate: swirl through the doorway, candle flame with the opening behind it, far-building interiors (#124). Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 54 +++++++---- .../retail-divergence-register.md | 3 +- src/AcDream.App/Rendering/GameWindow.cs | 70 ++++++++++++++- .../Rendering/RetailPViewRenderer.cs | 90 ++++++++++++++----- 4 files changed, 177 insertions(+), 40 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 7e785dc3..0894eb2c 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -4619,35 +4619,59 @@ cell classification is a future port. `[outstage]` (per-slice routing + cone verdicts) + `[outstage-pt]` (slice id set, attached matched count, unattached count). +**FIX 1 INSUFFICIENT (user screenshots, same evening):** the swirl is +the portal's TRANSLUCENT MESH, not (only) unattached particles. The +real mechanism — shared with #132 — is the #124 look-in ordering: the +slice drew the portal mesh (and all scene particles) BEFORE the look-in +sub-pass; translucents write no depth, so the far building's interior +(drawn into its far-Z-punched aperture) overpainted them wherever a +look-in opening sat behind them on screen. Both screenshots show the +swirl exactly in front of the hall's doorway. Retail cannot have this +bug: all landscape-stage alpha draws are deferred into ONE flush after +LScape::draw (`D3DPolyRender::FlushAlphaList`, DrawCells pc:432722). + +**FIX 2 (the FlushAlphaList deferral, same commit family as #124):** +the landscape stage is now TWO phases per frame — EARLY per slice: sky, +terrain, outdoor static meshes (the look-in punches need their depth, the +#117 lesson); then the #124 look-ins; then LATE per slice: outside-stage +dynamics' meshes + ALL attached scene particles + weather + the +unattached pass. Outdoor roots keep their existing order (no look-ins in +the stage; net order unchanged). Residual (documented, AP-34): under +OUTDOOR roots slice particles still draw before merged building +interiors (the outdoor sibling of this bug, unreported); building +exteriors' own translucent batches draw early. + **Gate:** stand inside, look out the doorway at the town portal — the -swirl renders through the door. +swirl renders through the door; the candle flame (#132) stays visible +with the through-opening behind it. --- ## #132 — Candle flame disappears when the through-opening background is behind it -**Status:** OPEN +**Status:** FIX SHIPPED (shared mechanism with #131 fix 2) — awaiting user visual gate **Severity:** LOW-MEDIUM **Filed:** 2026-06-12 (user report, #124 gate session) -**Component:** render — cell-particle compositing vs aperture pixels +**Component:** render — slice particles drawn before the #124 look-ins **Symptom (user, axiom):** "I have a candle, when I look at the candle when a wall is behind it it shows, but if I turn a bit and the opening through a house is behind it candle light disappears." -**Reading:** BACKGROUND-dependent disappearance — the candle (and its -owner static) stays in view; only what is behind it changes. That rules -out viewcone/owner culling (which keys on the candle's own position) -and points at per-pixel state in the aperture region: depth left by the -punch/seal/look-in machinery at those pixels, draw order of the cell -particle pass vs the aperture passes, or blend state. Candidate overlap -with the #124 look-in sub-pass (new pre-clear content in exactly those -pixels) — check whether the symptom predates `77cef4c` by looking at a -candle in front of a doorway WITHOUT a through-house view. +**Root cause (= #131's fix-2 mechanism):** the candle/lantern's flame +is an attached emitter drawn in the landscape slice's Scene-particle +pass, which ran BEFORE the #124 look-in sub-pass. Particles write no +depth; whenever a look-in opening ("the opening through a house") sat +behind the flame on screen, the far building's interior — drawn into +its far-Z-punched aperture — overpainted the flame. Against a plain +wall (no look-in aperture behind), nothing overdraws it → visible. +Background-dependence explained exactly. -**Next:** repro at the spot + `ACDREAM_PROBE_OUTSTAGE` lines for the -same frame; then a depth-state walkthrough of the aperture pixels for -the cell-particle pass. +**Fix:** the landscape stage's two-phase split (see #131 FIX 2): all +scene particles moved to the LATE phase, after the look-ins. + +**Gate:** the candle at the original spot — flame stays visible when +the through-opening is behind it. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 00c33ca3..0178bfd7 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -92,7 +92,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 33 rows +## 3. Documented approximation (AP) — 34 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -129,6 +129,7 @@ accepted-divergence entries (#96, #49, #50). | AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter | | AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | | AP-33 | Interior-root look-in statics (**#124** sub-pass) draw WHOLE — no per-part viewcone check; retail viewconeCheck's each part vs the installed view. Look-in DYNAMICS are not drawn at all (deferred; retail draws objects per overlapped cell in the landscape stage) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | Statics: a few wasted draws only. Dynamics: an NPC inside a far building seen through two openings is invisible where retail shows it | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | +| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins), not retail's single deferred alpha flush. Residual: under OUTDOOR roots slice particles still draw before merged building interiors (the unreported outdoor sibling of **#131**/**#132**); building exteriors' own translucent batches draw early | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the interior-root cases the user can see (#131 portal swirl, #132 candle flame) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6ec354f8..5a0f7c74 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7827,6 +7827,21 @@ public sealed class GameWindow : IDisposable renderWeather: playerSeenOutside, kf, environOverrideActive), + // #131/#132: the late phase — dynamics meshes + scene + // particles + weather AFTER the look-ins (FlushAlphaList + // deferral). + DrawLandscapeSliceLate = lateCtx => + DrawRetailPViewLandscapeSliceLate( + lateCtx, + camera, + frustum, + camPos, + playerLb, + animatedIds, + renderSky, + renderWeather: playerSeenOutside, + kf, + environOverrideActive), // T1: retail's depth discipline (PView::DrawCells, Ghidra 0x005a4840). // INTERIOR roots: one FULL depth clear between the outside stage and // the interior stage, then SEALS re-stamp every outside-leading @@ -9652,8 +9667,60 @@ public sealed class GameWindow : IDisposable animatedEntityIds: animatedIds); } + // #131/#132: scene particles + weather MOVED to the LATE phase + // (DrawRetailPViewLandscapeSliceLate) — they must composite AFTER the + // #124 look-ins (retail's FlushAlphaList deferral, DrawCells + // pc:432722); drawn here they were overpainted by far-building + // interiors wherever a look-in aperture sat behind them. + + if (scissor) + _gl!.Disable(EnableCap.ScissorTest); + + DisableClipDistances(); + } + + // #131/#132: the LATE landscape phase — per slice, invoked by the renderer + // AFTER the #124 look-in sub-pass, still pre-clear. Outside-stage + // dynamics' meshes (a translucent portal swirl blends over a far interior + // instead of being overpainted by it — translucents write no depth to + // protect themselves) + ALL attached scene particles (statics' flames + // included — the #132 candle) + weather. Retail equivalent: alpha draws + // collected during LScape::draw flush ONCE after it + // (D3DPolyRender::FlushAlphaList, PView::DrawCells pc:432722). + private void DrawRetailPViewLandscapeSliceLate( + AcDream.App.Rendering.RetailPViewLandscapeLateSliceContext lateCtx, + ICamera camera, + FrustumPlanes? frustum, + System.Numerics.Vector3 camPos, + uint? playerLb, + HashSet? animatedIds, + bool renderSky, + bool renderWeather, + AcDream.Core.World.SkyKeyframe kf, + bool environOverrideActive) + { + var slice = lateCtx.Slice; + bool scissor = BeginDoorwayScissor(true, slice.NdcAabb); + + _gl!.BindBufferBase(BufferTargetARB.UniformBuffer, + ClipFrame.TerrainClipUboBinding, _clipFrame!.TerrainUbo); + + // Outside-stage dynamics' meshes — viewcone pre-filtered by the + // renderer, never hard-clipped (T3). + DisableClipDistances(); + if (lateCtx.Dynamics.Count > 0) + { + var dynamicsEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero, + lateCtx.Dynamics, + (IReadOnlyDictionary?)null); + _wbDrawDispatcher!.Draw(camera, new[] { dynamicsEntry }, frustum, + neverCullLandblockId: playerLb, + visibleCellIds: null, + animatedEntityIds: animatedIds); + } + _outdoorSceneParticleEntityIds.Clear(); - foreach (var entity in sliceCtx.OutdoorEntities) + foreach (var entity in lateCtx.ParticleOwners) _outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity)); // #131 [outstage-pt] probe: the slice Scene-particle id set + how many @@ -9677,7 +9744,6 @@ public sealed class GameWindow : IDisposable } } - DisableClipDistances(); if (_outdoorSceneParticleEntityIds.Count > 0 && _particleSystem is not null && _particleRenderer is not null) diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index e7ca5ef8..2b39b1e6 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -33,6 +33,10 @@ public sealed class RetailPViewRenderer private readonly List _lookInFrames = new(); private readonly HashSet _lookInPrepareScratch = new(); + // #131/#132: the late landscape phase's scene-particle owner survivors + // (statics + outside-stage dynamics passing the slice cone). + private readonly List _lateParticleOwnerScratch = new(); + // T2 (BR-4): retail has NO distance constant on the flood-admission chain // (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test + // GetClip + GetVisible only). The old 48 m seed cap is replaced by the @@ -365,6 +369,18 @@ public sealed class RetailPViewRenderer if (clipAssembly.OutsideViewSlices.Length == 0) return; + // #131/#132 (the FlushAlphaList deferral): retail collects ALL alpha + // draws of the landscape stage and flushes them ONCE after LScape::draw + // (D3DPolyRender::FlushAlphaList, DrawCells pc:432722) — so translucent + // landscape content (portal swirl meshes, flame particles) composites + // AFTER the building look-ins. Our dispatcher draws translucency inside + // each Draw call, so the stage is split in TWO phases instead: EARLY = + // sky + terrain + outdoor STATIC meshes (the look-in punches need their + // depth to mark against, the #117 lesson); then the look-ins; then + // LATE = outside-stage dynamics' meshes + ALL scene particles + + // weather. Content drawn early and overlapped by a look-in aperture + // was otherwise overpainted by the far interior (translucents write no + // depth to protect themselves) — the portal-swirl/candle-flame class. int probeSliceIndex = 0; foreach (var slice in clipAssembly.OutsideViewSlices) { @@ -386,19 +402,6 @@ public sealed class RetailPViewRenderer if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) _outdoorStaticScratch.Add(e); } - // #118: outside-stage dynamics ride the landscape pass like retail's - // per-landcell DrawSortCell (DrawBlock 0x005a17c0, pc:430124) — drawn - // BEFORE the depth clear + seals so the seal PROTECTS their pixels in - // the aperture instead of z-killing them. Same per-slice cone test as - // the statics above. Empty under outdoor roots (see DrawInside). - foreach (var e in _outsideStageDynamics) - { - EntitySphere(e, out var c, out float r); - if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) - _outdoorStaticScratch.Add(e); - } - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled) - EmitOutStageProbe(probeSliceIndex, viewcone); probeSliceIndex++; ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch)); } @@ -409,19 +412,49 @@ public sealed class RetailPViewRenderer // retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785). DrawBuildingLookIns(ctx, partition); + // LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn + // pre-clear so the seal protects their aperture pixels; AFTER the + // look-ins so a translucent portal mesh blends over a far interior + // instead of being overpainted) + the scene-particle owners (statics + + // dynamics cone survivors — flames ride here for the same reason). + probeSliceIndex = 0; + foreach (var slice in clipAssembly.OutsideViewSlices) + { + _clipFrame.SetTerrainClip(slice.Planes); + UploadClipFrame(ctx.SetTerrainClipUbo); + _entities.ClearClipRouting(); + + _outdoorStaticScratch.Clear(); // late: dynamics survivors + _lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors + foreach (var e in partition.OutdoorStatic) + { + EntitySphere(e, out var c, out float r); + if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) + _lateParticleOwnerScratch.Add(e); + } + foreach (var e in _outsideStageDynamics) + { + EntitySphere(e, out var c, out float r); + if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) + { + _outdoorStaticScratch.Add(e); + _lateParticleOwnerScratch.Add(e); + } + } + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled) + EmitOutStageProbe(probeSliceIndex, viewcone); + probeSliceIndex++; + ctx.DrawLandscapeSliceLate?.Invoke(new RetailPViewLandscapeLateSliceContext( + slice, _outdoorStaticScratch, _lateParticleOwnerScratch)); + } + // #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls, // campfires, ground effects anchored at a position) have no owner id // to ride any of the id-filtered particle passes. The outdoor root // has the dedicated T3 pass for them; an INTERIOR root had NO pass - // at all — the portal swirl vanished exactly when viewed through a - // doorway. Draw them ONCE per frame (not per slice — alpha particles - // must not double-draw, the #121 lesson), still inside the landscape - // stage: drawn after the clear they would z-fail against the doorway - // seal; here they composite against the slice depth, and anything on - // screen outside the apertures is overpainted by the root's shells - // after the clear. Mutually exclusive with the outdoor T3 pass - // (this method's caller is the interior path when slices exist; - // GameWindow gates the T3 pass on IsOutdoorNode). + // at all. Draw them ONCE per frame (not per slice — alpha particles + // must not double-draw, the #121 lesson), at the END of the landscape + // stage: after the clear they would z-fail against the doorway seal. if (!ctx.RootCell.IsOutdoorNode) ctx.DrawUnattachedSceneParticles?.Invoke(); @@ -903,6 +936,11 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext IReadOnlyDictionary? AnimatedById)> LandblockEntries { get; init; } public required Action SetTerrainClipUbo { get; init; } public required Action DrawLandscapeSlice { get; init; } + + /// #131/#132: the LATE landscape phase, per slice, after the #124 + /// look-ins — outside-stage dynamics' meshes + all scene particles + + /// weather (the FlushAlphaList deferral; see DrawLandscapeThroughOutsideView). + public Action? DrawLandscapeSliceLate { get; init; } /// T1: one full-buffer depth clear between the outside stage and the /// interior stage (retail PView::DrawCells, Ghidra 0x005a4840). Null for outdoor /// roots — outdoors the interiors must depth-test against terrain + exteriors and @@ -933,6 +971,14 @@ public readonly record struct RetailPViewLandscapeSliceContext( ClipViewSlice Slice, IReadOnlyList OutdoorEntities); +/// #131/#132: the late landscape phase's per-slice payload — +/// outside-stage dynamics to mesh-draw, plus the full scene-particle owner +/// set (statics + dynamics cone survivors) the attached-emitter filter keys on. +public readonly record struct RetailPViewLandscapeLateSliceContext( + ClipViewSlice Slice, + IReadOnlyList Dynamics, + IReadOnlyList ParticleOwners); + public readonly record struct RetailPViewCellSliceContext( uint CellId, ClipViewSlice Slice, From 87afbc0a42203e6652eaa2a354bf3fb379e8e3c7 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 19:26:04 +0200 Subject: [PATCH 09/65] fix #132 (outdoor sibling): outdoor attached scene emitters move to the post-frame pass; sharpen the #131 probe User gate on 20d1730: the candle is FIXED indoors ("now the candle light is visible when I'm in the house when it is in front of the opening") and the OUTDOOR sibling surfaced exactly as AP-34 recorded ("when I go out it is not showing unless I turn so the angle doesn't put it in front of the opening"): under an OUTDOOR root the merged building interiors draw AFTER the landscape stage (DrawEnvCellShells), so a slice-drawn flame is overpainted by a punched aperture's interior behind it. Fix: outdoor roots SKIP the late-slice Scene-particle draw; attached outdoor-static scene emitters draw in the POST-FRAME pass alongside the T3 unattached pass, where depth is complete and flames composite correctly against interiors. The owner-id set carries over from the late slice (single full-screen slice outdoors); cell-pass and dynamics-pass emitters keep their own passes (their owners are never in the outdoor-static id set - no double-draw). Interior roots keep the late-slice draw (their stage ends with the clear + seal discipline). AP-34 row updated (the outdoor residual is now covered; the remaining residual is translucent MESH batches within stage draw calls). Portal swirl (#131): the user's "same results" on 20d1730 KILLS the look-in-erasure hypothesis for the portal - the mesh now draws after the look-ins and is still missing indoors. No further speculative fix; the [outstage] probe now prints each outside-stage dynamic's SourceGfxObjOrSetupId (portals have distinctive setups) and [outstage-pt] lists up to 12 distinct UNMATCHED attached emitter owner ids - the next capture identifies whether the portal entity reaches the through-door draw at all, and where its emitters point. Suites: App 259+1skip / Core 1439+2skip / UI 420 / Net 294 green. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 19 ++++++- .../retail-divergence-register.md | 2 +- src/AcDream.App/Rendering/GameWindow.cs | 54 ++++++++++++++----- .../Rendering/RetailPViewRenderer.cs | 2 +- 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 0894eb2c..36b10937 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -4670,8 +4670,23 @@ Background-dependence explained exactly. **Fix:** the landscape stage's two-phase split (see #131 FIX 2): all scene particles moved to the LATE phase, after the look-ins. -**Gate:** the candle at the original spot — flame stays visible when -the through-opening is behind it. +**Gate 1 result (user):** indoors FIXED ("now the candle light is +visible when I'm in the house when it is in front of the opening") — +but the OUTDOOR sibling surfaced ("when I go out it is not showing +unless I turn so the angle doesn't put it in front of the opening"): +under an OUTDOOR root the merged building interiors draw AFTER the +landscape stage, so a slice-drawn flame is overpainted by the punched +aperture's interior — the residual AP-34 had already recorded. + +**Fix 2 (outdoor):** outdoor roots skip the slice Scene pass; attached +outdoor-static scene emitters draw in the POST-FRAME pass alongside the +T3 unattached pass (depth complete there — flames composite correctly +against interiors). The owner-id filter carries over; cell-pass and +dynamics-pass emitters keep their own passes (owners never in the +outdoor-static set → no double-draw). + +**Gate:** both sides — indoors with the opening behind the candle, and +outdoors at the angle that previously erased it. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 0178bfd7..5c26f137 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -129,7 +129,7 @@ accepted-divergence entries (#96, #49, #50). | AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter | | AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | | AP-33 | Interior-root look-in statics (**#124** sub-pass) draw WHOLE — no per-part viewcone check; retail viewconeCheck's each part vs the installed view. Look-in DYNAMICS are not drawn at all (deferred; retail draws objects per overlapped cell in the landscape stage) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | Statics: a few wasted draws only. Dynamics: an NPC inside a far building seen through two openings is invisible where retail shows it | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | -| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins), not retail's single deferred alpha flush. Residual: under OUTDOOR roots slice particles still draw before merged building interiors (the unreported outdoor sibling of **#131**/**#132**); building exteriors' own translucent batches draw early | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the interior-root cases the user can see (#131 portal swirl, #132 candle flame) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | +| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 5a0f7c74..a60ed6a8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5096,6 +5096,7 @@ public sealed class GameWindow : IDisposable // #131 [outstage-pt] probe state (throwaway — strip when #131 closes). private string? _lastOutStagePtSig; + private readonly HashSet _outStageUnmatchedScratch = new(); private static System.Numerics.Vector3 SkyPesAnchor( AcDream.Core.World.SkyObjectData obj, @@ -7841,7 +7842,8 @@ public sealed class GameWindow : IDisposable renderSky, renderWeather: playerSeenOutside, kf, - environOverrideActive), + environOverrideActive, + isOutdoorRoot: clipRoot.IsOutdoorNode), // T1: retail's depth discipline (PView::DrawCells, Ghidra 0x005a4840). // INTERIOR roots: one FULL depth clear between the outside stage and // the interior stage, then SEALS re-stamp every outside-leading @@ -8002,20 +8004,26 @@ public sealed class GameWindow : IDisposable && _particleSystem is not null && _particleRenderer is not null) { // T3 (BR-5): unattached emitters (campfires, ground effects — - // AttachedObjectId == 0) under the OUTDOOR root. The unified - // path's attached emitters draw via the landscape slice + the - // per-cell callbacks; unattached ones had NO pass on - // outdoor-node frames (the unattached-particles-dropped- - // outdoors divergence, adjusted-confirmed). The outdoor root's - // outside view is full-screen (cone pass-all); depth test - // composites them against the world. + // AttachedObjectId == 0) under the OUTDOOR root. The outdoor + // root's outside view is full-screen (cone pass-all); depth + // test composites them against the world. + // #132 outdoor sibling: ATTACHED outdoor-static scene emitters + // (lantern/candle flames) moved here too — drawn in the + // landscape slice they were overpainted by merged building + // interiors (drawn later) whenever a punched aperture sat + // behind them. Post-frame, depth is complete and the flames + // composite correctly. The owner-id set is the late slice's + // (full-screen cone outdoors). Cell-pass and dynamics-pass + // emitters keep their own passes (no double-draw: their owners + // are never in the outdoor-static id set). sigSceneParticles = sigSceneParticles == "none" ? "unattached" : sigSceneParticles + "+unattached"; _particleRenderer.Draw( _particleSystem, camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, - emitter => emitter.AttachedObjectId == 0); + emitter => emitter.AttachedObjectId == 0 + || _outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)); } // Bug A fix (post-#26 worktree, 2026-04-26): weather sky @@ -9697,7 +9705,8 @@ public sealed class GameWindow : IDisposable bool renderSky, bool renderWeather, AcDream.Core.World.SkyKeyframe kf, - bool environOverrideActive) + bool environOverrideActive, + bool isOutdoorRoot) { var slice = lateCtx.Slice; bool scissor = BeginDoorwayScissor(true, slice.NdcAabb); @@ -9724,19 +9733,28 @@ public sealed class GameWindow : IDisposable _outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity)); // #131 [outstage-pt] probe: the slice Scene-particle id set + how many - // live emitters the filter would actually match. Print-on-change. + // live emitters the filter would actually match, plus the distinct + // UNMATCHED attached owner ids (the portal-identification handle — + // an emitter whose owner never lands in the set draws nowhere + // indoors). Print-on-change. if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled && _particleSystem is not null) { int matched = 0, attached = 0, unattached = 0; + _outStageUnmatchedScratch.Clear(); foreach (var (emitter, _) in _particleSystem.EnumerateLive()) { if (emitter.AttachedObjectId == 0) { unattached++; continue; } attached++; if (_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)) matched++; + else if (_outStageUnmatchedScratch.Count < 12) + _outStageUnmatchedScratch.Add(emitter.AttachedObjectId); } + var unm = new System.Text.StringBuilder(96); + foreach (uint id in _outStageUnmatchedScratch) + unm.Append(System.FormattableString.Invariant($" 0x{id:X8}")); string ptSig = System.FormattableString.Invariant( - $"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached}"); + $"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached} unmatchedIds=[{unm}]"); if (ptSig != _lastOutStagePtSig) { _lastOutStagePtSig = ptSig; @@ -9744,7 +9762,17 @@ public sealed class GameWindow : IDisposable } } - if (_outdoorSceneParticleEntityIds.Count > 0 + // #132 outdoor sibling: under an OUTDOOR root the merged building + // interiors draw AFTER this stage (DrawEnvCellShells) — a flame drawn + // here is overpainted whenever a punched aperture sits behind it + // (user-confirmed at the outdoor candle). Outdoor roots therefore + // SKIP the slice Scene pass and draw attached scene particles in the + // post-frame pass alongside the T3 unattached pass (the id set built + // above carries over — the outdoor root has a single full-screen + // slice). Interior roots draw here: the look-ins already ran and the + // post-clear seal discipline owns the rest of the frame. + if (!isOutdoorRoot + && _outdoorSceneParticleEntityIds.Count > 0 && _particleSystem is not null && _particleRenderer is not null) { diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 2b39b1e6..f23d419c 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -488,7 +488,7 @@ public sealed class RetailPViewRenderer bool pass = viewcone.SphereVisibleInOutsideSlice(sliceIndex, c, r); if (i > 0) sb.Append(' '); sb.Append(System.FormattableString.Invariant( - $"0x{(e.ServerGuid != 0 ? e.ServerGuid : e.Id):X8}:{(pass ? "PASS" : "CULL")}:r={r:F1}")); + $"0x{(e.ServerGuid != 0 ? e.ServerGuid : e.Id):X8}(s{e.SourceGfxObjOrSetupId:X8}):{(pass ? "PASS" : "CULL")}:r={r:F1}")); } sb.Append(']'); string sig = sb.ToString(); From a07279dfd19bc1206760f029f812f008eec00812 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 19:48:53 +0200 Subject: [PATCH 10/65] 131 probe: print matched emitter owner ids + the setup-dump diagnostic (portal identification capture) unattached=0 in the last capture refuted the unattached hypothesis (the fix-1 pass is vacuous); the swirl outdoors rides a MATCHED attached emitter, so its owner is an OutdoorStatic keyed by a synthetic id. The matched-ids dump on an inside-vs-outside capture pair names the owner: the id that flips. Issue131SetupProbeTests dumps the outstage candidate setups from the dat. Co-Authored-By: Claude Fable 5 --- src/AcDream.App/Rendering/GameWindow.cs | 14 +++- .../Rendering/Issue131SetupProbeTests.cs | 69 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a60ed6a8..2365ca14 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5097,6 +5097,7 @@ public sealed class GameWindow : IDisposable // #131 [outstage-pt] probe state (throwaway — strip when #131 closes). private string? _lastOutStagePtSig; private readonly HashSet _outStageUnmatchedScratch = new(); + private readonly HashSet _outStageMatchedScratch = new(); private static System.Numerics.Vector3 SkyPesAnchor( AcDream.Core.World.SkyObjectData obj, @@ -9742,19 +9743,28 @@ public sealed class GameWindow : IDisposable { int matched = 0, attached = 0, unattached = 0; _outStageUnmatchedScratch.Clear(); + _outStageMatchedScratch.Clear(); foreach (var (emitter, _) in _particleSystem.EnumerateLive()) { if (emitter.AttachedObjectId == 0) { unattached++; continue; } attached++; - if (_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)) matched++; + if (_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)) + { + matched++; + if (_outStageMatchedScratch.Count < 48) + _outStageMatchedScratch.Add(emitter.AttachedObjectId); + } else if (_outStageUnmatchedScratch.Count < 12) _outStageUnmatchedScratch.Add(emitter.AttachedObjectId); } var unm = new System.Text.StringBuilder(96); foreach (uint id in _outStageUnmatchedScratch) unm.Append(System.FormattableString.Invariant($" 0x{id:X8}")); + var mat = new System.Text.StringBuilder(192); + foreach (uint id in _outStageMatchedScratch) + mat.Append(System.FormattableString.Invariant($" 0x{id:X8}")); string ptSig = System.FormattableString.Invariant( - $"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached} unmatchedIds=[{unm}]"); + $"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached} matchedIds=[{mat}] unmatchedIds=[{unm}]"); if (ptSig != _lastOutStagePtSig) { _lastOutStagePtSig = ptSig; diff --git a/tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs b/tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs new file mode 100644 index 00000000..49cca50f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs @@ -0,0 +1,69 @@ +using System; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; +using DatSetup = DatReaderWriter.DBObjs.Setup; +using DatGfxObj = DatReaderWriter.DBObjs.GfxObj; + +namespace AcDream.App.Tests.Rendering; + +/// +/// #131 diagnostic (throwaway): identify the Holtburg portal among the +/// outside-stage setup ids captured by the [outstage] probe, by dumping each +/// candidate setup's parts + bounds from the dat. The portal's setup is the +/// translucent swirl; lamp posts / creatures / signs identify by part shape. +/// +public class Issue131SetupProbeTests +{ + private readonly ITestOutputHelper _out; + public Issue131SetupProbeTests(ITestOutputHelper output) => _out = output; + + [Fact] + public void Diagnostic_DumpOutstageCandidateSetups() + { + var datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + uint[] candidates = + { + 0x020010AC, // 0x7A9B4050 PASS r=11.9 — portal candidate A + 0x02000B8E, // 0x7A9B403B PASS r=11.6 — portal candidate B + 0x020019FF, // many instances (lamp posts?) + 0x02000290, + 0x02000001, // baseline (human?) + 0x02000E08, + }; + + foreach (uint setupId in candidates) + { + var setup = dats.Get(setupId); + if (setup is null) + { + _out.WriteLine(FormattableString.Invariant($"setup 0x{setupId:X8}: NOT FOUND")); + continue; + } + _out.WriteLine(FormattableString.Invariant( + $"setup 0x{setupId:X8}: parts={setup.Parts.Count}")); + int shown = 0; + foreach (uint partId in setup.Parts) + { + if (shown++ >= 4) { _out.WriteLine(" ..."); break; } + var gfx = dats.Get(partId); + if (gfx is null) { _out.WriteLine(FormattableString.Invariant($" part 0x{partId:X8}: not found")); continue; } + var sb = new System.Text.StringBuilder(); + sb.Append(FormattableString.Invariant( + $" part 0x{partId:X8}: polys={gfx.Polygons.Count} verts={gfx.VertexArray.Vertices.Count} surfaces=[")); + int sShown = 0; + foreach (uint surfId in gfx.Surfaces) + { + if (sShown++ >= 6) { sb.Append(" ..."); break; } + sb.Append(FormattableString.Invariant($" 0x{surfId:X8}")); + } + sb.Append(" ]"); + _out.WriteLine(sb.ToString()); + } + } + } +} From 47f32cd45cd324803af321e98c7b16e0c2d0f966 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 20:44:24 +0200 Subject: [PATCH 11/65] fix #131 (root cause): look-in cells draw their emitters - the cell-particles pass was missing from the #124 sub-pass The teleport capture pinned it: walking into the portal flipped pCell to 0xA9B4017A - the hall's PORCH EnvCell. The swirl emitter is owned by a static inside another building's cell. Outdoors the merge path runs the main per-cell pass incl. DrawCellParticles -> visible; under an interior root the #124 look-in sub-pass drew shells + statics but had no cell-particles call. Retail's nested DrawCells draws objects WITH their emitters (DrawObjCellForDummies pc:432878+). Fix: DrawBuildingLookIns pass 2 invokes DrawCellParticles per look-in cell with its static bucket. The owner-cone verdicts were geometrically correct all along (0xC0A9B462 = a porch torch); fixes 1-2 were real-but-adjacent (the unattached pass plugs an independent hole; the alpha deferral fixed #132). Suites: App 260+1skip / Core 1439+2skip / UI 420 / Net 294 green. Awaiting the swirl gate. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 26 +++++++++++---- .../Rendering/RetailPViewRenderer.cs | 33 +++++++++++++++++-- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 36b10937..4de6e2ca 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -4635,15 +4635,27 @@ the landscape stage is now TWO phases per frame — EARLY per slice: sky, terrain, outdoor static meshes (the look-in punches need their depth, the #117 lesson); then the #124 look-ins; then LATE per slice: outside-stage dynamics' meshes + ALL attached scene particles + weather + the -unattached pass. Outdoor roots keep their existing order (no look-ins in -the stage; net order unchanged). Residual (documented, AP-34): under -OUTDOOR roots slice particles still draw before merged building -interiors (the outdoor sibling of this bug, unreported); building -exteriors' own translucent batches draw early. +unattached pass. (This FIXED #132 indoors but not the portal.) + +**ROOT CAUSE (fix 3 — the real one, pinned by the teleport capture):** +walking into the portal flipped `pCell` to **0xA9B4017A — the hall's +porch EnvCell**. The portal's swirl emitter is owned by a STATIC inside +ANOTHER BUILDING'S CELL, not an outdoor entity at all. Outdoors the +hall's cells merge into the main frame and the per-cell object pass +runs `DrawCellParticles` → swirl visible. Under an interior root the +#124 look-in sub-pass drew the far cells' shells + statics but had NO +cell-particles call — retail's nested DrawCells draws objects WITH +their emitters (`DrawObjCellForDummies`). Every earlier suspect +(unattached pass, owner-cone verdicts, alpha ordering) was real-but- +adjacent; the cone math was correct all along (the 0xC0A9B462 flips +were a porch torch, geometrically defensible). + +**Fix 3:** `DrawBuildingLookIns` pass 2 invokes `DrawCellParticles` per +look-in cell with its static bucket (same callback as the main per-cell +pass; no-clip slice when the cell has no slot). **Gate:** stand inside, look out the doorway at the town portal — the -swirl renders through the door; the candle flame (#132) stays visible -with the through-opening behind it. +swirl renders through the door. --- diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index f23d419c..5a305809 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -308,7 +308,10 @@ public sealed class RetailPViewRenderer // repainted by the root's own shells after the depth clear, so over-draw // here is color-safe; statics draw whole (the main viewcone has no entry // for look-in cells; over-include is the safe direction). - private void DrawBuildingLookIns(RetailPViewDrawContext ctx, InteriorEntityPartition.Result partition) + private void DrawBuildingLookIns( + RetailPViewDrawContext ctx, + ClipFrameAssembly clipAssembly, + InteriorEntityPartition.Result partition) { if (_lookInFrames.Count == 0) return; @@ -355,6 +358,17 @@ public sealed class RetailPViewRenderer _cellStaticScratch.Clear(); _cellStaticScratch.AddRange(bucket); DrawEntityBucket(ctx, _cellStaticScratch, _oneCell); + + // #131: the cell-particles pass for look-in cells — retail's + // nested DrawCells draws objects WITH their emitters + // (DrawObjCellForDummies, pc:432878+). Without this, an + // emitter owned by a far building's cell static (the + // Holtburg hall-porch portal swirl, cell 0x017A) drew ONLY + // when the viewer was outdoors (the merge path runs the + // main per-cell pass) — invisible from inside any cottage. + foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) + ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext( + cellId, slice, _cellStaticScratch)); } } } @@ -410,7 +424,7 @@ public sealed class RetailPViewRenderer // stage (their punches mark against the terrain/exterior depth just // drawn), strictly BEFORE the depth clear + seals below, matching // retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785). - DrawBuildingLookIns(ctx, partition); + DrawBuildingLookIns(ctx, clipAssembly, partition); // LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn // pre-clear so the seal protects their aperture pixels; AFTER the @@ -429,8 +443,20 @@ public sealed class RetailPViewRenderer foreach (var e in partition.OutdoorStatic) { EntitySphere(e, out var c, out float r); - if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) + bool ownerPass = viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r); + if (ownerPass) _lateParticleOwnerScratch.Add(e); + // #131 owner watchlist (throwaway): ACDREAM_DUMP_ENTITY ids + // double as an ENTITY-id watchlist here — one line per watched + // outdoor-static owner per CHANGE of its cone verdict. + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled + && AcDream.Core.Rendering.RenderingDiagnostics.DumpEntitySourceIds.Contains(e.Id) + && (!_outStageOwnerVerdicts.TryGetValue(e.Id, out bool prev) || prev != ownerPass)) + { + _outStageOwnerVerdicts[e.Id] = ownerPass; + Console.WriteLine(System.FormattableString.Invariant( + $"[outstage-own] id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} pos=({e.Position.X:F1},{e.Position.Y:F1},{e.Position.Z:F1}) c=({c.X:F1},{c.Y:F1},{c.Z:F1}) r={r:F1} slice={probeSliceIndex} {(ownerPass ? "PASS" : "CULL")}")); + } } foreach (var e in _outsideStageDynamics) { @@ -475,6 +501,7 @@ public sealed class RetailPViewRenderer // which outdoor dynamics were routed to the outside stage and which // survived the slice viewcone. Strip with the probe when #131 closes. private string? _lastOutStageSig; + private readonly Dictionary _outStageOwnerVerdicts = new(); private void EmitOutStageProbe(int sliceIndex, ViewconeCuller viewcone) { From d208002bf8ec0a03bcafd3c1de6d19ad863a5fa5 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 20:52:34 +0200 Subject: [PATCH 12/65] fix #131 (root cause 4, structurally forced): look-in cells draw their DYNAMICS - the town portal is a server object in the hall's porch cell The headless replay of the captured indoor frame proved the look-in flood ADMITS the porch 0x017A (Diagnostic_LookInFlood_AdmitsHallPorchFromCottage: 14 cells). So the portal (a SERVER object - the teleport proves it - with ParentCellId 0xA9B4017A) routes to partition.Dynamics and draws NOWHERE under an interior root: dynamics-last viewcone-culls it (the main cone has no look-in cells) and post-seal it would z-fail beyond the root's door plane (the #118 lesson). This is AP-33's own recorded deferral - 'look-in DYNAMICS are not drawn' - the deferred case was the most-stared-at object in town. Outdoors the merge path puts the porch in the main cone -> drawn -> 'appears when I walk out'. Fix: DrawBuildingLookIns pass 2 draws look-in-cell dynamics with the statics (whole, AP-33 over-include) and their emitters ride the same DrawCellParticles call. No double-draw: dynamics-last keeps culling them; DrawDynamicsParticles only sees its cone survivors. #124 CLOSED by user gate same session. AP-33 row updated. Suites: App 261+1skip / Core 1439+2skip / UI 420 / Net 294 green. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 37 +++++++++------- .../retail-divergence-register.md | 2 +- .../Rendering/RetailPViewRenderer.cs | 34 ++++++++++----- .../Rendering/Issue131SetupProbeTests.cs | 43 +++++++++++++++++++ 4 files changed, 89 insertions(+), 27 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 4de6e2ca..86c51813 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -4304,7 +4304,7 @@ of which draw list the building's shell left. ## #124 — Looking out through an opening: far buildings with openings show missing/transparent back walls -**Status:** FIX SHIPPED — awaiting user visual gate +**Status:** CLOSED (user-gated 2026-06-12 evening: "124, that one is solved") **Severity:** MEDIUM **Filed:** 2026-06-11 (re-gate; pre-existing — "still have that issue"; user 2026-06-12: "especially visible when I look out through a door @@ -4637,22 +4637,27 @@ terrain, outdoor static meshes (the look-in punches need their depth, the dynamics' meshes + ALL attached scene particles + weather + the unattached pass. (This FIXED #132 indoors but not the portal.) -**ROOT CAUSE (fix 3 — the real one, pinned by the teleport capture):** -walking into the portal flipped `pCell` to **0xA9B4017A — the hall's -porch EnvCell**. The portal's swirl emitter is owned by a STATIC inside -ANOTHER BUILDING'S CELL, not an outdoor entity at all. Outdoors the -hall's cells merge into the main frame and the per-cell object pass -runs `DrawCellParticles` → swirl visible. Under an interior root the -#124 look-in sub-pass drew the far cells' shells + statics but had NO -cell-particles call — retail's nested DrawCells draws objects WITH -their emitters (`DrawObjCellForDummies`). Every earlier suspect -(unattached pass, owner-cone verdicts, alpha ordering) was real-but- -adjacent; the cone math was correct all along (the 0xC0A9B462 flips -were a porch torch, geometrically defensible). +**ROOT CAUSE (fix 4 — structurally forced; fixes 1–3 were +real-but-adjacent):** the teleport capture flipped `pCell` to +**0xA9B4017A — the hall's porch EnvCell** (the portal is a SERVER +object standing inside a look-in cell), and the headless replay of the +captured indoor frame proved the look-in flood ADMITS 0x017A (14 cells +incl. the porch — `Issue131SetupProbeTests.Diagnostic_LookInFlood_*`). +The partition routes server objects to the dynamics-last pass, where +(a) the viewcone has NO entries for look-in cells → culled, and (b) +even un-culled they would z-fail post-seal beyond the root's door plane +(the #118 lesson). This is exactly AP-33's recorded "look-in DYNAMICS +are not drawn (deferred)" — the deferred case was the town portal. +Outdoors the merge path puts the porch in the main cone → drawn → +"appears when I walk out." -**Fix 3:** `DrawBuildingLookIns` pass 2 invokes `DrawCellParticles` per -look-in cell with its static bucket (same callback as the main per-cell -pass; no-clip slice when the cell has no slot). +**Fix 4:** look-in-cell DYNAMICS draw inside `DrawBuildingLookIns` +pass 2 (with the statics, whole — AP-33's over-include), and their +emitters ride the same `DrawCellParticles` call (fix 3). Retail +equivalent: the nested DrawCells draws the cell's objects +(`DrawObjCellForDummies` pc:432878+). No double-draw: dynamics-last +keeps culling them (cell absent from the main cone); +DrawDynamicsParticles only sees dynamics-last cone survivors. **Gate:** stand inside, look out the doorway at the town portal — the swirl renders through the door. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 5c26f137..b7a710c0 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -128,7 +128,7 @@ accepted-divergence entries (#96, #49, #50). | AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 | | AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter | | AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | -| AP-33 | Interior-root look-in statics (**#124** sub-pass) draw WHOLE — no per-part viewcone check; retail viewconeCheck's each part vs the installed view. Look-in DYNAMICS are not drawn at all (deferred; retail draws objects per overlapped cell in the landscape stage) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | Statics: a few wasted draws only. Dynamics: an NPC inside a far building seen through two openings is invisible where retail shows it | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | +| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | | AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | --- diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 5a305809..4993f5c1 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -353,19 +353,33 @@ public sealed class RetailPViewRenderer _envCells.Render(WbRenderPass.Opaque, _oneCell); _envCells.Render(WbRenderPass.Transparent, _oneCell); - if (partition.ByCell.TryGetValue(cellId, out var bucket) && bucket.Count > 0) - { - _cellStaticScratch.Clear(); + _cellStaticScratch.Clear(); + if (partition.ByCell.TryGetValue(cellId, out var bucket)) _cellStaticScratch.AddRange(bucket); + + // #131 ROOT CAUSE: DYNAMICS living in a look-in cell (the + // Holtburg hall-porch PORTAL, pCell 0xA9B4017A) draw NOWHERE + // under an interior root — DrawDynamicsLast viewcone-culls + // them (the main cone has no entries for look-in cells), and + // post-clear they would z-fail against the root's seal anyway + // (the #118 lesson). Retail draws a look-in cell's objects + // inside the NESTED DrawCells (DrawObjCellForDummies, + // pc:432878+), i.e. right here in the landscape stage. Drawn + // WHOLE like the statics (AP-33's documented over-include). + // No double-draw: dynamics-last keeps culling them (their + // cell is absent from the main cone), and their emitters ride + // the DrawCellParticles call below, not DrawDynamicsParticles + // (which only sees dynamics-last cone survivors). + foreach (var e in partition.Dynamics) + if (e.ParentCellId == cellId) + _cellStaticScratch.Add(e); + + if (_cellStaticScratch.Count > 0) + { DrawEntityBucket(ctx, _cellStaticScratch, _oneCell); - // #131: the cell-particles pass for look-in cells — retail's - // nested DrawCells draws objects WITH their emitters - // (DrawObjCellForDummies, pc:432878+). Without this, an - // emitter owned by a far building's cell static (the - // Holtburg hall-porch portal swirl, cell 0x017A) drew ONLY - // when the viewer was outdoors (the merge path runs the - // main per-cell pass) — invisible from inside any cottage. + // The cell-particles pass for look-in cells — retail's + // nested DrawCells draws objects WITH their emitters. foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext( cellId, slice, _cellStaticScratch)); diff --git a/tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs b/tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs index 49cca50f..0c60c71a 100644 --- a/tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs @@ -19,6 +19,49 @@ public class Issue131SetupProbeTests private readonly ITestOutputHelper _out; public Issue131SetupProbeTests(ITestOutputHelper output) => _out = output; + /// #131: from the captured cottage-interior frame (the user's + /// portal-missing viewpoint), does the look-in flood admit the hall's + /// PORCH cell 0xA9B4017A (the portal's owner cell, pinned by the teleport + /// pCell flip)? If not admitted, no pass can draw the swirl regardless of + /// the emitter plumbing. + [Fact] + public void Diagnostic_LookInFlood_AdmitsHallPorchFromCottage() + { + var datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, 0xA9B40000u); + _out.WriteLine(FormattableString.Invariant($"loaded {cells.Count} A9B4 interior cells; hasPorch017A={cells.ContainsKey(0xA9B4017Au)}")); + AcDream.App.Rendering.LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null; + + // The captured frame: [viewer] root=0xA9B40171 eye=(155.255,14.533,96.074) + // fwd=(0.0702,0.9554,-0.2869) (portal-owner-verdicts.log:135118). + var eye = new System.Numerics.Vector3(155.255f, 14.533f, 96.074f); + var fwd = new System.Numerics.Vector3(0.0702f, 0.9554f, -0.2869f); + var view = System.Numerics.Matrix4x4.CreateLookAt(eye, eye + fwd, System.Numerics.Vector3.UnitZ); + var proj = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 1f, 5000f); + var viewProj = view * proj; + + var root = cells[0xA9B40171u]; + var pv = AcDream.App.Rendering.PortalVisibilityBuilder.Build( + root, eye, Lookup, viewProj, + buildingMembership: null, + drawLiftZ: AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); + _out.WriteLine(FormattableString.Invariant( + $"main flood={pv.OrderedVisibleCells.Count} outPolys={pv.OutsideView.Polygons.Count}")); + + var lookIn = AcDream.App.Rendering.PortalVisibilityBuilder.BuildFromExterior( + cells.Values, eye, Lookup, viewProj, + float.PositiveInfinity, pv.OutsideView.Polygons); + var sb = new System.Text.StringBuilder("look-in admitted:"); + foreach (uint id in lookIn.OrderedVisibleCells) + sb.Append(FormattableString.Invariant($" 0x{id & 0xFFFFu:X4}")); + _out.WriteLine(sb.ToString()); + _out.WriteLine(FormattableString.Invariant( + $"porch 0x017A admitted: {lookIn.OrderedVisibleCells.Contains(0xA9B4017Au)}")); + } + [Fact] public void Diagnostic_DumpOutstageCandidateSetups() { From 49cffe65656c83e5b0b0a6365d2fbc4874e43cea Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 20:55:13 +0200 Subject: [PATCH 13/65] close #131 + #132 (user-gated) + CLAUDE.md current-state ledger refresh #131: 'Ok now it works' (fix 4, d208002). #132: both sides gated. #124 closed earlier same session. CLAUDE.md open ledger now: #108-residual, #116, #127, #125 sticky-drop debt. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 8 ++++---- docs/ISSUES.md | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 58354787..ee0c8517 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,10 +111,10 @@ movement queries. **Currently working toward: M1.5 — Indoor world feels right** (M1 — Walkable + clickable world — landed 2026-05-16 via Phase B.6). The holistic building-render port (Option A: ONE `DrawInside(viewer_cell)`, -no inside/outside branch; BR-2..BR-7/T1..T6) is SHIPPED and user-gated, -as are the 2026-06-12 closes: #119/#128 tower stairs, #112 cottage -transparency. Open render/physics ledger: #113 re-check, #124, #129, -#130, #108-residual, #116, #127 (leads in ISSUES.md). Keep this +no inside/outside branch; BR-2..BR-7/T1..T6) is SHIPPED and user-gated. +2026-06-12 closes: #119/#128, #112, #113, #124 (interior-root look-ins), +#129, #130, #131, #132, UN-2. Open render/physics ledger: #108-residual, +#116, #127, #125 sticky-drop debt (leads in ISSUES.md). Keep this paragraph ≤5 lines + pointers — detail lives in the docs below, NOT here. For canonical state, read in this order: diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 86c51813..13e7dab6 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -4583,7 +4583,7 @@ or distance. ## #131 — Portal swirl invisible when viewed from inside a building through the doorway -**Status:** FIX SHIPPED — awaiting user visual gate +**Status:** CLOSED (user-gated 2026-06-12 night: "Ok now it works" — fix 4, `d208002`) **Severity:** MEDIUM (portals are landmark objects; the through-door view is common) **Filed:** 2026-06-12 (user report, #124 gate session) **Component:** render — UNATTACHED emitters have no pass under interior roots @@ -4666,7 +4666,7 @@ swirl renders through the door. ## #132 — Candle flame disappears when the through-opening background is behind it -**Status:** FIX SHIPPED (shared mechanism with #131 fix 2) — awaiting user visual gate +**Status:** CLOSED (user-gated 2026-06-12: indoors "now the candle light is visible", outdoors "Candle works now") **Severity:** LOW-MEDIUM **Filed:** 2026-06-12 (user report, #124 gate session) **Component:** render — slice particles drawn before the #124 look-ins From 5622d56fe81228faa79523f16f8c6efc10e5ca4d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 21:02:10 +0200 Subject: [PATCH 14/65] handoff: night session - 9 user-gated closes; NEXT = #108-residual (queue, the #131 entity-first lesson, apparatus, pickup prompt) Co-Authored-By: Claude Fable 5 --- ...night-session-handoff-108-residual-next.md | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 docs/research/2026-06-12-night-session-handoff-108-residual-next.md diff --git a/docs/research/2026-06-12-night-session-handoff-108-residual-next.md b/docs/research/2026-06-12-night-session-handoff-108-residual-next.md new file mode 100644 index 00000000..ec9f6757 --- /dev/null +++ b/docs/research/2026-06-12-night-session-handoff-108-residual-next.md @@ -0,0 +1,87 @@ +# Night-session handoff (2026-06-12): nine closes shipped; NEXT = #108-residual + +**Branch state:** `claude/thirsty-goldberg-51bb9b`, pushed to BOTH remotes at +`49cffe6`. Suites green at every commit: App 261+1skip / Core 1439+2skips / +UI 420 / Net 294. CLAUDE.md "Current state" + the render digest +(`claude-memory/project_render_pipeline_digest.md`) are refreshed to this +truth — orient there first. + +## 1. What this session closed (all user-gated; do NOT re-litigate) + +| Closed | Root cause | Commits | +|---|---|---| +| **#130** doorway top-edge strip | TWO stacked causes: scissor box `Floor(origin)+Ceiling(size)` under-covers top/right (sub-pixel, `NdcScissorRect`); THE strip = the +0.02 m shell draw-lift missing from draw-space portal consumers post-f35cb8b (6.7 px @2.4 m, measured) | `6c4b6d6`, `5135066` (AP-32 row added) | +| **#129** doors leak through terrain at ~a landblock | constant 0.0005 NDC punch bias spans ~190 m of eye depth at distance; capped to 0.5 m eye-space (`MarkBiasNdc`) | `4ba7148` (AD-18 updated) | +| **#113** hill-cottage phantom stairs | dead via `2163308` (cache cross-serving) — re-gate confirmed | — | +| **#124** far-building back walls through openings | interior-root look-ins ported as a LANDSCAPE-STAGE sub-pass (decomp: LScape::draw runs FIRST in DrawCells' outside branch, pc:432719, pre-clear/pre-seal; seeds clip vs the INSTALLED view → `BuildFromExterior(seedRegion:)`; punch-all-then-draw). NEVER merge look-ins into the main frame (post-clear seal z-kill) | `77cef4c` (AP-33 added) | +| **#132** candle flame vs through-opening background | slice particles drew BEFORE the look-ins / merged interiors (no depth self-protection) — the FlushAlphaList deferral ported as the two-phase slice split + outdoor post-frame attached pass | `20d1730`, `87afbc0` (AP-34 added) | +| **#131** portal swirl missing through doorways | FOUR layers (see lesson below); final: the portal is a SERVER object inside the hall's PORCH cell (look-in cell) → partition.Dynamics → dynamics-last culls it (no look-in cells in the main cone) + post-seal z-fail. Fix: `DrawBuildingLookIns` draws look-in-cell dynamics + emitters (retail nested DrawCells/`DrawObjCellForDummies`) | `1d3f9a8`, `47f32cd`, `d208002` | +| **UN-2** GetMaxSpeed ×4 contradiction | the implementation was retail-correct; BN pseudo-C drops x87 fmuls — byte-verified (3× `fmul [0x7C8918]`=4.0f); doc rewritten, weenie-null default aligned to literal 1.0; row deleted | `0cb97aa` (verifier `tools/verify_un2_fmul.py`) | + +## 2. THE #131 LESSON (cost: 4 fix iterations) + +**Identify the ENTITY before theorizing about draw passes.** Three +real-but-adjacent fixes shipped before the elimination chain (teleport pCell +flip → owner cell; headless replay → flood admits it; partition routing → +exactly one possible drop site) forced the answer. Two tools that would have +shortened it to one iteration: +- **The pick line**: left-click prints `[B.4b] pick guid=… name=…` + + `[B.7] pick-info … setup=…` — names any clickable object in the log. +- **The teleport/pCell flip**: walking onto/into a thing prints its cell. +Both need zero new code. The register also already KNEW the answer (AP-33's +"look-in DYNAMICS are not drawn — deferred") — scan-the-register-on-symptom +applies to rows YOU wrote hours earlier. + +## 3. NEXT (the queue to the M1.5 → M2 boundary) + +1. **#108-residual — cellar-ascent grass window (NEXT, desk-first).** + Climbing out of a cellar, grass covers the exit door until the eye pops + above grade. Punch/seal exonerated; it is MEMBERSHIP/VIEWER-side (which + cell the camera resolves while the eye is below grade). Apparatus + designed: a VERTICAL exit-walk-harness variant (HouseExitWalkReplayTests + machinery driving the camera up cellar stairs, watching viewer-cell + resolution per step). Read the physics digest + ISSUES #108 before + starting. User needed only for the final cellar gate. +2. **#127 — distant-building admission churn** (flood size oscillates ±1–3 + cells at mm eye deltas; suspect list includes the PortalBounds frustum + pre-gate — machinery #124 now reuses for interior roots). +3. **#116 — slide-response family** (physics, oracle-first: one cdb session). +4. **#125 sticky-drop debt** — failed texture uploads never retried + (session-sticky invisible meshes); robustness, no visual gate. + +## 4. Apparatus added this session (all env-gated, kept) + +| Tool | How | For | +|---|---|---| +| `[outstage]`/`[outstage-pt]`/`[outstage-own]` | `ACDREAM_PROBE_OUTSTAGE=1` (+`ACDREAM_DUMP_ENTITY=` doubles as the owner watchlist) | outside-stage dynamics routing/cone verdicts; scene-particle owner matching | +| `Issue130DoorwayStripTests` | App.Tests | aperture-vs-gate top-edge gap in DRAWN (lifted) space; the lift-seam sensitivity pin | +| `NdcScissorRectTests` / `Issue129PunchBiasTests` | App.Tests | scissor containment; punch-bias eye-span cap | +| `Issue124LookInSeedRegionTests` | App.Tests | seedRegion semantics at the real corner-building door | +| `Issue131SetupProbeTests` | App.Tests | dat setup dumps + the porch-admission replay of a captured frame | +| `tools/verify_un2_fmul.py` | `py` | re-derive the GetMaxSpeed ×4.0 byte proof | + +## 5. Paste-ready pickup prompt + +``` +Pick up acdream as a SENIOR 3D ENGINE DEVELOPER on #108-residual (the +cellar-ascent grass window). Branch claude/thirsty-goldberg-51bb9b == +pushed both remotes at 49cffe6. Read FIRST: CLAUDE.md "Current state", +docs/research/2026-06-12-night-session-handoff-108-residual-next.md (THE +handoff), then BOTH digests (render + physics; DO-NOT-RETRY tables apply). + +WORK ORDER: +1. #108-residual — eye-below-grade membership at cellar exits. Build the + VERTICAL exit-walk harness variant (HouseExitWalkReplayTests machinery, + a cellar staircase fixture), watch viewer-cell resolution per step while + the eye is below terrain grade; pin where the resolver demotes to + outdoor/terrain. Punch/seal are exonerated — do NOT touch them. +2. Then #127 (admission churn; PortalBounds pre-gate suspect), #116 + (slide-response, oracle-first cdb), #125 sticky-drop debt. +Closing this list ≈ the M1.5 → M2 boundary (M2 = kill a drudge). + +The user's reports are AXIOMS. Visual gates are the acceptance tests. +Suites green per commit: App 261+1skip / Core 1439+2skip / UI 420 / +Net 294. Register discipline: new deviation = same-commit row. For any +object-specific render bug: IDENTIFY THE ENTITY FIRST (the pick line +[B.4b] names clicked objects; pCell flips name cells) — the #131 lesson. +``` From bf965000dac74b7d9c207227be825d3035a97f5b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 21:21:51 +0200 Subject: [PATCH 15/65] handoff: add the M1.5 dungeon-demo exit gate to the next-session work order Co-Authored-By: Claude Fable 5 --- ...26-06-12-night-session-handoff-108-residual-next.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/research/2026-06-12-night-session-handoff-108-residual-next.md b/docs/research/2026-06-12-night-session-handoff-108-residual-next.md index ec9f6757..782aab65 100644 --- a/docs/research/2026-06-12-night-session-handoff-108-residual-next.md +++ b/docs/research/2026-06-12-night-session-handoff-108-residual-next.md @@ -77,7 +77,15 @@ WORK ORDER: outdoor/terrain. Punch/seal are exonerated — do NOT touch them. 2. Then #127 (admission churn; PortalBounds pre-gate suspect), #116 (slide-response, oracle-first cdb), #125 sticky-drop debt. -Closing this list ≈ the M1.5 → M2 boundary (M2 = kill a drudge). +3. When the ledger clears: run the M1.5 DUNGEON DEMO as the milestone + exit gate (milestones doc: enter any dungeon via portal, 3-5 rooms, + walls block / stairs work / lighting correct / transitions smooth). + The old blocker #95 died with the Option A rewrite (the ACME BFS it + lived in was deleted in T4); the portal entry flow is field-tested + (the 2026-06-12 accidental teleport). Dungeon-specific findings + (likely A7 lighting items) get fixed inside M1.5; a clean demo lands + M1.5 -> update the milestones doc + CLAUDE.md and start M2 (kill a + drudge; first port target per the research memos: CombatMath). The user's reports are AXIOMS. Visual gates are the acceptance tests. Suites green per commit: App 261+1skip / Core 1439+2skip / UI 420 / From 007af1391cc4ae705fd1f1d59d2cbaaa668cc90b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 22:05:06 +0200 Subject: [PATCH 16/65] #108-residual apparatus: vertical cellar-ascent viewer harness - membership/viewer layer EXONERATED The handoff's 'eye-below-grade membership demote' diagnosis is REFUTED. The harness drives the production stack headlessly per step of the A9B4 corner-building cellar ascent (0x0174 -> 0x0175 -> 0x0171, path fitted from the cellar-up live captures): FindCellList on the foot-sphere center for the player pick + the PhysicsCameraCollisionProbe SweepEye chain mirrored verbatim (AdjustPosition at pivot -> ResolveWithTransition IsViewer|PathClipped|FreeRotate|PerfectClip -> both fallbacks) with per-step branch attribution. Result: 0 outdoor/null viewer resolutions while the eye is below grade, 0 sweep failures, 0 fallback branches, across boom distance {2.61, 5} x damping lag {0, 0.3 m}. The viewer enters the main-floor room at eye z 94.01 - exactly as the head pops above grade (the stairwell portal sits AT grade), matching the user's report wording. The root is INTERIOR for the whole grass window; #108-residual is render-side (fix in the next commit). Tests stay as the healthy-layer characterization pin. Co-Authored-By: Claude Fable 5 --- .../Issue108CellarAscentViewerReplayTests.cs | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Physics/Issue108CellarAscentViewerReplayTests.cs diff --git a/tests/AcDream.Core.Tests/Physics/Issue108CellarAscentViewerReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue108CellarAscentViewerReplayTests.cs new file mode 100644 index 00000000..abd691a8 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue108CellarAscentViewerReplayTests.cs @@ -0,0 +1,344 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Tests.Conformance; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #108-residual vertical exit-walk harness (2026-06-12): the cellar-ascent +/// grass window. Climbing out of the Holtburg corner-building cellar +/// (0xA9B40174 room, floor z≈90 → 0x0175 staircase/lip → 0x0171 main floor at +/// z=94 = outdoor grade), the upstairs exit door is covered with grass until +/// the eye pops above grade. Punch/seal are exonerated (BR-2 experiment + +/// #117); the grass requires the frame to render through the OUTDOOR root — +/// i.e. the VIEWER-CELL resolution demotes to outdoor/null while the eye is +/// still below terrain grade inside the stairwell. +/// +/// This harness drives the PRODUCTION viewer-resolution stack headlessly per +/// step of a kinematic ascent (the #118 HouseExitWalkReplayTests pattern, +/// turned vertical): +/// player cell — CellTransit.FindCellList on the foot-sphere center (the +/// production controller pick), +/// viewer cell — the PhysicsCameraCollisionProbe.SweepEye chain mirrored +/// verbatim (CameraCornerSealReplayTests provenance): +/// AdjustPosition at the head pivot → ResolveWithTransition +/// (IsViewer|PathClipped|FreeRotate|PerfectClip, 0.3 m +/// viewer_sphere) → fallback 1 AdjustPosition at the sought +/// eye → fallback 2 (player_pos, cell 0). +/// Each step records WHICH branch produced the viewer cell, so a demote +/// self-attributes: +/// A. sweep Ok=false → fallback chain (AdjustPosition's SeenOutside +/// fall-through is an XY-only grid snap — no Z test — so an in-dirt +/// below-grade eye can return an OUTDOOR cell with found=true); +/// B. sweep end-cell pick demotes (exterior-portal straddle + containment +/// miss at the stopped eye); +/// C. the start-cell AdjustPosition at the pivot demotes; +/// D. all healthy here → the bug is upstream (App camera damping / +/// GameWindow TryGetCell consumption). +/// +/// Ascent path: fitted from the live captures (cellar-up-capture*.jsonl band +/// centroids, analyze_108_stairline.py): stairs at x≈153.9 ascending +Y, +/// z = 90.0 (y≤5.7) → 0.836·(y−5.73)+90.25 (stairs) → lip 93.25→94 over +/// y 9.3→10.4 → main floor 94.0. The boom (retail defaults: distance 2.61, +/// pitch 0.291, pivot feet+1.5) trails SOUTH into the stairwell — mid-stairs +/// the desired eye sits beyond the cellar's south wall (y≈4.87) and above its +/// ceiling: in no-cell dirt below grade. Stub terrain (−1000) — the membership +/// pick never reads terrain height (XY-column only), which is exactly the +/// mechanism under test. +/// +/// ── RESULT (2026-06-12): the MEMBERSHIP/VIEWER LAYER IS EXONERATED ────── +/// 0 grass-window steps, 0 sweep failures, 0 fallback branches across boom +/// distance {2.61, 5.0} × damping lag {0, 0.3 m}. The viewer resolves +/// 0x0174 → 0x0175 (eye z 93.65, below grade) → 0x0171 at eye z 94.01 — +/// the viewer enters the main-floor room EXACTLY as the head pops above +/// grade (the stairwell portal sits at grade), matching the user's wording. +/// The handoff's "it is MEMBERSHIP/VIEWER-side" diagnosis is therefore +/// REFUTED for the current pipeline; #108-residual is RENDER-side: the +/// landscape slice clips terrain by 2D NDC planes only ((nx,ny,0,dw) — +/// ClipFrame.cs:178, terrain_modern.vert:173), so terrain BETWEEN the eye +/// and the exit portal (the grade sheet at z≈94, which from a below-grade +/// eye projects into the aperture band at y 9.8–17) paints the doorway. +/// These tests stay as the characterization pin for the healthy layer. +/// +public class Issue108CellarAscentViewerReplayTests +{ + private readonly ITestOutputHelper _out; + public Issue108CellarAscentViewerReplayTests(ITestOutputHelper output) => _out = output; + + private const float ViewerSphereRadius = 0.3f; // retail viewer_sphere (acclient :93314) + private const float PivotHeight = 1.5f; // RetailChaseCamera.PivotHeight + private const float FootRadius = 0.48f; // player foot sphere + private const float BoomDistance = 2.61f; // retail viewer_offset length + private const float BoomPitch = 0.291f; // retail default pitch (16.7°) + private const float GradeZ = 94.0f; // cottage floor == door sill ≈ outdoor terrain grade + + private const uint Lb = 0xA9B40000u; // ConformanceDats.HoltburgLandblock + private const uint CellarRoom = Lb | 0x0174u; // floor z≈90.0 + private const uint MainFloor = Lb | 0x0171u; // z=94.0 + + // ── fixture ───────────────────────────────────────────────────────── + + private static (PhysicsEngine engine, PhysicsDataCache cache, + Dictionary envCells) + BuildEngine(DatCollection dats) + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + var envCells = new Dictionary(); + + // Full A9B4 interior set (Issue112MembershipTests.LoadLandblockInteriors + // pattern) — the ascent's pick walk may reach cells outside the corner + // building's 0x016F-0x0175 range. + for (uint low = 0x0100u; low <= 0x01FFu; low++) + { + try { envCells[Lb | low] = ConformanceDats.LoadEnvCell(dats, cache, Lb | low); } + catch { } + } + + // Buildings exactly as production registers them (Issue112MembershipTests. + // RegisterBuildings provenance): portals → BldPortalInfo with sign-extended + // OtherPortalId; landcell id from the building Frame.Origin (retail + // row-major grid). + var lbInfo = dats.Get(Lb | 0xFFFEu); + Assert.NotNull(lbInfo); + foreach (var building in lbInfo!.Buildings) + { + if (building.Portals.Count == 0) continue; + var portals = new List(building.Portals.Count); + foreach (var bp in building.Portals) + portals.Add(new BldPortalInfo( + otherCellId: Lb | (uint)bp.OtherCellId, + otherPortalId: unchecked((short)bp.OtherPortalId), + flags: (ushort)bp.Flags)); + var transform = + Matrix4x4.CreateFromQuaternion(building.Frame.Orientation) * + Matrix4x4.CreateTranslation(building.Frame.Origin); + int gridX = (int)(building.Frame.Origin.X / 24f); + int gridY = (int)(building.Frame.Origin.Y / 24f); + uint landcellLow = (uint)(gridX * 8 + gridY + 1); + cache.CacheBuilding(Lb | landcellLow, portals, transform); + } + + var heights = new byte[81]; + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = -1000f; + engine.AddLandblock(Lb, new TerrainSurface(heights, heightTable), + Array.Empty(), Array.Empty(), 0f, 0f); + + return (engine, cache, envCells); + } + + // ── the probe mirror (PhysicsCameraCollisionProbe.SweepEye, verbatim) ── + + private enum ViewerBranch { Sweep, AdjustFallback, NullFallback } + + private sealed record ViewerResolve( + Vector3 Eye, uint ViewerCellId, ViewerBranch Branch, + uint StartCell, bool PivotAdjustFound, ResolveResult Sweep); + + private static ViewerResolve ResolveViewer( + PhysicsEngine engine, Vector3 pivot, Vector3 desiredEye, uint cellId, Vector3 playerPos) + { + // update_viewer (pc:92775): no player cell → snap to player, viewer_cell null. + if (cellId == 0u) + return new ViewerResolve(playerPos, 0u, ViewerBranch.NullFallback, 0u, false, default); + + uint startCell = cellId; + bool pivotFound = false; + if ((cellId & 0xFFFFu) >= 0x0100u) + { + var (pivotCell, found) = engine.AdjustPosition(cellId, pivot); + pivotFound = found; + if (found) startCell = pivotCell; + } + + Vector3 begin = pivot - new Vector3(0f, 0f, ViewerSphereRadius); + Vector3 end = desiredEye - new Vector3(0f, 0f, ViewerSphereRadius); + + var r = engine.ResolveWithTransition( + currentPos: begin, + targetPos: end, + cellId: startCell, + sphereRadius: ViewerSphereRadius, + sphereHeight: 0f, + stepUpHeight: 0f, + stepDownHeight: 0f, + isOnGround: false, + body: null, + moverFlags: ObjectInfoState.IsViewer | ObjectInfoState.PathClipped + | ObjectInfoState.FreeRotate | ObjectInfoState.PerfectClip, + movingEntityId: 0); + + Vector3 eye = r.Position + new Vector3(0f, 0f, ViewerSphereRadius); + if (r.Ok) + return new ViewerResolve(eye, r.CellId, ViewerBranch.Sweep, startCell, pivotFound, r); + + var (eyeCell, eyeFound) = engine.AdjustPosition(cellId, desiredEye); + if (eyeFound) + return new ViewerResolve(desiredEye, eyeCell, ViewerBranch.AdjustFallback, startCell, pivotFound, r); + + return new ViewerResolve(playerPos, 0u, ViewerBranch.NullFallback, startCell, pivotFound, r); + } + + // ── the ascent ────────────────────────────────────────────────────── + + /// Stair-line feet Z for a path y (fitted from the capture bands). + private static float FeetZ(float y) + { + if (y < 5.73f) return 90.0f; + if (y < 9.30f) return MathF.Min(90.25f + 0.836f * (y - 5.73f), 93.25f); + if (y < 10.40f) return 93.25f + (y - 9.30f) * (0.75f / 1.10f); + return 94.0f; + } + + private sealed record Step( + int Index, Vector3 Feet, uint PlayerCell, + ViewerResolve Viewer, uint EyeContainedIn, bool EyeBelowGrade) + { + public bool ViewerOutdoorOrNull => + Viewer.ViewerCellId == 0u || (Viewer.ViewerCellId & 0xFFFFu) < 0x0100u; + } + + private List? RunAscent(float boomDistance, float pathLagMeters) + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return null; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var (engine, _, envCells) = BuildEngine(dats); + + const float yStart = 5.2f, yEnd = 16.0f; + const float stepLen = 0.02f; // 2 cm/frame ≈ 1.2 m/s at 60 Hz + var fwd = new Vector3(0f, 1f, 0f); // facing up the stairs / at the exit door + float cosP = MathF.Cos(BoomPitch), sinP = MathF.Sin(BoomPitch); + + // Stairs run at x≈153.9; past the lip the real walk line bends to the + // exit-door approach at x≈155 (corner-seal capture S1: player + // (154.93, 16.45)) — walking straight north at 153.9 ends in the wall + // beside the 0x0170 doorway, which a live player cannot do. + static float FeetX(float y) => + y <= 10.4f ? 153.9f + : y >= 14.0f ? 155.0f + : 153.9f + (y - 10.4f) / (14.0f - 10.4f) * (155.0f - 153.9f); + + var steps = new List(); + uint playerCell = CellarRoom; + int count = (int)MathF.Round((yEnd - yStart) / stepLen); + + for (int i = 0; i <= count; i++) + { + float y = yStart + i * stepLen; + var feet = new Vector3(FeetX(y), y, FeetZ(y)); + + // production controller pick: foot-sphere CENTER, seeded with the carried cell + playerCell = CellTransit.FindCellList( + engine.DataCache!, feet + new Vector3(0f, 0f, FootRadius), FootRadius, playerCell); + + // boom target — optionally computed from a lagged path point to model the + // exponential damping trail (≈0.27 m at climb speed; 0 = converged target) + float yBoom = MathF.Max(yStart, y - pathLagMeters); + var boomFeet = new Vector3(FeetX(yBoom), yBoom, FeetZ(yBoom)); + var pivot = feet + new Vector3(0f, 0f, PivotHeight); + var boomPivot = boomFeet + new Vector3(0f, 0f, PivotHeight); + var desiredEye = boomPivot - fwd * (boomDistance * cosP) + + new Vector3(0f, 0f, boomDistance * sinP); + + var viewer = ResolveViewer(engine, pivot, desiredEye, playerCell, feet); + + uint containedIn = 0u; + foreach (var (id, env) in envCells) + if (env.PointInCell(viewer.Eye)) { containedIn = id; break; } + + steps.Add(new Step(i, feet, playerCell, viewer, + containedIn, viewer.Eye.Z < GradeZ - 0.05f)); + } + + return steps; + } + + private void DumpStep(Step s) + { + var v = s.Viewer; + string line = FormattableString.Invariant( + $"step={s.Index,3} feet=({s.Feet.X:F2},{s.Feet.Y:F2},{s.Feet.Z:F2}) pCell=0x{s.PlayerCell & 0xFFFFu:X4} start=0x{v.StartCell & 0xFFFFu:X4}{(v.PivotAdjustFound ? "" : "!")} branch={v.Branch} ok={v.Sweep.Ok} eye=({v.Eye.X:F2},{v.Eye.Y:F2},{v.Eye.Z:F2}) viewer=0x{v.ViewerCellId & 0xFFFFu:X4} eyeIn=0x{s.EyeContainedIn & 0xFFFFu:X4} belowGrade={(s.EyeBelowGrade ? "Y" : "n")}"); + if (s.EyeBelowGrade && s.ViewerOutdoorOrNull) line += " << GRASS-WINDOW"; + _out.WriteLine(line); + } + + // ── diagnostics + pins ────────────────────────────────────────────── + + /// + /// Full per-step table of the ascent at retail boom defaults (converged + /// boom, no lag). Read this first — the GRASS-WINDOW marks name the steps + /// where the production stack resolves an outdoor/null viewer with the eye + /// below grade, and the branch column attributes the demote site. + /// + [Fact] + public void Diagnostic_CellarAscent_PerStepTable() + { + var steps = RunAscent(BoomDistance, pathLagMeters: 0f); + if (steps is null) return; + + uint lastPlayer = 0; uint lastViewer = 0xFFFFFFFFu; var lastBranch = (ViewerBranch)(-1); + int suspicious = 0; + foreach (var s in steps) + { + bool grass = s.EyeBelowGrade && s.ViewerOutdoorOrNull; + if (grass) suspicious++; + if (s.PlayerCell != lastPlayer || s.Viewer.ViewerCellId != lastViewer + || s.Viewer.Branch != lastBranch || grass || s.Index % 50 == 0) + DumpStep(s); + lastPlayer = s.PlayerCell; lastViewer = s.Viewer.ViewerCellId; lastBranch = s.Viewer.Branch; + } + _out.WriteLine(FormattableString.Invariant( + $"--- {suspicious}/{steps.Count} steps in the grass window (viewer outdoor/null while eye below grade) ---")); + } + + /// Boom-distance + damping-lag sweep: how wide is the window across poses? + [Fact] + public void Diagnostic_CellarAscent_PoseSweep() + { + foreach (float dist in new[] { 2.61f, 5.0f }) + foreach (float lag in new[] { 0f, 0.30f }) + { + var steps = RunAscent(dist, lag); + if (steps is null) return; + int grass = steps.FindAll(s => s.EyeBelowGrade && s.ViewerOutdoorOrNull).Count; + int okFalse = steps.FindAll(s => !s.Viewer.Sweep.Ok).Count; + int fb = steps.FindAll(s => s.Viewer.Branch != ViewerBranch.Sweep).Count; + _out.WriteLine(FormattableString.Invariant( + $"dist={dist:F2} lag={lag:F2}: grassWindow={grass}/{steps.Count} sweepOkFalse={okFalse} fallbackBranch={fb}")); + } + } + + /// + /// THE PIN: while the eye is below terrain grade on the cellar ascent, the + /// viewer must resolve INTERIOR — an outdoor/null viewer cell roots the + /// frame at the landscape and sweeps grass across the exit door (#108). + /// Retail's viewer rides the stairwell cells here (the cellar camera works + /// in retail); below grade inside the building footprint there is no + /// legitimate outdoor viewer. + /// + [Fact] + public void CellarAscent_ViewerStaysInterior_WhileEyeBelowGrade() + { + var steps = RunAscent(BoomDistance, pathLagMeters: 0f); + if (steps is null) return; + + var failures = steps.FindAll(s => s.EyeBelowGrade && s.ViewerOutdoorOrNull); + if (failures.Count > 0) + { + _out.WriteLine($"--- {failures.Count} grass-window steps ---"); + foreach (var s in failures) DumpStep(s); + } + Assert.True(failures.Count == 0, + $"{failures.Count}/{steps.Count} ascent steps resolve an outdoor/null viewer cell while the eye " + + "is below grade — the #108 grass window (see output for the branch attribution)"); + } +} From 96a425a9a57336e9475851932edcc53a2c261e0e Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 22:05:31 +0200 Subject: [PATCH 17/65] fix #108-residual (root cause): terrain drew DOUBLE-SIDED - port retail landPolysDraw eye-side gate as terrain backface cull The cellar-ascent grass window was the UNDERSIDE of the z~94 grade sheet. Retail terrain is single-sided: ACRender::landPolysDraw (0x006b7040) draws each land triangle ONLY when the camera is on the POSITIVE (upper) side of its plane (Plane::which_side2 vs Render::FrameCurrent, zFightTerrainAdjust bias) - a below-grade eye gets NO terrain, so retail shows sky through the cellar door. We inherited WB's frame-global cull DISABLE (WB GameScene.cs:841 - an editor camera goes underground by design) and TerrainModernRenderer.Draw set no cull state of its own -> terrain rasterized both sides. From a below-grade eye every aperture sight-ray RISES, so the only 'terrain' it can see is the grade sheet's underside - which painted the exit-door aperture (the landscape slice's 2D NDC clip planes (nx,ny,0,dw) have no depth axis and cannot exclude between-eye-and-portal geometry) and slid off the door exactly as the eye crossed grade. Membership/viewer was exonerated by the harness in the previous commit. Fix: TerrainModernRenderer.Draw owns its cull state (the 7th self-contained-GL-state instance): Enable(CullFace) + CullFace(Back) + FrontFace(Ccw), set -> draw -> restore the frame-global CW + cull-off baseline. GL backface culling evaluates retail's per-triangle eye-side predicate at rasterization; no shader change. Pins: - LandblockMeshTests.Build_AllTriangles_WindCounterClockwiseInWorldXY: every emitted triangle CCW in world XY across both FSplitNESW split directions - the winding invariant culling depends on. - TerrainCullOrientationTests: under the production camera convention (LookAt up=+Z, Numerics perspective) an up-facing triangle winds CCW in window space from above (kept) and CW from below (culled) - guards FrontFace inversion, which would blank terrain from above. Oracle note: retail's through-portal clip has NO portal-face near plane (PView::GetClip / Render::set_view install edge planes only); nearer- than-portal exclusion comes from the eye-side cull + cell-level admission. No register row: this PORTS the retail mechanism, retiring an undocumented WB-heritage deviation. Gate pending: cellar climb (grass window gone) + outdoor sanity glance (terrain intact from above). Suites: App 263+1skip / Core 1443+2skip / UI 420 / Net 294. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 58 ++++++++----- .../Rendering/TerrainModernRenderer.cs | 24 ++++++ .../Rendering/TerrainCullOrientationTests.cs | 82 +++++++++++++++++++ .../Terrain/LandblockMeshTests.cs | 35 ++++++++ 4 files changed, 180 insertions(+), 19 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/TerrainCullOrientationTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 13e7dab6..50ff2c3a 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -3701,27 +3701,47 @@ Unverified. The likely culprits, ranked by suspected probability: --- -## #108 — Cellar↔main-floor transition: terrain (grass) sweeps across the upstairs door opening — [REOPENED 2026-06-11 · narrowed residual] +## #108 — Cellar↔main-floor transition: terrain (grass) sweeps across the upstairs door opening — [FIX SHIPPED 2026-06-12 · awaiting cellar visual gate] -**Status:** REOPENED (narrowed) — the broad symptom is GONE (T5 + -re-gate #2: "Yes, but…"), but a residual remains in ONE window: during -the cellar ASCENT, while the eye is still below ground level, the -upstairs exit-door opening is covered with grass — "like the ground -level rose to the top of the door … as soon as my head pops up it falls -back to ground level" (user, re-gate 2026-06-11). The original -BR-2-era diagnosis stands: grass-sweep frames render through the -OUTDOOR root (membership/viewer-cell flips outdoor mid-cellar), and the -#117 depth-gated punch then correctly refuses to punch the aperture -where terrain depth is NEARER than the door fan (eye below grade ⇒ the -visible front-facing terrain can sit between the eye and the door in -depth). The punch must STAY depth-gated (DO-NOT-RETRY) — the fix is on -the membership/viewer side (why is the root outdoor while the eye is in -the cellar stairwell below grade?). Apparatus shape: a vertical -cellar-ascent variant of the #118 exit-walk harness (drive the eye up -the stair path; log root resolution + the punch's mark-pass outcome per -step). Prior history below. +**Status:** FIX SHIPPED (desk-pinned) — root cause found 2026-06-12; the +cellar-ascent visual gate is pending. + +**ROOT CAUSE (2026-06-12): terrain was drawn DOUBLE-SIDED — the grass was +the UNDERSIDE of the grade sheet.** Two steps: +1. The membership/viewer re-diagnosis below is **REFUTED** by the vertical + cellar-ascent harness (`Issue108CellarAscentViewerReplayTests`, dat-backed + A9B4 corner-building cellar 0x0174→0x0175→0x0171, production + FindCellList pick + the camera probe chain mirrored verbatim): 0 + outdoor/null viewer resolutions while the eye is below grade, 0 sweep + failures, 0 fallback branches across boom distance {2.61, 5} × damping + lag {0, 0.3}. The viewer enters 0x0171 at eye z 94.01 — exactly as the + head pops above grade (the stairwell portal sits at grade), matching the + user's wording. The root is INTERIOR the whole window. +2. Retail terrain is SINGLE-SIDED: `ACRender::landPolysDraw` (0x006b7040) + draws each land triangle ONLY when the camera is on the POSITIVE (upper) + side of its plane (`Plane::which_side2` vs `Render::FrameCurrent`). A + below-grade eye gets NO terrain — through the door retail shows sky. + WB renders the world with face culling DISABLED frame-globally (WB + `GameScene.cs:841` — editor heritage), and `TerrainModernRenderer.Draw` + set no cull state of its own → terrain drew double-sided. From a + below-grade eye every aperture sight-ray RISES, so the only "terrain" it + can see is the underside of the z≈94 grade sheet — which painted the + whole exit-door aperture (the landscape slice's 2D NDC clip planes + `(nx,ny,0,dw)` have no depth axis and cannot exclude it) and slid down + off the door exactly as the eye crossed grade. + **Fix: port the landPolysDraw eye-side gate as terrain backface culling** + — `TerrainModernRenderer.Draw` now owns Enable(CullFace) + Cull(Back) + + FrontFace(Ccw) (set→draw→restore; 7th instance of the self-contained-GL- + state rule). Pins: `LandblockMeshTests.Build_AllTriangles_WindCounter- + ClockwiseInWorldXY` (every emitted triangle CCW in world XY — cull-safe + winding) + `TerrainCullOrientationTests` (above-eye ⇒ CCW window winding + kept / below-eye ⇒ CW culled under the production camera convention). +**Gate:** climb out of the corner-building cellar — the grass window over +the exit door must be gone (sky/world through the door instead); plus a +general outdoor sanity glance (terrain intact from above — a wrong +FrontFace would blank it). **Severity:** MEDIUM -**Component:** ~~render / indoor PView~~ → **physics / membership** (cellar-transition root flip) +**Component:** render / terrain (single-sidedness) — membership/viewer EXONERATED During the cellar→main-floor ascent (Holtburg), the door opening visible on the main floor shows the outdoor GRASS texture sweeping over it — "like outdoor ground rising up from the diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.cs index d077a3e8..f14c98b1 100644 --- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs @@ -283,6 +283,27 @@ public sealed unsafe class TerrainModernRenderer : IDisposable // when wired, else the no-clip fallback (count 0 = ungated terrain). BindClipUboBinding2(); + // #108-residual: retail terrain is SINGLE-SIDED — ACRender::landPolysDraw + // (0x006b7040) draws each land triangle ONLY when the camera is on the + // POSITIVE (upper) side of its plane (Plane::which_side2 vs + // Render::FrameCurrent, zFightTerrainAdjust bias). GL backface culling + // evaluates the same per-triangle eye-side predicate at rasterization. + // LandblockMesh emits every triangle CCW in world XY seen from above + // (LandblockMeshTests winding pin), which the unified camera chain + // (CreateLookAt up=+Z + Numerics perspective) maps to CCW window + // winding from above / CW from below (TerrainCullOrientationTests) — + // so FrontFace(Ccw)+Cull(Back) keeps the top side and culls the + // underside. WB drew the whole world with culling DISABLED + // frame-globally (WB GameScene.cs:841 — an editor camera goes + // underground); inheriting that drew terrain DOUBLE-SIDED, and a + // below-grade eye (cellar ascent) saw the UNDERSIDE of the grade + // sheet through the exit-door aperture — the #108 grass window. + // Self-contained state per feedback_render_self_contained_gl_state; + // the frame-global CW + cull-off baseline is restored after the draw. + _gl.Enable(EnableCap.CullFace); + _gl.CullFace(TriangleFace.Back); + _gl.FrontFace(FrontFaceDirection.Ccw); + _gl.BindVertexArray(_globalVao); _gl.MemoryBarrier(MemoryBarrierMask.CommandBarrierBit); _gl.MultiDrawElementsIndirect( @@ -292,6 +313,9 @@ public sealed unsafe class TerrainModernRenderer : IDisposable (uint)sizeof(DrawElementsIndirectCommand)); _gl.BindVertexArray(0); _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); + + _gl.FrontFace(FrontFaceDirection.CW); + _gl.Disable(EnableCap.CullFace); } public void Dispose() diff --git a/tests/AcDream.App.Tests/Rendering/TerrainCullOrientationTests.cs b/tests/AcDream.App.Tests/Rendering/TerrainCullOrientationTests.cs new file mode 100644 index 00000000..3d0d3cd0 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/TerrainCullOrientationTests.cs @@ -0,0 +1,82 @@ +using System; +using System.Numerics; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// #108-residual orientation pin: TerrainModernRenderer culls terrain back +/// faces with FrontFace(Ccw) — the GL port of retail's single-sided terrain +/// (ACRender::landPolysDraw 0x006b7040: a land triangle draws ONLY when the +/// camera is on the POSITIVE side of its plane via Plane::which_side2). +/// +/// The FrontFace choice rests on one mapping fact: under the production +/// camera convention (Matrix4x4.CreateLookAt with up = world +Z, Numerics +/// CreatePerspectiveFieldOfView — RetailChaseCamera.cs:203 / :52), an +/// UP-FACING terrain triangle that LandblockMesh emits CCW in world XY +/// rasterizes +/// · CCW in NDC/window space when the eye is ABOVE its plane (kept), and +/// · CW when the eye is BELOW (culled — retail draws nothing there: from +/// a below-grade cellar eye the door aperture shows sky, never grass). +/// This test pins that mapping in pure CPU math so a projection-convention +/// change (handedness, Y-flip) can't silently invert the cull and either +/// resurrect the #108 grass window or cull terrain from above. +/// +public class TerrainCullOrientationTests +{ + // An up-facing triangle, CCW in world XY viewed from above — the exact + // emission convention pinned by LandblockMeshTests (crossZ > 0). + private static readonly Vector3[] Triangle = + { + new(-1f, 10f, 94f), + new( 1f, 10f, 94f), + new( 1f, 12f, 94f), + }; + + private static float NdcSignedArea2(Vector3 eye, Vector3 forward) + { + // The production camera shape: look-at with world-Z up + // (RetailChaseCamera.cs:203), Numerics perspective with the retail + // znear 0.1 (RetailChaseCamera.cs:52). + var view = Matrix4x4.CreateLookAt(eye, eye + forward, Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 0.1f, 5000f); + var viewProj = view * proj; + + Span ndc = stackalloc Vector2[3]; + for (int i = 0; i < 3; i++) + { + var c = Vector4.Transform(new Vector4(Triangle[i], 1f), viewProj); + Assert.True(c.W > 1e-3f, "test triangle must be in front of the eye"); + ndc[i] = new Vector2(c.X / c.W, c.Y / c.W); + } + + // Twice the signed area: > 0 = CCW in NDC (GL window space keeps the + // orientation — NDC y up maps to window y up, no flip). + return (ndc[1].X - ndc[0].X) * (ndc[2].Y - ndc[0].Y) + - (ndc[1].Y - ndc[0].Y) * (ndc[2].X - ndc[0].X); + } + + [Fact] + public void EyeAboveTerrainPlane_WindsCcw_FrontFaceKept() + { + // Eye above grade looking forward-down at the triangle (the normal + // outdoor view). Retail: which_side2 = POSITIVE → drawn. + float area = NdcSignedArea2(new Vector3(0f, 5f, 96.5f), new Vector3(0f, 1f, -0.3f)); + Assert.True(area > 0f, + $"above-plane eye must see the terrain triangle CCW (area2={area}) — " + + "FrontFace(Ccw)+Cull(Back) would otherwise cull terrain from above"); + } + + [Fact] + public void EyeBelowTerrainPlane_WindsCw_BackfaceCulled() + { + // Eye below grade (the cellar-stairwell window) looking up-forward at + // the underside. Retail: which_side2 = NEGATIVE → not drawn at all — + // the #108 grass that covered the exit door was exactly this + // underside rasterizing when culling was left disabled. + float area = NdcSignedArea2(new Vector3(0f, 5f, 92.5f), new Vector3(0f, 1f, 0.2f)); + Assert.True(area < 0f, + $"below-plane eye must see the terrain triangle CW (area2={area}) — " + + "it must backface-cull like retail's which_side2 eye-side gate"); + } +} diff --git a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs index ee123aee..efdce837 100644 --- a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs +++ b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs @@ -169,6 +169,41 @@ public class LandblockMeshTests Assert.True(cache.Count >= 2, $"Expected mix of palette codes, got {cache.Count}"); } + [Fact] + public void Build_AllTriangles_WindCounterClockwiseInWorldXY() + { + // #108-residual winding pin: TerrainModernRenderer enables backface + // culling with FrontFace(Ccw) — the GL port of retail's single-sided + // terrain (ACRender::landPolysDraw 0x006b7040 draws a land triangle + // only when the eye is on the POSITIVE side of its plane). That cull + // is only correct if EVERY emitted triangle winds the same way: + // counter-clockwise in world XY viewed from above (+Z toward the + // viewer), i.e. cross2D(v1-v0, v2-v0) > 0. Varied heights + several + // landblock coords exercise both FSplitNESW split directions across + // the 64 cells. A future emission-order change that flips any + // triangle would silently punch terrain holes under culling. + var block = BuildFlatLandBlock(); + for (int i = 0; i < 81; i++) + block.Height[i] = (byte)((i * 37) % 64); // varied, deterministic slopes + + foreach (var (lbx, lby) in new[] { (0u, 0u), (0xA9u, 0xB4u), (3u, 7u) }) + { + var cache = new Dictionary(); + var mesh = LandblockMesh.Build(block, lbx, lby, IdentityHeightTable, MakeContext(), cache); + + for (int t = 0; t < mesh.Indices.Length; t += 3) + { + var p0 = mesh.Vertices[mesh.Indices[t + 0]].Position; + var p1 = mesh.Vertices[mesh.Indices[t + 1]].Position; + var p2 = mesh.Vertices[mesh.Indices[t + 2]].Position; + float crossZ = (p1.X - p0.X) * (p2.Y - p0.Y) - (p1.Y - p0.Y) * (p2.X - p0.X); + Assert.True(crossZ > 0f, + $"lb=({lbx},{lby}) triangle {t / 3} winds CW in world XY (crossZ={crossZ}) — " + + "backface culling in TerrainModernRenderer would cull its TOP side"); + } + } + } + [Fact] public void Build_HeightmapPackedAsXMajor_NotYMajor() { From bf800677f038b914bbc4534b096d7231e10e51b0 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 09:54:58 +0200 Subject: [PATCH 18/65] close #108-residual (user-gated): terrain backface cull fixed the cellar grass window User visual gate 2026-06-12 ('Yes it is fixed.') - cellar climb clean, outdoor terrain intact from above. Flip ISSUES.md #108 to CLOSED. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 50ff2c3a..0683b4ae 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -3701,10 +3701,13 @@ Unverified. The likely culprits, ranked by suspected probability: --- -## #108 — Cellar↔main-floor transition: terrain (grass) sweeps across the upstairs door opening — [FIX SHIPPED 2026-06-12 · awaiting cellar visual gate] +## #108 — Cellar↔main-floor transition: terrain (grass) sweeps across the upstairs door opening — [CLOSED 2026-06-12 · user-gated] -**Status:** FIX SHIPPED (desk-pinned) — root cause found 2026-06-12; the -cellar-ascent visual gate is pending. +**Status:** CLOSED — user visual gate 2026-06-12 ("Yes it is fixed.") +after the terrain-backface-cull fix (`96a425a`). Root cause: terrain +drew double-sided; the grass was the grade sheet's underside seen from +a below-grade cellar eye. Membership/viewer EXONERATED by the vertical +cellar-ascent harness (`007af13`). **ROOT CAUSE (2026-06-12): terrain was drawn DOUBLE-SIDED — the grass was the UNDERSIDE of the grade sheet.** Two steps: From 4ad6fb9184ed93a0be75ffe5ceb848f1e6f5c281 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 10:03:33 +0200 Subject: [PATCH 19/65] close #127 (user-gated + desk pin): distant-building flood flap died with the W=0 clip port User re-gate 2026-06-12: ran past distant buildings, 'Seems to have been fixed' - no flicker/vanish. The per-building flood-admission bistability (#127, the building-flap mechanism behind the tower roof flap and #123 'buildings vanish when running past') is gone. Root: the bistable knife-edge admission died with the W=0 polyClipFinish clip port (987313a - the #119/#120 work that 'kills the knife-edge class everywhere') plus the #120 containment-rejection growth fix. The captured-pair evidence (tower-viewer-capture.log, 2026-06-11) PRE-dates all of those - it was that same near-eye knife edge, not a separate distant mechanism. Desk confirmation (both green at HEAD): - CapturedFlipPair_AdmissionIsStable: the original 4 cm flip pair is now |A|=|B| with zero diff across all FOVs and both pre-gate states. - DistantBuildingStrafe_NoAdmissionChurn (new regression pin): 0 admission churn across all 21 building groups x {10,30,60,120,190} m x 100 mm-step run-past strafes, both pre-gate states. A stable flood toggles each cell at most once over a monotone eye path; this asserts no cell toggles >=2x. ISSUES.md #127 -> CLOSED with the DO-NOT-RETRY note (no re-opening the BuildFromExterior seed gates for a flap symptom without a fresh HEAD repro - the captured-pair lead is dead). Render digest banner updated. Suites: App 264+1skip / Core 1443+2skip / UI 420 / Net 294. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 17 ++- .../Rendering/Issue127FloodFlipReplayTests.cs | 129 ++++++++++++++++++ 2 files changed, 144 insertions(+), 2 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 0683b4ae..15684e22 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -4460,8 +4460,21 @@ not raw terrain. Note the snap line even shows a candidate it rejected ## #127 — Per-building flood admissions are BISTABLE per frame under the outdoor root (the building-flap mechanism) -**Status:** OPEN — HIGH (the live mechanism behind the tower roof/edge -flap; almost certainly #123 and related flap reports) +**Status:** CLOSED 2026-06-12 — user re-gate ("Seems to have been +fixed" — ran past distant buildings, no flicker/vanish) + desk +confirmation. The bistable-admission mechanism died with the **W=0 +polyClipFinish clip port** (`987313a`, the #119/#120 work that +"kills the knife-edge class everywhere") plus the #120 containment- +rejection growth fix. NOTE the captured-pair evidence in +`tower-viewer-capture.log` predates all of those fixes — it was the +near-eye knife edge, the same class. Pins (both green at HEAD): +`Issue127FloodFlipReplayTests.CapturedFlipPair_AdmissionIsStable` +(the original 4 cm flip pair now |A|=|B|, zero diff, all FOVs, both +pre-gate states) + `DistantBuildingStrafe_NoAdmissionChurn` (the +regression pin: 0 churn across 21 building groups × {10,30,60,120,190} m +× 100 mm-steps run-past strafe, both pre-gate states). DO-NOT-RETRY: +do not re-open the BuildFromExterior seed gates for flap symptoms +without a FRESH repro at HEAD — the captured-pair lead is dead. **Filed:** 2026-06-11 (tower capture run) **Component:** render — BuildFromExterior seed admission / per-building flood stability diff --git a/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs b/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs index 581b5a52..72bd3385 100644 --- a/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs @@ -218,4 +218,133 @@ public class Issue127FloodFlipReplayTests Assert.Fail($"flood admission differs across the captured 4 cm pair (preGate={preGate}, fov={fov:F2}) — see output for the flipping cells"); } } + + // Centre of a building group's exit-portal AABB (world space). + private static (bool Has, Vector3 Center) PortalCenterFor(List group) + { + var (has, min, max) = PortalBoundsFor(group); + return (has, (min + max) * 0.5f); + } + + // Per-building admitted cells (this group only) at one (eye, gaze) — the + // production per-building flood + optional PortalBounds frustum pre-gate. + private static HashSet BuildingAdmits( + World w, List group, Vector3 eye, Matrix4x4 viewProj, + FrustumPlanes frustum, bool withPreGate) + { + var result = new HashSet(); + if (withPreGate) + { + var (has, min, max) = PortalBoundsFor(group); + if (has && !FrustumCuller.IsAabbVisible(frustum, min, max)) + return result; + } + var bf = PortalVisibilityBuilder.ConstructViewBuilding(group, eye, w.Lookup, viewProj); + foreach (uint id in bf.OrderedVisibleCells) + result.Add(id); + return result; + } + + /// + /// #127 distant-building churn detector. The captured 4 cm pair is now + /// stable (the near-eye W=0 clip port), but the user symptom is buildings + /// flickering when RUNNING PAST at a distance. This strafes the eye past + /// each loaded building at several distances in 1 mm steps with the gaze + /// fixed forward (the run-past geometry) and counts, per building cell, how + /// many times its admission toggles over the monotone strafe. A stable + /// flood toggles a cell AT MOST ONCE along a monotone eye path (it enters + /// or leaves the view a single time); >=2 toggles is churn — the building + /// flickers. preGate off vs on separates flood-math churn from the + /// PortalBounds frustum pre-gate. + /// + /// RESULT (2026-06-12, HEAD post-W=0-clip-port + #120 containment): ZERO + /// churning cases across all 21 building groups x {10,30,60,120,190} m x + /// 100 mm-steps, both preGate states. The near-eye knife-edge class the + /// W=0 polyClipFinish port (987313a) killed was the distant-building + /// flicker too; the user re-gate ("Seems to have been fixed") agrees. + /// Now the REGRESSION PIN — it asserts zero churn. + /// + [Fact] + public void DistantBuildingStrafe_NoAdmissionChurn() + { + var w = LoadWorld(); + if (w is null) return; + + const float fovY = MathF.PI / 3f; + const float eyeHeight = 1.8f; + const float strafeSpanM = 0.10f; // 10 cm strafe + const int strafeSteps = 100; // 1 mm/step + var distances = new[] { 10f, 30f, 60f, 120f, 190f }; + + int totalChurn = 0; + foreach (bool preGate in new[] { false, true }) + { + int worstToggles = 0; + string worstDesc = "(none)"; + int churningCases = 0; + + for (int gi = 0; gi < w.BuildingGroups.Count; gi++) + { + var group = w.BuildingGroups[gi]; + var (has, center) = PortalCenterFor(group); + if (!has) continue; + + foreach (float dist in distances) + { + // Eye south of the building at eye height, gaze NORTH toward + // the building centre; strafe along world +X (run-past). + var gaze = Vector3.Normalize(new Vector3(0f, 1f, -0.05f)); + var strafeDir = Vector3.Normalize(Vector3.Cross(Vector3.UnitZ, gaze)); // ~world +X + var eyeBase = new Vector3(center.X, center.Y - dist, center.Z + eyeHeight) + - strafeDir * (strafeSpanM * 0.5f); + + var toggleCount = new Dictionary(); + var prevIn = new Dictionary(); + for (int s = 0; s <= strafeSteps; s++) + { + var eye = eyeBase + strafeDir * (strafeSpanM * s / strafeSteps); + var vp = ViewProjFor(eye, gaze, fovY); + var frustum = FrustumPlanes.FromViewProjection(vp); + var admits = BuildingAdmits(w, group, eye, vp, frustum, preGate); + + var seen = new HashSet(admits); + foreach (uint id in seen) + { + bool wasIn = prevIn.TryGetValue(id, out var p) && p; + if (!wasIn && prevIn.ContainsKey(id)) + toggleCount[id] = toggleCount.GetValueOrDefault(id) + 1; + prevIn[id] = true; + } + foreach (var id in new List(prevIn.Keys)) + if (!seen.Contains(id)) + { + if (prevIn[id]) + toggleCount[id] = toggleCount.GetValueOrDefault(id) + 1; + prevIn[id] = false; + } + } + + foreach (var (id, toggles) in toggleCount) + { + if (toggles < 2) continue; // <=1 = clean enter/leave + churningCases++; + if (toggles > worstToggles) + { + worstToggles = toggles; + worstDesc = FormattableString.Invariant( + $"group#{gi} dist={dist:F0}m cell=0x{id:X8} toggles={toggles}"); + } + } + } + } + + _out.WriteLine(FormattableString.Invariant( + $"preGate={preGate}: churningCases={churningCases} worst={worstDesc} (worstToggles={worstToggles})")); + totalChurn += churningCases; + } + + Assert.True(totalChurn == 0, + $"{totalChurn} distant-building admission churn case(s) — a building's cells toggle >=2x " + + "over a monotone run-past strafe (the #127 flicker); see output for the worst building/distance/cell"); + } } From fbfecd20cad473c7beb952a089dab3c4e6a1b8b4 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 10:05:23 +0200 Subject: [PATCH 20/65] docs: CLAUDE.md current-state - #108-residual + #127 closed, ledger down to #116 + #125 Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ee0c8517..7892d6a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,10 +112,12 @@ movement queries. (M1 — Walkable + clickable world — landed 2026-05-16 via Phase B.6). The holistic building-render port (Option A: ONE `DrawInside(viewer_cell)`, no inside/outside branch; BR-2..BR-7/T1..T6) is SHIPPED and user-gated. -2026-06-12 closes: #119/#128, #112, #113, #124 (interior-root look-ins), -#129, #130, #131, #132, UN-2. Open render/physics ledger: #108-residual, -#116, #127, #125 sticky-drop debt (leads in ISSUES.md). Keep this -paragraph ≤5 lines + pointers — detail lives in the docs below, NOT here. +2026-06-12 closes: #119/#128, #112, #113, #124, #129, #130, #131, #132, +UN-2, **#108-residual** (terrain was double-sided — backface-cull port), +**#127** (distant-building flap died with the W=0 clip port). Open +render/physics ledger: #116 (slide-response, oracle-first), #125 +sticky-drop debt (leads in ISSUES.md). Keep this paragraph ≤5 lines + +pointers — detail lives in the docs below, NOT here. For canonical state, read in this order: - [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone From 35961f2039e0976650ec13356f3128753300d64b Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 10:11:34 +0200 Subject: [PATCH 21/65] #116 oracle desk read: slide-response leads pinned, needs a live cdb to finish Read both sides quote-for-quote (verified against source): our CSphere::slide_sphere port (TransitionTypes.cs:3054-3133) vs retail 0x00537440 (decomp 321403-321532). Findings: 1. Shape-1 (tick-22760 lost 3.57cm slide) is NOT the degenerate-offset guard - retail's guard only kills slides under ~1.4cm. The real divergence is the collision-normal SOURCE: our harness cn=(0,0,1) vs live cn=(0,+1,0). Strong lead: TransitionTypes.cs:3701-3702 defaults cn=Vector3.UnitZ when no valid normal. 2. Shape-2: retail's slide_sphere applies the slide IN-FRAME (add_offset_to_check_pos @0x53777e, return SLID) - our in-frame slide to Z=1.92 is likely retail-faithful and the D4 frame-1 hard-stop pin is the stale one (pending the first-airborne-frame plane state). 3. Candidate epsilon-squaring divergence: retail compares SQUARED quantities against 0.000199999995 (non-squared) while we compare against EpsilonSq=0.0002^2 - possibly 1e4x too small. Explains neither shape; DO NOT change without cdb (the test ah,5 branch polarity is the undecodable BN construct from the PosHitsSphere saga). No code change (oracle-first; the issue title calls for cdb and the BN decomp is provably ambiguous on the branch signs). ISSUES.md #116 + physics digest carry the leads + the scoped cdb plan. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 15684e22..aea90676 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -3968,6 +3968,51 @@ them byte-identical):** fixture as the acceptance pair. Do NOT patch the degenerate-offset guard ad hoc — the DO-NOT-RETRY table's slide entries (physics digest) apply. +**ORACLE DESK READ DONE (2026-06-12) — needs a LIVE cdb session to +finish.** Both sides quoted + verified against source (our +`CSphere::slide_sphere` port = `TransitionTypes.cs:3054-3133`; retail +`CSphere::slide_sphere` = decomp `0x00537440`, lines 321403-321532). +Three concrete leads, none safely fixable from the static BN decomp: + +1. **Shape-1 re-attributed — it is NOT the degenerate-offset guard + threshold.** Retail's guard kills slides under ~1.4 cm (`|offset|² < + 0.000199999995` at `0x537735`); the lost tick-22760 slide was 3.57 cm + (`X −0.0357`), well above it — retail would keep it too. The real + divergence is the COLLISION-NORMAL SOURCE: our harness recorded + `cn=(0,0,1)` (ground), live retail `cn=(0,+1,0)` (the door face). + Strong lead: `TransitionTypes.cs:3701-3702` — on a blocked move with + no valid collision normal we DEFAULT `cn = Vector3.UnitZ` ("push up"); + that exact (0,0,1) is what the harness sees. Whether retail has an + equivalent default (vs keeping the wall normal) is a runtime question. + +2. **Shape-2 — retail's slide_sphere applies the slide IN-FRAME** + (`add_offset_to_check_pos` @`0x53777e`, returns 4=SLID), so our + in-frame slide to Z=1.92 on frame 1 is likely retail-faithful and the + D4 frame-1 hard-stop pin (`BSPStepUpTests.D4_*`, expects Z=2.0) is the + STALE expectation. BUT retail always uses `contact_plane` OR + `last_known_contact_plane` (`0x53755a`); it has no "airborne wall-only, + no plane" third branch like ours (`TransitionTypes.cs:3080-3092`) — the + first-airborne-frame plane state needs a trace before flipping the pin. + +3. **Candidate epsilon-squaring divergence (real, but explains neither + shape).** Retail compares SQUARED quantities (`|cross|²` @`0x5375a5`, + `|offset|²` @`0x537735`) against `0.000199999995` (≈0.0002, NON-squared); + our port compares against `EpsilonSq = 0.0002²` (line 3105 + the + `dirLenSq >= EpsilonSq` branch @3098) — potentially ~10⁴× too small. + DO NOT change this without cdb confirmation: the BN `test ah, 0x5` + branch polarity (lines 321466-321467/321484-321485) is the exact + undecodable construct the PosHitsSphere saga warned about, and the + register reuse garbles which quantity is squared. A wrong guess here + regresses ALL wall-slide behavior. + +**Next (cdb session, well-scoped):** (a) `cdb -z uf +acclient!CSphere::slide_sphere` OR a live attach to disassemble +`0x00537440` and settle the two `test ah,5` branch signs + the +squared-vs-not threshold (prefer LIVE attach — prior lesson: static +`-z uf` misdecodes at OMAP boundaries); (b) live trace the tick-22760 +door push to confirm whether the `cn=(0,0,1)` comes from our +`UnitZ`-default (lead 1) and what retail's normal is at that instant. + --- ## #117 — Aperture-shaped see-through: doors/interiors visible through terrain hills and through nearer buildings — [DONE 2026-06-11 · 478c549, user re-gate "Yes solved"] From bf18a54369854be60304ec98aa097966a7d5c6c7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 10:21:51 +0200 Subject: [PATCH 22/65] fix #116 (partial, Ghidra-confirmed): slide_sphere degenerate guard uses F_EPSILON, not EpsilonSq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user brought up Ghidra; its decompiler (patchmem.gpr, full PDB) resolved the Binary-Ninja `test ah,5` x87 branch-sign ambiguity that blocked the desk read. CSphere::slide_sphere (0x00537440) decompiles cleanly to: fVar3 = |cross(collisionNormal, contactPlane.N)|²; if (::F_EPSILON <= fVar3) { // crease exists ... offset = cross * dot(cross,gDelta)/fVar3; if (|offset|² < ::F_EPSILON) return COLLIDED_TS; // degenerate guard ... add_offset_to_check_pos -> SLID_TS } Retail compares the SQUARED magnitudes against F_EPSILON (0.000199999995 ~= 0.0002 = PhysicsGlobals.EPSILON). Our port compared against EpsilonSq (0.0002^2 = 4e-8) - a ~5000x too-tight threshold (the BN pseudo-C rendered the comparison as `test ah,5` after an x87 FCMP, which is sign-ambiguous; agent reads disagreed). Fixed both comparisons at TransitionTypes.cs:3098,3105 to EPSILON. Effect: crease-exists now needs >=0.81 deg between the wall and contact normals (was 0.011 deg - which routed near-parallel pairs through the numerically unstable projection); the degenerate guard now hard-stops slides under ~1.41 cm like retail (was 0.2 mm). Branch POLARITY was already correct - no change there. No regression: full physics suite (612) + full Core (1443) green. Not a register deviation (no row existed; this is an undocumented porting error corrected to match retail). This does NOT close #116 - it fixes a tangential constant, not either reported shape. Ghidra also settled the two shapes' diagnosis (recorded in ISSUES.md #116 + physics digest): - Shape-1: our cn=UnitZ default IS retail-faithful (validate_transition 0x0050aa70 has the identical `if (collision_normal_valid==0) set_collision_normal(UnitZ)`). The real divergence is upstream - tick-22760 our collision_normal_valid was false where retail's was true (it recorded the door-face normal). Needs the instrumented tick-22760 replay. - Shape-2 (D4 stays skipped, note sharpened): slide_sphere slides in-frame (SLID_TS) so Z=1.92 is faithful and the D4 Z=2.0 hard-stop pin is the suspect half; the threshold fix didn't move D4 (real slide, not degenerate). Needs a cdb trace of an airborne wall hit. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 36 ++++++++++++++++++- src/AcDream.Core/Physics/TransitionTypes.cs | 16 +++++++-- .../Physics/BSPStepUpTests.cs | 19 +++++----- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index aea90676..6d5f5552 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -3930,13 +3930,47 @@ retail's viewer-distance smoothing (update_viewer region) before touching. ## #116 — Slide-response divergence family: near-perpendicular lateral slide lost + first-airborne-frame in-frame slide vs hard stop -**Status:** OPEN +**Status:** OPEN (narrowed) — one Ghidra-confirmed faithfulness fix +SHIPPED 2026-06-12; both reported shapes still need a runtime trace. **Severity:** LOW-MEDIUM (over-blocking, never under-blocking — no walk-throughs; feel-level divergence at walls/doors) **Filed:** 2026-06-11 (BR-7 / A6.P4 ship session) **Component:** physics (slide response — `SlideSphere` degenerate-offset guard + first-contact-frame behavior) +**GHIDRA SESSION 2026-06-12 (the BN branch-sign ambiguity RESOLVED via a +second decompiler — Ghidra MCP, patchmem.gpr, full PDB):** +- **SHIPPED (faithfulness fix):** `CSphere::slide_sphere` (Ghidra + `0x00537440`) compares its SQUARED magnitudes against `::F_EPSILON` + (= 0.000199999995 ≈ 0.0002 = `PhysicsGlobals.EPSILON`): `if (::F_EPSILON + <= |cross|²)` (crease) and `if (|offset|² < ::F_EPSILON) return + COLLIDED_TS` (degenerate guard). Our port compared against `EpsilonSq` + (0.0002² = 4e-8) — a ~5000× too-tight threshold (the BN `test ah,5` + obscured it). Fixed at `TransitionTypes.cs:3098,3105`; full physics + suite (612) + full Core (1443) green, no regression. Crease now needs + ≥0.81° between normals (was 0.011°); the guard stops slides under + ~1.41 cm like retail (was 0.2 mm). NOT a register deviation (no row + existed — it was an undocumented porting error; the fix matches retail). + ⚠️ This does NOT fix either reported shape below. +- **Shape-1 RE-DIAGNOSED — our `cn=UnitZ` default is RETAIL-FAITHFUL.** + Ghidra `validate_transition` (`0x0050aa70`) does exactly our + `TransitionTypes.cs:3701-3702`: `if (collision_normal_valid == 0) + set_collision_normal(UnitZ)`. So the harness `cn=(0,0,1)` is the + faithful FALLBACK; the real divergence is UPSTREAM — at tick-22760 our + `collision_normal_valid` was FALSE (→ UnitZ) where retail's was TRUE + (it had recorded the door-face normal `(0,+1,0)`). The bug is in the + COLLISION-RECORDING path (find_collisions / collide_with_environment), + not slide/validate. Next: replay tick-22760 + (`DoorBugTrajectoryReplayTests`) instrumented to see where our + collision-normal recording drops the wall normal. +- **Shape-2 NARROWED — D4 stays skipped.** Ghidra confirms slide_sphere + applies the slide IN-FRAME (`add_offset_to_check_pos` → SLID_TS), so our + Z=1.92 is faithful TO slide_sphere and the D4 Z=2.0 hard-stop pin is the + SUSPECT half. But the threshold fix did NOT change D4 (its offset is a + real slide, not degenerate), so whether retail's first airborne frame + REACHES slide_sphere (→1.92) or hard-stops upstream still needs a cdb + trace of an airborne wall hit before flipping the assertion. + **Two pinned shapes, both pre-dating BR-7 (the per-cell shadow port left them byte-identical):** diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 34dc4139..4e6d4b6e 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -3095,14 +3095,26 @@ public sealed class Transition Vector3 direction = Vector3.Cross(collisionNormal, contactPlane.Normal); float dirLenSq = direction.LengthSquared(); - if (dirLenSq >= PhysicsGlobals.EpsilonSq) + // #116 (2026-06-12, Ghidra-confirmed): retail CSphere::slide_sphere + // (0x00537440) compares these SQUARED magnitudes against F_EPSILON + // (0.000199999995 ≈ 0.0002 = PhysicsGlobals.EPSILON), NOT against the + // squared epsilon. Ghidra decomp: `if (::F_EPSILON <= fVar3)` where + // fVar3 = |cross|², and `if (|offset|² < ::F_EPSILON) return + // COLLIDED_TS`. Our port used EpsilonSq (0.0002² = 4e-8) — a ~5000× + // too-tight threshold (the BN pseudo-C `test ah,5` branch obscured the + // constant; the Ghidra second-decompiler pass settled it). Effect: + // crease-exists now needs ≥0.81° between the normals (was 0.011°, + // routing near-parallel pairs through the unstable projection); the + // degenerate guard now stops slides under ~1.41 cm like retail (was + // 0.2 mm). Register: AP-? (divergence retired). See ISSUES.md #116. + if (dirLenSq >= PhysicsGlobals.EPSILON) { // Crease exists: project displacement onto it. float diff = Vector3.Dot(direction, gDelta); float invDirLenSq = 1f / dirLenSq; Vector3 offset = direction * diff * invDirLenSq; - if (offset.LengthSquared() < PhysicsGlobals.EpsilonSq) + if (offset.LengthSquared() < PhysicsGlobals.EPSILON) return TransitionState.Collided; // Subtract current displacement to get the correction vector. diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs index 111316b8..560db1d6 100644 --- a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs @@ -546,14 +546,17 @@ public class BSPStepUpTests /// every frame replays the same hard stop and the character hangs in falling /// animation until another correction breaks the loop. /// - [Fact(Skip = "Issue #116 — slide-response divergence family (P1-era " + - "slide_sphere work made the first airborne wall frame slide in-frame " + - "to Z=1.92 instead of the L.2c-pinned hard stop at Z=2.0; the cached " + - "sliding-normal mechanism retail seeds via get_object_info " + - "(pc:279992, transient bit 4 → init_sliding_normal) only governs the " + - "NEXT frame, so which first-frame response is retail-faithful needs " + - "its own oracle read. NOT a cell-set problem — BR-7/A6.P4 left this " + - "byte-identical. See docs/ISSUES.md #116.")] + [Fact(Skip = "Issue #116 shape-2 — the engine slides IN-FRAME to Z=1.92 " + + "on the first airborne wall frame; this pin expects an L.2c hard stop " + + "at Z=2.0. Ghidra (2026-06-12) confirms retail CSphere::slide_sphere " + + "(0x00537440) applies the slide IN-FRAME (add_offset_to_check_pos → " + + "SLID_TS), so our 1.92 is faithful TO slide_sphere and the Z=2.0 " + + "expectation is the SUSPECT half — but whether retail's first " + + "airborne frame REACHES slide_sphere (→1.92) or hard-stops upstream " + + "(collide_with_environment dispatch / no last-known plane) needs a " + + "cdb trace of an airborne wall hit before flipping the assertion. The " + + "#116 threshold fix (EpsilonSq→F_EPSILON) did NOT change this — the D4 " + + "offset is a real slide, not degenerate. See docs/ISSUES.md #116.")] public void D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames() { var (root, resolved) = BSPStepUpFixtures.TallWall(); From 8682a8db7021bf91ce8c5aa5d227e9338fc79fa8 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 10:27:26 +0200 Subject: [PATCH 23/65] close #125: bounded upload retry kills the sticky-drop debt (failed GL uploads were never re-staged) The GL root cause was fixed in fcade06 (the gpu_us query-ring stale errors). This closes the remaining design debt: a genuinely-failed UploadMeshData was dropped permanently. Exact mechanism (traced this session): UploadMeshData's catch returns null, the staged item is already consumed, and _renderData stays empty - but the prepared data lingers in _cpuMeshCache, so the #128 EnsureLoaded re-arm hits PrepareMeshDataAsync's CPU-cache short-circuit (ObjectMeshManager.cs:448-453) which returns the cached data WITHOUT re-staging it for upload. The mesh stays invisible until CPU-cache eviction - session-sticky under low cache pressure (the in-tower scenario). Fix: the per-frame Tick drain (WbMeshAdapter) now re-stages a failed upload for the NEXT frame via ObjectMeshManager.UploadOrRequeue, bounded by MaxUploadRetries (3). The attempt counter lives on the ObjectMeshData object so it resets to 0 naturally on re-prepare. Re-stages are collected and re-enqueued AFTER the drain loop, never inside it, so a deterministic failure cannot spin the queue within a single frame; past the cap it gives up with a loud [up-retry] ... giving up line - a genuine GL defect now surfaces instead of the old silent permanent drop or an unbounded retry storm. Retail loads content synchronously and has no such failure mode; this converges the async pipeline toward that guarantee. The uncaught GenerateMipmaps path (open-question c) is INTENTIONALLY left to surface errors - a blanket catch there would mask future real defects (no-workarounds rule), and its trigger (fcade06) is retired. No visual gate (robustness). Build green; App.Tests 264 + WbMeshAdapter tests green. No GL-context test seam exists for the upload path, so the bounded retry is verified by construction + the regression suite. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 32 +++++++++++--- .../Rendering/Wb/ObjectMeshManager.cs | 44 +++++++++++++++++++ src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 13 +++++- 3 files changed, 81 insertions(+), 8 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 6d5f5552..2ae95c93 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -4453,8 +4453,9 @@ aperture instead of see-through to the world behind. ## #125 — GL InvalidOperation during staged texture upload: failed uploads are STICKY (never retried) + uncaught crash in GenerateMipmaps -**Status:** ROOT CAUSE FIXED 2026-06-11 (`fcade06`, live-verified) — -remaining: the sticky-drop design debt (below). +**Status:** CLOSED 2026-06-12 — the GL root cause was fixed `fcade06` +(2026-06-11, live-verified); the remaining sticky-drop DESIGN DEBT is now +fixed too (bounded upload retry, below). No visual gate (robustness). **RESOLVED (root cause):** the GL errors were the gpu_us QUERY RING's own — a glGenQueries name isn't a query object until first glBeginQuery, and @@ -4469,11 +4470,28 @@ slot; read only begun queries. Live-verified in-tower: 0 [wb-error] time under pview, meshMissing=0. **Normal runs (WB_DIAG off) never had these errors — this mechanism is RETIRED for #119.** -**Remaining debt (keep open under this number):** UploadMeshData removes -the preparation task BEFORE uploading, so any genuinely-failed upload is -never retried — permanently invisible mesh with one [wb-error] line. -The trigger is gone but the design flaw isn't; add retry/re-prepare -semantics in a maintenance pass. +**Remaining debt — FIXED 2026-06-12 (bounded upload retry):** the exact +stick was the CPU-cache short-circuit, not just the early `TryRemove`: a +failed `UploadMeshData` (catch → null) consumed the staged item and left +`_renderData` empty while the prepared data lingered in `_cpuMeshCache`, +so `PrepareMeshDataAsync`'s cache-hit path (`ObjectMeshManager.cs:448-453`) +returned it WITHOUT re-staging → never re-uploaded until CPU-cache +eviction (effectively session-sticky under low cache pressure). Fix: the +Tick drain (`WbMeshAdapter.cs`) now re-stages a failed upload for the NEXT +frame via `ObjectMeshManager.UploadOrRequeue`, bounded by +`MaxUploadRetries` (3) using a counter on the `ObjectMeshData` object +(resets to 0 on re-prepare). Re-stages are collected and re-enqueued +AFTER the drain loop — never inside it — so a deterministic failure can't +spin the queue in one frame; past the cap it gives up with a loud +`[up-retry] … giving up` line (surfaces a genuine GL defect instead of +the old silent permanent drop). Retail loads synchronously and has no +such failure mode; this converges the async pipeline toward that +guarantee. Build + App.Tests (264) green; no GL-context test seam exists +for the upload path so the retry is verified by construction + the +regression suite. The uncaught `GenerateMipmaps` path (open-question c) +is INTENTIONALLY left to surface errors — adding a blanket catch there +would mask future real defects (no-workarounds rule); its trigger +(`fcade06`) is already retired. **Filed:** 2026-06-11 (in-tower WB_DIAG launch, `tower-wbdiag3.log` — preserved in the worktree root) **Component:** render — WB staged texture pipeline (ObjectMeshManager / ManagedGLTextureArray) diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index d717a934..b9261ad1 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -62,6 +62,24 @@ namespace AcDream.App.Rendering.Wb { public VertexPositionNormalTexture[] Vertices { get; set; } = Array.Empty(); public List Batches { get; set; } = new(); + /// + /// #125 (2026-06-12): GL upload-retry counter. A failed + /// (returns null from its + /// catch) used to be dropped permanently — the staged item was consumed, + /// no render data was produced, and the prepared data lingered in the CPU + /// cache where PrepareMeshDataAsync's cache-hit short-circuit + /// returned it without ever re-staging it for upload (session-sticky + /// invisible mesh, one [wb-error] line). The drain loop now re-stages a + /// failed upload for the NEXT frame up to times. The counter lives on the mesh-data object so + /// it resets to 0 naturally whenever the id is re-prepared (fresh object), + /// and bounds a deterministic GL failure to a few loud lines instead of a + /// silent permanent drop OR an unbounded per-frame retry storm. Retail + /// loads content synchronously and has no such failure mode — this + /// converges our async pipeline toward that guarantee. + /// + public int UploadAttempts; + /// For EnvCell: the geometry of the cell itself. public ObjectMeshData? EnvCellGeometry { get; set; } @@ -216,6 +234,32 @@ namespace AcDream.App.Rendering.Wb { private readonly ConcurrentQueue _stagedMeshData = new(); public ConcurrentQueue StagedMeshData => _stagedMeshData; + /// #125: how many times a failed GL upload is re-staged before + /// giving up loudly. Small — a transient GL error clears on the next + /// frame; anything that fails this many times is a genuine defect to + /// surface, not retry forever. See . + public const int MaxUploadRetries = 3; + + /// + /// #125: drain one staged upload, returning whether it should be + /// re-staged for a later frame. The caller (the per-frame Tick drain) + /// collects the re-stages and re-enqueues them AFTER the drain loop — + /// never inside it — so a deterministic failure can't spin the queue in + /// a single frame. Increments the mesh-data's own attempt counter (resets + /// on re-prepare) and gives up loudly past . + /// + public bool UploadOrRequeue(ObjectMeshData meshData) { + if (UploadMeshData(meshData) is not null) + return false; // success (incl. legitimate 0-vertex → empty render data) + if (HasRenderData(meshData.ObjectId)) + return false; // raced to present by another path + meshData.UploadAttempts++; + if (meshData.UploadAttempts < MaxUploadRetries) + return true; // re-stage for next frame + Console.WriteLine($"[up-retry] 0x{meshData.ObjectId:X10} upload failed {meshData.UploadAttempts}x — giving up (was the #125 silent sticky drop; a GL error is being surfaced, not hidden)"); + return false; + } + // Cache for decoded textures to avoid redundant BCn decoding private readonly ConcurrentQueue _decodedTextureLru = new(); private readonly ConcurrentDictionary _decodedTextureCache = new(); diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index af2940ec..8bbdd6bd 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -244,10 +244,21 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter if (_disposed) return; _graphicsDevice!.ProcessGLQueue(); + // #125: drain staged uploads; a FAILED upload (UploadMeshData returned + // null from its catch) is re-staged for a LATER frame, not dropped. The + // re-stages are collected and re-enqueued AFTER the loop — re-enqueuing + // inside the while would let a deterministic failure spin the queue in a + // single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so + // a genuine defect surfaces loudly instead of the old silent sticky drop. + List? requeue = null; while (_meshManager!.StagedMeshData.TryDequeue(out var meshData)) { - _meshManager.UploadMeshData(meshData); + if (_meshManager.UploadOrRequeue(meshData)) + (requeue ??= new()).Add(meshData); } + if (requeue is not null) + foreach (var m in requeue) + _meshManager.StagedMeshData.Enqueue(m); bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled; var pendingBefore = texProbe From 1bf037a1c93451b12f6cf231786e58625fdfddd5 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 10:45:00 +0200 Subject: [PATCH 24/65] milestone: M1.5 LANDED (building/cellar demo); dungeon demo -> #133/G.3; start M2 M1.5 "Indoor world feels right" lands on its primary building/cellar demo, user-gated across the 2026-06 sessions: multi-floor inn navigation without sling-out/wall-clip, cottage cellar descend+ascend without falling through, walls block everywhere, smooth cell transitions. The holistic Option-A render port (one DrawInside(viewer_cell), BR-2..BR-7 / T1..T6) and the A6.P4 per-cell shadow physics shipped and were gated; the doorway-flap family is closed (#119/#128, #112, #113, #124, #129/#130/#131/#132, #108-residual, #127) and the #90/synthesis workarounds removed. The dungeon half is the one piece NOT landed: attempting the dungeon demo (meeting-hall portal) surfaced issue #133 - teleport-into-a-dungeon snaps the player BEFORE the dungeon landblock streams in (GameWindow.cs:4928 Resolve falls back to the resident Holtburg landblocks -> snaps to an outdoor cell over ocean). That is Phase G.3 (dungeon streaming + PlayerTeleport handling, M4), not a render bug (#95 died with the Option A rewrite). Per the milestones doc's pre-flagged choice, the dungeon demo is promoted to G.3 and M1.5 lands on the building/cellar demo (user decision 2026-06-13). Start M2 "Kill a drudge" - first port target CombatMath.ComputeDamage (port-ready per the combat-math research memo; ACE oracle). Drudges spawn outdoors for the demo, so M2 does not depend on #133/G.3. Files: milestones doc (M1.5 -> LANDED, M2 -> ACTIVE, currently-working- toward flipped), CLAUDE.md current-state -> M2, ISSUES.md #133 filed. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 21 ++++++----- docs/ISSUES.md | 58 +++++++++++++++++++++++++++++ docs/plans/2026-05-12-milestones.md | 50 +++++++++++++++++++++++-- 3 files changed, 116 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7892d6a4..e11dbb52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,16 +108,17 @@ movement queries. ## Current state -**Currently working toward: M1.5 — Indoor world feels right** -(M1 — Walkable + clickable world — landed 2026-05-16 via Phase B.6). -The holistic building-render port (Option A: ONE `DrawInside(viewer_cell)`, -no inside/outside branch; BR-2..BR-7/T1..T6) is SHIPPED and user-gated. -2026-06-12 closes: #119/#128, #112, #113, #124, #129, #130, #131, #132, -UN-2, **#108-residual** (terrain was double-sided — backface-cull port), -**#127** (distant-building flap died with the W=0 clip port). Open -render/physics ledger: #116 (slide-response, oracle-first), #125 -sticky-drop debt (leads in ISSUES.md). Keep this paragraph ≤5 lines + -pointers — detail lives in the docs below, NOT here. +**Currently working toward: M2 — Kill a drudge** (M1.5 — Indoor world +feels right — LANDED 2026-06-13 on the building/cellar demo; the holistic +Option-A render port + A6.P4 physics shipped and user-gated). **First M2 +target: `CombatMath.ComputeDamage`** (port-ready, ACE oracle; combat-math +research memo). Drudges spawn outdoors for the demo — dungeon access is +Phase G.3 (M4). **Dungeon teleport is BROKEN (#133):** teleport-into- +dungeon snaps before the dungeon streams in → lands in the old frame over +ocean; the M1.5 dungeon demo is deferred to G.3. Recent closes (2026-06-12/13): +#119/#128, #112, #113, #124, #129/#130/#131/#132, UN-2, #108-residual, +#127, #125; #116 partial (Ghidra threshold fix). Keep this paragraph ≤5 +lines + pointers — detail lives in the docs below, NOT here. For canonical state, read in this order: - [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 2ae95c93..b2aaeb38 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,64 @@ Copy this block when adding a new issue: --- +## #133 — Teleport into a dungeon snaps the player BEFORE the dungeon landblock streams in → lands at the old landblock's frame (ocean), not the dungeon + +**Status:** OPEN — promote to **Phase G.3** (Dungeon streaming + portal +space + `PlayerTeleport` handling; M4 per the milestones doc, line 360). +This is the M1.5 dungeon-demo blocker; the demo is deferred to G.3 (user +decision 2026-06-13). Does NOT block M2 combat (drudges can spawn +outdoors). +**Severity:** HIGH (any far/dungeon teleport is unusable) +**Filed:** 2026-06-13 (M1.5 dungeon-demo gate attempt — meeting-hall portal) +**Component:** physics/streaming — teleport-arrival snap vs async landblock hydration + +**Symptom (user):** used the meeting-hall portal to a dungeon; "no +dungeon, just ocean (where the dungeon is placed)." ACE spams `failed +transition for +Acdream from 0x01250126 [30 -60 6.0] to 0xA9B0000E +[-32227 -26748 5.9]` … marching south through `0xA993/0xA97F/…/0xA969` +at Z≈−0.9 (underwater) — the server keeps rejecting the client's bogus +outdoor movement. + +**Root cause (confirmed against code + the diagnostic log +`launch-dungeon-diag.log`):** ACE correctly placed the player in the +meeting-hall dungeon cell `0x01250126` (landblock `0x0125` = (1,37)). The +acdream teleport-arrival handler (`GameWindow.cs:4877-4960`) DOES recenter +the streaming origin to (1,37) (`_liveCenterX/Y`, :4910-4912), but then +**immediately** calls `_physicsEngine.Resolve(pos=(30,-60,6.005), +cell=0x01250126)` to snap the player (:4928-4931) — BEFORE the dungeon +landblock has streamed in. The physics engine still has only the OLD +Holtburg landblocks resident (A9B4 + neighbours), so `Resolve` can't find +the dungeon cell and falls back to an OUTDOOR scan against the resident +landblocks: local (30,−60) maps into A9B3 (the loaded block south of the +A9B4 spawn) → snaps to `0xA9B3000E`, terrainZ=94, indoor=False (the +`[snap]` line). The player is now at Holtburg's south edge; streaming then +shifts the frame out from under them and they slide south into ocean +(the `[cell-transit] A9B3→A9B2→…` chain mirrors ACE's failed-transition +sequence exactly). + +**Fix shape (G.3):** on a far/different-landblock teleport, recenter + +HOLD the snap until the destination dungeon landblock/cell hydrates (reuse +the #107 `IsSpawnCellReady` spawn-ready gate, applied to the teleport- +arrival path instead of only login), then place into the indoor cell via +the validated-claim path (#107/#111 `SetPositionInternal` shape). Also +audit the streaming controller actually LOADS the far dungeon landblock on +recenter (the 5×5 Chebyshev window around the new center), and that the +old landblocks unload without stranding the player mid-frame-shift. + +**Files:** `GameWindow.cs:4877-4960` (teleport arrival), +`PhysicsEngine.Resolve` (the outdoor fallback), the #107 `IsSpawnCellReady` +gate, `StreamingController` recenter. + +**Acceptance:** teleport into the meeting-hall dungeon → the player stands +in the dungeon cell, the dungeon renders (3-5 rooms), walls block, no +ocean / no ACE `failed transition` spam. + +**Apparatus:** `ACDREAM_PROBE_CELL=1` ([cell-transit]) + `ACDREAM_PROBE_VIEWER=1` +([viewer]) + `ACDREAM_WB_DIAG=1` + the always-on `[snap]`/`live: teleport` +lines capture the whole chain (`launch-dungeon-diag.log`, this session). + +--- + ## #104 — Scene VFX particles not clipped to the PView visible cell set **Status:** OPEN diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 77401952..7e9f6419 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -2,7 +2,9 @@ **Status:** Living document. Created 2026-05-12. **Sits above:** [`docs/plans/2026-04-11-roadmap.md`](2026-04-11-roadmap.md) (the strategic phase index). -**Currently working toward:** **M1.5 — Indoor world feels right.** +**Currently working toward:** **M2 — Kill a drudge.** (M1.5 — Indoor world +feels right — LANDED 2026-06-13 on the building/cellar demo; the dungeon +half deferred to Phase G.3 via issue #133. See the M1.5 section.) --- @@ -185,7 +187,37 @@ close range and the player sees "You pick up the X." in chat. --- -### M1.5 — "Indoor world feels right" — 🔵 ACTIVE (resumed 2026-05-21 after Phase O ship) +### M1.5 — "Indoor world feels right" — ✅ LANDED 2026-06-13 (building/cellar demo; dungeon half → Phase G.3 / issue #133) + +**M1.5 LANDED 2026-06-13.** The indoor world reads as solid. Across the +2026-06 sessions the holistic retail-faithful render port (Option A: ONE +`DrawInside(viewer_cell)`, no inside/outside branch — BR-2..BR-7 / T1..T6) +shipped and was user-gated, and the indoor physics/membership family was +brought to retail fidelity (the A6.P4 per-cell shadow architecture; the +#107/#111/#112 spawn + membership fixes; the cellar-lip wedge). End-to-end, +user-gated this milestone: walk into a building and climb a multi-floor inn +without sling-out or wall-clip; descend a cottage cellar and ascend it +without falling through (the #98 + cellar-lip + #108 grass-window closes); +walls block everywhere (indoor + stab-shell, the #99 door run-through +closed); cell transitions are smooth (the doorway "flap" family killed — +#119/#128, #112, #113, #124, #129/#130/#131/#132, #108-residual, #127 all +closed with user gates). The #90-stickiness + `TryFindIndoorWalkablePlane` +synthesis workarounds were removed by A6.P4. Remaining feel-level debt is +tracked (#116 slide-response, partial Ghidra fix shipped; A7 indoor +lighting fidelity not yet done — folded forward). **The dungeon demo is the +one piece NOT landed here:** attempting it (2026-06-13, meeting-hall portal) +surfaced issue **#133** — teleport-into-a-dungeon snaps the player before +the dungeon landblock streams in, landing them in the old (Holtburg) frame +over ocean. That is the **Phase G.3** machinery (dungeon streaming + +`PlayerTeleport` handling), an M4 concern; the dungeon demo is correctly +promoted there rather than blocking M1.5. Drudges for M2 can spawn +outdoors, so M2 is unblocked. + +--- + +#### (historical M1.5 working notes below) + +🔵 ACTIVE (resumed 2026-05-21 after Phase O ship) **2026-05-30 — render-pipeline pivot.** The indoor *rendering* seam (seamless in/out: the flap, missing/transparent walls, terrain bleed) will be solved by a @@ -293,13 +325,25 @@ unblocks that). --- -### M2 — "Kill a drudge" — ⏸ DEFERRED until M1.5 lands (was: NEXT) +### M2 — "Kill a drudge" — 🔵 ACTIVE (started 2026-06-13, after M1.5 landed) **Demo scenario:** Equip a sword. Walk to a drudge. Swing. See "You hit Drudge for 12 slashing damage (87%)" in chat. Watch the swing animation play. Drudge dies, drops loot. Pick up the loot. Open the inventory panel and see it. +**Dungeon note:** drudges live in dungeons in retail, but the M2 demo does +NOT require dungeon streaming (#133 / Phase G.3) — spawn a drudge outdoors +near Holtburg for the combat loop. Dungeon access lands later with G.3 (M4). + +**First port target (per the M2 combat-math research memo, +`memory/research/2026-06-04-combat-math-deep-dive.md`):** +`CombatMath.ComputeDamage` — damage-calc + armor-resists are port-ready +(ACE is the high-confidence oracle; two known scaffold bugs in +`CombatModel.cs` identified). Hit-roll is well-documented client-side; +the server sigmoid/crit + weapon-timing (the x87 `GetPowerBarLevel` +artifact) come after. + **Phases to ship:** - **F.2 (panels)** — Inventory panel reading `ItemRepository` (data already shipped in F.2 base; M2 ships the visual surface). From 9c2ceb233645292b810ee8b62ca23da2a9e6c8bf Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 14:45:53 +0200 Subject: [PATCH 25/65] =?UTF-8?q?milestone:=20re-open=20M1.5=20=E2=80=94?= =?UTF-8?q?=20dungeon=20support=20(full=20G.3)=20pulled=20into=20scope;=20?= =?UTF-8?q?M2=20re-deferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correction to 1bf037a. The user reverted the M1.5 landing: the indoor world isn't done while dungeons are completely broken. Attempting the dungeon demo revealed it's not a single bug (#133) but a whole-feature gap — terrain-less indoor-only dungeon landblocks aren't supported anywhere in the streaming/load/render/physics pipeline: - LandblockLoader.Load returns null when there's no LandBlock terrain record (dungeons have none) -> the dungeon never loads. - LandblockStreamer fails when the terrain mesh build returns null (dungeons have no terrain mesh). - The teleport-arrival snap Resolves before the dungeon hydrates -> places the player in the old Holtburg frame over ocean. The user chose the FULL Phase G.3 scope (dungeon streaming + portal-space loading screen + multi-landblock LOD + PlayerTeleport handling) and pulled it into M1.5. M1.5 lands only when BOTH the building/cellar demo (done) and the dungeon demo (enter via portal, navigate 3-5 rooms, walls block, smooth transitions) pass. M2 (CombatMath) re-deferred. Currently brainstorming the dungeon-support design (spec -> docs/superpowers/specs/). Docs corrected: milestones (M1.5 ACTIVE + extended, M2 DEFERRED, currently-working-toward -> M1.5), CLAUDE.md current-state, ISSUES.md #133 (G.3 pulled into M1.5). Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 23 +++++----- docs/ISSUES.md | 12 +++--- docs/plans/2026-05-12-milestones.md | 67 +++++++++++++++++++---------- 3 files changed, 63 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e11dbb52..e328ce51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,17 +108,18 @@ movement queries. ## Current state -**Currently working toward: M2 — Kill a drudge** (M1.5 — Indoor world -feels right — LANDED 2026-06-13 on the building/cellar demo; the holistic -Option-A render port + A6.P4 physics shipped and user-gated). **First M2 -target: `CombatMath.ComputeDamage`** (port-ready, ACE oracle; combat-math -research memo). Drudges spawn outdoors for the demo — dungeon access is -Phase G.3 (M4). **Dungeon teleport is BROKEN (#133):** teleport-into- -dungeon snaps before the dungeon streams in → lands in the old frame over -ocean; the M1.5 dungeon demo is deferred to G.3. Recent closes (2026-06-12/13): -#119/#128, #112, #113, #124, #129/#130/#131/#132, UN-2, #108-residual, -#127, #125; #116 partial (Ghidra threshold fix). Keep this paragraph ≤5 -lines + pointers — detail lives in the docs below, NOT here. +**Currently working toward: M1.5 — Indoor world feels right.** The +building/cellar demo is DONE + user-gated, but M1.5 was EXTENDED 2026-06-13 +to include **dungeon support (full Phase G.3)** — dungeons don't work at +all: terrain-less dungeon landblocks aren't supported by the streaming/ +load/render/physics pipeline (`LandblockLoader.Load` null with no +`LandBlock`; streamer needs a terrain mesh; teleport snaps before hydration +→ ocean — issue **#133**). M1.5 does NOT land until dungeons work; M2 +(CombatMath) deferred. Currently brainstorming the G.3 dungeon-support spec. +Recent closes (2026-06-12/13): #119/#128, #112, #113, #124, +#129/#130/#131/#132, UN-2, #108-residual, #127, #125; #116 partial (Ghidra +threshold fix). Keep this paragraph ≤5 lines + pointers — detail in the +docs below, NOT here. For canonical state, read in this order: - [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b2aaeb38..80581f8a 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -48,11 +48,13 @@ Copy this block when adding a new issue: ## #133 — Teleport into a dungeon snaps the player BEFORE the dungeon landblock streams in → lands at the old landblock's frame (ocean), not the dungeon -**Status:** OPEN — promote to **Phase G.3** (Dungeon streaming + portal -space + `PlayerTeleport` handling; M4 per the milestones doc, line 360). -This is the M1.5 dungeon-demo blocker; the demo is deferred to G.3 (user -decision 2026-06-13). Does NOT block M2 combat (drudges can spawn -outdoors). +**Status:** OPEN — promoted to **Phase G.3** (Dungeon streaming + portal +space + `PlayerTeleport` handling), **PULLED INTO M1.5** (user decision +2026-06-13: the indoor world isn't done while dungeons are broken; full +G.3 scope chosen). Brainstorming the spec → `docs/superpowers/specs/`. +This is now an M1.5 exit-gate blocker, not deferred. The investigation +below found it's not a single bug but a whole-feature gap (terrain-less +dungeon landblocks unsupported across the pipeline). **Severity:** HIGH (any far/dungeon teleport is unusable) **Filed:** 2026-06-13 (M1.5 dungeon-demo gate attempt — meeting-hall portal) **Component:** physics/streaming — teleport-arrival snap vs async landblock hydration diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 7e9f6419..fdbb5ef3 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -2,9 +2,13 @@ **Status:** Living document. Created 2026-05-12. **Sits above:** [`docs/plans/2026-04-11-roadmap.md`](2026-04-11-roadmap.md) (the strategic phase index). -**Currently working toward:** **M2 — Kill a drudge.** (M1.5 — Indoor world -feels right — LANDED 2026-06-13 on the building/cellar demo; the dungeon -half deferred to Phase G.3 via issue #133. See the M1.5 section.) +**Currently working toward:** **M1.5 — Indoor world feels right.** The +building/cellar demo is DONE + user-gated, but M1.5 was EXTENDED 2026-06-13 +to include **dungeon support (full Phase G.3)** — dungeons don't work yet +(terrain-less dungeon landblocks aren't supported by the streaming/load +pipeline; issue #133). M1.5 does NOT land until dungeons work. M2 stays +deferred. (Correction: M1.5 was briefly marked landed 2026-06-13; the user +reverted that — the indoor world isn't done while dungeons are broken.) --- @@ -187,9 +191,24 @@ close range and the player sees "You pick up the X." in chat. --- -### M1.5 — "Indoor world feels right" — ✅ LANDED 2026-06-13 (building/cellar demo; dungeon half → Phase G.3 / issue #133) +### M1.5 — "Indoor world feels right" — 🔵 ACTIVE (building/cellar demo DONE; EXTENDED 2026-06-13 to include dungeon support / Phase G.3) -**M1.5 LANDED 2026-06-13.** The indoor world reads as solid. Across the +**EXTENDED 2026-06-13 — dungeons pulled into M1.5 scope.** The +building/cellar demo (below) is DONE + user-gated, but attempting the +dungeon demo surfaced that dungeons don't work AT ALL: terrain-less +dungeon landblocks aren't supported anywhere in the streaming/load/ +render/physics pipeline (`LandblockLoader.Load` returns null with no +`LandBlock` terrain record; the streamer fails with no terrain mesh; the +teleport snap Resolves before hydration — issue #133). The user decided +M1.5 is NOT done while the indoor world excludes dungeons, and chose the +FULL Phase G.3 scope (dungeon streaming + portal-space loading screen + +multi-landblock dungeon LOD + `PlayerTeleport` handling). Design in +progress (`docs/superpowers/specs/` — dungeon-support spec). M1.5 lands +when: building/cellar demo (DONE) + dungeon demo (enter via portal, +navigate 3-5 rooms, walls block, smooth transitions) both pass. + +**Building/cellar demo — DONE + user-gated.** The indoor world reads as +solid. Across the 2026-06 sessions the holistic retail-faithful render port (Option A: ONE `DrawInside(viewer_cell)`, no inside/outside branch — BR-2..BR-7 / T1..T6) shipped and was user-gated, and the indoor physics/membership family was @@ -204,14 +223,18 @@ closed); cell transitions are smooth (the doorway "flap" family killed — closed with user gates). The #90-stickiness + `TryFindIndoorWalkablePlane` synthesis workarounds were removed by A6.P4. Remaining feel-level debt is tracked (#116 slide-response, partial Ghidra fix shipped; A7 indoor -lighting fidelity not yet done — folded forward). **The dungeon demo is the -one piece NOT landed here:** attempting it (2026-06-13, meeting-hall portal) -surfaced issue **#133** — teleport-into-a-dungeon snaps the player before -the dungeon landblock streams in, landing them in the old (Holtburg) frame -over ocean. That is the **Phase G.3** machinery (dungeon streaming + -`PlayerTeleport` handling), an M4 concern; the dungeon demo is correctly -promoted there rather than blocking M1.5. Drudges for M2 can spawn -outdoors, so M2 is unblocked. +lighting fidelity not yet done — folded forward). + +**Still OPEN in M1.5 — dungeon support (Phase G.3, issue #133).** Dungeons +don't work: the streaming/load/render/physics pipeline was built entirely +around outdoor landblocks (terrain + scattered buildings) and has no path +for terrain-less indoor-only dungeon landblocks. Confirmed gaps: +`LandblockLoader.Load` returns null with no `LandBlock` record; the +streamer fails with no terrain mesh; the teleport-arrival snap Resolves +before the dungeon hydrates → places the player in the old frame over +ocean. Full G.3 scope chosen by the user 2026-06-13 (streaming + portal- +space loading screen + multi-landblock LOD + `PlayerTeleport` handling). +Spec under `docs/superpowers/specs/`. --- @@ -325,24 +348,22 @@ unblocks that). --- -### M2 — "Kill a drudge" — 🔵 ACTIVE (started 2026-06-13, after M1.5 landed) +### M2 — "Kill a drudge" — ⏸ DEFERRED until M1.5 lands (incl. dungeons) **Demo scenario:** Equip a sword. Walk to a drudge. Swing. See "You hit Drudge for 12 slashing damage (87%)" in chat. Watch the swing animation play. Drudge dies, drops loot. Pick up the loot. Open the inventory panel and see it. -**Dungeon note:** drudges live in dungeons in retail, but the M2 demo does -NOT require dungeon streaming (#133 / Phase G.3) — spawn a drudge outdoors -near Holtburg for the combat loop. Dungeon access lands later with G.3 (M4). - -**First port target (per the M2 combat-math research memo, -`memory/research/2026-06-04-combat-math-deep-dive.md`):** +**First port target when M2 starts (per the M2 combat-math research memo, +`docs/research/2026-06-04-combat-math-deep-dive.md`):** `CombatMath.ComputeDamage` — damage-calc + armor-resists are port-ready (ACE is the high-confidence oracle; two known scaffold bugs in -`CombatModel.cs` identified). Hit-roll is well-documented client-side; -the server sigmoid/crit + weapon-timing (the x87 `GetPowerBarLevel` -artifact) come after. +`CombatModel.cs` identified — additive attributeBonus + subtractive armor). +Hit-roll is well-documented client-side; the server sigmoid/crit + +weapon-timing (the x87 `GetPowerBarLevel` artifact) come after. NOTE: M2 +was briefly started 2026-06-13 then re-deferred when M1.5 was extended to +include dungeons. **Phases to ship:** - **F.2 (panels)** — Inventory panel reading `ItemRepository` (data already From 90786c19e25664122021e5c8edc407261f8cea7d Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 14:53:27 +0200 Subject: [PATCH 26/65] =?UTF-8?q?handoff:=20M1.5=20dungeon=20support=20(G.?= =?UTF-8?q?3)=20grounded=20=E2=80=94=20design=20research=20+=20the=20terra?= =?UTF-8?q?in-less-premise=20refutation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the 2026-06-13 dungeon-G.3 handoff doc + a dat-probe test that RESOLVED the pivotal design ambiguity. A research agent assumed dungeon landblocks are terrain-less (LandblockLoader.Load returns null -> "rewrite the pipeline for terrain-less landblocks", 13 seams). The dat probe refutes it: dungeon landblock 0x0125 has a flat (all-zero-height) LandBlock record PLUS 71 EnvCells and no buildings/objects -> it streams fine via the existing pipeline as a flat-terrain landblock. The real blocker (#133) is narrow: the teleport-arrival handler (GameWindow.cs:4928) snaps the player via physics.Resolve BEFORE the dungeon landblock streams in -> Resolve falls back to the resident Holtburg landblocks -> places the player at A9B3 ocean. Fix shape: hold-until-hydration (reuse the #107 IsSpawnCellReady gate for the teleport-arrival path) + place into the EnvCell + the retail TeleportAnimState portal-space FSM for the full-G.3 loading screen. ACE confirms dungeons are single-landblock, so "multi-landblock LOD" is moot. The handoff captures: this session's closes (#108-residual/#127/#125 gated, #116 partial), the M1.5 re-open decision, the corrected root cause, the 5-way reference grounding (holtburger/ACE/retail decomp + the dat probe), the design direction, and the open brainstorm questions. Next session: resume the brainstorm at "propose approaches" -> spec -> writing-plans -> implement. Suites green: App 264+1skip / Core 1445+2skip / UI 420 / Net 294. Co-Authored-By: Claude Fable 5 --- .../research/2026-06-13-dungeon-g3-handoff.md | 205 ++++++++++++++++++ .../DungeonLandblockDatProbeTests.cs | 76 +++++++ 2 files changed, 281 insertions(+) create mode 100644 docs/research/2026-06-13-dungeon-g3-handoff.md create mode 100644 tests/AcDream.Core.Tests/Conformance/DungeonLandblockDatProbeTests.cs diff --git a/docs/research/2026-06-13-dungeon-g3-handoff.md b/docs/research/2026-06-13-dungeon-g3-handoff.md new file mode 100644 index 00000000..20f818dc --- /dev/null +++ b/docs/research/2026-06-13-dungeon-g3-handoff.md @@ -0,0 +1,205 @@ +# Handoff (2026-06-13): M1.5 EXTENDED — dungeon support (full Phase G.3). Design grounded; ready to brainstorm → spec → implement. + +**Branch:** `claude/thirsty-goldberg-51bb9b`, pushed to BOTH remotes at the +HEAD this doc commits with. Suites green: App 264+1skip / Core 1445+2skip / +UI 420 / Net 294 (the dungeon dat-probe test added this session is +output-only). + +This session closed a batch of M1.5 render/physics issues, then — at the +dungeon-demo gate — discovered dungeons don't work and the user **extended +M1.5 to include full dungeon support (Phase G.3)**. M2 is re-deferred. The +design is grounded (5-way reference research + a decisive dat probe); the +next session brainstorms approaches → writes the spec → implements. + +--- + +## 1. What this session shipped (all on branch, pushed, most user-gated) + +| Item | Outcome | Commits | +|---|---|---| +| **#108-residual** (cellar grass window) | CLOSED, user-gated "Yes it is fixed." Terrain was drawn DOUBLE-SIDED; the grass was the grade sheet's underside seen from a below-grade cellar eye. Ported retail `landPolysDraw` eye-side gate as terrain backface cull. Membership/viewer EXONERATED by a vertical cellar-ascent harness. | `007af13`, `96a425a`, `bf80067` | +| **#127** (distant-building flood flap) | CLOSED, user-gated "Seems to have been fixed." Died with the W=0 clip port (`987313a`); confirmed by a run-past churn detector (0 churn, 21 buildings × 5 distances × 100 mm-steps). | `4ad6fb9` | +| **#125** (sticky-drop debt) | CLOSED. Bounded upload retry — a failed `UploadMeshData` re-stages for the next frame up to `MaxUploadRetries` (counter on the `ObjectMeshData`); the CPU-cache short-circuit no longer permanently strands a failed upload. | `8682a8d` | +| **#116** (slide-response) | PARTIAL. Ghidra (the user pointed me to the running Ghidra MCP) resolved the BN `test ah,5` branch-sign ambiguity: `slide_sphere` compares squared magnitudes against `F_EPSILON` (0.0002), not `EpsilonSq` (4e-8) — fixed `TransitionTypes.cs:3098,3105`, full physics suite green. The two reported shapes still need a cdb trace (shape-1 = upstream collision-normal recording; shape-2 = D4 first-frame dispatch). | `35961f2`, `bf18a54` | + +--- + +## 2. The milestone churn (read this — the docs were corrected) + +- I briefly marked **M1.5 LANDED** on the building/cellar demo and started M2 + (`1bf037a`). **The user reverted that:** the indoor world isn't done while + dungeons are broken, so M1.5 is EXTENDED to include dungeon support, and the + user chose the **FULL Phase G.3 scope** (streaming + portal-space loading + screen + `PlayerTeleport` handling). Correction committed `9c2ceb2`. +- **Current truth:** M1.5 ACTIVE; building/cellar demo DONE + user-gated; + dungeon support (G.3) is the remaining M1.5 exit-gate. M2 (CombatMath first + port) DEFERRED. Docs reflect this (milestones doc, CLAUDE.md current-state, + ISSUES.md #133). + +--- + +## 3. The dungeon bug — CORRECTED root cause (issue #133) + +User attempted the dungeon demo via the **meeting-hall portal** → "no dungeon, +just ocean." ACE logged a flood of `failed transition for +Acdream from +0x01250126 [30 -60 6.0] to 0xA9B0000E [-32227 -26748 5.9]` … marching south at +Z≈−0.9 (underwater). + +**Diagnostic capture (`launch-dungeon-diag.log`, probes +`ACDREAM_PROBE_CELL`/`ACDREAM_PROBE_VIEWER`/`ACDREAM_WB_DIAG`):** +``` +live: teleport arrival — old lb=(169,180) new lb=(1,37) dist=42524.0 +[snap] claim=0xA9B3000E pos=(30,-60,6.005) cells=17 bestCell=0xA9B30103 ... indoor=False -> targetCell=0xA9B3000E +live: teleport complete — snapped to <30,-60,6.005> cell=0xA9B3000E +[cell-transit] A9B3000E -> A9B2000E -> A9B1000E -> ... (sliding south into ocean) +``` +ACE correctly placed the player in dungeon cell **0x01250126** (landblock +`0x0125` = (1,37)). acdream's arrival handler (`GameWindow.cs:4908-4931`) +recenters streaming to (1,37) but then **immediately** calls +`_physicsEngine.Resolve(pos=(30,-60,6.005), cell=0x01250126)` to snap the +player — **before the dungeon landblock has streamed in**. Resolve can't find +the dungeon cell, falls back to an outdoor scan against the **still-resident +Holtburg landblocks**, and snaps to `0xA9B3000E` (Holtburg's south edge, local +(30,−60) maps into the block south of the A9B4 spawn). Streaming then shifts +the frame out from under the player → slides south into ocean. + +### ⚠️ The "terrain-less landblock" framing is WRONG (verified by dat probe) + +A pipeline-seam research agent *assumed* dungeon landblocks have no `LandBlock` +record (so `LandblockLoader.Load` returns null) and produced a 13-seam +"rewrite the pipeline for terrain-less landblocks" plan. **A direct dat probe +(`DungeonLandblockDatProbeTests`, committed) refutes that:** +``` +0x0125 (dungeon): LandBlock 0x0125FFFF PRESENT, Height[81] allZero=True (flat) + LandBlockInfo: NumCells=71, Buildings=0, Objects=0 + EnvCells 0x0100.. present (the 71 dungeon rooms) +0xA9B4 (Holtburg): LandBlock PRESENT, heights non-zero; NumCells=123, Buildings=12, Objects=114 +``` +A dungeon landblock is a **flat-terrain landblock** (all-zero height index = +the lowest/"ocean" terrain) **plus its EnvCells, no buildings/objects**. So +`LandblockLoader.Load(0x0125…)` returns a valid flat landblock, the terrain +mesh builds a flat plane, and `PhysicsEngine.AddLandblock` gets a valid flat +`TerrainSurface`. **The existing pipeline can already stream a dungeon +landblock.** The 13 terrain-dependency seams are NOT the blocker. + +**The real blocker is narrow: teleport TIMING + PLACEMENT.** + +--- + +## 4. Reference grounding (5-way research; dat agent failed, replaced by the probe above) + +**holtburger (client-behavior oracle):** +- PlayerTeleport (0xF751) → enter `EnteringWorld` (portal space), **suspend + physics bodies**, send **LoginComplete immediately** (no waiting for assets). +- Exit portal space → `InWorld` when the server sends ObjectCreate (entities) + + UpdatePosition (player) + the **StartGame** event → resume bodies. +- holtburger does NOT stream landblocks (entity-centric); not our model — we + DO stream from our own dats. Take the **FSM shape** (EnteringWorld/InWorld + + suspend/resume) not the no-streaming part. +- DDD is NOT part of the teleport flow (responds empty). (`messages.rs:480-486`, + `:190-195`, `player.rs:71-79`, `types.rs:169-175`.) + +**ACE (server):** `Player_Location.cs:654-707` Teleport() sends PlayerTeleport +(sequence) → a **fake UpdatePosition** to trigger client load → the real +UpdatePosition with PositionPack (CellID = dungeonID<<16 | cellIndex, e.g. +`0x01250126`, xyz, rotation). **Server sends NO geometry — client loads cells +from its own dats by cellID** (matches our dat-driven model). Portal: +`Portal.cs:269-292` ActOnUse → AdjustDungeon (corrects cell id) → +ThreadSafeTeleport. **Dungeons are SINGLE-landblock** (`Player_Tick.cs:548-560` +forbids moving between dungeon landblocks without teleport) → "multi-landblock +LOD" in the full-G.3 scope is MOOT for AC dungeons. IsDungeon = all heights 0 + +NumCells>0 + no buildings (`Landblock.cs:575-631`). + +**Retail decomp (client):** terrain (`CLandBlock::grab_visible_cells`) and +dungeon cells (`CEnvCell::grab_visible_cells`, :311878) load on **separate +paths**; a cell with `seen_outside==0` loads ZERO terrain and walks only its +`stab_list` (adjacent EnvCells). **Portal-space = a 6-state `TeleportAnimState` +FSM** (:219682-219774): WORLD_FADE_OUT → TUNNEL_FADE_IN → TUNNEL (hold while +loading) → TUNNEL_FADE_OUT → WORLD_FADE_IN → OFF; `m_pPortalSpace` is the +tunnel viewport (the "loading"/black screen). Retail gates cell-ready on DDD +(server cell push) — **we don't need DDD** (we have the dats); we gate on our +own streaming hydration. Open: no distinct "pink screen" asset found — retail's +loading visual is the portal tunnel. + +**acdream pipeline seams (corrected by the dat probe):** the dungeon landblock +streams fine as flat-terrain. Real seams that matter: +- `GameWindow.cs:4908-4931` — teleport arrival: recenter then **Resolve + immediately** (the bug). No hold-until-hydration. +- `PhysicsEngine.IsSpawnCellReady` (`:468`) — the EXISTING #107 gate; already + handles indoor cells (checks DataCache for 0x0100+). **Reuse it for the + teleport-arrival path.** +- EnvCell hydration (render `_cellVisibility`/`EnvCellRenderer`; physics + `CacheCellStruct`) is iterated from `LandBlockInfo.NumCells` and is + **orthogonal to terrain** — should fire for a dungeon landblock once it + streams (`GameWindow.cs:5564-5576`, `6015-6028`). VERIFY it does. +- Placement: the player is at cell `0x01250126`, pos (30,−60,6.005); must be + placed in the **EnvCell** (the #107/#111 validated-claim path, + `WalkableFloorZNearest`), not on the flat terrain. + +--- + +## 5. Design direction (to confirm in the brainstorm) + +A retail-faithful, much-narrower-than-feared shape: + +1. **Teleport state machine (portal space).** On PlayerTeleport: enter a + PortalSpace/EnteringWorld state, suspend player physics, (optionally) start + the retail `TeleportAnimState` tunnel FSM for the loading visual. On arrival + UpdatePosition: recenter streaming on the destination landblock, then **HOLD** + — do not snap — until the destination landblock + the claimed EnvCell hydrate + (reuse `IsSpawnCellReady`). Then place into the EnvCell (validated-claim + path), exit PortalSpace → InWorld, resume physics, send LoginComplete. + (acdream already has `OnTeleportStarted`/portal-space + the #107 hold for + LOGIN — extend that machinery to the teleport-arrival path rather than + snapping at `:4928`.) +2. **Streaming a far teleport.** Confirm the recenter actually drives the + streamer to load the destination dungeon landblock (the Chebyshev window + around the new center) and unloads the old neighborhood without stranding the + player. The dungeon streams as a flat-terrain landblock — no new loader path + needed, but verify the apply path + EnvCell hydration fire. +3. **Render/physics in the dungeon.** Once the EnvCells hydrate, the existing + PView indoor render + per-cell collision should work (same as buildings). + The flat terrain renders below; PView roots at the viewer EnvCell. VERIFY the + 3-5-room navigation, walls block, stairs, lighting (A7 not done — expect + lighting findings), transitions. +4. **Portal-space loading screen (full-G.3 polish).** The retail 6-state tunnel + FSM (`TeleportAnimState`) — implement after the core teleport+place works, or + a simpler fade-to-black first. + +**Open design questions for the brainstorm:** +- Do we implement the retail `TeleportAnimState` tunnel FSM faithfully now, or a + simpler fade-to-black for M1.5 and the full tunnel later? +- How long to HOLD before giving up (the dungeon may need several frames to + stream); what's the failure/timeout behavior? +- Does the existing streaming controller already load a landblock 42 km away on + recenter, or does it assume incremental movement? (Confirm the recenter→load + path for a big jump.) +- Placement: the cell-local pos (30,−60,6.005) vs the EnvCell origins (~(0,−30,0)) + — confirm the EnvCell BSP contains the point and the #107/#111 walkable-floor + placement lands the player on the dungeon floor. + +--- + +## 6. Apparatus added this session (kept) + +| Tool | How | For | +|---|---|---| +| `DungeonLandblockDatProbeTests` | `dotnet test --filter DungeonLandblockDatProbe` | Dumps the dat structure of a dungeon (0x0125) vs outdoor (A9B4) landblock — the terrain-less-vs-flat resolution | +| `launch-dungeon-diag.log` | `ACDREAM_PROBE_CELL=1 ACDREAM_PROBE_VIEWER=1 ACDREAM_WB_DIAG=1` | The teleport→snap→slide capture; `[snap]`/`[cell-transit]`/`live: teleport` lines are the chain | +| `Issue108CellarAscentViewerReplayTests` | App.Tests filter | Vertical cellar-ascent viewer harness (membership EXONERATED for #108) | +| `Issue127FloodFlipReplayTests.DistantBuildingStrafe_NoAdmissionChurn` | App.Tests filter | #127 run-past churn-detector regression pin | + +Decomp grounding: holtburger teleport flow, ACE Teleport/Portal/AdjustDungeon, +retail `CEnvCell::grab_visible_cells` (:311878) + `TeleportAnimState` FSM +(:219682-219774). Full raw research in the workflow output (this session). + +--- + +## 7. Next session: brainstorm → spec → implement + +The brainstorming skill was invoked and scope was set (full G.3). The next +session resumes the brainstorm at "propose 2-3 approaches" with the grounding +above, settles the design, writes the spec to +`docs/superpowers/specs/2026-06-13-dungeon-support-design.md`, then → +writing-plans → implement. The paste-ready pickup prompt is in the session +message that produced this doc. diff --git a/tests/AcDream.Core.Tests/Conformance/DungeonLandblockDatProbeTests.cs b/tests/AcDream.Core.Tests/Conformance/DungeonLandblockDatProbeTests.cs new file mode 100644 index 00000000..e1fe5a96 --- /dev/null +++ b/tests/AcDream.Core.Tests/Conformance/DungeonLandblockDatProbeTests.cs @@ -0,0 +1,76 @@ +using System.Linq; +using DatReaderWriter; +using DatReaderWriter.Options; +using DatLandBlock = DatReaderWriter.DBObjs.LandBlock; +using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo; +using DatEnvCell = DatReaderWriter.DBObjs.EnvCell; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Conformance; + +/// +/// G.3 dungeon-support research probe (2026-06-13): resolve the pivotal +/// terrain-less-vs-ocean ambiguity for the meeting-hall dungeon landblock +/// 0x0125 (the teleport this session went to cell 0x01250126). Does a dungeon +/// landblock have a LandBlock (0xXXYYFFFF) terrain record at all, or only +/// LandBlockInfo + EnvCells? Output-only — no assertions. +/// +public sealed class DungeonLandblockDatProbeTests +{ + private readonly ITestOutputHelper _out; + public DungeonLandblockDatProbeTests(ITestOutputHelper output) => _out = output; + + [Fact] + public void Probe_Dungeon0125_vs_Holtburg_A9B4() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + foreach (uint lb in new uint[] { 0x0125u, 0xA9B4u }) + { + _out.WriteLine($"=== landblock 0x{lb:X4} ==="); + + uint terrainId = (lb << 16) | 0xFFFFu; + var block = dats.Get(terrainId); + if (block is null) + { + _out.WriteLine($" LandBlock 0x{terrainId:X8}: NULL (no terrain record)"); + } + else + { + var heights = block.Height; + bool allZero = heights is not null && heights.All(h => h == 0); + int distinct = heights is null ? 0 : heights.Distinct().Count(); + _out.WriteLine($" LandBlock 0x{terrainId:X8}: present, Height[{heights?.Length ?? 0}] allZero={allZero} distinctIndices={distinct} first8=[{(heights is null ? "" : string.Join(",", heights.Take(8)))}]"); + } + + uint infoId = (lb << 16) | 0xFFFEu; + var info = dats.Get(infoId); + if (info is null) + { + _out.WriteLine($" LandBlockInfo 0x{infoId:X8}: NULL"); + } + else + { + _out.WriteLine($" LandBlockInfo 0x{infoId:X8}: NumCells={info.NumCells} Buildings={info.Buildings?.Count ?? 0} Objects={info.Objects?.Count ?? 0}"); + } + + // probe the first few EnvCells + int found = 0; + for (uint low = 0x0100u; low < 0x0110u; low++) + { + uint cellId = (lb << 16) | low; + var cell = dats.Get(cellId); + if (cell is not null) + { + found++; + if (found <= 3) + _out.WriteLine($" EnvCell 0x{cellId:X8}: present, CellStructure={cell.CellStructure} Portals={cell.CellPortals?.Count ?? 0} pos=({cell.Position.Origin.X:F1},{cell.Position.Origin.Y:F1},{cell.Position.Origin.Z:F1})"); + } + } + _out.WriteLine($" EnvCells 0x0100..0x010F present: {found}"); + } + } +} From 6680fd42b2bfc15c96acfa26d7827ee0d6332875 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 16:43:39 +0200 Subject: [PATCH 27/65] =?UTF-8?q?spec:=20G.3=20dungeon=20support=20design?= =?UTF-8?q?=20(M1.5=20exit-gate)=20=E2=80=94=20phased,=20retail-faithful?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brainstorm outcome for #133/G.3. Grounds the corrected root cause (dungeon landblock = flat terrain + EnvCells, streams via the existing pipeline; the blocker is the teleport-arrival snap firing BEFORE the dest landblock hydrates) against the current code (5 verified seams) and lays out Approach C: G.3a core teleport-into-dungeon: hold-until-hydration on the arrival path (reuse #107 IsSpawnCellReady + IsSpawnClaimUnhydratable) + #111 validated-claim EnvCell placement + dest-ready streaming query + dest-coord validation + timeout safety + decouple EnvCell physics/visibility hydration from the render-mesh guard. -> VISUAL GATE G.3b #95 stab_list bounding — CONDITIONAL on the gate showing the blowup (its repro is stale, from the T4-deleted WB path; the current flood is landblock-confined + enqueue-once, so #95 is likely superseded). G.3c faithful TeleportAnimState portal-tunnel FSM (decomp 004d6300 / 219405-219774); the TAS_TUNNEL hold-exit gates on G.3a's same readiness predicate (the tunnel IS the hold's visual form). G.3d recall game-actions (/ls etc.) — same arrival flow; doubles as the test lever. Supersedes the §12 port-plan of r09 (most of it already shipped); r09 stays the wire/format/recall contract reference. Resolves the handoff's 4 open questions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-13-dungeon-support-design.md | 428 ++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-13-dungeon-support-design.md diff --git a/docs/superpowers/specs/2026-06-13-dungeon-support-design.md b/docs/superpowers/specs/2026-06-13-dungeon-support-design.md new file mode 100644 index 00000000..c0fae795 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-dungeon-support-design.md @@ -0,0 +1,428 @@ +# Phase G.3 — Dungeon Support (Design Spec) + +> **Status:** APPROVED design (brainstorm 2026-06-13). Next: `writing-plans`. +> **Milestone:** M1.5 ("Indoor world feels right"). G.3 is the remaining M1.5 +> exit-gate. M2 (CombatMath) stays deferred until this lands. +> **Issue:** [#133](../../ISSUES.md) (teleport-into-dungeon snaps to ocean) + +> [#95](../../ISSUES.md) (dungeon portal-graph visibility blowup — re-assessed +> below). +> **Supersedes** the §12 port-plan of +> [`docs/research/deepdives/r09-dungeon-portal-space.md`](../../research/deepdives/r09-dungeon-portal-space.md): +> most of R9's "new types" (EnvCell loader/renderer/physics, PortalVisibility +> BFS, multi-cell transit) already shipped and power the building/cellar demo. +> r09 stays the **retail contract reference** for the wire formats, the +> EnvCell/CellPortal layout, and the recall taxonomy. + +--- + +## 0. TL;DR + +Dungeons don't work because of **one timing+placement gap on one code path**, +not a terrain-less-pipeline rewrite. A dungeon landblock (e.g. `0x0125`, the +Holtburg-area meeting hall) is a **flat-terrain** landblock (`LandBlock` +present, all-zero heights) + 71 EnvCells + no buildings — it already streams, +renders, and collides through the existing pipeline. The teleport-arrival +handler snaps the player **before** that landblock has streamed in, so Resolve +falls back to the resident Holtburg blocks and lands the player in ocean. + +The fix is retail's own shape: **hold the player in portal space until the +destination cell is hydrated, then place into the EnvCell** — reusing the +#107/#111 login machinery — and then layer retail's portal-tunnel visual +(`TeleportAnimState`) on top. We ship it in four installments, gated by one +visual acceptance test. + +--- + +## 1. Corrected root cause (verified) + +### 1.1 The "terrain-less landblock" framing is WRONG (dat-verified) + +A prior research pass assumed dungeon landblocks have no `LandBlock` record, so +`LandblockLoader.Load` returns null and the whole streaming/render/physics +pipeline needs terrain-less support. **A direct dat probe +(`DungeonLandblockDatProbeTests`, committed) refutes that:** + +``` +0x0125 (dungeon): LandBlock 0x0125FFFF PRESENT, Height[81] allZero=True (flat) + LandBlockInfo: NumCells=71, Buildings=0, Objects=0 + EnvCells 0x0100.. present (the 71 dungeon rooms) +0xA9B4 (Holtburg): LandBlock PRESENT, heights non-zero; NumCells=123, Buildings=12, Objects=114 +``` + +A dungeon landblock is a **flat-terrain landblock** (lowest/"ocean" terrain +height index) **plus its EnvCells, no buildings/objects**. `LandblockLoader.Load` +returns a valid flat landblock; the terrain mesh builds a flat plane; +`PhysicsEngine.AddLandblock` gets a valid flat `TerrainSurface`. **The existing +pipeline already streams a dungeon landblock.** This matches ACE's `IsDungeon` +(all heights 0 + `NumCells > 0` + no buildings — `Landblock.cs:575`) and the +single-landblock rule (`Player_Tick.cs:548-560` forbids moving between dungeon +landblocks without a teleport — so "multi-landblock dungeon LOD" is moot). + +### 1.2 The real blocker: teleport TIMING + PLACEMENT + +`OnLivePositionUpdated` (`src/AcDream.App/Rendering/GameWindow.cs:4877-4961`) +detects teleport arrival as **any** player position update while in PortalSpace +(correct, per #107), then **unconditionally**: + +1. Recenters streaming to the destination landblock (`_liveCenterX/Y`, `:4908-4925`). +2. **Immediately** calls `_physicsEngine.Resolve(destPos, destCell, …)` to snap + the player (`:4927-4931`) — **before the destination landblock has streamed in**. +3. Snaps entity + controller (`:4935-4939`), exits PortalSpace (`:4950`), sends + `LoginComplete` (`:4953-4959`). + +Because the dungeon landblock isn't resident yet, Resolve can't find the +destination cell, falls back to an **outdoor scan against the still-resident +Holtburg landblocks**, and snaps to `0xA9B3000E` (Holtburg's south edge — local +`(30,−60)` maps into the block south of the A9B4 spawn). Streaming then shifts +the frame out from under the player → they slide south into ocean. ACE logs the +matching `failed transition for +Acdream from 0x01250126 … to 0xA9B0000E …` +chain (captured in `launch-dungeon-diag.log`). + +**There is no hold-until-hydration on the teleport-arrival path.** The #107 +*login* path directly above it (`GameWindow.cs:1010-1024`) HAS exactly this gate; +the teleport path doesn't. + +--- + +## 2. Grounded seam facts (the design rests on these) + +All five verified against current code this session (high confidence). + +### 2.1 Teleport-arrival + PortalSpace FSM +- `OnTeleportStarted` (`GameWindow.cs:~4971-4976`) — on `PlayerTeleport (0xF751)` + sets `_playerController.State = PlayerState.PortalSpace`, freezing movement. +- `PlayerMovementController.Update` (`PlayerMovementController.cs:840-854`) returns + a zero-movement result while `State == PortalSpace` — **PortalSpace already + doubles as the input-freeze.** It can equally serve as the hydration-wait gate. +- Exit is **only** via the arrival detection in `OnLivePositionUpdated` + (`:4880`). No timeout, no cell-hydration gate today. + +### 2.2 #107/#111 login machinery (directly reusable) +- `PhysicsEngine.IsSpawnCellReady(cellId)` (`PhysicsEngine.cs:468-472`): outdoor + (`cellId & 0xFFFF < 0x0100`) → always ready; indoor → `DataCache.GetCellStruct(cellId) + is not null` (the cell's physics BSP has hydrated). +- `IsSpawnClaimUnhydratable(claim)` (`GameWindow.cs:11728-11748`): fetches the dat + `LandBlockInfo` at `(lb & 0xFFFF0000) | 0xFFFE`; a claim whose low word is + `>= 0x0100 + NumCells` (or `NumCells==0`) can **never** hydrate → reject fast + (distinguishes a bogus claim from a not-yet-streamed one). +- #107 login hold (`GameWindow.cs:1010-1024`): `isSpawnGroundReady` waits for + terrain AND (claim outdoor OR `IsSpawnCellReady` OR `IsSpawnClaimUnhydratable`). + No timeout today (login can afford to wait forever; teleport cannot — see §5). +- #111 validated-claim placement (`PhysicsEngine.cs:626-646`): when + `snapDiag (zero-delta) && adjustedFound && indoor`, place via + `WalkableFloorZNearest` (`:383-406`) — projects Z onto the claim cell's **own + physics walkable polygons** (`normal.Z >= PhysicsGlobals.FloorZ`, 0.6642), + cell-local, nearest to the reference Z. Returns `null` if the cell isn't + hydrated → falls through to the legacy `bestCell` scan (**the ocean bug**). +- **The teleport-arrival Resolve call is already the same shape as login entry.** + The gate only needs to sit in front of it; no change to Resolve or + WalkableFloorZNearest. (Both already key on the full prefixed cell id + + indoor/outdoor.) + +### 2.3 Streaming far recenter (works as-is) +- `StreamingRegion.RecenterTo` (`StreamingRegion.cs:180-283`) recomputes the + near/far Chebyshev window **from scratch** around the new center — a 42 km jump + is treated identically to a 1-step move. No incremental-movement assumption. +- Drain: `StreamingController` applies ≤ `MaxCompletionsPerFrame` (default 4) + results/frame; `ApplyLoadedTerrainLocked` (`GameWindow.cs:5941-6150`) does GPU + upload + cell-visibility registration + AABB + `PhysicsEngine.AddLandblock` + + EnvCell/portal registration. Estimate: **~7-8 frames (~120-130 ms)** to hydrate + a 5×5 near window; physics ready +1-2 frames. +- Recenter keeps the old neighborhood until hysteresis unload (NearRadius+2 + demote, FarRadius+2 unload), so the player isn't instantly stranded. +- **New code needed:** a "destination landblock applied" query + dest-coord + validation (reject out-of-world coords — a malformed portal dest would otherwise + leave the player in an invisible, unloadable landblock). + +### 2.4 EnvCell hydration coupling (latent landmine — decouple) +- In `BuildInteriorEntitiesForStreaming` (`GameWindow.cs:5564-5651`), both + `BuildLoadedCell` (the portal-visibility node) **and** + `_physicsDataCache.CacheCellStruct` (the physics BSP) sit **inside** the render + guard `if (cellSubMeshes.Count > 0)` (`:5602`). A cell whose render mesh is empty + (`CellMesh.Build` returns nothing — e.g. all-untextured/`Stippling.NoPos` polys) + silently gets **no visibility node and no collision**, even if it has walkable + physics polygons. `CellTransit.FindTransitCellsSphere` then `GetCellStruct → null + → continue` (silently skips it) → fall-through-floor. +- A normal dungeon *room* has textured walls → non-empty submeshes → the guard + passes, so this is **probably not the meeting-hall blocker** — but it is a real + correctness landmine for any geometry-less collision cell, and decoupling is + cheap and retail-correct (physics/visibility do not depend on visible geometry). + **Fix:** gate `CacheCellStruct` on `cellStruct.PhysicsBSP != null` and + `BuildLoadedCell` on `cellStruct != null`, independent of the render submesh + count. (`CacheCellStruct` already early-returns on null BSP internally — + `PhysicsDataCache.cs:172` — so moving it out is safe.) + +### 2.5 #95 — dungeon portal-graph visibility blowup (RE-ASSESSED: likely superseded) +- ISSUES.md #95 (`888-913`): on a 2026-05-21 **A6.P1 scen5 (Town Network hub)** + trace, `visibleCells` per cell exploded to 135-145 with spurious cells from + landblocks `0x020A`/`0x0408` (other dungeons). Its "Files" point at the WB + `EnvCellRenderManager`/`VisibilityManager` + the Streaming cell-cache. +- **That code path was DELETED by the T1-T6 render rewrite (2026-06-11)** (T4: + "per-frame ACME BFS deleted… InteriorRenderer/DrawPortal deleted"). The current + flood, `PortalVisibilityBuilder.Build`, (a) confines neighbors to the camera + cell's landblock (`lbMask = cameraCell.CellId & 0xFFFF0000`, `:131`) and (b) has + **enqueue-once termination** (`queued` HashSet, `:165` — "at most N cells are + ever processed"). Since AC dungeons are single-landblock, that confinement is + *correct*, and the cross-landblock 135-cell blowup **structurally cannot + reproduce**: a single-landblock flood visits ≤ `NumCells` distinct cells (71 for + the meeting hall). +- **Verdict:** #95's evidence is stale, from a deleted path; the current pipeline + is bounded. Treat #95 as **likely superseded, unverified**. The meeting-hall + demo (71 cells, one landblock) IS its empirical test. **Do not pre-build the + stab_list bounding port** against a dead repro (G.3b is conditional — §3.2). + +--- + +## 3. The plan (Approach C — phased full-G.3) + +Each installment lands a **complete retail behavior** (the BR-2 half-port +lesson). The visual gate sits as early as possible, right after the core. + +### 3.1 G.3a — Core teleport-into-dungeon (the blocker) + +**Goal:** teleporting into the meeting-hall dungeon lands the player standing in +the dungeon cell, on the floor, with walls blocking — no ocean, no ACE +`failed transition` spam. + +**New component — `TeleportArrivalController`** (`src/AcDream.App/World/`): +- Owns a small phase: `Idle / Holding / Placing`, plus `_pendingArrival` + `(destPos, destCellId, deadline)`. +- Lives outside `GameWindow` (Code Structure Rule 1: no new feature bodies in the + god-object). `GameWindow.OnLivePositionUpdated` hands the arrival to it and + calls its per-frame `Tick`; `GameWindow` keeps only the wiring. +- Unit-testable in isolation (no GL, fake readiness predicate + fake Resolve). + +**Control flow (replaces the unconditional snap at `GameWindow.cs:4927-4950`):** +1. On arrival update in PortalSpace: validate `destCellId`'s landblock coords are + in-world; recenter streaming + prioritize-load the dest landblock (existing + path); stash `_pendingArrival`; enter `Holding`. Re-send `LoginComplete` + immediately (holtburger-conformant — `messages.rs:434`; do **not** wait for + assets to send it). +2. Each frame in `Holding`, evaluate the **readiness predicate**: + - `IsSpawnClaimUnhydratable(destCell)` → impossible claim: stop holding, place + via the safety-net demote (loud log), exit PortalSpace. + - `now > deadline` (timeout, ~10 s) → force-snap via safety-net demote + loud + log, exit PortalSpace. (See §5 — failure-surfacing, not symptom-masking.) + - `IsLandblockApplied(destLb) && IsSpawnCellReady(destCell)` → ready: go to 3. + - else stay frozen, retry next frame. +3. `Placing`: call the **existing** `Resolve(destPos, destCell, Vector3.Zero, …)`. + Because the cell is now hydrated, Resolve takes the #111 validated-claim branch + → `WalkableFloorZNearest` grounds the player on the EnvCell floor. Snap entity + + controller (existing `:4935-4939` code), exit PortalSpace, resume input. + +**New streaming query — `IsLandblockApplied(uint landblockId)`** (on +`StreamingController` / `GpuWorldState`): true once the landblock's terrain has +been applied (AABB set in `ApplyLoadedTerrainLocked`) **and** `AddLandblock` has +run into physics. Gate the hold on this, not on the GPU mesh alone. + +**Dest-coord validation:** in `OnLivePositionUpdated`, reject a destination whose +`(lbX, lbY)` is out of the world grid before recenter; log + abort the teleport +hold rather than recenter to a phantom block. + +**Hydration decouple (§2.4):** move `BuildLoadedCell` + `CacheCellStruct` out of +the `cellSubMeshes.Count > 0` guard in `BuildInteriorEntitiesForStreaming`. Gate +each on its own non-null precondition. + +**Acceptance (G.3a):** the visual gate in §6. This gate also empirically settles +#95 (does the flood blow up?) and the hydration coupling (does collision work?). + +### 3.2 G.3b — #95 visibility bounding (CONDITIONAL) + +**Trigger:** *only* if the G.3a visual gate shows the see-through-walls / +other-dungeon-geometry blowup at the meeting hall. + +**If triggered:** port retail `CEnvCell::grab_visible_cells` (`:311878`) — a cell +with `seen_outside == 0` loads ZERO terrain and walks ONLY its `stab_list` of +adjacent EnvCells; the portal graph is bounded by the dungeon's own cell +adjacency, never a radius. Verify the dat carries the stab_list and acdream's +EnvCell loader parses it before relying on it. + +**If NOT triggered:** close #95 as **superseded** (deleted WB path; current flood +bounded) with a one-line ISSUES.md note. **No speculative build.** + +### 3.3 G.3c — Portal-tunnel loading visual (faithful `TeleportAnimState`) + +**Goal:** the retail portal-space transition, ported faithfully (user decision +2026-06-13). Reconciles the older r09 §6 ("there is no loading screen") with the +named-retail decomp where this FSM actually lives. + +**Oracle:** `gmSmartBoxUI::BeginTeleportAnimation` (`004d6300`, named-retail line +218888) + the per-frame FSM (`219405-219774`). States: +`TAS_WORLD_FADE_OUT → TAS_TUNNEL_FADE_IN → TAS_TUNNEL / TAS_TUNNEL_CONTINUE → +TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN → (off)`. `m_pPortalSpace` is a +`UIElement_Viewport` rendering the tunnel scene (creature-mode objects + +`DISTANT_LIGHT` + smartbox FOV; `SetVisible(1)` on enter, `SetVisible(0)` on the +`TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN` edge at `219742-219747`). + +**Key architectural unification:** the `TAS_TUNNEL`/`TAS_TUNNEL_CONTINUE` **hold +state's exit gates on the same readiness predicate as G.3a** — retail's loading +visual and the hold-until-hydration gate are *one mechanism* (the tunnel is the +visual form of the hold). G.3a ships the bare PortalSpace freeze; G.3c wraps it +in the tunnel viewport + the fade FSM, exit-gated identically. + +**Port workflow:** grep-named → decompile `BeginTeleportAnimation` + the FSM → +pseudocode (durations, fade math, viewport scene construction) → port → test. +Detail deferred to the G.3c implementation phase; this spec fixes the design +(states, transitions, the readiness-gated hold) + the oracle pointers. + +### 3.4 G.3d — Recall game-actions + +Outbound **zero-payload** game-action builders (r09 §7.1): `TeleToLifestone +0x0063`, `TeleToHouse 0x0262`, `TeleToMansion 0x0278`, `TeleToMarketPlace 0x028D`, +`RecallAllegianceHometown 0x02AB`, `TeleToPkArena 0x0027`. The client only sends +the request; the server validates, plays the recall animation, then drives the +**same** `PlayerTeleport → UpdatePosition` arrival flow. + +Value: (1) doubles as the **easy test lever** for G.3a/G.3c — `/ls` triggers a +teleport with no portal-click choreography; (2) completes the recall UX (keybinds +exist; the wire sends + return handling did not). Wire through the existing +command bus. + +--- + +## 4. Data flow (the teleport happy path) + +``` +1. PlayerTeleport(0xF751) → OnTeleportStarted: enter PortalSpace, freeze input + [G.3c: BeginTeleportAnimation(TAS_WORLD_FADE_OUT)] +2. fake UpdatePosition(destCell) → validate dest coords → recenter streaming to dest lb + → prioritize-load dest lb → re-send LoginComplete +3. HOLD (TeleportArrivalController.Tick, each frame in PortalSpace): + ready = IsLandblockApplied(destLb) && IsSpawnCellReady(destCell) + - not ready → stay frozen, retry [G.3c: tunnel holds in TAS_TUNNEL/_CONTINUE] + - impossible → IsSpawnClaimUnhydratable → safety-net demote + loud log + - timeout → force-snap + loud log + leave PortalSpace +4. READY → Resolve(destPos, destCell) → #111 validated-claim branch + → WalkableFloorZNearest places on the EnvCell floor + → SetPosition(entity + controller) → exit PortalSpace, resume input + [G.3c: TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN → off] +``` + +(ACE server send-order, for reference — `Player_Location.Teleport:686`: +`PlayerTeleport(seq)` → fake `UpdatePosition` (start client load) → +`DoTeleportPhysicsStateChanges` (hidden / ignoreCollisions) → real +`UpdatePosition` → `OnTeleportComplete` after `CreateWorldObjectsCompleted`.) + +--- + +## 5. Error handling + +| Failure | Handling | No-workaround rationale | +|---|---|---| +| Impossible / poisoned claim (cell id ∉ `[0x0100, 0x0100+NumCells)`, or no struct + no surface) | `IsSpawnClaimUnhydratable` → safety-net demote (`PhysicsEngine.Resolve` head, `:536-570`) + loud log; never hold forever | Reuses the validated #107/#111 reject; no new masking | +| Dest LB fails to stream (worker crash / corrupt dat / OOB coords) | Timeout ceiling (~10 s) → force-snap + loud log + leave PortalSpace | **Surfaces** the failure (visible bad placement + log), does not freeze the client or silence the cause; gets a divergence-register row | +| Mid-hold entity-rescue race | Already serialized by `_datLock` during recenter (verified, seam-3) | No change | + +The timeout is the one judgment call: holding forever on a never-hydrating +landblock would soft-lock the client. The chosen behavior **fails loudly and +visibly** (force-snap + log), which is the opposite of a symptom-masking grace +period — it makes a broken teleport obvious rather than hiding it. It is recorded +as a deliberate adaptation (retail loads synchronously; async streaming has no +direct analog). + +--- + +## 6. Testing & acceptance + +### 6.1 Headless / unit +- `TeleportArrivalController` FSM: `Idle → Holding → Placing` happy path; + impossible-claim immediate reject; timeout force-snap; ready-predicate gating + (fake `IsLandblockApplied` / `IsSpawnCellReady`). +- Hydration-decouple test: a geometry-less EnvCell (empty render mesh, non-empty + physics BSP) still gets `CacheCellStruct` + `BuildLoadedCell`. +- `TeleportFlowTests`: fake `PlayerTeleport` + `UpdatePosition` wire → controller + phase transitions + input-gate flips. +- `DungeonLandblockDatProbeTests` (exists): pins `0x0125` = flat + 71 cells. +- G.3c: `TeleportAnimState` FSM transition test (state sequence + the + readiness-gated `TAS_TUNNEL` hold-exit). +- G.3d: recall-builder byte tests (opcode + empty payload, per builder). + +### 6.2 Visual gate (the acceptance test — after G.3a) +Teleport into the meeting-hall dungeon via the portal: +- Player stands **in the dungeon cell**, on the floor (not ocean, not falling). +- The dungeon renders; navigate **3–5 rooms**; **walls block** movement. +- **No ocean / no ACE `failed transition` spam.** +- (Implicitly) the portal flood does **not** blow up (#95 check) and collision + works in every room (hydration-coupling check). + +`ACDREAM_PROBE_CELL=1` + `ACDREAM_PROBE_VIEWER=1` + `ACDREAM_WB_DIAG=1` + the +always-on `[snap]` / `live: teleport` lines capture the chain (the +`launch-dungeon-diag.log` protocol from this session). + +### 6.3 Per-installment build/test gates +Each installment: `dotnet build` green + `dotnet test` green +(App / Core / UI / Net suites) before it's "done"; G.3a additionally requires the +visual gate. + +--- + +## 7. Retail divergence register impact + +- **G.3a timeout force-snap** → NEW row (adaptation: async streaming hold has no + synchronous-retail analog; retail loads the cell set synchronously before + `SetPositionInternal`). +- **Hydration decouple** → NO row (bug fix retiring an incidental render↔physics + coupling; restores retail-correct independence). +- **G.3c** → only a row if a faithful asset can't be reproduced (e.g. the tunnel + viewport scene) and a documented courtesy substitute is shipped. +- **#95 close-as-superseded** (if G.3b not triggered) → ISSUES.md note only. + +--- + +## 8. Component boundaries (what each unit does / depends on) + +| Unit | Location | Does | Depends on | +|---|---|---|---| +| `TeleportArrivalController` | `AcDream.App/World/` | Owns the `Idle/Holding/Placing` phase + `_pendingArrival`; decides hold-vs-place each frame | readiness predicate (injected), `Resolve` (injected), PortalSpace state | +| readiness predicate | `StreamingController` + `PhysicsEngine` | `IsLandblockApplied(lb)` ∧ `IsSpawnCellReady(cell)`; `IsSpawnClaimUnhydratable(cell)` | dat `LandBlockInfo`, `DataCache` | +| hydration decouple | `GameWindow.BuildInteriorEntitiesForStreaming` | `BuildLoadedCell` + `CacheCellStruct` gated on cellStruct/BSP, not render mesh | `cellStruct`, `PhysicsBSP` | +| `TeleportAnimState` FSM (G.3c) | `AcDream.App` UI/render | Portal-tunnel fade FSM; hold-exit gated on the readiness predicate | `m_pPortalSpace` viewport, the readiness predicate | +| recall builders (G.3d) | `AcDream.Core/Network/Actions` | Zero-payload outbound game actions | command bus | + +`AcDream.Core` gains no GL/window dependency. The controller + FSM live in +`AcDream.App`; the readiness predicate's physics half lives in `AcDream.Core` +(pure), its streaming half in `AcDream.App`. + +--- + +## 9. References cited + +- **Current code (verified this session):** `GameWindow.cs` 4877-4961 (arrival), + ~4971-4976 (`OnTeleportStarted`), 1010-1024 (#107 login gate), 11728-11748 + (`IsSpawnClaimUnhydratable`), 5564-5651 (EnvCell hydration guard), 5941-6150 + (`ApplyLoadedTerrainLocked`); `PhysicsEngine.cs` 468-472 (`IsSpawnCellReady`), + 626-646 (#111 validated claim), 383-406 (`WalkableFloorZNearest`), 536-570 + (Resolve safety net); `StreamingRegion.cs` 180-283 (`RecenterTo`); + `StreamingController.cs` 120-149 (drain); `PortalVisibilityBuilder.cs` 131 + (lbMask), 165 (enqueue-once); `CellTransit.cs` 515-516 (null-skip); + `PhysicsDataCache.cs` 172 (null-BSP early-return). +- **Decomp (named-retail):** `BeginTeleportAnimation` `004d6300` (line 218888) + + the `TeleportAnimState` FSM 219405-219774; `m_pPortalSpace` viewport + 218829/219363; `CEnvCell::grab_visible_cells` `:311878` (G.3b stab_list). +- **holtburger:** `messages.rs:434` (client re-sends `LoginComplete` on teleport). +- **ACE:** `Player_Location.Teleport:686` (send order); `Landblock.cs:575` + (`IsDungeon`); `Player_Tick.cs:548-560` (single-landblock dungeons); recall + handlers + `Portal.ActOnUse`/`AdjustDungeon`. +- **r09 deepdive:** `docs/research/deepdives/r09-dungeon-portal-space.md` (EnvCell + / CellPortal wire layout, recall taxonomy, the retail contract). +- **Issues:** [#133](../../ISSUES.md), [#95](../../ISSUES.md). +- **Digests (DO-NOT-RETRY tables apply):** `project_render_pipeline_digest`, + `project_physics_collision_digest`. + +--- + +## 10. Open questions (resolved here; revisit only if the gate disagrees) + +1. **Loading visual now or later?** Faithful `TeleportAnimState` in G.3c (user + decision). Unified with the G.3a hold (the tunnel IS the hold's visual). +2. **Hold timeout/failure?** Reject impossible claims instantly + (`IsSpawnClaimUnhydratable`); hold plausible-but-slow with a ~10 s ceiling; + on timeout force-snap + loud log (fail visibly, never freeze). +3. **Big-jump streaming?** Verified to work (Chebyshev recenter). Add only + `IsLandblockApplied` + dest-coord validation. +4. **EnvCell placement vs flat terrain?** The #111 `WalkableFloorZNearest` EnvCell + path (identical to the cellar path that already works); the flat terrain + renders below. The gate guarantees the cell is hydrated before Resolve runs. +5. **(New, deferred to G.3b/implementation)** Does the dat carry a parsed + `stab_list` for `grab_visible_cells` bounding? Only matters if the gate shows + the #95 blowup. From c9650bd3bdb555be746a35a02ec1be6dc48073c8 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 17:02:03 +0200 Subject: [PATCH 28/65] plan(G.3a): core teleport-into-dungeon implementation plan (#133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD plan for the gated G.3a core: a pure TeleportArrivalController state machine (hold-until-hydration + force-snap on impossible/timeout) + its GameWindow wiring (replace the unconditional arrival snap with recenter + deferred BeginArrival; per-frame Tick; readiness predicate reusing the #107 login triplet) + the EnvCell physics/visibility hydration decouple + the visual acceptance gate. G.3b/c/d get their own plans after the gate. Also syncs the spec: the readiness predicate reuses SampleTerrainZ + IsSpawnCellReady + IsSpawnClaimUnhydratable (the validated #107 login gate) rather than a new IsLandblockApplied query — strictly more faithful, less new surface. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-13-dungeon-support-g3a.md | 633 ++++++++++++++++++ .../2026-06-13-dungeon-support-design.md | 31 +- 2 files changed, 653 insertions(+), 11 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md diff --git a/docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md b/docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md new file mode 100644 index 00000000..4391fca3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md @@ -0,0 +1,633 @@ +# G.3a — Core Teleport-Into-Dungeon Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make teleporting into a dungeon land the player standing in the dungeon cell (on the floor, walls blocking) instead of snapping to ocean — by holding the player in portal space until the destination landblock/cell streams in, then placing via the existing validated-claim path. + +**Architecture:** Replace the unconditional snap in `GameWindow.OnLivePositionUpdated` with a small, pure, unit-tested `TeleportArrivalController` state machine. On a teleport arrival the handler recenters streaming (kicks off the load) but **defers** the snap; a per-frame `Tick` reuses the #107 login readiness triplet (`SampleTerrainZ` ∧ (`outdoor` ∨ `IsSpawnCellReady`); `IsSpawnClaimUnhydratable` short-circuits impossible claims) and places the player via the unchanged `PhysicsEngine.Resolve` once the destination is ready. A coarse frame-count timeout fails loudly rather than freezing. Plus a small decouple of EnvCell physics/visibility hydration from the render-mesh guard. + +**Tech Stack:** C# .NET 10, xUnit, Silk.NET (App layer). No new dependencies. + +**Spec:** [`docs/superpowers/specs/2026-06-13-dungeon-support-design.md`](../specs/2026-06-13-dungeon-support-design.md) (§3.1, §4, §5). + +**Scope:** This plan is **G.3a only** — the gated core that ends at the visual acceptance test. G.3b (#95 stab_list bounding, *conditional* on the gate showing a blowup), G.3c (faithful `TeleportAnimState` tunnel FSM), and G.3d (recall game-actions) each get their own plan **after** the G.3a gate passes. + +--- + +## File Structure + +| File | Responsibility | Action | +|---|---|---| +| `src/AcDream.App/World/TeleportArrivalController.cs` | Pure state machine: hold a teleport arrival until ready, then place (or force-place on impossible/timeout). No GL/dat/network — readiness + placement are injected delegates. | **Create** | +| `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs` | Unit tests for the state machine (all transitions, timeout, re-arm). | **Create** | +| `src/AcDream.App/Rendering/GameWindow.cs` | Wire the controller in: construct lazily, the readiness + placement callbacks, replace the unconditional arrival snap (`:4877-4961`) with recenter + `BeginArrival`, add per-frame `Tick` (after `:6838`). Decouple EnvCell physics/visibility hydration from the render-mesh guard (`:5601-5652`). | **Modify** | + +`TeleportArrivalController` is deliberately a *pure* unit (App layer, `System.Numerics` only) so it is testable without standing up the renderer. GameWindow keeps only the wiring + closures over its runtime state (Code Structure Rule 1). + +--- + +## Task 1: `TeleportArrivalController` (pure state machine, TDD) + +**Files:** +- Create: `src/AcDream.App/World/TeleportArrivalController.cs` +- Test: `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs`: + +```csharp +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.World; +using Xunit; + +namespace AcDream.App.Tests.World; + +public class TeleportArrivalControllerTests +{ + // Records each Place(destPos, destCell, forced) call. + private sealed record PlaceCall(Vector3 Pos, uint Cell, bool Forced); + + private static TeleportArrivalController Make( + ArrivalReadiness verdict, + List placed, + int maxHoldFrames = TeleportArrivalController.DefaultMaxHoldFrames) + => new( + readiness: (_, _) => verdict, + place: (pos, cell, forced) => placed.Add(new PlaceCall(pos, cell, forced)), + maxHoldFrames: maxHoldFrames); + + [Fact] + public void BeginArrival_EntersHolding() + { + var placed = new List(); + var c = Make(ArrivalReadiness.NotReady, placed); + + c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); + + Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); + Assert.Empty(placed); + } + + [Fact] + public void Tick_WhenIdle_IsNoOp() + { + var placed = new List(); + var c = Make(ArrivalReadiness.Ready, placed); + + c.Tick(); // never began + + Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); + Assert.Empty(placed); + } + + [Fact] + public void Tick_NotReady_KeepsHolding_DoesNotPlace() + { + var placed = new List(); + var c = Make(ArrivalReadiness.NotReady, placed); + c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); + + c.Tick(); + c.Tick(); + + Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); + Assert.Empty(placed); + } + + [Fact] + public void Tick_Ready_PlacesUnforced_AndIdles() + { + var placed = new List(); + var c = Make(ArrivalReadiness.Ready, placed); + c.BeginArrival(new Vector3(30, -60, 6.005f), 0x01250126u); + + c.Tick(); + + Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); + var call = Assert.Single(placed); + Assert.False(call.Forced); + Assert.Equal(0x01250126u, call.Cell); + Assert.Equal(new Vector3(30, -60, 6.005f), call.Pos); + } + + [Fact] + public void Tick_Impossible_PlacesForced_AndIdles() + { + var placed = new List(); + var c = Make(ArrivalReadiness.Impossible, placed); + c.BeginArrival(new Vector3(1, 2, 3), 0x0125FF00u); + + c.Tick(); + + Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); + var call = Assert.Single(placed); + Assert.True(call.Forced); + } + + [Fact] + public void Tick_Timeout_PlacesForced_AfterMaxHoldFrames() + { + var placed = new List(); + var c = Make(ArrivalReadiness.NotReady, placed, maxHoldFrames: 3); + c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); + + c.Tick(); // 1 + c.Tick(); // 2 + Assert.Empty(placed); + Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); + + c.Tick(); // 3 -> timeout + + var call = Assert.Single(placed); + Assert.True(call.Forced); + Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); + } + + [Fact] + public void BeginArrival_AfterPlace_ReArms() + { + var placed = new List(); + var c = Make(ArrivalReadiness.Ready, placed); + + c.BeginArrival(new Vector3(1, 0, 0), 0x01250126u); + c.Tick(); // places #1, idle + c.BeginArrival(new Vector3(2, 0, 0), 0x01250127u); + c.Tick(); // places #2, idle + + Assert.Equal(2, placed.Count); + Assert.Equal(0x01250127u, placed[1].Cell); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalControllerTests"` +Expected: FAIL — `TeleportArrivalController` / `ArrivalReadiness` / `TeleportArrivalPhase` do not exist (compile error). + +- [ ] **Step 3: Write the implementation** + +Create `src/AcDream.App/World/TeleportArrivalController.cs`: + +```csharp +using System; +using System.Numerics; + +namespace AcDream.App.World; + +/// Verdict from the per-frame readiness probe for a held teleport arrival. +public enum ArrivalReadiness +{ + /// Destination not yet hydrated; keep holding. + NotReady, + + /// Destination terrain + cell are ready; place now. + Ready, + + /// The claim can never hydrate (e.g. an indoor cell id outside the dat's + /// LandBlockInfo.NumCells range). Place immediately via the caller's safety-net + /// demote rather than hold forever. + Impossible, +} + +/// Lifecycle of a single teleport arrival. +public enum TeleportArrivalPhase { Idle, Holding } + +/// +/// G.3a (#133) — holds a teleport arrival in portal space until the destination +/// dungeon landblock/cell has streamed in, THEN places the player. Replaces the +/// unconditional snap in GameWindow.OnLivePositionUpdated that resolved the +/// arrival against the resident (old) landblocks before the destination hydrated +/// and landed the player in ocean. +/// +/// The controller is pure: readiness and placement are injected delegates, +/// so it carries no GL / dat / network dependency and is fully unit-testable. The +/// player stays input-frozen while this is Holding because the GameWindow keeps +/// PlayerState.PortalSpace until the placement delegate flips it back to +/// InWorld. +/// +/// The timeout is a coarse frame count (not wall-clock) so the controller +/// needs no external clock; it is a loud safety net for a never-hydrating +/// destination, not a precise deadline. +/// +public sealed class TeleportArrivalController +{ + /// ~10 s at 60 fps. Coarse safety net for a destination that never streams. + public const int DefaultMaxHoldFrames = 600; + + private readonly Func _readiness; + private readonly Action _place; // (destPos, destCell, forced) + private readonly int _maxHoldFrames; + + private Vector3 _destPos; + private uint _destCell; + private int _heldFrames; + + public TeleportArrivalPhase Phase { get; private set; } = TeleportArrivalPhase.Idle; + + public TeleportArrivalController( + Func readiness, + Action place, + int maxHoldFrames = DefaultMaxHoldFrames) + { + _readiness = readiness ?? throw new ArgumentNullException(nameof(readiness)); + _place = place ?? throw new ArgumentNullException(nameof(place)); + _maxHoldFrames = maxHoldFrames; + } + + /// Begin holding for a teleport arrival. Called from OnLivePositionUpdated + /// AFTER the streaming origin has been recentered on the destination landblock. + /// Re-calling with a fresh server position resets the hold (server-authoritative). + public void BeginArrival(Vector3 destPos, uint destCell) + { + _destPos = destPos; + _destCell = destCell; + _heldFrames = 0; + Phase = TeleportArrivalPhase.Holding; + } + + /// Per-frame: evaluate readiness and place when ready / impossible / timed out. + /// No-op when Idle. + public void Tick() + { + if (Phase != TeleportArrivalPhase.Holding) return; + _heldFrames++; + + ArrivalReadiness verdict = _readiness(_destPos, _destCell); + if (verdict == ArrivalReadiness.Ready) + { + Place(forced: false); + return; + } + + if (verdict == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames) + { + Place(forced: true); + } + // else NotReady -> keep holding + } + + private void Place(bool forced) + { + _place(_destPos, _destCell, forced); + Phase = TeleportArrivalPhase.Idle; + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalControllerTests"` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/World/TeleportArrivalController.cs tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs +git commit -m "feat(G.3a): TeleportArrivalController hold-until-hydration state machine (#133) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: Wire `TeleportArrivalController` into GameWindow + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (add field; lazy construct + 2 callbacks; replace the arrival snap at `:4877-4961`; per-frame `Tick` after `:6838`) + +This task has no isolated unit test (it edits the 10k-line runtime god-object). It is verified by `dotnet build` + `dotnet test` green and the Task 4 visual gate. Make the edits exactly as shown. + +- [ ] **Step 1: Add the field + the lazy-construct helper + the two callbacks** + +Add near the other player/teleport fields in `GameWindow.cs` (anywhere in the field region; e.g. just above `OnTeleportStarted` at `:4971`): + +```csharp +// G.3a (#133): holds a teleport arrival in portal space until the destination +// dungeon landblock/cell has hydrated, then places the player via the unchanged +// validated-claim Resolve path. Lazily constructed on the first teleport (all +// runtime deps are wired by then). +private AcDream.App.World.TeleportArrivalController? _teleportArrival; +private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity; + +private void EnsureTeleportArrivalController() +{ + if (_teleportArrival is not null) return; + _teleportArrival = new AcDream.App.World.TeleportArrivalController( + readiness: TeleportArrivalReadiness, + place: PlaceTeleportArrival); +} + +// Reuses the #107 login readiness triplet (GameWindow.cs:1010-1024), evaluated +// against the teleport's (destPos, destCell): an impossible indoor claim short- +// circuits to immediate placement; otherwise hold until terrain is sampled and, +// for an indoor cell, the cell struct has hydrated. +private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness( + System.Numerics.Vector3 destPos, uint destCell) +{ + if (IsSpawnClaimUnhydratable(destCell)) + return AcDream.App.World.ArrivalReadiness.Impossible; + if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null) + return AcDream.App.World.ArrivalReadiness.NotReady; + bool indoor = (destCell & 0xFFFFu) >= 0x0100u; + if (indoor && !_physicsEngine.IsSpawnCellReady(destCell)) + return AcDream.App.World.ArrivalReadiness.NotReady; + return AcDream.App.World.ArrivalReadiness.Ready; +} + +// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only +// once the destination is ready (or force-run on impossible/timeout, logged loud). +private void PlaceTeleportArrival( + System.Numerics.Vector3 destPos, uint destCell, bool forced) +{ + var resolved = _physicsEngine.Resolve( + destPos, destCell, System.Numerics.Vector3.Zero, _playerController!.StepUpHeight); + var snappedPos = new System.Numerics.Vector3( + resolved.Position.X, resolved.Position.Y, resolved.Position.Z); + + if (forced) + Console.WriteLine( + $"live: teleport HOLD gave up (impossible/timeout) — force-snapping " + + $"cell=0x{destCell:X8} pos={destPos} -> 0x{resolved.CellId:X8} {snappedPos}"); + + if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) + { + pe.SetPosition(snappedPos); + pe.ParentCellId = resolved.CellId; + pe.Rotation = _pendingTeleportRot; + } + _playerController.SetPosition(snappedPos, resolved.CellId); + + _chaseCamera?.Update(snappedPos, _playerController.Yaw); + _retailChaseCamera?.Update(snappedPos, _playerController.Yaw, + playerVelocity: System.Numerics.Vector3.Zero, + isOnGround: true, + contactPlaneNormal: System.Numerics.Vector3.UnitZ, + dt: 1f / 60f); + + _playerController.State = AcDream.App.Input.PlayerState.InWorld; + Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}"); + + // Tell the server the client finished loading the new landblock (holtburger + // client/messages.rs:434 — re-send LoginComplete after each portal transition). + _liveSession?.SendGameAction( + AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); +} +``` + +- [ ] **Step 2: Construct the controller when a teleport starts** + +In `OnTeleportStarted` (`GameWindow.cs:4971-4976`), add the ensure-call after setting PortalSpace: + +```csharp +private void OnTeleportStarted(uint sequence) +{ + if (_playerController is not null) + _playerController.State = AcDream.App.Input.PlayerState.PortalSpace; + EnsureTeleportArrivalController(); + Console.WriteLine($"live: teleport started (seq={sequence})"); +} +``` + +- [ ] **Step 3: Replace the unconditional arrival snap with recenter + BeginArrival** + +Replace the entire arrival block at `GameWindow.cs:4877-4961` (from `// Phase B.3: portal-space arrival detection.` through its closing brace) with: + +```csharp + // Phase B.3 / G.3a (#133): portal-space arrival detection. + // Only runs for our own player character while in PortalSpace. + if (_playerController is not null + && _playerController.State == AcDream.App.Input.PlayerState.PortalSpace + && update.Guid == _playerServerGuid) + { + // Compute old landblock coords from controller position (using the + // current streaming origin as the reference center). + var oldPos = _playerController.Position; + int oldLbX = _liveCenterX + (int)System.Math.Floor(oldPos.X / 192f); + int oldLbY = _liveCenterY + (int)System.Math.Floor(oldPos.Y / 192f); + + bool differentLandblock = (lbX != oldLbX || lbY != oldLbY); + + Console.WriteLine( + $"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " + + $"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}"); + + System.Numerics.Vector3 newWorldPos; + if (differentLandblock) + { + // Recenter the streaming controller on the new landblock NOW (kick + // off the dungeon load). After recentering, the destination is + // (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin. + _liveCenterX = lbX; + _liveCenterY = lbY; + newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ); + } + else + { + newWorldPos = worldPos; + } + + // G.3a: do NOT snap here. The destination dungeon landblock has not + // streamed in yet; an immediate Resolve falls back to the resident + // (old) landblocks and lands the player in ocean (#133). HOLD the snap + // in portal space — TeleportArrivalController.Tick (per frame) places + // the player via PlaceTeleportArrival once the destination cell + // hydrates (TeleportArrivalReadiness == Ready), or force-places on an + // impossible claim / timeout. PortalSpace keeps input frozen meanwhile. + EnsureTeleportArrivalController(); + _pendingTeleportRot = rot; + _teleportArrival!.BeginArrival(newWorldPos, p.LandblockId); + } +``` + +- [ ] **Step 4: Add the per-frame Tick after the live-session drain** + +In `OnUpdate`, immediately after `_liveSessionController?.Tick();` (`GameWindow.cs:6838`), add: + +```csharp + // G.3a (#133): advance any held teleport arrival. Runs AFTER streaming + // (which applies the destination landblock) and the live-session drain + // (which may have just called BeginArrival), so a destination that + // hydrated this frame is placed the same frame. + _teleportArrival?.Tick(); +``` + +- [ ] **Step 5: Build + run the full suites** + +Run: `dotnet build` +Expected: build succeeds (0 errors). + +Run: `dotnet test` +Expected: all suites green (App / Core / UI / Net) — no regressions. (Counts at baseline: App 264+1skip / Core 1445+2skip / UI 420 / Net 294.) + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(G.3a): hold teleport arrival until dungeon hydrates, then place (#133) + +Replaces the unconditional OnLivePositionUpdated snap (which resolved against +the resident old landblocks before the destination streamed in -> ocean) with a +recenter + deferred BeginArrival; per-frame Tick places via the unchanged #111 +validated-claim Resolve once SampleTerrainZ + IsSpawnCellReady report ready, or +force-snaps loudly on an impossible claim / ~10s timeout. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: Decouple EnvCell physics/visibility hydration from the render-mesh guard + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs:5601-5652` + +**Why:** `BuildLoadedCell` (the portal-visibility node) and `CacheCellStruct` (the physics BSP) currently sit *inside* `if (cellSubMeshes.Count > 0)`. A collision cell with an empty render mesh would silently get no collision and no visibility node — retail couples neither to visible geometry. This is insurance for any geometry-less dungeon cell. **It touches the shared (building) hydration path**, so its acceptance includes a no-regression check on the frozen building/cellar demo. + +- [ ] **Step 1: Make the edit** + +In `BuildInteriorEntitiesForStreaming` (`GameWindow.cs:5601-5652`), the current shape is: + +```csharp +var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); +if (cellSubMeshes.Count > 0) +{ + _pendingCellMeshes[envCellId] = cellSubMeshes; + var physicsCellOrigin = envCell.Position.Origin + lbOffset; + var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( + 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); + var cellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); + var physicsCellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); + + _envCellRenderer?.RegisterCell(/* ... cellTransform, cellOrigin ... */); + BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); + _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); +} +``` + +Restructure so the transforms + physics/visibility hydration run unconditionally (they don't depend on visible geometry), and only the render registration stays behind the submesh-count guard: + +```csharp +var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); + +// G.3a (#133) hydration decouple: the cell transforms and the physics + +// visibility hydration are INDEPENDENT of whether the cell has drawable +// geometry. Retail couples neither collision nor portal visibility to a render +// mesh. Previously these sat behind `cellSubMeshes.Count > 0`, which silently +// dropped collision (CellTransit.GetCellStruct -> null -> fall through floor) +// and the visibility node for any geometry-less collision cell. CacheCellStruct +// self-gates on a null PhysicsBSP (PhysicsDataCache.cs:172), so this is safe for +// cells that genuinely have no physics. +var physicsCellOrigin = envCell.Position.Origin + lbOffset; +var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( + 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); +var cellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); +var physicsCellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); + +BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); +_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); + +// Render registration only when the cell actually has drawable submeshes. +if (cellSubMeshes.Count > 0) +{ + _pendingCellMeshes[envCellId] = cellSubMeshes; + _envCellRenderer?.RegisterCell(/* ... cellTransform, cellOrigin ... — UNCHANGED args ... */); +} +``` + +Keep the `_envCellRenderer?.RegisterCell(...)` call's argument list exactly as it is today (`cellTransform`, `cellOrigin`, etc.) — only its position in the block changes (now inside the `Count > 0` guard, with the transforms hoisted above). + +- [ ] **Step 2: Build + run the full suites** + +Run: `dotnet build` +Expected: build succeeds. + +Run: `dotnet test` +Expected: all suites green — in particular no regression in any existing EnvCell / streaming / membership test. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "fix(G.3a): hydrate EnvCell physics + visibility independent of render mesh (#133) + +BuildLoadedCell + CacheCellStruct were gated behind cellSubMeshes.Count > 0, so a +geometry-less collision cell got no collision (fall-through) and no visibility +node. Retail couples neither to visible geometry; CacheCellStruct self-gates on a +null PhysicsBSP, so this is safe. Render registration stays behind the submesh +guard. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: Visual acceptance gate (STOP — user verification) + +This is the M1.5 dungeon-demo gate and the empirical test of #95 + the hydration decouple. It cannot be automated; hand the running client to the user. + +- [ ] **Step 1: Build green** + +Run: `dotnet build` +Expected: 0 errors. + +- [ ] **Step 2: Launch against the live ACE server** + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1" +$env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" +$env:ACDREAM_PROBE_CELL = "1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-g3a-gate.log" +``` + +Run in the background; give it ~8 s to reach in-world. Use the meeting-hall portal (or `/ls` once G.3d lands) to teleport into the dungeon. + +- [ ] **Step 2: User verifies (the acceptance criteria)** + +The user confirms, in the running client: +- Player **stands in the dungeon cell**, on the floor — not ocean, not falling. +- The dungeon renders; the user can **navigate 3-5 rooms**; **walls block** movement. +- **No ocean / no ACE `failed transition` spam** (check the ACE console + `launch-g3a-gate.log`). +- **#95 check:** no see-through-walls, no other-dungeon geometry rendering inside the current dungeon (if it DOES blow up → proceed to the G.3b plan). +- **Hydration-decouple no-regression:** re-walk a Holtburg building + cellar (the frozen M1.5 demo) — walls still block, no new phantom collisions, interiors render as before. + +- [ ] **Step 3: On pass — record the milestone progress** + +- Move #133 to **Recently closed** in `docs/ISSUES.md` with the G.3a commit SHAs. +- If #95 did NOT reproduce, add a one-line note closing #95 as superseded (its repro was the T4-deleted WB cell-cache path); if it DID, leave #95 open and start the G.3b plan. +- Update the roadmap G.3 row + the milestones doc (G.3a core landed). +- Then proceed to the G.3c (faithful `TeleportAnimState`) and G.3d (recalls) plans. + +--- + +## Self-Review + +**Spec coverage (against `2026-06-13-dungeon-support-design.md` §3.1):** +- Hold-until-hydration on the arrival path → Task 2 (BeginArrival + Tick). +- Reuse #107 `IsSpawnCellReady` + `IsSpawnClaimUnhydratable` → Task 2 `TeleportArrivalReadiness`. +- #111 validated-claim EnvCell placement → Task 2 `PlaceTeleportArrival` (unchanged `Resolve`). +- Readiness predicate reuses `SampleTerrainZ` (the synced refinement) → Task 2. +- Dest-coord validation → handled by the Impossible (indoor) + timeout (outdoor) paths; **no separate task** (YAGNI — the timeout IS the malformed-dest safety net; noted in spec §10.3). +- Timeout safety (fail loudly, never freeze) → Task 1 `_maxHoldFrames` + Task 2 forced-place loud log. +- Decouple physics/visibility hydration from the render-mesh guard → Task 3. +- Visual gate (also settles #95 + hydration coupling) → Task 4. + +**Placeholder scan:** Task 1 + its tests are complete code. Task 2/3 are exact edits with full code; the only `/* ... */` is the deliberately-unchanged `RegisterCell(...)` arg list (instruction: keep verbatim, only move it) — not a content gap. Task 4 is a manual gate (correctly not code). + +**Type consistency:** `TeleportArrivalController` / `ArrivalReadiness` / `TeleportArrivalPhase` and the delegate shapes `Func` + `Action` match between Task 1's class, its tests, and Task 2's `EnsureTeleportArrivalController` / `TeleportArrivalReadiness` / `PlaceTeleportArrival`. `BeginArrival(Vector3,uint)` and `Tick()` signatures match across all three. + +**Deferred to other plans (out of G.3a scope):** #95 stab_list bounding (G.3b, conditional), `TeleportAnimState` tunnel FSM (G.3c), recall game-actions (G.3d). diff --git a/docs/superpowers/specs/2026-06-13-dungeon-support-design.md b/docs/superpowers/specs/2026-06-13-dungeon-support-design.md index c0fae795..6f62f41c 100644 --- a/docs/superpowers/specs/2026-06-13-dungeon-support-design.md +++ b/docs/superpowers/specs/2026-06-13-dungeon-support-design.md @@ -130,9 +130,12 @@ All five verified against current code this session (high confidence). a 5×5 near window; physics ready +1-2 frames. - Recenter keeps the old neighborhood until hysteresis unload (NearRadius+2 demote, FarRadius+2 unload), so the player isn't instantly stranded. -- **New code needed:** a "destination landblock applied" query + dest-coord - validation (reject out-of-world coords — a malformed portal dest would otherwise - leave the player in an invisible, unloadable landblock). +- **New code needed:** reuse the #107 login-gate **terrain-ready signal** + `_physicsEngine.SampleTerrainZ(x,y) is not null` (non-null once the destination + terrain landblock has applied) — no separate "landblock applied" query is + required. Plus dest-coord validation (reject out-of-world coords — a malformed + portal dest would otherwise leave the player in an invisible, unloadable + landblock). ### 2.4 EnvCell hydration coupling (latent landmine — decouple) - In `BuildInteriorEntitiesForStreaming` (`GameWindow.cs:5564-5651`), both @@ -203,17 +206,22 @@ the dungeon cell, on the floor, with walls blocking — no ocean, no ACE via the safety-net demote (loud log), exit PortalSpace. - `now > deadline` (timeout, ~10 s) → force-snap via safety-net demote + loud log, exit PortalSpace. (See §5 — failure-surfacing, not symptom-masking.) - - `IsLandblockApplied(destLb) && IsSpawnCellReady(destCell)` → ready: go to 3. + - `SampleTerrainZ(destPos) != null && (outdoor || IsSpawnCellReady(destCell))` + → ready: go to 3. - else stay frozen, retry next frame. 3. `Placing`: call the **existing** `Resolve(destPos, destCell, Vector3.Zero, …)`. Because the cell is now hydrated, Resolve takes the #111 validated-claim branch → `WalkableFloorZNearest` grounds the player on the EnvCell floor. Snap entity + controller (existing `:4935-4939` code), exit PortalSpace, resume input. -**New streaming query — `IsLandblockApplied(uint landblockId)`** (on -`StreamingController` / `GpuWorldState`): true once the landblock's terrain has -been applied (AABB set in `ApplyLoadedTerrainLocked`) **and** `AddLandblock` has -run into physics. Gate the hold on this, not on the GPU mesh alone. +**Readiness predicate — reuse the #107 login triplet (no new query).** The +hold gates on exactly the three checks the login auto-entry gate already uses +(`GameWindow.cs:1010-1024`), evaluated against the teleport's `(destPos, +destCell)` instead of the spawn claim: `SampleTerrainZ(destPos.X, destPos.Y) is +not null` (destination terrain applied) ∧ (outdoor cell OR +`IsSpawnCellReady(destCell)`); `IsSpawnClaimUnhydratable(destCell)` short-circuits +an impossible claim to immediate placement. This reuses proven, validated code +rather than introducing a parallel "landblock applied" query. **Dest-coord validation:** in `OnLivePositionUpdated`, reject a destination whose `(lbX, lbY)` is out of the world grid before recenter; log + abort the teleport @@ -288,7 +296,7 @@ command bus. 2. fake UpdatePosition(destCell) → validate dest coords → recenter streaming to dest lb → prioritize-load dest lb → re-send LoginComplete 3. HOLD (TeleportArrivalController.Tick, each frame in PortalSpace): - ready = IsLandblockApplied(destLb) && IsSpawnCellReady(destCell) + ready = SampleTerrainZ(destPos) != null && (outdoor || IsSpawnCellReady(destCell)) - not ready → stay frozen, retry [G.3c: tunnel holds in TAS_TUNNEL/_CONTINUE] - impossible → IsSpawnClaimUnhydratable → safety-net demote + loud log - timeout → force-snap + loud log + leave PortalSpace @@ -374,7 +382,7 @@ visual gate. | Unit | Location | Does | Depends on | |---|---|---|---| | `TeleportArrivalController` | `AcDream.App/World/` | Owns the `Idle/Holding/Placing` phase + `_pendingArrival`; decides hold-vs-place each frame | readiness predicate (injected), `Resolve` (injected), PortalSpace state | -| readiness predicate | `StreamingController` + `PhysicsEngine` | `IsLandblockApplied(lb)` ∧ `IsSpawnCellReady(cell)`; `IsSpawnClaimUnhydratable(cell)` | dat `LandBlockInfo`, `DataCache` | +| readiness predicate | `PhysicsEngine` (reused #107 triplet) | `SampleTerrainZ(pos)` ∧ (outdoor ∨ `IsSpawnCellReady(cell)`); `IsSpawnClaimUnhydratable(cell)` | `DataCache`, dat `LandBlockInfo` | | hydration decouple | `GameWindow.BuildInteriorEntitiesForStreaming` | `BuildLoadedCell` + `CacheCellStruct` gated on cellStruct/BSP, not render mesh | `cellStruct`, `PhysicsBSP` | | `TeleportAnimState` FSM (G.3c) | `AcDream.App` UI/render | Portal-tunnel fade FSM; hold-exit gated on the readiness predicate | `m_pPortalSpace` viewport, the readiness predicate | | recall builders (G.3d) | `AcDream.Core/Network/Actions` | Zero-payload outbound game actions | command bus | @@ -419,7 +427,8 @@ visual gate. (`IsSpawnClaimUnhydratable`); hold plausible-but-slow with a ~10 s ceiling; on timeout force-snap + loud log (fail visibly, never freeze). 3. **Big-jump streaming?** Verified to work (Chebyshev recenter). Add only - `IsLandblockApplied` + dest-coord validation. + dest-coord validation; the readiness gate reuses `SampleTerrainZ` (no new + streaming query). 4. **EnvCell placement vs flat terrain?** The #111 `WalkableFloorZNearest` EnvCell path (identical to the cellar path that already works); the flat terrain renders below. The gate guarantees the cell is hydrated before Resolve runs. From 7947d7ad0a4f4f426cb66b8355df5e703759f554 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 17:06:33 +0200 Subject: [PATCH 29/65] feat(G.3a): TeleportArrivalController hold-until-hydration state machine (#133) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../World/TeleportArrivalController.cs | 103 +++++++++++++++ .../World/TeleportArrivalControllerTests.cs | 123 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 src/AcDream.App/World/TeleportArrivalController.cs create mode 100644 tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs diff --git a/src/AcDream.App/World/TeleportArrivalController.cs b/src/AcDream.App/World/TeleportArrivalController.cs new file mode 100644 index 00000000..d6538533 --- /dev/null +++ b/src/AcDream.App/World/TeleportArrivalController.cs @@ -0,0 +1,103 @@ +using System; +using System.Numerics; + +namespace AcDream.App.World; + +/// Verdict from the per-frame readiness probe for a held teleport arrival. +public enum ArrivalReadiness +{ + /// Destination not yet hydrated; keep holding. + NotReady, + + /// Destination terrain + cell are ready; place now. + Ready, + + /// The claim can never hydrate (e.g. an indoor cell id outside the dat's + /// LandBlockInfo.NumCells range). Place immediately via the caller's safety-net + /// demote rather than hold forever. + Impossible, +} + +/// Lifecycle of a single teleport arrival. +public enum TeleportArrivalPhase { Idle, Holding } + +/// +/// G.3a (#133) — holds a teleport arrival in portal space until the destination +/// dungeon landblock/cell has streamed in, THEN places the player. Replaces the +/// unconditional snap in GameWindow.OnLivePositionUpdated that resolved the +/// arrival against the resident (old) landblocks before the destination hydrated +/// and landed the player in ocean. +/// +/// The controller is pure: readiness and placement are injected delegates, +/// so it carries no GL / dat / network dependency and is fully unit-testable. The +/// player stays input-frozen while this is Holding because the GameWindow keeps +/// PlayerState.PortalSpace until the placement delegate flips it back to +/// InWorld. +/// +/// The timeout is a coarse frame count (not wall-clock) so the controller +/// needs no external clock; it is a loud safety net for a never-hydrating +/// destination, not a precise deadline. +/// +public sealed class TeleportArrivalController +{ + /// ~10 s at 60 fps. Coarse safety net for a destination that never streams. + public const int DefaultMaxHoldFrames = 600; + + private readonly Func _readiness; + private readonly Action _place; // (destPos, destCell, forced) + private readonly int _maxHoldFrames; + + private Vector3 _destPos; + private uint _destCell; + private int _heldFrames; + + public TeleportArrivalPhase Phase { get; private set; } = TeleportArrivalPhase.Idle; + + public TeleportArrivalController( + Func readiness, + Action place, + int maxHoldFrames = DefaultMaxHoldFrames) + { + _readiness = readiness ?? throw new ArgumentNullException(nameof(readiness)); + _place = place ?? throw new ArgumentNullException(nameof(place)); + _maxHoldFrames = maxHoldFrames; + } + + /// Begin holding for a teleport arrival. Called from OnLivePositionUpdated + /// AFTER the streaming origin has been recentered on the destination landblock. + /// Re-calling with a fresh server position resets the hold (server-authoritative). + public void BeginArrival(Vector3 destPos, uint destCell) + { + _destPos = destPos; + _destCell = destCell; + _heldFrames = 0; + Phase = TeleportArrivalPhase.Holding; + } + + /// Per-frame: evaluate readiness and place when ready / impossible / timed out. + /// No-op when Idle. + public void Tick() + { + if (Phase != TeleportArrivalPhase.Holding) return; + _heldFrames++; + + ArrivalReadiness verdict = _readiness(_destPos, _destCell); + if (verdict == ArrivalReadiness.Ready) + { + Place(forced: false); + return; + } + + if (verdict == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames) + { + Place(forced: true); + } + // else NotReady -> keep holding + } + + private void Place(bool forced) + { + _place(_destPos, _destCell, forced); + Phase = TeleportArrivalPhase.Idle; + } +} diff --git a/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs b/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs new file mode 100644 index 00000000..fbf8727f --- /dev/null +++ b/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.World; +using Xunit; + +namespace AcDream.App.Tests.World; + +public class TeleportArrivalControllerTests +{ + // Records each Place(destPos, destCell, forced) call. + private sealed record PlaceCall(Vector3 Pos, uint Cell, bool Forced); + + private static TeleportArrivalController Make( + ArrivalReadiness verdict, + List placed, + int maxHoldFrames = TeleportArrivalController.DefaultMaxHoldFrames) + => new( + readiness: (_, _) => verdict, + place: (pos, cell, forced) => placed.Add(new PlaceCall(pos, cell, forced)), + maxHoldFrames: maxHoldFrames); + + [Fact] + public void BeginArrival_EntersHolding() + { + var placed = new List(); + var c = Make(ArrivalReadiness.NotReady, placed); + + c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); + + Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); + Assert.Empty(placed); + } + + [Fact] + public void Tick_WhenIdle_IsNoOp() + { + var placed = new List(); + var c = Make(ArrivalReadiness.Ready, placed); + + c.Tick(); // never began + + Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); + Assert.Empty(placed); + } + + [Fact] + public void Tick_NotReady_KeepsHolding_DoesNotPlace() + { + var placed = new List(); + var c = Make(ArrivalReadiness.NotReady, placed); + c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); + + c.Tick(); + c.Tick(); + + Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); + Assert.Empty(placed); + } + + [Fact] + public void Tick_Ready_PlacesUnforced_AndIdles() + { + var placed = new List(); + var c = Make(ArrivalReadiness.Ready, placed); + c.BeginArrival(new Vector3(30, -60, 6.005f), 0x01250126u); + + c.Tick(); + + Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); + var call = Assert.Single(placed); + Assert.False(call.Forced); + Assert.Equal(0x01250126u, call.Cell); + Assert.Equal(new Vector3(30, -60, 6.005f), call.Pos); + } + + [Fact] + public void Tick_Impossible_PlacesForced_AndIdles() + { + var placed = new List(); + var c = Make(ArrivalReadiness.Impossible, placed); + c.BeginArrival(new Vector3(1, 2, 3), 0x0125FF00u); + + c.Tick(); + + Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); + var call = Assert.Single(placed); + Assert.True(call.Forced); + } + + [Fact] + public void Tick_Timeout_PlacesForced_AfterMaxHoldFrames() + { + var placed = new List(); + var c = Make(ArrivalReadiness.NotReady, placed, maxHoldFrames: 3); + c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); + + c.Tick(); // 1 + c.Tick(); // 2 + Assert.Empty(placed); + Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); + + c.Tick(); // 3 -> timeout + + var call = Assert.Single(placed); + Assert.True(call.Forced); + Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); + } + + [Fact] + public void BeginArrival_AfterPlace_ReArms() + { + var placed = new List(); + var c = Make(ArrivalReadiness.Ready, placed); + + c.BeginArrival(new Vector3(1, 0, 0), 0x01250126u); + c.Tick(); // places #1, idle + c.BeginArrival(new Vector3(2, 0, 0), 0x01250127u); + c.Tick(); // places #2, idle + + Assert.Equal(2, placed.Count); + Assert.Equal(0x01250127u, placed[1].Cell); + } +} From aca4b4645a3f2dcb92d38a3ab9cb440c578260e1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 17:11:40 +0200 Subject: [PATCH 30/65] refactor(G.3a): Place flips Idle before delegate; test mid-hold reset (#133) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../World/TeleportArrivalController.cs | 4 +++- .../World/TeleportArrivalControllerTests.cs | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/World/TeleportArrivalController.cs b/src/AcDream.App/World/TeleportArrivalController.cs index d6538533..096f0cce 100644 --- a/src/AcDream.App/World/TeleportArrivalController.cs +++ b/src/AcDream.App/World/TeleportArrivalController.cs @@ -97,7 +97,9 @@ public sealed class TeleportArrivalController private void Place(bool forced) { - _place(_destPos, _destCell, forced); + // Flip to Idle BEFORE invoking the placement delegate so the machine + // reflects "done holding" even if the delegate were to re-enter Tick. Phase = TeleportArrivalPhase.Idle; + _place(_destPos, _destCell, forced); } } diff --git a/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs b/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs index fbf8727f..54a23f2f 100644 --- a/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs +++ b/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs @@ -120,4 +120,28 @@ public class TeleportArrivalControllerTests Assert.Equal(2, placed.Count); Assert.Equal(0x01250127u, placed[1].Cell); } + + [Fact] + public void BeginArrival_DuringHold_ResetsTimeoutCounter() + { + var placed = new List(); + var c = Make(ArrivalReadiness.NotReady, placed, maxHoldFrames: 3); + + c.BeginArrival(new Vector3(1, 0, 0), 0x01250126u); + c.Tick(); // held=1 + c.Tick(); // held=2 (one short of the timeout) + + // Re-arm mid-hold with a fresh destination: the counter must restart. + c.BeginArrival(new Vector3(2, 0, 0), 0x01250199u); + c.Tick(); // held=1 again (NOT 3 -> no placement yet) + c.Tick(); // held=2 + Assert.Empty(placed); + Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); + + c.Tick(); // held=3 -> timeout, forced place of the SECOND destination + var call = Assert.Single(placed); + Assert.True(call.Forced); + Assert.Equal(0x01250199u, call.Cell); + Assert.Equal(new Vector3(2, 0, 0), call.Pos); + } } From f22121bd7d78a7e5e48c90766377149be95977b0 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 17:16:12 +0200 Subject: [PATCH 31/65] feat(G.3a): hold teleport arrival until dungeon hydrates, then place (#133) Replaces the unconditional OnLivePositionUpdated snap (which resolved against the resident old landblocks before the destination streamed in -> ocean) with a recenter + deferred BeginArrival; per-frame Tick places via the unchanged #111 validated-claim Resolve once SampleTerrainZ + IsSpawnCellReady report ready, or force-snaps loudly on an impossible claim / ~10s timeout. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 175 ++++++++++++++---------- 1 file changed, 106 insertions(+), 69 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2365ca14..1c1db412 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -4874,7 +4874,7 @@ public sealed class GameWindow : IDisposable entity.Rotation = rmState.Body.Orientation; } - // Phase B.3: portal-space arrival detection. + // Phase B.3 / G.3a (#133): portal-space arrival detection. // Only runs for our own player character while in PortalSpace. if (_playerController is not null && _playerController.State == AcDream.App.Input.PlayerState.PortalSpace @@ -4888,79 +4888,109 @@ public sealed class GameWindow : IDisposable bool differentLandblock = (lbX != oldLbX || lbY != oldLbY); - // #107 (2026-06-10): ANY player position update while in PortalSpace - // IS the teleport arrival. Retail/holtburger exit portal space on the - // next position event unconditionally (holtburger messages.rs - // PlayerTeleport handler: log + LoginComplete; the destination applies - // through the normal position flow — no distance test). The old - // `differentLandblock || farAway(>100m)` arrival gate was an - // invention: ACE's same-landblock short-hop position corrections - // (e.g. right after an indoor login) matched neither condition, so - // PortalSpace never exited and movement input stayed frozen for the - // whole session (the #107 "input ignored" wedge shape — - // flood-fix-gate2.log: `teleport started (seq=1)` with no arrival). + Console.WriteLine( + $"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " + + $"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}"); + + System.Numerics.Vector3 newWorldPos; + if (differentLandblock) { - Console.WriteLine( - $"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " + - $"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}"); - - System.Numerics.Vector3 newWorldPos; - if (differentLandblock) - { - // 1. Recenter the streaming controller on the new landblock. - _liveCenterX = lbX; - _liveCenterY = lbY; - - // Recompute worldPos with new center (it becomes local-to-center). - // After recentering, the new position is (p.PositionX, p.PositionY, p.PositionZ) - // relative to the new origin — which maps to world-space (0,0,0) + local offset. - // The streamingController.Tick will pick up _liveCenterX/_liveCenterY automatically. - newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ); - // (after recentering, origin is (0,0,0) since lb == center) - } - else - { - // Same landblock: worldPos is already in the current center frame. - newWorldPos = worldPos; - } - - // 2. Resolve through physics for the correct ground Z. - uint newCellId = p.LandblockId; - var resolved = _physicsEngine.Resolve( - newWorldPos, newCellId, - System.Numerics.Vector3.Zero, _playerController.StepUpHeight); - var snappedPos = new System.Numerics.Vector3( - resolved.Position.X, resolved.Position.Y, resolved.Position.Z); - - // 3. Snap player entity + controller. - entity.SetPosition(snappedPos); - entity.ParentCellId = resolved.CellId; - entity.Rotation = rot; - _playerController.SetPosition(snappedPos, resolved.CellId); - - // 4. Recenter chase camera on the new position. - _chaseCamera?.Update(snappedPos, _playerController.Yaw); - _retailChaseCamera?.Update(snappedPos, _playerController.Yaw, - playerVelocity: System.Numerics.Vector3.Zero, - isOnGround: true, - contactPlaneNormal: System.Numerics.Vector3.UnitZ, - dt: 1f / 60f); - - // 5. Return to InWorld. - _playerController.State = AcDream.App.Input.PlayerState.InWorld; - Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}"); - - // 5. Send LoginComplete to tell the server the client finished loading. - // Per holtburger's PlayerTeleport handler (client/messages.rs:434-440), - // retail clients call send_login_complete() after each portal transition. - // ResetLoginComplete() clears the latch so the 0xF746 PlayerCreate path - // doesn't also send one. We send directly here instead. - _liveSession?.SendGameAction( - AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); + // Recenter the streaming controller on the new landblock NOW (kick + // off the dungeon load). After recentering, the destination is + // (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin. + _liveCenterX = lbX; + _liveCenterY = lbY; + newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ); } + else + { + newWorldPos = worldPos; + } + + // G.3a: do NOT snap here. The destination dungeon landblock has not + // streamed in yet; an immediate Resolve falls back to the resident + // (old) landblocks and lands the player in ocean (#133). HOLD the snap + // in portal space — TeleportArrivalController.Tick (per frame) places + // the player via PlaceTeleportArrival once the destination cell + // hydrates (TeleportArrivalReadiness == Ready), or force-places on an + // impossible claim / timeout. PortalSpace keeps input frozen meanwhile. + EnsureTeleportArrivalController(); + _pendingTeleportRot = rot; + _teleportArrival!.BeginArrival(newWorldPos, p.LandblockId); } } + // G.3a (#133): holds a teleport arrival in portal space until the destination + // dungeon landblock/cell has hydrated, then places the player via the unchanged + // validated-claim Resolve path. Lazily constructed on the first teleport (all + // runtime deps are wired by then). + private AcDream.App.World.TeleportArrivalController? _teleportArrival; + private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity; + + private void EnsureTeleportArrivalController() + { + if (_teleportArrival is not null) return; + _teleportArrival = new AcDream.App.World.TeleportArrivalController( + readiness: TeleportArrivalReadiness, + place: PlaceTeleportArrival); + } + + // Reuses the #107 login readiness triplet (GameWindow.cs:1010-1024), evaluated + // against the teleport's (destPos, destCell): an impossible indoor claim short- + // circuits to immediate placement; otherwise hold until terrain is sampled and, + // for an indoor cell, the cell struct has hydrated. + private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness( + System.Numerics.Vector3 destPos, uint destCell) + { + if (IsSpawnClaimUnhydratable(destCell)) + return AcDream.App.World.ArrivalReadiness.Impossible; + if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null) + return AcDream.App.World.ArrivalReadiness.NotReady; + bool indoor = (destCell & 0xFFFFu) >= 0x0100u; + if (indoor && !_physicsEngine.IsSpawnCellReady(destCell)) + return AcDream.App.World.ArrivalReadiness.NotReady; + return AcDream.App.World.ArrivalReadiness.Ready; + } + + // The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only + // once the destination is ready (or force-run on impossible/timeout, logged loud). + private void PlaceTeleportArrival( + System.Numerics.Vector3 destPos, uint destCell, bool forced) + { + var resolved = _physicsEngine.Resolve( + destPos, destCell, System.Numerics.Vector3.Zero, _playerController!.StepUpHeight); + var snappedPos = new System.Numerics.Vector3( + resolved.Position.X, resolved.Position.Y, resolved.Position.Z); + + if (forced) + Console.WriteLine( + $"live: teleport HOLD gave up (impossible/timeout) — force-snapping " + + $"cell=0x{destCell:X8} pos={destPos} -> 0x{resolved.CellId:X8} {snappedPos}"); + + if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) + { + pe.SetPosition(snappedPos); + pe.ParentCellId = resolved.CellId; + pe.Rotation = _pendingTeleportRot; + } + _playerController.SetPosition(snappedPos, resolved.CellId); + + _chaseCamera?.Update(snappedPos, _playerController.Yaw); + _retailChaseCamera?.Update(snappedPos, _playerController.Yaw, + playerVelocity: System.Numerics.Vector3.Zero, + isOnGround: true, + contactPlaneNormal: System.Numerics.Vector3.UnitZ, + dt: 1f / 60f); + + _playerController.State = AcDream.App.Input.PlayerState.InWorld; + Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}"); + + // Tell the server the client finished loading the new landblock (holtburger + // client/messages.rs:434 — re-send LoginComplete after each portal transition). + _liveSession?.SendGameAction( + AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); + } + /// /// Phase B.3: fires when the server sends a PlayerTeleport (0xF751). /// Freeze movement input by setting the player controller to PortalSpace. @@ -4972,6 +5002,7 @@ public sealed class GameWindow : IDisposable { if (_playerController is not null) _playerController.State = AcDream.App.Input.PlayerState.PortalSpace; + EnsureTeleportArrivalController(); Console.WriteLine($"live: teleport started (seq={sequence})"); } @@ -6837,6 +6868,12 @@ public sealed class GameWindow : IDisposable // Step 2: routed through the controller; functionally identical. _liveSessionController?.Tick(); + // G.3a (#133): advance any held teleport arrival. Runs AFTER streaming + // (which applies the destination landblock) and the live-session drain + // (which may have just called BeginArrival), so a destination that + // hydrated this frame is placed the same frame. + _teleportArrival?.Tick(); + // Phase K.1a — tick the input dispatcher so Hold-type bindings // re-fire while their chord is held. K.1b adds the subscribers // that actually consume the events. From ab050a015fdd0cded7b54d7483dc451c22156c36 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 17:26:34 +0200 Subject: [PATCH 32/65] fix(G.3a): hydrate EnvCell physics + visibility independent of render mesh (#133) BuildLoadedCell + CacheCellStruct were gated behind cellSubMeshes.Count > 0, so a geometry-less collision cell got no collision (fall-through) and no visibility node. Retail couples neither to visible geometry; CacheCellStruct self-gates on a null PhysicsBSP, so this is safe. Render registration stays behind the submesh guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 70 +++++++++++++------------ 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1c1db412..c96ad05f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5630,25 +5630,46 @@ public sealed class GameWindow : IDisposable // as WorldEntity records below — they have real GfxObj MeshRefs that work // fine; EnvCellRenderer.RegisterCell receives an empty staticObjects list. var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); + + // The cell transforms and the physics + visibility hydration are + // INDEPENDENT of whether the cell has drawable geometry. Retail + // couples neither collision nor portal visibility to a render mesh. + // Keep the small render lift out of physics; retail BSP contact + // planes use the EnvCell origin verbatim. The lift constant is + // shared with every draw-space consumer of portal polygons + // (OutsideView gate, seal/punch fans) — see + // PortalVisibilityBuilder.ShellDrawLiftZ (#130). + var physicsCellOrigin = envCell.Position.Origin + lbOffset; + var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( + 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); + var cellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); + var physicsCellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); + + // G.3a (#133) hydration decouple: BuildLoadedCell + CacheCellStruct + // were previously gated behind `cellSubMeshes.Count > 0`, which + // silently dropped collision (CellTransit.GetCellStruct -> null -> + // fall through floor) and the visibility node for any geometry-less + // collision cell. CacheCellStruct self-gates on a null PhysicsBSP + // (PhysicsDataCache), so this is safe for cells with no physics. + // + // BuildLoadedCell uses the PHYSICS (unlifted) transform. The +0.02 m + // render lift above is a DRAW concern (shell z-fighting vs terrain); + // feeding it into the visibility graph shifted every HORIZONTAL portal + // plane 2 cm up, side-culling decks/landings (#119-residual, + // TowerAscentReplayTests.CapturedTopOfStairs_*). Vertical doorways were + // immune (the lift slides their planes along themselves). + BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); + _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); + + // Render registration only when the cell actually has drawable submeshes. if (cellSubMeshes.Count > 0) { _pendingCellMeshes[envCellId] = cellSubMeshes; - // Keep the small render lift out of physics; retail BSP - // contact planes use the EnvCell origin verbatim. The lift - // constant is shared with every draw-space consumer of - // portal polygons (OutsideView gate, seal/punch fans) — - // see PortalVisibilityBuilder.ShellDrawLiftZ (#130). - var physicsCellOrigin = envCell.Position.Origin + lbOffset; - var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( - 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); - var cellTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * - System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); - var physicsCellTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * - System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); - // Phase A8: register the cell with EnvCellRenderer for rendering. // staticObjects is empty — cell stabs continue as separate WorldEntity // records via the dispatcher (see lines below for the unchanged stab path). @@ -5661,25 +5682,6 @@ public sealed class GameWindow : IDisposable cellWorldPosition: cellOrigin, cellRotation: envCell.Position.Orientation, staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>()); - - // Step 4: build LoadedCell for portal visibility — with the - // PHYSICS (unlifted) transform. The +0.02 m render lift above - // is a DRAW concern (shell z-fighting vs terrain); feeding it - // into the visibility graph shifted every HORIZONTAL portal - // plane 2 cm up, putting an eye standing on a deck/landing - // 10–20 mm BELOW the lifted plane — outside the side test's - // ±10 mm in-plane window — so the cell behind the portal was - // side-culled: the tower-top staircase vanish + roof flap - // (#119-residual; captured live at eye z=126.803 vs the - // 010A→0107 plane at 126.80, reproduced ONLY with the lift in - // TowerAscentReplayTests.CapturedTopOfStairs_*). Vertical - // doorways were immune (the lift slides their planes along - // themselves), which is why this hit exactly stairs, decks, - // and cellar mouths. - BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); - - // Cache CellStruct physics BSP for indoor collision (UNCHANGED). - _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); } } } From 3238f1fde45b5c27081995782e9d3a29419d53bc Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 17:33:52 +0200 Subject: [PATCH 33/65] docs(G.3a): note CacheCellStruct's unconditional UCG CellGraph add is inert (#133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-up: the hydration decouple's safety rests not only on CacheCellStruct self-gating its BSP cache, but on the fact that a geometry-less cell — though now added to the UCG CellGraph unconditionally — never enters the _cellStruct BSP dictionary membership/placement resolve through, so the player can never be rooted in one. Document that load-bearing invariant at the hoist. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index c96ad05f..290b70e9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5655,6 +5655,11 @@ public sealed class GameWindow : IDisposable // fall through floor) and the visibility node for any geometry-less // collision cell. CacheCellStruct self-gates on a null PhysicsBSP // (PhysicsDataCache), so this is safe for cells with no physics. + // Note CacheCellStruct DOES register every resolved cell into the UCG + // CellGraph unconditionally (before that BSP gate), so a geometry-less + // cell now enters the graph — but it never enters the _cellStruct BSP + // dictionary that membership/placement resolve through, so the player + // can never be rooted in such a cell. The added graph entry is inert. // // BuildLoadedCell uses the PHYSICS (unlifted) transform. The +0.02 m // render lift above is a DRAW concern (shell z-fighting vs terrain); From e7058caa793a6e4c66cade5e517f2efca0341b5b Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 18:05:36 +0200 Subject: [PATCH 34/65] Revert "fix(G.3a): hydrate EnvCell physics + visibility independent of render mesh (#133)" This reverts commit ab050a015fdd0cded7b54d7483dc451c22156c36. --- src/AcDream.App/Rendering/GameWindow.cs | 75 +++++++++++-------------- 1 file changed, 34 insertions(+), 41 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 290b70e9..1c1db412 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5630,51 +5630,25 @@ public sealed class GameWindow : IDisposable // as WorldEntity records below — they have real GfxObj MeshRefs that work // fine; EnvCellRenderer.RegisterCell receives an empty staticObjects list. var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); - - // The cell transforms and the physics + visibility hydration are - // INDEPENDENT of whether the cell has drawable geometry. Retail - // couples neither collision nor portal visibility to a render mesh. - // Keep the small render lift out of physics; retail BSP contact - // planes use the EnvCell origin verbatim. The lift constant is - // shared with every draw-space consumer of portal polygons - // (OutsideView gate, seal/punch fans) — see - // PortalVisibilityBuilder.ShellDrawLiftZ (#130). - var physicsCellOrigin = envCell.Position.Origin + lbOffset; - var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( - 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); - var cellTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * - System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); - var physicsCellTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * - System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); - - // G.3a (#133) hydration decouple: BuildLoadedCell + CacheCellStruct - // were previously gated behind `cellSubMeshes.Count > 0`, which - // silently dropped collision (CellTransit.GetCellStruct -> null -> - // fall through floor) and the visibility node for any geometry-less - // collision cell. CacheCellStruct self-gates on a null PhysicsBSP - // (PhysicsDataCache), so this is safe for cells with no physics. - // Note CacheCellStruct DOES register every resolved cell into the UCG - // CellGraph unconditionally (before that BSP gate), so a geometry-less - // cell now enters the graph — but it never enters the _cellStruct BSP - // dictionary that membership/placement resolve through, so the player - // can never be rooted in such a cell. The added graph entry is inert. - // - // BuildLoadedCell uses the PHYSICS (unlifted) transform. The +0.02 m - // render lift above is a DRAW concern (shell z-fighting vs terrain); - // feeding it into the visibility graph shifted every HORIZONTAL portal - // plane 2 cm up, side-culling decks/landings (#119-residual, - // TowerAscentReplayTests.CapturedTopOfStairs_*). Vertical doorways were - // immune (the lift slides their planes along themselves). - BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); - _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); - - // Render registration only when the cell actually has drawable submeshes. if (cellSubMeshes.Count > 0) { _pendingCellMeshes[envCellId] = cellSubMeshes; + // Keep the small render lift out of physics; retail BSP + // contact planes use the EnvCell origin verbatim. The lift + // constant is shared with every draw-space consumer of + // portal polygons (OutsideView gate, seal/punch fans) — + // see PortalVisibilityBuilder.ShellDrawLiftZ (#130). + var physicsCellOrigin = envCell.Position.Origin + lbOffset; + var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( + 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); + var cellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); + var physicsCellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); + // Phase A8: register the cell with EnvCellRenderer for rendering. // staticObjects is empty — cell stabs continue as separate WorldEntity // records via the dispatcher (see lines below for the unchanged stab path). @@ -5687,6 +5661,25 @@ public sealed class GameWindow : IDisposable cellWorldPosition: cellOrigin, cellRotation: envCell.Position.Orientation, staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>()); + + // Step 4: build LoadedCell for portal visibility — with the + // PHYSICS (unlifted) transform. The +0.02 m render lift above + // is a DRAW concern (shell z-fighting vs terrain); feeding it + // into the visibility graph shifted every HORIZONTAL portal + // plane 2 cm up, putting an eye standing on a deck/landing + // 10–20 mm BELOW the lifted plane — outside the side test's + // ±10 mm in-plane window — so the cell behind the portal was + // side-culled: the tower-top staircase vanish + roof flap + // (#119-residual; captured live at eye z=126.803 vs the + // 010A→0107 plane at 126.80, reproduced ONLY with the lift in + // TowerAscentReplayTests.CapturedTopOfStairs_*). Vertical + // doorways were immune (the lift slides their planes along + // themselves), which is why this hit exactly stairs, decks, + // and cellar mouths. + BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); + + // Cache CellStruct physics BSP for indoor collision (UNCHANGED). + _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); } } } From 2ce5e5c8622dcd2f666250ae764bc5119416cce1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 18:27:45 +0200 Subject: [PATCH 35/65] fix(G.3a): validated-claim placement keeps the claim's landblock prefix (#133) The #111 validated-claim branch returned lbPrefix | (cellId & 0xFFFF), where lbPrefix is found by searching resident landblocks for one containing the candidate position. A dungeon EnvCell's local Y can be negative, so the dungeon landblock fails the [0,192) bounds test and the loop matches a neighbouring (e.g. Holtburg) resident block -> the validated claim 0x00070143 got re-stamped 0xA9B30143, making the client mis-resolve the player to the wrong landblock and spam ACE with rejected moves. The validated claim's full id is authoritative; return it directly. Byte-identical for the login case (position in the claim's own landblock); fixes the far-teleport dungeon case. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Physics/PhysicsEngine.cs | 16 +- .../Issue133DungeonTeleportPrefixTests.cs | 142 ++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 80a76cf8..378afe92 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -638,9 +638,23 @@ public sealed class PhysicsEngine { Console.WriteLine(System.FormattableString.Invariant( $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its walkable floor z={claimFloorZ.Value:F3}")); + // #133 (2026-06-13): return the VALIDATED claim's OWN full cell id, + // NOT lbPrefix | (cellId & 0xFFFF). lbPrefix is found by scanning + // resident landblocks for one whose [0,192) local bounds contain + // the candidate XY — but a dungeon EnvCell's local Y can be NEGATIVE + // (server teleport to 0x00070143 at local (70,-60,0.01)). The dungeon + // landblock fails the localY>=0 bounds test, so the loop matches a + // neighbouring still-resident block (e.g. Holtburg 0xA9B3), re-stamping + // the validated claim 0x00070143 -> 0xA9B30143. The client then + // mis-resolves the player into the wrong landblock and spams ACE with + // rejected moves. The validated claim's prefix is AUTHORITATIVE; a + // position falling in a neighbouring resident landblock must not + // re-stamp it. Byte-identical for the login case (the position lies in + // the claim's own landblock, so lbPrefix == cellId & 0xFFFF0000); + // diverges only — and correctly — in the far-teleport dungeon case. return new ResolveResult( new Vector3(candidatePos.X, candidatePos.Y, claimFloorZ.Value), - lbPrefix | (cellId & 0xFFFFu), + cellId, IsOnGround: true); } } diff --git a/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs b/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs new file mode 100644 index 00000000..e429f100 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #133 (Bug A) — the validated-claim placement branch of +/// must return the VALIDATED claim's own +/// full cell id, NOT lbPrefix | (cellId & 0xFFFF). +/// +/// +/// lbPrefix is found by scanning resident landblocks for one whose +/// [0,192) local bounds contain the candidate XY. A dungeon EnvCell's +/// local Y can be NEGATIVE relative to its own landblock (the live capture: +/// server teleport to dungeon cell 0x00070143 at local (70,-60,0.01)). +/// The dungeon landblock fails the localY >= 0 bounds test, so the loop +/// instead matches a still-resident NEIGHBOURING block (a Holtburg landblock +/// whose world bounds happen to contain the same XY) and sets +/// lbPrefix = 0xA9B30000. The old code then returned +/// 0xA9B30000 | 0x0143 = 0xA9B30143, re-stamping the validated dungeon +/// claim with the wrong landblock — the client mis-resolved the player into +/// Holtburg and spammed ACE with rejected moves +/// (movement pre-validation failed from 00070143 to A9B30143). +/// +/// +/// +/// The validated claim's prefix is authoritative; a position falling in a +/// neighbouring resident landblock must not re-stamp it. This test reproduces +/// the exact geometry of the capture (dungeon claim in landblock 0x0007, +/// candidate XY also inside resident Holtburg 0xA9B3) and asserts the +/// returned cell keeps its 0x0007 prefix. +/// +/// +public class Issue133DungeonTeleportPrefixTests +{ + private const uint DungeonLandblock = 0x00070000u; + private const uint DungeonCellId = 0x00070143u; // indoor (low 0x0143 ≥ 0x0100) + private const uint HoltburgLandblock = 0xA9B30000u; // a neighbouring resident block + + // The capture: dungeon cell 0x00070143 at dungeon-local (70, -60, 0.01). + // We place the Holtburg block at world origin so its [0,192) bounds contain + // the candidate XY, and the dungeon block at world Y-offset 130 so the SAME + // world XY lands at dungeon-local Y = 70 - 130 = -60 (the captured negative). + private static readonly Vector3 SpawnPos = new(70f, 70f, 0.01f); + + [Fact] + public void ValidatedDungeonClaim_KeepsItsLandblockPrefix_NotTheNeighbour() + { + var engine = BuildEngine(); + + // Zero delta = the snap shape (teleport arrival). cellId is the dungeon + // claim; the candidate XY also falls inside the resident Holtburg block. + var result = engine.Resolve(SpawnPos, DungeonCellId, delta: Vector3.Zero, stepUpHeight: 0.5f); + + Assert.True(result.IsOnGround); + // The validated claim's prefix is authoritative — high word stays 0x0007, + // NOT re-stamped to the neighbouring Holtburg 0xA9B3. + Assert.Equal(DungeonCellId, result.CellId); + Assert.Equal(DungeonLandblock, result.CellId & 0xFFFF0000u); + } + + // ── fixture ────────────────────────────────────────────────────────────── + + private static PhysicsEngine BuildEngine() + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + + // The dungeon cell: a Leaf CellBSP contains any point, so AdjustPosition + // validates the claim (returns it with found=true). Its Resolved set has + // one walkable floor polygon at z=0 under the spawn XY so the #111 + // validated-claim branch grounds onto it. + cache.RegisterCellStructForTest(DungeonCellId, MakeDungeonCell()); + + // Resident Holtburg block at world origin: its [0,192) bounds CONTAIN the + // candidate XY (70,70). This is the block the lbPrefix loop wrongly matched. + engine.AddLandblock( + landblockId: HoltburgLandblock, + terrain: FlatTerrain(), + cells: Array.Empty(), + portals: Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + // The dungeon's own landblock, offset so the candidate XY produces a + // NEGATIVE dungeon-local Y (70 - 130 = -60) → it FAILS the [0,192) bounds + // test, which is exactly why the old code fell through to the Holtburg + // prefix. Registered so the scenario is faithful (a resident dungeon block + // whose local bounds don't cover the EnvCell's negative-Y position). + engine.AddLandblock( + landblockId: DungeonLandblock, + terrain: FlatTerrain(), + cells: Array.Empty(), + portals: Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 130f); + + return engine; + } + + /// Flat 81-vertex stub terrain (all zero heights). + private static TerrainSurface FlatTerrain() => new(new byte[81], new float[256]); + + private static CellPhysics MakeDungeonCell() + { + // One floor polygon: a 200×200 square at z=0 centred so it covers the + // spawn XY. Normal (0,0,1) → normal.Z = 1 ≥ FloorZ (0.6642) → walkable. + // Identity transform: cell-local == world, so the plane d = 0 (z + d = 0). + var floor = new ResolvedPolygon + { + Vertices = new[] + { + new Vector3(-100f, -100f, 0f), + new Vector3( 200f, -100f, 0f), + new Vector3( 200f, 200f, 0f), + new Vector3(-100f, 200f, 0f), + }, + Plane = new Plane(new Vector3(0f, 0f, 1f), 0f), + NumPoints = 4, + SidesType = CullMode.None, + }; + + return new CellPhysics + { + BSP = new PhysicsBSPTree { Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf } }, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary { [0] = floor }, + // Leaf root → point_in_cell true for any point → AdjustPosition + // validates the claim (found=true, cell unchanged). + CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf } }, + Portals = Array.Empty(), + PortalPolygons = new Dictionary(), + VisibleCellIds = new HashSet(), + }; + } +} From 70c559c1ba25c83b408eee6a3f9f10fd7f7ba575 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 18:30:43 +0200 Subject: [PATCH 36/65] =?UTF-8?q?docs(G.3):=20gate=20correction=20?= =?UTF-8?q?=E2=80=94=20G.3a=20core=20landed;=20#95=20CONFIRMED=20LIVE=20(n?= =?UTF-8?q?ot=20superseded)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The G.3a visual gate ran a real PlayerTeleport into the 0x0007 dungeon. The core hold+place worked (grounded on the dungeon floor, no ocean) and Bug A (landblock- prefix mis-stamp) is fixed (2ce5e5c). But the gate proved #95 (portal-graph visibility blowup, ~9.1M instances/frame) is LIVE under the current pipeline — my plan's "likely superseded / conditional G.3b" premise was wrong. Spec §2.5/§3.2 + ISSUES #133/#95 updated: G.3b (grab_visible_cells stab_list bounding) is REQUIRED, needs its own grounding/brainstorm. Also noted: the render-only hydration decouple was reverted (e7058ca) for making the player invisible at Holtburg. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 34 ++++++++++++-- .../2026-06-13-dungeon-support-design.md | 46 +++++++++++++------ 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 80581f8a..8abe2820 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -51,10 +51,25 @@ Copy this block when adding a new issue: **Status:** OPEN — promoted to **Phase G.3** (Dungeon streaming + portal space + `PlayerTeleport` handling), **PULLED INTO M1.5** (user decision 2026-06-13: the indoor world isn't done while dungeons are broken; full -G.3 scope chosen). Brainstorming the spec → `docs/superpowers/specs/`. -This is now an M1.5 exit-gate blocker, not deferred. The investigation -below found it's not a single bug but a whole-feature gap (terrain-less -dungeon landblocks unsupported across the pipeline). +G.3 scope chosen). Spec: `docs/superpowers/specs/2026-06-13-dungeon-support-design.md`; +G.3a plan: `docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md`. +This is now an M1.5 exit-gate blocker, not deferred. + +**PROGRESS (2026-06-13 PM — G.3a core LANDED + Bug A fixed; gate exposed #95):** +the teleport-timing root cause IS fixed. G.3a shipped the `TeleportArrivalController` +hold-until-hydration (`7947d7a`/`aca4b46`/`f22121b`) + the validated-claim +landblock-prefix fix (`2ce5e5c`, "Bug A"). Live gate proof: a real `PlayerTeleport` +into the `0x0007` dungeon held through the 46 km jump and grounded the player on the +dungeon's walkable floor (`[snap] claim=0x00070143 VALIDATED -> z=0.000`) — **no +ocean.** The "terrain-less landblock" framing was refuted earlier (dat probe: dungeon += flat-terrain LandBlock + EnvCells). REMAINING blockers, both exposed at the gate: +(1) **#95 CONFIRMED LIVE** — the dungeon renders as "thin air" because WB-DIAG blows +up to ~9.1M instances/frame at `0x0007` (see #95); (2) **possible Bug C** — per-tick +membership may still drift in the dungeon's negative-local-Y frame (ACE `movement +pre-validation failed` spam) — re-gate after Bug A to confirm. NOTE: a render-only +EnvCell hydration decouple was tried in G.3a and REVERTED (`e7058ca`) — it made the +player character invisible at Holtburg (it touched the shared building hydration +path); re-approach separately if a geometry-less collision cell ever needs it. **Severity:** HIGH (any far/dungeon teleport is unusable) **Filed:** 2026-06-13 (M1.5 dungeon-demo gate attempt — meeting-hall portal) **Component:** physics/streaming — teleport-arrival snap vs async landblock hydration @@ -887,7 +902,16 @@ Retail oracle for cell-id hysteresis: `acclient_2013_pseudo_c.txt:308742-308783` ## #95 — Dungeon portal-graph visibility blowup (see-through-walls / other dungeons rendered) -**Status:** OPEN — **explains user-observed "dungeons are broken"** +**Status:** OPEN — **RE-CONFIRMED LIVE under the current Option-A pipeline (2026-06-13 +G.3a gate).** A real `PlayerTeleport` into the `0x0007` dungeon blew WB-DIAG up to +entSeen=6.5M / instances=9.1M / drawsIssued=590K per frame (vs. 3345/4667 at Holtburg) +with a flood of `[mesh-miss] 0x000100xxxx` interior re-requests → dungeon renders as +"thin air." So the T1–T6 rewrite did NOT supersede this (the earlier "likely superseded" +read was wrong). This is now the **#133/G.3b** blocker; fix shape = port retail +`CEnvCell::grab_visible_cells` (:311878) stab_list bounding (seen_outside==0 → walk only +stab_list, never the whole resident cell set). Needs its own grounding/brainstorm in the +flap-sensitive `PortalVisibilityBuilder`. **Originally** also: **explains user-observed +"dungeons are broken"** **Severity:** HIGH (blocks all dungeon navigation visually) **Filed:** 2026-05-21 **Component:** rendering, visibility, EnvCell portal traversal diff --git a/docs/superpowers/specs/2026-06-13-dungeon-support-design.md b/docs/superpowers/specs/2026-06-13-dungeon-support-design.md index 6f62f41c..95129126 100644 --- a/docs/superpowers/specs/2026-06-13-dungeon-support-design.md +++ b/docs/superpowers/specs/2026-06-13-dungeon-support-design.md @@ -169,10 +169,19 @@ All five verified against current code this session (high confidence). *correct*, and the cross-landblock 135-cell blowup **structurally cannot reproduce**: a single-landblock flood visits ≤ `NumCells` distinct cells (71 for the meeting hall). -- **Verdict:** #95's evidence is stale, from a deleted path; the current pipeline - is bounded. Treat #95 as **likely superseded, unverified**. The meeting-hall - demo (71 cells, one landblock) IS its empirical test. **Do not pre-build the - stab_list bounding port** against a dead repro (G.3b is conditional — §3.2). +- **Verdict (pre-gate, 2026-06-13 AM):** #95's evidence is stale, from a deleted + path; the current pipeline looked bounded. Treated #95 as likely superseded. +- **⚠️ GATE CORRECTION (2026-06-13 PM — #95 is CONFIRMED LIVE):** the G.3a visual + gate ran a real `PlayerTeleport` into the `0x0007` dungeon (Town Network). The + core hold+place worked (player grounded on the dungeon floor, z=0 — no ocean), + but **WB-DIAG exploded to entSeen=6.5M / instances=9.1M / drawsIssued=590K per + frame** (vs. 3345 / 4667 at Holtburg), with a flood of `[mesh-miss] 0x000100xxxx` + interior re-requests → the dungeon renders as "thin air." **#95 reproduces under + the current Option-A pipeline.** The "bounded flood" reasoning was wrong for the + `0x0007` dungeon (the grounding agent's "still live" verdict was correct; this + doc over-discounted it). **G.3b is now REQUIRED, not conditional** (§3.2). The + retail-faithful fix shape stands: port `CEnvCell::grab_visible_cells` (:311878) + stab_list bounding — a `seen_outside==0` cell walks ONLY its `stab_list`. --- @@ -234,19 +243,28 @@ each on its own non-null precondition. **Acceptance (G.3a):** the visual gate in §6. This gate also empirically settles #95 (does the flood blow up?) and the hydration coupling (does collision work?). -### 3.2 G.3b — #95 visibility bounding (CONDITIONAL) +### 3.2 G.3b — #95 visibility bounding (REQUIRED — gate-confirmed 2026-06-13) -**Trigger:** *only* if the G.3a visual gate shows the see-through-walls / -other-dungeon-geometry blowup at the meeting hall. +**The G.3a gate confirmed the blowup** (9.1M instances/frame in `0x0007`), so this +is the next blocker, not a conditional follow-up. The dungeon will not render +until the portal-visibility flood is bounded to the dungeon's own cell adjacency. -**If triggered:** port retail `CEnvCell::grab_visible_cells` (`:311878`) — a cell -with `seen_outside == 0` loads ZERO terrain and walks ONLY its `stab_list` of -adjacent EnvCells; the portal graph is bounded by the dungeon's own cell -adjacency, never a radius. Verify the dat carries the stab_list and acdream's -EnvCell loader parses it before relying on it. +**Fix:** port retail `CEnvCell::grab_visible_cells` (`:311878`) — a cell with +`seen_outside == 0` loads ZERO terrain and walks ONLY its `stab_list` of adjacent +EnvCells; the portal graph is bounded by the dungeon's own cell adjacency, never a +radius / never the whole resident cell set. This is a render-pipeline change in +`PortalVisibilityBuilder` (the flap-/DO-NOT-RETRY-sensitive area) and needs its own +grounding + brainstorm before implementation (verify the dat carries the stab_list +and acdream's EnvCell loader parses it; confirm the `seen_outside` flag is read; +decide how it composes with the outdoor-root look-in floods). **NOT a wing-it +inline fix.** -**If NOT triggered:** close #95 as **superseded** (deleted WB path; current flood -bounded) with a one-line ISSUES.md note. **No speculative build.** +**Open question surfaced at the gate (possible Bug C):** even with Bug A fixed +(placement keeps the dungeon prefix, `2ce5e5c`), the dungeon's negative-local-Y +coordinate frame may cause the per-tick membership/landblock resolution to drift +(the ACE `movement pre-validation failed` spam). Re-gate after Bug A to see if it +persists; if so, fold the dungeon-coordinate membership handling into G.3b's +grounding (it is plausibly the same `seen_outside` / cross-landblock root as #95). ### 3.3 G.3c — Portal-tunnel loading visual (faithful `TeleportAnimState`) From c8188e0ed695288a268f0b23d3d0f93ce56833d7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 18:35:58 +0200 Subject: [PATCH 37/65] =?UTF-8?q?docs:=20correct=20stale=20UCG=20CellGraph?= =?UTF-8?q?=20comments=20=E2=80=94=20the=20graph=20is=20active,=20not=20in?= =?UTF-8?q?ert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "consumed by nobody (zero behavior change)" / "INERT in Stage 1 (no writer)" comments predate the UCG becoming load-bearing. Verified against the call sites: CellGraph is populated unconditionally in CacheCellStruct (before the idempotency + null-BSP guards, so BSP-less cells are included) and consumed for the player render/lighting root (CurrCell, written at the PhysicsEngine.UpdatePlayerCurrCell player chokepoint; read by GameWindow:7502/7717), the universal id->cell resolver (GetVisible), the 3rd-person camera cell (FindVisibleChildCell), and the block-local terrain origin (TryGetTerrainOrigin, read by CellTransit:484/736). Comments only — no behavior change. Core suite 1445 passed / 2 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Physics/PhysicsDataCache.cs | 12 ++++++++++-- src/AcDream.Core/World/Cells/CellGraph.cs | 19 ++++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index deec7ed3..7218e016 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -26,8 +26,16 @@ public sealed class PhysicsDataCache private readonly ConcurrentDictionary _buildings = new(); /// - /// UCG Stage 1: the unified cell graph, built alongside the legacy cell caches. - /// Consumed by nobody this stage (zero behavior change). + /// The unified cell graph (UCG): the active id->cell resolver and registry. + /// Populated unconditionally in — BEFORE the + /// idempotency + null-BSP guards, so BSP-less cells are registered too — and + /// consumed across the engine: the player render/lighting root + /// (CellGraph.CurrCell, written at the player chokepoint + /// PhysicsEngine.UpdatePlayerCurrCell and read by the renderer), the + /// universal id->cell lookup (GetVisible), the 3rd-person camera cell + /// (FindVisibleChildCell), and the block-local terrain origin + /// (TryGetTerrainOrigin, read by CellTransit's pick + transit + /// paths). No longer inert. /// public UcgCellGraph CellGraph { get; } = new(); diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs index fb6269fd..00b19ce9 100644 --- a/src/AcDream.Core/World/Cells/CellGraph.cs +++ b/src/AcDream.Core/World/Cells/CellGraph.cs @@ -6,17 +6,26 @@ using AcDream.Core.Physics; // TerrainSurface namespace AcDream.Core.World.Cells; /// -/// The unified cell graph: the authoritative id->cell resolver and registry. -/// Built alongside the legacy render/physics cell systems in Stage 1 and consumed -/// by nobody (zero behavior change). Retail anchor: CObjCell::GetVisible (pseudo_c:308209). -/// Worker-thread populated; reads are concurrency-safe. +/// The unified cell graph: the active, authoritative id->cell resolver and registry. +/// Populated unconditionally from +/// (before its +/// idempotency + null-BSP guards, so BSP-less cells are included) and consumed across +/// the engine: resolves any cell id, is +/// the player render/lighting root, resolves the +/// 3rd-person camera cell, and supplies the block-local +/// terrain origin for the LandDefs lcoord math. Retail anchor: CObjCell::GetVisible +/// (pseudo_c:308209). Worker-thread populated; reads are concurrency-safe. /// public sealed class CellGraph { private readonly ConcurrentDictionary _envCells = new(); private readonly ConcurrentDictionary _terrain = new(); - /// Player's current cell. Defined for Stage 2; INERT in Stage 1 (no writer). + /// The player's current cell — the render/lighting root. Written ONLY at the + /// player chokepoint + /// (NPCs never touch it — a per-entity writer was the cottage-doorway "blue-hole" + /// cause); read by the renderer for the player root (GameWindow). Left unchanged when + /// the id isn't yet resolvable in the graph (stale beats null). public ObjCell? CurrCell { get; internal set; } public bool Contains(uint envCellId) => _envCells.ContainsKey(envCellId); From dd7b73a837779f60f0ee2db06b1bcdbd697eaf00 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 18:39:45 +0200 Subject: [PATCH 38/65] docs(G.3): file login-INTO-a-dungeon gap (streaming not recentered at login) Re-gate of Bug A revealed: logging in with the character saved inside a far dungeon hangs at the #107 auto-entry hold (frozen, no [snap]). The streaming center is set once at startup to the default and the login spawn never recenters it, so the dungeon never streams and IsSpawnCellReady never goes true. The teleport-arrival path recenters (G.3a); the login path doesn't. Filed under #133 with the fix shape (recenter onto the spawn landblock at login) + the ACE-reset workaround. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 8abe2820..63e83b6a 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -70,6 +70,20 @@ pre-validation failed` spam) — re-gate after Bug A to confirm. NOTE: a render- EnvCell hydration decouple was tried in G.3a and REVERTED (`e7058ca`) — it made the player character invisible at Holtburg (it touched the shared building hydration path); re-approach separately if a geometry-less collision cell ever needs it. + +**NEW GAP (2026-06-13 PM — login-INTO-a-dungeon):** logging in while the saved +character is inside a far dungeon hangs at the auto-entry hold (player frozen, +no `[snap]`/`auto-entered player mode`, movement input ignored). Root: the +streaming center is set ONCE at startup to the default (`_liveCenterX/Y = centerX/ +centerY`, `GameWindow.cs:1942` → "centered on 0xA9B4FFFF") and the login spawn never +recenters it; a dungeon spawn 46 km away never streams, so `IsSpawnCellReady(spawn +cell)` stays false and the #107 hold waits forever. The TELEPORT-arrival path +recenters (G.3a `TeleportArrivalController`); the LOGIN path does not. Fix shape = +recenter streaming onto the spawn landblock when the login spawn first arrives +(mind the #107 auto-entry hold's `SampleTerrainZ(pe.Position)` frame after the +recenter). Pre-existing; only surfaces now that the test character can be saved in +a dungeon. Workaround to unblock testing: move `+Acdream` out of the dungeon +server-side (ACE) before logging in. **Severity:** HIGH (any far/dungeon teleport is unusable) **Filed:** 2026-06-13 (M1.5 dungeon-demo gate attempt — meeting-hall portal) **Component:** physics/streaming — teleport-arrival snap vs async landblock hydration From 95d9dab4bb245fb023fe3f2ba862bf6c5250daad Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 18:52:00 +0200 Subject: [PATCH 39/65] =?UTF-8?q?test(#95):=20headless=20dungeon-flood=20d?= =?UTF-8?q?iagnostic=20=E2=80=94=20measure=20visible-cell=20count=20on=200?= =?UTF-8?q?x0007?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Issue95DungeonFloodDiagnosticTests.cs | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs diff --git a/tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs b/tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs new file mode 100644 index 00000000..5e5f1228 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs @@ -0,0 +1,198 @@ +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; +using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo; + +namespace AcDream.App.Tests.Rendering; + +/// +/// #95 MEASUREMENT (2026-06-13): entering the 0x0007 dungeon (Town Network) explodes +/// WB-DIAG to ~9.1M instances/frame. Suspected cause: +/// floods the dungeon's portal graph WITHOUT the retail grab_visible_cells stab_list bounding +/// (decomp:311878). A dungeon cell has seen_outside==0; retail's PVS for it is just the +/// cell's stab_list () — typically a small bounded +/// set. If our flood instead visits ~all cells of the landblock, that is the blowup. +/// +/// This is a DIAGNOSTIC, not a fix: it loads the real 0x0007 interior cells, runs the real +/// production flood from representative dungeon-cell roots, and PRINTS the ground-truth numbers — +/// flood visited-cell-set size () vs the +/// root's stab_list size (), plus how many visited cells +/// cross landblocks. The single assertion just guarantees the test ran; the VALUE is the output. +/// +public class Issue95DungeonFloodDiagnosticTests +{ + private const uint TownNetwork = 0x00070000u; + + private readonly ITestOutputHelper _out; + public Issue95DungeonFloodDiagnosticTests(ITestOutputHelper output) => _out = output; + + // Production-ish projection (mirrors the sibling harnesses): FovY ~1.2, 1280x720, + // near 0.1, far 5000. The flood's clip is near-independent, so exactness is not + // load-bearing for cell-count measurement. + private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt) + { + var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f); + return view * proj; + } + + [Fact] + public void Measure_DungeonFlood_VisibleCellCount() + { + var datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: dat dir did not resolve (ACDREAM_DAT_DIR unset and " + + "%USERPROFILE%\\Documents\\Asheron's Call absent). No numbers measured."); + // Diagnostic test: do not hard-fail when dats are absent (matches sibling harnesses). + return; + } + _out.WriteLine($"dat dir resolved: {datDir}"); + + using var dats = new DatCollection(datDir, DatAccessType.Read); + + // 1) LandBlockInfo header — NumCells for 0x0007. + var lbi = dats.Get(TownNetwork | 0xFFFEu); + if (lbi is null) + { + _out.WriteLine($"SKIP: LandBlockInfo 0x{TownNetwork | 0xFFFEu:X8} not found in the dat " + + "(0x0007 may not exist in this client_cell_1.dat)."); + return; + } + _out.WriteLine($"=== 0x0007 (Town Network) LandBlockInfo ==="); + _out.WriteLine($"NumCells (DatLandBlockInfo.NumCells) = {lbi.NumCells}"); + + // 2) Load ALL interior cells (sparse ids tolerated — see LoadAllInteriorCells). + var loaded = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, TownNetwork); + _out.WriteLine($"cells actually loaded = {loaded.Count}"); + Assert.True(loaded.Count > 0, "no interior cells loaded for 0x0007 — cannot measure"); + + Func lookup = id => loaded.TryGetValue(id, out var c) ? c : null; + + // 3) Per-cell stab_list (VisibleCells) distribution across ALL loaded cells. + // This is the bounded retail PVS size we expect the flood to roughly match. + var stabSizes = loaded.Values.Select(c => c.VisibleCells.Count).ToList(); + int seenOutsideCount = loaded.Values.Count(c => c.SeenOutside); + int interiorCount = loaded.Count - seenOutsideCount; + _out.WriteLine(""); + _out.WriteLine("=== stab_list (LoadedCell.VisibleCells) distribution over ALL loaded cells ==="); + _out.WriteLine($"cells with SeenOutside==true (entrance/exterior-facing) = {seenOutsideCount}"); + _out.WriteLine($"cells with SeenOutside==false (interior dungeon) = {interiorCount}"); + if (stabSizes.Count > 0) + _out.WriteLine(FormattableString.Invariant( + $"VisibleCells.Count min={stabSizes.Min()} max={stabSizes.Max()} avg={stabSizes.Average():F1} sum={stabSizes.Sum()}")); + int emptyStab = stabSizes.Count(s => s == 0); + _out.WriteLine($"cells with EMPTY stab_list (no dat PVS) = {emptyStab}"); + + // 4) Pick representative DUNGEON roots: the first interior (SeenOutside==false) cells in + // ascending id order. If none exist, fall back to 0x00070100 and report that. + var interiorRoots = loaded + .Where(kv => !kv.Value.SeenOutside) + .OrderBy(kv => kv.Key) + .Select(kv => kv.Value) + .Take(5) + .ToList(); + + if (interiorRoots.Count == 0) + { + _out.WriteLine(""); + _out.WriteLine("NOTE: NO cell has SeenOutside==false (all cells see the exterior). " + + "Falling back to root 0x00070100 for the flood measurement."); + if (loaded.TryGetValue(TownNetwork | 0x0100u, out var fallback)) + interiorRoots.Add(fallback); + else + { + _out.WriteLine("WARN: 0x00070100 not loaded either; using the lowest-id loaded cell."); + interiorRoots.Add(loaded.OrderBy(kv => kv.Key).First().Value); + } + } + + _out.WriteLine(""); + _out.WriteLine("=== PER-ROOT FLOOD MEASUREMENT (PortalVisibilityBuilder.Build) ==="); + _out.WriteLine("property read for the visited-cell set: PortalVisibilityFrame.OrderedVisibleCells"); + _out.WriteLine("root | seenOut | stab(VisibleCells) | flood(OrderedVisibleCells) | crossLB | dir"); + + var floodSizes = new List(); + foreach (var root in interiorRoots) + { + // Eye at the root cell's world origin, looking toward its first portal (or +X if none), + // so the flood actually fires through an opening. Sweep all 6 axis directions and KEEP + // the maximum visited-set — the blowup is a worst-case-over-orientation quantity. + var eye = root.WorldPosition; + int bestFlood = -1; + string bestDir = "?"; + int bestCrossLb = -1; + List? bestVisited = null; + + // Direction candidates: toward each portal's polygon centroid (the natural look-through), + // plus the 6 cardinal axes as a fallback sweep. + var lookTargets = new List<(Vector3 target, string label)>(); + for (int pi = 0; pi < root.Portals.Count && pi < root.PortalPolygons.Count; pi++) + { + var poly = root.PortalPolygons[pi]; + if (poly is { Length: >= 1 }) + { + var cl = Vector3.Zero; + foreach (var v in poly) cl += v; + cl /= poly.Length; + lookTargets.Add((Vector3.Transform(cl, root.WorldTransform), + $"portal{pi}->0x{root.Portals[pi].OtherCellId:X4}")); + } + } + foreach (var (d, lbl) in new (Vector3, string)[] + { + (Vector3.UnitX, "+X"), (-Vector3.UnitX, "-X"), + (Vector3.UnitY, "+Y"), (-Vector3.UnitY, "-Y"), + (Vector3.UnitZ, "+Z"), (-Vector3.UnitZ, "-Z"), + }) + lookTargets.Add((eye + d * 5f, lbl)); + + foreach (var (target, label) in lookTargets) + { + if (Vector3.DistanceSquared(target, eye) < 1e-6f) continue; + var frame = PortalVisibilityBuilder.Build(root, eye, lookup, ViewProjFor(eye, target)); + int floodN = frame.OrderedVisibleCells.Count; + if (floodN > bestFlood) + { + bestFlood = floodN; + bestDir = label; + bestVisited = frame.OrderedVisibleCells; + bestCrossLb = frame.OrderedVisibleCells.Count(id => (id & 0xFFFF0000u) != TownNetwork); + } + } + + floodSizes.Add(bestFlood); + _out.WriteLine(FormattableString.Invariant( + $"0x{root.CellId:X8} | {(root.SeenOutside ? "Y" : "N"),5} | {root.VisibleCells.Count,18} | {bestFlood,26} | {bestCrossLb,7} | {bestDir}")); + + // For the FIRST root, also print the actual visited set + stab set for eyeballing. + if (ReferenceEquals(root, interiorRoots[0]) && bestVisited is not null) + { + _out.WriteLine(" first-root visited (OrderedVisibleCells, low ids): " + + string.Join(" ", bestVisited.Select(id => $"{id & 0xFFFFu:X4}"))); + _out.WriteLine(" first-root stab_list (VisibleCells, low ids): " + + string.Join(" ", root.VisibleCells.Select(id => $"{id & 0xFFFFu:X4}"))); + } + } + + // 5) Aggregate flood-size stats across the sampled roots — the headline numbers. + _out.WriteLine(""); + _out.WriteLine("=== AGGREGATE over sampled roots ==="); + if (floodSizes.Count > 0) + _out.WriteLine(FormattableString.Invariant( + $"flood visited-set size (OrderedVisibleCells): min={floodSizes.Min()} max={floodSizes.Max()} avg={floodSizes.Average():F1} (NumCells={lbi.NumCells}, loaded={loaded.Count})")); + var sampledStab = interiorRoots.Select(r => r.VisibleCells.Count).ToList(); + if (sampledStab.Count > 0) + _out.WriteLine(FormattableString.Invariant( + $"sampled roots' stab_list size (VisibleCells): min={sampledStab.Min()} max={sampledStab.Max()} avg={sampledStab.Average():F1}")); + _out.WriteLine(""); + _out.WriteLine("INTERPRETATION: if flood max ~= loaded.Count (visits ~all cells) while stab " + + "is small, that is the #95 blowup — the flood is unbounded by the retail stab_list PVS."); + } +} From 47ae237e7b0dee87ff3c9def1fd40111077cbc71 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 19:00:14 +0200 Subject: [PATCH 40/65] fix(G.3): recenter streaming onto the spawn landblock at login (#133) A character saved inside a far dungeon hung at the #107 auto-entry hold because the streaming center was fixed at the startup default and the login spawn never recentered it, so the dungeon never streamed. Mirror the teleport-arrival recenter on the login player-spawn path: when the player's spawn landblock differs from the current center, recenter before translating the spawn position (landblock-local -> new-center frame). No-op for a same-landblock (normal Holtburg) login. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1c1db412..16956ac4 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2436,6 +2436,40 @@ public sealed class GameWindow : IDisposable // landblock; each neighbor landblock is offset by 192 units per step. int lbX = (int)((p.LandblockId >> 24) & 0xFFu); int lbY = (int)((p.LandblockId >> 16) & 0xFFu); + + // G.3 (#133): recenter streaming onto the player's spawn landblock at + // login. The streaming center (_liveCenterX/_liveCenterY) is pinned to + // the startup default (Holtburg, 0xA9B4) and is otherwise only moved by + // the teleport-arrival path (OnLivePositionUpdated, ~line 4901). A + // character saved INSIDE a far dungeon spawns with that dungeon's + // landblock id, but the center never followed it, so the dungeon (tens + // of km away in world space) never streamed and the #107 auto-entry + // gate's SampleTerrainZ(pe.Position) waited forever — the player hung + // frozen at login. Mirror the teleport-arrival recenter HERE, for the + // PLAYER's spawn only, BEFORE the world-space translation below: when + // the spawn landblock differs from the current center, move the center + // onto it so the spawn maps to (PositionX, PositionY, PositionZ) in the + // new center frame (identical to the teleport path's + // `newWorldPos = new Vector3(p.PositionX, p.PositionY, p.PositionZ)`), + // and the next StreamingController.Tick observes the new center and + // streams the spawn landblock. + // + // No-op for a normal Holtburg login: the saved spawn landblock equals + // the default center, so the guard is false and origin/worldPos are + // byte-identical to the pre-fix path. Gated on the player guid so NPC / + // object spawns never move the center. Idempotent + thrash-free: a + // re-sent CreateObject for the same spawn landblock leaves the center + // already-equal, so the guard is false on every repeat. + if (spawn.Guid == _playerServerGuid + && (lbX != _liveCenterX || lbY != _liveCenterY)) + { + Console.WriteLine( + $"live: login spawn — recentering streaming from ({_liveCenterX},{_liveCenterY}) " + + $"to ({lbX},{lbY}) for player spawn @0x{p.LandblockId:X8}"); + _liveCenterX = lbX; + _liveCenterY = lbY; + } + var origin = new System.Numerics.Vector3( (lbX - _liveCenterX) * 192f, (lbY - _liveCenterY) * 192f, From a40c38e8bdc0b7050b7d51ddb73cde1b6e5f21ac Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 19:36:04 +0200 Subject: [PATCH 41/65] =?UTF-8?q?milestone(G.3):=20dungeons=20RENDER=20?= =?UTF-8?q?=E2=80=94=20#95=20was=20a=20Bug-A=20symptom,=20not=20an=20unbou?= =?UTF-8?q?nded=20flood?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autonomous /loop verification: a live launch into the 0x0007 dungeon renders with a sane budget (WB-DIAG instances ~39,000, meshMissing=0; was 9.1M pre-Bug-A), correct membership (no ACE failed-transition spam), navigable. The chain: G.3a teleport hold+place + Bug A (2ce5e5c, validated-claim landblock prefix) + login-into-dungeon recenter (47ae237). A headless diagnostic (Issue95DungeonFloodDiagnosticTests, 95d9dab) proved the portal flood is already bounded (1-17 cells vs the stab_list's 120-204), so #95's "port grab_visible_cells stab_list bounding" was the WRONG fix and is NOT pursued. ISSUES #95 -> RESOLVED, #133 -> renders + login-into-dungeon fixed; CLAUDE.md current state + render digest updated. Remaining for M1.5: A7 dungeon torch/point-lighting. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 19 +++++++++++-------- docs/ISSUES.md | 41 ++++++++++++++++++++++++++++++----------- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e328ce51..d4c43ff5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,14 +108,17 @@ movement queries. ## Current state -**Currently working toward: M1.5 — Indoor world feels right.** The -building/cellar demo is DONE + user-gated, but M1.5 was EXTENDED 2026-06-13 -to include **dungeon support (full Phase G.3)** — dungeons don't work at -all: terrain-less dungeon landblocks aren't supported by the streaming/ -load/render/physics pipeline (`LandblockLoader.Load` null with no -`LandBlock`; streamer needs a terrain mesh; teleport snaps before hydration -→ ocean — issue **#133**). M1.5 does NOT land until dungeons work; M2 -(CombatMath) deferred. Currently brainstorming the G.3 dungeon-support spec. +**Currently working toward: M1.5 — Indoor world feels right.** Building/cellar +demo DONE; **dungeons now RENDER** (2026-06-13, autonomous /loop): G.3a teleport +hold+place + **Bug A** (validated-claim keeps the dungeon landblock prefix, `2ce5e5c`) ++ **login-into-dungeon recenter** (`47ae237`) → live `0x0007` dungeon renders, navigable, +correct membership, WB-DIAG instances **9.1M→39K**. **#95 was a Bug-A symptom, NOT an +unbounded flood — DO NOT port `grab_visible_cells` stab_list bounding** (the flood is +already bounded; the "terrain-less landblock" framing was refuted — dungeons are +flat-terrain + EnvCells). REMAINING for M1.5: **A7 dungeon torch/point-lighting** (dungeon +gets retail's flat 0.2 indoor ambient but `Setup.Lights` torches aren't registered → dim, +"lighting off"); needs visual iteration. M2 (CombatMath) deferred. Detail in **#133/#95** +(ISSUES) + the render digest's top banner. Recent closes (2026-06-12/13): #119/#128, #112, #113, #124, #129/#130/#131/#132, UN-2, #108-residual, #127, #125; #116 partial (Ghidra threshold fix). Keep this paragraph ≤5 lines + pointers — detail in the diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 63e83b6a..9974b7be 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -83,7 +83,23 @@ recenter streaming onto the spawn landblock when the login spawn first arrives (mind the #107 auto-entry hold's `SampleTerrainZ(pe.Position)` frame after the recenter). Pre-existing; only surfaces now that the test character can be saved in a dungeon. Workaround to unblock testing: move `+Acdream` out of the dungeon -server-side (ACE) before logging in. +server-side (ACE) before logging in. **FIXED 2026-06-13 (`47ae237`)** — the login +player-spawn path now recenters `_liveCenterX/Y` onto the spawn landblock (mirrors +the teleport-arrival recenter; no-op for a same-landblock Holtburg login). Verified +live: `live: login spawn — recentering streaming from (169,180) to (0,7)` → dungeon +streams → `auto-entered player mode` in the dungeon. + +**✅ DUNGEON RENDERS — M1.5 milestone (2026-06-13 PM, autonomous /loop, objectively +verified).** With Bug A (`2ce5e5c`) + login-into-dungeon (`47ae237`), a live launch +into the `0x0007` dungeon: player grounded on the dungeon floor (`[snap] claim=0x00070143 +VALIDATED z=0.000`), correct membership (cell stays `0x0007…`, ZERO ACE `failed +transition` spam), and the render budget is sane — **WB-DIAG instances ~39,000 +(meshMissing=0)** vs the 9.1M pre-Bug-A blowup (#95, now RESOLVED as a Bug-A symptom). +User-confirmed: "no errors from ACE this time." REMAINING (not a render bug): dungeon +**torch/point-lighting = Phase A7** — the dungeon correctly gets retail's flat 0.2 indoor +ambient (`GameWindow.UpdateSunFromSky`, `playerInsideCell` true via `playerRoot && !SeenOutside`), +but per-cell `Setup.Lights` point-lights (torches) aren't registered yet, so it looks dim +("lighting off"). That's the A7 indoor-lighting feature, needs visual iteration. **Severity:** HIGH (any far/dungeon teleport is unusable) **Filed:** 2026-06-13 (M1.5 dungeon-demo gate attempt — meeting-hall portal) **Component:** physics/streaming — teleport-arrival snap vs async landblock hydration @@ -916,16 +932,19 @@ Retail oracle for cell-id hysteresis: `acclient_2013_pseudo_c.txt:308742-308783` ## #95 — Dungeon portal-graph visibility blowup (see-through-walls / other dungeons rendered) -**Status:** OPEN — **RE-CONFIRMED LIVE under the current Option-A pipeline (2026-06-13 -G.3a gate).** A real `PlayerTeleport` into the `0x0007` dungeon blew WB-DIAG up to -entSeen=6.5M / instances=9.1M / drawsIssued=590K per frame (vs. 3345/4667 at Holtburg) -with a flood of `[mesh-miss] 0x000100xxxx` interior re-requests → dungeon renders as -"thin air." So the T1–T6 rewrite did NOT supersede this (the earlier "likely superseded" -read was wrong). This is now the **#133/G.3b** blocker; fix shape = port retail -`CEnvCell::grab_visible_cells` (:311878) stab_list bounding (seen_outside==0 → walk only -stab_list, never the whole resident cell set). Needs its own grounding/brainstorm in the -flap-sensitive `PortalVisibilityBuilder`. **Originally** also: **explains user-observed -"dungeons are broken"** +**Status:** RESOLVED 2026-06-13 — **the 9.1M-instance blowup was a SYMPTOM of Bug A +(wrong dungeon membership), NOT an unbounded portal flood.** Chain of evidence: (1) a +headless diagnostic on the real `0x0007` dungeon (`Issue95DungeonFloodDiagnosticTests`, +`95d9dab`) measured `PortalVisibilityBuilder` visiting only **1–17 cells** per root — +already tightly bounded and a strict *subset* of the stab_list (`VisibleCells`, which is +the BIG set: avg 120, max 204 of 205 cells). So porting `grab_visible_cells` stab_list +bounding would have made it WORSE — **DO NOT do that.** (2) The 9.1M blowup was captured at +the G.3a gate *before* Bug A's fix (`2ce5e5c`), when the player's membership wrongly +resolved to `0xA9B3` (Holtburg) → the render rooted at the wrong place. (3) With Bug A + +login-into-dungeon (`47ae237`) fixed, a live launch into `0x0007` measured +**instances=~39,000 (down from 9.1M, ~230×), meshMissing=0**, dungeon renders, no ACE +errors. The flood was never the bug. **Originally** also: explained user-observed +"dungeons are broken" **Severity:** HIGH (blocks all dungeon navigation visually) **Filed:** 2026-05-21 **Component:** rendering, visibility, EnvCell portal traversal From d6fb788c9699b623c3cca0750be948c1f9bae5d1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 19:43:27 +0200 Subject: [PATCH 42/65] =?UTF-8?q?diag:=20ACDREAM=5FPROBE=5FLIGHT=20?= =?UTF-8?q?=E2=80=94=20log=20dungeon=20ambient/sun/active-light=20state=20?= =?UTF-8?q?(#133=20A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 18 +++++ .../Rendering/RenderingDiagnostics.cs | 72 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 16956ac4..24472020 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7625,6 +7625,24 @@ public sealed class GameWindow : IDisposable _sceneLightingUbo?.Upload(ubo); + // #133 A7 (2026-06-13): objective dungeon-lighting probe. One + // rate-limited [light] line — insideCell / ambient / sun / + // registered-point-lights / active-slot-count / player cell — so + // the dungeon-dim question is self-verifiable from launch.log + // without a screenshot. RegisteredCount is point/spot lights only + // (the sun lives in LightManager.Sun, never in the _all list); + // ubo.CellAmbient.W is the shader active-slot count, which counts + // the (zeroed) sun slot indoors. Inert unless ACDREAM_PROBE_LIGHT=1. + AcDream.Core.Rendering.RenderingDiagnostics.EmitLight( + insideCell: playerInsideCell, + ambientR: Lighting.CurrentAmbient.AmbientColor.X, + ambientG: Lighting.CurrentAmbient.AmbientColor.Y, + ambientB: Lighting.CurrentAmbient.AmbientColor.Z, + sunIntensity: Lighting.Sun?.Intensity ?? 0f, + registeredLights: Lighting.RegisteredCount, + activeLights: (int)ubo.CellAmbient.W, + playerCellId: playerRoot?.CellId ?? 0u); + // Never cull the landblock the player is currently on. uint? playerLb = null; if (_playerMode && _playerController is not null) diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index ba081f71..a070fe54 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -243,6 +243,34 @@ public static class RenderingDiagnostics public static bool ProbePhantomEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_PHANTOM") == "1"; + /// + /// #133 A7 (2026-06-13) dungeon-lighting objective probe. When true, + /// the per-frame scene-lighting build emits ONE [light] line + /// roughly every second (wall-clock rate-limited like WB-DIAG) via + /// : + /// + /// [light] insideCell=<bool> ambient=(r,g,b) sun=<intensity> + /// registeredLights=<N> activeLights=<uCellAmbient.w> playerCell=0x<id> + /// + /// This is the self-verification signal for the dungeon-dim question: + /// + /// insideCell=true ambient=(0.20,0.20,0.20) sun=0 + /// confirms the indoor branch fired (retail flat ambient, sun killed). + /// registeredLights is the count of dat-baked + /// point/spot lights (Setup.Lights) registered with the + /// LightManager — if this is 0 in a dungeon, the cell's static + /// objects carry no baked torches (so the only illumination IS the + /// 0.2 ambient → dim). + /// activeLights is uCellAmbient.w — the + /// shader's active-slot count, which INCLUDES the (zeroed) sun slot + /// indoors. So activeLights=1 registeredLights=0 = "only the dead + /// sun slot, no torches in range". + /// + /// Output-only, inert when off. Initial state from ACDREAM_PROBE_LIGHT=1. + /// + public static bool ProbeLightEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1"; + // Cell-change gate for EmitVis. The probe fires once per distinct root cell // so launch.log stays readable under motion (the per-frame call is a no-op // when the root is unchanged). Sentinel 0 = "no root yet" — the first real @@ -336,6 +364,50 @@ public static class RenderingDiagnostics /// internal static void ResetVisibilityProbeForTests() => _lastVisRootCellId = 0; + // Wall-clock rate-limit gate for EmitLight. Ticks (100 ns) is plenty — + // we only need ~1 Hz and avoid a Stopwatch allocation/field. Sentinel 0 + // = "never emitted" so the first call always fires. + private static long _lastLightEmitTicks; + private const long LightEmitIntervalTicks = 10_000_000; // 1 s in 100-ns ticks + + /// + /// #133 A7 — emit ONE rate-limited [light] line describing the + /// current scene-lighting state. Cheap no-op when + /// is false; otherwise fires at most + /// once per second. Pull the values from the spot where + /// GameWindow.UpdateSunFromSky set Lighting.CurrentAmbient + /// / Lighting.Sun and where SceneLightingUbo.Build computed + /// the active-slot count. + /// + /// The playerInsideCell value driving the indoor branch. + /// Cell ambient red (xyz of uCellAmbient). + /// Cell ambient green. + /// Cell ambient blue. + /// The sun LightSource.Intensity (0 indoors). + /// Total point/spot lights registered with the LightManager. + /// uCellAmbient.w — shader active-slot count (includes the zeroed sun slot indoors). + /// The player's current cell id (0 if unresolved → outside). + public static void EmitLight(bool insideCell, + float ambientR, float ambientG, float ambientB, + float sunIntensity, + int registeredLights, + int activeLights, + uint playerCellId) + { + if (!ProbeLightEnabled) return; + + long now = DateTime.UtcNow.Ticks; + if (_lastLightEmitTicks != 0 && (now - _lastLightEmitTicks) < LightEmitIntervalTicks) + return; + _lastLightEmitTicks = now; + + var ci = System.Globalization.CultureInfo.InvariantCulture; + Console.WriteLine(string.Format(ci, + "[light] insideCell={0} ambient=({1:0.###},{2:0.###},{3:0.###}) sun={4:0.###} registeredLights={5} activeLights={6} playerCell=0x{7:X8}", + insideCell, ambientR, ambientG, ambientB, sunIntensity, + registeredLights, activeLights, playerCellId)); + } + private static bool _probeEnvCellEnabled = Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1"; From a80061b0c2a3c9821f126eaf6affdb09a65c9527 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 20:35:01 +0200 Subject: [PATCH 43/65] =?UTF-8?q?fix(G.3=20A7):=20dungeon=20lighting=20?= =?UTF-8?q?=E2=80=94=20select=208=20NEAREST=20lights,=20not=20viewer-in-ra?= =?UTF-8?q?nge=20(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active-light selection dropped any point light whose range didn't reach the VIEWER (DistSq > Range^2*slack -> skip). Retail's D3D-style fixed pipeline picks the 8 NEAREST lights and applies the hard range cutoff PER SURFACE in the shader (mesh_modern.frag: if (d < range)). The viewer-range candidacy filter suppressed a torch whenever the player stood outside its range, so a dungeon room with 2227 registered torches lit only the ~1 the player was standing in (activeLights ~= 1, rest of the room at flat 0.2 ambient = the "lighting off" report). Drop the filter; take the nearest 8 regardless of viewer range. Removed the now-unused RangeSlack const; updated the two tests that codified the old filter. Core lighting suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Lighting/LightManager.cs | 13 ++++++++++--- .../Lighting/LightManagerTests.cs | 18 +++++++++++++----- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index a9ba8dfc..98402ac7 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -37,7 +37,6 @@ namespace AcDream.Core.Lighting; public sealed class LightManager { public const int MaxActiveLights = 8; // D3D parity - private const float RangeSlack = 1.1f; // 10% hysteresis around hard cutoff private readonly List _all = new(); private readonly LightSource?[] _active = new LightSource?[MaxActiveLights]; @@ -109,8 +108,16 @@ public sealed class LightManager Vector3 delta = light.WorldPosition - viewerWorldPos; light.DistSq = delta.LengthSquared(); - float rangeSq = light.Range * light.Range * RangeSlack * RangeSlack; - if (light.DistSq > rangeSq) continue; + // Retail D3D-style fixed-pipeline lighting picks the 8 NEAREST point + // lights and applies each light's hard range-cutoff PER SURFACE in the + // shader (mesh_modern.frag: `if (d < range && range > 1e-3)`). The + // previous viewer-range candidacy filter (skip when DistSq > Range²·slack²) + // was wrong — it dropped a torch whenever the VIEWER stood outside that + // torch's range, so a dungeon room with 2227 registered torches lit only + // the ~1 the player was standing inside (activeLights≈1, the rest of the + // room at flat 0.2 ambient — the "dungeon lighting off" report). Take the + // nearest 8 regardless of viewer range; the shader's per-fragment + // `d < range` does the actual hard cutoff. candidates.Add(light); } diff --git a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs index 9df68a2b..1bb225a2 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs @@ -60,21 +60,29 @@ public sealed class LightManagerTests } [Fact] - public void Tick_DropsLightsOutsideRangeWithSlack() + public void Tick_SelectsByDistance_RegardlessOfViewerRange() { + // Retail D3D-style: candidacy is distance-only (the nearest 8). A torch + // lights its OWN surfaces — the shader applies the hard `d < range` cutoff + // PER FRAGMENT (mesh_modern.frag) — so a torch the VIEWER is standing + // outside the range of is still selected; it lights the wall it sits on. + // Replaces the old viewer-range candidacy filter that suppressed it, which + // left dungeon rooms (2227 registered torches) at activeLights≈1 / flat 0.2 + // ambient — the "dungeon lighting off" report (#133 A7). var mgr = new LightManager(); - mgr.Register(MakePoint(new Vector3(20, 0, 0), range: 5f)); // far outside its own range + mgr.Register(MakePoint(new Vector3(20, 0, 0), range: 5f)); // viewer outside the torch's range mgr.Tick(viewerWorldPos: Vector3.Zero); - Assert.Equal(0, mgr.ActiveCount); + Assert.Equal(1, mgr.ActiveCount); // selected by distance; the shader culls per-surface } [Fact] - public void Tick_IncludesLightsNearRangeEdge_WithSlack() + public void Tick_IncludesNearbyLight() { var mgr = new LightManager(); - // Light at distance 5.0, range 5.0: distSq=25, rangeSq*1.1^2 = 25*1.21 = 30.25 → included. + // A nearby point light is selected (distance-only candidacy; the shader + // applies the per-fragment range cutoff). mgr.Register(MakePoint(new Vector3(5, 0, 0), range: 5f)); mgr.Tick(viewerWorldPos: Vector3.Zero); From 167f05c4faf1aee5c82b6f6a32431182197127af Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 20:45:29 +0200 Subject: [PATCH 44/65] docs(G.3 A7): record dungeon light-selection fix (activeLights 2->8) + the 0.30 ambient follow-up Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 9974b7be..5c528ee3 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -95,11 +95,21 @@ into the `0x0007` dungeon: player grounded on the dungeon floor (`[snap] claim=0 VALIDATED z=0.000`), correct membership (cell stays `0x0007…`, ZERO ACE `failed transition` spam), and the render budget is sane — **WB-DIAG instances ~39,000 (meshMissing=0)** vs the 9.1M pre-Bug-A blowup (#95, now RESOLVED as a Bug-A symptom). -User-confirmed: "no errors from ACE this time." REMAINING (not a render bug): dungeon -**torch/point-lighting = Phase A7** — the dungeon correctly gets retail's flat 0.2 indoor -ambient (`GameWindow.UpdateSunFromSky`, `playerInsideCell` true via `playerRoot && !SeenOutside`), -but per-cell `Setup.Lights` point-lights (torches) aren't registered yet, so it looks dim -("lighting off"). That's the A7 indoor-lighting feature, needs visual iteration. +User-confirmed: "no errors from ACE this time." + +**✅ A7 dungeon lighting — selection fix LANDED + objectively verified (`a80061b`).** The +"lighting off" report was NOT missing torches — the `ACDREAM_PROBE_LIGHT` diagnostic +(`d6fb788`) showed the dungeon correctly gets retail's flat 0.2 indoor ambient + sun zeroed +(`UpdateSunFromSky`, `playerInsideCell` true) AND **2227 torch/point-lights register**. The +bug was the active-light SELECTION: `LightManager.Tick` dropped any light whose range didn't +reach the VIEWER (`DistSq > Range²·slack² → skip`), so a room with 2227 torches lit only the +~1 the player stood inside (`activeLights≈1`, rest at flat 0.2). Retail's D3D model picks the +8 NEAREST lights and applies the hard range-cutoff PER SURFACE in the shader +(`mesh_modern.frag: if (d < range)`). Fix = drop the viewer-range candidacy filter, take the +nearest 8. Probe after: **`activeLights` 2→8** in the dungeon (the room's 8 nearest torches now +light it). Core lighting suite green. SECONDARY (flagged, not fixed): retail's per-cell ambient +default is 0.30 (`0x3e99999a`) read PER-CELL (`m_clrAmbientLight`) vs our flat 0.20 — a +candidate brightness tweak needing a decomp pass to confirm the world-EnvCell ambient source. **Severity:** HIGH (any far/dungeon teleport is unusable) **Filed:** 2026-06-13 (M1.5 dungeon-demo gate attempt — meeting-hall portal) **Component:** physics/streaming — teleport-arrival snap vs async landblock hydration From 9e809bc66117cbac4faf07e6d91a41da7f8c108a Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 20:55:14 +0200 Subject: [PATCH 45/65] =?UTF-8?q?diag:=20ACDREAM=5FPROBE=5FLIGHT=20[light-?= =?UTF-8?q?detail]=20=E2=80=94=20per-light=20range/intensity/cone=20(#133?= =?UTF-8?q?=20A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 3 +- .../Rendering/RenderingDiagnostics.cs | 47 ++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 24472020..b3e5efa0 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7641,7 +7641,8 @@ public sealed class GameWindow : IDisposable sunIntensity: Lighting.Sun?.Intensity ?? 0f, registeredLights: Lighting.RegisteredCount, activeLights: (int)ubo.CellAmbient.W, - playerCellId: playerRoot?.CellId ?? 0u); + playerCellId: playerRoot?.CellId ?? 0u, + lights: Lighting); // Never cull the landblock the player is currently on. uint? playerLb = null; diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index a070fe54..872285e4 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -372,12 +372,25 @@ public static class RenderingDiagnostics /// /// #133 A7 — emit ONE rate-limited [light] line describing the - /// current scene-lighting state. Cheap no-op when + /// current scene-lighting state, followed (when + /// is supplied) by up to three [light-detail] lines for the nearest + /// ACTIVE point/spot lights. Cheap no-op when /// is false; otherwise fires at most /// once per second. Pull the values from the spot where /// GameWindow.UpdateSunFromSky set Lighting.CurrentAmbient /// / Lighting.Sun and where SceneLightingUbo.Build computed /// the active-slot count. + /// + /// The [light-detail] lines are the answer to the "candle-spotlight" + /// question — they expose each torch's REAL dat-derived runtime values + /// (range= Falloff metres, intensity=, cone= radians, + /// color=, distToViewer=) so it is visible in launch.log + /// whether dungeon torches are tiny-range points or wide cones and at what + /// intensity — without a screenshot: + /// + /// [light-detail] kind=Point range=<Falloff m> intensity=<I> cone=<rad> color=(r,g,b) distToViewer=<m> + /// + /// /// /// The playerInsideCell value driving the indoor branch. /// Cell ambient red (xyz of uCellAmbient). @@ -387,12 +400,16 @@ public static class RenderingDiagnostics /// Total point/spot lights registered with the LightManager. /// uCellAmbient.w — shader active-slot count (includes the zeroed sun slot indoors). /// The player's current cell id (0 if unresolved → outside). + /// The ticked LightManager (its Active list, sorted nearest-first by the + /// just-completed Tick). When non-null, drives the [light-detail] lines. Optional so existing call + /// sites / tests that only want the aggregate line keep compiling. public static void EmitLight(bool insideCell, float ambientR, float ambientG, float ambientB, float sunIntensity, int registeredLights, int activeLights, - uint playerCellId) + uint playerCellId, + AcDream.Core.Lighting.LightManager? lights = null) { if (!ProbeLightEnabled) return; @@ -406,6 +423,32 @@ public static class RenderingDiagnostics "[light] insideCell={0} ambient=({1:0.###},{2:0.###},{3:0.###}) sun={4:0.###} registeredLights={5} activeLights={6} playerCell=0x{7:X8}", insideCell, ambientR, ambientG, ambientB, sunIntensity, registeredLights, activeLights, playerCellId)); + + // #133 A7 (2026-06-13) — per-light detail for the "spotlight bubble" + // question. Dump the actual runtime dat-derived values of the nearest + // ~3 ACTIVE point/spot lights so the real Falloff/Intensity/ConeAngle + // are visible in launch.log (are torch ranges 1m or 10m? points or + // spots? what intensity?). The sun (Directional, slot 0) is skipped — + // it carries no Range/cone meaning. DistSq is already cached by + // LightManager.Tick this frame, so the active list is sorted nearest- + // first; we just take the first few non-directional entries. + if (lights is null) return; + var active = lights.Active; + int shown = 0; + const int MaxDetail = 3; + for (int i = 0; i < active.Length && shown < MaxDetail; i++) + { + var ls = active[i]; + if (ls is null) continue; + if (ls.Kind == AcDream.Core.Lighting.LightKind.Directional) continue; + + float dist = ls.DistSq >= 0f ? MathF.Sqrt(ls.DistSq) : 0f; + Console.WriteLine(string.Format(ci, + "[light-detail] kind={0} range={1:0.###} intensity={2:0.###} cone={3:0.####} color=({4:0.###},{5:0.###},{6:0.###}) distToViewer={7:0.###}", + ls.Kind, ls.Range, ls.Intensity, ls.ConeAngle, + ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z, dist)); + shown++; + } } private static bool _probeEnvCellEnabled = From 1e70a5a484475f358660f372ef000fd9c83e3e20 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 20:58:03 +0200 Subject: [PATCH 46/65] =?UTF-8?q?fix(G.3=20A7):=20torch=20range=20=3D=20Fa?= =?UTF-8?q?lloff=20x=201.5=20(retail=20rangeAdjust)=20=E2=80=94=20wider=20?= =?UTF-8?q?pools=20(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retail PrimD3DRender::config_hardware_light (0x0059ad30) sets the hardware light Range = Falloff * rangeAdjust (1.5, global 0x00820cc4). We used Range = Falloff, so torches reached only 2/3 of retail -> tight 'candle/spotlight' bubbles in dungeons. Match retail's reach. Ambient 0.20 confirmed retail-faithful (the 0.30 was CreatureMode, not world cells). Lighting suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Lighting/LightInfoLoader.cs | 7 ++++++- tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/AcDream.Core/Lighting/LightInfoLoader.cs b/src/AcDream.Core/Lighting/LightInfoLoader.cs index 63a250f4..db9bf9bc 100644 --- a/src/AcDream.Core/Lighting/LightInfoLoader.cs +++ b/src/AcDream.Core/Lighting/LightInfoLoader.cs @@ -79,7 +79,12 @@ public static class LightInfoLoader (info.Color?.Green ?? 255) / 255f, (info.Color?.Blue ?? 255) / 255f), Intensity = info.Intensity, - Range = info.Falloff, + // Retail PrimD3DRender::config_hardware_light (0x0059ad30) sets the + // hardware light Range = Falloff * rangeAdjust, where rangeAdjust is + // the fixed global 1.5 (0x00820cc4). Our prior Range = Falloff reached + // only 2/3 of retail's distance → tight torch bubbles (the dungeon + // "candles/spotlights" report, #133 A7). Match retail's reach. + Range = info.Falloff * 1.5f, ConeAngle = info.ConeAngle, OwnerId = ownerId, IsLit = true, diff --git a/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs index c3884a66..0651b274 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs @@ -93,7 +93,7 @@ public sealed class LightInfoLoaderTests var light = result[0]; Assert.Equal(LightKind.Point, light.Kind); Assert.Equal(77u, light.OwnerId); - Assert.Equal(8f, light.Range); + Assert.Equal(12f, light.Range); // Falloff 8 × retail rangeAdjust 1.5 (config_hardware_light) Assert.Equal(0.8f, light.Intensity); Assert.Equal(new Vector3(101, 202, 303), light.WorldPosition); Assert.InRange(light.ColorLinear.X, 0.99f, 1.01f); From 0fe479ba0625bcd9dba22d6584742ffbb068f3e6 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 21:19:47 +0200 Subject: [PATCH 47/65] docs(A7): pin the GENERAL light over-saturation cause (intensity=100 mis-read) + FPS note Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 5c528ee3..4a672ae3 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -107,9 +107,26 @@ reach the VIEWER (`DistSq > Range²·slack² → skip`), so a room with 2227 tor 8 NEAREST lights and applies the hard range-cutoff PER SURFACE in the shader (`mesh_modern.frag: if (d < range)`). Fix = drop the viewer-range candidacy filter, take the nearest 8. Probe after: **`activeLights` 2→8** in the dungeon (the room's 8 nearest torches now -light it). Core lighting suite green. SECONDARY (flagged, not fixed): retail's per-cell ambient -default is 0.30 (`0x3e99999a`) read PER-CELL (`m_clrAmbientLight`) vs our flat 0.20 — a -candidate brightness tweak needing a decomp pass to confirm the world-EnvCell ambient source. +light it). Core lighting suite green. Then `Range = Falloff × 1.5` (retail `rangeAdjust`, +`config_hardware_light` 0x0059adc, `a80061b`+) widened the pools. Ambient 0.20 is +retail-faithful (`SmartBox::SetWorldAmbientLight(0.2f)`); the 0.30 was a red herring +(`CreatureMode` paperdoll renderer, not world cells). + +**⚠️ REAL remaining cause — GENERAL light over-saturation (NOT dungeon-specific; belongs to +the #79 indoor-lighting umbrella).** Screenshot + `[light-detail]` probe (`9e809bc`): torches +read **`intensity=100`** (+ garbage `cone`). Our shader does `Diffuse = color × intensity` → +`color × 100` → every lit surface blows out to white = the hard "spotlight" disks. Retail's +`config_hardware_light` (0x0059adc) uses the SAME math (`Diffuse = (color/255) × intensity`) +and is NOT blown out → **retail's intensity is ~1.0; we are mis-reading the dat +`LightInfo.Intensity`** (likely a DatReaderWriter field/type bug — its source is a compiled +NuGet, not vendored, so unconfirmed). Over-saturates EVERY light (houses + outdoors + dungeons — +matches the user's "same issue everywhere; retail is uniform"). **DO NOT ad-hoc `÷100` +(forbidden workaround, risks the frozen outdoor/building lighting).** Proper fix = pin the +dat-format (raw-byte inspect a `LightInfo` / get the DatReaderWriter source) → correct the +intensity read → fixes the general spotty lighting everywhere. GENERAL engine-lighting work, +beyond G.3 dungeon scope. Separately: dungeon FPS 14–30 (WB-DIAG ~22K draws/frame — heavy +cell-geometry draw count / poor instancing — a general rendering-perf task; the 8-light +selection also added a per-frame 2227-light sort that should become a partial-select). **Severity:** HIGH (any far/dungeon teleport is unusable) **Filed:** 2026-06-13 (M1.5 dungeon-demo gate attempt — meeting-hall portal) **Component:** physics/streaming — teleport-arrival snap vs async landblock hydration From 5872bcf075849382757675848555422fdd8ec793 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 21:26:17 +0200 Subject: [PATCH 48/65] perf(lighting): allocation-free nearest-N light selection (#133 FPS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tick built a new List<>(N) and ran an O(N log N) Sort every frame; in a dungeon N is thousands of torches, so it allocated a large list per frame (GC pressure -> FPS). Replace with an insertion partial-select that keeps the nearest maxPoint directly in the _active window — O(N * maxPoint), maxPoint<=8, zero allocation. Same selection result (nearest 8); lighting suite 20/20 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Lighting/LightManager.cs | 89 +++++++++++++---------- 1 file changed, 51 insertions(+), 38 deletions(-) diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index 98402ac7..0f4a73c9 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -93,53 +93,66 @@ public sealed class LightManager /// public void Tick(Vector3 viewerWorldPos) { - // Pass 1: compute DistSq + filter out lights outside the slack radius. - var candidates = new List(_all.Count); - foreach (var light in _all) - { - if (!light.IsLit) continue; - if (light.Kind == LightKind.Directional) - { - // Directional lights don't participate in this ranking — - // the sun is always slot 0. - continue; - } - - Vector3 delta = light.WorldPosition - viewerWorldPos; - light.DistSq = delta.LengthSquared(); - - // Retail D3D-style fixed-pipeline lighting picks the 8 NEAREST point - // lights and applies each light's hard range-cutoff PER SURFACE in the - // shader (mesh_modern.frag: `if (d < range && range > 1e-3)`). The - // previous viewer-range candidacy filter (skip when DistSq > Range²·slack²) - // was wrong — it dropped a torch whenever the VIEWER stood outside that - // torch's range, so a dungeon room with 2227 registered torches lit only - // the ~1 the player was standing inside (activeLights≈1, the rest of the - // room at flat 0.2 ambient — the "dungeon lighting off" report). Take the - // nearest 8 regardless of viewer range; the shader's per-fragment - // `d < range` does the actual hard cutoff. - candidates.Add(light); - } - - // Pass 2: sort by DistSq ascending, take up to 7. - candidates.Sort((a, b) => a.DistSq.CompareTo(b.DistSq)); - + // Retail D3D-style fixed-pipeline lighting takes the nearest (MaxActiveLights-1) + // point lights (slot 0 is the sun) and applies each light's hard range cutoff + // PER SURFACE in the shader (mesh_modern.frag: `if (d < range && range > 1e-3)`), + // NOT a viewer-range candidacy filter — a torch the viewer stands outside the + // range of must still light the wall it sits on. + // + // Allocation-free partial selection: the old path built `new List<>(N)` and + // ran an O(N log N) Sort EVERY FRAME; in a dungeon N is thousands of torches, + // so that allocated a large list per frame (GC pressure → FPS). Instead keep + // the nearest maxPoint directly in the _active window, maintained sorted by + // insertion. O(N · maxPoint), maxPoint ≤ 8, zero allocation. Array.Clear(_active); _activeCount = 0; - // Slot 0 = sun when present. + // Slot 0 = sun when present (directional; never ranked by distance). + int baseSlot = 0; if (Sun is not null) { _active[0] = Sun; - _activeCount = 1; + baseSlot = 1; } - int maxPoint = MaxActiveLights - _activeCount; - int pointCount = Math.Min(maxPoint, candidates.Count); - for (int i = 0; i < pointCount; i++) + int maxPoint = MaxActiveLights - baseSlot; + int filled = 0; + if (maxPoint > 0) { - _active[_activeCount + i] = candidates[i]; + foreach (var light in _all) + { + if (!light.IsLit || light.Kind == LightKind.Directional) continue; + + Vector3 delta = light.WorldPosition - viewerWorldPos; + light.DistSq = delta.LengthSquared(); + + // Maintain _active[baseSlot .. baseSlot+filled) sorted ascending by + // DistSq. Insert if there's room or this light is nearer than the + // current farthest (then the farthest falls off the end). + if (filled < maxPoint) + { + int j = baseSlot + filled; + while (j > baseSlot && _active[j - 1]!.DistSq > light.DistSq) + { + _active[j] = _active[j - 1]; + j--; + } + _active[j] = light; + filled++; + } + else if (light.DistSq < _active[baseSlot + maxPoint - 1]!.DistSq) + { + int j = baseSlot + maxPoint - 1; + while (j > baseSlot && _active[j - 1]!.DistSq > light.DistSq) + { + _active[j] = _active[j - 1]; + j--; + } + _active[j] = light; + } + } } - _activeCount += pointCount; + + _activeCount = baseSlot + filled; } } From 007e2873099b7105d8ffbd5499e75244456d8549 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 21:48:46 +0200 Subject: [PATCH 49/65] =?UTF-8?q?fix(A7):=20port=20retail=20calc=5Fpoint?= =?UTF-8?q?=5Flight=20(1-dist/falloff)=20ramp=20=E2=80=94=20kill=20the=20"?= =?UTF-8?q?spotlight"=20hard=20edge=20(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dungeon/house/outdoor lights read as hard-edged blown discs ("spotlights") because our point/spot shader used `atten = 1.0` flat inside a hard `d < range` cutoff. The mesh.frag comment claimed this was retail-faithful ("no attenuation inside Range... the bubble-of-light look relies on crisp boundaries", citing r13 10.2) — that was a misread and the literal cause of the symptom. Verified against the decomp (not guessed): calc_point_light (0x0059c8b0, the PER-VERTEX point-light path that lights static walls) scales each light's contribution by (1 - dist/falloff_eff) — a LINEAR ramp that fades to exactly 0 at the edge, eliminating the hard disc. falloff_eff = Falloff * static_light_factor, and static_light_factor = 1.3 (0x00820e24), NOT the 1.5 config_hardware_light rangeAdjust (that 1.5 is the D3D-dynamic path for moving objects, a different path). The Ghidra port (acclient.c:808639) is more garbled — BN pseudo-C is the oracle here; the exact normalization factor + a half-Lambert wrap (0.5*dist+N*L) are x87-obscured (same artifact class as GetPowerBarLevel) and left unported. Changes: - mesh_modern.frag + mesh.frag: replace flat atten with clamp(1 - d/range, 0, 1); Range now carries falloff_eff so the ramp fades to 0 at the cutoff. Fix the false "no attenuation / crisp bubble" comment in mesh.frag. - LightInfoLoader: Range = Falloff * 1.3 (static_light_factor), was * 1.5. - LightManager: correct the stale class doc comment (Tick is now nearest-8 allocation-free partial-select with NO viewer-range slack filter). - divergence register: AP-16 updated (slack filter removed), AP-35 added (per-pixel vs per-vertex Gouraud; dropped half-Lambert wrap + normalization). - test: LightingHookSinkTests Range 8*1.3 = 10.4. Build + 20 lighting tests green. Visual gate pending (game-wide lighting change: dungeon torches, house candles, outdoor braziers). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 5 ++-- src/AcDream.App/Rendering/Shaders/mesh.frag | 14 +++++++---- .../Rendering/Shaders/mesh_modern.frag | 10 +++++++- src/AcDream.Core/Lighting/LightInfoLoader.cs | 15 +++++++----- src/AcDream.Core/Lighting/LightManager.cs | 24 ++++++++++--------- .../Lighting/LightingHookSinkTests.cs | 2 +- 6 files changed, 44 insertions(+), 26 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b7a710c0..90c82257 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -92,7 +92,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 34 rows +## 3. Documented approximation (AP) — 35 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -111,7 +111,7 @@ accepted-divergence entries (#96, #49, #50). | AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager | | AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) | | AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs | -| AP-16 | Global nearest-8 viewer-distance light selection with 10% range slack (own r13 design); retail bound D3D lights per object/cell | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; 1.1 slack is anti-pop hysteresis | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights); pop thresholds differ | r13 §12.2 (acdream design); retail D3D 8-light constraint | +| AP-16 | Global nearest-8 viewer-distance light selection (own r13 design); retail bound D3D lights per object/cell. NO viewer-range candidacy filter — each light's range cutoff is applied per-surface in the shader (the earlier `Range²×1.1` slack filter was removed; it dropped torches the viewer stood outside, the #133 "lighting off" report) | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; nearest-8 is an allocation-free partial-select (no per-frame list/sort) | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights) | r13 §12.2 (acdream design); retail D3D 8-light constraint | | AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E | | AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) | | AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 | @@ -130,6 +130,7 @@ accepted-divergence entries (#96, #49, #50). | AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | | AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | +| AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 − dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ −0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`−0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 | --- diff --git a/src/AcDream.App/Rendering/Shaders/mesh.frag b/src/AcDream.App/Rendering/Shaders/mesh.frag index 7765a46a..45fe4e7f 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh.frag @@ -46,10 +46,12 @@ layout(std140, binding = 1) uniform SceneLighting { vec4 uCameraAndTime; }; -// Retail hard-cutoff lighting equation (r13 §10.2). No distance -// attenuation inside Range; hard edge at Range; spotlights use a -// binary cos-cone test. This is deliberate — the retail "bubble of -// light" look relies on crisp boundaries. +// Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0): the +// contribution scales by (1 - dist/falloff_eff) — a LINEAR fade to exactly +// 0 at the edge, NOT a hard-cutoff bubble. (The prior "no attenuation inside +// Range / crisp boundaries" note was a misread; it is the literal cause of +// the #133 "spotlight" look. falloff_eff = Falloff * static_light_factor 1.3 +// is folded into Range by LightInfoLoader.) Spots add a binary cos-cone test. vec3 accumulateLights(vec3 N, vec3 worldPos) { vec3 lit = uCellAmbient.xyz; int activeLights = int(uCellAmbient.w); @@ -73,7 +75,9 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) { if (d < range && range > 1e-3) { vec3 Ldir = toL / max(d, 1e-4); float ndl = max(0.0, dot(N, Ldir)); - float atten = 1.0; // retail: no attenuation inside Range + // calc_point_light (1 - dist/falloff_eff) linear ramp; Range already + // carries falloff_eff (Falloff * 1.3), so it fades to 0 at the cutoff. + float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0); if (kind == 2) { // Spotlight: hard-edged cos-cone test. float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5); diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag index bbcc9584..040e15b2 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag @@ -49,7 +49,15 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) { if (d < range && range > 1e-3) { vec3 Ldir = toL / max(d, 1e-4); float ndl = max(0.0, dot(N, Ldir)); - float atten = 1.0; + // Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0, + // line 0x0059c9a2): contribution scales by (1 - dist/falloff_eff), a + // LINEAR fade to exactly 0 at the edge. That is what makes a torch a + // smooth glow that blends into the ambient instead of a flat disc with + // a hard edge — the dungeon/house/outdoor "spotlight" look (#133 A7). + // falloff_eff = Falloff * static_light_factor (1.3, 0x00820e24) is folded + // into the shader Range (dirAndRange.w) by LightInfoLoader, so the ramp + // denominator is just Range and fades to 0 exactly at the cutoff. + float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0); if (kind == 2) { float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5); float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); diff --git a/src/AcDream.Core/Lighting/LightInfoLoader.cs b/src/AcDream.Core/Lighting/LightInfoLoader.cs index db9bf9bc..671da599 100644 --- a/src/AcDream.Core/Lighting/LightInfoLoader.cs +++ b/src/AcDream.Core/Lighting/LightInfoLoader.cs @@ -79,12 +79,15 @@ public static class LightInfoLoader (info.Color?.Green ?? 255) / 255f, (info.Color?.Blue ?? 255) / 255f), Intensity = info.Intensity, - // Retail PrimD3DRender::config_hardware_light (0x0059ad30) sets the - // hardware light Range = Falloff * rangeAdjust, where rangeAdjust is - // the fixed global 1.5 (0x00820cc4). Our prior Range = Falloff reached - // only 2/3 of retail's distance → tight torch bubbles (the dungeon - // "candles/spotlights" report, #133 A7). Match retail's reach. - Range = info.Falloff * 1.5f, + // falloff_eff for the per-vertex point-light burn-in (calc_point_light + // 0x0059c8b0) is Falloff * static_light_factor, where static_light_factor + // is the fixed global 1.3 (0x00820e24). That is the path that lights + // STATIC walls — what the dungeon/house "spotlight" report (#133 A7) is + // about — so we match it, not the D3D-dynamic config_hardware_light + // rangeAdjust (1.5, a different path for moving objects). The shader ramp + // (1 - dist/Range) fades to exactly 0 at this Range, eliminating the hard + // disc edge that read as a spotlight. + Range = info.Falloff * 1.3f, ConeAngle = info.ConeAngle, OwnerId = ownerId, IsLit = true, diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index 0f4a73c9..24769c6e 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -11,23 +11,25 @@ namespace AcDream.Core.Lighting; /// §12.2). /// /// -/// Active-light selection algorithm (r13 §12.2 "Tick" steps): +/// Active-light selection algorithm (r13 §12.2), as implemented by +/// : /// /// -/// Recompute DistSq from viewer to every registered -/// point/spot light. +/// Reserve slot 0 for the sun (directional, infinite range) when present. /// /// -/// Drop lights outside Range² * 1.1 (10% slack prevents -/// pop as we walk across the boundary). -/// -/// -/// Rank remaining lights by DistSq ascending. Pick top 7. -/// -/// -/// Reserve slot 0 for the sun (directional, infinite range). +/// For every registered lit point/spot light, recompute DistSq +/// from the viewer and keep the nearest (MaxActiveLights − sunSlot) +/// directly in the active window via an allocation-free insertion +/// partial-select (no per-frame list/sort). /// /// +/// There is deliberately NO viewer-range candidacy filter: each light's +/// own range cutoff is applied PER SURFACE in the shader +/// (mesh_modern.frag: d < range), so a torch the viewer +/// stands outside the range of must still light the wall it sits on. The +/// earlier Range² × 1.1 slack filter wrongly dropped exactly those +/// lights (the #133 "lighting off" report). /// /// /// diff --git a/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs index 0651b274..676155bf 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs @@ -93,7 +93,7 @@ public sealed class LightInfoLoaderTests var light = result[0]; Assert.Equal(LightKind.Point, light.Kind); Assert.Equal(77u, light.OwnerId); - Assert.Equal(12f, light.Range); // Falloff 8 × retail rangeAdjust 1.5 (config_hardware_light) + Assert.Equal(10.4f, light.Range, 3); // Falloff 8 × static_light_factor 1.3 (calc_point_light 0x00820e24) Assert.Equal(0.8f, light.Intensity); Assert.Equal(new Vector3(101, 202, 303), light.WorldPosition); Assert.InRange(light.ColorLinear.X, 0.99f, 1.01f); From 56860501b63172ff840026ea5752194c6bcccfeb Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 22:32:56 +0200 Subject: [PATCH 50/65] fix(G.3): collapse streaming to the single dungeon landblock indoors (#133 FPS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dungeon FPS sat at ~30 (frame ~33ms) because the 25x25 streaming window around the dungeon landblock pulled in ~129 NEIGHBORING landblocks + their thousands of torch/particle emitters, all drawn though never visible. In AC all dungeons are packed adjacent in the unused "ocean" map grid, so those neighbors are unrelated dungeons. The FPS timeline proved it: 247 fps at login (lb 0/0, ~10K entities) → 17 → 30 as landblocks streamed in (lb 0→129) — the cost tracked LANDBLOCK count, not entities. Retail-faithful: ACE LandblockManager.GetAdjacentIDs returns ZERO adjacents for a dungeon (`if (landblock.IsDungeon) return adjacents;`, Landblock.cs:577-582) — every dungeon is a self-contained landblock you never see out of. Fix: when the player stands in a sealed indoor cell (CurrCell.IsEnv && !SeenOutside — the same predicate that kills the sun/sky), collapse streaming to just the player's dungeon landblock and unload the neighbors. Building interiors (cottage/inn) have SeenOutside cells, so they are NOT gated and keep their surrounding terrain (the frozen building/cellar demo is unaffected). Unloading the neighbors also tears down their lights (removeTerrain → UnregisterOwner), shrinking LightManager._all from ~2227 toward retail's ≤40 — which directly helps the A7 lighting bake landing next. Mechanics (StreamingController): - Edge IN: ClearPendingLoads() cancels the in-flight 25x25 window (new streamer ClearLoads control job — worker drops queued Loads, keeps Unloads), unload every resident neighbor, pin a radius-0 StreamingRegion, (re)load the dungeon block if needed. - Stay collapsed: sweep any straggler that finished loading after the edge (a Load the worker had already dequeued before ClearLoads). - Edge OUT (portal/teleport to outdoors): rebuild the full two-tier window at the new center, unload anything stale. AP-36 added to the divergence register (the gate uses the cheap SeenOutside cell predicate as an approximation of ACE's full landblock IsDungeon classification). GameWindow also carries a TEMP ACDREAM_LOG_FPS=1 headless FPS line (strip after the A7 FPS+lighting verification). Build green; 58 streaming tests green (6 new dungeon-gate tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 3 +- src/AcDream.App/Rendering/GameWindow.cs | 22 ++- .../Streaming/LandblockStreamJob.cs | 10 ++ .../Streaming/LandblockStreamer.cs | 43 ++++++ .../Streaming/StreamingController.cs | 110 +++++++++++++- .../StreamingControllerDungeonGateTests.cs | 142 ++++++++++++++++++ 6 files changed, 324 insertions(+), 6 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 90c82257..79d30650 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -92,7 +92,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 35 rows +## 3. Documented approximation (AP) — 36 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -130,6 +130,7 @@ accepted-divergence entries (#96, #49, #50). | AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | | AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | +| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock | `src/AcDream.App/Rendering/GameWindow.cs:6895` (predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | | AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 − dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ −0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`−0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 | --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b3e5efa0..0e992fa2 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1914,6 +1914,7 @@ public sealed class GameWindow : IDisposable state: _worldState, nearRadius: _nearRadius, farRadius: _farRadius, + clearPendingLoads: _streamer.ClearPendingLoads, removeTerrain: id => { // Phase G.2: release any LightSources attached to entities @@ -6882,7 +6883,20 @@ public sealed class GameWindow : IDisposable observerCy = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f); } - _streamingController.Tick(observerCx, observerCy); + // Dungeon gate (#133 FPS): when the player stands in a SEALED EnvCell + // (indoor cell that doesn't see outside — the same predicate that kills + // the sun/sky, playerInsideCell below), collapse streaming to the single + // dungeon landblock. AC dungeons have no adjacent landblocks; the 25×25 + // window otherwise pulls in ~129 unrelated ocean-grid dungeons. Building + // interiors (cottage/inn) have SeenOutside cells, so they are NOT gated + // and keep their surrounding terrain. + // Mirrors the playerInsideCell computation below (CurrCell → registry + // LoadedCell.SeenOutside): true only for a sealed indoor cell. + bool insideDungeon = + _physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell pcEnv + && _cellVisibility.TryGetCell(pcEnv.Id, out var pcReg) + && pcReg is { SeenOutside: false }; + _streamingController.Tick(observerCx, observerCy, insideDungeon); // Re-inject persistent entities rescued from unloaded landblocks // into the current center landblock (the one the observer is in). @@ -8418,6 +8432,11 @@ public sealed class GameWindow : IDisposable } _lastFps = fps; _lastFrameMs = avgFrameTime; + // TEMP (A7 FPS measurement, strip after): headless FPS/frame-time so the + // launch log can be correlated against the [WB-DIAG] draw stats. + if (Environment.GetEnvironmentVariable("ACDREAM_LOG_FPS") == "1") + Console.WriteLine( + $"[FPS] {fps:F1} fps | {avgFrameTime:F2} ms | lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount} anim {animatedCount}"); _perfAccum = 0; _perfFrameCount = 0; } @@ -10589,6 +10608,7 @@ public sealed class GameWindow : IDisposable state: _worldState, nearRadius: _nearRadius, farRadius: _farRadius, + clearPendingLoads: _streamer.ClearPendingLoads, removeTerrain: id => { if (_lightingSink is not null && diff --git a/src/AcDream.App/Streaming/LandblockStreamJob.cs b/src/AcDream.App/Streaming/LandblockStreamJob.cs index c5e36815..050c1265 100644 --- a/src/AcDream.App/Streaming/LandblockStreamJob.cs +++ b/src/AcDream.App/Streaming/LandblockStreamJob.cs @@ -14,6 +14,16 @@ public abstract record LandblockStreamJob(uint LandblockId) { public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId); public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId); + + /// + /// Control job: drop every queued (not-yet-started) Load from the worker's + /// priority queues, keeping Unloads. Posted by + /// when the player enters a + /// dungeon and the in-flight outdoor/neighbor window load must be cancelled + /// (#133 FPS — dungeons have no adjacent landblocks). LandblockId is 0 by + /// convention; readers pattern-match on the type. + /// + public sealed record ClearLoads() : LandblockStreamJob(0); } /// diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index 19b2a94b..ffaa6de7 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -141,6 +141,22 @@ public sealed class LandblockStreamer : IDisposable _inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId)); } + /// + /// Cancel every queued-but-not-started Load. Posts a + /// control job which the worker + /// honours at read time, dropping all pending Loads from both priority + /// queues (Unloads survive). Used on the dungeon-entry edge to abort the + /// in-flight 25×25 neighbor window so the ~129 ocean-grid dungeons never + /// finish loading (#133 FPS). Loads the worker has ALREADY dequeued still + /// complete; the StreamingController's collapsed-sweep unloads those few. + /// + public void ClearPendingLoads() + { + if (System.Threading.Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(LandblockStreamer)); + _inbox.Writer.TryWrite(new LandblockStreamJob.ClearLoads()); + } + /// /// Drain up to completed results. /// Non-blocking. Call from the render thread once per OnUpdate. @@ -180,7 +196,18 @@ public sealed class LandblockStreamer : IDisposable } while (_inbox.Reader.TryRead(out var job)) + { + if (job is LandblockStreamJob.ClearLoads) + { + // Dungeon-entry cancellation: drop every queued Load, + // keep Unloads. Handled at read time so it supersedes + // Loads sitting in the priority queues ahead of it. + DropLoadJobs(highPriority); + DropLoadJobs(lowPriority); + continue; + } EnqueuePrioritized(job, highPriority, lowPriority); + } if (highPriority.Count == 0 && lowPriority.Count == 0) continue; @@ -233,6 +260,22 @@ public sealed class LandblockStreamer : IDisposable lowPriority.Enqueue(job); } + /// + /// Drop every from a priority queue, + /// preserving Unloads (and any other control jobs). Rotates the queue once + /// in place. Used by the path. + /// + private static void DropLoadJobs(Queue queue) + { + int count = queue.Count; + for (int i = 0; i < count; i++) + { + var job = queue.Dequeue(); + if (job is not LandblockStreamJob.Load) + queue.Enqueue(job); + } + } + private static void RemoveLowPriorityJobsForLandblock( Queue queue, uint landblockId, diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index f0bc0955..dfae63ef 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -22,9 +22,16 @@ public sealed class StreamingController private readonly Func> _drainCompletions; private readonly Action _applyTerrain; private readonly Action? _removeTerrain; + private readonly Action? _clearPendingLoads; private readonly GpuWorldState _state; private StreamingRegion? _region; + // True while streaming is collapsed to the single dungeon landblock the + // player stands in (the dungeon gate, #133 FPS). AC dungeons have NO + // adjacent landblocks — neighbors are unrelated ocean-grid dungeons that + // are never visible, so we stop loading the 25×25 window entirely. + private bool _collapsed; + /// /// Near-tier radius (LBs from observer that load full detail: terrain + /// scenery + entities). Set at construction; readable thereafter. @@ -71,13 +78,15 @@ public sealed class StreamingController GpuWorldState state, int nearRadius, int farRadius, - Action? removeTerrain = null) + Action? removeTerrain = null, + Action? clearPendingLoads = null) { _enqueueLoad = enqueueLoad; _enqueueUnload = enqueueUnload; _drainCompletions = drainCompletions; _applyTerrain = applyTerrain; _removeTerrain = removeTerrain; + _clearPendingLoads = clearPendingLoads; _state = state; NearRadius = nearRadius; FarRadius = farRadius; @@ -97,7 +106,32 @@ public sealed class StreamingController /// → enqueue full unload /// /// - public void Tick(int observerCx, int observerCy) + public void Tick(int observerCx, int observerCy, bool insideDungeon = false) + { + uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy); + + if (insideDungeon) + { + if (!_collapsed) + EnterDungeonCollapse(observerCx, observerCy, centerId); + else + SweepCollapsed(centerId); + } + else + { + if (_collapsed) + ExitDungeonExpand(observerCx, observerCy); + else + NormalTick(observerCx, observerCy); + } + + DrainAndApply(); + } + + /// + /// Outdoor / building-interior streaming — the original two-tier model. + /// + private void NormalTick(int observerCx, int observerCy) { if (_region is null) { @@ -116,9 +150,77 @@ public sealed class StreamingController foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id); foreach (var id in diff.ToUnload) _enqueueUnload(id); } + } - // Drain up to N completions per frame so a big diff doesn't spike - // GPU upload time. Remaining completions wait for the next frame. + /// + /// Dungeon-entry edge: cancel the in-flight window load, unload every + /// resident neighbor, and pin streaming to the player's single dungeon + /// landblock. Retail-faithful — AC dungeons have no adjacent landblocks + /// (ACE LandblockManager.GetAdjacentIDs returns empty for a dungeon); + /// the 25×25 window was pulling in ~129 unrelated ocean-grid dungeons and + /// their thousands of emitters (#133 FPS). Unloading them also tears down + /// their lights, shrinking the static-light set toward retail's ≤40. + /// + private void EnterDungeonCollapse(int cx, int cy, uint centerId) + { + _collapsed = true; + _clearPendingLoads?.Invoke(); + + foreach (var id in _state.LoadedLandblockIds) + if (id != centerId) _enqueueUnload(id); + + // Pin a radius-0 region so RecenterTo never re-expands while inside, + // and so the post-exit rebuild starts from a clean, consistent state. + _region = new StreamingRegion(cx, cy, 0, 0); + _region.MarkResidentFromBootstrap(); + + // The dungeon landblock itself must be (or become) loaded. If a prior + // ClearPendingLoads cancelled its queued load, re-enqueue it. + if (!_state.IsLoaded(centerId)) + _enqueueLoad(centerId, LandblockStreamJobKind.LoadNear); + } + + /// + /// While collapsed, unload any landblock that finished loading after the + /// collapse edge — a Load the worker had already dequeued before the + /// control job took + /// effect. At steady state only the dungeon landblock is resident, so this + /// is a no-op. + /// + private void SweepCollapsed(uint centerId) + { + foreach (var id in _state.LoadedLandblockIds) + if (id != centerId) _enqueueUnload(id); + } + + /// + /// Dungeon-exit edge (portal to outdoors / teleport): rebuild the full + /// two-tier window at the new center and unload anything resident from the + /// collapsed state that falls outside it. + /// + private void ExitDungeonExpand(int observerCx, int observerCy) + { + _collapsed = false; + var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius); + + foreach (var id in _state.LoadedLandblockIds) + if (!rebuilt.Resident.Contains(id)) _enqueueUnload(id); + + var boot = rebuilt.ComputeFirstTickDiff(); + foreach (var id in boot.ToLoadNear) + if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadNear); + foreach (var id in boot.ToLoadFar) + if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadFar); + rebuilt.MarkResidentFromBootstrap(); + _region = rebuilt; + } + + /// + /// Drain up to N completions per frame so a big diff doesn't spike GPU + /// upload time. Remaining completions wait for the next frame. + /// + private void DrainAndApply() + { var drained = _drainCompletions(MaxCompletionsPerFrame); foreach (var result in drained) { diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs new file mode 100644 index 00000000..ab4a4d62 --- /dev/null +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AcDream.App.Streaming; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.Core.Tests.Streaming; + +/// +/// The dungeon streaming gate (#133 FPS). AC dungeons have no adjacent +/// landblocks (ACE LandblockManager.GetAdjacentIDs returns empty for a +/// dungeon); they sit packed in the ocean grid, so the normal 25×25 window +/// pulls in ~129 unrelated neighbor dungeons + their emitters. When the player +/// is inside a sealed dungeon cell, Tick(insideDungeon: true) collapses +/// streaming to the single dungeon landblock and unloads the neighbors. +/// +public class StreamingControllerDungeonGateTests +{ + private static uint Encode(int x, int y) => ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu; + + private static LoadedLandblock MakeLb(int x, int y) => new LoadedLandblock( + Encode(x, y), + Heightmap: null!, + Entities: Array.Empty()); + + private sealed record Harness( + StreamingController Ctrl, + List<(uint Id, LandblockStreamJobKind Kind)> Loads, + List Unloads, + Func ClearCalls, + GpuWorldState State); + + private static Harness Make() + { + var loads = new List<(uint, LandblockStreamJobKind)>(); + var unloads = new List(); + int clearCalls = 0; + var state = new GpuWorldState(); + var ctrl = new StreamingController( + enqueueLoad: (id, kind) => loads.Add((id, kind)), + enqueueUnload: unloads.Add, + drainCompletions: _ => Array.Empty(), + applyTerrain: (_, _) => { }, + state: state, + nearRadius: 4, + farRadius: 12, + clearPendingLoads: () => clearCalls++); + return new Harness(ctrl, loads, unloads, () => clearCalls, state); + } + + [Fact] + public void EntersDungeon_CancelsPending_UnloadsNeighbors_KeepsCenter() + { + var h = Make(); + uint center = Encode(0, 7); + h.State.AddLandblock(MakeLb(0, 7)); // the dungeon landblock + h.State.AddLandblock(MakeLb(0, 8)); // a neighbor ocean dungeon + h.State.AddLandblock(MakeLb(1, 7)); // another neighbor + + h.Ctrl.Tick(observerCx: 0, observerCy: 7, insideDungeon: true); + + Assert.Equal(1, h.ClearCalls()); // in-flight window load cancelled + Assert.Contains(Encode(0, 8), h.Unloads); // neighbor unloaded + Assert.Contains(Encode(1, 7), h.Unloads); // neighbor unloaded + Assert.DoesNotContain(center, h.Unloads); // dungeon landblock kept + Assert.DoesNotContain(h.Loads, l => l.Id == center); // already loaded → no reload + } + + [Fact] + public void EntersDungeon_CenterNotLoaded_EnqueuesCenterLoad() + { + var h = Make(); // empty state — the dungeon landblock isn't resident yet + + h.Ctrl.Tick(observerCx: 0, observerCy: 7, insideDungeon: true); + + Assert.Equal(1, h.ClearCalls()); + Assert.Contains(h.Loads, l => l.Id == Encode(0, 7) + && l.Kind == LandblockStreamJobKind.LoadNear); + } + + [Fact] + public void StayingCollapsed_SweepsStragglerThatFinishedAfterTheEdge() + { + var h = Make(); + h.State.AddLandblock(MakeLb(0, 7)); + h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse edge + h.Unloads.Clear(); + + // A Load the worker had already dequeued before ClearLoads now completes. + h.State.AddLandblock(MakeLb(0, 8)); + h.Ctrl.Tick(0, 7, insideDungeon: true); // sweep + + Assert.Contains(Encode(0, 8), h.Unloads); + Assert.DoesNotContain(Encode(0, 7), h.Unloads); + } + + [Fact] + public void StayingCollapsed_DoesNotReClearOrReloadCenter() + { + var h = Make(); + h.State.AddLandblock(MakeLb(0, 7)); + h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse (clear #1) + h.Loads.Clear(); + + h.Ctrl.Tick(0, 7, insideDungeon: true); // stay collapsed + + Assert.Equal(1, h.ClearCalls()); // clear only fired on the edge + Assert.Empty(h.Loads); // no spurious center reloads + } + + [Fact] + public void ExitsDungeon_RebuildsFullWindow_UnloadsStaleDungeonLandblock() + { + var h = Make(); + h.State.AddLandblock(MakeLb(0, 7)); + h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse + h.Loads.Clear(); + h.Unloads.Clear(); + + // Exit through a portal to an outdoor location far from the dungeon block. + h.Ctrl.Tick(observerCx: 100, observerCy: 100, insideDungeon: false); + + Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadNear); + Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar); + Assert.Contains(Encode(0, 7), h.Unloads); // stale dungeon block, outside new window + } + + [Fact] + public void NormalOutdoorTick_Unchanged_NoCollapseNoClear() + { + var h = Make(); + + h.Ctrl.Tick(observerCx: 100, observerCy: 100); // default insideDungeon: false + + Assert.Equal(0, h.ClearCalls()); + Assert.Empty(h.Unloads); + // 9 near (9×9? no — nearRadius 4 → 9×9=81) + far ring loads enqueued. + Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadNear); + Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar); + } +} From d9e7dd65e9844c00964eda522a8caa0aa794ff82 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 22:43:18 +0200 Subject: [PATCH 51/65] =?UTF-8?q?fix(G.3):=20hysteresis=20on=20the=20dunge?= =?UTF-8?q?on=20streaming=20gate=20=E2=80=94=20stop=20collapse=E2=86=94exp?= =?UTF-8?q?and=20thrash=20(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut of the dungeon gate keyed expand on the per-frame insideDungeon signal (CurrCell is a sealed EnvCell). Live, CurrCell momentarily resolves to null mid-frame while the player stays put in the dungeon landblock, so the gate flipped collapse→expand→collapse every few frames. Each expand re-streamed the full 25×25 window; the unloads couldn't keep up (MaxCompletionsPerFrame=4), so registered lights leaked to 212k and FPS spiked to single digits between the ~199 fps collapsed frames. Fix: once collapsed, key the gate on the STABLE observer landblock, not CurrCell. Stay collapsed while the player remains in the dungeon landblock (_collapsedCenter); expand only when the observer actually moves to a different landblock (portal/ teleport out). CurrCell flicker no longer thrashes. Regression test added (Collapsed_CurrCellFlickersToNull_SameLandblock_DoesNotExpand). Build green; 60 streaming tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Streaming/StreamingController.cs | 27 ++++++++++++++----- .../StreamingControllerDungeonGateTests.cs | 19 +++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index dfae63ef..2637e9e3 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -32,6 +32,14 @@ public sealed class StreamingController // are never visible, so we stop loading the 25×25 window entirely. private bool _collapsed; + // The dungeon landblock id we collapsed onto. Once collapsed we key the + // gate on this STABLE landblock, not the per-frame insideDungeon signal: + // CurrCell can momentarily resolve to null/outdoor mid-frame, and gating + // expand on that flicker thrashes collapse↔expand (reload storms + a light + // leak). We only expand when the observer actually moves to a different + // landblock (teleport/portal out). + private uint _collapsedCenter; + /// /// Near-tier radius (LBs from observer that load full detail: terrain + /// scenery + entities). Set at construction; readable thereafter. @@ -110,19 +118,23 @@ public sealed class StreamingController { uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy); - if (insideDungeon) + if (_collapsed) { - if (!_collapsed) - EnterDungeonCollapse(observerCx, observerCy, centerId); + // Hysteresis: stay collapsed while the player remains in the dungeon + // landblock, regardless of CurrCell flicker. Expand only on an actual + // landblock change (the player left through a portal / was teleported). + if (centerId != _collapsedCenter) + ExitDungeonExpand(observerCx, observerCy); else SweepCollapsed(centerId); } + else if (insideDungeon) + { + EnterDungeonCollapse(observerCx, observerCy, centerId); + } else { - if (_collapsed) - ExitDungeonExpand(observerCx, observerCy); - else - NormalTick(observerCx, observerCy); + NormalTick(observerCx, observerCy); } DrainAndApply(); @@ -164,6 +176,7 @@ public sealed class StreamingController private void EnterDungeonCollapse(int cx, int cy, uint centerId) { _collapsed = true; + _collapsedCenter = centerId; _clearPendingLoads?.Invoke(); foreach (var id in _state.LoadedLandblockIds) diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs index ab4a4d62..78dfb57e 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs @@ -109,6 +109,25 @@ public class StreamingControllerDungeonGateTests Assert.Empty(h.Loads); // no spurious center reloads } + [Fact] + public void Collapsed_CurrCellFlickersToNull_SameLandblock_DoesNotExpand() + { + // Regression: the live run thrashed collapse↔expand because CurrCell + // momentarily resolved to null (insideDungeon=false) while the player + // stayed in the dungeon landblock — leaking lights via reload storms. + // The landblock-hysteresis must hold the collapse. + var h = Make(); + h.State.AddLandblock(MakeLb(0, 7)); + h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse + h.Loads.Clear(); + h.Unloads.Clear(); + + h.Ctrl.Tick(0, 7, insideDungeon: false); // CurrCell flicker, same landblock + + Assert.Empty(h.Loads); // NO full-window reload + Assert.Empty(h.Unloads); // only the center is resident → nothing to sweep + } + [Fact] public void ExitsDungeon_RebuildsFullWindow_UnloadsStaleDungeonLandblock() { From 2561918a70a16b1661d10ce66ce7e89dca108c5f Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 22:51:50 +0200 Subject: [PATCH 52/65] fix(G.3): pin dungeon collapse to the cell's landblock, not the position-derived one (#133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "The dungeon is broken" — the collapse was unloading the REAL dungeon. A dungeon's EnvCells sit at arbitrary "ocean" world coords with negative cell-local Y (snap showed pos=(58.9,-69.6) in cell 0x00070133), so the observer landblock _liveCenterY + floor(pp.Y/192) = 7 + floor(-69.6/192) = 7 + (-1) = 6 lands one row off. The collapse pinned to 0x0006 and unloaded 0x0007 — the real dungeon — which nulled CurrCell (the cell no longer existed) and left the player floating in outdoor-lit empty space (lb 1/1 @ ~1585 fps, but the wrong landblock). This is the Bug-A negative-local-coordinate class. Fix: when inside a dungeon, pin the collapse to the cell's OWN landblock (CurrCell.Id >> 16), never the position-derived observer landblock — the cell id is the authoritative landblock for ocean-placed dungeon geometry. Also hardened the hysteresis so a transient CurrCell flicker can't thrash: - Re-collapse when insideDungeon at a DIFFERENT landblock (multi-landblock dungeon). - Expand only on a DISTANT move (Chebyshev > 1) — a real exit teleports far from the ocean-grid block; the off-by-one flicker is always an ADJACENT (±1) landblock, so it now HOLDS the collapse instead of expanding. - SweepCollapsed always preserves _collapsedCenter (the true dungeon landblock), never the per-frame observer landblock. Build green; 59 streaming tests green (flicker regression test updated to the realistic adjacent off-by-one). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 19 +++++++++-- .../Streaming/StreamingController.cs | 33 +++++++++++++++---- .../StreamingControllerDungeonGateTests.cs | 18 +++++----- 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 0e992fa2..71a6f558 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -6892,10 +6892,23 @@ public sealed class GameWindow : IDisposable // and keep their surrounding terrain. // Mirrors the playerInsideCell computation below (CurrCell → registry // LoadedCell.SeenOutside): true only for a sealed indoor cell. - bool insideDungeon = - _physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell pcEnv + bool insideDungeon = false; + if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell pcEnv && _cellVisibility.TryGetCell(pcEnv.Id, out var pcReg) - && pcReg is { SeenOutside: false }; + && pcReg is { SeenOutside: false }) + { + insideDungeon = true; + // Pin the collapse to the cell's OWN landblock (cell id high 16 bits), + // NOT the position-derived observer landblock. A dungeon's EnvCells sit + // at arbitrary world coords (the "ocean" placement) with negative local + // offsets, so floor(pp.Y/192) lands one landblock off — which collapses + // onto the WRONG landblock and unloads the real dungeon, nulling CurrCell + // and breaking the render (the Bug-A coordinate class). The cell id is the + // authoritative landblock. + uint cellLb = pcEnv.Id >> 16; + observerCx = (int)((cellLb >> 8) & 0xFFu); + observerCy = (int)(cellLb & 0xFFu); + } _streamingController.Tick(observerCx, observerCy, insideDungeon); // Re-inject persistent entities rescued from unloaded landblocks diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 2637e9e3..9a357cbb 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -120,13 +120,22 @@ public sealed class StreamingController if (_collapsed) { - // Hysteresis: stay collapsed while the player remains in the dungeon - // landblock, regardless of CurrCell flicker. Expand only on an actual - // landblock change (the player left through a portal / was teleported). - if (centerId != _collapsedCenter) + // Hysteresis. Cases: + // - Still in the SAME dungeon landblock → hold (sweep stragglers). + // - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon) + // → re-collapse onto it. + // - CurrCell flickered null but the player hasn't gone anywhere: the + // observer landblock reverts to the position-derived value, which for a + // dungeon is only ever the ADJACENT off-by-one landblock (negative cell- + // local Y). Hold — never expand on an adjacent flicker. + // - Genuinely left to a DISTANT landblock (portal/teleport out, always far + // from the ocean-grid dungeon block) → expand. + if (insideDungeon && centerId != _collapsedCenter) + EnterDungeonCollapse(observerCx, observerCy, centerId); + else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1) ExitDungeonExpand(observerCx, observerCy); else - SweepCollapsed(centerId); + SweepCollapsed(); } else if (insideDungeon) { @@ -200,10 +209,20 @@ public sealed class StreamingController /// effect. At steady state only the dungeon landblock is resident, so this /// is a no-op. /// - private void SweepCollapsed(uint centerId) + private void SweepCollapsed() { + // Always preserve the true dungeon landblock (_collapsedCenter), never the + // per-frame observer landblock — a CurrCell flicker must not unload the dungeon. foreach (var id in _state.LoadedLandblockIds) - if (id != centerId) _enqueueUnload(id); + if (id != _collapsedCenter) _enqueueUnload(id); + } + + /// Chebyshev distance in landblock cells between two landblock ids. + private static int ChebyshevLandblocks(uint a, uint b) + { + int ax = (int)((a >> 24) & 0xFFu), ay = (int)((a >> 16) & 0xFFu); + int bx = (int)((b >> 24) & 0xFFu), by = (int)((b >> 16) & 0xFFu); + return Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by)); } /// diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs index 78dfb57e..fd99fe30 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs @@ -110,22 +110,24 @@ public class StreamingControllerDungeonGateTests } [Fact] - public void Collapsed_CurrCellFlickersToNull_SameLandblock_DoesNotExpand() + public void Collapsed_CurrCellFlickersToAdjacentOffByOne_DoesNotExpand() { - // Regression: the live run thrashed collapse↔expand because CurrCell - // momentarily resolved to null (insideDungeon=false) while the player - // stayed in the dungeon landblock — leaking lights via reload storms. - // The landblock-hysteresis must hold the collapse. + // Regression: the live run broke because a dungeon cell's negative local-Y + // makes the position-derived observer landblock land one row off (0,7→0,6). + // When CurrCell flickers null mid-frame, GameWindow stops overriding to the + // cell landblock and passes that adjacent (0,6). The Chebyshev>1 guard must + // treat that as a flicker and HOLD — never expand (which would unload the + // real dungeon and re-stream the 25×25 neighbor window). var h = Make(); h.State.AddLandblock(MakeLb(0, 7)); - h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse + h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse onto the dungeon (0,7) h.Loads.Clear(); h.Unloads.Clear(); - h.Ctrl.Tick(0, 7, insideDungeon: false); // CurrCell flicker, same landblock + h.Ctrl.Tick(0, 6, insideDungeon: false); // flicker → adjacent off-by-one Assert.Empty(h.Loads); // NO full-window reload - Assert.Empty(h.Unloads); // only the center is resident → nothing to sweep + Assert.Empty(h.Unloads); // dungeon (0,7) preserved; nothing else resident } [Fact] From 53e22a350dc67744d952ab65364ea7c8472418c5 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 09:52:01 +0200 Subject: [PATCH 53/65] fix(G.3): relocate the player entity to its CELL landblock indoors, not position-derived (#133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the dungeon-collapse fix the local player avatar stopped rendering: the per-frame RelocateEntity moved the player entity to its position-derived landblock floor(pp/192), which for a dungeon's negative-local-Y cell is the off-by-one (0,6) — the very landblock the collapse unloads. So the player entity sat in an unloaded landblock and was never drawn (the dungeon itself, in 0x0007, rendered fine). Fix: when the player is in an indoor cell (CellId low word >= 0x0100), relocate to the cell's OWN landblock (CellId >> 16), matching the streaming-collapse pin. The cell id is authoritative for ocean-placed dungeon geometry. Outdoor entities keep the position-derived path. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 71a6f558..a2531674 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7073,10 +7073,24 @@ public sealed class GameWindow : IDisposable // so it doesn't get frustum-culled when the player walks away from // the spawn landblock. Without this, the entity stays in the spawn // landblock's entity list and disappears when that landblock is culled. - var pp = _playerController.Position; - int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); - int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); - uint currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); + uint currentLb; + if (result.CellId != 0 && (result.CellId & 0xFFFFu) >= 0x0100u) + { + // Indoor cell (dungeon/building EnvCell): the entity's landblock is + // the CELL's landblock. Dungeon EnvCells sit at arbitrary "ocean" + // world coords with negative local-Y, so floor(pp.Y/192) lands one + // landblock off (the Bug-A class) — relocating the player into the + // landblock the dungeon collapse unloaded, making the avatar + // invisible. The cell id is authoritative. + currentLb = (result.CellId & 0xFFFF0000u) | 0xFFFFu; + } + else + { + var pp = _playerController.Position; + int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); + int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); + currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); + } _worldState.RelocateEntity(pe, currentLb); } From 7d8da99f79493c45cbc80fc710a8a76434d535bb Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 10:06:17 +0200 Subject: [PATCH 54/65] fix(G.3): collapse dungeon streaming at the snap, not after landblock finalize (#133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dungeon-streaming gate read SeenOutside from the render registry (_cellVisibility.TryGetCell), which only succeeds AFTER the landblock FINALIZES — ~tens of seconds for a 205-cell dungeon. So the collapse fired late and the full 25x25 neighbor window churned in first ("~30s to stabilize at high FPS"). EnvCell extends ObjCell, which already carries SeenOutside (set from the EnvCell dat flags at construction), so CurrCell.SeenOutside is available the moment the player is placed (the snap). Read it directly instead of the registry. Collapse now engages ~3s in (snap) instead of ~30s (finalize); residual is the ~24 neighbors the bootstrap loads before the snap, which then unload. Also simplifies the predicate. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a2531674..a42ba321 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -6890,12 +6890,16 @@ public sealed class GameWindow : IDisposable // window otherwise pulls in ~129 unrelated ocean-grid dungeons. Building // interiors (cottage/inn) have SeenOutside cells, so they are NOT gated // and keep their surrounding terrain. - // Mirrors the playerInsideCell computation below (CurrCell → registry - // LoadedCell.SeenOutside): true only for a sealed indoor cell. + // True only for a sealed indoor cell. Read the physics CurrCell's own + // SeenOutside (ObjCell.SeenOutside, set from the EnvCell dat flags) rather + // than the render registry: the registry lookup only succeeds AFTER the + // landblock FINALIZES (~tens of seconds for a 205-cell dungeon), which + // delayed the collapse and let the full 25×25 neighbor window churn in + // first (the "~30s to stabilize" report). CurrCell.SeenOutside is set the + // moment the player is placed, so the collapse now engages at the snap. bool insideDungeon = false; if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell pcEnv - && _cellVisibility.TryGetCell(pcEnv.Id, out var pcReg) - && pcReg is { SeenOutside: false }) + && !pcEnv.SeenOutside) { insideDungeon = true; // Pin the collapse to the cell's OWN landblock (cell id high 16 bits), From d90c5385d232a79d24a59a0773d4f7f0113c81e1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 13:49:02 +0200 Subject: [PATCH 55/65] fix(G.3): register portals-only connector cells for visibility (#133 ramp grey) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grey "barrier" at a dungeon ramp was a one-cell registration gap. The ramp's connector cell (0x0007014D) is a portals-only pass-through — CellMesh.Build yields 0 drawable sub-meshes for it (you walk through it on adjacent floors). But the whole registration block — including the portal-VISIBILITY registration (BuildLoadedCell -> _cellVisibility) — was gated behind `if (cellSubMeshes.Count > 0)`. So that cell was never added to the visibility graph; the flood lookup-missed it (PortalVisibilityBuilder :369), couldn't traverse it to the room below, and the grey clear color showed through. Confirmed live via two added probes: [cellreg] registered=204/205 (only 0x014D missing) + [pv-trace] p4->0x0007014D skip=lookup-miss. After the fix: registered=205, hasRamp=True, skip=lookup-miss gone, the room below renders. Fix: compute the cell transforms and call BuildLoadedCell (visibility) for EVERY cell with a valid cellStruct, regardless of drawable sub-meshes — matching retail, which keeps the whole landblock cell array resident before the flood runs. Drawing (RegisterCell, _pendingCellMeshes) and the physics BSP (CacheCellStruct) stay gated on drawable geometry (a portals-only connector has nothing to draw and no collision surface). Not a regression from the FPS-collapse work — a pre-existing gate the now-navigable dungeon exposed (every ramp/stair/cellar mouth would show it). TEMP diagnostics retained for the residual angle-grey investigation (strip after): [cellreg] (GameWindow), the 0x0007 [pv-trace] gate widen + raw-NDC bbox (PortalVisibility- Builder). Three earlier render-math theories (portal_side, on-screen clip, near-eye projection) were each refuted by apparatus/probe before shipping — this is the verified one. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 85 ++++++++++++------- .../Rendering/PortalVisibilityBuilder.cs | 25 +++++- 2 files changed, 77 insertions(+), 33 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a42ba321..5a6e2868 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -61,6 +61,7 @@ public sealed class GameWindow : IDisposable // though the title-bar FPS is only updated every 0.5s. private double _lastFps = 60.0; private double _lastFrameMs = 16.7; + private string _lastCellRegSig = ""; // TEMP #133 ramp-flood-collapse [cellreg] dedup // Phase I.2: per-frame counters surfaced through the ImGui DebugPanel // VM closures. Computed once per render pass alongside the frustum @@ -5664,26 +5665,42 @@ public sealed class GameWindow : IDisposable // Static objects inside the cell continue to flow through the dispatcher // as WorldEntity records below — they have real GfxObj MeshRefs that work // fine; EnvCellRenderer.RegisterCell receives an empty staticObjects list. + // Transforms — needed by the portal-visibility cell (unlifted) AND the + // render/physics path. Computed for EVERY cell with a valid cellStruct, + // not just drawable ones. Keep the small render lift out of physics; retail + // BSP contact planes use the EnvCell origin verbatim. The lift constant is + // shared with every draw-space consumer of portal polygons (OutsideView + // gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130). + var physicsCellOrigin = envCell.Position.Origin + lbOffset; + var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( + 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); + var cellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); + var physicsCellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); + + // PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless + // of whether CellMesh.Build produced drawable sub-meshes. A portals-only + // pass-through connector (a ramp / stair / cellar mouth) yields 0 render + // sub-meshes but MUST be in the visibility graph so the flood can traverse it + // to the cells beyond — otherwise the flood lookup-misses the unregistered + // neighbour and the grey clear shows through the opening (#133: ramp + // neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier + // at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace] + // skip=lookup-miss). Retail keeps the whole landblock cell array resident + // before the flood runs; BuildLoadedCell reads the cellStruct portals, NOT + // the render sub-meshes. The +0.02 m render lift is a DRAW concern only and + // is intentionally NOT fed into the visibility transform (#119-residual: the + // lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells). + BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); + var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); if (cellSubMeshes.Count > 0) { _pendingCellMeshes[envCellId] = cellSubMeshes; - // Keep the small render lift out of physics; retail BSP - // contact planes use the EnvCell origin verbatim. The lift - // constant is shared with every draw-space consumer of - // portal polygons (OutsideView gate, seal/punch fans) — - // see PortalVisibilityBuilder.ShellDrawLiftZ (#130). - var physicsCellOrigin = envCell.Position.Origin + lbOffset; - var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( - 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); - var cellTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * - System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); - var physicsCellTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * - System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); - // Phase A8: register the cell with EnvCellRenderer for rendering. // staticObjects is empty — cell stabs continue as separate WorldEntity // records via the dispatcher (see lines below for the unchanged stab path). @@ -5697,23 +5714,8 @@ public sealed class GameWindow : IDisposable cellRotation: envCell.Position.Orientation, staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>()); - // Step 4: build LoadedCell for portal visibility — with the - // PHYSICS (unlifted) transform. The +0.02 m render lift above - // is a DRAW concern (shell z-fighting vs terrain); feeding it - // into the visibility graph shifted every HORIZONTAL portal - // plane 2 cm up, putting an eye standing on a deck/landing - // 10–20 mm BELOW the lifted plane — outside the side test's - // ±10 mm in-plane window — so the cell behind the portal was - // side-culled: the tower-top staircase vanish + roof flap - // (#119-residual; captured live at eye z=126.803 vs the - // 010A→0107 plane at 126.80, reproduced ONLY with the lift in - // TowerAscentReplayTests.CapturedTopOfStairs_*). Vertical - // doorways were immune (the lift slides their planes along - // themselves), which is why this hit exactly stairs, decks, - // and cellar mouths. - BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); - - // Cache CellStruct physics BSP for indoor collision (UNCHANGED). + // Cache CellStruct physics BSP for indoor collision (UNCHANGED — gated + // on drawable cells; a portals-only connector has no collision surface). _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); } } @@ -7689,6 +7691,25 @@ public sealed class GameWindow : IDisposable playerCellId: playerRoot?.CellId ?? 0u, lights: Lighting); + // TEMP (#133 ramp-flood-collapse): cell-registration completeness for the + // player's dungeon landblock. If the ramp neighbour (0x....014D in 0x0007) + // is absent from _cellVisibility, the portal flood can't admit it (lookup-miss + // at PortalVisibilityBuilder.cs:369) and the grey clear shows through. Logs only + // when the count or ramp-presence changes (dedup) — pairs with [pv-trace] skip=. + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled && playerRoot is not null) + { + uint plb = playerRoot.CellId >> 16; + int reg = _cellVisibility.GetCellsForLandblock(plb).Count; + uint rampId = (plb << 16) | 0x014Du; + bool hasRamp = _cellVisibility.TryGetCell(rampId, out _); + string sig = plb.ToString("X4") + ":" + reg + ":" + hasRamp; + if (sig != _lastCellRegSig) + { + _lastCellRegSig = sig; + Console.WriteLine($"[cellreg] lb=0x{plb:X4} registered={reg} hasRamp0x{rampId:X8}={hasRamp} playerCell=0x{playerRoot.CellId:X8}"); + } + } + // Never cull the landblock the player is currently on. uint? playerLb = null; if (_playerMode && _playerController is not null) diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index 38f263b8..d31ea93d 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -759,7 +759,13 @@ public static class PortalVisibilityBuilder private static bool IsHoltburgIndoorProbeCell(uint cellId) { - if ((cellId & 0xFFFF0000u) != 0xA9B40000u) + uint lb = cellId & 0xFFFF0000u; + // TEMP (#133 ramp-flood-collapse diagnosis): widen the [pv-trace] gate to the + // 0x0007 Town Network dungeon so the per-portal skip= reason (lookup-miss / + // clip-empty / reciprocal-empty / side) is emitted for the ramp neighbour. + if (lb == 0x00070000u) + return true; + if (lb != 0xA9B40000u) return false; uint low = cellId & 0xFFFFu; return low >= 0x016F && low <= 0x0175; @@ -821,6 +827,7 @@ public static class PortalVisibilityBuilder // genuinely off-screen; the ndc coords (post-clip, bounded) show where on screen it lands. int projN = -1, clipN = -1; string ndcText = ""; + string rawText = ""; if (i < cameraCell.PortalPolygons.Count) { var poly = cameraCell.PortalPolygons[i]; @@ -830,6 +837,21 @@ public static class PortalVisibilityBuilder projN = clip.Length; if (clip.Length >= 3) { + // Raw projected-NDC bbox (pre-screen-clip): WHERE the portal lands on screen, + // even when ClipToRegion drops it to empty. A clip=0 portal whose raw bbox is + // inside [-1,1] is on-screen-but-wrongly-dropped (the bug); a bbox outside + // [-1,1] is genuinely off-screen (correct). Distinguishes the two. + float rminX = float.MaxValue, rminY = float.MaxValue, rmaxX = -float.MaxValue, rmaxY = -float.MaxValue; + foreach (var cv in clip) + { + if (cv.W <= 1e-6f) continue; + float nx = cv.X / cv.W, ny = cv.Y / cv.W; + rminX = MathF.Min(rminX, nx); rmaxX = MathF.Max(rmaxX, nx); + rminY = MathF.Min(rminY, ny); rmaxY = MathF.Max(rmaxY, ny); + } + if (rminX <= rmaxX) + rawText = FormattableString.Invariant($" raw=[{rminX:F1},{rminY:F1}..{rmaxX:F1},{rmaxY:F1}]"); + var ndc = PortalProjection.ClipToRegion(clip, FullScreenQuad); clipN = ndc.Length; var ns = new System.Text.StringBuilder(48); @@ -842,6 +864,7 @@ public static class PortalVisibilityBuilder sb.Append(" D=").Append(float.IsNaN(d) ? "na" : d.ToString("F2")); sb.Append(side ? " TRV" : " CULL"); sb.Append(" proj=").Append(projN).Append(" clip=").Append(clipN); + if (rawText.Length > 0) sb.Append(rawText); if (ndcText.Length > 0) sb.Append(" ndc=").Append(ndcText); } sb.Append(" || outPolys=").Append(frame.OutsideView.Polygons.Count); From 3e006d372a77903db11afdecc007d257e6f58464 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 14:21:41 +0200 Subject: [PATCH 56/65] =?UTF-8?q?fix(G.3):=20register=20connector=20cells?= =?UTF-8?q?=20in=20the=20PHYSICS=20graph=20too=20=E2=80=94=20viewer-cell?= =?UTF-8?q?=20transit=20(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After registering portals-only connector cells for VISIBILITY (d90c538), an angle-dependent residual grey remained when the camera crossed a ramp: the camera-collision sweep (SmartBox::update_viewer -> sphere_path.curr_cell, pc:92870) could not transit INTO the connector cell because it had no physics cell to sweep into — CacheCellStruct was still gated on drawable sub-meshes. So the viewer cell stalled one cell behind the eye (confirmed live: [flap-sweep] transited every cached neighbour but NEVER the un-cached connector 0x014D, viewerCell stuck at 0x00070103 while the eye sat 1.32 m past the connector's portal plane), and the side test correctly culled the on-screen connector portal -> grey. Fix: move CacheCellStruct out of the `cellSubMeshes.Count > 0` gate, next to BuildLoadedCell — cache EVERY cell with a valid cellStruct for physics too. Retail keeps the whole landblock cell array resident for the sweep; a portals-only connector has an empty collision BSP but its portals drive the transit. User-gated: "I see no grey background any longer." Build green; 12 flood-gate tests + 677 physics/cell/transit tests green (no collision or membership regression). TEMP render probes still retained (strip after). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 5a6e2868..3eb7d8aa 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5696,6 +5696,20 @@ public sealed class GameWindow : IDisposable // lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells). BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); + // PHYSICS cell graph: cache EVERY cell with a valid cellStruct, regardless of + // drawable sub-meshes. The camera-collision sweep (SmartBox::update_viewer → + // sphere_path.curr_cell, pc:92870) and the player cell-transit must be able to + // TRANSIT THROUGH a portals-only connector — otherwise the viewer/curr cell can + // never reach it and lags one cell behind the eye (#133 residual: the camera sat + // 1.32 m past the ramp portal's plane while the viewer cell stalled in + // 0x00070103 — the sweep transited every cached neighbour but NEVER the + // un-cached connector 0x014D — so the side test culled the on-screen connector + // portal and the grey clear showed through). Retail keeps the whole landblock + // cell array resident for the sweep; a portals-only connector has an empty + // collision BSP but its portals drive the transit. CacheCellStruct reads the + // cellStruct directly, not the render sub-meshes. + _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); + var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); if (cellSubMeshes.Count > 0) { @@ -5713,10 +5727,6 @@ public sealed class GameWindow : IDisposable cellWorldPosition: cellOrigin, cellRotation: envCell.Position.Orientation, staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>()); - - // Cache CellStruct physics BSP for indoor collision (UNCHANGED — gated - // on drawable cells; a portals-only connector has no collision surface). - _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); } } } From 3e641339e9d52ebdb8bae2380a661f4656707e4f Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 14:27:45 +0200 Subject: [PATCH 57/65] chore(G.3): strip the #133 temp diagnostics Remove the throwaway probes added to diagnose the dungeon FPS/grey issues now that they're fixed: the ACDREAM_LOG_FPS headless line + [cellreg] registration line (GameWindow), and the [pv-trace] 0x0007 gate-widen + raw-NDC bbox addition to the flap probe (PortalVisibilityBuilder, reverted to the pre-#133 form). The permanent Phase-U.4c [flap]/[pv-trace] probes (ACDREAM_PROBE_FLAP) are kept as-is. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 25 ------------------- .../Rendering/PortalVisibilityBuilder.cs | 25 +------------------ 2 files changed, 1 insertion(+), 49 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3eb7d8aa..e45101dc 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -61,7 +61,6 @@ public sealed class GameWindow : IDisposable // though the title-bar FPS is only updated every 0.5s. private double _lastFps = 60.0; private double _lastFrameMs = 16.7; - private string _lastCellRegSig = ""; // TEMP #133 ramp-flood-collapse [cellreg] dedup // Phase I.2: per-frame counters surfaced through the ImGui DebugPanel // VM closures. Computed once per render pass alongside the frustum @@ -7701,25 +7700,6 @@ public sealed class GameWindow : IDisposable playerCellId: playerRoot?.CellId ?? 0u, lights: Lighting); - // TEMP (#133 ramp-flood-collapse): cell-registration completeness for the - // player's dungeon landblock. If the ramp neighbour (0x....014D in 0x0007) - // is absent from _cellVisibility, the portal flood can't admit it (lookup-miss - // at PortalVisibilityBuilder.cs:369) and the grey clear shows through. Logs only - // when the count or ramp-presence changes (dedup) — pairs with [pv-trace] skip=. - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled && playerRoot is not null) - { - uint plb = playerRoot.CellId >> 16; - int reg = _cellVisibility.GetCellsForLandblock(plb).Count; - uint rampId = (plb << 16) | 0x014Du; - bool hasRamp = _cellVisibility.TryGetCell(rampId, out _); - string sig = plb.ToString("X4") + ":" + reg + ":" + hasRamp; - if (sig != _lastCellRegSig) - { - _lastCellRegSig = sig; - Console.WriteLine($"[cellreg] lb=0x{plb:X4} registered={reg} hasRamp0x{rampId:X8}={hasRamp} playerCell=0x{playerRoot.CellId:X8}"); - } - } - // Never cull the landblock the player is currently on. uint? playerLb = null; if (_playerMode && _playerController is not null) @@ -8494,11 +8474,6 @@ public sealed class GameWindow : IDisposable } _lastFps = fps; _lastFrameMs = avgFrameTime; - // TEMP (A7 FPS measurement, strip after): headless FPS/frame-time so the - // launch log can be correlated against the [WB-DIAG] draw stats. - if (Environment.GetEnvironmentVariable("ACDREAM_LOG_FPS") == "1") - Console.WriteLine( - $"[FPS] {fps:F1} fps | {avgFrameTime:F2} ms | lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount} anim {animatedCount}"); _perfAccum = 0; _perfFrameCount = 0; } diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index d31ea93d..38f263b8 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -759,13 +759,7 @@ public static class PortalVisibilityBuilder private static bool IsHoltburgIndoorProbeCell(uint cellId) { - uint lb = cellId & 0xFFFF0000u; - // TEMP (#133 ramp-flood-collapse diagnosis): widen the [pv-trace] gate to the - // 0x0007 Town Network dungeon so the per-portal skip= reason (lookup-miss / - // clip-empty / reciprocal-empty / side) is emitted for the ramp neighbour. - if (lb == 0x00070000u) - return true; - if (lb != 0xA9B40000u) + if ((cellId & 0xFFFF0000u) != 0xA9B40000u) return false; uint low = cellId & 0xFFFFu; return low >= 0x016F && low <= 0x0175; @@ -827,7 +821,6 @@ public static class PortalVisibilityBuilder // genuinely off-screen; the ndc coords (post-clip, bounded) show where on screen it lands. int projN = -1, clipN = -1; string ndcText = ""; - string rawText = ""; if (i < cameraCell.PortalPolygons.Count) { var poly = cameraCell.PortalPolygons[i]; @@ -837,21 +830,6 @@ public static class PortalVisibilityBuilder projN = clip.Length; if (clip.Length >= 3) { - // Raw projected-NDC bbox (pre-screen-clip): WHERE the portal lands on screen, - // even when ClipToRegion drops it to empty. A clip=0 portal whose raw bbox is - // inside [-1,1] is on-screen-but-wrongly-dropped (the bug); a bbox outside - // [-1,1] is genuinely off-screen (correct). Distinguishes the two. - float rminX = float.MaxValue, rminY = float.MaxValue, rmaxX = -float.MaxValue, rmaxY = -float.MaxValue; - foreach (var cv in clip) - { - if (cv.W <= 1e-6f) continue; - float nx = cv.X / cv.W, ny = cv.Y / cv.W; - rminX = MathF.Min(rminX, nx); rmaxX = MathF.Max(rmaxX, nx); - rminY = MathF.Min(rminY, ny); rmaxY = MathF.Max(rmaxY, ny); - } - if (rminX <= rmaxX) - rawText = FormattableString.Invariant($" raw=[{rminX:F1},{rminY:F1}..{rmaxX:F1},{rmaxY:F1}]"); - var ndc = PortalProjection.ClipToRegion(clip, FullScreenQuad); clipN = ndc.Length; var ns = new System.Text.StringBuilder(48); @@ -864,7 +842,6 @@ public static class PortalVisibilityBuilder sb.Append(" D=").Append(float.IsNaN(d) ? "na" : d.ToString("F2")); sb.Append(side ? " TRV" : " CULL"); sb.Append(" proj=").Append(projN).Append(" clip=").Append(clipN); - if (rawText.Length > 0) sb.Append(rawText); if (ndcText.Length > 0) sb.Append(" ndc=").Append(ndcText); } sb.Append(" || outPolys=").Append(frame.OutsideView.Polygons.Count); From 3b93f91ebe0b86ff7caec153a24c8612a182a6f0 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 14:27:45 +0200 Subject: [PATCH 58/65] =?UTF-8?q?feat(A7):=20LightBake=20Core=20=E2=80=94?= =?UTF-8?q?=20verified=20per-vertex=20static-light=20burn-in=20(foundation?= =?UTF-8?q?,=20not=20wired)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The faithful fix for the spotty dungeon/house/outdoor lighting is retail's per-vertex static-light bake (D3DPolyRender::SetStaticLightingVertexColors 0x0059cfe0), NOT a per-pixel ramp. This lands the GL-free Core: LightBake.PointContribution / ComputeVertexColor port calc_point_light (0x0059c8b0) VERBATIM — verified against a clean Ghidra decompile (the BN pseudo-C is x87-mangled): half-Lambert wrap with LIGHT_POINT_RANGE=0.75 (0x007e5430), the distsq>1 norm branch, the per-channel min-to-color clamp, and the final [0,1] clamp. static_light_factor=1.3 (0x00820e24) is already folded into LightSource.Range by LightInfoLoader. 7 conformance tests (hand-derived golden values) green. NOT wired yet — the integration (a per-vertex colour attribute on the cell mesh + the bake driver keyed on envCellId + the shader consumption) is the remaining A7 work; see ISSUES.md A7. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Lighting/LightBake.cs | 101 ++++++++++++++++ .../Lighting/LightBakeTests.cs | 109 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 src/AcDream.Core/Lighting/LightBake.cs create mode 100644 tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs diff --git a/src/AcDream.Core/Lighting/LightBake.cs b/src/AcDream.Core/Lighting/LightBake.cs new file mode 100644 index 00000000..1ab52714 --- /dev/null +++ b/src/AcDream.Core/Lighting/LightBake.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.Core.Lighting; + +/// +/// Retail per-vertex static-light burn-in. Ported verbatim from +/// calc_point_light (acclient 0x0059c8b0), the function retail's +/// D3DPolyRender::SetStaticLightingVertexColors (0x0059cfe0) runs over +/// EVERY vertex of an EnvCell mesh × EVERY reaching static light, baking the +/// result into the vertex diffuse colour ONCE (then the rasteriser Gouraud- +/// interpolates it across each triangle and the texture stage modulates it). +/// +/// +/// This is the faithful answer to the dungeon "spotlight" look (#133 A7): our +/// old per-pixel nearest-8 path lit only the 8 torches nearest the CAMERA and +/// re-ranked them every frame (the sliding crescent). The retail bake sums ALL +/// reaching lights into the vertex once, keyed on light position not camera — +/// uniform, stable, and never blown out (each light is clamped to its own +/// colour, then the vertex sum is clamped to [0,1]). +/// +/// +/// Constants (decomp-cited, not guessed): +/// +/// static_light_factor = 1.3 (0x00820e24) — folded into +/// by LightInfoLoader, so +/// falloff_eff == light.Range here. +/// LIGHT_POINT_RANGE = 0.75 (0x007e5430) — the half-Lambert wrap +/// uses 2·LPR = 1.5 as the divisor and (2·LPR − 1) = 0.5 as the +/// distance bias, so even surfaces angled away from a torch receive some light. +/// +/// +public static class LightBake +{ + // calc_point_light literals. + private const float TwoLpr = 1.5f; // LIGHT_POINT_RANGE + LIGHT_POINT_RANGE + private const float WrapBias = 0.5f; // (2 · LIGHT_POINT_RANGE) − 1.0 + + /// + /// Accumulate one static light's contribution into a per-vertex RGB sum, + /// exactly as calc_point_light does. Returns the contribution to ADD + /// (already per-channel clamped to the light's own colour); the caller sums + /// over all reaching lights and clamps the total to [0,1]. + /// + public static Vector3 PointContribution( + Vector3 vtxWorldPos, Vector3 vtxWorldNormal, LightSource light) + { + // D = light − vertex (FROM vertex TO light), used un-normalised. + float dx = light.WorldPosition.X - vtxWorldPos.X; + float dy = light.WorldPosition.Y - vtxWorldPos.Y; + float dz = light.WorldPosition.Z - vtxWorldPos.Z; + + float distsq = dx * dx + dy * dy + dz * dz; + float dist = MathF.Sqrt(distsq); + float falloffEff = light.Range; // = Falloff × static_light_factor(1.3) + if (dist >= falloffEff || falloffEff <= 1e-4f) + return Vector3.Zero; + + // Half-Lambert wrap: (1/1.5)·(N·D + 0.5·dist), N un-normalised vertex normal. + float wrap = (1f / TwoLpr) * + (vtxWorldNormal.X * dx + vtxWorldNormal.Y * dy + vtxWorldNormal.Z * dz + + WrapBias * dist); + if (wrap <= 0f) + return Vector3.Zero; + + // norm branch — ported EXACTLY (changes the near-vs-far falloff shape). + float norm = distsq > 1f ? distsq * dist : dist; + float scale = (1f - dist / falloffEff) * light.Intensity * (wrap / norm); + + // Per channel: contribution clamped to the light's own colour (a single + // light can never push a channel past its colour — the no-blowout ceiling). + return new Vector3( + MathF.Min(scale * light.ColorLinear.X, light.ColorLinear.X), + MathF.Min(scale * light.ColorLinear.Y, light.ColorLinear.Y), + MathF.Min(scale * light.ColorLinear.Z, light.ColorLinear.Z)); + } + + /// + /// Bake the full per-vertex colour by summing every reaching lit point/spot + /// light, then clamping to [0,1] (the SetStaticLightingVertexColors + /// final clamp). Directional lights are skipped — they are handled by the + /// sun path, not the static burn-in. + /// + public static Vector3 ComputeVertexColor( + Vector3 vtxWorldPos, Vector3 vtxWorldNormal, IReadOnlyList reaching) + { + float r = 0f, g = 0f, b = 0f; + for (int i = 0; i < reaching.Count; i++) + { + var light = reaching[i]; + if (!light.IsLit || light.Kind == LightKind.Directional) continue; + var c = PointContribution(vtxWorldPos, vtxWorldNormal, light); + r += c.X; g += c.Y; b += c.Z; + } + return new Vector3( + Math.Clamp(r, 0f, 1f), + Math.Clamp(g, 0f, 1f), + Math.Clamp(b, 0f, 1f)); + } +} diff --git a/tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs b/tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs new file mode 100644 index 00000000..be082b37 --- /dev/null +++ b/tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs @@ -0,0 +1,109 @@ +using System; +using System.Numerics; +using AcDream.Core.Lighting; +using Xunit; + +namespace AcDream.Core.Tests.Lighting; + +/// +/// Conformance tests for the per-vertex static-light burn-in +/// (), ported from retail calc_point_light +/// (0x0059c8b0). Golden values are hand-derived from the decompiled equation: +/// wrap = (1/1.5)·(N·D + 0.5·dist); norm = distsq>1 ? distsq·dist : dist; +/// scale = (1 − dist/Range)·intensity·(wrap/norm); contrib = min(scale·color, color). +/// +public sealed class LightBakeTests +{ + private static LightSource Torch(Vector3 pos, float intensity = 100f, float range = 10f) + => new LightSource + { + Kind = LightKind.Point, + WorldPosition = pos, + ColorLinear = Vector3.One, + Intensity = intensity, + Range = range, + IsLit = true, + }; + + [Fact] + public void NearTorch_FacingIt_SaturatesToColor() + { + // Vertex at origin facing up (+Z); torch 2 m above. + // dist=2, distsq=4, wrap=(1/1.5)(2+1)=2, norm=4·2=8, + // scale=(1-0.2)·100·(2/8)=20 → min(20·1,1)=1 per channel. + var c = LightBake.PointContribution( + Vector3.Zero, new Vector3(0, 0, 1), Torch(new Vector3(0, 0, 2))); + Assert.Equal(1f, c.X, 4); + Assert.Equal(1f, c.Y, 4); + Assert.Equal(1f, c.Z, 4); + } + + [Fact] + public void FarTorch_FallsOffSmoothly() + { + // Torch 8 m above (still within Range 10). scale=(1-0.8)·100·(8/512)=0.3125. + var c = LightBake.PointContribution( + Vector3.Zero, new Vector3(0, 0, 1), Torch(new Vector3(0, 0, 8))); + Assert.Equal(0.3125f, c.X, 4); + Assert.Equal(0.3125f, c.Y, 4); + Assert.Equal(0.3125f, c.Z, 4); + } + + [Fact] + public void OutOfRange_ContributesNothing() + { + // Torch 11 m above, Range 10 → dist >= falloff_eff, skipped. + var c = LightBake.PointContribution( + Vector3.Zero, new Vector3(0, 0, 1), Torch(new Vector3(0, 0, 11))); + Assert.Equal(Vector3.Zero, c); + } + + [Fact] + public void FacingAway_BeyondWrap_ContributesNothing() + { + // Normal points away (−Z) from a torch above: N·D=−2, wrap=(1/1.5)(−2+1)<0. + var c = LightBake.PointContribution( + Vector3.Zero, new Vector3(0, 0, -1), Torch(new Vector3(0, 0, 2))); + Assert.Equal(Vector3.Zero, c); + } + + [Fact] + public void HalfLambertWrap_LightsSurfaceAngledPast90Degrees() + { + // Normal at ~100° from the light direction still gets light (Lambert would not). + // Light straight above (+Z 2 m); normal tilted to (sin100°, 0, cos100°). + double t = 100.0 * Math.PI / 180.0; + var n = new Vector3((float)Math.Sin(t), 0, (float)Math.Cos(t)); // cos100° < 0 + var c = LightBake.PointContribution(Vector3.Zero, n, Torch(new Vector3(0, 0, 2))); + Assert.True(c.X > 0f, "half-Lambert wrap should light a surface angled past 90°"); + } + + [Fact] + public void ComputeVertexColor_SumsLightsAndClampsToOne() + { + // Two saturating torches → sum clamps to 1, never overflows. + var lights = new[] + { + Torch(new Vector3(0, 0, 2)), + Torch(new Vector3(0, 0, 2)), + }; + var c = LightBake.ComputeVertexColor(Vector3.Zero, new Vector3(0, 0, 1), lights); + Assert.Equal(1f, c.X, 4); + Assert.Equal(1f, c.Y, 4); + Assert.Equal(1f, c.Z, 4); + } + + [Fact] + public void ComputeVertexColor_SkipsDirectionalAndUnlit() + { + var lights = new[] + { + new LightSource { Kind = LightKind.Directional, WorldPosition = new Vector3(0,0,2), + ColorLinear = Vector3.One, Intensity = 100f, Range = 10f, IsLit = true }, + new LightSource { Kind = LightKind.Point, WorldPosition = new Vector3(0,0,2), + ColorLinear = Vector3.One, Intensity = 100f, Range = 10f, IsLit = false }, + }; + var c = LightBake.ComputeVertexColor(Vector3.Zero, new Vector3(0, 0, 1), lights); + Assert.Equal(Vector3.Zero, c); + } +} From a100bc37a7f07d03ef64c80460713a988208af23 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 14:33:07 +0200 Subject: [PATCH 59/65] docs(G.3): file #134 (ramp slide) + #135 (login FPS); record #133 grey+FPS fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap-up bookkeeping for the dungeon work this session: - #135 — login FPS ramp (~10 fps -> high over ~30 s): the streaming collapse only fires once CurrCell resolves to a sealed cell, so the first-frame bootstrap loads ~24 neighbour ocean-grid dungeons (+ ~19k entities each) then unloads them. Residual of the dungeon collapse; clean fix = pre-collapse at login when the spawn cell is a sealed dungeon cell. - #134 — ramp slide-response feel ("lags downward" instead of gliding along the slope). SURFACED (not caused) by 3e006d3 caching the ramp connector cell in the physics graph; the slope-walk/edge-slide is now exercised. Port the retail slide-response; no band-aid. - #133 — progress note: dungeon FPS FIXED (streaming collapse to the single dungeon landblock, 14-30 -> ~1000+ fps) + grey barrier FIXED (register portals-only connector cells for BOTH visibility and the physics graph even when they build 0 sub-meshes; d90c538 + 3e006d3). A7 per-vertex lighting bake (LightBake Core 3b93f91) is the remaining "lighting off" work; revised diagnosis (intensity=100 is the real dat value; the divergence is no-static-light-burnin, not a mis-read). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 122 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 107 insertions(+), 15 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 4a672ae3..9a169623 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,61 @@ Copy this block when adding a new issue: --- +## #135 — ~30 s low-FPS ramp at login (≈10 fps → high) before streaming settles + +**Status:** OPEN +**Severity:** LOW (startup-only; self-corrects) +**Filed:** 2026-06-14 +**Component:** streaming — first-frame bootstrap vs the dungeon collapse + +**Description:** On login into a dungeon, FPS starts ~10 and climbs over ~30 s before +settling (then 1000+ fps). User: "we still have about 30ish seconds before FPS is ramped +up; when logging in I get like 10 then it slowly increases." + +**Root cause / status:** The #133 streaming collapse (`5686050`/`d9e7dd6`/`7d8da99`) only +engages once CurrCell resolves to a sealed cell (the snap, a few s in). Before that the +first Tick bootstraps the full 25×25 window, so ~24 neighbour ocean-grid dungeons (+ their +~19k entities) load, then unload when the collapse fires. The collapse-at-snap change moved +the trigger from finalize-time (~30 s) toward snap-time but the bootstrap churn remains. +Clean fix = pre-collapse at login when the spawn cell is a sealed dungeon cell so the full +window never enqueues (touches the sensitive login spawn path — do carefully; no band-aid). + +**Files:** `GameWindow.cs:6885` (streaming Tick gate); `StreamingController.cs` (collapse); +login recenter `OnLiveEntitySpawnedLocked` ~2470. + +**Acceptance:** Login into a dungeon reaches steady-state FPS within ~1–2 s (no full-window +neighbour load/unload churn). + +--- + +## #134 — Player "lags downward" instead of gliding along a dungeon ramp edge + +**Status:** OPEN +**Severity:** LOW-MEDIUM (movement feel; not a hard traversal block) +**Filed:** 2026-06-14 +**Component:** physics — slope-walk / edge-slide response + +**Description:** Running up or down against a dungeon ramp's edge, the player "sort of lags +downwards" instead of gliding/sliding ALONG the ramp surface (up when running up, down when +running down). Reported in the 0x0007 Town Network dungeon ramp after #133. + +**Root cause / status:** Surfaced (not caused) by the #133 connector-cell physics +registration (`3e006d3`): the ramp connector cell's collision is now fully resident in the +physics graph, so the slope-walk / edge-slide response on it is exercised for the first time. +"Lag down" suggests the slide velocity is projected toward gravity rather than along the +contact plane (the slope tangent). Likely the retail edge-slide / slope-slide response is +incomplete — see #32 (retail edge-slide/cliff-slide/precipice-slide incomplete) and the +AP-6 / TS-1 / TS-4 slide rows in the divergence register. NO band-aid — port the retail +slide-response. + +**Files:** `src/AcDream.Core/Physics/` (slide-response in TransitionTypes / BSPQuery); ramp +cell 0x0007014D + neighbours. + +**Acceptance:** Running up a walkable ramp climbs it smoothly; running into the edge slides +along the slope (up/down per input direction), matching retail feel. + +--- + ## #133 — Teleport into a dungeon snaps the player BEFORE the dungeon landblock streams in → lands at the old landblock's frame (ocean), not the dungeon **Status:** OPEN — promoted to **Phase G.3** (Dungeon streaming + portal @@ -97,6 +152,37 @@ transition` spam), and the render budget is sane — **WB-DIAG instances ~39,000 (meshMissing=0)** vs the 9.1M pre-Bug-A blowup (#95, now RESOLVED as a Bug-A symptom). User-confirmed: "no errors from ACE this time." +**✅ DUNGEON FPS FIXED + GREY BARRIER FIXED (2026-06-14, user-confirmed).** Two +separate causes, both resolved: + +- **FPS (was 14–30, now ~1000+):** AC dungeons sit adjacent in the "ocean" landblock + grid, so the 25×25 (farRadius=12) streaming window pulled ~129 neighbour dungeons + + their ~19k particle emitters / entities each frame. Fix = **collapse streaming to the + player's single dungeon landblock** when CurrCell is a sealed EnvCell (`!SeenOutside`), + with landblock-level hysteresis to stop collapse↔expand thrash. Confirmed against ACE + (`landblock.IsDungeon → return adjacents` with no neighbours): dungeons have no neighbour + landblocks, so collapsing to the one block is retail-faithful. Commits `5686050` (collapse) + + `d9e7dd6` (hysteresis) + `2561918` (pin to CurrCell's landblock, not the position-derived + one — the negative cell-local-Y made `floor(pp.Y/192)` land one block off and unload the + REAL dungeon). Divergence register: AP-36. + +- **GREY BARRIER (the "barrier above the ramp" / cellar-mouth grey):** portals-only + connector cells (ramp mouths, stair landings, cellar throats) build **0 drawable + sub-meshes**, and BOTH cell-registration gates (`BuildLoadedCell` → visibility + `_cellVisibility`, and `CacheCellStruct` → the physics cell graph) were gated on + `cellSubMeshes.Count > 0`. So a connector cell never registered → the portal flood + hit a **lookup-miss** at its opening (the un-flooded opening shows the clear/grey + colour) AND the camera eye-sweep couldn't transit through it. Fix = register EVERY + cell with a valid cellStruct for visibility + physics; only the *drawing* registration + stays gated on having sub-meshes. Commits `d90c538` (visibility) + `3e006d3` (physics + graph). The physics-graph half EXPOSED the ramp slide-response feel (now **#134**). + Three render-MATH theories (portal_side centroid, on-screen clip, near-eye projection) + were instrumented and REFUTED before the real lookup-miss cause was found — apparatus + discipline held. Render-pipeline digest updated. + +Residual (filed separately): login FPS ramp **#135**; ramp slide-response **#134**; the +A7 per-vertex lighting bake (below) is the remaining "lighting off" work. + **✅ A7 dungeon lighting — selection fix LANDED + objectively verified (`a80061b`).** The "lighting off" report was NOT missing torches — the `ACDREAM_PROBE_LIGHT` diagnostic (`d6fb788`) showed the dungeon correctly gets retail's flat 0.2 indoor ambient + sun zeroed @@ -112,21 +198,27 @@ light it). Core lighting suite green. Then `Range = Falloff × 1.5` (retail `ran retail-faithful (`SmartBox::SetWorldAmbientLight(0.2f)`); the 0.30 was a red herring (`CreatureMode` paperdoll renderer, not world cells). -**⚠️ REAL remaining cause — GENERAL light over-saturation (NOT dungeon-specific; belongs to -the #79 indoor-lighting umbrella).** Screenshot + `[light-detail]` probe (`9e809bc`): torches -read **`intensity=100`** (+ garbage `cone`). Our shader does `Diffuse = color × intensity` → -`color × 100` → every lit surface blows out to white = the hard "spotlight" disks. Retail's -`config_hardware_light` (0x0059adc) uses the SAME math (`Diffuse = (color/255) × intensity`) -and is NOT blown out → **retail's intensity is ~1.0; we are mis-reading the dat -`LightInfo.Intensity`** (likely a DatReaderWriter field/type bug — its source is a compiled -NuGet, not vendored, so unconfirmed). Over-saturates EVERY light (houses + outdoors + dungeons — -matches the user's "same issue everywhere; retail is uniform"). **DO NOT ad-hoc `÷100` -(forbidden workaround, risks the frozen outdoor/building lighting).** Proper fix = pin the -dat-format (raw-byte inspect a `LightInfo` / get the DatReaderWriter source) → correct the -intensity read → fixes the general spotty lighting everywhere. GENERAL engine-lighting work, -beyond G.3 dungeon scope. Separately: dungeon FPS 14–30 (WB-DIAG ~22K draws/frame — heavy -cell-geometry draw count / poor instancing — a general rendering-perf task; the 8-light -selection also added a per-frame 2227-light sort that should become a partial-select). +**⚠️ REAL remaining cause — REVISED 2026-06-14 (the earlier "mis-read intensity" theory is +REFUTED).** `intensity=100` is the **REAL dat value** (raw-byte verified `00 00 C8 42` = 100.0f; +DatReaderWriter 2.1.7 parses it correctly; the garbage `cone` is MSVC `CD CD CD CD` +uninitialized fill Turbine baked into the dat — point lights never read it). **DO NOT `÷100`.** +The actual divergence is the **[HIGH] `no-static-light-burnin`**: retail bakes ALL of a cell's +reaching static lights **PER-VERTEX once** (`D3DPolyRender::SetStaticLightingVertexColors` +0x0059cfe0 → `calc_point_light` 0x0059c8b0, Gouraud-interpolated → uniform, never blown out via +the per-channel min-to-colour clamp), while we light **per-PIXEL with only the 8 nearest-to- +CAMERA lights** → bright pools near torches, dark between, and a crescent that slides as the +camera re-ranks the 8-slot list. Diagnosed via a 5-agent investigation + a clean Ghidra +decompile (the BN pseudo-C is x87-mangled). **LANDED:** the per-pixel `(1-dist/falloff_eff)` +shader ramp (`007e287`, necessary but NOT sufficient — it can't fix the per-vertex-vs-per-pixel +structure) + the GL-free `LightBake` Core (`3b93f91`: the verbatim `calc_point_light` port + +7 conformance tests). **REMAINING — the A7 integration:** add a per-vertex linear-RGB colour +attribute to the cell mesh + a bake driver keyed on `envCellId` (NOT the dedup `cellGeomId` — +adjacent rooms share a geom but not their torches) + consume it in `mesh_modern.frag` for cell +draws; bound the bake's light set to the player dungeon (#133's FPS collapse already does this). +Belongs to the #79/#93 indoor-lighting umbrella; outdoor static objects + building shells still +use the per-pixel-8 path (the same spottiness — separate follow-up). **NOTE — dungeon FPS is +FIXED** (was 14–30 from streaming ~129 neighbour ocean-grid dungeons; now ~1000+ fps after the +#133 streaming collapse + the allocation-free 8-light partial-select, `5872bcf`/`5686050`). **Severity:** HIGH (any far/dungeon teleport is unusable) **Filed:** 2026-06-13 (M1.5 dungeon-demo gate attempt — meeting-hall portal) **Component:** physics/streaming — teleport-arrival snap vs async landblock hydration From 712f17f0f2d46f50b9f9e5b66e10215970896a06 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 16:46:56 +0200 Subject: [PATCH 60/65] =?UTF-8?q?fix(G.3):=20pre-collapse=20dungeon=20stre?= =?UTF-8?q?aming=20at=20login/teleport=20=E2=80=94=20kill=20the=20login=20?= =?UTF-8?q?FPS=20ramp=20(#135)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On login (or teleport) into a dungeon, FPS started ~10 and climbed over ~30 s. Root cause: the dungeon "collapse" (which shrinks the 25x25 streaming window to the player's single dungeon landblock — AC dungeons have no neighbours) only fires once the per-frame `insideDungeon` gate reads true, and that gate keys on the physics CurrCell, which isn't set until the player is PLACED, which waits for the dungeon landblock to hydrate. So during the whole hydration window NormalTick bootstraps the full window — ~24 unrelated ocean-grid neighbour dungeons + their ~19k entities each — and the collapse only mops them up afterward. That mop-up is the ramp. Fix: trigger the SAME collapse early, the instant we recenter the streaming center onto a sealed dungeon cell, before the first NormalTick. - StreamingController.PreCollapseToDungeon(cx,cy): fires EnterDungeonCollapse early (idempotent). The expensive neighbour window is never enqueued. - GameWindow.IsSealedDungeonCell(cellId): reads the EnvCell dat SeenOutside flag (CurrCell is null pre-placement) — the same flag ObjCell.SeenOutside and the per-frame gate use, so the early decision matches the eventual one. Distinguishes a real dungeon from a cottage/inn interior (SeenOutside → keeps its outdoor surround). Excludes the 0xFFFE/0xFFFF structural shell ids so an outdoor spawn id can't type-confuse a LandBlock record as an EnvCell. - Hooks: OnLiveEntitySpawnedLocked (login) + OnLivePositionUpdated (teleport). - Observer robustness: during a teleport PortalSpace hold the streaming observer follows the recentered destination, not the frozen pre-teleport position (which could drift >=2 landblocks off and trip ExitDungeonExpand). And _lastLivePlayerLandblockId is now filtered to the player guid (resolves the Phase A.1 TODO) so a stray NPC UpdatePosition can't drift the login-hold observer off the dungeon. Faithful EARLY trigger of the existing AP-36 collapse mechanism, not a new workaround — AP-36 amended in the same commit. Adversarially reviewed across timing / threading / faithfulness lenses; 5 new tests including the real runtime ordering (Tick bootstraps, then PreCollapse cancels). Core suite green (1463). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 18 +++- .../retail-divergence-register.md | 2 +- src/AcDream.App/Rendering/GameWindow.cs | 93 +++++++++++++++++- .../Streaming/StreamingController.cs | 31 ++++++ .../StreamingControllerDungeonGateTests.cs | 94 +++++++++++++++++++ 5 files changed, 231 insertions(+), 7 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 9a169623..ddb9b279 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -48,11 +48,27 @@ Copy this block when adding a new issue: ## #135 — ~30 s low-FPS ramp at login (≈10 fps → high) before streaming settles -**Status:** OPEN +**Status:** FIX LANDED — pending visual gate (login into the 0x0007 dungeon → FPS steady in ~1–2 s, no neighbour load/unload churn) **Severity:** LOW (startup-only; self-corrects) **Filed:** 2026-06-14 **Component:** streaming — first-frame bootstrap vs the dungeon collapse +**FIX (2026-06-14):** pre-collapse streaming the instant we recenter onto a SEALED +dungeon cell at login/teleport, before the first `NormalTick` bootstraps the window. +- `StreamingController.PreCollapseToDungeon(cx,cy)` — fires the existing `EnterDungeonCollapse` + early (idempotent), so the expensive ocean-grid neighbour window is never enqueued + (teleport) / is enqueued-then-immediately-cleared for a cheap Holtburg frame (login). +- `GameWindow.IsSealedDungeonCell(cellId)` — reads the `EnvCell` dat `SeenOutside` flag + (the same flag the hydrated `ObjCell.SeenOutside` + the per-frame gate use) so a cottage/inn + interior keeps its outdoor surround; excludes the 0xFFFE/0xFFFF shell ids. +- Hooks in `OnLiveEntitySpawnedLocked` (login) + `OnLivePositionUpdated` (teleport). +- Observer robustness: during a teleport `PortalSpace` hold the observer follows the + recentered destination (not the frozen position); `_lastLivePlayerLandblockId` is now + filtered to the player guid (resolving a Phase A.1 TODO) so a stray NPC update can't drift + the login-hold observer off the dungeon and trip `ExitDungeonExpand`. +Adversarially reviewed (3 lenses); register row AP-36 amended. Tests in +`StreamingControllerDungeonGateTests` (5 new, incl. the real Tick-then-PreCollapse ordering). + **Description:** On login into a dungeon, FPS starts ~10 and climbs over ~30 s before settling (then 1000+ fps). User: "we still have about 30ish seconds before FPS is ramped up; when logging in I get like 10 then it slowly increases." diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 79d30650..080c4d01 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -130,7 +130,7 @@ accepted-divergence entries (#96, #49, #50). | AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | | AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | -| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock | `src/AcDream.App/Rendering/GameWindow.cs:6895` (predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | +| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | | AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 − dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ −0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`−0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 | --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e45101dc..d4729ab1 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2471,6 +2471,23 @@ public sealed class GameWindow : IDisposable _liveCenterY = lbY; } + // #135: the instant we know the player spawned into a SEALED dungeon, + // pre-collapse streaming to that single landblock — BEFORE the first + // StreamingController.Tick bootstraps the 25×25 ocean-grid window. The + // player isn't placed yet (physics CurrCell is null), so the per-frame + // insideDungeon gate stays false for the entire hydration window and + // NormalTick would otherwise load ~24 neighbor dungeons then unload them + // (the login FPS ramp the user reported — 10 fps slowly climbing). Sealed- + // dungeon only: a cottage/inn interior (SeenOutside) keeps its outdoor + // surround. We hold _datLock here, and IsSealedDungeonCell re-takes it + // (reentrant); the controller call is render-thread-safe (Channel writes). + if (spawn.Guid == _playerServerGuid + && _streamingController is not null + && IsSealedDungeonCell(p.LandblockId)) + { + _streamingController.PreCollapseToDungeon(lbX, lbY); + } + var origin = new System.Numerics.Vector3( (lbX - _liveCenterX) * 192f, (lbY - _liveCenterY) * 192f, @@ -4484,10 +4501,18 @@ public sealed class GameWindow : IDisposable private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) { - // Phase A.1: track the most recently updated entity's landblock so the - // streaming controller can follow the player. TODO: filter by our own - // character guid once we reliably know it from CharacterList. - _lastLivePlayerLandblockId = update.Position.LandblockId; + // Phase A.1 / #135: track the PLAYER's last server-known landblock so the + // streaming controller can follow the player in the fly-camera / pre-player-mode + // (login hold) views. Filtered to our OWN character guid — resolving the original + // Phase A.1 TODO. An arbitrary NPC's UpdatePosition from a far outdoor landblock + // must NOT move the streaming observer: during a dungeon-login hold (player not + // yet placed, so _playerController is null and the PortalSpace observer branch + // can't apply) that would drift the observer off the pre-collapsed dungeon + // landblock and trip ExitDungeonExpand, re-streaming the 25×25 neighbor window + // the pre-collapse just suppressed. _playerServerGuid is set from CharacterList + // (~line 1984) before world entry, so it is valid by the time updates arrive. + if (update.Guid == _playerServerGuid) + _lastLivePlayerLandblockId = update.Position.LandblockId; if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; @@ -4936,6 +4961,15 @@ public sealed class GameWindow : IDisposable _liveCenterX = lbX; _liveCenterY = lbY; newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ); + + // #135: pre-collapse on teleport into a sealed dungeon too — same + // race as login. The destination isn't placed until it hydrates, so + // without this NormalTick loads the full neighbor window during the + // arrival hold. The PortalSpace observer branch (OnUpdate) keeps the + // observer pinned to _liveCenterX/Y while held, so the stale frozen + // player position can't drift the observer off the dungeon and re-expand. + if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId)) + _streamingController.PreCollapseToDungeon(lbX, lbY); } else { @@ -6863,7 +6897,27 @@ public sealed class GameWindow : IDisposable int observerCx = _liveCenterX; int observerCy = _liveCenterY; - if (_playerMode && _playerController is not null) + if (_playerMode && _playerController is not null + && _playerController.State == AcDream.App.Input.PlayerState.PortalSpace) + { + // Teleport hold (#135): the local player position is frozen at the + // PRE-teleport spot, expressed in the OLD center frame, but + // _liveCenterX/_liveCenterY were already recentered onto the + // destination landblock (OnLivePositionUpdated). Follow the + // destination directly — the stale position-derived offset + // (_liveCenterX + floor(frozenPos/192)) could land ≥2 landblocks off + // the dungeon and trip ExitDungeonExpand, re-streaming the very + // neighbor window the pre-collapse just suppressed. Correct for an + // outdoor teleport too: pre-load the destination during the hold. + // + // NOTE: these assignments equal the observerCx/Cy defaults initialized + // above — the LOAD-BEARING effect of this branch is INHIBITING the + // position-derived offset in the else-if below while the player position + // is frozen, not the (redundant) assignment. Kept explicit for clarity. + observerCx = _liveCenterX; + observerCy = _liveCenterY; + } + else if (_playerMode && _playerController is not null) { // Player mode: follow the physics-resolved player position. // The player walks via the local physics engine; the server @@ -11894,6 +11948,35 @@ public sealed class GameWindow : IDisposable return unhydratable; } + // #135: is this server-sent cell id a SEALED dungeon EnvCell — an indoor cell + // (low 16 bits >= 0x0100) whose EnvCell dat flags lack SeenOutside? Distinguishes + // a real dungeon (collapse streaming to its single landblock) from a building + // interior (cottage/inn — SeenOutside, which keeps its outdoor surround) and from + // an outdoor cell, WITHOUT needing the cell hydrated. Reads the SAME dat flag as + // the hydration path (BuildLoadedCell, ~line 5999) and as the physics + // CurrCell.SeenOutside the per-frame insideDungeon gate reads — so the pre-collapse + // decision matches the eventual gate decision exactly. Returns false when the dat + // lacks the cell (out-of-range index / missing record) so we never collapse on a + // guess. The dat read is reentrant-safe under _datLock (Monitor) — callers may + // already hold it (the login spawn handler does). + private bool IsSealedDungeonCell(uint cellId) + { + // Not an EnvCell: the sub-0x0100 outdoor sub-cells AND the 0xFFFE/0xFFFF + // structural shell ids (LandBlockInfo / LandBlock heightmap). A naive + // `< 0x0100` test MISSES 0xFFFF (65535 is not < 256), and Get on + // 0xXXYYFFFF would then type-confuse the LandBlock record living at that id as + // an EnvCell (its bytes unpack to a bogus Flags value). A real spawn/teleport + // position never carries a shell id, but exclude them so the read is sound. + uint low = cellId & 0xFFFFu; + if (low < 0x0100u || low >= 0xFFFEu) return false; + if (_dats is null) return false; + DatReaderWriter.DBObjs.EnvCell? envCell; + lock (_datLock) + envCell = _dats.Get(cellId); + return envCell is not null + && !envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside); + } + private void EnterPlayerModeFromAutoEntry() { _playerMode = true; diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 9a357cbb..d6d00518 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -149,6 +149,37 @@ public sealed class StreamingController DrainAndApply(); } + /// + /// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first + /// has a chance to bootstrap the full 25×25 window. Called + /// from the login / teleport spawn path the instant the streaming center is + /// recentered onto a SEALED dungeon landblock. + /// + /// The per-frame insideDungeon gate keys on the physics + /// CurrCell, which is only set once the player is PLACED — and placement + /// waits for the dungeon landblock to hydrate. So for the whole hydration window + /// (tens of seconds for a ~200-cell dungeon) the gate reads false and + /// would enqueue the ~24 unrelated ocean-grid neighbor + /// dungeons (+ ~19k entities each); the collapse then only mops them up after + /// placement. That mop-up is the 10→high FPS ramp users see at a dungeon login. + /// + /// Pre-collapsing means the EXPENSIVE dungeon-neighbour window is never + /// enqueued. On teleport nothing is enqueued at all (this fires before the next + /// Tick recenters). On login a brief Holtburg outdoor window may be enqueued by the + /// frame-1 NormalTick (before the player's spawn arrives) and is immediately + /// cancelled by _clearPendingLoads here — cheap outdoor terrain, not the + /// ocean-grid dungeons, and a handful of already-dequeued loads get swept next + /// frame. Idempotent: a no-op when already collapsed onto this same landblock, so a + /// re-sent spawn or a same-frame double call costs nothing. Render-thread only, + /// same as . + /// + public void PreCollapseToDungeon(int cx, int cy) + { + uint centerId = StreamingRegion.EncodeLandblockId(cx, cy); + if (_collapsed && _collapsedCenter == centerId) return; + EnterDungeonCollapse(cx, cy, centerId); + } + /// /// Outdoor / building-interior streaming — the original two-tier model. /// diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs index fd99fe30..522a4d07 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs @@ -147,6 +147,100 @@ public class StreamingControllerDungeonGateTests Assert.Contains(Encode(0, 7), h.Unloads); // stale dungeon block, outside new window } + [Fact] + public void PreCollapse_BeforeAnyTick_LoadsOnlyDungeon_NeverBootstrapsWindow() + { + // #135: at a dungeon login/teleport we pre-collapse the instant we recenter, + // BEFORE the first Tick. The full 25×25 neighbor window must NEVER be enqueued + // — only the single dungeon landblock loads. + var h = Make(); // empty state — nothing resident, _region is null + + h.Ctrl.PreCollapseToDungeon(0, 7); + + Assert.Single(h.Loads); // exactly one load + Assert.Equal(Encode(0, 7), h.Loads[0].Id); // the dungeon landblock + Assert.Equal(LandblockStreamJobKind.LoadNear, h.Loads[0].Kind); + Assert.DoesNotContain(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar); + } + + [Fact] + public void PreCollapse_AfterBootstrapTick_CancelsWindow_UnloadsResidentNeighbors_KeepsDungeon() + { + // The REAL runtime ordering at a dungeon login: the per-frame streaming Tick + // runs FIRST and bootstraps the full 25×25 window, THEN the spawn handler fires + // PreCollapseToDungeon. The pre-collapse must cancel the queued window loads + // (_clearPendingLoads) and unload any neighbor that already finished streaming. + var h = Make(); + + h.Ctrl.Tick(0, 7, insideDungeon: false); // frame 1: NormalTick bootstraps the window + Assert.True(h.Loads.Count > 1); // the full window was enqueued + + // Simulate neighbor landblocks that finished loading during the bootstrap, + // before the collapse edge. + h.State.AddLandblock(MakeLb(0, 7)); // the dungeon landblock itself + h.State.AddLandblock(MakeLb(0, 8)); // a neighbor ocean dungeon that loaded + h.State.AddLandblock(MakeLb(1, 7)); // another neighbor + h.Loads.Clear(); + h.Unloads.Clear(); + + h.Ctrl.PreCollapseToDungeon(0, 7); + + Assert.Equal(1, h.ClearCalls()); // queued window loads cancelled + Assert.Contains(Encode(0, 8), h.Unloads); // resident neighbor unloaded + Assert.Contains(Encode(1, 7), h.Unloads); + Assert.DoesNotContain(Encode(0, 7), h.Unloads); // dungeon landblock kept + } + + [Fact] + public void PreCollapse_ThenHoldTicksWithStaleObserver_StaysCollapsed() + { + // After pre-collapse the player is held (CurrCell still null → insideDungeon + // false) while the dungeon hydrates. A stale observer that is the SAME dungeon + // landblock must keep streaming collapsed — no full-window reload. + var h = Make(); + h.Ctrl.PreCollapseToDungeon(0, 7); + h.Loads.Clear(); + h.Unloads.Clear(); + + h.Ctrl.Tick(0, 7, insideDungeon: false); // hold frame: not placed yet + + Assert.Empty(h.Loads); // no neighbor window + Assert.Empty(h.Unloads); + } + + [Fact] + public void PreCollapse_IsIdempotent_OnSameLandblock() + { + // A re-sent player spawn / a same-frame double call must not re-clear or + // re-enqueue. + var h = Make(); + h.Ctrl.PreCollapseToDungeon(0, 7); + h.Loads.Clear(); + + h.Ctrl.PreCollapseToDungeon(0, 7); + + Assert.Equal(1, h.ClearCalls()); // clear fired only on the first collapse + Assert.Empty(h.Loads); // no second dungeon load + } + + [Fact] + public void PreCollapse_ThenPlaced_InsideDungeonTick_StaysCollapsed() + { + // When placement finally fires, the per-frame Tick(insideDungeon: true) sees + // the same collapsed landblock and holds — no re-collapse churn. + var h = Make(); + h.State.AddLandblock(MakeLb(0, 7)); // dungeon landblock finished loading + h.Ctrl.PreCollapseToDungeon(0, 7); + h.Loads.Clear(); + h.Unloads.Clear(); + + h.Ctrl.Tick(0, 7, insideDungeon: true); // placed: gate now fires + + Assert.Equal(1, h.ClearCalls()); // no second clear + Assert.Empty(h.Loads); + Assert.DoesNotContain(Encode(0, 7), h.Unloads); + } + [Fact] public void NormalOutdoorTick_Unchanged_NoCollapseNoClear() { From 2c923755c41eb7d42d6d3ccdeea114441fc8a618 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 17:13:12 +0200 Subject: [PATCH 61/65] fix(G.3): place the player on the cell floor for an indoor dungeon login (#135 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions from the pre-collapse (712f17f), found by live gate + a runtime probe: 1) Login-into-dungeon stopped loading the dungeon. The login-hold streaming observer fell through to the OFFLINE fly-camera branch once _lastLivePlayerLandblockId was filtered to the player guid (a dungeon-local NPC used to keep it pinned). A camera-derived observer far from the pre-collapsed dungeon tripped ExitDungeonExpand and unloaded it. Fix: a LIVE in-world session never uses the fly camera for the observer — it follows the player's server landblock, falling back to the recentered spawn center (_liveCenterX/Y). The fly camera is the OFFLINE observer only. 2) Even with the dungeon resident, auto-entry hung: the #106 "ground ready" gate required SampleTerrainZ under the spawn, but a dungeon's negative-offset cells place the spawn's WORLD position in a NEIGHBOUR terrain landblock the #135 collapse deliberately doesn't load (probe: cellReady=True, terrReady=False forever). The terrain gate is wrong for an indoor spawn — the player lands on the EnvCell FLOOR. Fix: gate an indoor (hydratable) spawn/teleport on IsSpawnCellReady, not the terrain heightmap; outdoor (and unhydratable→demote) spawns still hold on terrain. Applied to both isSpawnGroundReady (login auto- entry) and TeleportArrivalReadiness (teleport). This is the faithful equivalent of retail's synchronous cell load + place-on-floor; the pre-#135 terrain hold only passed because the 25x25 window streamed the neighbour terrain. Verified live: login into 0x0007 → auto-entered player mode, snapped to 0x00070145, dungeon renders, FPS steady. Register AD-2 amended. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../retail-divergence-register.md | 2 +- src/AcDream.App/Rendering/GameWindow.cs | 86 ++++++++++++++----- 2 files changed, 64 insertions(+), 24 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 080c4d01..8a9ddd3c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | -| AD-2 | Async spawn gates replacing retail's synchronous cell load: terrain-ready hold (**#106**) + indoor cell-hydration hold (**#107**, `IsSpawnCellReady`); claims beyond NumCells skip the gate (demoted) | `src/AcDream.App/Rendering/GameWindow.cs:1008` (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode | retail synchronous cell load before SetPosition (no gate exists) | +| AD-2 | Async spawn gates replacing retail's synchronous cell load. **#135 refinement:** an INDOOR spawn/teleport (cell ≥ 0x0100, hydratable) gates ONLY on the EnvCell floor (`IsSpawnCellReady`), NOT the terrain heightmap; an OUTDOOR spawn (or an unhydratable indoor claim that demotes outdoor) gates on the terrain-ready hold (**#106**). A dungeon's negative-offset cells can place the spawn's WORLD position in a neighbour terrain landblock the #135 dungeon collapse doesn't load, so a terrain requirement would hang indoor login/teleport forever (cellReady true, terrain null) — the player lands on the cell floor, terrain is irrelevant indoors. Claims beyond NumCells skip the gate (demoted) | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportArrivalReadiness` ~5012) (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge. Indoor-on-cellReady is the faithful equivalent of retail's synchronous cell load + place-on-floor (terrain under a dungeon is meaningless; the pre-#135 terrain hold only passed because the 25×25 window streamed the neighbour terrain) | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode; an indoor spawn whose cell never hydrates now holds on cellReady alone (no terrain backstop) — but that path is exactly the #107 hold | retail synchronous cell load before SetPosition (no gate exists) | | AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) | | AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d4729ab1..f9baef23 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1007,21 +1007,36 @@ public sealed class GameWindow : IDisposable // integrates gravity against an empty world and free-falls // the player into the void (retail loads cells synchronously; // this is the async-streaming equivalent of that invariant). - isSpawnGroundReady: () => _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe) - && _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null - // #107 gate-2 extension (2026-06-10): an INDOOR spawn claim - // additionally waits for the claimed cell's hydration so the - // entry snap's AdjustPosition validation can act (retail loads - // the cell synchronously before SetPosition; this is the - // async-streaming equivalent). Claims that can never hydrate - // (id outside the landblock's NumCells range per the dat) - // don't hold the gate — the Resolve-head safety net demotes - // them loudly. - && (!_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp) - || sp.Position is not { } spawnClaim - || spawnClaim.LandblockId == 0 - || _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId) - || IsSpawnClaimUnhydratable(spawnClaim.LandblockId)), + isSpawnGroundReady: () => + { + if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return false; + + // #107 / #135: spawn-ground readiness is spawn-claim aware. For an + // INDOOR claim (sealed dungeon / building interior) the ground the + // player lands on is the EnvCell FLOOR (its BSP), so gate on the + // cell's hydration (IsSpawnCellReady) — NOT the terrain heightmap. + // A dungeon's cells sit in their landblock at an arbitrary (often + // negative) offset, so the spawn's WORLD position can fall in a + // NEIGHBOUR terrain landblock that the #135 dungeon collapse + // deliberately does not load; requiring terrain there hangs login + // forever (cellReady true, SampleTerrainZ null). Retail loads the + // cell synchronously and places the player on the cell floor — + // cellReady is the faithful indoor equivalent (#106/#107, AD-2). + // (Before #135 this only passed by accident: the 25×25 window + // happened to stream the neighbour terrain.) + if (_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp) + && sp.Position is { } spawnClaim + && spawnClaim.LandblockId != 0 + && (spawnClaim.LandblockId & 0xFFFFu) >= 0x0100u + && !IsSpawnClaimUnhydratable(spawnClaim.LandblockId)) + return _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId); + + // Outdoor spawn, OR an unhydratable indoor claim that will demote to + // an outdoor position: hold until the terrain under the spawn streams + // (the original #106 gate — entering against an empty world free-falls + // the player into the void). + return _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null; + }, enterPlayerMode: EnterPlayerModeFromAutoEntry); } @@ -5013,10 +5028,19 @@ public sealed class GameWindow : IDisposable { if (IsSpawnClaimUnhydratable(destCell)) return AcDream.App.World.ArrivalReadiness.Impossible; - if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null) - return AcDream.App.World.ArrivalReadiness.NotReady; + + // #135: an INDOOR destination (sealed dungeon / building interior) gates on the + // EnvCell FLOOR, not the terrain heightmap. A dungeon's negative-offset cells can + // place destPos in a NEIGHBOUR terrain landblock the #135 collapse doesn't load, + // so SampleTerrainZ would stay null forever (the cell IS ready). Retail places on + // the cell floor. Outdoor: the terrain heightmap is the ground. bool indoor = (destCell & 0xFFFFu) >= 0x0100u; - if (indoor && !_physicsEngine.IsSpawnCellReady(destCell)) + if (indoor) + return _physicsEngine.IsSpawnCellReady(destCell) + ? AcDream.App.World.ArrivalReadiness.Ready + : AcDream.App.World.ArrivalReadiness.NotReady; + + if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null) return AcDream.App.World.ArrivalReadiness.NotReady; return AcDream.App.World.ArrivalReadiness.Ready; } @@ -6929,12 +6953,28 @@ public sealed class GameWindow : IDisposable observerCy = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); } else if (_liveSession is not null - && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld - && _lastLivePlayerLandblockId is { } lid) + && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld) { - // Live mode (fly camera): follow the server's last-known player position. - observerCx = (int)((lid >> 24) & 0xFFu); - observerCy = (int)((lid >> 16) & 0xFFu); + // Live, not yet in player mode: the login auto-entry hold, or a live + // fly-camera spectator. Follow the PLAYER's server-known landblock; if it + // hasn't arrived yet, KEEP the _liveCenterX/_liveCenterY default — which is + // the spawn/teleport recenter (the dungeon landblock at a dungeon login). + // + // #135 regression fix (2026-06-14): this MUST NOT fall through to the + // fly-camera projection below. During a dungeon-login hold the streaming is + // pre-collapsed onto the spawn landblock; a camera-derived observer far from + // it trips ExitDungeonExpand and unloads the dungeon before it can hydrate — + // the player is never placed and login hangs with no dungeon. Previously + // _lastLivePlayerLandblockId was set by ANY entity, so a dungeon-local NPC + // kept this branch on the dungeon; once it was filtered to the player guid + // (line ~4507), a not-yet-arrived player UP dropped to the camera branch. + // The fly camera is the OFFLINE observer only. + if (_lastLivePlayerLandblockId is { } lid) + { + observerCx = (int)((lid >> 24) & 0xFFu); + observerCy = (int)((lid >> 16) & 0xFFu); + } + // else: keep the _liveCenterX/_liveCenterY default (the spawn recenter). } else { From b4ed8e7908c029e99c397f1acfa55b57fc94e7ef Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 18:11:15 +0200 Subject: [PATCH 62/65] =?UTF-8?q?docs:=20file=20#136=20=E2=80=94=20red-con?= =?UTF-8?q?e=20dungeon=20decoration=20renders=20red=20(frozen-phase=20rend?= =?UTF-8?q?er=20divergence)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigated the user-reported divergence (a solid-red cone in the 0x0007 dungeon that retail doesn't draw). Narrowed by elimination: - geometry, not VFX (survives particles-off) - object 0x70007055 / Setup 0x020019F0, physState=0x1C — NOT NoDraw/Hidden - its distinguishing texture 0x06006D65 (DXT1 256x128) DECODES tan/opaque offline, identical to a neighbour decoration (0x020019EE / tex 0x06006D63) that renders fine - not a per-instance tint (hook dropped) => the red is introduced at runtime in the WB bindless texture-array upload/sampling path (a #105-class "samples undefined until flushed" / layer-handle misassignment), possibly lighting. Both WB-render-migration and sky/lighting are FROZEN phases, so the fix awaits explicit sign-off. Full diagnosis + reusable diagnostic approach in the issue. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index ddb9b279..02b630da 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,63 @@ Copy this block when adding a new issue: --- +## #136 — "Red cone" decoration renders solid red in the 0x0007 dungeon (retail shows nothing) + +**Status:** OPEN — root-cause narrowed; fix touches a FROZEN phase (awaiting decision) +**Severity:** LOW (cosmetic; one decoration in one dungeon) +**Filed:** 2026-06-14 +**Component:** rendering — WB bindless texture-array pipeline (FROZEN) / possibly lighting (FROZEN) + +**Description:** In the `0x0007` Town Network dungeon, a static decoration renders as a +solid bright-RED cone (apex toward the floor) ~3 m up, ~6.8 m from the login spawn. The +user's side-by-side retail client shows NOTHING there — acdream draws geometry retail +doesn't. Became visible only after the #135 login-into-dungeon fix placed the player at the +exact saved spawn next to it (the object always existed; it wasn't reachable/rendered +before). + +**Root cause / status (investigated 2026-06-14):** narrowed by elimination, NOT yet fixed. +- It is GEOMETRY, not a VFX: survives with particle rendering fully disabled. +- The object: server static landblock object `guid=0x70007055` (ACE `0x7`+lb`0x0007`+idx + `0x55`), Setup `0x020019F0`, `physState=0x1C` (Ethereal) — **NoDraw (0x20) and Hidden + (0x4000) are NOT set**, so it's not acdream ignoring retail's NODRAW_PS gate + (`acclient.h:2822`; `CPhysicsObj::set_nodraw` 0x0050fca0). +- Setup `0x020019F0` = 1 part (GfxObj `0x0100447F`, 23 polys), 2 surfaces; it is + near-IDENTICAL to neighbour decoration `0x020019EE` (renders FINE) — they share surface + `0x0800122B` and differ only in one surface: cone `0x0800162B` → tex `0x06006D65`, + neighbour `0x08001629` → tex `0x06006D63`. +- Both distinguishing textures are `PFID_DXT1 256x128`, and BOTH **decode to the same + tan/beige, fully-opaque** image offline (`SurfaceDecoder`: cone avgRGB (210,179,126), + neighbour (214,181,127), 0% alpha). So the source texture is NOT red and NOT transparent + — acdream's decode is correct. +- It is NOT a per-instance tint/highlight (that hook was dropped; `EnvCellRenderer`). +- => The red is introduced at RUNTIME, downstream of decode: most likely the WB bindless + `sampler2DArray` UPLOAD/SAMPLING path (a layer that samples UNDEFINED storage until a + flush runs — the #105 white-walls mechanism class, `ManagedGLTextureArray` PendingUpdate), + or a layer/handle misassignment for this specific texture. Lighting not fully excluded. + +**Why deferred:** the fix lands in the **WB rendering migration** (and possibly **sky/ +lighting**) — both FROZEN phases (CLAUDE.md). A genuine defect, but touching a frozen +subsystem needs explicit user sign-off; flagged for that decision. Also: the acdream dev +window can't be granted to computer-use (not a Start-menu app) and acdream has no +screenshot-to-disk feature, so autonomous visual verification needs a frame-dump feature +added first. + +**Files:** `src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs` (array upload/flush), +`src/AcDream.App/Rendering/Wb/TextureCache.cs` (id→array/layer), `mesh_modern.frag:87` +(bindless `sampler2DArray(vTextureHandle)`), the cone = `0x70007055`/Setup `0x020019F0`/tex +`0x06006D65` in cell `0x00070145`. + +**Diagnostic approach (reusable):** a throwaway `RedConeSetupProbeTests` dumped the +Setup→part→surface→SurfaceTexture→texture chain + decoded alpha/avg-color from the dat; the +`[static-spawn]` + nearby-entity probes in `OnLiveEntitySpawnedLocked`/`OnUpdate` ID'd the +guid/flags. A draw-time probe of the cone's resolved bindless handle/layer/PendingUpdateCount +would confirm the exact mechanism. + +**Acceptance:** the `0x70007055` decoration renders with its tan texture (matching its +neighbour) OR is correctly suppressed to match retail (which shows nothing there). + +--- + ## #135 — ~30 s low-FPS ramp at login (≈10 fps → high) before streaming settles **Status:** FIX LANDED — pending visual gate (login into the 0x0007 dungeon → FPS steady in ~1–2 s, no neighbour load/unload churn) From 6f81e2c91dbc39b32400528a67ffd192570eb71c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 19:03:08 +0200 Subject: [PATCH 63/65] =?UTF-8?q?fix(render):=20hide=20editor-only=20place?= =?UTF-8?q?ment=20markers=20in=20dungeons=20=E2=80=94=20port=20retail's=20?= =?UTF-8?q?degrade-to-nothing=20(#136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "red cone" (+ green floor petals) in the 0x0007 Town Network dungeon is a dat EnvCell static object (Setup 0x02000C39 / GfxObj 0x010028CA) using pure red/green MARKER textures (0x08000109 / 0x0800010A). It is an EDITOR-ONLY placement marker: its DIDDegrade table 0x11000118 is {slot0 Id=mesh MaxDist=0, slot1 Id=0 MaxDist=FLT_MAX}, i.e. visible ONLY at distance 0 (the WorldBuilder editor origin) and degraded to GfxObj id 0 (nothing) at any real distance. retail's distance-based degrade (CPhysicsPart::UpdateViewerDistance 0x0050E030 -> Draw 0x0050D7A0) therefore never draws it in the live client. acdream's render pipeline is extracted from WorldBuilder, which (being an editor) renders every cell static's base mesh directly and has NO degrade handling at all (zero DIDDegrade references in references/WorldBuilder) — so acdream inherited the "show the marker" behavior and drew it forever. It only became visible now because the #135 login-into-dungeon fix drops the player at the exact saved spawn next to it. Fix: GfxObjDegradeResolver.IsRuntimeHiddenMarker() detects the editor-marker pattern (HasDIDDegrade + Degrades[0].MaxDist==0 + a degrade entry with Id==0). The EnvCell static-object hydration (GameWindow ~5793) skips such GfxObjs — whole-stab for bare GfxObj stabs, per-part for Setup stabs (an all-marker Setup then drops via meshRefs.Count==0). This is the faithful equivalent of retail's runtime degrade for static geometry (always viewed at distance > 0); real LOD objects (slot0.MaxDist>0) and degrade-to-real-mesh objects are untouched. Diagnosis was extensive (geometry-not-VFX via particle-off; texture-not-lighting via flat-ambient frame dumps; per-surface runtime decode pinned the red/green marker surfaces; a draw-time probe pinned the dat-static entity id; a dat dump of the Setup + degrade table confirmed the editor-marker pattern). Verified live via a frame dump: the red cone + green petals are gone, all real dungeon decorations still render. 4 new GfxObjDegradeResolver unit tests cover the marker / normal-LOD / no-table / degrades-to-real-mesh cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 17 ++++ .../Meshing/GfxObjDegradeResolver.cs | 49 +++++++++++ .../Meshing/GfxObjDegradeResolverTests.cs | 85 +++++++++++++++++++ 3 files changed, 151 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index f9baef23..8f27733a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5800,6 +5800,17 @@ public sealed class GameWindow : IDisposable .DumpEntitySourceIds.Contains(stab.Id); int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0; + // #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to + // nothing (GfxObj id 0) at any runtime distance, so retail's distance-based + // degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the + // WorldBuilder editor shows it at the origin. acdream's render path came from + // WB (no distance LOD), so without this skip it draws the marker forever (the + // red/green dungeon "cone"). Bare-GfxObj stabs are checked here; Setup stabs + // skip per-part below (a Setup that is ALL markers drops via meshRefs.Count==0). + if ((stab.Id & 0xFF000000u) == 0x01000000u + && AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, stab.Id)) + continue; + var meshRefs = new List(); var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); if ((stab.Id & 0xFF000000u) == 0x01000000u) @@ -5833,6 +5844,12 @@ public sealed class GameWindow : IDisposable } foreach (var mr in flat) { + // #136: skip an editor-only marker PART (retail hides it at runtime + // distance). The #136 dungeon "cone" is Setup 0x02000C39 whose sole + // part GfxObj 0x010028CA is such a marker — skipping it empties + // meshRefs and the whole stab drops below. + if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, mr.GfxObjId)) + continue; var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) { diff --git a/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs b/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs index c8d38bf7..c9c2bedd 100644 --- a/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs +++ b/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs @@ -141,4 +141,53 @@ public static class GfxObjDegradeResolver resolvedGfxObj = closeGfxObj; return true; } + + /// + /// True when a GfxObj is an EDITOR-ONLY placement marker that retail's distance-based + /// degrade hides at any runtime distance. Such a marker's closest degrade slot is visible + /// ONLY at distance 0 (Degrades[0].MaxDist == 0) and the table degrades to GfxObj + /// id 0 (= nothing) at real distance. Retail + /// (CPhysicsPart::UpdateViewerDistance 0x0050E030 → Draw 0x0050D7A0 picks + /// gfxobj[deg_level] by viewer distance) therefore never draws it in the live + /// client — only WorldBuilder shows it at the editor origin. acdream has no per-frame + /// distance-LOD (the resolver above always returns slot 0), so without this check it + /// renders the marker mesh forever — the #136 dungeon "red/green cone" (Setup 0x02000C39 + /// / GfxObj 0x010028CA, whose degrade table 0x11000118 is {slot0 Id=mesh MaxDist=0, + /// slot1 Id=0 MaxDist=FLT_MAX}). Callers that hydrate static geometry (always viewed at + /// distance > 0) skip such GfxObjs. + /// + public static bool IsRuntimeHiddenMarker(DatCollection dats, uint gfxObjId) + => IsRuntimeHiddenMarker( + id => dats.Get(id), + id => dats.Get(id), + gfxObjId); + + /// Loader-callback overload of . + public static bool IsRuntimeHiddenMarker( + Func getGfxObj, + Func getDegradeInfo, + uint gfxObjId) + { + var gfxObj = getGfxObj(gfxObjId); + if (gfxObj is null + || !gfxObj.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) + || gfxObj.DIDDegrade == 0) + return false; + + var info = getDegradeInfo(gfxObj.DIDDegrade); + if (info is null || info.Degrades.Count == 0) + return false; + + // Closest slot visible only at distance exactly 0 = editor-only placement marker. + bool firstSlotEditorOnly = info.Degrades[0].MaxDist == 0f; + if (!firstSlotEditorOnly) + return false; + + // ...and the table degrades to NOTHING (id 0) at real distance — confirms it + // becomes invisible at runtime rather than LOD-swapping to a real mesh. + foreach (var d in info.Degrades) + if ((uint)d.Id == 0u) + return true; + return false; + } } diff --git a/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs b/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs index 54dc9c28..887bd340 100644 --- a/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs +++ b/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs @@ -179,4 +179,89 @@ public class GfxObjDegradeResolverTests Assert.Equal(baseId, resolvedId); Assert.Null(resolvedGfx); } + + // ── #136: editor-only placement marker detection ────────────────────────── + + /// + /// The #136 dungeon "cone": its degrade table's slot 0 is visible ONLY at distance 0 + /// (MaxDist=0) and the table degrades to GfxObj id 0 (= nothing) at real distance. + /// Retail's distance degrade never draws it in the live client; we must skip it. + /// + [Fact] + public void IsRuntimeHiddenMarker_EditorMarkerDegradingToNothing_True() + { + const uint markerGfx = 0x010028CAu; + const uint degradeId = 0x11000118u; + var gfx = new GfxObj { Flags = GfxObjFlags.HasDIDDegrade, DIDDegrade = degradeId }; + var info = new GfxObjDegradeInfo + { + Degrades = + { + new GfxObjInfo { Id = markerGfx, MaxDist = 0f }, + new GfxObjInfo { Id = 0u, MaxDist = float.MaxValue }, + }, + }; + var gfxObjs = new Dictionary { [markerGfx] = gfx }; + var infos = new Dictionary { [degradeId] = info }; + + Assert.True(GfxObjDegradeResolver.IsRuntimeHiddenMarker( + id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), markerGfx)); + } + + /// A real LOD object — slot 0 visible out to a real distance (MaxDist>0) — + /// is NOT a marker, even though it degrades further. + [Fact] + public void IsRuntimeHiddenMarker_NormalLodObject_False() + { + const uint baseId = 0x01000055u; + const uint degradeId = 0x110006D0u; + var gfx = new GfxObj { Flags = GfxObjFlags.HasDIDDegrade, DIDDegrade = degradeId }; + var info = new GfxObjDegradeInfo + { + Degrades = + { + new GfxObjInfo { Id = 0x01001795u, MaxDist = 25f }, + new GfxObjInfo { Id = 0u, MaxDist = float.MaxValue }, + }, + }; + var gfxObjs = new Dictionary { [baseId] = gfx }; + var infos = new Dictionary { [degradeId] = info }; + + Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker( + id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), baseId)); + } + + /// No degrade table at all → not a marker. + [Fact] + public void IsRuntimeHiddenMarker_NoDegradeTable_False() + { + const uint baseId = 0x01001212u; + var gfx = new GfxObj { Flags = 0, DIDDegrade = 0 }; + var gfxObjs = new Dictionary { [baseId] = gfx }; + Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker( + id => gfxObjs.GetValueOrDefault(id), _ => null, baseId)); + } + + /// slot 0 is editor-only (MaxDist=0) but degrades to a REAL mesh (no id-0 + /// entry) — a genuine close-only LOD, not an invisible marker. Do NOT skip. + [Fact] + public void IsRuntimeHiddenMarker_EditorSlotButDegradesToRealMesh_False() + { + const uint baseId = 0x01002000u; + const uint degradeId = 0x11002000u; + var gfx = new GfxObj { Flags = GfxObjFlags.HasDIDDegrade, DIDDegrade = degradeId }; + var info = new GfxObjDegradeInfo + { + Degrades = + { + new GfxObjInfo { Id = baseId, MaxDist = 0f }, + new GfxObjInfo { Id = 0x01002001u, MaxDist = float.MaxValue }, + }, + }; + var gfxObjs = new Dictionary { [baseId] = gfx }; + var infos = new Dictionary { [degradeId] = info }; + + Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker( + id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), baseId)); + } } From fd0ecfcf2e2716c53ed37cabadab70ed9d29f617 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 19:05:03 +0200 Subject: [PATCH 64/65] =?UTF-8?q?docs:=20close=20#136=20=E2=80=94=20red=20?= =?UTF-8?q?cone=20was=20an=20editor-only=20placement=20marker=20(fixed=206?= =?UTF-8?q?f81e2c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the #136 entry with the definitive root cause (editor-only dat placement marker hidden by retail's distance degrade, inherited as visible from the WB-derived render path) replacing the earlier refuted texture-pipeline hypothesis; mark FIXED. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ISSUES.md | 85 +++++++++++++++++++++----------------------------- 1 file changed, 36 insertions(+), 49 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 02b630da..96bd2d56 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,60 +46,47 @@ Copy this block when adding a new issue: --- -## #136 — "Red cone" decoration renders solid red in the 0x0007 dungeon (retail shows nothing) +## #136 — DONE — "red cone" in the 0x0007 dungeon was an editor-only placement marker acdream drew (retail hides it) -**Status:** OPEN — root-cause narrowed; fix touches a FROZEN phase (awaiting decision) -**Severity:** LOW (cosmetic; one decoration in one dungeon) -**Filed:** 2026-06-14 -**Component:** rendering — WB bindless texture-array pipeline (FROZEN) / possibly lighting (FROZEN) +**Status:** FIXED `6f81e2c` (2026-06-14) — verified live via frame dump: the red cone + +green floor "petals" are gone, all real dungeon decorations still render. User-approved +frozen-phase fix. +**Severity:** LOW (cosmetic; one marker in one dungeon) +**Filed/Fixed:** 2026-06-14 +**Component:** rendering — EnvCell static-object hydration (WB-derived path) vs retail degrade -**Description:** In the `0x0007` Town Network dungeon, a static decoration renders as a -solid bright-RED cone (apex toward the floor) ~3 m up, ~6.8 m from the login spawn. The -user's side-by-side retail client shows NOTHING there — acdream draws geometry retail -doesn't. Became visible only after the #135 login-into-dungeon fix placed the player at the -exact saved spawn next to it (the object always existed; it wasn't reachable/rendered -before). +**Description:** In the `0x0007` Town Network dungeon a bright-RED downward cone (+ a +green/red shape on the floor) rendered ~6 m from the login spawn; the user's side-by-side +retail client showed NOTHING there. Became visible only after the #135 login-into-dungeon +fix placed the player at the exact saved spawn next to it. -**Root cause / status (investigated 2026-06-14):** narrowed by elimination, NOT yet fixed. -- It is GEOMETRY, not a VFX: survives with particle rendering fully disabled. -- The object: server static landblock object `guid=0x70007055` (ACE `0x7`+lb`0x0007`+idx - `0x55`), Setup `0x020019F0`, `physState=0x1C` (Ethereal) — **NoDraw (0x20) and Hidden - (0x4000) are NOT set**, so it's not acdream ignoring retail's NODRAW_PS gate - (`acclient.h:2822`; `CPhysicsObj::set_nodraw` 0x0050fca0). -- Setup `0x020019F0` = 1 part (GfxObj `0x0100447F`, 23 polys), 2 surfaces; it is - near-IDENTICAL to neighbour decoration `0x020019EE` (renders FINE) — they share surface - `0x0800122B` and differ only in one surface: cone `0x0800162B` → tex `0x06006D65`, - neighbour `0x08001629` → tex `0x06006D63`. -- Both distinguishing textures are `PFID_DXT1 256x128`, and BOTH **decode to the same - tan/beige, fully-opaque** image offline (`SurfaceDecoder`: cone avgRGB (210,179,126), - neighbour (214,181,127), 0% alpha). So the source texture is NOT red and NOT transparent - — acdream's decode is correct. -- It is NOT a per-instance tint/highlight (that hook was dropped; `EnvCellRenderer`). -- => The red is introduced at RUNTIME, downstream of decode: most likely the WB bindless - `sampler2DArray` UPLOAD/SAMPLING path (a layer that samples UNDEFINED storage until a - flush runs — the #105 white-walls mechanism class, `ManagedGLTextureArray` PendingUpdate), - or a layer/handle misassignment for this specific texture. Lighting not fully excluded. +**Root cause (definitive):** the cone is ONE dat-hydrated EnvCell static object (`guid=0`, +`id=0x40000835`, Setup `0x02000C39` / GfxObj `0x010028CA`) baked into cell `0x00070145`, +using pure red+green MARKER surfaces (`0x08000109` red, `0x0800010A` green). It is an +**editor-only placement marker**: its `DIDDegrade` table `0x11000118` = +`{slot0 Id=mesh MaxDist=0, slot1 Id=0 MaxDist=FLT_MAX}` — visible ONLY at distance 0 (the +WorldBuilder editor origin) and degraded to GfxObj **id 0 (= nothing)** at any real distance. +Retail's distance-based degrade (`CPhysicsPart::UpdateViewerDistance` 0x0050E030 → `Draw` +0x0050D7A0 draws `gfxobj[deg_level]`) therefore never draws it in the live client. acdream's +render path is extracted from **WorldBuilder**, which — being an editor — renders every cell +static's base mesh directly and has **no degrade handling at all** (zero `DIDDegrade` refs in +`references/WorldBuilder`), so acdream inherited "show the marker" and drew it forever. (NOT +a texture/lighting bug — the cone's *own* object 0x70007055 decodes tan and was a red +herring; the marker is a separate `guid=0` dat static.) -**Why deferred:** the fix lands in the **WB rendering migration** (and possibly **sky/ -lighting**) — both FROZEN phases (CLAUDE.md). A genuine defect, but touching a frozen -subsystem needs explicit user sign-off; flagged for that decision. Also: the acdream dev -window can't be granted to computer-use (not a Start-menu app) and acdream has no -screenshot-to-disk feature, so autonomous visual verification needs a frame-dump feature -added first. +**Fix (`6f81e2c`):** `GfxObjDegradeResolver.IsRuntimeHiddenMarker()` detects the editor-marker +pattern (`HasDIDDegrade` + `Degrades[0].MaxDist==0` + a degrade entry with `Id==0`). EnvCell +static-object hydration (`GameWindow.cs` ~5793) skips such GfxObjs — whole-stab for bare +GfxObj stabs, per-part for Setup stabs (an all-marker Setup then drops via `meshRefs.Count==0`). +Faithful equivalent of retail's runtime degrade for static geometry (always viewed at +distance > 0); real LOD objects (`slot0.MaxDist>0`) and degrade-to-real-mesh objects are +untouched. 4 new `GfxObjDegradeResolver` unit tests. -**Files:** `src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs` (array upload/flush), -`src/AcDream.App/Rendering/Wb/TextureCache.cs` (id→array/layer), `mesh_modern.frag:87` -(bindless `sampler2DArray(vTextureHandle)`), the cone = `0x70007055`/Setup `0x020019F0`/tex -`0x06006D65` in cell `0x00070145`. - -**Diagnostic approach (reusable):** a throwaway `RedConeSetupProbeTests` dumped the -Setup→part→surface→SurfaceTexture→texture chain + decoded alpha/avg-color from the dat; the -`[static-spawn]` + nearby-entity probes in `OnLiveEntitySpawnedLocked`/`OnUpdate` ID'd the -guid/flags. A draw-time probe of the cone's resolved bindless handle/layer/PendingUpdateCount -would confirm the exact mechanism. - -**Acceptance:** the `0x70007055` decoration renders with its tan texture (matching its -neighbour) OR is correctly suppressed to match retail (which shows nothing there). +**Follow-up (not done):** outdoor `LandBlockInfo.Objects` stabs could carry the same markers; +apply `IsRuntimeHiddenMarker` there too if any surface. Also revealed (separate): the per- +pixel point-light shader overblows close torches (no per-channel `min(scale·color,color)` cap +vs retail `calc_point_light`) — the bright-red dungeon WALL under normal lighting; tracked +under the #79/#93 A7 lighting umbrella. --- From d2b8a51426fdbc05887219d76d6cd5b1ecea8769 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 21:08:40 +0200 Subject: [PATCH 65/65] =?UTF-8?q?docs:=20wrap-up=20=E2=80=94=20file=20#137?= =?UTF-8?q?=20(dungeon=20collision)=20+=20#138=20(teleport-out=20world=20l?= =?UTF-8?q?oading);=20close=20#135/#136?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #137: dungeon collision wrong at doors / wall openings (EnvCell collision; needs repro). - #138: teleport OUT of a dungeon loads the outdoor world incompletely (missing trees/ scenery, broken collision) + a position desync (avatar moves but player position doesn't) — hypothesised as the dungeon-streaming collapse→EXPAND gap (same machinery as #135). - #135 marked DONE (user-verified FPS-steady dungeon login); #136 closed (editor-marker hide). - CLAUDE.md current-state refreshed: #135/#136 shipped, A7 lighting + #137/#138 remaining. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 27 +++++++++----------- docs/ISSUES.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d4c43ff5..508e28d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,21 +108,18 @@ movement queries. ## Current state -**Currently working toward: M1.5 — Indoor world feels right.** Building/cellar -demo DONE; **dungeons now RENDER** (2026-06-13, autonomous /loop): G.3a teleport -hold+place + **Bug A** (validated-claim keeps the dungeon landblock prefix, `2ce5e5c`) -+ **login-into-dungeon recenter** (`47ae237`) → live `0x0007` dungeon renders, navigable, -correct membership, WB-DIAG instances **9.1M→39K**. **#95 was a Bug-A symptom, NOT an -unbounded flood — DO NOT port `grab_visible_cells` stab_list bounding** (the flood is -already bounded; the "terrain-less landblock" framing was refuted — dungeons are -flat-terrain + EnvCells). REMAINING for M1.5: **A7 dungeon torch/point-lighting** (dungeon -gets retail's flat 0.2 indoor ambient but `Setup.Lights` torches aren't registered → dim, -"lighting off"); needs visual iteration. M2 (CombatMath) deferred. Detail in **#133/#95** -(ISSUES) + the render digest's top banner. -Recent closes (2026-06-12/13): #119/#128, #112, #113, #124, -#129/#130/#131/#132, UN-2, #108-residual, #127, #125; #116 partial (Ghidra -threshold fix). Keep this paragraph ≤5 lines + pointers — detail in the -docs below, NOT here. +**Currently working toward: M1.5 — Indoor world feels right.** Dungeons RENDER + +are navigable; **login into a dungeon** now loads + places the player and is +**FPS-steady from the start** (#135 pre-collapse + indoor cell-floor spawn gate, +`712f17f`+`2c92375`). The dungeon **"red cone"** was an editor-only placement marker +acdream inherited from WB (retail hides it via distance degrade) — FIXED (#136 `6f81e2c`). +REMAINING for M1.5: **A7 dungeon lighting** (LightBake Core landed `3b93f91`; per-vertex +bake integration + the per-pixel torch OVER-blow still open — #79/#93); **#137 dungeon +collision** (doors / wall openings); **#138 teleport-OUT of a dungeon** loads the outdoor +world incompletely + position desync (the collapse→EXPAND gap — same machinery as #135). +M2 (CombatMath) deferred. Detail in ISSUES (#135–#138) + the render/physics digests. +Recent closes (2026-06-14): #135, #136. Keep this paragraph ≤6 lines + pointers — detail +in the docs below, NOT here. For canonical state, read in this order: - [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 96bd2d56..b0f629ae 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,72 @@ Copy this block when adding a new issue: --- +## #138 — Teleport OUT of a dungeon loads the outdoor world incompletely + position desync + +**Status:** OPEN +**Severity:** MEDIUM (breaks the dungeon→outdoor transition; collision + visuals wrong after exit) +**Filed:** 2026-06-14 +**Component:** streaming — dungeon collapse↔expand (the #133/#135 collapse) + teleport-arrival + +**Description (user):** taking a portal OUT of a dungeon to the outdoor world often loads +the world incompletely — **fewer objects than expected (e.g. missing trees/scenery)**, and +**collision doesn't work properly**. There's also a **position desync**: "it's like I'm not +moving while my character is moving" (the avatar animates/advances but the player's +actual position / camera doesn't track, or vice-versa). + +**Root cause / status (hypothesis — needs investigation):** very likely a gap in the +dungeon-streaming **collapse→expand** introduced for #133/#135. Inside a dungeon, streaming +is COLLAPSED to the single dungeon landblock (radius-0). On teleport OUT, +`StreamingController.ExitDungeonExpand` must rebuild the full 25×25 outdoor window at the new +center. Suspects: (a) the expand doesn't fully re-enqueue / re-hydrate the outdoor landblocks +(→ missing trees/scenery + no collision because shadow-object registration never ran for the +un-hydrated blocks); (b) the teleport-arrival recenter (`OnLivePositionUpdated`) + +`PreCollapseToDungeon`/observer interaction leaves the streaming observer pinned wrong after +exit; (c) the position desync = the player controller / streaming observer disagree on the +post-exit world position (the avatar moves in one frame, the streaming/camera in another). +Pairs with #135 (`712f17f`/`2c92375`) — same collapse machinery; the EXIT path is the gap. + +**Files:** `src/AcDream.App/Streaming/StreamingController.cs` (`ExitDungeonExpand`, the +collapse/expand hysteresis), `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` +teleport recenter ~4912, the streaming Tick gate ~6890, the PortalSpace observer branch), +`TeleportArrivalController`. Cross-check the post-exit shadow-object/collision registration. + +**Acceptance:** portal out of the 0x0007 dungeon → full outdoor world streams (trees/scenery +present), collision works, and the player position tracks correctly (no avatar-vs-camera desync). + +--- + +## #137 — Dungeon collision incorrect at doors and wall openings + +**Status:** OPEN +**Severity:** MEDIUM (movement/collision correctness in dungeons) +**Filed:** 2026-06-14 +**Component:** physics — EnvCell collision (doors, portal openings, cell geometry) + +**Description (user):** collision is still wrong in dungeons — **doors** and **openings in +walls** in particular. (Symptoms not fully characterized yet: likely walking through +openings that should block / blocking at openings that should pass, and door collision not +matching the door's open/closed state.) + +**Root cause / status (to investigate):** dungeon collision is EnvCell-based — the cell's +collision BSP + portal openings + per-cell static objects (doors). Candidates: door +apparatus collision in EnvCells (open/closed BSP swap) not fully ported; portal-opening +(wall gap) collision geometry handled differently from buildings; the per-cell +shadow-object registration (A6.P4, see the physics digest) for dungeon EnvCell statics. +Related families: #32 (edge-slide), #116 (slide-response), the door-collision saga +(see `feedback_dedup_keys_after_cardinality_change`, `feedback_retail_per_cell_shadow_list`). +Needs a targeted repro (which door / which opening, expected vs actual) before fixing — +oracle-first per the physics digest. + +**Files:** `src/AcDream.Core/Physics/` (EnvCell collision, CellTransit, the door apparatus), +`src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (per-cell registration). See +`claude-memory/project_physics_collision_digest.md` (the collision SSOT + DO-NOT-RETRY table). + +**Acceptance:** doors block/pass per their open/closed state; wall openings pass; solid walls +block — matching retail, in the 0x0007 dungeon. + +--- + ## #136 — DONE — "red cone" in the 0x0007 dungeon was an editor-only placement marker acdream drew (retail hides it) **Status:** FIXED `6f81e2c` (2026-06-14) — verified live via frame dump: the red cone + @@ -92,7 +158,7 @@ under the #79/#93 A7 lighting umbrella. ## #135 — ~30 s low-FPS ramp at login (≈10 fps → high) before streaming settles -**Status:** FIX LANDED — pending visual gate (login into the 0x0007 dungeon → FPS steady in ~1–2 s, no neighbour load/unload churn) +**Status:** DONE `712f17f`+`2c92375` (2026-06-14) — user-verified: login into the 0x0007 dungeon is FPS-steady from the start; dungeon loads + places the player. (NOTE: the teleport-OUT path has a separate streaming gap — see #138.) **Severity:** LOW (startup-only; self-corrects) **Filed:** 2026-06-14 **Component:** streaming — first-frame bootstrap vs the dungeon collapse