diff --git a/CLAUDE.md b/CLAUDE.md index 58354787..508e28d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,14 +108,18 @@ 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, -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 -paragraph ≤5 lines + pointers — detail lives 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 4938d1ab..b0f629ae 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,356 @@ 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 + +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 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 (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.) + +**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. + +**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. + +--- + +## #135 — ~30 s low-FPS ramp at login (≈10 fps → high) before streaming settles + +**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 + +**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." + +**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 +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). 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. + +**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. **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." + +**✅ 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 +(`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. 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 — 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 + +**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 @@ -827,7 +1177,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 — **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 @@ -3701,27 +4063,50 @@ 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 — [CLOSED 2026-06-12 · user-gated] -**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:** 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: +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 @@ -3907,13 +4292,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):** @@ -3945,6 +4364,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"] @@ -4304,35 +4768,56 @@ 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:** 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") +**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. --- ## #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 @@ -4347,11 +4832,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) @@ -4417,8 +4919,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 @@ -4477,73 +4992,213 @@ 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. --- ## #130 — Background-color strip along the TOP outer edge of a doorway when looking out from inside -**Status:** OPEN +**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; "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 — 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". -**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 (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. -**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. +**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 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, look out the door with the lintel on screen, +sweep the gaze — no background strip at the top edge at any alignment +or distance. + +--- + +## #131 — Portal swirl invisible when viewed from inside a building through the doorway + +**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 + +**Symptom (user, axiom):** "the portal swirl is missing, when I look out +from inside a house. Appears when I walk out again." + +**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. + +**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 (kept, env-gated):** `ACDREAM_PROBE_OUTSTAGE=1` — +`[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. (This FIXED #132 indoors but not the portal.) + +**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 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. + +--- + +## #132 — Candle flame disappears when the through-opening background is behind it + +**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 + +**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." + +**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. + +**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 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 045f4a49..30c1cd07 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 1. Intentional architecture (IA) — 15 rows +## 1. Intentional architecture (IA) — 14 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -55,7 +55,6 @@ accepted-divergence entries (#96, #49, #50). | IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md | | IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) | | IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` | -| IA-15 | D.2b retail UI is our own UiHost/UiElement retained-mode tree drawing an 8-piece dat-sprite window frame (later: XML markup + controls.ini stylesheet), not a byte-port of keystone.dll's LayoutDesc binary tree | `src/AcDream.App/UI/UiNineSlicePanel.cs` + `RetailChromeSprites.cs` + `src/AcDream.App/UI/Layout/LayoutImporter.cs` | keystone.dll has no PDB/decomp so a byte-port is impossible by definition; we mirror retail's ElementDesc field model + controls.ini tokens, and the chrome sprites ARE the real dat RenderSurfaces (Step-0 prove-out 2026-06-14 confirmed 0x06004CC2 center + 0x060074BF..C6 bevel). The 8-piece edge/corner→position mapping is NOW DATA-DRIVEN from the dat: the `LayoutImporter` (gated `ACDREAM_RETAIL_UI_IMPORTER`) reads the real `LayoutDesc` for `0x2100006C` and resolves chrome element positions + sprite ids directly from parsed dat fields; locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800×600 and the controls.ini cascade still lack an oracle — layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) | --- @@ -64,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) | @@ -80,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 | @@ -93,7 +92,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 32 rows +## 3. Documented approximation (AP) — 37 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -112,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 | @@ -128,11 +127,16 @@ 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 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Standalone Type-0 text elements are also skipped (vitals numbers render via `UiMeter.Label` bound by the controller; a dedicated dat-text widget is Plan 2). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port and a dat-text widget are deferred to Plan 2. Gated opt-in (`ACDREAM_RETAIL_UI_IMPORTER`) and locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape, or a window needing standalone dat text, renders an empty/wrong meter or drops text — no oracle diff until the Plan-2 widgets land | `UIElement_Meter::DrawChildren` @0x46fbd0; `UIElement_Text::DrawSelf` @0x467aa0; `docs/research/2026-06-15-layoutdesc-format.md` | +| 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. **#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 | +| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Standalone Type-0 text elements are also skipped (vitals numbers render via `UiMeter.Label` bound by the controller; a dedicated dat-text widget is Plan 2). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port and a dat-text widget are deferred to Plan 2. Gated opt-in (`ACDREAM_RETAIL_UI_IMPORTER`) and locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape, or a window needing standalone dat text, renders an empty/wrong meter or drops text — no oracle diff until the Plan-2 widgets land | `UIElement_Meter::DrawChildren` @0x46fbd0; `UIElement_Text::DrawSelf` @0x467aa0; `docs/research/2026-06-15-layoutdesc-format.md` | --- -## 4. Temporary stopgap (TS) — 29 rows +## 4. Temporary stopgap (TS) — 30 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -165,10 +169,11 @@ accepted-divergence entries (#96, #49, #50). | TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 | | TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) | | TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) | +| TS-30 | UI panels drawn as flat translucent rectangles + 1 px border; retail composes 9-slice dat sprite backgrounds via LayoutDesc trees | `src/AcDream.App/UI/UiPanel.cs:10` | Development visibility until the D.2b retail-look toolkit consumes the dat assets | Purely visual until D.2b — but pixel-position assumptions built against the placeholder (hit regions, layout constants) may not survive the swap to retail sprite metrics | RenderSurface 0x06xxxxxx 9-slice; LayoutDesc 0x21xxxxxx | --- -## 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 @@ -177,7 +182,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) | @@ -193,20 +197,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/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 77401952..fdbb5ef3 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -2,7 +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:** **M1.5 — Indoor world feels right.** +**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.) --- @@ -185,7 +191,56 @@ 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" — 🔵 ACTIVE (building/cellar demo DONE; EXTENDED 2026-06-13 to include dungeon support / Phase G.3) + +**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 +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). + +**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/`. + +--- + +#### (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 +348,23 @@ unblocks that). --- -### M2 — "Kill a drudge" — ⏸ DEFERRED until M1.5 lands (was: NEXT) +### 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. +**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 — 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 shipped in F.2 base; M2 ships the visual surface). 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..782aab65 --- /dev/null +++ b/docs/research/2026-06-12-night-session-handoff-108-residual-next.md @@ -0,0 +1,95 @@ +# 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. +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 / +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. +``` 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/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 new file mode 100644 index 00000000..95129126 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-dungeon-support-design.md @@ -0,0 +1,455 @@ +# 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:** 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 + `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 (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`. + +--- + +## 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.) + - `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. + +**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 +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 (REQUIRED — gate-confirmed 2026-06-13) + +**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. + +**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.** + +**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`) + +**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 = 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 +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 | `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 | + +`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 + 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. +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. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 96c63ceb..16302f69 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1015,21 +1015,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); } @@ -2086,6 +2101,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 @@ -2608,6 +2624,57 @@ 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; + } + + // #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, @@ -4621,10 +4688,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; @@ -5046,7 +5121,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 @@ -5060,79 +5135,127 @@ 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}"); + // 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); - 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()); + // #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 + { + 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; + + // #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) + 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; + } + + // 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. @@ -5144,6 +5267,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})"); } @@ -5266,6 +5390,11 @@ 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 readonly HashSet _outStageUnmatchedScratch = new(); + private readonly HashSet _outStageMatchedScratch = new(); + private static System.Numerics.Vector3 SkyPesAnchor( AcDream.Core.World.SkyObjectData obj, System.Numerics.Vector3 cameraWorldPos) @@ -5765,22 +5894,56 @@ 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); + + // 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) { _pendingCellMeshes[envCellId] = cellSubMeshes; - // Keep the small render lift out of physics; retail BSP - // contact planes use the EnvCell origin verbatim. - var physicsCellOrigin = envCell.Position.Origin + lbOffset; - var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(0f, 0f, 0.02f); - 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). @@ -5793,25 +5956,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); } } } @@ -5828,6 +5972,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) @@ -5861,6 +6016,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) { @@ -6949,7 +7110,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 @@ -6961,12 +7142,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 { @@ -6980,7 +7177,37 @@ 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. + // 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 + && !pcEnv.SeenOutside) + { + 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 // into the current center landblock (the one the observer is in). @@ -7000,6 +7227,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. @@ -7138,10 +7371,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); } @@ -7717,6 +7964,25 @@ 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, + lights: Lighting); + // Never cull the landblock the player is currently on. uint? playerLb = null; if (_playerMode && _playerController is not null) @@ -7796,9 +8062,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 @@ -7813,6 +8079,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()) @@ -7827,10 +8099,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; @@ -7956,10 +8229,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, @@ -7985,6 +8258,22 @@ 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, + 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 @@ -8005,6 +8294,26 @@ 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), + // #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 => @@ -8125,20 +8434,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 @@ -9800,12 +10115,113 @@ public sealed class GameWindow : IDisposable animatedEntityIds: animatedIds); } - _outdoorSceneParticleEntityIds.Clear(); - foreach (var entity in sliceCtx.OutdoorEntities) - _outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity)); + // #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(); - if (_outdoorSceneParticleEntityIds.Count > 0 + } + + // #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, + bool isOutdoorRoot) + { + 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 lateCtx.ParticleOwners) + _outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity)); + + // #131 [outstage-pt] probe: the slice Scene-particle id set + how many + // 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(); + _outStageMatchedScratch.Clear(); + foreach (var (emitter, _) in _particleSystem.EnumerateLive()) + { + if (emitter.AttachedObjectId == 0) { unattached++; continue; } + attached++; + 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} matchedIds=[{mat}] unmatchedIds=[{unm}]"); + if (ptSig != _lastOutStagePtSig) + { + _lastOutStagePtSig = ptSig; + Console.WriteLine("[outstage-pt] " + ptSig); + } + } + + // #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) { @@ -9881,9 +10297,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); } @@ -10136,26 +10559,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; } @@ -10523,6 +10938,7 @@ public sealed class GameWindow : IDisposable state: _worldState, nearRadius: _nearRadius, farRadius: _farRadius, + clearPendingLoads: _streamer.ClearPendingLoads, removeTerrain: id => { if (_lightingSink is not null && @@ -11771,6 +12187,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/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/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/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index ba1cb700..38f263b8 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; } @@ -451,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(); @@ -503,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 @@ -633,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 f42941be..4993f5c1 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -27,6 +27,16 @@ 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(); + + // #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 @@ -54,7 +64,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 @@ -65,6 +77,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); @@ -76,15 +108,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, @@ -213,6 +261,133 @@ 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, + ClipFrameAssembly clipAssembly, + 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); + + _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); + + // 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)); + } + } + } + } + private void DrawLandscapeThroughOutsideView( RetailPViewDrawContext ctx, ClipFrameAssembly clipAssembly, @@ -222,6 +397,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) { @@ -243,21 +430,74 @@ 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). + probeSliceIndex++; + 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, clipAssembly, 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); + 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) { 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.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch)); + 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. 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(); + // 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 @@ -271,6 +511,33 @@ 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 readonly Dictionary _outStageOwnerVerdicts = new(); + + 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}(s{e.SourceGfxObjOrSetupId: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; @@ -665,6 +932,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 @@ -704,6 +977,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 @@ -711,6 +989,13 @@ 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; } + + /// #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; } } @@ -727,6 +1012,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, 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.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/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 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..d6d00518 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -22,9 +22,24 @@ 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; + + // 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. @@ -71,13 +86,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 +114,76 @@ 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 (_collapsed) + { + // 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(); + } + else if (insideDungeon) + { + EnterDungeonCollapse(observerCx, observerCy, centerId); + } + else + { + NormalTick(observerCx, observerCy); + } + + 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. + /// + private void NormalTick(int observerCx, int observerCy) { if (_region is null) { @@ -116,9 +202,88 @@ 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; + _collapsedCenter = centerId; + _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() + { + // 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 != _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)); + } + + /// + /// 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/src/AcDream.App/World/TeleportArrivalController.cs b/src/AcDream.App/World/TeleportArrivalController.cs new file mode 100644 index 00000000..096f0cce --- /dev/null +++ b/src/AcDream.App/World/TeleportArrivalController.cs @@ -0,0 +1,105 @@ +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) + { + // 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/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/src/AcDream.Core/Lighting/LightInfoLoader.cs b/src/AcDream.Core/Lighting/LightInfoLoader.cs index 63a250f4..671da599 100644 --- a/src/AcDream.Core/Lighting/LightInfoLoader.cs +++ b/src/AcDream.Core/Lighting/LightInfoLoader.cs @@ -79,7 +79,15 @@ public static class LightInfoLoader (info.Color?.Green ?? 255) / 255f, (info.Color?.Blue ?? 255) / 255f), Intensity = info.Intensity, - Range = info.Falloff, + // 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 a9ba8dfc..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). /// /// /// @@ -37,7 +39,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]; @@ -94,45 +95,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(); - - float rangeSq = light.Range * light.Range * RangeSlack * RangeSlack; - if (light.DistSq > rangeSq) continue; - 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; } } 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/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/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/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/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/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 9c02119b..872285e4 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 @@ -229,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 @@ -322,6 +364,93 @@ 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, 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). + /// 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). + /// 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, + AcDream.Core.Lighting.LightManager? lights = null) + { + 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)); + + // #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 = Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1"; 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); 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"); + } +} 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"); + } } 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")); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs b/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs new file mode 100644 index 00000000..2f779822 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs @@ -0,0 +1,435 @@ +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]; + // 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; + 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, + buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); + 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}")); + } + + /// 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(); + 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/Issue131SetupProbeTests.cs b/tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs new file mode 100644 index 00000000..0c60c71a --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs @@ -0,0 +1,112 @@ +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; + + /// #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() + { + 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()); + } + } + } +} 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."); + } +} 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); + } +} 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.App.Tests/World/TeleportArrivalControllerTests.cs b/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs new file mode 100644 index 00000000..54a23f2f --- /dev/null +++ b/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs @@ -0,0 +1,147 @@ +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); + } + + [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); + } +} 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}"); + } + } +} 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); + } +} 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); diff --git a/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs index c3884a66..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(8f, light.Range); + 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); 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)); + } } 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(); 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)"); + } +} 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(), + }; + } +} 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/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs new file mode 100644 index 00000000..522a4d07 --- /dev/null +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs @@ -0,0 +1,257 @@ +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 Collapsed_CurrCellFlickersToAdjacentOffByOne_DoesNotExpand() + { + // 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 onto the dungeon (0,7) + h.Loads.Clear(); + h.Unloads.Clear(); + + h.Ctrl.Tick(0, 6, insideDungeon: false); // flicker → adjacent off-by-one + + Assert.Empty(h.Loads); // NO full-window reload + Assert.Empty(h.Unloads); // dungeon (0,7) preserved; nothing else resident + } + + [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 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() + { + 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); + } +} 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() { 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('