diff --git a/CLAUDE.md b/CLAUDE.md index 508e28d5..58354787 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,18 +108,14 @@ movement queries. ## Current state -**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. +**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. 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 b0f629ae..4938d1ab 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,356 +46,6 @@ 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 @@ -1177,19 +827,7 @@ 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:** 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" +**Status:** OPEN — **explains user-observed "dungeons are broken"** **Severity:** HIGH (blocks all dungeon navigation visually) **Filed:** 2026-05-21 **Component:** rendering, visibility, EnvCell portal traversal @@ -4063,50 +3701,27 @@ Unverified. The likely culprits, ranked by suspected probability: --- -## #108 — Cellar↔main-floor transition: terrain (grass) sweeps across the upstairs door opening — [CLOSED 2026-06-12 · user-gated] +## #108 — Cellar↔main-floor transition: terrain (grass) sweeps across the upstairs door opening — [REOPENED 2026-06-11 · narrowed residual] -**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). +**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. **Severity:** MEDIUM -**Component:** render / terrain (single-sidedness) — membership/viewer EXONERATED +**Component:** ~~render / indoor PView~~ → **physics / membership** (cellar-transition root flip) 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 @@ -4292,47 +3907,13 @@ 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 (narrowed) — one Ghidra-confirmed faithfulness fix -SHIPPED 2026-06-12; both reported shapes still need a runtime trace. +**Status:** OPEN **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):** @@ -4364,51 +3945,6 @@ 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"] @@ -4768,56 +4304,35 @@ 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:** CLOSED (user-gated 2026-06-12 evening: "124, that one is solved") +**Status:** OPEN **Severity:** MEDIUM -**Filed:** 2026-06-11 (re-gate; pre-existing — "still have that issue"; -user 2026-06-12: "especially visible when I look out through a door -opening when inside a building") +**Filed:** 2026-06-11 (re-gate; pre-existing — "still have that issue") **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. 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. +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. --- ## #125 — GL InvalidOperation during staged texture upload: failed uploads are STICKY (never retried) + uncaught crash in GenerateMipmaps -**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). +**Status:** ROOT CAUSE FIXED 2026-06-11 (`fcade06`, live-verified) — +remaining: the sticky-drop design debt (below). **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 @@ -4832,28 +4347,11 @@ 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 — 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. +**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. **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) @@ -4919,21 +4417,8 @@ 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:** 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. +**Status:** OPEN — HIGH (the live mechanism behind the tower roof/edge +flap; almost certainly #123 and related flap reports) **Filed:** 2026-06-11 (tower capture run) **Component:** render — BuildFromExterior seed admission / per-building flood stability @@ -4992,213 +4477,73 @@ staircase entity's per-frame draw decision. ## #129 — Doors/doorways leak through terrain and houses from over a landblock away -**Status:** FIX SHIPPED — awaiting user visual gate +**Status:** OPEN **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, AD-18) +**Component:** render — aperture depth punch at distance (#117 family) **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. -**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". +**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. -**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. +**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). --- ## #130 — Background-color strip along the TOP outer edge of a doorway when looking out from inside -**Status:** FIX 2 SHIPPED — awaiting user visual re-gate +**Status:** OPEN **Severity:** LOW-MEDIUM (small strip, but on the most-stared-at pixels in the game) -**Filed:** 2026-06-12 (user report, post-#119-close session) -**Component:** render — drawn-shell lift vs draw-space portal consumers (AP-32) +**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) **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. Survived the scissor fix (`6c4b6d6`) -— user screenshot 2026-06-12 evening, "very subtle". +of the TOP of the doorway opening. -**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. +**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. -**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. +**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. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 8a9ddd3c..91bde7ea 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | -| AD-2 | Async spawn gates replacing retail's synchronous cell load. **#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-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-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) | @@ -79,7 +79,7 @@ accepted-divergence entries (#96, #49, #50). | AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 | | AD-16 | Building-flood gate is a CPU frustum test on each building's `PortalBounds` AABB; retail floods exactly when the shell draws and an aperture survives (no bounds constant anywhere) | `src/AcDream.App/Rendering/GameWindow.cs:7634` | Documented as the tight equivalent of the shell viewconeCheck for flood purposes (the FPS fix the Chebyshev≤1 hack approximated); per-portal admission still goes through BuildFromExterior's screen clip; missing-bounds buildings always flood (safe over-include) | A too-small/stale PortalBounds AABB means the interior never floods — doorway shows a hole/black aperture from outside (inverse of the vanishing-staircase class) | `DrawBuilding` 0x0059f2a0; `BSPPORTAL::portal_draw_portals_only` 0x53d870 | | AD-17 | ≤8 GPU `gl_ClipDistance` half-planes per view region, degrading to a union-AABB scissor (over-include) on multi-polygon / >8-edge views; particles always scissor; scissor slices disable per-object viewcone culling. Retail CPU-clips against the exact portal polygon | `src/AcDream.App/Rendering/ClipPlaneSet.cs:23` | GL guarantees only 8 simultaneous clip planes; invariant documented: over-inclusion is safe, under-inclusion is the bug class | Fallback on complex multi-aperture views draws terrain/sky/particles/objects outside the true aperture but inside its AABB — background/interior bleed strips at doorways (the **#130** family) | `ACRender::polyClipFinish` decomp:702749; PView portal_view slices | -| AD-18 | Aperture far-Z punch is two-pass stencil-gated with 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-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-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 | @@ -92,7 +92,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 36 rows +## 3. Documented approximation (AP) — 31 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -111,7 +111,7 @@ accepted-divergence entries (#96, #49, #50). | AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager | | AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) | | AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs | -| AP-16 | Global nearest-8 viewer-distance light selection (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-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-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 | @@ -127,11 +127,6 @@ accepted-divergence entries (#96, #49, #50). | AP-29 | Target-indicator fallback for entities with no baked selection sphere: invented 1.5 m × scale box + 16/12 px screen floors (primary path is a faithful `GetObjectBoundingBox` port) | `src/AcDream.App/UI/TargetIndicatorPanel.cs:86` | Fallback only fires when the Setup didn't bake a selection sphere — rare in practice | Sphere-less entities get a non-retail indicator size/placement; the pixel floors prevent retail's far-distance collapse | `SmartBox::GetObjectBoundingBox` 0x00452e20; `GetSelectionSphere` | | AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 | | AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter | -| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | -| 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 | --- @@ -172,7 +167,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 5. Unclear (UN) — 5 rows +## 5. Unclear (UN) — 6 rows These rows have a missing, contradictory, or never-argued justification. They are the highest-priority audits: each needs either a recorded @@ -181,6 +176,7 @@ 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) | @@ -196,19 +192,20 @@ 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. **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. +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. **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 fdbb5ef3..77401952 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -2,13 +2,7 @@ **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.** 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.) +**Currently working toward:** **M1.5 — Indoor world feels right.** --- @@ -191,56 +185,7 @@ close range and the player sees "You pick up the X." in chat. --- -### 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) +### M1.5 — "Indoor world feels right" — 🔵 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 @@ -348,23 +293,13 @@ unblocks that). --- -### M2 — "Kill a drudge" — ⏸ DEFERRED until M1.5 lands (incl. dungeons) +### M2 — "Kill a drudge" — ⏸ DEFERRED until M1.5 lands (was: NEXT) **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 deleted file mode 100644 index 782aab65..00000000 --- a/docs/research/2026-06-12-night-session-handoff-108-residual-next.md +++ /dev/null @@ -1,95 +0,0 @@ -# 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 deleted file mode 100644 index 20f818dc..00000000 --- a/docs/research/2026-06-13-dungeon-g3-handoff.md +++ /dev/null @@ -1,205 +0,0 @@ -# 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 deleted file mode 100644 index 4391fca3..00000000 --- a/docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md +++ /dev/null @@ -1,633 +0,0 @@ -# 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 deleted file mode 100644 index 95129126..00000000 --- a/docs/superpowers/specs/2026-06-13-dungeon-support-design.md +++ /dev/null @@ -1,455 +0,0 @@ -# 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 8f27733a..59f0f83c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1007,36 +1007,21 @@ 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: () => - { - 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; - }, + 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)), enterPlayerMode: EnterPlayerModeFromAutoEntry); } @@ -1929,7 +1914,6 @@ 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 @@ -2452,57 +2436,6 @@ 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, @@ -4516,18 +4449,10 @@ public sealed class GameWindow : IDisposable private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) { - // 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; + // 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; if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; @@ -4949,7 +4874,7 @@ public sealed class GameWindow : IDisposable entity.Rotation = rmState.Body.Orientation; } - // Phase B.3 / G.3a (#133): portal-space arrival detection. + // Phase B.3: 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 @@ -4963,127 +4888,79 @@ public sealed class GameWindow : IDisposable 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) + // #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). { - // 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); + Console.WriteLine( + $"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " + + $"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}"); - // #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); + 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()); } - 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. @@ -5095,7 +4972,6 @@ 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})"); } @@ -5218,11 +5094,6 @@ 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) @@ -5722,56 +5593,22 @@ 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). @@ -5784,6 +5621,25 @@ public sealed class GameWindow : IDisposable cellWorldPosition: cellOrigin, cellRotation: envCell.Position.Orientation, staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>()); + + // Step 4: build LoadedCell for portal visibility — with the + // PHYSICS (unlifted) transform. The +0.02 m render lift above + // is a DRAW concern (shell z-fighting vs terrain); feeding it + // into the visibility graph shifted every HORIZONTAL portal + // plane 2 cm up, putting an eye standing on a deck/landing + // 10–20 mm BELOW the lifted plane — outside the side test's + // ±10 mm in-plane window — so the cell behind the portal was + // side-culled: the tower-top staircase vanish + roof flap + // (#119-residual; captured live at eye z=126.803 vs the + // 010A→0107 plane at 126.80, reproduced ONLY with the lift in + // TowerAscentReplayTests.CapturedTopOfStairs_*). Vertical + // doorways were immune (the lift slides their planes along + // themselves), which is why this hit exactly stairs, decks, + // and cellar mouths. + BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); + + // Cache CellStruct physics BSP for indoor collision (UNCHANGED). + _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); } } } @@ -5800,17 +5656,6 @@ 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) @@ -5844,12 +5689,6 @@ 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) { @@ -6938,27 +6777,7 @@ public sealed class GameWindow : IDisposable int observerCx = _liveCenterX; int observerCy = _liveCenterY; - 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) + if (_playerMode && _playerController is not null) { // Player mode: follow the physics-resolved player position. // The player walks via the local physics engine; the server @@ -6970,28 +6789,12 @@ 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) + && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld + && _lastLivePlayerLandblockId is { } lid) { - // 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). + // Live mode (fly camera): follow the server's last-known player position. + observerCx = (int)((lid >> 24) & 0xFFu); + observerCy = (int)((lid >> 16) & 0xFFu); } else { @@ -7005,37 +6808,7 @@ public sealed class GameWindow : IDisposable observerCy = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f); } - // 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); + _streamingController.Tick(observerCx, observerCy); // Re-inject persistent entities rescued from unloaded landblocks // into the current center landblock (the one the observer is in). @@ -7055,12 +6828,6 @@ 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. @@ -7199,24 +6966,10 @@ 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. - 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); - } + 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); _worldState.RelocateEntity(pe, currentLb); } @@ -7792,25 +7545,6 @@ 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) @@ -7890,9 +7624,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; - _outdoorNodeBuildingCells.Clear(); - if (viewerRoot is not null || viewerCellId != 0u) + if (viewerRoot is 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 @@ -7907,12 +7641,6 @@ 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()) @@ -7927,11 +7655,10 @@ public sealed class GameWindow : IDisposable _outdoorNodeBuildingCells.Add(bc); } } - if (viewerRoot is null) - _outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId); + _outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId); if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) Console.WriteLine(System.FormattableString.Invariant( - $"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)")); + $"[outdoor-node] cell=0x{viewerCellId:X8} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)")); } uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u; @@ -8057,10 +7784,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). - // #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, + // 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, ViewerEyePos = viewerEyePos, ViewProjection = envCellViewProj, CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, @@ -8086,22 +7813,6 @@ 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 @@ -8122,26 +7833,6 @@ 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 => @@ -8262,26 +7953,20 @@ 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 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). + // 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. sigSceneParticles = sigSceneParticles == "none" ? "unattached" : sigSceneParticles + "+unattached"; _particleRenderer.Draw( _particleSystem, camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, - emitter => emitter.AttachedObjectId == 0 - || _outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)); + emitter => emitter.AttachedObjectId == 0); } // Bug A fix (post-#26 worktree, 2026-04-26): weather sky @@ -9933,113 +9618,12 @@ public sealed class GameWindow : IDisposable animatedEntityIds: animatedIds); } - // #131/#132: scene particles + weather MOVED to the LATE phase - // (DrawRetailPViewLandscapeSliceLate) — they must composite AFTER the - // #124 look-ins (retail's FlushAlphaList deferral, DrawCells - // pc:432722); drawn here they were overpainted by far-building - // interiors wherever a look-in aperture sat behind them. - - if (scissor) - _gl!.Disable(EnableCap.ScissorTest); - - DisableClipDistances(); - } - - // #131/#132: the LATE landscape phase — per slice, invoked by the renderer - // AFTER the #124 look-in sub-pass, still pre-clear. Outside-stage - // dynamics' meshes (a translucent portal swirl blends over a far interior - // instead of being overpainted by it — translucents write no depth to - // protect themselves) + ALL attached scene particles (statics' flames - // included — the #132 candle) + weather. Retail equivalent: alpha draws - // collected during LScape::draw flush ONCE after it - // (D3DPolyRender::FlushAlphaList, PView::DrawCells pc:432722). - private void DrawRetailPViewLandscapeSliceLate( - AcDream.App.Rendering.RetailPViewLandscapeLateSliceContext lateCtx, - ICamera camera, - FrustumPlanes? frustum, - System.Numerics.Vector3 camPos, - uint? playerLb, - HashSet? animatedIds, - bool renderSky, - bool renderWeather, - AcDream.Core.World.SkyKeyframe kf, - bool environOverrideActive, - 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) + foreach (var entity in sliceCtx.OutdoorEntities) _outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity)); - // #131 [outstage-pt] probe: the slice Scene-particle id set + how many - // live emitters the filter would actually match, 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 + DisableClipDistances(); + if (_outdoorSceneParticleEntityIds.Count > 0 && _particleSystem is not null && _particleRenderer is not null) { @@ -10115,16 +9699,9 @@ 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); } @@ -10377,18 +9954,26 @@ 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). 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. + // 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). private bool BeginDoorwayScissor(bool apply, System.Numerics.Vector4 ndcAabb) { if (!apply || _window is null) return false; var fb = _window.FramebufferSize; - var box = NdcScissorRect.ToPixels(ndcAabb, fb.X, fb.Y); + // 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); _gl!.Enable(EnableCap.ScissorTest); - _gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height); + _gl.Scissor(px, py, (uint)System.Math.Max(1, pw), (uint)System.Math.Max(1, ph)); return true; } @@ -10756,7 +10341,6 @@ public sealed class GameWindow : IDisposable state: _worldState, nearRadius: _nearRadius, farRadius: _farRadius, - clearPendingLoads: _streamer.ClearPendingLoads, removeTerrain: id => { if (_lightingSink is not null && @@ -12005,35 +11589,6 @@ 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 deleted file mode 100644 index f26eb0c6..00000000 --- a/src/AcDream.App/Rendering/NdcScissorRect.cs +++ /dev/null @@ -1,45 +0,0 @@ -// 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 4f0a6436..969396b8 100644 --- a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs +++ b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs @@ -52,8 +52,7 @@ 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 uDepthBiasEyeCapN; // eye-span cap x near plane (#129; see MarkBiasNdc) +uniform float uDepthBias; // NDC bias toward the viewer (mark pass only) out float gl_ClipDistance[8]; void main() { @@ -63,14 +62,7 @@ void main() if (uForceFarZ == 1) clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail) else if (uDepthBias > 0.0) - { - // #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; - } + clipPos.z -= uDepthBias * clipPos.w; // #117 mark-pass bias (see DrawDepthFan) gl_Position = clipPos; }"; @@ -87,7 +79,6 @@ 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]; @@ -113,7 +104,6 @@ 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(); @@ -154,37 +144,10 @@ 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 @@ -274,8 +237,6 @@ 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 38f263b8..ba1cb700 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -97,31 +97,16 @@ 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, - float drawLiftZ = 0f) + Func? buildingMembership = null) { var frame = new PortalVisibilityFrame(); if (cameraCell == null) return frame; @@ -333,22 +318,8 @@ 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. - // 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}"); + AddRegion(frame.OutsideView, clippedRegion); + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts}"); continue; } @@ -480,18 +451,12 @@ 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, - IReadOnlyList? seedRegion = null) + float maxSeedDistance = float.PositiveInfinity) { var frame = new PortalVisibilityFrame(); var todo = new CellTodoList(); @@ -538,7 +503,7 @@ public static class PortalVisibilityBuilder poly, cell.WorldTransform, viewProj, - seedRegion ?? FullScreenRegion, + FullScreenRegion, out _); // T2 (BR-4): empty clip = no seed, no exceptions (retail's @@ -668,9 +633,8 @@ public static class PortalVisibilityBuilder Vector3 cameraPos, Func lookup, Matrix4x4 viewProj, - float maxSeedDistance = float.PositiveInfinity, - IReadOnlyList? seedRegion = null) - => BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance, seedRegion); + float maxSeedDistance = float.PositiveInfinity) + => BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance); // 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 4993f5c1..f42941be 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -27,16 +27,6 @@ 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 @@ -64,9 +54,7 @@ public sealed class RetailPViewRenderer ctx.RootCell, ctx.ViewerEyePos, ctx.CellLookup, - ctx.ViewProjection, - buildingMembership: null, - drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); + ctx.ViewProjection); // 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 @@ -77,26 +65,6 @@ 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); @@ -108,31 +76,15 @@ 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: prepareCells, + filter: drawableCells, centerLbX: ctx.RenderCenterLbX, centerLbY: ctx.RenderCenterLbY, renderRadius: ctx.RenderRadius); - var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries); + var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries); var result = new RetailPViewFrameResult { PortalFrame = pvFrame, @@ -261,133 +213,6 @@ 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, @@ -397,18 +222,6 @@ 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) { @@ -430,74 +243,21 @@ public sealed class RetailPViewRenderer if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) _outdoorStaticScratch.Add(e); } - 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")}")); - } - } + // #118: outside-stage dynamics ride the landscape pass like retail's + // per-landcell DrawSortCell (DrawBlock 0x005a17c0, pc:430124) — drawn + // BEFORE the depth clear + seals so the seal PROTECTS their pixels in + // the aperture instead of z-killing them. Same per-slice cone test as + // the statics above. Empty under outdoor roots (see DrawInside). foreach (var e in _outsideStageDynamics) { EntitySphere(e, out var c, out float r); if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) - { _outdoorStaticScratch.Add(e); - _lateParticleOwnerScratch.Add(e); - } } - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled) - EmitOutStageProbe(probeSliceIndex, viewcone); probeSliceIndex++; - ctx.DrawLandscapeSliceLate?.Invoke(new RetailPViewLandscapeLateSliceContext( - slice, _outdoorStaticScratch, _lateParticleOwnerScratch)); + ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch)); } - // #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 @@ -511,33 +271,6 @@ 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; @@ -932,12 +665,6 @@ 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 @@ -977,11 +704,6 @@ 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 @@ -989,13 +711,6 @@ 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; } } @@ -1012,14 +727,6 @@ 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 45fe4e7f..7765a46a 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh.frag @@ -46,12 +46,10 @@ layout(std140, binding = 1) uniform SceneLighting { vec4 uCameraAndTime; }; -// 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. +// 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. vec3 accumulateLights(vec3 N, vec3 worldPos) { vec3 lit = uCellAmbient.xyz; int activeLights = int(uCellAmbient.w); @@ -75,9 +73,7 @@ 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)); - // 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); + float atten = 1.0; // retail: no attenuation inside Range 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 040e15b2..bbcc9584 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag @@ -49,15 +49,7 @@ 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)); - // 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); + float atten = 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 f14c98b1..d077a3e8 100644 --- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs @@ -283,27 +283,6 @@ 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( @@ -313,9 +292,6 @@ 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 b9261ad1..d717a934 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -62,24 +62,6 @@ 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; } @@ -234,32 +216,6 @@ 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 8bbdd6bd..af2940ec 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -244,21 +244,10 @@ 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)) { - if (_meshManager.UploadOrRequeue(meshData)) - (requeue ??= new()).Add(meshData); + _meshManager.UploadMeshData(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 050c1265..c5e36815 100644 --- a/src/AcDream.App/Streaming/LandblockStreamJob.cs +++ b/src/AcDream.App/Streaming/LandblockStreamJob.cs @@ -14,16 +14,6 @@ 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 ffaa6de7..19b2a94b 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -141,22 +141,6 @@ 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. @@ -196,18 +180,7 @@ 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; @@ -260,22 +233,6 @@ 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 d6d00518..f0bc0955 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -22,24 +22,9 @@ 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. @@ -86,15 +71,13 @@ public sealed class StreamingController GpuWorldState state, int nearRadius, int farRadius, - Action? removeTerrain = null, - Action? clearPendingLoads = null) + Action? removeTerrain = null) { _enqueueLoad = enqueueLoad; _enqueueUnload = enqueueUnload; _drainCompletions = drainCompletions; _applyTerrain = applyTerrain; _removeTerrain = removeTerrain; - _clearPendingLoads = clearPendingLoads; _state = state; NearRadius = nearRadius; FarRadius = farRadius; @@ -114,76 +97,7 @@ public sealed class StreamingController /// → enqueue full unload /// /// - 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) + public void Tick(int observerCx, int observerCy) { if (_region is null) { @@ -202,88 +116,9 @@ public sealed class StreamingController foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id); foreach (var id in diff.ToUnload) _enqueueUnload(id); } - } - /// - /// 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() - { + // Drain up to N completions per frame so a big diff doesn't spike + // GPU upload time. Remaining completions wait for the next frame. 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 deleted file mode 100644 index 096f0cce..00000000 --- a/src/AcDream.App/World/TeleportArrivalController.cs +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index 1ab52714..00000000 --- a/src/AcDream.Core/Lighting/LightBake.cs +++ /dev/null @@ -1,101 +0,0 @@ -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 671da599..63a250f4 100644 --- a/src/AcDream.Core/Lighting/LightInfoLoader.cs +++ b/src/AcDream.Core/Lighting/LightInfoLoader.cs @@ -79,15 +79,7 @@ public static class LightInfoLoader (info.Color?.Green ?? 255) / 255f, (info.Color?.Blue ?? 255) / 255f), Intensity = info.Intensity, - // 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, + Range = info.Falloff, ConeAngle = info.ConeAngle, OwnerId = ownerId, IsLit = true, diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index 24769c6e..a9ba8dfc 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -11,25 +11,23 @@ namespace AcDream.Core.Lighting; /// §12.2). /// /// -/// Active-light selection algorithm (r13 §12.2), as implemented by -/// : +/// Active-light selection algorithm (r13 §12.2 "Tick" steps): /// /// -/// Reserve slot 0 for the sun (directional, infinite range) when present. +/// Recompute DistSq from viewer to every registered +/// point/spot light. /// /// -/// 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). +/// 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). /// /// -/// 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). /// /// /// @@ -39,6 +37,7 @@ 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]; @@ -95,66 +94,45 @@ public sealed class LightManager /// public void Tick(Vector3 viewerWorldPos) { - // 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. + // 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)); + Array.Clear(_active); _activeCount = 0; - // Slot 0 = sun when present (directional; never ranked by distance). - int baseSlot = 0; + // Slot 0 = sun when present. if (Sun is not null) { _active[0] = Sun; - baseSlot = 1; + _activeCount = 1; } - int maxPoint = MaxActiveLights - baseSlot; - int filled = 0; - if (maxPoint > 0) + int maxPoint = MaxActiveLights - _activeCount; + int pointCount = Math.Min(maxPoint, candidates.Count); + for (int i = 0; i < pointCount; 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; - } - } + _active[_activeCount + i] = candidates[i]; } - - _activeCount = baseSlot + filled; + _activeCount += pointCount; } } diff --git a/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs b/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs index c9c2bedd..c8d38bf7 100644 --- a/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs +++ b/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs @@ -141,53 +141,4 @@ 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 ec1006e1..c82ce2b9 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -935,47 +935,52 @@ public sealed class MotionInterpreter // ── CMotionInterp::get_max_speed (0x00527cb0) ───────────────────────────── /// - /// Return the maximum movement speed in m/s: run rate × RunAnimSpeed (4.0). - /// Mirrors retail CMotionInterp::get_max_speed at 0x00527cb0. + /// Return the run rate. Mirrors retail + /// CMotionInterp::get_max_speed at 0x00527cb0. /// /// - /// 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). + /// 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. /// /// /// - /// 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. + /// 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). /// /// public float GetMaxSpeed() { - // 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; + // 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; return RunAnimSpeed * rate; } diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 7218e016..deec7ed3 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -26,16 +26,8 @@ public sealed class PhysicsDataCache private readonly ConcurrentDictionary _buildings = new(); /// - /// 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. + /// UCG Stage 1: the unified cell graph, built alongside the legacy cell caches. + /// Consumed by nobody this stage (zero behavior change). /// public UcgCellGraph CellGraph { get; } = new(); diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 378afe92..80a76cf8 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -638,23 +638,9 @@ 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), - cellId, + lbPrefix | (cellId & 0xFFFFu), IsOnGround: true); } } diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 4e6d4b6e..34dc4139 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -3095,26 +3095,14 @@ public sealed class Transition Vector3 direction = Vector3.Cross(collisionNormal, contactPlane.Normal); float dirLenSq = direction.LengthSquared(); - // #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) + if (dirLenSq >= PhysicsGlobals.EpsilonSq) { // 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.EPSILON) + if (offset.LengthSquared() < PhysicsGlobals.EpsilonSq) 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 872285e4..9c02119b 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -109,20 +109,6 @@ 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 @@ -243,34 +229,6 @@ 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 @@ -364,93 +322,6 @@ 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 00b19ce9..fb6269fd 100644 --- a/src/AcDream.Core/World/Cells/CellGraph.cs +++ b/src/AcDream.Core/World/Cells/CellGraph.cs @@ -6,26 +6,17 @@ using AcDream.Core.Physics; // TerrainSurface namespace AcDream.Core.World.Cells; /// -/// 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. +/// 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. /// public sealed class CellGraph { private readonly ConcurrentDictionary _envCells = new(); private readonly ConcurrentDictionary _terrain = new(); - /// 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). + /// Player's current cell. Defined for Stage 2; INERT in Stage 1 (no writer). 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 deleted file mode 100644 index 75a964b1..00000000 --- a/tests/AcDream.App.Tests/Rendering/Issue124LookInSeedRegionTests.cs +++ /dev/null @@ -1,153 +0,0 @@ -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 72bd3385..581b5a52 100644 --- a/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs @@ -218,133 +218,4 @@ 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 deleted file mode 100644 index 8686c1c0..00000000 --- a/tests/AcDream.App.Tests/Rendering/Issue129PunchBiasTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index 2f779822..00000000 --- a/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs +++ /dev/null @@ -1,435 +0,0 @@ -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 deleted file mode 100644 index 0c60c71a..00000000 --- a/tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index 5e5f1228..00000000 --- a/tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs +++ /dev/null @@ -1,198 +0,0 @@ -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 deleted file mode 100644 index 2dc084ff..00000000 --- a/tests/AcDream.App.Tests/Rendering/NdcScissorRectTests.cs +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index 3d0d3cd0..00000000 --- a/tests/AcDream.App.Tests/Rendering/TerrainCullOrientationTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index 54a23f2f..00000000 --- a/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs +++ /dev/null @@ -1,147 +0,0 @@ -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 deleted file mode 100644 index e1fe5a96..00000000 --- a/tests/AcDream.Core.Tests/Conformance/DungeonLandblockDatProbeTests.cs +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index be082b37..00000000 --- a/tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs +++ /dev/null @@ -1,109 +0,0 @@ -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 1bb225a2..9df68a2b 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs @@ -60,29 +60,21 @@ public sealed class LightManagerTests } [Fact] - public void Tick_SelectsByDistance_RegardlessOfViewerRange() + public void Tick_DropsLightsOutsideRangeWithSlack() { - // 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)); // viewer outside the torch's range + mgr.Register(MakePoint(new Vector3(20, 0, 0), range: 5f)); // far outside its own range mgr.Tick(viewerWorldPos: Vector3.Zero); - Assert.Equal(1, mgr.ActiveCount); // selected by distance; the shader culls per-surface + Assert.Equal(0, mgr.ActiveCount); } [Fact] - public void Tick_IncludesNearbyLight() + public void Tick_IncludesLightsNearRangeEdge_WithSlack() { var mgr = new LightManager(); - // A nearby point light is selected (distance-only candidacy; the shader - // applies the per-fragment range cutoff). + // Light at distance 5.0, range 5.0: distSq=25, rangeSq*1.1^2 = 25*1.21 = 30.25 → included. 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 676155bf..c3884a66 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(10.4f, light.Range, 3); // Falloff 8 × static_light_factor 1.3 (calc_point_light 0x00820e24) + Assert.Equal(8f, light.Range); 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 887bd340..54dc9c28 100644 --- a/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs +++ b/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs @@ -179,89 +179,4 @@ 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 560db1d6..111316b8 100644 --- a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs @@ -546,17 +546,14 @@ 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 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.")] + [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.")] 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 deleted file mode 100644 index abd691a8..00000000 --- a/tests/AcDream.Core.Tests/Physics/Issue108CellarAscentViewerReplayTests.cs +++ /dev/null @@ -1,344 +0,0 @@ -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 deleted file mode 100644 index e429f100..00000000 --- a/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -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 e2c4f896..251b0570 100644 --- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs +++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs @@ -845,14 +845,15 @@ public sealed class MotionInterpreterTests [InlineData(MotionCommand.RunForward)] public void GetMaxSpeed_IgnoresForwardCommand_AlwaysReturnsRunRate(uint command) { - // 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); + // 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; interp.InterpretedState.ForwardCommand = command; float speed = interp.GetMaxSpeed(); @@ -861,33 +862,17 @@ public sealed class MotionInterpreterTests } [Fact] - public void GetMaxSpeed_NoWeenie_ReturnsLiteralOneTimesRunAnimSpeed() + public void GetMaxSpeed_RunForward_NoWeenie_FallsBackToMyRunRate() { - // 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. + // 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. 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 deleted file mode 100644 index 522a4d07..00000000 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs +++ /dev/null @@ -1,257 +0,0 @@ -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 efdce837..ee123aee 100644 --- a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs +++ b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs @@ -169,41 +169,6 @@ 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 deleted file mode 100644 index d5b6eb8c..00000000 --- a/tools/verify_un2_fmul.py +++ /dev/null @@ -1,40 +0,0 @@ -# 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('