merge: bring main into claude/hopeful-maxwell-214a12 (LayoutDesc importer branch)

main was 65 commits ahead of this branch's fork point. Only conflict was the
divergence register: both sides appended an 'AP-32' row. Resolved by keeping
main's AP-32..AP-36 (cell-shell lift, look-in cells, alpha deferral, dungeon
streaming, point lights) and renumbering the importer's row to AP-37; AP header
count -> 37. GameWindow.cs auto-merged cleanly. Verified: AcDream.App builds
0/0; AcDream.App.Tests 354 passed / 1 skipped / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-15 16:19:15 +02:00
commit 5ac9d8c19c
53 changed files with 6691 additions and 439 deletions

View file

@ -108,14 +108,18 @@ movement queries.
## Current state
**Currently working toward: M1.5 — Indoor world feels right**
(M1 — Walkable + clickable world — landed 2026-05-16 via Phase B.6).
The holistic building-render port (Option A: ONE `DrawInside(viewer_cell)`,
no inside/outside branch; BR-2..BR-7/T1..T6) is SHIPPED and user-gated,
as are the 2026-06-12 closes: #119/#128 tower stairs, #112 cottage
transparency. Open render/physics ledger: #113 re-check, #124, #129,
#130, #108-residual, #116, #127 (leads in ISSUES.md). Keep this
paragraph ≤5 lines + pointers — detail lives in the docs below, NOT here.
**Currently working toward: M1.5 — Indoor world feels right.** Dungeons RENDER +
are navigable; **login into a dungeon** now loads + places the player and is
**FPS-steady from the start** (#135 pre-collapse + indoor cell-floor spawn gate,
`712f17f`+`2c92375`). The dungeon **"red cone"** was an editor-only placement marker
acdream inherited from WB (retail hides it via distance degrade) — FIXED (#136 `6f81e2c`).
REMAINING for M1.5: **A7 dungeon lighting** (LightBake Core landed `3b93f91`; per-vertex
bake integration + the per-pixel torch OVER-blow still open — #79/#93); **#137 dungeon
collision** (doors / wall openings); **#138 teleport-OUT of a dungeon** loads the outdoor
world incompletely + position desync (the collapse→EXPAND gap — same machinery as #135).
M2 (CombatMath) deferred. Detail in ISSUES (#135#138) + the render/physics digests.
Recent closes (2026-06-14): #135, #136. Keep this paragraph ≤6 lines + pointers — detail
in the docs below, NOT here.
For canonical state, read in this order:
- [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone

View file

@ -46,6 +46,356 @@ Copy this block when adding a new issue:
---
## #138 — Teleport OUT of a dungeon loads the outdoor world incompletely + position desync
**Status:** OPEN
**Severity:** MEDIUM (breaks the dungeon→outdoor transition; collision + visuals wrong after exit)
**Filed:** 2026-06-14
**Component:** streaming — dungeon collapse↔expand (the #133/#135 collapse) + teleport-arrival
**Description (user):** taking a portal OUT of a dungeon to the outdoor world often loads
the world incompletely — **fewer objects than expected (e.g. missing trees/scenery)**, and
**collision doesn't work properly**. There's also a **position desync**: "it's like I'm not
moving while my character is moving" (the avatar animates/advances but the player's
actual position / camera doesn't track, or vice-versa).
**Root cause / status (hypothesis — needs investigation):** very likely a gap in the
dungeon-streaming **collapse→expand** introduced for #133/#135. Inside a dungeon, streaming
is COLLAPSED to the single dungeon landblock (radius-0). On teleport OUT,
`StreamingController.ExitDungeonExpand` must rebuild the full 25×25 outdoor window at the new
center. Suspects: (a) the expand doesn't fully re-enqueue / re-hydrate the outdoor landblocks
(→ missing trees/scenery + no collision because shadow-object registration never ran for the
un-hydrated blocks); (b) the teleport-arrival recenter (`OnLivePositionUpdated`) +
`PreCollapseToDungeon`/observer interaction leaves the streaming observer pinned wrong after
exit; (c) the position desync = the player controller / streaming observer disagree on the
post-exit world position (the avatar moves in one frame, the streaming/camera in another).
Pairs with #135 (`712f17f`/`2c92375`) — same collapse machinery; the EXIT path is the gap.
**Files:** `src/AcDream.App/Streaming/StreamingController.cs` (`ExitDungeonExpand`, the
collapse/expand hysteresis), `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated`
teleport recenter ~4912, the streaming Tick gate ~6890, the PortalSpace observer branch),
`TeleportArrivalController`. Cross-check the post-exit shadow-object/collision registration.
**Acceptance:** portal out of the 0x0007 dungeon → full outdoor world streams (trees/scenery
present), collision works, and the player position tracks correctly (no avatar-vs-camera desync).
---
## #137 — Dungeon collision incorrect at doors and wall openings
**Status:** OPEN
**Severity:** MEDIUM (movement/collision correctness in dungeons)
**Filed:** 2026-06-14
**Component:** physics — EnvCell collision (doors, portal openings, cell geometry)
**Description (user):** collision is still wrong in dungeons — **doors** and **openings in
walls** in particular. (Symptoms not fully characterized yet: likely walking through
openings that should block / blocking at openings that should pass, and door collision not
matching the door's open/closed state.)
**Root cause / status (to investigate):** dungeon collision is EnvCell-based — the cell's
collision BSP + portal openings + per-cell static objects (doors). Candidates: door
apparatus collision in EnvCells (open/closed BSP swap) not fully ported; portal-opening
(wall gap) collision geometry handled differently from buildings; the per-cell
shadow-object registration (A6.P4, see the physics digest) for dungeon EnvCell statics.
Related families: #32 (edge-slide), #116 (slide-response), the door-collision saga
(see `feedback_dedup_keys_after_cardinality_change`, `feedback_retail_per_cell_shadow_list`).
Needs a targeted repro (which door / which opening, expected vs actual) before fixing —
oracle-first per the physics digest.
**Files:** `src/AcDream.Core/Physics/` (EnvCell collision, CellTransit, the door apparatus),
`src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (per-cell registration). See
`claude-memory/project_physics_collision_digest.md` (the collision SSOT + DO-NOT-RETRY table).
**Acceptance:** doors block/pass per their open/closed state; wall openings pass; solid walls
block — matching retail, in the 0x0007 dungeon.
---
## #136 — DONE — "red cone" in the 0x0007 dungeon was an editor-only placement marker acdream drew (retail hides it)
**Status:** FIXED `6f81e2c` (2026-06-14) — verified live via frame dump: the red cone +
green floor "petals" are gone, all real dungeon decorations still render. User-approved
frozen-phase fix.
**Severity:** LOW (cosmetic; one marker in one dungeon)
**Filed/Fixed:** 2026-06-14
**Component:** rendering — EnvCell static-object hydration (WB-derived path) vs retail degrade
**Description:** In the `0x0007` Town Network dungeon a bright-RED downward cone (+ a
green/red shape on the floor) rendered ~6 m from the login spawn; the user's side-by-side
retail client showed NOTHING there. Became visible only after the #135 login-into-dungeon
fix placed the player at the exact saved spawn next to it.
**Root cause (definitive):** the cone is ONE dat-hydrated EnvCell static object (`guid=0`,
`id=0x40000835`, Setup `0x02000C39` / GfxObj `0x010028CA`) baked into cell `0x00070145`,
using pure red+green MARKER surfaces (`0x08000109` red, `0x0800010A` green). It is an
**editor-only placement marker**: its `DIDDegrade` table `0x11000118` =
`{slot0 Id=mesh MaxDist=0, slot1 Id=0 MaxDist=FLT_MAX}` — visible ONLY at distance 0 (the
WorldBuilder editor origin) and degraded to GfxObj **id 0 (= nothing)** at any real distance.
Retail's distance-based degrade (`CPhysicsPart::UpdateViewerDistance` 0x0050E030 → `Draw`
0x0050D7A0 draws `gfxobj[deg_level]`) therefore never draws it in the live client. acdream's
render path is extracted from **WorldBuilder**, which — being an editor — renders every cell
static's base mesh directly and has **no degrade handling at all** (zero `DIDDegrade` refs in
`references/WorldBuilder`), so acdream inherited "show the marker" and drew it forever. (NOT
a texture/lighting bug — the cone's *own* object 0x70007055 decodes tan and was a red
herring; the marker is a separate `guid=0` dat static.)
**Fix (`6f81e2c`):** `GfxObjDegradeResolver.IsRuntimeHiddenMarker()` detects the editor-marker
pattern (`HasDIDDegrade` + `Degrades[0].MaxDist==0` + a degrade entry with `Id==0`). EnvCell
static-object hydration (`GameWindow.cs` ~5793) skips such GfxObjs — whole-stab for bare
GfxObj stabs, per-part for Setup stabs (an all-marker Setup then drops via `meshRefs.Count==0`).
Faithful equivalent of retail's runtime degrade for static geometry (always viewed at
distance > 0); real LOD objects (`slot0.MaxDist>0`) and degrade-to-real-mesh objects are
untouched. 4 new `GfxObjDegradeResolver` unit tests.
**Follow-up (not done):** outdoor `LandBlockInfo.Objects` stabs could carry the same markers;
apply `IsRuntimeHiddenMarker` there too if any surface. Also revealed (separate): the per-
pixel point-light shader overblows close torches (no per-channel `min(scale·color,color)` cap
vs retail `calc_point_light`) — the bright-red dungeon WALL under normal lighting; tracked
under the #79/#93 A7 lighting umbrella.
---
## #135 — ~30 s low-FPS ramp at login (≈10 fps → high) before streaming settles
**Status:** DONE `712f17f`+`2c92375` (2026-06-14) — user-verified: login into the 0x0007 dungeon is FPS-steady from the start; dungeon loads + places the player. (NOTE: the teleport-OUT path has a separate streaming gap — see #138.)
**Severity:** LOW (startup-only; self-corrects)
**Filed:** 2026-06-14
**Component:** streaming — first-frame bootstrap vs the dungeon collapse
**FIX (2026-06-14):** pre-collapse streaming the instant we recenter onto a SEALED
dungeon cell at login/teleport, before the first `NormalTick` bootstraps the window.
- `StreamingController.PreCollapseToDungeon(cx,cy)` — fires the existing `EnterDungeonCollapse`
early (idempotent), so the expensive ocean-grid neighbour window is never enqueued
(teleport) / is enqueued-then-immediately-cleared for a cheap Holtburg frame (login).
- `GameWindow.IsSealedDungeonCell(cellId)` — reads the `EnvCell` dat `SeenOutside` flag
(the same flag the hydrated `ObjCell.SeenOutside` + the per-frame gate use) so a cottage/inn
interior keeps its outdoor surround; excludes the 0xFFFE/0xFFFF shell ids.
- Hooks in `OnLiveEntitySpawnedLocked` (login) + `OnLivePositionUpdated` (teleport).
- Observer robustness: during a teleport `PortalSpace` hold the observer follows the
recentered destination (not the frozen position); `_lastLivePlayerLandblockId` is now
filtered to the player guid (resolving a Phase A.1 TODO) so a stray NPC update can't drift
the login-hold observer off the dungeon and trip `ExitDungeonExpand`.
Adversarially reviewed (3 lenses); register row AP-36 amended. Tests in
`StreamingControllerDungeonGateTests` (5 new, incl. the real Tick-then-PreCollapse ordering).
**Description:** On login into a dungeon, FPS starts ~10 and climbs over ~30 s before
settling (then 1000+ fps). User: "we still have about 30ish seconds before FPS is ramped
up; when logging in I get like 10 then it slowly increases."
**Root cause / status:** The #133 streaming collapse (`5686050`/`d9e7dd6`/`7d8da99`) only
engages once CurrCell resolves to a sealed cell (the snap, a few s in). Before that the
first Tick bootstraps the full 25×25 window, so ~24 neighbour ocean-grid dungeons (+ their
~19k entities) load, then unload when the collapse fires. The collapse-at-snap change moved
the trigger from finalize-time (~30 s) toward snap-time but the bootstrap churn remains.
Clean fix = pre-collapse at login when the spawn cell is a sealed dungeon cell so the full
window never enqueues (touches the sensitive login spawn path — do carefully; no band-aid).
**Files:** `GameWindow.cs:6885` (streaming Tick gate); `StreamingController.cs` (collapse);
login recenter `OnLiveEntitySpawnedLocked` ~2470.
**Acceptance:** Login into a dungeon reaches steady-state FPS within ~12 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 1430, 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 1430 from streaming ~129 neighbour ocean-grid dungeons; now ~1000+ fps after the
#133 streaming collapse + the allocation-free 8-light partial-select, `5872bcf`/`5686050`).
**Severity:** HIGH (any far/dungeon teleport is unusable)
**Filed:** 2026-06-13 (M1.5 dungeon-demo gate attempt — meeting-hall portal)
**Component:** physics/streaming — teleport-arrival snap vs async landblock hydration
**Symptom (user):** used the meeting-hall portal to a dungeon; "no
dungeon, just ocean (where the dungeon is placed)." ACE spams `failed
transition for +Acdream from 0x01250126 [30 -60 6.0] to 0xA9B0000E
[-32227 -26748 5.9]` … marching south through `0xA993/0xA97F/…/0xA969`
at Z≈0.9 (underwater) — the server keeps rejecting the client's bogus
outdoor movement.
**Root cause (confirmed against code + the diagnostic log
`launch-dungeon-diag.log`):** ACE correctly placed the player in the
meeting-hall dungeon cell `0x01250126` (landblock `0x0125` = (1,37)). The
acdream teleport-arrival handler (`GameWindow.cs:4877-4960`) DOES recenter
the streaming origin to (1,37) (`_liveCenterX/Y`, :4910-4912), but then
**immediately** calls `_physicsEngine.Resolve(pos=(30,-60,6.005),
cell=0x01250126)` to snap the player (:4928-4931) — BEFORE the dungeon
landblock has streamed in. The physics engine still has only the OLD
Holtburg landblocks resident (A9B4 + neighbours), so `Resolve` can't find
the dungeon cell and falls back to an OUTDOOR scan against the resident
landblocks: local (30,60) maps into A9B3 (the loaded block south of the
A9B4 spawn) → snaps to `0xA9B3000E`, terrainZ=94, indoor=False (the
`[snap]` line). The player is now at Holtburg's south edge; streaming then
shifts the frame out from under them and they slide south into ocean
(the `[cell-transit] A9B3→A9B2→…` chain mirrors ACE's failed-transition
sequence exactly).
**Fix shape (G.3):** on a far/different-landblock teleport, recenter +
HOLD the snap until the destination dungeon landblock/cell hydrates (reuse
the #107 `IsSpawnCellReady` spawn-ready gate, applied to the teleport-
arrival path instead of only login), then place into the indoor cell via
the validated-claim path (#107/#111 `SetPositionInternal` shape). Also
audit the streaming controller actually LOADS the far dungeon landblock on
recenter (the 5×5 Chebyshev window around the new center), and that the
old landblocks unload without stranding the player mid-frame-shift.
**Files:** `GameWindow.cs:4877-4960` (teleport arrival),
`PhysicsEngine.Resolve` (the outdoor fallback), the #107 `IsSpawnCellReady`
gate, `StreamingController` recenter.
**Acceptance:** teleport into the meeting-hall dungeon → the player stands
in the dungeon cell, the dungeon renders (3-5 rooms), walls block, no
ocean / no ACE `failed transition` spam.
**Apparatus:** `ACDREAM_PROBE_CELL=1` ([cell-transit]) + `ACDREAM_PROBE_VIEWER=1`
([viewer]) + `ACDREAM_WB_DIAG=1` + the always-on `[snap]`/`live: teleport`
lines capture the whole chain (`launch-dungeon-diag.log`, this session).
---
## #104 — Scene VFX particles not clipped to the PView visible cell set
**Status:** OPEN
@ -827,7 +1177,19 @@ Retail oracle for cell-id hysteresis: `acclient_2013_pseudo_c.txt:308742-308783`
## #95 — Dungeon portal-graph visibility blowup (see-through-walls / other dungeons rendered)
**Status:** OPEN — **explains user-observed "dungeons are broken"**
**Status:** RESOLVED 2026-06-13 — **the 9.1M-instance blowup was a SYMPTOM of Bug A
(wrong dungeon membership), NOT an unbounded portal flood.** Chain of evidence: (1) a
headless diagnostic on the real `0x0007` dungeon (`Issue95DungeonFloodDiagnosticTests`,
`95d9dab`) measured `PortalVisibilityBuilder` visiting only **117 cells** per root —
already tightly bounded and a strict *subset* of the stab_list (`VisibleCells`, which is
the BIG set: avg 120, max 204 of 205 cells). So porting `grab_visible_cells` stab_list
bounding would have made it WORSE — **DO NOT do that.** (2) The 9.1M blowup was captured at
the G.3a gate *before* Bug A's fix (`2ce5e5c`), when the player's membership wrongly
resolved to `0xA9B3` (Holtburg) → the render rooted at the wrong place. (3) With Bug A +
login-into-dungeon (`47ae237`) fixed, a live launch into `0x0007` measured
**instances=~39,000 (down from 9.1M, ~230×), meshMissing=0**, dungeon renders, no ACE
errors. The flood was never the bug. **Originally** also: explained user-observed
"dungeons are broken"
**Severity:** HIGH (blocks all dungeon navigation visually)
**Filed:** 2026-05-21
**Component:** rendering, visibility, EnvCell portal traversal
@ -3701,27 +4063,50 @@ Unverified. The likely culprits, ranked by suspected probability:
---
## #108 — Cellar↔main-floor transition: terrain (grass) sweeps across the upstairs door opening — [REOPENED 2026-06-11 · narrowed residual]
## #108 — Cellar↔main-floor transition: terrain (grass) sweeps across the upstairs door opening — [CLOSED 2026-06-12 · user-gated]
**Status:** REOPENED (narrowed) — the broad symptom is GONE (T5 +
re-gate #2: "Yes, but…"), but a residual remains in ONE window: during
the cellar ASCENT, while the eye is still below ground level, the
upstairs exit-door opening is covered with grass — "like the ground
level rose to the top of the door … as soon as my head pops up it falls
back to ground level" (user, re-gate 2026-06-11). The original
BR-2-era diagnosis stands: grass-sweep frames render through the
OUTDOOR root (membership/viewer-cell flips outdoor mid-cellar), and the
#117 depth-gated punch then correctly refuses to punch the aperture
where terrain depth is NEARER than the door fan (eye below grade ⇒ the
visible front-facing terrain can sit between the eye and the door in
depth). The punch must STAY depth-gated (DO-NOT-RETRY) — the fix is on
the membership/viewer side (why is the root outdoor while the eye is in
the cellar stairwell below grade?). Apparatus shape: a vertical
cellar-ascent variant of the #118 exit-walk harness (drive the eye up
the stair path; log root resolution + the punch's mark-pass outcome per
step). Prior history below.
**Status:** CLOSED — user visual gate 2026-06-12 ("Yes it is fixed.")
after the terrain-backface-cull fix (`96a425a`). Root cause: terrain
drew double-sided; the grass was the grade sheet's underside seen from
a below-grade cellar eye. Membership/viewer EXONERATED by the vertical
cellar-ascent harness (`007af13`).
**ROOT CAUSE (2026-06-12): terrain was drawn DOUBLE-SIDED — the grass was
the UNDERSIDE of the grade sheet.** Two steps:
1. The membership/viewer re-diagnosis below is **REFUTED** by the vertical
cellar-ascent harness (`Issue108CellarAscentViewerReplayTests`, dat-backed
A9B4 corner-building cellar 0x0174→0x0175→0x0171, production
FindCellList pick + the camera probe chain mirrored verbatim): 0
outdoor/null viewer resolutions while the eye is below grade, 0 sweep
failures, 0 fallback branches across boom distance {2.61, 5} × damping
lag {0, 0.3}. The viewer enters 0x0171 at eye z 94.01 — exactly as the
head pops above grade (the stairwell portal sits at grade), matching the
user's wording. The root is INTERIOR the whole window.
2. Retail terrain is SINGLE-SIDED: `ACRender::landPolysDraw` (0x006b7040)
draws each land triangle ONLY when the camera is on the POSITIVE (upper)
side of its plane (`Plane::which_side2` vs `Render::FrameCurrent`). A
below-grade eye gets NO terrain — through the door retail shows sky.
WB renders the world with face culling DISABLED frame-globally (WB
`GameScene.cs:841` — editor heritage), and `TerrainModernRenderer.Draw`
set no cull state of its own → terrain drew double-sided. From a
below-grade eye every aperture sight-ray RISES, so the only "terrain" it
can see is the underside of the z≈94 grade sheet — which painted the
whole exit-door aperture (the landscape slice's 2D NDC clip planes
`(nx,ny,0,dw)` have no depth axis and cannot exclude it) and slid down
off the door exactly as the eye crossed grade.
**Fix: port the landPolysDraw eye-side gate as terrain backface culling**
`TerrainModernRenderer.Draw` now owns Enable(CullFace) + Cull(Back) +
FrontFace(Ccw) (set→draw→restore; 7th instance of the self-contained-GL-
state rule). Pins: `LandblockMeshTests.Build_AllTriangles_WindCounter-
ClockwiseInWorldXY` (every emitted triangle CCW in world XY — cull-safe
winding) + `TerrainCullOrientationTests` (above-eye ⇒ CCW window winding
kept / below-eye ⇒ CW culled under the production camera convention).
**Gate:** climb out of the corner-building cellar — the grass window over
the exit door must be gone (sky/world through the door instead); plus a
general outdoor sanity glance (terrain intact from above — a wrong
FrontFace would blank it).
**Severity:** MEDIUM
**Component:** ~~render / indoor PView~~**physics / membership** (cellar-transition root flip)
**Component:** render / terrain (single-sidedness) — membership/viewer EXONERATED
During the cellar→main-floor ascent (Holtburg), the door opening visible on the main floor
shows the outdoor GRASS texture sweeping over it — "like outdoor ground rising up from the
@ -3907,13 +4292,47 @@ retail's viewer-distance smoothing (update_viewer region) before touching.
## #116 — Slide-response divergence family: near-perpendicular lateral slide lost + first-airborne-frame in-frame slide vs hard stop
**Status:** OPEN
**Status:** OPEN (narrowed) — one Ghidra-confirmed faithfulness fix
SHIPPED 2026-06-12; both reported shapes still need a runtime trace.
**Severity:** LOW-MEDIUM (over-blocking, never under-blocking — no
walk-throughs; feel-level divergence at walls/doors)
**Filed:** 2026-06-11 (BR-7 / A6.P4 ship session)
**Component:** physics (slide response — `SlideSphere` degenerate-offset
guard + first-contact-frame behavior)
**GHIDRA SESSION 2026-06-12 (the BN branch-sign ambiguity RESOLVED via a
second decompiler — Ghidra MCP, patchmem.gpr, full PDB):**
- **SHIPPED (faithfulness fix):** `CSphere::slide_sphere` (Ghidra
`0x00537440`) compares its SQUARED magnitudes against `::F_EPSILON`
(= 0.000199999995 ≈ 0.0002 = `PhysicsGlobals.EPSILON`): `if (::F_EPSILON
<= |cross|²)` (crease) and `if (|offset|² < ::F_EPSILON) return
COLLIDED_TS` (degenerate guard). Our port compared against `EpsilonSq`
(0.0002² = 4e-8) — a ~5000× too-tight threshold (the BN `test ah,5`
obscured it). Fixed at `TransitionTypes.cs:3098,3105`; full physics
suite (612) + full Core (1443) green, no regression. Crease now needs
≥0.81° between normals (was 0.011°); the guard stops slides under
~1.41 cm like retail (was 0.2 mm). NOT a register deviation (no row
existed — it was an undocumented porting error; the fix matches retail).
⚠️ This does NOT fix either reported shape below.
- **Shape-1 RE-DIAGNOSED — our `cn=UnitZ` default is RETAIL-FAITHFUL.**
Ghidra `validate_transition` (`0x0050aa70`) does exactly our
`TransitionTypes.cs:3701-3702`: `if (collision_normal_valid == 0)
set_collision_normal(UnitZ)`. So the harness `cn=(0,0,1)` is the
faithful FALLBACK; the real divergence is UPSTREAM — at tick-22760 our
`collision_normal_valid` was FALSE (→ UnitZ) where retail's was TRUE
(it had recorded the door-face normal `(0,+1,0)`). The bug is in the
COLLISION-RECORDING path (find_collisions / collide_with_environment),
not slide/validate. Next: replay tick-22760
(`DoorBugTrajectoryReplayTests`) instrumented to see where our
collision-normal recording drops the wall normal.
- **Shape-2 NARROWED — D4 stays skipped.** Ghidra confirms slide_sphere
applies the slide IN-FRAME (`add_offset_to_check_pos` → SLID_TS), so our
Z=1.92 is faithful TO slide_sphere and the D4 Z=2.0 hard-stop pin is the
SUSPECT half. But the threshold fix did NOT change D4 (its offset is a
real slide, not degenerate), so whether retail's first airborne frame
REACHES slide_sphere (→1.92) or hard-stops upstream still needs a cdb
trace of an airborne wall hit before flipping the assertion.
**Two pinned shapes, both pre-dating BR-7 (the per-cell shadow port left
them byte-identical):**
@ -3945,6 +4364,51 @@ them byte-identical):**
fixture as the acceptance pair. Do NOT patch the degenerate-offset guard
ad hoc — the DO-NOT-RETRY table's slide entries (physics digest) apply.
**ORACLE DESK READ DONE (2026-06-12) — needs a LIVE cdb session to
finish.** Both sides quoted + verified against source (our
`CSphere::slide_sphere` port = `TransitionTypes.cs:3054-3133`; retail
`CSphere::slide_sphere` = decomp `0x00537440`, lines 321403-321532).
Three concrete leads, none safely fixable from the static BN decomp:
1. **Shape-1 re-attributed — it is NOT the degenerate-offset guard
threshold.** Retail's guard kills slides under ~1.4 cm (`|offset|² <
0.000199999995` at `0x537735`); the lost tick-22760 slide was 3.57 cm
(`X 0.0357`), well above it — retail would keep it too. The real
divergence is the COLLISION-NORMAL SOURCE: our harness recorded
`cn=(0,0,1)` (ground), live retail `cn=(0,+1,0)` (the door face).
Strong lead: `TransitionTypes.cs:3701-3702` — on a blocked move with
no valid collision normal we DEFAULT `cn = Vector3.UnitZ` ("push up");
that exact (0,0,1) is what the harness sees. Whether retail has an
equivalent default (vs keeping the wall normal) is a runtime question.
2. **Shape-2 — retail's slide_sphere applies the slide IN-FRAME**
(`add_offset_to_check_pos` @`0x53777e`, returns 4=SLID), so our
in-frame slide to Z=1.92 on frame 1 is likely retail-faithful and the
D4 frame-1 hard-stop pin (`BSPStepUpTests.D4_*`, expects Z=2.0) is the
STALE expectation. BUT retail always uses `contact_plane` OR
`last_known_contact_plane` (`0x53755a`); it has no "airborne wall-only,
no plane" third branch like ours (`TransitionTypes.cs:3080-3092`) — the
first-airborne-frame plane state needs a trace before flipping the pin.
3. **Candidate epsilon-squaring divergence (real, but explains neither
shape).** Retail compares SQUARED quantities (`|cross|²` @`0x5375a5`,
`|offset|²` @`0x537735`) against `0.000199999995` (≈0.0002, NON-squared);
our port compares against `EpsilonSq = 0.0002²` (line 3105 + the
`dirLenSq >= EpsilonSq` branch @3098) — potentially ~10⁴× too small.
DO NOT change this without cdb confirmation: the BN `test ah, 0x5`
branch polarity (lines 321466-321467/321484-321485) is the exact
undecodable construct the PosHitsSphere saga warned about, and the
register reuse garbles which quantity is squared. A wrong guess here
regresses ALL wall-slide behavior.
**Next (cdb session, well-scoped):** (a) `cdb -z uf
acclient!CSphere::slide_sphere` OR a live attach to disassemble
`0x00537440` and settle the two `test ah,5` branch signs + the
squared-vs-not threshold (prefer LIVE attach — prior lesson: static
`-z uf` misdecodes at OMAP boundaries); (b) live trace the tick-22760
door push to confirm whether the `cn=(0,0,1)` comes from our
`UnitZ`-default (lead 1) and what retail's normal is at that instant.
---
## #117 — Aperture-shaped see-through: doors/interiors visible through terrain hills and through nearer buildings — [DONE 2026-06-11 · 478c549, user re-gate "Yes solved"]
@ -4304,35 +4768,56 @@ of which draw list the building's shell left.
## #124 — Looking out through an opening: far buildings with openings show missing/transparent back walls
**Status:** OPEN
**Status:** CLOSED (user-gated 2026-06-12 evening: "124, that one is solved")
**Severity:** MEDIUM
**Filed:** 2026-06-11 (re-gate; pre-existing — "still have that issue")
**Filed:** 2026-06-11 (re-gate; pre-existing — "still have that issue";
user 2026-06-12: "especially visible when I look out through a door
opening when inside a building")
**Component:** render — per-building look-in floods under INTERIOR roots
From inside a building, looking out through a door/window at ANOTHER
building that has an opening: the far building's back walls are
missing/transparent (see the world through it). **Lead (by read):** the
per-building look-in floods (`MergeNearbyBuildingFloods`) run ONLY for
outdoor roots — `RetailPViewDrawContext.NearbyBuildingCells` is
documented "Null for interior roots." So under an interior root the far
building's INTERIOR never floods: through its window you see the shell
only, and a shell has no interior back-wall faces → transparent.
Retail runs the building look-in inside `LScape::draw` (DrawBlock →
DrawPortal → ConstructView(CBldPortal)), which executes for ANY root
whose outside view is non-empty — including interior roots looking out
a doorway. Fix shape: provide the nearby-building gather + per-building
floods for interior roots too, with look-in apertures getting PUNCH
semantics (the `forceFarZ` selector currently keys on
`clipRoot.IsOutdoorNode`, which under-punches this case). Needs its own
focused pass — touches the gather, the merge, and the depth-mask
selector.
missing/transparent. The lead confirmed by decomp: retail runs the
look-in INSIDE the landscape stage for ANY root — `LScape::draw` is the
FIRST call of `PView::DrawCells`' outside-view branch (pc:432719),
strictly BEFORE the depth clear (pc:432732) and the seals (pc:432785);
`ConstructView(CBldPortal)`'s GetClip runs under the INSTALLED view
(the doorway region), and all apertures far-Z punch (pass 1) before any
interior cell draws (pass 2).
**Fix (2026-06-12):**
- The per-building gather (frustum pre-gate on `Building.PortalBounds`)
now runs for interior roots too; the root's own doorway self-excludes
via the seed eye-side test.
- `BuildFromExterior` gained `seedRegion` — the port of retail's
installed-view clip: interior-root look-ins seed clipped against the
OutsideView (doorway) polygons, so a building not visible through the
doorway never floods. Outdoor roots keep the full-screen default.
- NEW `DrawBuildingLookIns` sub-pass inside the LANDSCAPE stage (before
the depth clear + seals): per building, punch ALL apertures
(`DrawLookInPortalPunch`, always far-Z), then draw the flooded cells'
shells + statics far→near. NOT merged into the main frame — a merged
cell would draw post-clear and z-fail against the root's seal.
- Look-in cells join the Prepare/partition set (shells get batches,
statics route to ByCell, consumed only by the sub-pass).
Pins: `Issue124LookInSeedRegionTests` (containing region floods ⊆
full-screen flood; disjoint region floods nothing; interior-side eye
never seeds its own exit door). Register: AP-33 (look-in statics drawn
whole — no per-part viewcone; look-in DYNAMICS deferred — an NPC inside
a far building stays invisible; both documented).
**Gate:** from inside a building, look out the door at another building
with an open door/window — its interior/back walls render through its
aperture instead of see-through to the world behind.
---
## #125 — GL InvalidOperation during staged texture upload: failed uploads are STICKY (never retried) + uncaught crash in GenerateMipmaps
**Status:** ROOT CAUSE FIXED 2026-06-11 (`fcade06`, live-verified) —
remaining: the sticky-drop design debt (below).
**Status:** CLOSED 2026-06-12 — the GL root cause was fixed `fcade06`
(2026-06-11, live-verified); the remaining sticky-drop DESIGN DEBT is now
fixed too (bounded upload retry, below). No visual gate (robustness).
**RESOLVED (root cause):** the GL errors were the gpu_us QUERY RING's own
— a glGenQueries name isn't a query object until first glBeginQuery, and
@ -4347,11 +4832,28 @@ slot; read only begun queries. Live-verified in-tower: 0 [wb-error]
time under pview, meshMissing=0. **Normal runs (WB_DIAG off) never had
these errors — this mechanism is RETIRED for #119.**
**Remaining debt (keep open under this number):** UploadMeshData removes
the preparation task BEFORE uploading, so any genuinely-failed upload is
never retried — permanently invisible mesh with one [wb-error] line.
The trigger is gone but the design flaw isn't; add retry/re-prepare
semantics in a maintenance pass.
**Remaining debt — FIXED 2026-06-12 (bounded upload retry):** the exact
stick was the CPU-cache short-circuit, not just the early `TryRemove`: a
failed `UploadMeshData` (catch → null) consumed the staged item and left
`_renderData` empty while the prepared data lingered in `_cpuMeshCache`,
so `PrepareMeshDataAsync`'s cache-hit path (`ObjectMeshManager.cs:448-453`)
returned it WITHOUT re-staging → never re-uploaded until CPU-cache
eviction (effectively session-sticky under low cache pressure). Fix: the
Tick drain (`WbMeshAdapter.cs`) now re-stages a failed upload for the NEXT
frame via `ObjectMeshManager.UploadOrRequeue`, bounded by
`MaxUploadRetries` (3) using a counter on the `ObjectMeshData` object
(resets to 0 on re-prepare). Re-stages are collected and re-enqueued
AFTER the drain loop — never inside it — so a deterministic failure can't
spin the queue in one frame; past the cap it gives up with a loud
`[up-retry] … giving up` line (surfaces a genuine GL defect instead of
the old silent permanent drop). Retail loads synchronously and has no
such failure mode; this converges the async pipeline toward that
guarantee. Build + App.Tests (264) green; no GL-context test seam exists
for the upload path so the retry is verified by construction + the
regression suite. The uncaught `GenerateMipmaps` path (open-question c)
is INTENTIONALLY left to surface errors — adding a blanket catch there
would mask future real defects (no-workarounds rule); its trigger
(`fcade06`) is already retired.
**Filed:** 2026-06-11 (in-tower WB_DIAG launch, `tower-wbdiag3.log` — preserved in the worktree root)
**Component:** render — WB staged texture pipeline (ObjectMeshManager / ManagedGLTextureArray)
@ -4417,8 +4919,21 @@ not raw terrain. Note the snap line even shows a candidate it rejected
## #127 — Per-building flood admissions are BISTABLE per frame under the outdoor root (the building-flap mechanism)
**Status:** OPEN — HIGH (the live mechanism behind the tower roof/edge
flap; almost certainly #123 and related flap reports)
**Status:** CLOSED 2026-06-12 — user re-gate ("Seems to have been
fixed" — ran past distant buildings, no flicker/vanish) + desk
confirmation. The bistable-admission mechanism died with the **W=0
polyClipFinish clip port** (`987313a`, the #119/#120 work that
"kills the knife-edge class everywhere") plus the #120 containment-
rejection growth fix. NOTE the captured-pair evidence in
`tower-viewer-capture.log` predates all of those fixes — it was the
near-eye knife edge, the same class. Pins (both green at HEAD):
`Issue127FloodFlipReplayTests.CapturedFlipPair_AdmissionIsStable`
(the original 4 cm flip pair now |A|=|B|, zero diff, all FOVs, both
pre-gate states) + `DistantBuildingStrafe_NoAdmissionChurn` (the
regression pin: 0 churn across 21 building groups × {10,30,60,120,190} m
× 100 mm-steps run-past strafe, both pre-gate states). DO-NOT-RETRY:
do not re-open the BuildFromExterior seed gates for flap symptoms
without a FRESH repro at HEAD — the captured-pair lead is dead.
**Filed:** 2026-06-11 (tower capture run)
**Component:** render — BuildFromExterior seed admission / per-building
flood stability
@ -4477,73 +4992,213 @@ staircase entity's per-frame draw decision.
## #129 — Doors/doorways leak through terrain and houses from over a landblock away
**Status:** OPEN
**Status:** FIX SHIPPED — awaiting user visual gate
**Severity:** MEDIUM (visible at distance during normal outdoor play)
**Filed:** 2026-06-12 (user report, post-#119-close session)
**Component:** render — aperture depth punch at distance (#117 family)
**Component:** render — aperture depth punch at distance (#117 family, AD-18)
**Symptom (user):** "leakage of like doors and doorways through the
terrain and houses over a landblock" — door/doorway-shaped patches
visible THROUGH intervening terrain and nearer buildings when the
source building is roughly a landblock (~192 m) or more away.
**Leads:**
1. **The #117 stencil depth-gate bias at long range (top suspect).**
#117's fix (`478c549`) marks aperture pixels at biased true depth
(LEQUAL, bias 0.0005 NDC) then far-Z punches only marked pixels. With
a non-linear depth buffer, 0.0005 NDC at ~200 m spans many METERS of
view depth — the bias can exceed the separation between the aperture
and a hill/house in front of it, marking occluder pixels and punching
them → the occluder shows the interior/background behind. The #108
coverage constraint pulls the bias up; distance pulls it wrong —
re-derive the bias in eye-space (or scale by w) instead of constant
NDC.
2. Per-building look-in floods admitting distant buildings (the #127
churn family) — would gate WHICH buildings punch, not the
through-occluder leak itself.
**Root cause (lead 1 confirmed analytically, `Issue129PunchBiasTests`):**
the #117 mark-pass bias was a CONSTANT 0.0005 NDC. NDC depth is
non-linear — a constant NDC bias `b` spans ≈ `b·d²/near` meters of eye
depth at distance `d`. With retail's znear 0.1 that is 0.125 m at 5 m
but **~190 m at a landblock**: every hill/house in front of a distant
aperture passed the LEQUAL mark and was far-Z punched → the door-shaped
leak. Exactly AD-18's recorded "Risk if assumption breaks".
**Next:** capture at the spot (ACDREAM_PROBE_VIEWER=1 + a screenshot +
player/eye position from [snap]/[viewer]); confirm whether the leak
patch matches an aperture polygon of the distant building; then test
the eye-space-bias hypothesis headlessly (the #117 commit has the bias
math).
**Fix (2026-06-12):** cap the bias's EYE-SPACE span —
`biasNdc(d) = min(0.0005, 0.5 m × near / d²)`
(`PortalDepthMaskRenderer.MarkBiasNdc`, mirrored in the vertex shader).
Below the ~10 m crossover the constant term wins, bit-identical to the
T5-validated behavior (#108 grass coverage untouched); beyond it the
punch can never reach an occluder more than 0.5 m in front of the
aperture plane. Pins: `Issue129PunchBiasTests` (old form spans >100 m
at a landblock; capped form ≤0.5 m at all distances; close range
unchanged).
**Gate:** the original spot — distant building doors no longer show
through terrain/houses at ~a landblock; AND the #108 cellar grass-sweep
stays gone up close. If a >10 m-range #108-class residue appears, the
cap constant (0.5 m) is the tuning knob — see AD-18.
---
## #130 — Background-color strip along the TOP outer edge of a doorway when looking out from inside
**Status:** OPEN
**Status:** FIX 2 SHIPPED — awaiting user visual re-gate
**Severity:** LOW-MEDIUM (small strip, but on the most-stared-at pixels in the game)
**Filed:** 2026-06-12 (user report, post-#119-close session; "also NOW" —
possibly new since the W=0 clip port `987313a`)
**Component:** render — doorway aperture edge (seal/punch/OutsideView seam)
**Filed:** 2026-06-12 (user report, post-#119-close session)
**Component:** render — drawn-shell lift vs draw-space portal consumers (AP-32)
**Symptom (user):** standing inside looking out through a doorway, a
thin strip of background (clear/world) color runs along the OUTER edge
of the TOP of the doorway opening.
of the TOP of the doorway opening. Survived the scissor fix (`6c4b6d6`)
— user screenshot 2026-06-12 evening, "very subtle".
**Leads (capture first — plausibly a `987313a` regression):**
1. The W=0 port changed `ProjectToClip` (exact w>=0, no 1e-4 epsilon)
and DELETED the `EyeInsidePortalOpening` rescue — the OutsideView
region through a near doorway is computed slightly differently now.
If the OutsideView's top edge sits ~1 px BELOW the aperture's drawn
shell edge, terrain/outdoor geometry isn't drawn in that strip while
the interior seal/punch still cleared it → background color.
Suspects within the port: `MergeSubPixelVertices` shaving a top
vertex; the exact-w boundary vs the old epsilon shifting the
projected edge; the deleted rescue no longer substituting the full
view for an eye-pressed doorway.
2. The interior SEAL depth vs the shell top edge (the #118-era
machinery) — a 1-px mismatch between the seal polygon and the shell
aperture would show the clear color exactly at an edge.
**Root cause (the REAL strip, pinned by
`Issue130DoorwayStripTests.UnliftedGate_LeavesTheStripAtTheDrawnTopEdge`):
the +0.02 m shell render lift.** Cell shells DRAW 2 cm above the dat
origin (z-fight vs terrain, AP-32); since `f35cb8b` (the #119-residual
fix) the visibility graph deliberately uses the PHYSICS (unlifted)
transform — but the OutsideView color gate and the seal fans, which are
DRAW-space consumers, kept the unlifted polygons. The drawn lintel
therefore sits one lift-projection ABOVE the gate's top edge —
**6.7 px at a 2.4 m doorway** (measured) — and that band gets no
terrain/sky color while the seal also stamps 2 cm low. Regression from
`f35cb8b` (2026-06-11), NOT from the W=0 clip port. Vertical edges are
immune (the lift slides them along themselves) — top edge only, exactly
as reported.
**Next:** screenshot + [viewer]/[pv-dump] capture at a doorway showing
the strip; diff the OutsideView top edge NDC vs the aperture polygon's
projected top edge for that frame (the CornerFloodReplay harness
machinery can replay the frame headlessly once the eye/cell are
captured). If it reproduces at the same doorway with `987313a` reverted
locally, it's the port's edge math; fix the math, never re-add the
rescue.
**Fix 2:** draw-space consumers re-apply the lift —
`PortalVisibilityBuilder.Build(drawLiftZ:)` projects the exit-portal
OutsideView region with the lifted transform (flood admission, side
tests, CellViews stay physics-space per f35cb8b), and the seal/punch
fans lift their world verts. One shared constant
`PortalVisibilityBuilder.ShellDrawLiftZ` now feeds the shell
registration, the gate, and the fans. AP-32 register row added (the
lift had no row). Pins: the lifted gate covers the drawn aperture to
0.00 px across the 147-combo sweep; the unlifted gate shows the 6.7 px
strip (sensitivity).
**Fix 1 (also real, sub-pixel): `6c4b6d6`** — the doorway-slice scissor
`Floor(origin)+Ceiling(size)` cut up to 1 px off the top/right edges;
now a conservative outer bound (`NdcScissorRect`, AD-17 doctrine).
The W=0 clip port `987313a` is exonerated (CPU pipeline sub-pixel exact
in like-for-like space).
**Gate:** stand inside, look out the door with the lintel on screen,
sweep the gaze — no background strip at the top edge at any alignment
or distance.
---
## #131 — Portal swirl invisible when viewed from inside a building through the doorway
**Status:** CLOSED (user-gated 2026-06-12 night: "Ok now it works" — fix 4, `d208002`)
**Severity:** MEDIUM (portals are landmark objects; the through-door view is common)
**Filed:** 2026-06-12 (user report, #124 gate session)
**Component:** render — UNATTACHED emitters have no pass under interior roots
**Symptom (user, axiom):** "the portal swirl is missing, when I look out
from inside a house. Appears when I walk out again."
**Root cause (confirmed by read + the [outstage] capture):** every
particle pass under an interior root is id-FILTERED: the landscape
slice's Scene pass and the cell/dynamics passes all require
`emitter.AttachedObjectId != 0` and membership in an owner set. An
UNATTACHED emitter (`AttachedObjectId == 0` — portal swirls, campfires,
ground effects anchored at a position) therefore draws NOWHERE when the
root is interior. The outdoor root has the dedicated T3 pass for
exactly this class (its own comment: "unattached ones had NO pass on
outdoor-node frames") — the identical hole on interior-root frames was
never plugged. Walk out → the T3 pass picks the swirl up → "appears
when I walk out again". The capture corroborated the rest of the chain
healthy: outside-stage routing + cone PASS for the dynamics, 57
attached emitters matched and drawn through the doorway.
**Fix (2026-06-12):** `DrawUnattachedSceneParticles` — invoked ONCE per
interior-root frame at the end of the landscape stage (pre-clear; drawn
later they would z-fail against the doorway seal), after the #124
look-ins so swirls blend over far interiors, NOT per slice (alpha
particles must not double-draw — the #121 lesson). Mutually exclusive
with the outdoor T3 pass by root kind. Residual (documented): unattached
INDOOR emitters now draw pre-clear and are overpainted by the room's
shells — same invisibility as before this fix; the proper per-emitter
cell classification is a future port.
**Apparatus (kept, env-gated):** `ACDREAM_PROBE_OUTSTAGE=1`
`[outstage]` (per-slice routing + cone verdicts) + `[outstage-pt]`
(slice id set, attached matched count, unattached count).
**FIX 1 INSUFFICIENT (user screenshots, same evening):** the swirl is
the portal's TRANSLUCENT MESH, not (only) unattached particles. The
real mechanism — shared with #132 — is the #124 look-in ordering: the
slice drew the portal mesh (and all scene particles) BEFORE the look-in
sub-pass; translucents write no depth, so the far building's interior
(drawn into its far-Z-punched aperture) overpainted them wherever a
look-in opening sat behind them on screen. Both screenshots show the
swirl exactly in front of the hall's doorway. Retail cannot have this
bug: all landscape-stage alpha draws are deferred into ONE flush after
LScape::draw (`D3DPolyRender::FlushAlphaList`, DrawCells pc:432722).
**FIX 2 (the FlushAlphaList deferral, same commit family as #124):**
the landscape stage is now TWO phases per frame — EARLY per slice: sky,
terrain, outdoor static meshes (the look-in punches need their depth, the
#117 lesson); then the #124 look-ins; then LATE per slice: outside-stage
dynamics' meshes + ALL attached scene particles + weather + the
unattached pass. (This FIXED #132 indoors but not the portal.)
**ROOT CAUSE (fix 4 — structurally forced; fixes 13 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.
---

View file

@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50).
---
## 1. Intentional architecture (IA) — 15 rows
## 1. Intentional architecture (IA) — 14 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -55,7 +55,6 @@ accepted-divergence entries (#96, #49, #50).
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
| IA-15 | D.2b retail UI is our own UiHost/UiElement retained-mode tree drawing an 8-piece dat-sprite window frame (later: XML markup + controls.ini stylesheet), not a byte-port of keystone.dll's LayoutDesc binary tree | `src/AcDream.App/UI/UiNineSlicePanel.cs` + `RetailChromeSprites.cs` + `src/AcDream.App/UI/Layout/LayoutImporter.cs` | keystone.dll has no PDB/decomp so a byte-port is impossible by definition; we mirror retail's ElementDesc field model + controls.ini tokens, and the chrome sprites ARE the real dat RenderSurfaces (Step-0 prove-out 2026-06-14 confirmed 0x06004CC2 center + 0x060074BF..C6 bevel). The 8-piece edge/corner→position mapping is NOW DATA-DRIVEN from the dat: the `LayoutImporter` (gated `ACDREAM_RETAIL_UI_IMPORTER`) reads the real `LayoutDesc` for `0x2100006C` and resolves chrome element positions + sprite ids directly from parsed dat fields; locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800×600 and the controls.ini cascade still lack an oracle — layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) |
---
@ -64,7 +63,7 @@ accepted-divergence entries (#96, #49, #50).
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
| AD-2 | Async spawn gates replacing retail's synchronous cell load: terrain-ready hold (**#106**) + indoor cell-hydration hold (**#107**, `IsSpawnCellReady`); claims beyond NumCells skip the gate (demoted) | `src/AcDream.App/Rendering/GameWindow.cs:1008` (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode | retail synchronous cell load before SetPosition (no gate exists) |
| AD-2 | Async spawn gates replacing retail's synchronous cell load. **#135 refinement:** an INDOOR spawn/teleport (cell ≥ 0x0100, hydratable) gates ONLY on the EnvCell floor (`IsSpawnCellReady`), NOT the terrain heightmap; an OUTDOOR spawn (or an unhydratable indoor claim that demotes outdoor) gates on the terrain-ready hold (**#106**). A dungeon's negative-offset cells can place the spawn's WORLD position in a neighbour terrain landblock the #135 dungeon collapse doesn't load, so a terrain requirement would hang indoor login/teleport forever (cellReady true, terrain null) — the player lands on the cell floor, terrain is irrelevant indoors. Claims beyond NumCells skip the gate (demoted) | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportArrivalReadiness` ~5012) (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge. Indoor-on-cellReady is the faithful equivalent of retail's synchronous cell load + place-on-floor (terrain under a dungeon is meaningless; the pre-#135 terrain hold only passed because the 25×25 window streamed the neighbour terrain) | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode; an indoor spawn whose cell never hydrates now holds on cellReady alone (no terrain backstop) — but that path is exactly the #107 hold | retail synchronous cell load before SetPosition (no gate exists) |
| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
@ -80,7 +79,7 @@ accepted-divergence entries (#96, #49, #50).
| AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 |
| AD-16 | Building-flood gate is a CPU frustum test on each building's `PortalBounds` AABB; retail floods exactly when the shell draws and an aperture survives (no bounds constant anywhere) | `src/AcDream.App/Rendering/GameWindow.cs:7634` | Documented as the tight equivalent of the shell viewconeCheck for flood purposes (the FPS fix the Chebyshev≤1 hack approximated); per-portal admission still goes through BuildFromExterior's screen clip; missing-bounds buildings always flood (safe over-include) | A too-small/stale PortalBounds AABB means the interior never floods — doorway shows a hole/black aperture from outside (inverse of the vanishing-staircase class) | `DrawBuilding` 0x0059f2a0; `BSPPORTAL::portal_draw_portals_only` 0x53d870 |
| AD-17 | ≤8 GPU `gl_ClipDistance` half-planes per view region, degrading to a union-AABB scissor (over-include) on multi-polygon / >8-edge views; particles always scissor; scissor slices disable per-object viewcone culling. Retail CPU-clips against the exact portal polygon | `src/AcDream.App/Rendering/ClipPlaneSet.cs:23` | GL guarantees only 8 simultaneous clip planes; invariant documented: over-inclusion is safe, under-inclusion is the bug class | Fallback on complex multi-aperture views draws terrain/sky/particles/objects outside the true aperture but inside its AABB — background/interior bleed strips at doorways (the **#130** family) | `ACRender::polyClipFinish` decomp:702749; PView portal_view slices |
| AD-18 | Aperture far-Z punch is two-pass stencil-gated with invented `PunchMarkDepthBias = 0.0005` NDC; retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders (hills, closer buildings), painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Bias is depth-dependent: an occluder within ~bias in front of a distant aperture gets punched through; door-plane-hugging geometry just beyond it re-occludes the aperture (a **#108**-class regression) | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) |
| AD-18 | Aperture far-Z punch is two-pass stencil-gated with an invented mark bias: 0.0005 NDC capped to a 0.5 m EYE-SPACE span (`MarkBiasNdc`); retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders, painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. **#129** (2026-06-12): the constant-NDC bias spanned ~190 m of eye depth at a landblock (non-linear depth) → distant occluders punched; the eye-space cap bounds the reach (`Issue129PunchBiasTests`). DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Door-plane-hugging geometry beyond the 0.5 m cap re-occludes the aperture (a **#108**-class regression at >10 m viewing range); an occluder within the cap in front of a distant aperture still punches through | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) |
| AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw``DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 |
| AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 |
| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/GameWindow.cs:7671` | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 |
@ -93,7 +92,7 @@ accepted-divergence entries (#96, #49, #50).
---
## 3. Documented approximation (AP) — 32 rows
## 3. Documented approximation (AP) — 37 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -112,7 +111,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager |
| AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) |
| AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs |
| AP-16 | Global nearest-8 viewer-distance light selection with 10% range slack (own r13 design); retail bound D3D lights per object/cell | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; 1.1 slack is anti-pop hysteresis | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights); pop thresholds differ | r13 §12.2 (acdream design); retail D3D 8-light constraint |
| AP-16 | Global nearest-8 viewer-distance light selection (own r13 design); retail bound D3D lights per object/cell. NO viewer-range candidacy filter — each light's range cutoff is applied per-surface in the shader (the earlier `Range²×1.1` slack filter was removed; it dropped torches the viewer stood outside, the #133 "lighting off" report) | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; nearest-8 is an allocation-free partial-select (no per-frame list/sort) | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights) | r13 §12.2 (acdream design); retail D3D 8-light constraint |
| AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E |
| AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) |
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
@ -128,11 +127,16 @@ accepted-divergence entries (#96, #49, #50).
| AP-29 | Target-indicator fallback for entities with no baked selection sphere: invented 1.5 m × scale box + 16/12 px screen floors (primary path is a faithful `GetObjectBoundingBox` port) | `src/AcDream.App/UI/TargetIndicatorPanel.cs:86` | Fallback only fires when the Setup didn't bake a selection sphere — rare in practice | Sphere-less entities get a non-retail indicator size/placement; the pixel floors prevent retail's far-distance collapse | `SmartBox::GetObjectBoundingBox` 0x00452e20; `GetSelectionSphere` |
| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
| AP-32 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Standalone Type-0 text elements are also skipped (vitals numbers render via `UiMeter.Label` bound by the controller; a dedicated dat-text widget is Plan 2). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port and a dat-text widget are deferred to Plan 2. Gated opt-in (`ACDREAM_RETAIL_UI_IMPORTER`) and locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape, or a window needing standalone dat text, renders an empty/wrong meter or drops text — no oracle diff until the Plan-2 widgets land | `UIElement_Meter::DrawChildren` @0x46fbd0; `UIElement_Text::DrawSelf` @0x467aa0; `docs/research/2026-06-15-layoutdesc-format.md` |
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 |
| AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ 0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 |
| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Standalone Type-0 text elements are also skipped (vitals numbers render via `UiMeter.Label` bound by the controller; a dedicated dat-text widget is Plan 2). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port and a dat-text widget are deferred to Plan 2. Gated opt-in (`ACDREAM_RETAIL_UI_IMPORTER`) and locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape, or a window needing standalone dat text, renders an empty/wrong meter or drops text — no oracle diff until the Plan-2 widgets land | `UIElement_Meter::DrawChildren` @0x46fbd0; `UIElement_Text::DrawSelf` @0x467aa0; `docs/research/2026-06-15-layoutdesc-format.md` |
---
## 4. Temporary stopgap (TS) — 29 rows
## 4. Temporary stopgap (TS) — 30 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -165,10 +169,11 @@ accepted-divergence entries (#96, #49, #50).
| TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 |
| TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) |
| TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) |
| TS-30 | UI panels drawn as flat translucent rectangles + 1 px border; retail composes 9-slice dat sprite backgrounds via LayoutDesc trees | `src/AcDream.App/UI/UiPanel.cs:10` | Development visibility until the D.2b retail-look toolkit consumes the dat assets | Purely visual until D.2b — but pixel-position assumptions built against the placeholder (hit regions, layout constants) may not survive the swap to retail sprite metrics | RenderSurface 0x06xxxxxx 9-slice; LayoutDesc 0x21xxxxxx |
---
## 5. Unclear (UN) — 6 rows
## 5. Unclear (UN) — 5 rows
These rows have a missing, contradictory, or never-argued justification.
They are the highest-priority audits: each needs either a recorded
@ -177,7 +182,6 @@ equivalence argument (promote to AD/AP) or a fix.
| # | Divergence | Where (file:line) | Recorded justification (deficient) | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| UN-1 | `CheckOtherCells` iterates the overlap set SORTED by cell id; retail walks the CELLARRAY in build order — and the loop halts on the first non-OK result, so order is behavior-bearing | `src/AcDream.Core/Physics/CellTransit.cs:1718` | Justified only as "deterministic order for greppable probe logs" — no equivalence argument vs retail's array order recorded | A sphere straddling two cells that would each return a different non-OK result halts on a different cell than retail — different collision normal / slide direction at multi-cell straddles | `CTransition::check_other_cells` pc:272717-272798 |
| UN-2 | `GetMaxSpeed`: XML doc asserts the bare run rate is retail-correct (~5.9 m/s catch-up; the ×RunAnimSpeed multiply "a misread" → ~23.5 m/s), yet the implementation multiplies by RunAnimSpeed citing ACE as retail-verified. The two recorded justifications CONTRADICT — one describes the current code as known-wrong | `src/AcDream.Core/Physics/MotionInterpreter.cs:972` | None coherent — doc and code disagree about which behavior is retail | If the bare-rate reading is right, remote-entity catch-up runs ~4× retail speed — the multi-second 1-Hz blip / racing-remote symptom the doc itself records | `CMotionInterp::get_max_speed` pc:305127; catch-up :353122 |
| UN-3 | AdminEnvirons fog-override RGB tints hardcoded with no retail constant cited (RedFog 0.60/0.05/0.05 etc.); Snapshot replaces fog COLOR only, keeping keyframe distances on an unverified assumption | `src/AcDream.Core/World/WeatherState.cs:350` | Enum semantics cite ACE EnvironChangeType + r12 §5.2; no source for the RGB values or the color-only override scope | A server-forced fog event renders the wrong hue and/or wrong density vs what retail clients showed for the same packet | AdminEnvirons 0xEA60; ACE EnvironChangeType.cs |
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
| UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) |
@ -193,20 +197,19 @@ phase-gated — they carry their trigger in their row and should land
WITH that phase, not before.
1. **TS-20 — GfxObj DrawingBSP traversal (#113)** — phantom geometry is visible in Holtburg RIGHT NOW; the holistic port handoff already specs the fix; first diagnose the id filter against a door GfxObj.
2. **UN-2 — GetMaxSpeed contradiction** — the file argues against its own implementation; if the bare-rate reading is right, remote catch-up runs ~4× retail. Settle with one decomp re-read + a cdb catch-up trace; cheap to resolve, expensive to leave.
3. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there.
4. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis.
5. **UN-5 — Backward/strafe run multiplier** — potential ~2.4× local-vs-wire speed mismatch on a common input (S at run); one cdb session against retail answers it.
6. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output).
7. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing.
8. **TS-22 — adjust_motion port** — active bug-class generator: any new `get_state_velocity` consumer during backward/strafe silently gets zero velocity.
9. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass.
10. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
11. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
12. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
13. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now.
14. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
15. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
2. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there.
3. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis.
4. **UN-5 — Backward/strafe run multiplier** — potential ~2.4× local-vs-wire speed mismatch on a common input (S at run); one cdb session against retail answers it.
5. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output).
6. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing.
7. **TS-22 — adjust_motion port** — active bug-class generator: any new `get_state_velocity` consumer during backward/strafe silently gets zero velocity.
8. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass.
9. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
10. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
11. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
12. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now.
13. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
14. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):**
M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating),

View file

@ -2,7 +2,13 @@
**Status:** Living document. Created 2026-05-12.
**Sits above:** [`docs/plans/2026-04-11-roadmap.md`](2026-04-11-roadmap.md) (the strategic phase index).
**Currently working toward:** **M1.5 — Indoor world feels right.**
**Currently working toward:** **M1.5 — Indoor world feels right.** The
building/cellar demo is DONE + user-gated, but M1.5 was EXTENDED 2026-06-13
to include **dungeon support (full Phase G.3)** — dungeons don't work yet
(terrain-less dungeon landblocks aren't supported by the streaming/load
pipeline; issue #133). M1.5 does NOT land until dungeons work. M2 stays
deferred. (Correction: M1.5 was briefly marked landed 2026-06-13; the user
reverted that — the indoor world isn't done while dungeons are broken.)
---
@ -185,7 +191,56 @@ close range and the player sees "You pick up the X." in chat.
---
### M1.5 — "Indoor world feels right" — 🔵 ACTIVE (resumed 2026-05-21 after Phase O ship)
### M1.5 — "Indoor world feels right" — 🔵 ACTIVE (building/cellar demo DONE; EXTENDED 2026-06-13 to include dungeon support / Phase G.3)
**EXTENDED 2026-06-13 — dungeons pulled into M1.5 scope.** The
building/cellar demo (below) is DONE + user-gated, but attempting the
dungeon demo surfaced that dungeons don't work AT ALL: terrain-less
dungeon landblocks aren't supported anywhere in the streaming/load/
render/physics pipeline (`LandblockLoader.Load` returns null with no
`LandBlock` terrain record; the streamer fails with no terrain mesh; the
teleport snap Resolves before hydration — issue #133). The user decided
M1.5 is NOT done while the indoor world excludes dungeons, and chose the
FULL Phase G.3 scope (dungeon streaming + portal-space loading screen +
multi-landblock dungeon LOD + `PlayerTeleport` handling). Design in
progress (`docs/superpowers/specs/` — dungeon-support spec). M1.5 lands
when: building/cellar demo (DONE) + dungeon demo (enter via portal,
navigate 3-5 rooms, walls block, smooth transitions) both pass.
**Building/cellar demo — DONE + user-gated.** The indoor world reads as
solid. Across the
2026-06 sessions the holistic retail-faithful render port (Option A: ONE
`DrawInside(viewer_cell)`, no inside/outside branch — BR-2..BR-7 / T1..T6)
shipped and was user-gated, and the indoor physics/membership family was
brought to retail fidelity (the A6.P4 per-cell shadow architecture; the
#107/#111/#112 spawn + membership fixes; the cellar-lip wedge). End-to-end,
user-gated this milestone: walk into a building and climb a multi-floor inn
without sling-out or wall-clip; descend a cottage cellar and ascend it
without falling through (the #98 + cellar-lip + #108 grass-window closes);
walls block everywhere (indoor + stab-shell, the #99 door run-through
closed); cell transitions are smooth (the doorway "flap" family killed —
#119/#128, #112, #113, #124, #129/#130/#131/#132, #108-residual, #127 all
closed with user gates). The #90-stickiness + `TryFindIndoorWalkablePlane`
synthesis workarounds were removed by A6.P4. Remaining feel-level debt is
tracked (#116 slide-response, partial Ghidra fix shipped; A7 indoor
lighting fidelity not yet done — folded forward).
**Still OPEN in M1.5 — dungeon support (Phase G.3, issue #133).** Dungeons
don't work: the streaming/load/render/physics pipeline was built entirely
around outdoor landblocks (terrain + scattered buildings) and has no path
for terrain-less indoor-only dungeon landblocks. Confirmed gaps:
`LandblockLoader.Load` returns null with no `LandBlock` record; the
streamer fails with no terrain mesh; the teleport-arrival snap Resolves
before the dungeon hydrates → places the player in the old frame over
ocean. Full G.3 scope chosen by the user 2026-06-13 (streaming + portal-
space loading screen + multi-landblock LOD + `PlayerTeleport` handling).
Spec under `docs/superpowers/specs/`.
---
#### (historical M1.5 working notes below)
🔵 ACTIVE (resumed 2026-05-21 after Phase O ship)
**2026-05-30 — render-pipeline pivot.** The indoor *rendering* seam (seamless
in/out: the flap, missing/transparent walls, terrain bleed) will be solved by a
@ -293,13 +348,23 @@ unblocks that).
---
### M2 — "Kill a drudge" — ⏸ DEFERRED until M1.5 lands (was: NEXT)
### M2 — "Kill a drudge" — ⏸ DEFERRED until M1.5 lands (incl. dungeons)
**Demo scenario:** Equip a sword. Walk to a drudge. Swing. See "You hit
Drudge for 12 slashing damage (87%)" in chat. Watch the swing animation
play. Drudge dies, drops loot. Pick up the loot. Open the inventory panel
and see it.
**First port target when M2 starts (per the M2 combat-math research memo,
`docs/research/2026-06-04-combat-math-deep-dive.md`):**
`CombatMath.ComputeDamage` — damage-calc + armor-resists are port-ready
(ACE is the high-confidence oracle; two known scaffold bugs in
`CombatModel.cs` identified — additive attributeBonus + subtractive armor).
Hit-roll is well-documented client-side; the server sigmoid/crit +
weapon-timing (the x87 `GetPowerBarLevel` artifact) come after. NOTE: M2
was briefly started 2026-06-13 then re-deferred when M1.5 was extended to
include dungeons.
**Phases to ship:**
- **F.2 (panels)** — Inventory panel reading `ItemRepository` (data already
shipped in F.2 base; M2 ships the visual surface).

View file

@ -0,0 +1,95 @@
# Night-session handoff (2026-06-12): nine closes shipped; NEXT = #108-residual
**Branch state:** `claude/thirsty-goldberg-51bb9b`, pushed to BOTH remotes at
`49cffe6`. Suites green at every commit: App 261+1skip / Core 1439+2skips /
UI 420 / Net 294. CLAUDE.md "Current state" + the render digest
(`claude-memory/project_render_pipeline_digest.md`) are refreshed to this
truth — orient there first.
## 1. What this session closed (all user-gated; do NOT re-litigate)
| Closed | Root cause | Commits |
|---|---|---|
| **#130** doorway top-edge strip | TWO stacked causes: scissor box `Floor(origin)+Ceiling(size)` under-covers top/right (sub-pixel, `NdcScissorRect`); THE strip = the +0.02 m shell draw-lift missing from draw-space portal consumers post-f35cb8b (6.7 px @2.4 m, measured) | `6c4b6d6`, `5135066` (AP-32 row added) |
| **#129** doors leak through terrain at ~a landblock | constant 0.0005 NDC punch bias spans ~190 m of eye depth at distance; capped to 0.5 m eye-space (`MarkBiasNdc`) | `4ba7148` (AD-18 updated) |
| **#113** hill-cottage phantom stairs | dead via `2163308` (cache cross-serving) — re-gate confirmed | — |
| **#124** far-building back walls through openings | interior-root look-ins ported as a LANDSCAPE-STAGE sub-pass (decomp: LScape::draw runs FIRST in DrawCells' outside branch, pc:432719, pre-clear/pre-seal; seeds clip vs the INSTALLED view → `BuildFromExterior(seedRegion:)`; punch-all-then-draw). NEVER merge look-ins into the main frame (post-clear seal z-kill) | `77cef4c` (AP-33 added) |
| **#132** candle flame vs through-opening background | slice particles drew BEFORE the look-ins / merged interiors (no depth self-protection) — the FlushAlphaList deferral ported as the two-phase slice split + outdoor post-frame attached pass | `20d1730`, `87afbc0` (AP-34 added) |
| **#131** portal swirl missing through doorways | FOUR layers (see lesson below); final: the portal is a SERVER object inside the hall's PORCH cell (look-in cell) → partition.Dynamics → dynamics-last culls it (no look-in cells in the main cone) + post-seal z-fail. Fix: `DrawBuildingLookIns` draws look-in-cell dynamics + emitters (retail nested DrawCells/`DrawObjCellForDummies`) | `1d3f9a8`, `47f32cd`, `d208002` |
| **UN-2** GetMaxSpeed ×4 contradiction | the implementation was retail-correct; BN pseudo-C drops x87 fmuls — byte-verified (3× `fmul [0x7C8918]`=4.0f); doc rewritten, weenie-null default aligned to literal 1.0; row deleted | `0cb97aa` (verifier `tools/verify_un2_fmul.py`) |
## 2. THE #131 LESSON (cost: 4 fix iterations)
**Identify the ENTITY before theorizing about draw passes.** Three
real-but-adjacent fixes shipped before the elimination chain (teleport pCell
flip → owner cell; headless replay → flood admits it; partition routing →
exactly one possible drop site) forced the answer. Two tools that would have
shortened it to one iteration:
- **The pick line**: left-click prints `[B.4b] pick guid=… name=…` +
`[B.7] pick-info … setup=…` — names any clickable object in the log.
- **The teleport/pCell flip**: walking onto/into a thing prints its cell.
Both need zero new code. The register also already KNEW the answer (AP-33's
"look-in DYNAMICS are not drawn — deferred") — scan-the-register-on-symptom
applies to rows YOU wrote hours earlier.
## 3. NEXT (the queue to the M1.5 → M2 boundary)
1. **#108-residual — cellar-ascent grass window (NEXT, desk-first).**
Climbing out of a cellar, grass covers the exit door until the eye pops
above grade. Punch/seal exonerated; it is MEMBERSHIP/VIEWER-side (which
cell the camera resolves while the eye is below grade). Apparatus
designed: a VERTICAL exit-walk-harness variant (HouseExitWalkReplayTests
machinery driving the camera up cellar stairs, watching viewer-cell
resolution per step). Read the physics digest + ISSUES #108 before
starting. User needed only for the final cellar gate.
2. **#127 — distant-building admission churn** (flood size oscillates ±13
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=<ids>` 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.
```

View file

@ -0,0 +1,205 @@
# Handoff (2026-06-13): M1.5 EXTENDED — dungeon support (full Phase G.3). Design grounded; ready to brainstorm → spec → implement.
**Branch:** `claude/thirsty-goldberg-51bb9b`, pushed to BOTH remotes at the
HEAD this doc commits with. Suites green: App 264+1skip / Core 1445+2skip /
UI 420 / Net 294 (the dungeon dat-probe test added this session is
output-only).
This session closed a batch of M1.5 render/physics issues, then — at the
dungeon-demo gate — discovered dungeons don't work and the user **extended
M1.5 to include full dungeon support (Phase G.3)**. M2 is re-deferred. The
design is grounded (5-way reference research + a decisive dat probe); the
next session brainstorms approaches → writes the spec → implements.
---
## 1. What this session shipped (all on branch, pushed, most user-gated)
| Item | Outcome | Commits |
|---|---|---|
| **#108-residual** (cellar grass window) | CLOSED, user-gated "Yes it is fixed." Terrain was drawn DOUBLE-SIDED; the grass was the grade sheet's underside seen from a below-grade cellar eye. Ported retail `landPolysDraw` eye-side gate as terrain backface cull. Membership/viewer EXONERATED by a vertical cellar-ascent harness. | `007af13`, `96a425a`, `bf80067` |
| **#127** (distant-building flood flap) | CLOSED, user-gated "Seems to have been fixed." Died with the W=0 clip port (`987313a`); confirmed by a run-past churn detector (0 churn, 21 buildings × 5 distances × 100 mm-steps). | `4ad6fb9` |
| **#125** (sticky-drop debt) | CLOSED. Bounded upload retry — a failed `UploadMeshData` re-stages for the next frame up to `MaxUploadRetries` (counter on the `ObjectMeshData`); the CPU-cache short-circuit no longer permanently strands a failed upload. | `8682a8d` |
| **#116** (slide-response) | PARTIAL. Ghidra (the user pointed me to the running Ghidra MCP) resolved the BN `test ah,5` branch-sign ambiguity: `slide_sphere` compares squared magnitudes against `F_EPSILON` (0.0002), not `EpsilonSq` (4e-8) — fixed `TransitionTypes.cs:3098,3105`, full physics suite green. The two reported shapes still need a cdb trace (shape-1 = upstream collision-normal recording; shape-2 = D4 first-frame dispatch). | `35961f2`, `bf18a54` |
---
## 2. The milestone churn (read this — the docs were corrected)
- I briefly marked **M1.5 LANDED** on the building/cellar demo and started M2
(`1bf037a`). **The user reverted that:** the indoor world isn't done while
dungeons are broken, so M1.5 is EXTENDED to include dungeon support, and the
user chose the **FULL Phase G.3 scope** (streaming + portal-space loading
screen + `PlayerTeleport` handling). Correction committed `9c2ceb2`.
- **Current truth:** M1.5 ACTIVE; building/cellar demo DONE + user-gated;
dungeon support (G.3) is the remaining M1.5 exit-gate. M2 (CombatMath first
port) DEFERRED. Docs reflect this (milestones doc, CLAUDE.md current-state,
ISSUES.md #133).
---
## 3. The dungeon bug — CORRECTED root cause (issue #133)
User attempted the dungeon demo via the **meeting-hall portal** → "no dungeon,
just ocean." ACE logged a flood of `failed transition for +Acdream from
0x01250126 [30 -60 6.0] to 0xA9B0000E [-32227 -26748 5.9]` … marching south at
Z≈0.9 (underwater).
**Diagnostic capture (`launch-dungeon-diag.log`, probes
`ACDREAM_PROBE_CELL`/`ACDREAM_PROBE_VIEWER`/`ACDREAM_WB_DIAG`):**
```
live: teleport arrival — old lb=(169,180) new lb=(1,37) dist=42524.0
[snap] claim=0xA9B3000E pos=(30,-60,6.005) cells=17 bestCell=0xA9B30103 ... indoor=False -> targetCell=0xA9B3000E
live: teleport complete — snapped to <30,-60,6.005> cell=0xA9B3000E
[cell-transit] A9B3000E -> A9B2000E -> A9B1000E -> ... (sliding south into ocean)
```
ACE correctly placed the player in dungeon cell **0x01250126** (landblock
`0x0125` = (1,37)). acdream's arrival handler (`GameWindow.cs:4908-4931`)
recenters streaming to (1,37) but then **immediately** calls
`_physicsEngine.Resolve(pos=(30,-60,6.005), cell=0x01250126)` to snap the
player — **before the dungeon landblock has streamed in**. Resolve can't find
the dungeon cell, falls back to an outdoor scan against the **still-resident
Holtburg landblocks**, and snaps to `0xA9B3000E` (Holtburg's south edge, local
(30,60) maps into the block south of the A9B4 spawn). Streaming then shifts
the frame out from under the player → slides south into ocean.
### ⚠️ The "terrain-less landblock" framing is WRONG (verified by dat probe)
A pipeline-seam research agent *assumed* dungeon landblocks have no `LandBlock`
record (so `LandblockLoader.Load` returns null) and produced a 13-seam
"rewrite the pipeline for terrain-less landblocks" plan. **A direct dat probe
(`DungeonLandblockDatProbeTests`, committed) refutes that:**
```
0x0125 (dungeon): LandBlock 0x0125FFFF PRESENT, Height[81] allZero=True (flat)
LandBlockInfo: NumCells=71, Buildings=0, Objects=0
EnvCells 0x0100.. present (the 71 dungeon rooms)
0xA9B4 (Holtburg): LandBlock PRESENT, heights non-zero; NumCells=123, Buildings=12, Objects=114
```
A dungeon landblock is a **flat-terrain landblock** (all-zero height index =
the lowest/"ocean" terrain) **plus its EnvCells, no buildings/objects**. So
`LandblockLoader.Load(0x0125…)` returns a valid flat landblock, the terrain
mesh builds a flat plane, and `PhysicsEngine.AddLandblock` gets a valid flat
`TerrainSurface`. **The existing pipeline can already stream a dungeon
landblock.** The 13 terrain-dependency seams are NOT the blocker.
**The real blocker is narrow: teleport TIMING + PLACEMENT.**
---
## 4. Reference grounding (5-way research; dat agent failed, replaced by the probe above)
**holtburger (client-behavior oracle):**
- PlayerTeleport (0xF751) → enter `EnteringWorld` (portal space), **suspend
physics bodies**, send **LoginComplete immediately** (no waiting for assets).
- Exit portal space → `InWorld` when the server sends ObjectCreate (entities) +
UpdatePosition (player) + the **StartGame** event → resume bodies.
- holtburger does NOT stream landblocks (entity-centric); not our model — we
DO stream from our own dats. Take the **FSM shape** (EnteringWorld/InWorld +
suspend/resume) not the no-streaming part.
- DDD is NOT part of the teleport flow (responds empty). (`messages.rs:480-486`,
`:190-195`, `player.rs:71-79`, `types.rs:169-175`.)
**ACE (server):** `Player_Location.cs:654-707` Teleport() sends PlayerTeleport
(sequence) → a **fake UpdatePosition** to trigger client load → the real
UpdatePosition with PositionPack (CellID = dungeonID<<16 | cellIndex, e.g.
`0x01250126`, xyz, rotation). **Server sends NO geometry — client loads cells
from its own dats by cellID** (matches our dat-driven model). Portal:
`Portal.cs:269-292` ActOnUse → AdjustDungeon (corrects cell id) →
ThreadSafeTeleport. **Dungeons are SINGLE-landblock** (`Player_Tick.cs:548-560`
forbids moving between dungeon landblocks without teleport) → "multi-landblock
LOD" in the full-G.3 scope is MOOT for AC dungeons. IsDungeon = all heights 0 +
NumCells>0 + no buildings (`Landblock.cs:575-631`).
**Retail decomp (client):** terrain (`CLandBlock::grab_visible_cells`) and
dungeon cells (`CEnvCell::grab_visible_cells`, :311878) load on **separate
paths**; a cell with `seen_outside==0` loads ZERO terrain and walks only its
`stab_list` (adjacent EnvCells). **Portal-space = a 6-state `TeleportAnimState`
FSM** (:219682-219774): WORLD_FADE_OUT → TUNNEL_FADE_IN → TUNNEL (hold while
loading) → TUNNEL_FADE_OUT → WORLD_FADE_IN → OFF; `m_pPortalSpace` is the
tunnel viewport (the "loading"/black screen). Retail gates cell-ready on DDD
(server cell push) — **we don't need DDD** (we have the dats); we gate on our
own streaming hydration. Open: no distinct "pink screen" asset found — retail's
loading visual is the portal tunnel.
**acdream pipeline seams (corrected by the dat probe):** the dungeon landblock
streams fine as flat-terrain. Real seams that matter:
- `GameWindow.cs:4908-4931` — teleport arrival: recenter then **Resolve
immediately** (the bug). No hold-until-hydration.
- `PhysicsEngine.IsSpawnCellReady` (`:468`) — the EXISTING #107 gate; already
handles indoor cells (checks DataCache for 0x0100+). **Reuse it for the
teleport-arrival path.**
- EnvCell hydration (render `_cellVisibility`/`EnvCellRenderer`; physics
`CacheCellStruct`) is iterated from `LandBlockInfo.NumCells` and is
**orthogonal to terrain** — should fire for a dungeon landblock once it
streams (`GameWindow.cs:5564-5576`, `6015-6028`). VERIFY it does.
- Placement: the player is at cell `0x01250126`, pos (30,60,6.005); must be
placed in the **EnvCell** (the #107/#111 validated-claim path,
`WalkableFloorZNearest`), not on the flat terrain.
---
## 5. Design direction (to confirm in the brainstorm)
A retail-faithful, much-narrower-than-feared shape:
1. **Teleport state machine (portal space).** On PlayerTeleport: enter a
PortalSpace/EnteringWorld state, suspend player physics, (optionally) start
the retail `TeleportAnimState` tunnel FSM for the loading visual. On arrival
UpdatePosition: recenter streaming on the destination landblock, then **HOLD**
— do not snap — until the destination landblock + the claimed EnvCell hydrate
(reuse `IsSpawnCellReady`). Then place into the EnvCell (validated-claim
path), exit PortalSpace → InWorld, resume physics, send LoginComplete.
(acdream already has `OnTeleportStarted`/portal-space + the #107 hold for
LOGIN — extend that machinery to the teleport-arrival path rather than
snapping at `:4928`.)
2. **Streaming a far teleport.** Confirm the recenter actually drives the
streamer to load the destination dungeon landblock (the Chebyshev window
around the new center) and unloads the old neighborhood without stranding the
player. The dungeon streams as a flat-terrain landblock — no new loader path
needed, but verify the apply path + EnvCell hydration fire.
3. **Render/physics in the dungeon.** Once the EnvCells hydrate, the existing
PView indoor render + per-cell collision should work (same as buildings).
The flat terrain renders below; PView roots at the viewer EnvCell. VERIFY the
3-5-room navigation, walls block, stairs, lighting (A7 not done — expect
lighting findings), transitions.
4. **Portal-space loading screen (full-G.3 polish).** The retail 6-state tunnel
FSM (`TeleportAnimState`) — implement after the core teleport+place works, or
a simpler fade-to-black first.
**Open design questions for the brainstorm:**
- Do we implement the retail `TeleportAnimState` tunnel FSM faithfully now, or a
simpler fade-to-black for M1.5 and the full tunnel later?
- How long to HOLD before giving up (the dungeon may need several frames to
stream); what's the failure/timeout behavior?
- Does the existing streaming controller already load a landblock 42 km away on
recenter, or does it assume incremental movement? (Confirm the recenter→load
path for a big jump.)
- Placement: the cell-local pos (30,60,6.005) vs the EnvCell origins (~(0,30,0))
— confirm the EnvCell BSP contains the point and the #107/#111 walkable-floor
placement lands the player on the dungeon floor.
---
## 6. Apparatus added this session (kept)
| Tool | How | For |
|---|---|---|
| `DungeonLandblockDatProbeTests` | `dotnet test --filter DungeonLandblockDatProbe` | Dumps the dat structure of a dungeon (0x0125) vs outdoor (A9B4) landblock — the terrain-less-vs-flat resolution |
| `launch-dungeon-diag.log` | `ACDREAM_PROBE_CELL=1 ACDREAM_PROBE_VIEWER=1 ACDREAM_WB_DIAG=1` | The teleport→snap→slide capture; `[snap]`/`[cell-transit]`/`live: teleport` lines are the chain |
| `Issue108CellarAscentViewerReplayTests` | App.Tests filter | Vertical cellar-ascent viewer harness (membership EXONERATED for #108) |
| `Issue127FloodFlipReplayTests.DistantBuildingStrafe_NoAdmissionChurn` | App.Tests filter | #127 run-past churn-detector regression pin |
Decomp grounding: holtburger teleport flow, ACE Teleport/Portal/AdjustDungeon,
retail `CEnvCell::grab_visible_cells` (:311878) + `TeleportAnimState` FSM
(:219682-219774). Full raw research in the workflow output (this session).
---
## 7. Next session: brainstorm → spec → implement
The brainstorming skill was invoked and scope was set (full G.3). The next
session resumes the brainstorm at "propose 2-3 approaches" with the grounding
above, settles the design, writes the spec to
`docs/superpowers/specs/2026-06-13-dungeon-support-design.md`, then →
writing-plans → implement. The paste-ready pickup prompt is in the session
message that produced this doc.

View file

@ -0,0 +1,633 @@
# G.3a — Core Teleport-Into-Dungeon Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make teleporting into a dungeon land the player standing in the dungeon cell (on the floor, walls blocking) instead of snapping to ocean — by holding the player in portal space until the destination landblock/cell streams in, then placing via the existing validated-claim path.
**Architecture:** Replace the unconditional snap in `GameWindow.OnLivePositionUpdated` with a small, pure, unit-tested `TeleportArrivalController` state machine. On a teleport arrival the handler recenters streaming (kicks off the load) but **defers** the snap; a per-frame `Tick` reuses the #107 login readiness triplet (`SampleTerrainZ` ∧ (`outdoor` `IsSpawnCellReady`); `IsSpawnClaimUnhydratable` short-circuits impossible claims) and places the player via the unchanged `PhysicsEngine.Resolve` once the destination is ready. A coarse frame-count timeout fails loudly rather than freezing. Plus a small decouple of EnvCell physics/visibility hydration from the render-mesh guard.
**Tech Stack:** C# .NET 10, xUnit, Silk.NET (App layer). No new dependencies.
**Spec:** [`docs/superpowers/specs/2026-06-13-dungeon-support-design.md`](../specs/2026-06-13-dungeon-support-design.md) (§3.1, §4, §5).
**Scope:** This plan is **G.3a only** — the gated core that ends at the visual acceptance test. G.3b (#95 stab_list bounding, *conditional* on the gate showing a blowup), G.3c (faithful `TeleportAnimState` tunnel FSM), and G.3d (recall game-actions) each get their own plan **after** the G.3a gate passes.
---
## File Structure
| File | Responsibility | Action |
|---|---|---|
| `src/AcDream.App/World/TeleportArrivalController.cs` | Pure state machine: hold a teleport arrival until ready, then place (or force-place on impossible/timeout). No GL/dat/network — readiness + placement are injected delegates. | **Create** |
| `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs` | Unit tests for the state machine (all transitions, timeout, re-arm). | **Create** |
| `src/AcDream.App/Rendering/GameWindow.cs` | Wire the controller in: construct lazily, the readiness + placement callbacks, replace the unconditional arrival snap (`:4877-4961`) with recenter + `BeginArrival`, add per-frame `Tick` (after `:6838`). Decouple EnvCell physics/visibility hydration from the render-mesh guard (`:5601-5652`). | **Modify** |
`TeleportArrivalController` is deliberately a *pure* unit (App layer, `System.Numerics` only) so it is testable without standing up the renderer. GameWindow keeps only the wiring + closures over its runtime state (Code Structure Rule 1).
---
## Task 1: `TeleportArrivalController` (pure state machine, TDD)
**Files:**
- Create: `src/AcDream.App/World/TeleportArrivalController.cs`
- Test: `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs`
- [ ] **Step 1: Write the failing tests**
Create `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs`:
```csharp
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.World;
using Xunit;
namespace AcDream.App.Tests.World;
public class TeleportArrivalControllerTests
{
// Records each Place(destPos, destCell, forced) call.
private sealed record PlaceCall(Vector3 Pos, uint Cell, bool Forced);
private static TeleportArrivalController Make(
ArrivalReadiness verdict,
List<PlaceCall> 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<PlaceCall>();
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<PlaceCall>();
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<PlaceCall>();
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<PlaceCall>();
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<PlaceCall>();
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<PlaceCall>();
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<PlaceCall>();
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;
/// <summary>Verdict from the per-frame readiness probe for a held teleport arrival.</summary>
public enum ArrivalReadiness
{
/// <summary>Destination not yet hydrated; keep holding.</summary>
NotReady,
/// <summary>Destination terrain + cell are ready; place now.</summary>
Ready,
/// <summary>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.</summary>
Impossible,
}
/// <summary>Lifecycle of a single teleport arrival.</summary>
public enum TeleportArrivalPhase { Idle, Holding }
/// <summary>
/// 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 <c>GameWindow.OnLivePositionUpdated</c> that resolved the
/// arrival against the resident (old) landblocks before the destination hydrated
/// and landed the player in ocean.
///
/// <para>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
/// <c>PlayerState.PortalSpace</c> until the placement delegate flips it back to
/// InWorld.</para>
///
/// <para>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.</para>
/// </summary>
public sealed class TeleportArrivalController
{
/// <summary>~10 s at 60 fps. Coarse safety net for a destination that never streams.</summary>
public const int DefaultMaxHoldFrames = 600;
private readonly Func<Vector3, uint, ArrivalReadiness> _readiness;
private readonly Action<Vector3, uint, bool> _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<Vector3, uint, ArrivalReadiness> readiness,
Action<Vector3, uint, bool> place,
int maxHoldFrames = DefaultMaxHoldFrames)
{
_readiness = readiness ?? throw new ArgumentNullException(nameof(readiness));
_place = place ?? throw new ArgumentNullException(nameof(place));
_maxHoldFrames = maxHoldFrames;
}
/// <summary>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).</summary>
public void BeginArrival(Vector3 destPos, uint destCell)
{
_destPos = destPos;
_destCell = destCell;
_heldFrames = 0;
Phase = TeleportArrivalPhase.Holding;
}
/// <summary>Per-frame: evaluate readiness and place when ready / impossible / timed out.
/// No-op when Idle.</summary>
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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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<Vector3,uint,ArrivalReadiness>` + `Action<Vector3,uint,bool>` 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).

View file

@ -0,0 +1,455 @@
# Phase G.3 — Dungeon Support (Design Spec)
> **Status:** APPROVED design (brainstorm 2026-06-13). Next: `writing-plans`.
> **Milestone:** M1.5 ("Indoor world feels right"). G.3 is the remaining M1.5
> exit-gate. M2 (CombatMath) stays deferred until this lands.
> **Issue:** [#133](../../ISSUES.md) (teleport-into-dungeon snaps to ocean) +
> [#95](../../ISSUES.md) (dungeon portal-graph visibility blowup — re-assessed
> below).
> **Supersedes** the §12 port-plan of
> [`docs/research/deepdives/r09-dungeon-portal-space.md`](../../research/deepdives/r09-dungeon-portal-space.md):
> most of R9's "new types" (EnvCell loader/renderer/physics, PortalVisibility
> BFS, multi-cell transit) already shipped and power the building/cellar demo.
> r09 stays the **retail contract reference** for the wire formats, the
> EnvCell/CellPortal layout, and the recall taxonomy.
---
## 0. TL;DR
Dungeons don't work because of **one timing+placement gap on one code path**,
not a terrain-less-pipeline rewrite. A dungeon landblock (e.g. `0x0125`, the
Holtburg-area meeting hall) is a **flat-terrain** landblock (`LandBlock`
present, all-zero heights) + 71 EnvCells + no buildings — it already streams,
renders, and collides through the existing pipeline. The teleport-arrival
handler snaps the player **before** that landblock has streamed in, so Resolve
falls back to the resident Holtburg blocks and lands the player in ocean.
The fix is retail's own shape: **hold the player in portal space until the
destination cell is hydrated, then place into the EnvCell** — reusing the
#107/#111 login machinery — and then layer retail's portal-tunnel visual
(`TeleportAnimState`) on top. We ship it in four installments, gated by one
visual acceptance test.
---
## 1. Corrected root cause (verified)
### 1.1 The "terrain-less landblock" framing is WRONG (dat-verified)
A prior research pass assumed dungeon landblocks have no `LandBlock` record, so
`LandblockLoader.Load` returns null and the whole streaming/render/physics
pipeline needs terrain-less support. **A direct dat probe
(`DungeonLandblockDatProbeTests`, committed) refutes that:**
```
0x0125 (dungeon): LandBlock 0x0125FFFF PRESENT, Height[81] allZero=True (flat)
LandBlockInfo: NumCells=71, Buildings=0, Objects=0
EnvCells 0x0100.. present (the 71 dungeon rooms)
0xA9B4 (Holtburg): LandBlock PRESENT, heights non-zero; NumCells=123, Buildings=12, Objects=114
```
A dungeon landblock is a **flat-terrain landblock** (lowest/"ocean" terrain
height index) **plus its EnvCells, no buildings/objects**. `LandblockLoader.Load`
returns a valid flat landblock; the terrain mesh builds a flat plane;
`PhysicsEngine.AddLandblock` gets a valid flat `TerrainSurface`. **The existing
pipeline already streams a dungeon landblock.** This matches ACE's `IsDungeon`
(all heights 0 + `NumCells > 0` + no buildings — `Landblock.cs:575`) and the
single-landblock rule (`Player_Tick.cs:548-560` forbids moving between dungeon
landblocks without a teleport — so "multi-landblock dungeon LOD" is moot).
### 1.2 The real blocker: teleport TIMING + PLACEMENT
`OnLivePositionUpdated` (`src/AcDream.App/Rendering/GameWindow.cs:4877-4961`)
detects teleport arrival as **any** player position update while in PortalSpace
(correct, per #107), then **unconditionally**:
1. Recenters streaming to the destination landblock (`_liveCenterX/Y`, `:4908-4925`).
2. **Immediately** calls `_physicsEngine.Resolve(destPos, destCell, …)` to snap
the player (`:4927-4931`) — **before the destination landblock has streamed in**.
3. Snaps entity + controller (`:4935-4939`), exits PortalSpace (`:4950`), sends
`LoginComplete` (`:4953-4959`).
Because the dungeon landblock isn't resident yet, Resolve can't find the
destination cell, falls back to an **outdoor scan against the still-resident
Holtburg landblocks**, and snaps to `0xA9B3000E` (Holtburg's south edge — local
`(30,60)` maps into the block south of the A9B4 spawn). Streaming then shifts
the frame out from under the player → they slide south into ocean. ACE logs the
matching `failed transition for +Acdream from 0x01250126 … to 0xA9B0000E …`
chain (captured in `launch-dungeon-diag.log`).
**There is no hold-until-hydration on the teleport-arrival path.** The #107
*login* path directly above it (`GameWindow.cs:1010-1024`) HAS exactly this gate;
the teleport path doesn't.
---
## 2. Grounded seam facts (the design rests on these)
All five verified against current code this session (high confidence).
### 2.1 Teleport-arrival + PortalSpace FSM
- `OnTeleportStarted` (`GameWindow.cs:~4971-4976`) — on `PlayerTeleport (0xF751)`
sets `_playerController.State = PlayerState.PortalSpace`, freezing movement.
- `PlayerMovementController.Update` (`PlayerMovementController.cs:840-854`) returns
a zero-movement result while `State == PortalSpace` — **PortalSpace already
doubles as the input-freeze.** It can equally serve as the hydration-wait gate.
- Exit is **only** via the arrival detection in `OnLivePositionUpdated`
(`:4880`). No timeout, no cell-hydration gate today.
### 2.2 #107/#111 login machinery (directly reusable)
- `PhysicsEngine.IsSpawnCellReady(cellId)` (`PhysicsEngine.cs:468-472`): outdoor
(`cellId & 0xFFFF < 0x0100`) → always ready; indoor → `DataCache.GetCellStruct(cellId)
is not null` (the cell's physics BSP has hydrated).
- `IsSpawnClaimUnhydratable(claim)` (`GameWindow.cs:11728-11748`): fetches the dat
`LandBlockInfo` at `(lb & 0xFFFF0000) | 0xFFFE`; a claim whose low word is
`>= 0x0100 + NumCells` (or `NumCells==0`) can **never** hydrate → reject fast
(distinguishes a bogus claim from a not-yet-streamed one).
- #107 login hold (`GameWindow.cs:1010-1024`): `isSpawnGroundReady` waits for
terrain AND (claim outdoor OR `IsSpawnCellReady` OR `IsSpawnClaimUnhydratable`).
No timeout today (login can afford to wait forever; teleport cannot — see §5).
- #111 validated-claim placement (`PhysicsEngine.cs:626-646`): when
`snapDiag (zero-delta) && adjustedFound && indoor`, place via
`WalkableFloorZNearest` (`:383-406`) — projects Z onto the claim cell's **own
physics walkable polygons** (`normal.Z >= PhysicsGlobals.FloorZ`, 0.6642),
cell-local, nearest to the reference Z. Returns `null` if the cell isn't
hydrated → falls through to the legacy `bestCell` scan (**the ocean bug**).
- **The teleport-arrival Resolve call is already the same shape as login entry.**
The gate only needs to sit in front of it; no change to Resolve or
WalkableFloorZNearest. (Both already key on the full prefixed cell id +
indoor/outdoor.)
### 2.3 Streaming far recenter (works as-is)
- `StreamingRegion.RecenterTo` (`StreamingRegion.cs:180-283`) recomputes the
near/far Chebyshev window **from scratch** around the new center — a 42 km jump
is treated identically to a 1-step move. No incremental-movement assumption.
- Drain: `StreamingController` applies ≤ `MaxCompletionsPerFrame` (default 4)
results/frame; `ApplyLoadedTerrainLocked` (`GameWindow.cs:5941-6150`) does GPU
upload + cell-visibility registration + AABB + `PhysicsEngine.AddLandblock` +
EnvCell/portal registration. Estimate: **~7-8 frames (~120-130 ms)** to hydrate
a 5×5 near window; physics ready +1-2 frames.
- Recenter keeps the old neighborhood until hysteresis unload (NearRadius+2
demote, FarRadius+2 unload), so the player isn't instantly stranded.
- **New code needed:** reuse the #107 login-gate **terrain-ready signal**
`_physicsEngine.SampleTerrainZ(x,y) is not null` (non-null once the destination
terrain landblock has applied) — no separate "landblock applied" query is
required. Plus dest-coord validation (reject out-of-world coords — a malformed
portal dest would otherwise leave the player in an invisible, unloadable
landblock).
### 2.4 EnvCell hydration coupling (latent landmine — decouple)
- In `BuildInteriorEntitiesForStreaming` (`GameWindow.cs:5564-5651`), both
`BuildLoadedCell` (the portal-visibility node) **and**
`_physicsDataCache.CacheCellStruct` (the physics BSP) sit **inside** the render
guard `if (cellSubMeshes.Count > 0)` (`:5602`). A cell whose render mesh is empty
(`CellMesh.Build` returns nothing — e.g. all-untextured/`Stippling.NoPos` polys)
silently gets **no visibility node and no collision**, even if it has walkable
physics polygons. `CellTransit.FindTransitCellsSphere` then `GetCellStruct → null
→ continue` (silently skips it) → fall-through-floor.
- A normal dungeon *room* has textured walls → non-empty submeshes → the guard
passes, so this is **probably not the meeting-hall blocker** — but it is a real
correctness landmine for any geometry-less collision cell, and decoupling is
cheap and retail-correct (physics/visibility do not depend on visible geometry).
**Fix:** gate `CacheCellStruct` on `cellStruct.PhysicsBSP != null` and
`BuildLoadedCell` on `cellStruct != null`, independent of the render submesh
count. (`CacheCellStruct` already early-returns on null BSP internally —
`PhysicsDataCache.cs:172` — so moving it out is safe.)
### 2.5 #95 — dungeon portal-graph visibility blowup (RE-ASSESSED: likely superseded)
- ISSUES.md #95 (`888-913`): on a 2026-05-21 **A6.P1 scen5 (Town Network hub)**
trace, `visibleCells` per cell exploded to 135-145 with spurious cells from
landblocks `0x020A`/`0x0408` (other dungeons). Its "Files" point at the WB
`EnvCellRenderManager`/`VisibilityManager` + the Streaming cell-cache.
- **That code path was DELETED by the T1-T6 render rewrite (2026-06-11)** (T4:
"per-frame ACME BFS deleted… InteriorRenderer/DrawPortal deleted"). The current
flood, `PortalVisibilityBuilder.Build`, (a) confines neighbors to the camera
cell's landblock (`lbMask = cameraCell.CellId & 0xFFFF0000`, `:131`) and (b) has
**enqueue-once termination** (`queued` HashSet, `:165` — "at most N cells are
ever processed"). Since AC dungeons are single-landblock, that confinement is
*correct*, and the cross-landblock 135-cell blowup **structurally cannot
reproduce**: a single-landblock flood visits ≤ `NumCells` distinct cells (71 for
the meeting hall).
- **Verdict (pre-gate, 2026-06-13 AM):** #95's evidence is stale, from a deleted
path; the current pipeline looked bounded. Treated #95 as likely superseded.
- **⚠️ GATE CORRECTION (2026-06-13 PM — #95 is CONFIRMED LIVE):** the G.3a visual
gate ran a real `PlayerTeleport` into the `0x0007` dungeon (Town Network). The
core hold+place worked (player grounded on the dungeon floor, z=0 — no ocean),
but **WB-DIAG exploded to entSeen=6.5M / instances=9.1M / drawsIssued=590K per
frame** (vs. 3345 / 4667 at Holtburg), with a flood of `[mesh-miss] 0x000100xxxx`
interior re-requests → the dungeon renders as "thin air." **#95 reproduces under
the current Option-A pipeline.** The "bounded flood" reasoning was wrong for the
`0x0007` dungeon (the grounding agent's "still live" verdict was correct; this
doc over-discounted it). **G.3b is now REQUIRED, not conditional** (§3.2). The
retail-faithful fix shape stands: port `CEnvCell::grab_visible_cells` (:311878)
stab_list bounding — a `seen_outside==0` cell walks ONLY its `stab_list`.
---
## 3. The plan (Approach C — phased full-G.3)
Each installment lands a **complete retail behavior** (the BR-2 half-port
lesson). The visual gate sits as early as possible, right after the core.
### 3.1 G.3a — Core teleport-into-dungeon (the blocker)
**Goal:** teleporting into the meeting-hall dungeon lands the player standing in
the dungeon cell, on the floor, with walls blocking — no ocean, no ACE
`failed transition` spam.
**New component — `TeleportArrivalController`** (`src/AcDream.App/World/`):
- Owns a small phase: `Idle / Holding / Placing`, plus `_pendingArrival`
`(destPos, destCellId, deadline)`.
- Lives outside `GameWindow` (Code Structure Rule 1: no new feature bodies in the
god-object). `GameWindow.OnLivePositionUpdated` hands the arrival to it and
calls its per-frame `Tick`; `GameWindow` keeps only the wiring.
- Unit-testable in isolation (no GL, fake readiness predicate + fake Resolve).
**Control flow (replaces the unconditional snap at `GameWindow.cs:4927-4950`):**
1. On arrival update in PortalSpace: validate `destCellId`'s landblock coords are
in-world; recenter streaming + prioritize-load the dest landblock (existing
path); stash `_pendingArrival`; enter `Holding`. Re-send `LoginComplete`
immediately (holtburger-conformant — `messages.rs:434`; do **not** wait for
assets to send it).
2. Each frame in `Holding`, evaluate the **readiness predicate**:
- `IsSpawnClaimUnhydratable(destCell)` → impossible claim: stop holding, place
via the safety-net demote (loud log), exit PortalSpace.
- `now > deadline` (timeout, ~10 s) → force-snap via safety-net demote + loud
log, exit PortalSpace. (See §5 — failure-surfacing, not symptom-masking.)
- `SampleTerrainZ(destPos) != null && (outdoor || IsSpawnCellReady(destCell))`
→ ready: go to 3.
- else stay frozen, retry next frame.
3. `Placing`: call the **existing** `Resolve(destPos, destCell, Vector3.Zero, …)`.
Because the cell is now hydrated, Resolve takes the #111 validated-claim branch
`WalkableFloorZNearest` grounds the player on the EnvCell floor. Snap entity
+ controller (existing `:4935-4939` code), exit PortalSpace, resume input.
**Readiness predicate — reuse the #107 login triplet (no new query).** The
hold gates on exactly the three checks the login auto-entry gate already uses
(`GameWindow.cs:1010-1024`), evaluated against the teleport's `(destPos,
destCell)` instead of the spawn claim: `SampleTerrainZ(destPos.X, destPos.Y) is
not null` (destination terrain applied) ∧ (outdoor cell OR
`IsSpawnCellReady(destCell)`); `IsSpawnClaimUnhydratable(destCell)` short-circuits
an impossible claim to immediate placement. This reuses proven, validated code
rather than introducing a parallel "landblock applied" query.
**Dest-coord validation:** in `OnLivePositionUpdated`, reject a destination whose
`(lbX, lbY)` is out of the world grid before recenter; log + abort the teleport
hold rather than recenter to a phantom block.
**Hydration decouple (§2.4):** move `BuildLoadedCell` + `CacheCellStruct` out of
the `cellSubMeshes.Count > 0` guard in `BuildInteriorEntitiesForStreaming`. Gate
each on its own non-null precondition.
**Acceptance (G.3a):** the visual gate in §6. This gate also empirically settles
#95 (does the flood blow up?) and the hydration coupling (does collision work?).
### 3.2 G.3b — #95 visibility bounding (REQUIRED — gate-confirmed 2026-06-13)
**The G.3a gate confirmed the blowup** (9.1M instances/frame in `0x0007`), so this
is the next blocker, not a conditional follow-up. The dungeon will not render
until the portal-visibility flood is bounded to the dungeon's own cell adjacency.
**Fix:** port retail `CEnvCell::grab_visible_cells` (`:311878`) — a cell with
`seen_outside == 0` loads ZERO terrain and walks ONLY its `stab_list` of adjacent
EnvCells; the portal graph is bounded by the dungeon's own cell adjacency, never a
radius / never the whole resident cell set. This is a render-pipeline change in
`PortalVisibilityBuilder` (the flap-/DO-NOT-RETRY-sensitive area) and needs its own
grounding + brainstorm before implementation (verify the dat carries the stab_list
and acdream's EnvCell loader parses it; confirm the `seen_outside` flag is read;
decide how it composes with the outdoor-root look-in floods). **NOT a wing-it
inline fix.**
**Open question surfaced at the gate (possible Bug C):** even with Bug A fixed
(placement keeps the dungeon prefix, `2ce5e5c`), the dungeon's negative-local-Y
coordinate frame may cause the per-tick membership/landblock resolution to drift
(the ACE `movement pre-validation failed` spam). Re-gate after Bug A to see if it
persists; if so, fold the dungeon-coordinate membership handling into G.3b's
grounding (it is plausibly the same `seen_outside` / cross-landblock root as #95).
### 3.3 G.3c — Portal-tunnel loading visual (faithful `TeleportAnimState`)
**Goal:** the retail portal-space transition, ported faithfully (user decision
2026-06-13). Reconciles the older r09 §6 ("there is no loading screen") with the
named-retail decomp where this FSM actually lives.
**Oracle:** `gmSmartBoxUI::BeginTeleportAnimation` (`004d6300`, named-retail line
218888) + the per-frame FSM (`219405-219774`). States:
`TAS_WORLD_FADE_OUT → TAS_TUNNEL_FADE_IN → TAS_TUNNEL / TAS_TUNNEL_CONTINUE →
TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN → (off)`. `m_pPortalSpace` is a
`UIElement_Viewport` rendering the tunnel scene (creature-mode objects +
`DISTANT_LIGHT` + smartbox FOV; `SetVisible(1)` on enter, `SetVisible(0)` on the
`TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN` edge at `219742-219747`).
**Key architectural unification:** the `TAS_TUNNEL`/`TAS_TUNNEL_CONTINUE` **hold
state's exit gates on the same readiness predicate as G.3a** — retail's loading
visual and the hold-until-hydration gate are *one mechanism* (the tunnel is the
visual form of the hold). G.3a ships the bare PortalSpace freeze; G.3c wraps it
in the tunnel viewport + the fade FSM, exit-gated identically.
**Port workflow:** grep-named → decompile `BeginTeleportAnimation` + the FSM →
pseudocode (durations, fade math, viewport scene construction) → port → test.
Detail deferred to the G.3c implementation phase; this spec fixes the design
(states, transitions, the readiness-gated hold) + the oracle pointers.
### 3.4 G.3d — Recall game-actions
Outbound **zero-payload** game-action builders (r09 §7.1): `TeleToLifestone
0x0063`, `TeleToHouse 0x0262`, `TeleToMansion 0x0278`, `TeleToMarketPlace 0x028D`,
`RecallAllegianceHometown 0x02AB`, `TeleToPkArena 0x0027`. The client only sends
the request; the server validates, plays the recall animation, then drives the
**same** `PlayerTeleport → UpdatePosition` arrival flow.
Value: (1) doubles as the **easy test lever** for G.3a/G.3c — `/ls` triggers a
teleport with no portal-click choreography; (2) completes the recall UX (keybinds
exist; the wire sends + return handling did not). Wire through the existing
command bus.
---
## 4. Data flow (the teleport happy path)
```
1. PlayerTeleport(0xF751) → OnTeleportStarted: enter PortalSpace, freeze input
[G.3c: BeginTeleportAnimation(TAS_WORLD_FADE_OUT)]
2. fake UpdatePosition(destCell) → validate dest coords → recenter streaming to dest lb
→ prioritize-load dest lb → re-send LoginComplete
3. HOLD (TeleportArrivalController.Tick, each frame in PortalSpace):
ready = SampleTerrainZ(destPos) != null && (outdoor || IsSpawnCellReady(destCell))
- not ready → stay frozen, retry [G.3c: tunnel holds in TAS_TUNNEL/_CONTINUE]
- impossible → IsSpawnClaimUnhydratable → safety-net demote + loud log
- timeout → force-snap + loud log + leave PortalSpace
4. READY → Resolve(destPos, destCell) → #111 validated-claim branch
→ WalkableFloorZNearest places on the EnvCell floor
→ SetPosition(entity + controller) → exit PortalSpace, resume input
[G.3c: TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN → off]
```
(ACE server send-order, for reference — `Player_Location.Teleport:686`:
`PlayerTeleport(seq)` → fake `UpdatePosition` (start client load) →
`DoTeleportPhysicsStateChanges` (hidden / ignoreCollisions) → real
`UpdatePosition``OnTeleportComplete` after `CreateWorldObjectsCompleted`.)
---
## 5. Error handling
| Failure | Handling | No-workaround rationale |
|---|---|---|
| Impossible / poisoned claim (cell id ∉ `[0x0100, 0x0100+NumCells)`, or no struct + no surface) | `IsSpawnClaimUnhydratable` → safety-net demote (`PhysicsEngine.Resolve` head, `:536-570`) + loud log; never hold forever | Reuses the validated #107/#111 reject; no new masking |
| Dest LB fails to stream (worker crash / corrupt dat / OOB coords) | Timeout ceiling (~10 s) → force-snap + loud log + leave PortalSpace | **Surfaces** the failure (visible bad placement + log), does not freeze the client or silence the cause; gets a divergence-register row |
| Mid-hold entity-rescue race | Already serialized by `_datLock` during recenter (verified, seam-3) | No change |
The timeout is the one judgment call: holding forever on a never-hydrating
landblock would soft-lock the client. The chosen behavior **fails loudly and
visibly** (force-snap + log), which is the opposite of a symptom-masking grace
period — it makes a broken teleport obvious rather than hiding it. It is recorded
as a deliberate adaptation (retail loads synchronously; async streaming has no
direct analog).
---
## 6. Testing & acceptance
### 6.1 Headless / unit
- `TeleportArrivalController` FSM: `Idle → Holding → Placing` happy path;
impossible-claim immediate reject; timeout force-snap; ready-predicate gating
(fake `IsLandblockApplied` / `IsSpawnCellReady`).
- Hydration-decouple test: a geometry-less EnvCell (empty render mesh, non-empty
physics BSP) still gets `CacheCellStruct` + `BuildLoadedCell`.
- `TeleportFlowTests`: fake `PlayerTeleport` + `UpdatePosition` wire → controller
phase transitions + input-gate flips.
- `DungeonLandblockDatProbeTests` (exists): pins `0x0125` = flat + 71 cells.
- G.3c: `TeleportAnimState` FSM transition test (state sequence + the
readiness-gated `TAS_TUNNEL` hold-exit).
- G.3d: recall-builder byte tests (opcode + empty payload, per builder).
### 6.2 Visual gate (the acceptance test — after G.3a)
Teleport into the meeting-hall dungeon via the portal:
- Player stands **in the dungeon cell**, on the floor (not ocean, not falling).
- The dungeon renders; navigate **35 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.

View file

@ -1015,21 +1015,36 @@ public sealed class GameWindow : IDisposable
// integrates gravity against an empty world and free-falls
// the player into the void (retail loads cells synchronously;
// this is the async-streaming equivalent of that invariant).
isSpawnGroundReady: () => _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)
&& _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null
// #107 gate-2 extension (2026-06-10): an INDOOR spawn claim
// additionally waits for the claimed cell's hydration so the
// entry snap's AdjustPosition validation can act (retail loads
// the cell synchronously before SetPosition; this is the
// async-streaming equivalent). Claims that can never hydrate
// (id outside the landblock's NumCells range per the dat)
// don't hold the gate — the Resolve-head safety net demotes
// them loudly.
&& (!_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
|| sp.Position is not { } spawnClaim
|| spawnClaim.LandblockId == 0
|| _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId)
|| IsSpawnClaimUnhydratable(spawnClaim.LandblockId)),
isSpawnGroundReady: () =>
{
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return false;
// #107 / #135: spawn-ground readiness is spawn-claim aware. For an
// INDOOR claim (sealed dungeon / building interior) the ground the
// player lands on is the EnvCell FLOOR (its BSP), so gate on the
// cell's hydration (IsSpawnCellReady) — NOT the terrain heightmap.
// A dungeon's cells sit in their landblock at an arbitrary (often
// negative) offset, so the spawn's WORLD position can fall in a
// NEIGHBOUR terrain landblock that the #135 dungeon collapse
// deliberately does not load; requiring terrain there hangs login
// forever (cellReady true, SampleTerrainZ null). Retail loads the
// cell synchronously and places the player on the cell floor —
// cellReady is the faithful indoor equivalent (#106/#107, AD-2).
// (Before #135 this only passed by accident: the 25×25 window
// happened to stream the neighbour terrain.)
if (_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
&& sp.Position is { } spawnClaim
&& spawnClaim.LandblockId != 0
&& (spawnClaim.LandblockId & 0xFFFFu) >= 0x0100u
&& !IsSpawnClaimUnhydratable(spawnClaim.LandblockId))
return _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId);
// Outdoor spawn, OR an unhydratable indoor claim that will demote to
// an outdoor position: hold until the terrain under the spawn streams
// (the original #106 gate — entering against an empty world free-falls
// the player into the void).
return _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null;
},
enterPlayerMode: EnterPlayerModeFromAutoEntry);
}
@ -2086,6 +2101,7 @@ public sealed class GameWindow : IDisposable
state: _worldState,
nearRadius: _nearRadius,
farRadius: _farRadius,
clearPendingLoads: _streamer.ClearPendingLoads,
removeTerrain: id =>
{
// Phase G.2: release any LightSources attached to entities
@ -2608,6 +2624,57 @@ public sealed class GameWindow : IDisposable
// landblock; each neighbor landblock is offset by 192 units per step.
int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
// G.3 (#133): recenter streaming onto the player's spawn landblock at
// login. The streaming center (_liveCenterX/_liveCenterY) is pinned to
// the startup default (Holtburg, 0xA9B4) and is otherwise only moved by
// the teleport-arrival path (OnLivePositionUpdated, ~line 4901). A
// character saved INSIDE a far dungeon spawns with that dungeon's
// landblock id, but the center never followed it, so the dungeon (tens
// of km away in world space) never streamed and the #107 auto-entry
// gate's SampleTerrainZ(pe.Position) waited forever — the player hung
// frozen at login. Mirror the teleport-arrival recenter HERE, for the
// PLAYER's spawn only, BEFORE the world-space translation below: when
// the spawn landblock differs from the current center, move the center
// onto it so the spawn maps to (PositionX, PositionY, PositionZ) in the
// new center frame (identical to the teleport path's
// `newWorldPos = new Vector3(p.PositionX, p.PositionY, p.PositionZ)`),
// and the next StreamingController.Tick observes the new center and
// streams the spawn landblock.
//
// No-op for a normal Holtburg login: the saved spawn landblock equals
// the default center, so the guard is false and origin/worldPos are
// byte-identical to the pre-fix path. Gated on the player guid so NPC /
// object spawns never move the center. Idempotent + thrash-free: a
// re-sent CreateObject for the same spawn landblock leaves the center
// already-equal, so the guard is false on every repeat.
if (spawn.Guid == _playerServerGuid
&& (lbX != _liveCenterX || lbY != _liveCenterY))
{
Console.WriteLine(
$"live: login spawn — recentering streaming from ({_liveCenterX},{_liveCenterY}) " +
$"to ({lbX},{lbY}) for player spawn @0x{p.LandblockId:X8}");
_liveCenterX = lbX;
_liveCenterY = lbY;
}
// #135: the instant we know the player spawned into a SEALED dungeon,
// pre-collapse streaming to that single landblock — BEFORE the first
// StreamingController.Tick bootstraps the 25×25 ocean-grid window. The
// player isn't placed yet (physics CurrCell is null), so the per-frame
// insideDungeon gate stays false for the entire hydration window and
// NormalTick would otherwise load ~24 neighbor dungeons then unload them
// (the login FPS ramp the user reported — 10 fps slowly climbing). Sealed-
// dungeon only: a cottage/inn interior (SeenOutside) keeps its outdoor
// surround. We hold _datLock here, and IsSealedDungeonCell re-takes it
// (reentrant); the controller call is render-thread-safe (Channel writes).
if (spawn.Guid == _playerServerGuid
&& _streamingController is not null
&& IsSealedDungeonCell(p.LandblockId))
{
_streamingController.PreCollapseToDungeon(lbX, lbY);
}
var origin = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
@ -4621,9 +4688,17 @@ public sealed class GameWindow : IDisposable
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{
// Phase A.1: track the most recently updated entity's landblock so the
// streaming controller can follow the player. TODO: filter by our own
// character guid once we reliably know it from CharacterList.
// Phase A.1 / #135: track the PLAYER's last server-known landblock so the
// streaming controller can follow the player in the fly-camera / pre-player-mode
// (login hold) views. Filtered to our OWN character guid — resolving the original
// Phase A.1 TODO. An arbitrary NPC's UpdatePosition from a far outdoor landblock
// must NOT move the streaming observer: during a dungeon-login hold (player not
// yet placed, so _playerController is null and the PortalSpace observer branch
// can't apply) that would drift the observer off the pre-collapsed dungeon
// landblock and trip ExitDungeonExpand, re-streaming the 25×25 neighbor window
// the pre-collapse just suppressed. _playerServerGuid is set from CharacterList
// (~line 1984) before world entry, so it is valid by the time updates arrive.
if (update.Guid == _playerServerGuid)
_lastLivePlayerLandblockId = update.Position.LandblockId;
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
@ -5046,7 +5121,7 @@ public sealed class GameWindow : IDisposable
entity.Rotation = rmState.Body.Orientation;
}
// Phase B.3: portal-space arrival detection.
// Phase B.3 / G.3a (#133): portal-space arrival detection.
// Only runs for our own player character while in PortalSpace.
if (_playerController is not null
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
@ -5060,18 +5135,6 @@ public sealed class GameWindow : IDisposable
bool differentLandblock = (lbX != oldLbX || lbY != oldLbY);
// #107 (2026-06-10): ANY player position update while in PortalSpace
// IS the teleport arrival. Retail/holtburger exit portal space on the
// next position event unconditionally (holtburger messages.rs
// PlayerTeleport handler: log + LoginComplete; the destination applies
// through the normal position flow — no distance test). The old
// `differentLandblock || farAway(>100m)` arrival gate was an
// invention: ACE's same-landblock short-hop position corrections
// (e.g. right after an indoor login) matched neither condition, so
// PortalSpace never exited and movement input stayed frozen for the
// whole session (the #107 "input ignored" wedge shape —
// flood-fix-gate2.log: `teleport started (seq=1)` with no arrival).
{
Console.WriteLine(
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
@ -5079,38 +5142,104 @@ public sealed class GameWindow : IDisposable
System.Numerics.Vector3 newWorldPos;
if (differentLandblock)
{
// 1. Recenter the streaming controller on the new landblock.
// 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;
// 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)
// #135: pre-collapse on teleport into a sealed dungeon too — same
// race as login. The destination isn't placed until it hydrates, so
// without this NormalTick loads the full neighbor window during the
// arrival hold. The PortalSpace observer branch (OnUpdate) keeps the
// observer pinned to _liveCenterX/Y while held, so the stale frozen
// player position can't drift the observer off the dungeon and re-expand.
if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
_streamingController.PreCollapseToDungeon(lbX, lbY);
}
else
{
// 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;
// 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(
newWorldPos, newCellId,
System.Numerics.Vector3.Zero, _playerController.StepUpHeight);
destPos, destCell, 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;
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);
// 4. Recenter chase camera on the new position.
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
playerVelocity: System.Numerics.Vector3.Zero,
@ -5118,20 +5247,14 @@ public sealed class GameWindow : IDisposable
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.
// 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());
}
}
}
/// <summary>
/// Phase B.3: fires when the server sends a PlayerTeleport (0xF751).
@ -5144,6 +5267,7 @@ public sealed class GameWindow : IDisposable
{
if (_playerController is not null)
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
EnsureTeleportArrivalController();
Console.WriteLine($"live: teleport started (seq={sequence})");
}
@ -5266,6 +5390,11 @@ public sealed class GameWindow : IDisposable
private static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity)
=> entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id;
// #131 [outstage-pt] probe state (throwaway — strip when #131 closes).
private string? _lastOutStagePtSig;
private readonly HashSet<uint> _outStageUnmatchedScratch = new();
private readonly HashSet<uint> _outStageMatchedScratch = new();
private static System.Numerics.Vector3 SkyPesAnchor(
AcDream.Core.World.SkyObjectData obj,
System.Numerics.Vector3 cameraWorldPos)
@ -5765,15 +5894,15 @@ 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.
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.
// 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, 0.02f);
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
@ -5781,6 +5910,40 @@ public sealed class GameWindow : IDisposable
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;
// Phase A8: register the cell with EnvCellRenderer for rendering.
// staticObjects is empty — cell stabs continue as separate WorldEntity
// records via the dispatcher (see lines below for the unchanged stab path).
@ -5793,25 +5956,6 @@ public sealed class GameWindow : IDisposable
cellWorldPosition: cellOrigin,
cellRotation: envCell.Position.Orientation,
staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>());
// Step 4: build LoadedCell for portal visibility — with the
// PHYSICS (unlifted) transform. The +0.02 m render lift above
// is a DRAW concern (shell z-fighting vs terrain); feeding it
// into the visibility graph shifted every HORIZONTAL portal
// plane 2 cm up, putting an eye standing on a deck/landing
// 1020 mm BELOW the lifted plane — outside the side test's
// ±10 mm in-plane window — so the cell behind the portal was
// side-culled: the tower-top staircase vanish + roof flap
// (#119-residual; captured live at eye z=126.803 vs the
// 010A→0107 plane at 126.80, reproduced ONLY with the lift in
// TowerAscentReplayTests.CapturedTopOfStairs_*). Vertical
// doorways were immune (the lift slides their planes along
// themselves), which is why this hit exactly stairs, decks,
// and cellar mouths.
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
// Cache CellStruct physics BSP for indoor collision (UNCHANGED).
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
}
}
}
@ -5828,6 +5972,17 @@ public sealed class GameWindow : IDisposable
.DumpEntitySourceIds.Contains(stab.Id);
int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0;
// #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to
// nothing (GfxObj id 0) at any runtime distance, so retail's distance-based
// degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the
// WorldBuilder editor shows it at the origin. acdream's render path came from
// WB (no distance LOD), so without this skip it draws the marker forever (the
// red/green dungeon "cone"). Bare-GfxObj stabs are checked here; Setup stabs
// skip per-part below (a Setup that is ALL markers drops via meshRefs.Count==0).
if ((stab.Id & 0xFF000000u) == 0x01000000u
&& AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, stab.Id))
continue;
var meshRefs = new List<AcDream.Core.World.MeshRef>();
var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
if ((stab.Id & 0xFF000000u) == 0x01000000u)
@ -5861,6 +6016,12 @@ public sealed class GameWindow : IDisposable
}
foreach (var mr in flat)
{
// #136: skip an editor-only marker PART (retail hides it at runtime
// distance). The #136 dungeon "cone" is Setup 0x02000C39 whose sole
// part GfxObj 0x010028CA is such a marker — skipping it empties
// meshRefs and the whole stab drops below.
if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, mr.GfxObjId))
continue;
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null)
{
@ -6949,7 +7110,27 @@ public sealed class GameWindow : IDisposable
int observerCx = _liveCenterX;
int observerCy = _liveCenterY;
if (_playerMode && _playerController is not null)
if (_playerMode && _playerController is not null
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace)
{
// Teleport hold (#135): the local player position is frozen at the
// PRE-teleport spot, expressed in the OLD center frame, but
// _liveCenterX/_liveCenterY were already recentered onto the
// destination landblock (OnLivePositionUpdated). Follow the
// destination directly — the stale position-derived offset
// (_liveCenterX + floor(frozenPos/192)) could land ≥2 landblocks off
// the dungeon and trip ExitDungeonExpand, re-streaming the very
// neighbor window the pre-collapse just suppressed. Correct for an
// outdoor teleport too: pre-load the destination during the hold.
//
// NOTE: these assignments equal the observerCx/Cy defaults initialized
// above — the LOAD-BEARING effect of this branch is INHIBITING the
// position-derived offset in the else-if below while the player position
// is frozen, not the (redundant) assignment. Kept explicit for clarity.
observerCx = _liveCenterX;
observerCy = _liveCenterY;
}
else if (_playerMode && _playerController is not null)
{
// Player mode: follow the physics-resolved player position.
// The player walks via the local physics engine; the server
@ -6961,13 +7142,29 @@ public sealed class GameWindow : IDisposable
observerCy = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
}
else if (_liveSession is not null
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld
&& _lastLivePlayerLandblockId is { } lid)
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld)
{
// Live, 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)
{
// Live mode (fly camera): follow the server's last-known player position.
observerCx = (int)((lid >> 24) & 0xFFu);
observerCy = (int)((lid >> 16) & 0xFFu);
}
// else: keep the _liveCenterX/_liveCenterY default (the spawn recenter).
}
else
{
// Offline: project the fly camera's world-space position back into
@ -6980,7 +7177,37 @@ public sealed class GameWindow : IDisposable
observerCy = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f);
}
_streamingController.Tick(observerCx, observerCy);
// Dungeon gate (#133 FPS): when the player stands in a SEALED EnvCell
// (indoor cell that doesn't see outside — the same predicate that kills
// the sun/sky, playerInsideCell below), collapse streaming to the single
// dungeon landblock. AC dungeons have no adjacent landblocks; the 25×25
// window otherwise pulls in ~129 unrelated ocean-grid dungeons. Building
// interiors (cottage/inn) have SeenOutside cells, so they are NOT gated
// and keep their surrounding terrain.
// True only for a sealed indoor cell. Read the physics CurrCell's own
// SeenOutside (ObjCell.SeenOutside, set from the EnvCell dat flags) rather
// than the render registry: the registry lookup only succeeds AFTER the
// landblock FINALIZES (~tens of seconds for a 205-cell dungeon), which
// delayed the collapse and let the full 25×25 neighbor window churn in
// first (the "~30s to stabilize" report). CurrCell.SeenOutside is set the
// moment the player is placed, so the collapse now engages at the snap.
bool insideDungeon = false;
if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell pcEnv
&& !pcEnv.SeenOutside)
{
insideDungeon = true;
// Pin the collapse to the cell's OWN landblock (cell id high 16 bits),
// NOT the position-derived observer landblock. A dungeon's EnvCells sit
// at arbitrary world coords (the "ocean" placement) with negative local
// offsets, so floor(pp.Y/192) lands one landblock off — which collapses
// onto the WRONG landblock and unloads the real dungeon, nulling CurrCell
// and breaking the render (the Bug-A coordinate class). The cell id is the
// authoritative landblock.
uint cellLb = pcEnv.Id >> 16;
observerCx = (int)((cellLb >> 8) & 0xFFu);
observerCy = (int)(cellLb & 0xFFu);
}
_streamingController.Tick(observerCx, observerCy, insideDungeon);
// Re-inject persistent entities rescued from unloaded landblocks
// into the current center landblock (the one the observer is in).
@ -7000,6 +7227,12 @@ public sealed class GameWindow : IDisposable
// Step 2: routed through the controller; functionally identical.
_liveSessionController?.Tick();
// G.3a (#133): advance any held teleport arrival. Runs AFTER streaming
// (which applies the destination landblock) and the live-session drain
// (which may have just called BeginArrival), so a destination that
// hydrated this frame is placed the same frame.
_teleportArrival?.Tick();
// Phase K.1a — tick the input dispatcher so Hold-type bindings
// re-fire while their chord is held. K.1b adds the subscribers
// that actually consume the events.
@ -7138,10 +7371,24 @@ public sealed class GameWindow : IDisposable
// so it doesn't get frustum-culled when the player walks away from
// the spawn landblock. Without this, the entity stays in the spawn
// landblock's entity list and disappears when that landblock is culled.
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);
uint currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
}
_worldState.RelocateEntity(pe, currentLb);
}
@ -7717,6 +7964,25 @@ public sealed class GameWindow : IDisposable
_sceneLightingUbo?.Upload(ubo);
// #133 A7 (2026-06-13): objective dungeon-lighting probe. One
// rate-limited [light] line — insideCell / ambient / sun /
// registered-point-lights / active-slot-count / player cell — so
// the dungeon-dim question is self-verifiable from launch.log
// without a screenshot. RegisteredCount is point/spot lights only
// (the sun lives in LightManager.Sun, never in the _all list);
// ubo.CellAmbient.W is the shader active-slot count, which counts
// the (zeroed) sun slot indoors. Inert unless ACDREAM_PROBE_LIGHT=1.
AcDream.Core.Rendering.RenderingDiagnostics.EmitLight(
insideCell: playerInsideCell,
ambientR: Lighting.CurrentAmbient.AmbientColor.X,
ambientG: Lighting.CurrentAmbient.AmbientColor.Y,
ambientB: Lighting.CurrentAmbient.AmbientColor.Z,
sunIntensity: Lighting.Sun?.Intensity ?? 0f,
registeredLights: Lighting.RegisteredCount,
activeLights: (int)ubo.CellAmbient.W,
playerCellId: playerRoot?.CellId ?? 0u,
lights: Lighting);
// Never cull the landblock the player is currently on.
uint? playerLb = null;
if (_playerMode && _playerController is not null)
@ -7796,9 +8062,9 @@ public sealed class GameWindow : IDisposable
// OutdoorCellNode.Build filters to exit portals internally. The clipRoot flip +
// OutsideView terrain integration that consumes this is the next (cutover) step.
_outdoorNode = null;
if (viewerRoot is null && viewerCellId != 0u)
{
_outdoorNodeBuildingCells.Clear();
if (viewerRoot is not null || viewerCellId != 0u)
{
// T2 (BR-4): draw-driven flood gating. Retail floods a building's
// interior exactly when its shell DRAWS and an aperture survives
// the view (DrawBuilding Ghidra 0x0059f2a0: per-view viewconeCheck
@ -7813,6 +8079,12 @@ public sealed class GameWindow : IDisposable
// Per-building iteration is also the FPS fix the 2026-06-07
// Chebyshev hack approximated: dozens of AABB tests instead of an
// O(all loaded cells) portal sweep.
// #124: the gather now runs for INTERIOR roots too — retail's
// look-in executes inside LScape::draw for ANY root with a
// non-empty outside view (DrawCells pc:432719). The renderer
// routes interior-root look-ins to its landscape-stage sub-pass
// (DrawBuildingLookIns); the root's own building self-excludes
// via the seed eye-side test.
foreach (var registry in _buildingRegistries.Values)
{
foreach (var b in registry.All())
@ -7827,10 +8099,11 @@ public sealed class GameWindow : IDisposable
_outdoorNodeBuildingCells.Add(bc);
}
}
if (viewerRoot is null)
_outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId);
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[outdoor-node] cell=0x{viewerCellId:X8} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)"));
$"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)"));
}
uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u;
@ -7956,10 +8229,10 @@ public sealed class GameWindow : IDisposable
var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewDrawContext
{
RootCell = clipRoot,
// R-A2: outdoor root floods each nearby building per-building (not via the root). The
// gather above populates _outdoorNodeBuildingCells only on outdoor-node frames, so it
// is fresh here exactly when clipRoot.IsOutdoorNode; null for interior roots.
NearbyBuildingCells = clipRoot.IsOutdoorNode ? _outdoorNodeBuildingCells : null,
// R-A2: outdoor root floods each nearby building per-building (not via the root).
// #124: interior roots get the gather too — the renderer routes them to the
// landscape-stage look-in sub-pass instead of the merge.
NearbyBuildingCells = _outdoorNodeBuildingCells,
ViewerEyePos = viewerEyePos,
ViewProjection = envCellViewProj,
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
@ -7985,6 +8258,22 @@ public sealed class GameWindow : IDisposable
renderWeather: playerSeenOutside,
kf,
environOverrideActive),
// #131/#132: the late phase — dynamics meshes + scene
// particles + weather AFTER the look-ins (FlushAlphaList
// deferral).
DrawLandscapeSliceLate = lateCtx =>
DrawRetailPViewLandscapeSliceLate(
lateCtx,
camera,
frustum,
camPos,
playerLb,
animatedIds,
renderSky,
renderWeather: playerSeenOutside,
kf,
environOverrideActive,
isOutdoorRoot: clipRoot.IsOutdoorNode),
// T1: retail's depth discipline (PView::DrawCells, Ghidra 0x005a4840).
// INTERIOR roots: one FULL depth clear between the outside stage and
// the interior stage, then SEALS re-stamp every outside-leading
@ -8005,6 +8294,26 @@ public sealed class GameWindow : IDisposable
DrawExitPortalMasks = sliceCtx =>
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
forceFarZ: clipRoot.IsOutdoorNode),
// #124: look-in apertures are ALWAYS the punch (retail
// maxZ1), independent of the root-keyed selector above.
DrawLookInPortalPunch = sliceCtx =>
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
forceFarZ: true),
// #131: unattached emitters under an interior root — the
// landscape-stage pass (the outdoor T3 pass below is gated
// IsOutdoorNode, so the two never both run).
DrawUnattachedSceneParticles = () =>
{
if (_particleSystem is null || _particleRenderer is null)
return;
DisableClipDistances();
_particleRenderer.Draw(
_particleSystem,
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
emitter => emitter.AttachedObjectId == 0);
},
DrawCellParticles = sliceCtx =>
DrawRetailPViewCellParticles(sliceCtx, camera, camPos),
DrawDynamicsParticles = survivors =>
@ -8125,20 +8434,26 @@ public sealed class GameWindow : IDisposable
&& _particleSystem is not null && _particleRenderer is not null)
{
// T3 (BR-5): unattached emitters (campfires, ground effects —
// AttachedObjectId == 0) under the OUTDOOR root. The unified
// path's attached emitters draw via the landscape slice + the
// per-cell callbacks; unattached ones had NO pass on
// outdoor-node frames (the unattached-particles-dropped-
// outdoors divergence, adjusted-confirmed). The outdoor root's
// outside view is full-screen (cone pass-all); depth test
// composites them against the world.
// AttachedObjectId == 0) under the OUTDOOR root. The outdoor
// root's outside view is full-screen (cone pass-all); depth
// test composites them against the world.
// #132 outdoor sibling: ATTACHED outdoor-static scene emitters
// (lantern/candle flames) moved here too — drawn in the
// landscape slice they were overpainted by merged building
// interiors (drawn later) whenever a punched aperture sat
// behind them. Post-frame, depth is complete and the flames
// composite correctly. The owner-id set is the late slice's
// (full-screen cone outdoors). Cell-pass and dynamics-pass
// emitters keep their own passes (no double-draw: their owners
// are never in the outdoor-static id set).
sigSceneParticles = sigSceneParticles == "none" ? "unattached" : sigSceneParticles + "+unattached";
_particleRenderer.Draw(
_particleSystem,
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
emitter => emitter.AttachedObjectId == 0);
emitter => emitter.AttachedObjectId == 0
|| _outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId));
}
// Bug A fix (post-#26 worktree, 2026-04-26): weather sky
@ -9800,12 +10115,113 @@ public sealed class GameWindow : IDisposable
animatedEntityIds: animatedIds);
}
_outdoorSceneParticleEntityIds.Clear();
foreach (var entity in sliceCtx.OutdoorEntities)
_outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity));
// #131/#132: scene particles + weather MOVED to the LATE phase
// (DrawRetailPViewLandscapeSliceLate) — they must composite AFTER the
// #124 look-ins (retail's FlushAlphaList deferral, DrawCells
// pc:432722); drawn here they were overpainted by far-building
// interiors wherever a look-in aperture sat behind them.
if (scissor)
_gl!.Disable(EnableCap.ScissorTest);
DisableClipDistances();
if (_outdoorSceneParticleEntityIds.Count > 0
}
// #131/#132: the LATE landscape phase — per slice, invoked by the renderer
// AFTER the #124 look-in sub-pass, still pre-clear. Outside-stage
// dynamics' meshes (a translucent portal swirl blends over a far interior
// instead of being overpainted by it — translucents write no depth to
// protect themselves) + ALL attached scene particles (statics' flames
// included — the #132 candle) + weather. Retail equivalent: alpha draws
// collected during LScape::draw flush ONCE after it
// (D3DPolyRender::FlushAlphaList, PView::DrawCells pc:432722).
private void DrawRetailPViewLandscapeSliceLate(
AcDream.App.Rendering.RetailPViewLandscapeLateSliceContext lateCtx,
ICamera camera,
FrustumPlanes? frustum,
System.Numerics.Vector3 camPos,
uint? playerLb,
HashSet<uint>? 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<uint, AcDream.Core.World.WorldEntity>?)null);
_wbDrawDispatcher!.Draw(camera, new[] { dynamicsEntry }, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: null,
animatedEntityIds: animatedIds);
}
_outdoorSceneParticleEntityIds.Clear();
foreach (var entity in lateCtx.ParticleOwners)
_outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity));
// #131 [outstage-pt] probe: the slice Scene-particle id set + how many
// live emitters the filter would actually match, plus the distinct
// UNMATCHED attached owner ids (the portal-identification handle —
// an emitter whose owner never lands in the set draws nowhere
// indoors). Print-on-change.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled
&& _particleSystem is not null)
{
int matched = 0, attached = 0, unattached = 0;
_outStageUnmatchedScratch.Clear();
_outStageMatchedScratch.Clear();
foreach (var (emitter, _) in _particleSystem.EnumerateLive())
{
if (emitter.AttachedObjectId == 0) { unattached++; continue; }
attached++;
if (_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId))
{
matched++;
if (_outStageMatchedScratch.Count < 48)
_outStageMatchedScratch.Add(emitter.AttachedObjectId);
}
else if (_outStageUnmatchedScratch.Count < 12)
_outStageUnmatchedScratch.Add(emitter.AttachedObjectId);
}
var unm = new System.Text.StringBuilder(96);
foreach (uint id in _outStageUnmatchedScratch)
unm.Append(System.FormattableString.Invariant($" 0x{id:X8}"));
var mat = new System.Text.StringBuilder(192);
foreach (uint id in _outStageMatchedScratch)
mat.Append(System.FormattableString.Invariant($" 0x{id:X8}"));
string ptSig = System.FormattableString.Invariant(
$"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached} matchedIds=[{mat}] unmatchedIds=[{unm}]");
if (ptSig != _lastOutStagePtSig)
{
_lastOutStagePtSig = ptSig;
Console.WriteLine("[outstage-pt] " + ptSig);
}
}
// #132 outdoor sibling: under an OUTDOOR root the merged building
// interiors draw AFTER this stage (DrawEnvCellShells) — a flame drawn
// here is overpainted whenever a punched aperture sits behind it
// (user-confirmed at the outdoor candle). Outdoor roots therefore
// SKIP the slice Scene pass and draw attached scene particles in the
// post-frame pass alongside the T3 unattached pass (the id set built
// above carries over — the outdoor root has a single full-screen
// slice). Interior roots draw here: the look-ins already ran and the
// post-clear seal discipline owns the rest of the frame.
if (!isOutdoorRoot
&& _outdoorSceneParticleEntityIds.Count > 0
&& _particleSystem is not null
&& _particleRenderer is not null)
{
@ -9881,9 +10297,16 @@ public sealed class GameWindow : IDisposable
if (localVerts.Length < 3)
continue;
// cell.WorldTransform is the PHYSICS (unlifted) transform (f35cb8b);
// the shell that rasterizes this aperture draws +ShellDrawLiftZ
// higher. The seal/punch is a DRAW — stamp depth in the same lifted
// space or the stamp sits 2 cm below the drawn hole (#130 family).
int n = System.Math.Min(localVerts.Length, world.Length);
for (int v = 0; v < n; v++)
{
world[v] = System.Numerics.Vector3.Transform(localVerts[v], cell.WorldTransform);
world[v].Z += AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ;
}
_portalDepthMask.DrawDepthFan(world[..n], viewProjection, sliceCtx.Slice.Planes, forceFarZ);
}
@ -10136,26 +10559,18 @@ public sealed class GameWindow : IDisposable
// Phase W Stage 4: set a glScissor to an NDC AABB (the doorway / OutsideView region) in
// framebuffer pixels and enable the scissor test; returns true iff applied (the caller then
// disables EnableCap.ScissorTest after its draw/clear). Mirrors the terrain Scissor-mode
// NDC→pixel conversion (one source for the box math). Used to confine the sky/weather particle
// passes (particle.vert has no gl_ClipDistance) and the conditional doorway depth-only Z-clear
// to the doorway opening. Returns false (no scissor) when not applied (outdoor / no window).
// disables EnableCap.ScissorTest after its draw/clear). Used to bracket the landscape slice
// (sky, terrain, statics, weather — particle.vert has no gl_ClipDistance). Returns false
// (no scissor) when not applied (outdoor / no window). The box is the CONSERVATIVE outer
// bound (NdcScissorRect): the previous Floor(origin)+Ceiling(size) form cut up to one pixel
// off the TOP/RIGHT edges at unlucky alignments — the #130 doorway top-edge background strip.
private bool BeginDoorwayScissor(bool apply, System.Numerics.Vector4 ndcAabb)
{
if (!apply || _window is null) return false;
var fb = _window.FramebufferSize;
// NDC [-1,1] → window pixels. Clamp so a doorway opening that extends past a screen edge
// still yields a valid box (same clamp the terrain Scissor path uses).
float nx0 = System.Math.Clamp(ndcAabb.X, -1f, 1f);
float ny0 = System.Math.Clamp(ndcAabb.Y, -1f, 1f);
float nx1 = System.Math.Clamp(ndcAabb.Z, -1f, 1f);
float ny1 = System.Math.Clamp(ndcAabb.W, -1f, 1f);
int px = (int)System.MathF.Floor((nx0 * 0.5f + 0.5f) * fb.X);
int py = (int)System.MathF.Floor((ny0 * 0.5f + 0.5f) * fb.Y);
int pw = (int)System.MathF.Ceiling((nx1 - nx0) * 0.5f * fb.X);
int ph = (int)System.MathF.Ceiling((ny1 - ny0) * 0.5f * fb.Y);
var box = NdcScissorRect.ToPixels(ndcAabb, fb.X, fb.Y);
_gl!.Enable(EnableCap.ScissorTest);
_gl.Scissor(px, py, (uint)System.Math.Max(1, pw), (uint)System.Math.Max(1, ph));
_gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height);
return true;
}
@ -10523,6 +10938,7 @@ public sealed class GameWindow : IDisposable
state: _worldState,
nearRadius: _nearRadius,
farRadius: _farRadius,
clearPendingLoads: _streamer.ClearPendingLoads,
removeTerrain: id =>
{
if (_lightingSink is not null &&
@ -11771,6 +12187,35 @@ public sealed class GameWindow : IDisposable
return unhydratable;
}
// #135: is this server-sent cell id a SEALED dungeon EnvCell — an indoor cell
// (low 16 bits >= 0x0100) whose EnvCell dat flags lack SeenOutside? Distinguishes
// a real dungeon (collapse streaming to its single landblock) from a building
// interior (cottage/inn — SeenOutside, which keeps its outdoor surround) and from
// an outdoor cell, WITHOUT needing the cell hydrated. Reads the SAME dat flag as
// the hydration path (BuildLoadedCell, ~line 5999) and as the physics
// CurrCell.SeenOutside the per-frame insideDungeon gate reads — so the pre-collapse
// decision matches the eventual gate decision exactly. Returns false when the dat
// lacks the cell (out-of-range index / missing record) so we never collapse on a
// guess. The dat read is reentrant-safe under _datLock (Monitor) — callers may
// already hold it (the login spawn handler does).
private bool IsSealedDungeonCell(uint cellId)
{
// Not an EnvCell: the sub-0x0100 outdoor sub-cells AND the 0xFFFE/0xFFFF
// structural shell ids (LandBlockInfo / LandBlock heightmap). A naive
// `< 0x0100` test MISSES 0xFFFF (65535 is not < 256), and Get<EnvCell> 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<DatReaderWriter.DBObjs.EnvCell>(cellId);
return envCell is not null
&& !envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside);
}
private void EnterPlayerModeFromAutoEntry()
{
_playerMode = true;

View file

@ -0,0 +1,45 @@
// NdcScissorRect.cs
//
// NDC AABB → framebuffer-pixel scissor box, CONSERVATIVE (outer bound).
// The scissor that brackets a landscape/doorway slice is a fallback BOUND on
// the slice's view region (AD-17 in the divergence register): it must CONTAIN
// every fragment the per-fragment plane clip would keep. Under-inclusion is
// the bug class — the #130 doorway top-edge background strip was this box
// computed as Floor(origin) + Ceiling(size), whose far edge
// floor(min)+ceil(maxmin) 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 ≥ X00.5 ⇒ i ≥ floor(X0) and i ≤ X10.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
{
/// <summary>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.</summary>
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));
}
}

View file

@ -53,6 +53,7 @@ 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)
out float gl_ClipDistance[8];
void main()
{
@ -62,7 +63,14 @@ void main()
if (uForceFarZ == 1)
clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail)
else if (uDepthBias > 0.0)
clipPos.z -= uDepthBias * clipPos.w; // #117 mark-pass bias (see DrawDepthFan)
{
// #117 mark-pass bias, #129 eye-space cap. clipPos.w = eye depth d;
// an NDC bias b spans ~b*d*d/near meters of eye depth, so the
// constant-NDC form alone reached METERS at distance (door-shaped
// leaks through hills/houses). Keep in sync with MarkBiasNdc.
float biasNdc = min(uDepthBias, uDepthBiasEyeCapN / max(clipPos.w * clipPos.w, 1e-6));
clipPos.z -= biasNdc * clipPos.w;
}
gl_Position = clipPos;
}";
@ -79,6 +87,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
private readonly int _locPlanes;
private readonly int _locForceFarZ;
private readonly int _locDepthBias;
private readonly int _locDepthBiasEyeCapN;
private const int MaxFanVerts = 32;
private readonly float[] _scratch = new float[MaxFanVerts * 3];
@ -104,6 +113,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
_locPlanes = _gl.GetUniformLocation(_program, "uPlanes");
_locForceFarZ = _gl.GetUniformLocation(_program, "uForceFarZ");
_locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias");
_locDepthBiasEyeCapN = _gl.GetUniformLocation(_program, "uDepthBiasEyeCapN");
_vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer();
@ -144,10 +154,37 @@ void main() { } // depth-only: color writes are masked off by the caller state
/// stencil below). The bias keeps the #108 case covered — terrain
/// hugging the door plane (centimeters in front of the aperture) must
/// still be punched; a hill or another house meters nearer must not.
/// 0.0005 NDC ≈ 6 cm at 5 m / ≈ 1 m at 20 m with znear=0.1.
/// </summary>
private const float PunchMarkDepthBias = 0.0005f;
/// <summary>
/// #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
/// <see cref="PunchMarkBiasEyeCapMeters"/>. 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.
/// </summary>
public const float PunchMarkBiasEyeCapMeters = 0.5f;
/// <summary>Retail <c>Render::znear</c> = 0.1 (decomp :342173, re-landed
/// d4b5c71). The cap conversion below assumes the production camera near
/// plane; the small f/(fn) factor (~1.00002 at far 5000) is ignored.</summary>
public const float CameraNearPlaneMeters = 0.1f;
/// <summary>CPU mirror of the vertex-shader mark-bias expression (keep in
/// sync with <c>VertSrc</c>): the NDC bias applied at eye depth
/// <paramref name="eyeDepthMeters"/>.</summary>
public static float MarkBiasNdc(float eyeDepthMeters) =>
MathF.Min(PunchMarkDepthBias,
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters
/ MathF.Max(eyeDepthMeters * eyeDepthMeters, 1e-6f));
/// <summary>
/// Draw one portal polygon as an invisible depth write, clipped to the
/// slice's clip-space half-planes. <paramref name="forceFarZ"/> selects
@ -237,6 +274,8 @@ void main() { } // depth-only: color writes are masked off by the caller state
_gl.DepthMask(false);
_gl.Uniform1(_locForceFarZ, 0);
_gl.Uniform1(_locDepthBias, PunchMarkDepthBias);
_gl.Uniform1(_locDepthBiasEyeCapN,
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters);
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
// ── PUNCH pass B: far-Z write on marked pixels only;

View file

@ -97,16 +97,31 @@ public static class PortalVisibilityBuilder
Console.WriteLine($"[pv-ERROR] chain tail(24):{tail}");
}
/// <summary>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).</summary>
public const float ShellDrawLiftZ = 0.02f;
/// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param>
/// <param name="buildingMembership">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.</param>
/// <param name="drawLiftZ">World +Z applied ONLY to the exit-portal projection feeding
/// <see cref="PortalVisibilityFrame.OutsideView"/> (a draw-space region; see
/// <see cref="ShellDrawLiftZ"/>). Flood admission, side tests, and CellViews are unaffected.
/// Production passes <see cref="ShellDrawLiftZ"/>; tests replaying visibility semantics pass 0.</param>
public static PortalVisibilityFrame Build(
LoadedCell cameraCell,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
Func<uint, bool>? buildingMembership = null)
Func<uint, bool>? buildingMembership = null,
float drawLiftZ = 0f)
{
var frame = new PortalVisibilityFrame();
if (cameraCell == null) return frame;
@ -318,8 +333,22 @@ public static class PortalVisibilityBuilder
Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]");
}
// Exit portal -> outdoors visible through this (clipped) opening.
AddRegion(frame.OutsideView, clippedRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts}");
// OutsideView gates DRAWN color (terrain/sky/scissor), and the
// shell that rasterizes this aperture draws +drawLiftZ above
// the physics transform — project the region in the SAME
// lifted space or terrain stops a lift-height short of the
// drawn lintel (#130 strip). Flood semantics keep the
// unlifted clippedRegion path above.
var outsideRegion = drawLiftZ == 0f
? clippedRegion
: ClipPortalAgainstView(
poly,
cell.WorldTransform * Matrix4x4.CreateTranslation(0f, 0f, drawLiftZ),
viewProj,
activeViewPolygons,
out _);
AddRegion(frame.OutsideView, outsideRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={outsideRegion.Count} clipVerts={clipVerts}");
continue;
}
@ -451,12 +480,18 @@ public static class PortalVisibilityBuilder
/// camera cell. It keeps the same retail distance-priority traversal and
/// neighbour reciprocal clipping once inside the building.
/// </summary>
/// <param name="seedRegion">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).</param>
public static PortalVisibilityFrame BuildFromExterior(
IEnumerable<LoadedCell> candidateCells,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity)
float maxSeedDistance = float.PositiveInfinity,
IReadOnlyList<ViewPolygon>? seedRegion = null)
{
var frame = new PortalVisibilityFrame();
var todo = new CellTodoList();
@ -503,7 +538,7 @@ public static class PortalVisibilityBuilder
poly,
cell.WorldTransform,
viewProj,
FullScreenRegion,
seedRegion ?? FullScreenRegion,
out _);
// T2 (BR-4): empty clip = no seed, no exceptions (retail's
@ -633,8 +668,9 @@ public static class PortalVisibilityBuilder
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity)
=> BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance);
float maxSeedDistance = float.PositiveInfinity,
IReadOnlyList<ViewPolygon>? seedRegion = null)
=> BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance, seedRegion);
// The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute.
private static readonly Vector2[] FullScreenQuad =

View file

@ -27,6 +27,16 @@ public sealed class RetailPViewRenderer
// R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame).
private readonly Dictionary<uint, List<LoadedCell>> _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<PortalVisibilityFrame> _lookInFrames = new();
private readonly HashSet<uint> _lookInPrepareScratch = new();
// #131/#132: the late landscape phase's scene-particle owner survivors
// (statics + outside-stage dynamics passing the slice cone).
private readonly List<WorldEntity> _lateParticleOwnerScratch = new();
// T2 (BR-4): retail has NO distance constant on the flood-admission chain
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
// GetClip + GetVisible only). The old 48 m seed cap is replaced by the
@ -54,7 +64,9 @@ public sealed class RetailPViewRenderer
ctx.RootCell,
ctx.ViewerEyePos,
ctx.CellLookup,
ctx.ViewProjection);
ctx.ViewProjection,
buildingMembership: null,
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
// R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge
// the small (~2-cell) per-building views into the frame. Retail reaches building interiors via
@ -65,6 +77,26 @@ public sealed class RetailPViewRenderer
if (ctx.RootCell.IsOutdoorNode && ctx.NearbyBuildingCells is not null)
MergeNearbyBuildingFloods(ctx, pvFrame);
// #124: interior-root building look-ins. Retail runs the look-in INSIDE
// the landscape stage for ANY root — LScape::draw is the FIRST call of
// DrawCells' outside-view branch (pc:432719), strictly BEFORE the depth
// clear (pc:432732) and the exit-portal seals (pc:432785); a far
// building seen through our doorway floods clipped to the INSTALLED
// outside view (GetClip vs current view, ConstructView(CBldPortal)
// 0x005a59a0). These frames therefore draw in DrawBuildingLookIns
// (inside the landscape stage), NEVER merged into the main frame — a
// merged cell would draw post-clear and z-fail against the root's seal
// (its geometry is beyond the door plane). The eye-side seed test
// self-excludes the root's own building (the eye is on its interior
// side). Outdoor roots keep the MergeNearbyBuildingFloods path above
// (no depth clear under outdoor roots — the merged form is equivalent
// there).
_lookInFrames.Clear();
if (!ctx.RootCell.IsOutdoorNode
&& ctx.NearbyBuildingCells is not null
&& pvFrame.OutsideView.Polygons.Count > 0)
BuildInteriorRootLookIns(ctx, pvFrame);
var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame);
UploadClipFrame(ctx.SetTerrainClipUbo);
@ -76,15 +108,31 @@ public sealed class RetailPViewRenderer
var drawableCells = new HashSet<uint>(pvFrame.OrderedVisibleCells);
UseIndoorMembershipOnlyRouting();
// #124: look-in cells need prepared shell batches + their statics routed
// into partition.ByCell (consumed ONLY by DrawBuildingLookIns — the main
// cell-object pass iterates pvFrame.OrderedVisibleCells, which never
// contains them). drawableCells itself stays the MAIN flood: it feeds the
// seals, the outside-stage predicate, and the frame result.
var prepareCells = drawableCells;
if (_lookInFrames.Count > 0)
{
_lookInPrepareScratch.Clear();
_lookInPrepareScratch.UnionWith(drawableCells);
foreach (var f in _lookInFrames)
foreach (uint c in f.OrderedVisibleCells)
_lookInPrepareScratch.Add(c);
prepareCells = _lookInPrepareScratch;
}
_envCells.PrepareRenderBatches(
ctx.ViewProjection,
ctx.CameraWorldPosition,
filter: drawableCells,
filter: prepareCells,
centerLbX: ctx.RenderCenterLbX,
centerLbY: ctx.RenderCenterLbY,
renderRadius: ctx.RenderRadius);
var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries);
var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries);
var result = new RetailPViewFrameResult
{
PortalFrame = pvFrame,
@ -213,6 +261,133 @@ public sealed class RetailPViewRenderer
}
}
// #124: per-building look-in floods for an INTERIOR root, seeded clipped
// against the OutsideView (retail: GetClip runs under the INSTALLED view —
// the accumulated doorway region — so a far building floods only within the
// doorway, ConstructView(CBldPortal) 0x005a59a0 via PView::GetClip
// 0x005a4320). Same grouping as MergeNearbyBuildingFloods; the root's own
// building self-excludes via the seed eye-side test.
private void BuildInteriorRootLookIns(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame)
{
foreach (var group in _buildingGroups.Values)
group.Clear();
foreach (var cell in ctx.NearbyBuildingCells!)
{
uint groupKey = cell.BuildingId ?? cell.CellId;
if (!_buildingGroups.TryGetValue(groupKey, out var group))
{
group = new List<LoadedCell>();
_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<WorldEntity>()));
}
}
}
// Pass 2: shells + statics, far→near.
UseIndoorMembershipOnlyRouting();
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = frame.OrderedVisibleCells[i];
_oneCell.Clear();
_oneCell.Add(cellId);
_envCells.Render(WbRenderPass.Opaque, _oneCell);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
_cellStaticScratch.Clear();
if (partition.ByCell.TryGetValue(cellId, out var bucket))
_cellStaticScratch.AddRange(bucket);
// #131 ROOT CAUSE: DYNAMICS living in a look-in cell (the
// Holtburg hall-porch PORTAL, pCell 0xA9B4017A) draw NOWHERE
// under an interior root — DrawDynamicsLast viewcone-culls
// them (the main cone has no entries for look-in cells), and
// post-clear they would z-fail against the root's seal anyway
// (the #118 lesson). Retail draws a look-in cell's objects
// inside the NESTED DrawCells (DrawObjCellForDummies,
// pc:432878+), i.e. right here in the landscape stage. Drawn
// WHOLE like the statics (AP-33's documented over-include).
// No double-draw: dynamics-last keeps culling them (their
// cell is absent from the main cone), and their emitters ride
// the DrawCellParticles call below, not DrawDynamicsParticles
// (which only sees dynamics-last cone survivors).
foreach (var e in partition.Dynamics)
if (e.ParentCellId == cellId)
_cellStaticScratch.Add(e);
if (_cellStaticScratch.Count > 0)
{
DrawEntityBucket(ctx, _cellStaticScratch, _oneCell);
// The cell-particles pass for look-in cells — retail's
// nested DrawCells draws objects WITH their emitters.
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(
cellId, slice, _cellStaticScratch));
}
}
}
}
private void DrawLandscapeThroughOutsideView(
RetailPViewDrawContext ctx,
ClipFrameAssembly clipAssembly,
@ -222,6 +397,18 @@ public sealed class RetailPViewRenderer
if (clipAssembly.OutsideViewSlices.Length == 0)
return;
// #131/#132 (the FlushAlphaList deferral): retail collects ALL alpha
// draws of the landscape stage and flushes them ONCE after LScape::draw
// (D3DPolyRender::FlushAlphaList, DrawCells pc:432722) — so translucent
// landscape content (portal swirl meshes, flame particles) composites
// AFTER the building look-ins. Our dispatcher draws translucency inside
// each Draw call, so the stage is split in TWO phases instead: EARLY =
// sky + terrain + outdoor STATIC meshes (the look-in punches need their
// depth to mark against, the #117 lesson); then the look-ins; then
// LATE = outside-stage dynamics' meshes + ALL scene particles +
// weather. Content drawn early and overlapped by a look-in aperture
// was otherwise overpainted by the far interior (translucents write no
// depth to protect themselves) — the portal-swirl/candle-flame class.
int probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
@ -243,21 +430,74 @@ public sealed class RetailPViewRenderer
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
_outdoorStaticScratch.Add(e);
}
// #118: outside-stage dynamics ride the landscape pass like retail's
// per-landcell DrawSortCell (DrawBlock 0x005a17c0, pc:430124) — drawn
// BEFORE the depth clear + seals so the seal PROTECTS their pixels in
// the aperture instead of z-killing them. Same per-slice cone test as
// the statics above. Empty under outdoor roots (see DrawInside).
probeSliceIndex++;
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
}
// #124: far-building look-ins draw HERE — still inside the landscape
// stage (their punches mark against the terrain/exterior depth just
// drawn), strictly BEFORE the depth clear + seals below, matching
// retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785).
DrawBuildingLookIns(ctx, clipAssembly, partition);
// LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn
// pre-clear so the seal protects their aperture pixels; AFTER the
// look-ins so a translucent portal mesh blends over a far interior
// instead of being overpainted) + the scene-particle owners (statics +
// dynamics cone survivors — flames ride here for the same reason).
probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
_clipFrame.SetTerrainClip(slice.Planes);
UploadClipFrame(ctx.SetTerrainClipUbo);
_entities.ClearClipRouting();
_outdoorStaticScratch.Clear(); // late: dynamics survivors
_lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors
foreach (var e in partition.OutdoorStatic)
{
EntitySphere(e, out var c, out float r);
bool ownerPass = viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r);
if (ownerPass)
_lateParticleOwnerScratch.Add(e);
// #131 owner watchlist (throwaway): ACDREAM_DUMP_ENTITY ids
// double as an ENTITY-id watchlist here — one line per watched
// outdoor-static owner per CHANGE of its cone verdict.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled
&& AcDream.Core.Rendering.RenderingDiagnostics.DumpEntitySourceIds.Contains(e.Id)
&& (!_outStageOwnerVerdicts.TryGetValue(e.Id, out bool prev) || prev != ownerPass))
{
_outStageOwnerVerdicts[e.Id] = ownerPass;
Console.WriteLine(System.FormattableString.Invariant(
$"[outstage-own] id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} pos=({e.Position.X:F1},{e.Position.Y:F1},{e.Position.Z:F1}) c=({c.X:F1},{c.Y:F1},{c.Z:F1}) r={r:F1} slice={probeSliceIndex} {(ownerPass ? "PASS" : "CULL")}"));
}
}
foreach (var e in _outsideStageDynamics)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
{
_outdoorStaticScratch.Add(e);
_lateParticleOwnerScratch.Add(e);
}
}
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled)
EmitOutStageProbe(probeSliceIndex, viewcone);
probeSliceIndex++;
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
ctx.DrawLandscapeSliceLate?.Invoke(new RetailPViewLandscapeLateSliceContext(
slice, _outdoorStaticScratch, _lateParticleOwnerScratch));
}
// #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
// campfires, ground effects anchored at a position) have no owner id
// to ride any of the id-filtered particle passes. The outdoor root
// has the dedicated T3 pass for them; an INTERIOR root had NO pass
// at all. Draw them ONCE per frame (not per slice — alpha particles
// must not double-draw, the #121 lesson), at the END of the landscape
// stage: after the clear they would z-fail against the doorway seal.
if (!ctx.RootCell.IsOutdoorNode)
ctx.DrawUnattachedSceneParticles?.Invoke();
// T1: retail clears the FULL depth buffer ONCE between the outside
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
// Clear gated on portalsDrawnCount; exact gate semantics is a plan
@ -271,6 +511,33 @@ public sealed class RetailPViewRenderer
UseIndoorMembershipOnlyRouting();
}
// #131 [outstage] probe state (2026-06-12, throwaway): print-on-change —
// which outdoor dynamics were routed to the outside stage and which
// survived the slice viewcone. Strip with the probe when #131 closes.
private string? _lastOutStageSig;
private readonly Dictionary<uint, bool> _outStageOwnerVerdicts = new();
private void EmitOutStageProbe(int sliceIndex, ViewconeCuller viewcone)
{
var sb = new System.Text.StringBuilder(192);
sb.Append("slice=").Append(sliceIndex)
.Append(" outStage=").Append(_outsideStageDynamics.Count).Append(" [");
for (int i = 0; i < _outsideStageDynamics.Count; i++)
{
var e = _outsideStageDynamics[i];
EntitySphere(e, out var c, out float r);
bool pass = viewcone.SphereVisibleInOutsideSlice(sliceIndex, c, r);
if (i > 0) sb.Append(' ');
sb.Append(System.FormattableString.Invariant(
$"0x{(e.ServerGuid != 0 ? e.ServerGuid : e.Id):X8}(s{e.SourceGfxObjOrSetupId:X8}):{(pass ? "PASS" : "CULL")}:r={r:F1}"));
}
sb.Append(']');
string sig = sb.ToString();
if (sig == _lastOutStageSig) return;
_lastOutStageSig = sig;
Console.WriteLine("[outstage] " + sig);
}
// §4 flap [clip-route] probe state (2026-06-10, throwaway): print-on-change signature +
// monotonic sequence so held-flap vs healthy frames diff cleanly in one capture.
private string? _lastClipRouteSig;
@ -665,6 +932,12 @@ public interface IRetailPViewCellDrawCallbacks
{
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; }
/// <summary>#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).</summary>
public Action<RetailPViewCellSliceContext>? DrawLookInPortalPunch { get; }
}
public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks
@ -704,6 +977,11 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
public required Action<uint> SetTerrainClipUbo { get; init; }
public required Action<RetailPViewLandscapeSliceContext> DrawLandscapeSlice { get; init; }
/// <summary>#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).</summary>
public Action<RetailPViewLandscapeLateSliceContext>? DrawLandscapeSliceLate { get; init; }
/// <summary>T1: one full-buffer depth clear between the outside stage and the
/// interior stage (retail PView::DrawCells, Ghidra 0x005a4840). Null for outdoor
/// roots — outdoors the interiors must depth-test against terrain + exteriors and
@ -711,6 +989,13 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
public Action? ClearDepthForInterior { get; init; }
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
public Action<RetailPViewCellSliceContext>? DrawLookInPortalPunch { get; init; }
/// <summary>#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.</summary>
public Action? DrawUnattachedSceneParticles { get; init; }
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
}
@ -727,6 +1012,14 @@ public readonly record struct RetailPViewLandscapeSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> OutdoorEntities);
/// <summary>#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.</summary>
public readonly record struct RetailPViewLandscapeLateSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> Dynamics,
IReadOnlyList<WorldEntity> ParticleOwners);
public readonly record struct RetailPViewCellSliceContext(
uint CellId,
ClipViewSlice Slice,

View file

@ -46,10 +46,12 @@ layout(std140, binding = 1) uniform SceneLighting {
vec4 uCameraAndTime;
};
// Retail hard-cutoff lighting equation (r13 §10.2). No distance
// attenuation inside Range; hard edge at Range; spotlights use a
// binary cos-cone test. This is deliberate — the retail "bubble of
// light" look relies on crisp boundaries.
// Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0): the
// contribution scales by (1 - dist/falloff_eff) — a LINEAR fade to exactly
// 0 at the edge, NOT a hard-cutoff bubble. (The prior "no attenuation inside
// Range / crisp boundaries" note was a misread; it is the literal cause of
// the #133 "spotlight" look. falloff_eff = Falloff * static_light_factor 1.3
// is folded into Range by LightInfoLoader.) Spots add a binary cos-cone test.
vec3 accumulateLights(vec3 N, vec3 worldPos) {
vec3 lit = uCellAmbient.xyz;
int activeLights = int(uCellAmbient.w);
@ -73,7 +75,9 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) {
if (d < range && range > 1e-3) {
vec3 Ldir = toL / max(d, 1e-4);
float ndl = max(0.0, dot(N, Ldir));
float atten = 1.0; // retail: no attenuation inside Range
// calc_point_light (1 - dist/falloff_eff) linear ramp; Range already
// carries falloff_eff (Falloff * 1.3), so it fades to 0 at the cutoff.
float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0);
if (kind == 2) {
// Spotlight: hard-edged cos-cone test.
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);

View file

@ -49,7 +49,15 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) {
if (d < range && range > 1e-3) {
vec3 Ldir = toL / max(d, 1e-4);
float ndl = max(0.0, dot(N, Ldir));
float atten = 1.0;
// Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0,
// line 0x0059c9a2): contribution scales by (1 - dist/falloff_eff), a
// LINEAR fade to exactly 0 at the edge. That is what makes a torch a
// smooth glow that blends into the ambient instead of a flat disc with
// a hard edge — the dungeon/house/outdoor "spotlight" look (#133 A7).
// falloff_eff = Falloff * static_light_factor (1.3, 0x00820e24) is folded
// into the shader Range (dirAndRange.w) by LightInfoLoader, so the ramp
// denominator is just Range and fades to 0 exactly at the cutoff.
float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0);
if (kind == 2) {
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);

View file

@ -283,6 +283,27 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// when wired, else the no-clip fallback (count 0 = ungated terrain).
BindClipUboBinding2();
// #108-residual: retail terrain is SINGLE-SIDED — ACRender::landPolysDraw
// (0x006b7040) draws each land triangle ONLY when the camera is on the
// POSITIVE (upper) side of its plane (Plane::which_side2 vs
// Render::FrameCurrent, zFightTerrainAdjust bias). GL backface culling
// evaluates the same per-triangle eye-side predicate at rasterization.
// LandblockMesh emits every triangle CCW in world XY seen from above
// (LandblockMeshTests winding pin), which the unified camera chain
// (CreateLookAt up=+Z + Numerics perspective) maps to CCW window
// winding from above / CW from below (TerrainCullOrientationTests) —
// so FrontFace(Ccw)+Cull(Back) keeps the top side and culls the
// underside. WB drew the whole world with culling DISABLED
// frame-globally (WB GameScene.cs:841 — an editor camera goes
// underground); inheriting that drew terrain DOUBLE-SIDED, and a
// below-grade eye (cellar ascent) saw the UNDERSIDE of the grade
// sheet through the exit-door aperture — the #108 grass window.
// Self-contained state per feedback_render_self_contained_gl_state;
// the frame-global CW + cull-off baseline is restored after the draw.
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
_gl.BindVertexArray(_globalVao);
_gl.MemoryBarrier(MemoryBarrierMask.CommandBarrierBit);
_gl.MultiDrawElementsIndirect(
@ -292,6 +313,9 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
(uint)sizeof(DrawElementsIndirectCommand));
_gl.BindVertexArray(0);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
_gl.FrontFace(FrontFaceDirection.CW);
_gl.Disable(EnableCap.CullFace);
}
public void Dispose()

View file

@ -62,6 +62,24 @@ namespace AcDream.App.Rendering.Wb {
public VertexPositionNormalTexture[] Vertices { get; set; } = Array.Empty<VertexPositionNormalTexture>();
public List<MeshBatchData> Batches { get; set; } = new();
/// <summary>
/// #125 (2026-06-12): GL upload-retry counter. A failed
/// <see cref="ObjectMeshManager.UploadMeshData"/> (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 <c>PrepareMeshDataAsync</c>'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 <see cref="ObjectMeshManager.
/// MaxUploadRetries"/> 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.
/// </summary>
public int UploadAttempts;
/// <summary>For EnvCell: the geometry of the cell itself.</summary>
public ObjectMeshData? EnvCellGeometry { get; set; }
@ -216,6 +234,32 @@ namespace AcDream.App.Rendering.Wb {
private readonly ConcurrentQueue<ObjectMeshData> _stagedMeshData = new();
public ConcurrentQueue<ObjectMeshData> StagedMeshData => _stagedMeshData;
/// <summary>#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 <see cref="ObjectMeshData.UploadAttempts"/>.</summary>
public const int MaxUploadRetries = 3;
/// <summary>
/// #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 <see cref="MaxUploadRetries"/>.
/// </summary>
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<uint> _decodedTextureLru = new();
private readonly ConcurrentDictionary<uint, byte[]> _decodedTextureCache = new();

View file

@ -244,10 +244,21 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
if (_disposed) return;
_graphicsDevice!.ProcessGLQueue();
// #125: drain staged uploads; a FAILED upload (UploadMeshData returned
// null from its catch) is re-staged for a LATER frame, not dropped. The
// re-stages are collected and re-enqueued AFTER the loop — re-enqueuing
// inside the while would let a deterministic failure spin the queue in a
// single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so
// a genuine defect surfaces loudly instead of the old silent sticky drop.
List<ObjectMeshData>? requeue = null;
while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
{
_meshManager.UploadMeshData(meshData);
if (_meshManager.UploadOrRequeue(meshData))
(requeue ??= new()).Add(meshData);
}
if (requeue is not null)
foreach (var m in requeue)
_meshManager.StagedMeshData.Enqueue(m);
bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled;
var pendingBefore = texProbe

View file

@ -14,6 +14,16 @@ public abstract record LandblockStreamJob(uint LandblockId)
{
public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId);
/// <summary>
/// Control job: drop every queued (not-yet-started) Load from the worker's
/// priority queues, keeping Unloads. Posted by
/// <see cref="LandblockStreamer.ClearPendingLoads"/> 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.
/// </summary>
public sealed record ClearLoads() : LandblockStreamJob(0);
}
/// <summary>

View file

@ -141,6 +141,22 @@ public sealed class LandblockStreamer : IDisposable
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
}
/// <summary>
/// Cancel every queued-but-not-started Load. Posts a
/// <see cref="LandblockStreamJob.ClearLoads"/> 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.
/// </summary>
public void ClearPendingLoads()
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
_inbox.Writer.TryWrite(new LandblockStreamJob.ClearLoads());
}
/// <summary>
/// Drain up to <paramref name="maxBatchSize"/> completed results.
/// Non-blocking. Call from the render thread once per OnUpdate.
@ -180,7 +196,18 @@ public sealed class LandblockStreamer : IDisposable
}
while (_inbox.Reader.TryRead(out var job))
{
if (job is LandblockStreamJob.ClearLoads)
{
// Dungeon-entry cancellation: drop every queued Load,
// keep Unloads. Handled at read time so it supersedes
// Loads sitting in the priority queues ahead of it.
DropLoadJobs(highPriority);
DropLoadJobs(lowPriority);
continue;
}
EnqueuePrioritized(job, highPriority, lowPriority);
}
if (highPriority.Count == 0 && lowPriority.Count == 0)
continue;
@ -233,6 +260,22 @@ public sealed class LandblockStreamer : IDisposable
lowPriority.Enqueue(job);
}
/// <summary>
/// Drop every <see cref="LandblockStreamJob.Load"/> from a priority queue,
/// preserving Unloads (and any other control jobs). Rotates the queue once
/// in place. Used by the <see cref="LandblockStreamJob.ClearLoads"/> path.
/// </summary>
private static void DropLoadJobs(Queue<LandblockStreamJob> 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<LandblockStreamJob> queue,
uint landblockId,

View file

@ -22,9 +22,24 @@ public sealed class StreamingController
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
private readonly Action<uint>? _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;
/// <summary>
/// Near-tier radius (LBs from observer that load full detail: terrain +
/// scenery + entities). Set at construction; readable thereafter.
@ -71,13 +86,15 @@ public sealed class StreamingController
GpuWorldState state,
int nearRadius,
int farRadius,
Action<uint>? removeTerrain = null)
Action<uint>? removeTerrain = null,
Action? clearPendingLoads = null)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions;
_applyTerrain = applyTerrain;
_removeTerrain = removeTerrain;
_clearPendingLoads = clearPendingLoads;
_state = state;
NearRadius = nearRadius;
FarRadius = farRadius;
@ -97,7 +114,76 @@ public sealed class StreamingController
/// <item><see cref="TwoTierDiff.ToUnload"/> → enqueue full unload</item>
/// </list>
/// </summary>
public void Tick(int observerCx, int observerCy)
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
{
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
if (_collapsed)
{
// Hysteresis. Cases:
// - Still in the SAME dungeon landblock → hold (sweep stragglers).
// - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon)
// → re-collapse onto it.
// - CurrCell flickered null but the player hasn't gone anywhere: the
// observer landblock reverts to the position-derived value, which for a
// dungeon is only ever the ADJACENT off-by-one landblock (negative cell-
// local Y). Hold — never expand on an adjacent flicker.
// - Genuinely left to a DISTANT landblock (portal/teleport out, always far
// from the ocean-grid dungeon block) → expand.
if (insideDungeon && centerId != _collapsedCenter)
EnterDungeonCollapse(observerCx, observerCy, centerId);
else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1)
ExitDungeonExpand(observerCx, observerCy);
else
SweepCollapsed();
}
else if (insideDungeon)
{
EnterDungeonCollapse(observerCx, observerCy, centerId);
}
else
{
NormalTick(observerCx, observerCy);
}
DrainAndApply();
}
/// <summary>
/// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first
/// <see cref="Tick"/> 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.
///
/// <para>The per-frame <c>insideDungeon</c> gate keys on the physics
/// <c>CurrCell</c>, 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
/// <see cref="NormalTick"/> 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.</para>
///
/// <para>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 <c>_clearPendingLoads</c> 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 <see cref="Tick"/>.</para>
/// </summary>
public void PreCollapseToDungeon(int cx, int cy)
{
uint centerId = StreamingRegion.EncodeLandblockId(cx, cy);
if (_collapsed && _collapsedCenter == centerId) return;
EnterDungeonCollapse(cx, cy, centerId);
}
/// <summary>
/// Outdoor / building-interior streaming — the original two-tier model.
/// </summary>
private void NormalTick(int observerCx, int observerCy)
{
if (_region is null)
{
@ -116,9 +202,88 @@ public sealed class StreamingController
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
foreach (var id in diff.ToUnload) _enqueueUnload(id);
}
}
// Drain up to N completions per frame so a big diff doesn't spike
// GPU upload time. Remaining completions wait for the next frame.
/// <summary>
/// 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 <c>LandblockManager.GetAdjacentIDs</c> 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.
/// </summary>
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);
}
/// <summary>
/// While collapsed, unload any landblock that finished loading after the
/// collapse edge — a Load the worker had already dequeued before the
/// <see cref="LandblockStreamer.ClearPendingLoads"/> control job took
/// effect. At steady state only the dungeon landblock is resident, so this
/// is a no-op.
/// </summary>
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);
}
/// <summary>Chebyshev distance in landblock cells between two landblock ids.</summary>
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));
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Drain up to N completions per frame so a big diff doesn't spike GPU
/// upload time. Remaining completions wait for the next frame.
/// </summary>
private void DrainAndApply()
{
var drained = _drainCompletions(MaxCompletionsPerFrame);
foreach (var result in drained)
{

View file

@ -0,0 +1,105 @@
using System;
using System.Numerics;
namespace AcDream.App.World;
/// <summary>Verdict from the per-frame readiness probe for a held teleport arrival.</summary>
public enum ArrivalReadiness
{
/// <summary>Destination not yet hydrated; keep holding.</summary>
NotReady,
/// <summary>Destination terrain + cell are ready; place now.</summary>
Ready,
/// <summary>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.</summary>
Impossible,
}
/// <summary>Lifecycle of a single teleport arrival.</summary>
public enum TeleportArrivalPhase { Idle, Holding }
/// <summary>
/// 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 <c>GameWindow.OnLivePositionUpdated</c> that resolved the
/// arrival against the resident (old) landblocks before the destination hydrated
/// and landed the player in ocean.
///
/// <para>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
/// <c>PlayerState.PortalSpace</c> until the placement delegate flips it back to
/// InWorld.</para>
///
/// <para>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.</para>
/// </summary>
public sealed class TeleportArrivalController
{
/// <summary>~10 s at 60 fps. Coarse safety net for a destination that never streams.</summary>
public const int DefaultMaxHoldFrames = 600;
private readonly Func<Vector3, uint, ArrivalReadiness> _readiness;
private readonly Action<Vector3, uint, bool> _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<Vector3, uint, ArrivalReadiness> readiness,
Action<Vector3, uint, bool> place,
int maxHoldFrames = DefaultMaxHoldFrames)
{
_readiness = readiness ?? throw new ArgumentNullException(nameof(readiness));
_place = place ?? throw new ArgumentNullException(nameof(place));
_maxHoldFrames = maxHoldFrames;
}
/// <summary>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).</summary>
public void BeginArrival(Vector3 destPos, uint destCell)
{
_destPos = destPos;
_destCell = destCell;
_heldFrames = 0;
Phase = TeleportArrivalPhase.Holding;
}
/// <summary>Per-frame: evaluate readiness and place when ready / impossible / timed out.
/// No-op when Idle.</summary>
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);
}
}

View file

@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Lighting;
/// <summary>
/// Retail per-vertex static-light burn-in. Ported verbatim from
/// <c>calc_point_light</c> (acclient 0x0059c8b0), the function retail's
/// <c>D3DPolyRender::SetStaticLightingVertexColors</c> (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).
///
/// <para>
/// 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]).
/// </para>
///
/// <para>Constants (decomp-cited, not guessed):</para>
/// <list type="bullet">
/// <item><c>static_light_factor</c> = 1.3 (0x00820e24) — folded into
/// <see cref="LightSource.Range"/> by <c>LightInfoLoader</c>, so
/// <c>falloff_eff == light.Range</c> here.</item>
/// <item><c>LIGHT_POINT_RANGE</c> = 0.75 (0x007e5430) — the half-Lambert wrap
/// uses <c>2·LPR = 1.5</c> as the divisor and <c>(2·LPR 1) = 0.5</c> as the
/// distance bias, so even surfaces angled away from a torch receive some light.</item>
/// </list>
/// </summary>
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
/// <summary>
/// Accumulate one static light's contribution into a per-vertex RGB sum,
/// exactly as <c>calc_point_light</c> 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].
/// </summary>
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));
}
/// <summary>
/// Bake the full per-vertex colour by summing every reaching lit point/spot
/// light, then clamping to [0,1] (the <c>SetStaticLightingVertexColors</c>
/// final clamp). Directional lights are skipped — they are handled by the
/// sun path, not the static burn-in.
/// </summary>
public static Vector3 ComputeVertexColor(
Vector3 vtxWorldPos, Vector3 vtxWorldNormal, IReadOnlyList<LightSource> 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));
}
}

View file

@ -79,7 +79,15 @@ public static class LightInfoLoader
(info.Color?.Green ?? 255) / 255f,
(info.Color?.Blue ?? 255) / 255f),
Intensity = info.Intensity,
Range = info.Falloff,
// falloff_eff for the per-vertex point-light burn-in (calc_point_light
// 0x0059c8b0) is Falloff * static_light_factor, where static_light_factor
// is the fixed global 1.3 (0x00820e24). That is the path that lights
// STATIC walls — what the dungeon/house "spotlight" report (#133 A7) is
// about — so we match it, not the D3D-dynamic config_hardware_light
// rangeAdjust (1.5, a different path for moving objects). The shader ramp
// (1 - dist/Range) fades to exactly 0 at this Range, eliminating the hard
// disc edge that read as a spotlight.
Range = info.Falloff * 1.3f,
ConeAngle = info.ConeAngle,
OwnerId = ownerId,
IsLit = true,

View file

@ -11,23 +11,25 @@ namespace AcDream.Core.Lighting;
/// §12.2).
///
/// <para>
/// Active-light selection algorithm (r13 §12.2 "Tick" steps):
/// Active-light selection algorithm (r13 §12.2), as implemented by
/// <see cref="Tick"/>:
/// <list type="number">
/// <item><description>
/// Recompute <c>DistSq</c> from viewer to every registered
/// point/spot light.
/// Reserve slot 0 for the sun (directional, infinite range) when present.
/// </description></item>
/// <item><description>
/// Drop lights outside <c>Range² * 1.1</c> (10% slack prevents
/// pop as we walk across the boundary).
/// </description></item>
/// <item><description>
/// Rank remaining lights by <c>DistSq</c> ascending. Pick top 7.
/// </description></item>
/// <item><description>
/// Reserve slot 0 for the sun (directional, infinite range).
/// For every registered lit point/spot light, recompute <c>DistSq</c>
/// from the viewer and keep the nearest <c>(MaxActiveLights sunSlot)</c>
/// directly in the active window via an allocation-free insertion
/// partial-select (no per-frame list/sort).
/// </description></item>
/// </list>
/// There is deliberately NO viewer-range candidacy filter: each light's
/// own range cutoff is applied PER SURFACE in the shader
/// (<c>mesh_modern.frag</c>: <c>d &lt; range</c>), so a torch the viewer
/// stands outside the range of must still light the wall it sits on. The
/// earlier <c>Range² × 1.1</c> slack filter wrongly dropped exactly those
/// lights (the #133 "lighting off" report).
/// </para>
///
/// <para>
@ -37,7 +39,6 @@ namespace AcDream.Core.Lighting;
public sealed class LightManager
{
public const int MaxActiveLights = 8; // D3D parity
private const float RangeSlack = 1.1f; // 10% hysteresis around hard cutoff
private readonly List<LightSource> _all = new();
private readonly LightSource?[] _active = new LightSource?[MaxActiveLights];
@ -94,45 +95,66 @@ public sealed class LightManager
/// </summary>
public void Tick(Vector3 viewerWorldPos)
{
// Pass 1: compute DistSq + filter out lights outside the slack radius.
var candidates = new List<LightSource>(_all.Count);
// Retail D3D-style fixed-pipeline lighting takes the nearest (MaxActiveLights-1)
// point lights (slot 0 is the sun) and applies each light's hard range cutoff
// PER SURFACE in the shader (mesh_modern.frag: `if (d < range && range > 1e-3)`),
// NOT a viewer-range candidacy filter — a torch the viewer stands outside the
// range of must still light the wall it sits on.
//
// Allocation-free partial selection: the old path built `new List<>(N)` and
// ran an O(N log N) Sort EVERY FRAME; in a dungeon N is thousands of torches,
// so that allocated a large list per frame (GC pressure → FPS). Instead keep
// the nearest maxPoint directly in the _active window, maintained sorted by
// insertion. O(N · maxPoint), maxPoint ≤ 8, zero allocation.
Array.Clear(_active);
_activeCount = 0;
// Slot 0 = sun when present (directional; never ranked by distance).
int baseSlot = 0;
if (Sun is not null)
{
_active[0] = Sun;
baseSlot = 1;
}
int maxPoint = MaxActiveLights - baseSlot;
int filled = 0;
if (maxPoint > 0)
{
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;
}
if (!light.IsLit || light.Kind == LightKind.Directional) 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.
if (Sun is not null)
// 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)
{
_active[0] = Sun;
_activeCount = 1;
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;
}
}
}
int maxPoint = MaxActiveLights - _activeCount;
int pointCount = Math.Min(maxPoint, candidates.Count);
for (int i = 0; i < pointCount; i++)
{
_active[_activeCount + i] = candidates[i];
}
_activeCount += pointCount;
_activeCount = baseSlot + filled;
}
}

View file

@ -141,4 +141,53 @@ public static class GfxObjDegradeResolver
resolvedGfxObj = closeGfxObj;
return true;
}
/// <summary>
/// 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 (<c>Degrades[0].MaxDist == 0</c>) and the table degrades to GfxObj
/// id 0 (= nothing) at real distance. Retail
/// (<c>CPhysicsPart::UpdateViewerDistance</c> 0x0050E030 → <c>Draw</c> 0x0050D7A0 picks
/// <c>gfxobj[deg_level]</c> 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 &gt; 0) skip such GfxObjs.
/// </summary>
public static bool IsRuntimeHiddenMarker(DatCollection dats, uint gfxObjId)
=> IsRuntimeHiddenMarker(
id => dats.Get<GfxObj>(id),
id => dats.Get<GfxObjDegradeInfo>(id),
gfxObjId);
/// <summary>Loader-callback overload of <see cref="IsRuntimeHiddenMarker(DatCollection, uint)"/>.</summary>
public static bool IsRuntimeHiddenMarker(
Func<uint, GfxObj?> getGfxObj,
Func<uint, GfxObjDegradeInfo?> 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;
}
}

View file

@ -935,52 +935,47 @@ public sealed class MotionInterpreter
// ── CMotionInterp::get_max_speed (0x00527cb0) ─────────────────────────────
/// <summary>
/// Return the run rate. Mirrors retail
/// <c>CMotionInterp::get_max_speed</c> at <c>0x00527cb0</c>.
/// Return the maximum movement speed in m/s: run rate × RunAnimSpeed (4.0).
/// Mirrors retail <c>CMotionInterp::get_max_speed</c> at <c>0x00527cb0</c>.
///
/// <para>
/// <b>Decomp (named-retail/acclient_2013_pseudo_c.txt:305127):</b>
/// <code>
/// 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
/// }
/// </code>
/// Binary Ninja shows the return type as <c>void</c> because the float
/// return rides the x87 FPU stack rather than EAX. Both branches
/// emit an <c>fld</c> of either <c>this_1</c> (the InqRunRate
/// out-param value) or <c>my_run_rate</c>, leaving the run rate on
/// ST0 as the return value.
/// <b>The ×4.0 is byte-verified retail (UN-2 resolved 2026-06-12).</b>
/// The Binary Ninja pseudo-C (named-retail/acclient_2013_pseudo_c.txt:305127)
/// renders this function as <c>void</c> with a bare <c>this->my_run_rate;</c>
/// statement because it drops x87 instructions — a known BN artifact class.
/// Disassembling the PDB-matched v11.4186 binary at VA <c>0x00527cb0</c>
/// shows all THREE return paths end with
/// <c>fmul dword ptr [0x007C8918]</c>, and the .rdata dword at
/// <c>0x007C8918</c> is <c>0x40800000</c> = 4.0f (the sibling
/// <c>get_adjusted_max_speed</c> 0x00527d00 carries the same trailing
/// fmul). Re-derive with <c>py tools/verify_un2_fmul.py</c>. 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).
/// </para>
///
/// <para>
/// <b>Critical:</b> this returns the BARE run rate (typically 1.0 to
/// ~3.0), NOT a velocity in m/s. We previously multiplied by
/// <c>RunAnimSpeed</c> to get a m/s value, reasoning that
/// <c>2 × bare_rate</c> would be too slow a catch-up speed for the
/// caller (<c>InterpolationManager::adjust_offset</c>). That was a
/// misread of the decomp — retail's catch-up IS that slow on purpose.
/// The multi-second 1-Hz blip the user reported when observing retail
/// remotes from acdream traced to body racing at the wrong (overshot)
/// catch-up speed (~23.5 m/s instead of the retail-correct ~5.9 m/s
/// for a run-skill-200 char).
/// Consequence: the dead-reckoning catch-up speed
/// (<c>InterpolationManager::adjust_offset</c> 0x00555d30, pc:353122)
/// is <c>2 × get_max_speed()</c> ≈ 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.
/// </para>
/// </summary>
public float GetMaxSpeed()
{
// Resolve current run rate: prefer WeenieObj.InqRunRate, fall back to MyRunRate.
// Then multiply by RunAnimSpeed (4.0). Matches ACE's MotionInterp.cs:670-678
// which is verified against retail (the ACE MotionInterp file is a
// line-by-line port). Returns the maximum world-space velocity in m/s
// — for run skill 200 with rate ≈ 2.94, this is ≈ 11.76 m/s. Used by
// InterpolationManager.AdjustOffset to compute the catch-up speed
// (= 2 × maxSpeed).
float rate = MyRunRate;
if (WeenieObj is not null && WeenieObj.InqRunRate(out float queried))
rate = queried;
// Retail 0x00527cb0: weenie null → 1.0; InqRunRate ok → queried;
// InqRunRate failed → my_run_rate. Every path × RunAnimSpeed (4.0,
// .rdata 0x007C8918). Note the weenie-null default is the LITERAL 1.0
// (.rdata 0x007928B0), not my_run_rate.
float rate = 1.0f;
if (WeenieObj is not null && !WeenieObj.InqRunRate(out rate))
rate = MyRunRate;
return RunAnimSpeed * rate;
}

View file

@ -26,8 +26,16 @@ public sealed class PhysicsDataCache
private readonly ConcurrentDictionary<uint, BuildingPhysics> _buildings = new();
/// <summary>
/// UCG Stage 1: the unified cell graph, built alongside the legacy cell caches.
/// Consumed by nobody this stage (zero behavior change).
/// The unified cell graph (UCG): the active id-&gt;cell resolver and registry.
/// Populated unconditionally in <see cref="CacheCellStruct"/> — BEFORE the
/// idempotency + null-BSP guards, so BSP-less cells are registered too — and
/// consumed across the engine: the player render/lighting root
/// (<c>CellGraph.CurrCell</c>, written at the player chokepoint
/// <c>PhysicsEngine.UpdatePlayerCurrCell</c> and read by the renderer), the
/// universal id-&gt;cell lookup (<c>GetVisible</c>), the 3rd-person camera cell
/// (<c>FindVisibleChildCell</c>), and the block-local terrain origin
/// (<c>TryGetTerrainOrigin</c>, read by <c>CellTransit</c>'s pick + transit
/// paths). No longer inert.
/// </summary>
public UcgCellGraph CellGraph { get; } = new();

View file

@ -638,9 +638,23 @@ public sealed class PhysicsEngine
{
Console.WriteLine(System.FormattableString.Invariant(
$"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its walkable floor z={claimFloorZ.Value:F3}"));
// #133 (2026-06-13): return the VALIDATED claim's OWN full cell id,
// NOT lbPrefix | (cellId & 0xFFFF). lbPrefix is found by scanning
// resident landblocks for one whose [0,192) local bounds contain
// the candidate XY — but a dungeon EnvCell's local Y can be NEGATIVE
// (server teleport to 0x00070143 at local (70,-60,0.01)). The dungeon
// landblock fails the localY>=0 bounds test, so the loop matches a
// neighbouring still-resident block (e.g. Holtburg 0xA9B3), re-stamping
// the validated claim 0x00070143 -> 0xA9B30143. The client then
// mis-resolves the player into the wrong landblock and spams ACE with
// rejected moves. The validated claim's prefix is AUTHORITATIVE; a
// position falling in a neighbouring resident landblock must not
// re-stamp it. Byte-identical for the login case (the position lies in
// the claim's own landblock, so lbPrefix == cellId & 0xFFFF0000);
// diverges only — and correctly — in the far-teleport dungeon case.
return new ResolveResult(
new Vector3(candidatePos.X, candidatePos.Y, claimFloorZ.Value),
lbPrefix | (cellId & 0xFFFFu),
cellId,
IsOnGround: true);
}
}

View file

@ -3095,14 +3095,26 @@ public sealed class Transition
Vector3 direction = Vector3.Cross(collisionNormal, contactPlane.Normal);
float dirLenSq = direction.LengthSquared();
if (dirLenSq >= PhysicsGlobals.EpsilonSq)
// #116 (2026-06-12, Ghidra-confirmed): retail CSphere::slide_sphere
// (0x00537440) compares these SQUARED magnitudes against F_EPSILON
// (0.000199999995 ≈ 0.0002 = PhysicsGlobals.EPSILON), NOT against the
// squared epsilon. Ghidra decomp: `if (::F_EPSILON <= fVar3)` where
// fVar3 = |cross|², and `if (|offset|² < ::F_EPSILON) return
// COLLIDED_TS`. Our port used EpsilonSq (0.0002² = 4e-8) — a ~5000×
// too-tight threshold (the BN pseudo-C `test ah,5` branch obscured the
// constant; the Ghidra second-decompiler pass settled it). Effect:
// crease-exists now needs ≥0.81° between the normals (was 0.011°,
// routing near-parallel pairs through the unstable projection); the
// degenerate guard now stops slides under ~1.41 cm like retail (was
// 0.2 mm). Register: AP-? (divergence retired). See ISSUES.md #116.
if (dirLenSq >= PhysicsGlobals.EPSILON)
{
// Crease exists: project displacement onto it.
float diff = Vector3.Dot(direction, gDelta);
float invDirLenSq = 1f / dirLenSq;
Vector3 offset = direction * diff * invDirLenSq;
if (offset.LengthSquared() < PhysicsGlobals.EpsilonSq)
if (offset.LengthSquared() < PhysicsGlobals.EPSILON)
return TransitionState.Collided;
// Subtract current displacement to get the correction vector.

View file

@ -109,6 +109,20 @@ public static class RenderingDiagnostics
public static bool ProbeViewerEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_VIEWER") == "1";
/// <summary>
/// #131 (2026-06-12) outside-stage dynamics probe. When true, the renderer
/// emits one <c>[outstage]</c> 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 <c>[outstage-pt]</c> 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
/// <c>ACDREAM_PROBE_OUTSTAGE=1</c>.
/// </summary>
public static bool ProbeOutStageEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_OUTSTAGE") == "1";
/// <summary>
/// Phase U.4c (2026-05-31) flap-convergence probe. When true, the portal
/// visibility pass emits, EVERY frame the camera root is an indoor cell, a
@ -229,6 +243,34 @@ public static class RenderingDiagnostics
public static bool ProbePhantomEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_PHANTOM") == "1";
/// <summary>
/// #133 A7 (2026-06-13) dungeon-lighting objective probe. When true,
/// the per-frame scene-lighting build emits ONE <c>[light]</c> line
/// roughly every second (wall-clock rate-limited like WB-DIAG) via
/// <see cref="EmitLight"/>:
/// <code>
/// [light] insideCell=&lt;bool&gt; ambient=(r,g,b) sun=&lt;intensity&gt;
/// registeredLights=&lt;N&gt; activeLights=&lt;uCellAmbient.w&gt; playerCell=0x&lt;id&gt;
/// </code>
/// This is the self-verification signal for the dungeon-dim question:
/// <list type="bullet">
/// <item><description><c>insideCell=true ambient=(0.20,0.20,0.20) sun=0</c>
/// confirms the indoor branch fired (retail flat ambient, sun killed).</description></item>
/// <item><description><c>registeredLights</c> is the count of dat-baked
/// point/spot lights (<c>Setup.Lights</c>) registered with the
/// <c>LightManager</c> — 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).</description></item>
/// <item><description><c>activeLights</c> is <c>uCellAmbient.w</c> — the
/// shader's active-slot count, which INCLUDES the (zeroed) sun slot
/// indoors. So <c>activeLights=1 registeredLights=0</c> = "only the dead
/// sun slot, no torches in range".</description></item>
/// </list>
/// Output-only, inert when off. Initial state from <c>ACDREAM_PROBE_LIGHT=1</c>.
/// </summary>
public static bool ProbeLightEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1";
// Cell-change gate for EmitVis. The probe fires once per distinct root cell
// so launch.log stays readable under motion (the per-frame call is a no-op
// when the root is unchanged). Sentinel 0 = "no root yet" — the first real
@ -322,6 +364,93 @@ public static class RenderingDiagnostics
/// </summary>
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
/// <summary>
/// #133 A7 — emit ONE rate-limited <c>[light]</c> line describing the
/// current scene-lighting state, followed (when <paramref name="lights"/>
/// is supplied) by up to three <c>[light-detail]</c> lines for the nearest
/// ACTIVE point/spot lights. Cheap no-op when
/// <see cref="ProbeLightEnabled"/> is false; otherwise fires at most
/// once per second. Pull the values from the spot where
/// <c>GameWindow.UpdateSunFromSky</c> set <c>Lighting.CurrentAmbient</c>
/// / <c>Lighting.Sun</c> and where <c>SceneLightingUbo.Build</c> computed
/// the active-slot count.
/// <para>
/// The <c>[light-detail]</c> lines are the answer to the "candle-spotlight"
/// question — they expose each torch's REAL dat-derived runtime values
/// (<c>range=</c> Falloff metres, <c>intensity=</c>, <c>cone=</c> radians,
/// <c>color=</c>, <c>distToViewer=</c>) so it is visible in launch.log
/// whether dungeon torches are tiny-range points or wide cones and at what
/// intensity — without a screenshot:
/// <code>
/// [light-detail] kind=Point range=&lt;Falloff m&gt; intensity=&lt;I&gt; cone=&lt;rad&gt; color=(r,g,b) distToViewer=&lt;m&gt;
/// </code>
/// </para>
/// </summary>
/// <param name="insideCell">The <c>playerInsideCell</c> value driving the indoor branch.</param>
/// <param name="ambientR">Cell ambient red (xyz of <c>uCellAmbient</c>).</param>
/// <param name="ambientG">Cell ambient green.</param>
/// <param name="ambientB">Cell ambient blue.</param>
/// <param name="sunIntensity">The sun <c>LightSource.Intensity</c> (0 indoors).</param>
/// <param name="registeredLights">Total point/spot lights registered with the LightManager.</param>
/// <param name="activeLights"><c>uCellAmbient.w</c> — shader active-slot count (includes the zeroed sun slot indoors).</param>
/// <param name="playerCellId">The player's current cell id (0 if unresolved → outside).</param>
/// <param name="lights">The ticked <c>LightManager</c> (its <c>Active</c> list, sorted nearest-first by the
/// just-completed Tick). When non-null, drives the <c>[light-detail]</c> lines. Optional so existing call
/// sites / tests that only want the aggregate line keep compiling.</param>
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";

View file

@ -6,17 +6,26 @@ using AcDream.Core.Physics; // TerrainSurface
namespace AcDream.Core.World.Cells;
/// <summary>
/// The unified cell graph: the authoritative id-&gt;cell resolver and registry.
/// Built alongside the legacy render/physics cell systems in Stage 1 and consumed
/// by nobody (zero behavior change). Retail anchor: CObjCell::GetVisible (pseudo_c:308209).
/// Worker-thread populated; reads are concurrency-safe.
/// The unified cell graph: the active, authoritative id-&gt;cell resolver and registry.
/// Populated unconditionally from
/// <see cref="AcDream.Core.Physics.PhysicsDataCache.CacheCellStruct"/> (before its
/// idempotency + null-BSP guards, so BSP-less cells are included) and consumed across
/// the engine: <see cref="GetVisible"/> resolves any cell id, <see cref="CurrCell"/> is
/// the player render/lighting root, <see cref="FindVisibleChildCell"/> resolves the
/// 3rd-person camera cell, and <see cref="TryGetTerrainOrigin"/> 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.
/// </summary>
public sealed class CellGraph
{
private readonly ConcurrentDictionary<uint, EnvCell> _envCells = new();
private readonly ConcurrentDictionary<uint, (TerrainSurface Terrain, Vector3 Origin)> _terrain = new();
/// <summary>Player's current cell. Defined for Stage 2; INERT in Stage 1 (no writer).</summary>
/// <summary>The player's current cell — the render/lighting root. Written ONLY at the
/// player chokepoint <see cref="AcDream.Core.Physics.PhysicsEngine.UpdatePlayerCurrCell"/>
/// (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).</summary>
public ObjCell? CurrCell { get; internal set; }
public bool Contains(uint envCellId) => _envCells.ContainsKey(envCellId);

View file

@ -0,0 +1,153 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #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.
/// </summary>
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<uint, LoadedCell> 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");
}
}

View file

@ -218,4 +218,133 @@ public class Issue127FloodFlipReplayTests
Assert.Fail($"flood admission differs across the captured 4 cm pair (preGate={preGate}, fov={fov:F2}) — see output for the flipping cells");
}
}
// Centre of a building group's exit-portal AABB (world space).
private static (bool Has, Vector3 Center) PortalCenterFor(List<LoadedCell> 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<uint> BuildingAdmits(
World w, List<LoadedCell> group, Vector3 eye, Matrix4x4 viewProj,
FrustumPlanes frustum, bool withPreGate)
{
var result = new HashSet<uint>();
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;
}
/// <summary>
/// #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.
/// </summary>
[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<uint, int>();
var prevIn = new Dictionary<uint, bool>();
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<uint>(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<uint>(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");
}
}

View file

@ -0,0 +1,68 @@
using System;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #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²·(fn)/(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.
/// </summary>
public class Issue129PunchBiasTests
{
private const float Near = PortalDepthMaskRenderer.CameraNearPlaneMeters; // 0.1 (retail znear)
private const float Far = 5000f;
/// <summary>Eye-depth span (meters) covered by an NDC depth bias b at eye
/// distance d: ndc(d) = f(dn)/((fn)d) ⇒ d(ndc) inverse ⇒
/// span = b·d²·(fn)/(f·n) (exact for small b via the derivative).</summary>
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"));
}
}

View file

@ -0,0 +1,435 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #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.
/// </summary>
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}"));
}
/// <summary>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.</summary>
[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<string>();
foreach (var p in view.Polygons) parts.Add(p.Vertices.Length.ToString());
return $"[{string.Join(",", parts)}]";
}
/// <summary>
/// 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.
/// </summary>
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<Vector2, bool> 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;
}
/// <summary>Highest boundary y of the polygon at vertical line x (NaN when
/// the line misses the polygon).</summary>
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;
}
}

View file

@ -0,0 +1,112 @@
using System;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
using DatSetup = DatReaderWriter.DBObjs.Setup;
using DatGfxObj = DatReaderWriter.DBObjs.GfxObj;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #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.
/// </summary>
public class Issue131SetupProbeTests
{
private readonly ITestOutputHelper _out;
public Issue131SetupProbeTests(ITestOutputHelper output) => _out = output;
/// <summary>#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.</summary>
[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<DatSetup>(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<DatGfxObj>(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());
}
}
}
}

View file

@ -0,0 +1,198 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #95 MEASUREMENT (2026-06-13): entering the 0x0007 dungeon (Town Network) explodes
/// WB-DIAG to ~9.1M instances/frame. Suspected cause: <see cref="PortalVisibilityBuilder.Build"/>
/// floods the dungeon's portal graph WITHOUT the retail grab_visible_cells stab_list bounding
/// (decomp:311878). A dungeon cell has <c>seen_outside==0</c>; retail's PVS for it is just the
/// cell's <c>stab_list</c> (<see cref="LoadedCell.VisibleCells"/>) — 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 (<see cref="PortalVisibilityFrame.OrderedVisibleCells"/>) vs the
/// root's stab_list size (<see cref="LoadedCell.VisibleCells"/>), plus how many visited cells
/// cross landblocks. The single assertion just guarantees the test ran; the VALUE is the output.
/// </summary>
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<DatLandBlockInfo>(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<uint, LoadedCell?> 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<int>();
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<uint>? 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.");
}
}

View file

@ -0,0 +1,80 @@
using System;
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #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(maxmin), up to one pixel short of the true max edge —
/// the doorway top-edge background strip.
/// </summary>
public class NdcScissorRectTests
{
/// <summary>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.</summary>
[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);
}
}

View file

@ -0,0 +1,82 @@
using System;
using System.Numerics;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #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.
/// </summary>
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<Vector2> 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");
}
}

View file

@ -0,0 +1,147 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.World;
using Xunit;
namespace AcDream.App.Tests.World;
public class TeleportArrivalControllerTests
{
// Records each Place(destPos, destCell, forced) call.
private sealed record PlaceCall(Vector3 Pos, uint Cell, bool Forced);
private static TeleportArrivalController Make(
ArrivalReadiness verdict,
List<PlaceCall> 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<PlaceCall>();
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<PlaceCall>();
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<PlaceCall>();
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<PlaceCall>();
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<PlaceCall>();
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<PlaceCall>();
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<PlaceCall>();
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<PlaceCall>();
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);
}
}

View file

@ -0,0 +1,76 @@
using System.Linq;
using DatReaderWriter;
using DatReaderWriter.Options;
using DatLandBlock = DatReaderWriter.DBObjs.LandBlock;
using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo;
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Conformance;
/// <summary>
/// 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.
/// </summary>
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<DatLandBlock>(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<DatLandBlockInfo>(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<DatEnvCell>(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}");
}
}
}

View file

@ -0,0 +1,109 @@
using System;
using System.Numerics;
using AcDream.Core.Lighting;
using Xunit;
namespace AcDream.Core.Tests.Lighting;
/// <summary>
/// Conformance tests for the per-vertex static-light burn-in
/// (<see cref="LightBake"/>), ported from retail <c>calc_point_light</c>
/// (0x0059c8b0). Golden values are hand-derived from the decompiled equation:
/// wrap = (1/1.5)·(N·D + 0.5·dist); norm = distsq&gt;1 ? distsq·dist : dist;
/// scale = (1 dist/Range)·intensity·(wrap/norm); contrib = min(scale·color, color).
/// </summary>
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);
}
}

View file

@ -60,21 +60,29 @@ public sealed class LightManagerTests
}
[Fact]
public void Tick_DropsLightsOutsideRangeWithSlack()
public void Tick_SelectsByDistance_RegardlessOfViewerRange()
{
// Retail D3D-style: candidacy is distance-only (the nearest 8). A torch
// lights its OWN surfaces — the shader applies the hard `d < range` cutoff
// PER FRAGMENT (mesh_modern.frag) — so a torch the VIEWER is standing
// outside the range of is still selected; it lights the wall it sits on.
// Replaces the old viewer-range candidacy filter that suppressed it, which
// left dungeon rooms (2227 registered torches) at activeLights≈1 / flat 0.2
// ambient — the "dungeon lighting off" report (#133 A7).
var mgr = new LightManager();
mgr.Register(MakePoint(new Vector3(20, 0, 0), range: 5f)); // far outside its own range
mgr.Register(MakePoint(new Vector3(20, 0, 0), range: 5f)); // viewer outside the torch's range
mgr.Tick(viewerWorldPos: Vector3.Zero);
Assert.Equal(0, mgr.ActiveCount);
Assert.Equal(1, mgr.ActiveCount); // selected by distance; the shader culls per-surface
}
[Fact]
public void Tick_IncludesLightsNearRangeEdge_WithSlack()
public void Tick_IncludesNearbyLight()
{
var mgr = new LightManager();
// Light at distance 5.0, range 5.0: distSq=25, rangeSq*1.1^2 = 25*1.21 = 30.25 → included.
// A nearby point light is selected (distance-only candidacy; the shader
// applies the per-fragment range cutoff).
mgr.Register(MakePoint(new Vector3(5, 0, 0), range: 5f));
mgr.Tick(viewerWorldPos: Vector3.Zero);

View file

@ -93,7 +93,7 @@ public sealed class LightInfoLoaderTests
var light = result[0];
Assert.Equal(LightKind.Point, light.Kind);
Assert.Equal(77u, light.OwnerId);
Assert.Equal(8f, light.Range);
Assert.Equal(10.4f, light.Range, 3); // Falloff 8 × static_light_factor 1.3 (calc_point_light 0x00820e24)
Assert.Equal(0.8f, light.Intensity);
Assert.Equal(new Vector3(101, 202, 303), light.WorldPosition);
Assert.InRange(light.ColorLinear.X, 0.99f, 1.01f);

View file

@ -179,4 +179,89 @@ public class GfxObjDegradeResolverTests
Assert.Equal(baseId, resolvedId);
Assert.Null(resolvedGfx);
}
// ── #136: editor-only placement marker detection ──────────────────────────
/// <summary>
/// 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.
/// </summary>
[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<uint, GfxObj> { [markerGfx] = gfx };
var infos = new Dictionary<uint, GfxObjDegradeInfo> { [degradeId] = info };
Assert.True(GfxObjDegradeResolver.IsRuntimeHiddenMarker(
id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), markerGfx));
}
/// <summary>A real LOD object — slot 0 visible out to a real distance (MaxDist&gt;0) —
/// is NOT a marker, even though it degrades further.</summary>
[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<uint, GfxObj> { [baseId] = gfx };
var infos = new Dictionary<uint, GfxObjDegradeInfo> { [degradeId] = info };
Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker(
id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), baseId));
}
/// <summary>No degrade table at all → not a marker.</summary>
[Fact]
public void IsRuntimeHiddenMarker_NoDegradeTable_False()
{
const uint baseId = 0x01001212u;
var gfx = new GfxObj { Flags = 0, DIDDegrade = 0 };
var gfxObjs = new Dictionary<uint, GfxObj> { [baseId] = gfx };
Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker(
id => gfxObjs.GetValueOrDefault(id), _ => null, baseId));
}
/// <summary>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.</summary>
[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<uint, GfxObj> { [baseId] = gfx };
var infos = new Dictionary<uint, GfxObjDegradeInfo> { [degradeId] = info };
Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker(
id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), baseId));
}
}

View file

@ -546,14 +546,17 @@ public class BSPStepUpTests
/// every frame replays the same hard stop and the character hangs in falling
/// animation until another correction breaks the loop.
/// </summary>
[Fact(Skip = "Issue #116 — slide-response divergence family (P1-era " +
"slide_sphere work made the first airborne wall frame slide in-frame " +
"to Z=1.92 instead of the L.2c-pinned hard stop at Z=2.0; the cached " +
"sliding-normal mechanism retail seeds via get_object_info " +
"(pc:279992, transient bit 4 → init_sliding_normal) only governs the " +
"NEXT frame, so which first-frame response is retail-faithful needs " +
"its own oracle read. NOT a cell-set problem — BR-7/A6.P4 left this " +
"byte-identical. See docs/ISSUES.md #116.")]
[Fact(Skip = "Issue #116 shape-2 — the engine slides IN-FRAME to Z=1.92 " +
"on the first airborne wall frame; this pin expects an L.2c hard stop " +
"at Z=2.0. Ghidra (2026-06-12) confirms retail CSphere::slide_sphere " +
"(0x00537440) applies the slide IN-FRAME (add_offset_to_check_pos → " +
"SLID_TS), so our 1.92 is faithful TO slide_sphere and the Z=2.0 " +
"expectation is the SUSPECT half — but whether retail's first " +
"airborne frame REACHES slide_sphere (→1.92) or hard-stops upstream " +
"(collide_with_environment dispatch / no last-known plane) needs a " +
"cdb trace of an airborne wall hit before flipping the assertion. The " +
"#116 threshold fix (EpsilonSq→F_EPSILON) did NOT change this — the D4 " +
"offset is a real slide, not degenerate. See docs/ISSUES.md #116.")]
public void D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames()
{
var (root, resolved) = BSPStepUpFixtures.TallWall();

View file

@ -0,0 +1,344 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Tests.Conformance;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #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·(y5.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.817) paints the doorway.
/// These tests stay as the characterization pin for the healthy layer.
/// </summary>
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<uint, AcDream.Core.World.Cells.EnvCell> envCells)
BuildEngine(DatCollection dats)
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
var envCells = new Dictionary<uint, AcDream.Core.World.Cells.EnvCell>();
// 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<DatReaderWriter.DBObjs.LandBlockInfo>(Lb | 0xFFFEu);
Assert.NotNull(lbInfo);
foreach (var building in lbInfo!.Buildings)
{
if (building.Portals.Count == 0) continue;
var portals = new List<BldPortalInfo>(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<CellSurface>(), Array.Empty<PortalPlane>(), 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 ──────────────────────────────────────────────────────
/// <summary>Stair-line feet Z for a path y (fitted from the capture bands).</summary>
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<Step>? 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<Step>();
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 ──────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[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) ---"));
}
/// <summary>Boom-distance + damping-lag sweep: how wide is the window across poses?</summary>
[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}"));
}
}
/// <summary>
/// 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.
/// </summary>
[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)");
}
}

View file

@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #133 (Bug A) — the validated-claim placement branch of
/// <see cref="PhysicsEngine.Resolve"/> must return the VALIDATED claim's own
/// full cell id, NOT <c>lbPrefix | (cellId &amp; 0xFFFF)</c>.
///
/// <para>
/// <c>lbPrefix</c> is found by scanning resident landblocks for one whose
/// <c>[0,192)</c> 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 <c>0x00070143</c> at local <c>(70,-60,0.01)</c>).
/// The dungeon landblock fails the <c>localY &gt;= 0</c> 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
/// <c>lbPrefix = 0xA9B30000</c>. The old code then returned
/// <c>0xA9B30000 | 0x0143 = 0xA9B30143</c>, re-stamping the validated dungeon
/// claim with the wrong landblock — the client mis-resolved the player into
/// Holtburg and spammed ACE with rejected moves
/// (<c>movement pre-validation failed from 00070143 to A9B30143</c>).
/// </para>
///
/// <para>
/// 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 <c>0x0007</c>,
/// candidate XY also inside resident Holtburg <c>0xA9B3</c>) and asserts the
/// returned cell keeps its <c>0x0007</c> prefix.
/// </para>
/// </summary>
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<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
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<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 130f);
return engine;
}
/// <summary>Flat 81-vertex stub terrain (all zero heights).</summary>
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<ushort, ResolvedPolygon> { [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<PortalInfo>(),
PortalPolygons = new Dictionary<ushort, ResolvedPolygon>(),
VisibleCellIds = new HashSet<uint>(),
};
}
}

View file

@ -845,15 +845,14 @@ public sealed class MotionInterpreterTests
[InlineData(MotionCommand.RunForward)]
public void GetMaxSpeed_IgnoresForwardCommand_AlwaysReturnsRunRate(uint command)
{
// GetMaxSpeed is the InterpolationManager.AdjustOffset catch-up speed — it deliberately
// returns RunAnimSpeed × run-rate REGARDLESS of the current ForwardCommand (see GetMaxSpeed's
// doc comment: the bare run rate × RunAnimSpeed, ACE MotionInterp.cs:670-678, retail-verified
// — the slow catch-up is intentional, it fixed the 1-Hz remote-blip). It does NOT branch
// per-command. These previously asserted a REMOVED command-branching design (WalkForward →
// WalkAnimSpeed, WalkBackward → ×0.65, Idle → 0); that contract no longer exists, so they are
// consolidated here to PIN the no-branch contract across commands (Phase W green-tests triage).
var interp = MakeInterp();
interp.MyRunRate = 1.75f;
// GetMaxSpeed is the InterpolationManager.AdjustOffset catch-up speed — it
// returns RunAnimSpeed × run-rate REGARDLESS of the current ForwardCommand
// (retail 0x00527cb0 never reads interpreted_state; UN-2 byte verification
// 2026-06-12, tools/verify_un2_fmul.py). These previously asserted a REMOVED
// command-branching design (WalkForward → WalkAnimSpeed, WalkBackward →
// ×0.65, Idle → 0); they PIN the no-branch contract across commands.
var weenie = new FakeWeenie { RunRate = 1.75f };
var interp = MakeInterp(weenie: weenie);
interp.InterpretedState.ForwardCommand = command;
float speed = interp.GetMaxSpeed();
@ -862,17 +861,33 @@ public sealed class MotionInterpreterTests
}
[Fact]
public void GetMaxSpeed_RunForward_NoWeenie_FallsBackToMyRunRate()
public void GetMaxSpeed_NoWeenie_ReturnsLiteralOneTimesRunAnimSpeed()
{
// WeenieObj is null (MakeInterp with no weenie argument); MyRunRate
// is set explicitly. GetMaxSpeed must use MyRunRate as the run-rate
// source when InqRunRate is unavailable.
// Retail 0x00527cb0 weenie_obj == null path: fld 1.0 (.rdata 0x007928B0),
// fmul 4.0 (.rdata 0x007C8918) — the LITERAL 1.0, NOT my_run_rate (UN-2
// byte verification 2026-06-12). MyRunRate is set to a different value to
// prove it is not consulted on this path.
var interp = MakeInterp();
interp.MyRunRate = 1.75f;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
float speed = interp.GetMaxSpeed();
Assert.Equal(MotionInterpreter.RunAnimSpeed * 1.0f, speed, precision: 4);
}
[Fact]
public void GetMaxSpeed_InqRunRateFails_FallsBackToMyRunRate()
{
// Retail 0x00527cb0 InqRunRate-failure path: fld [esi+0x7c] (my_run_rate),
// fmul 4.0. The InqRunRate out-value is discarded on failure.
var weenie = new FakeWeenie { RunRate = 9.9f, InqRunRateResult = false };
var interp = MakeInterp(weenie: weenie);
interp.MyRunRate = 1.75f;
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
float speed = interp.GetMaxSpeed();
Assert.Equal(MotionInterpreter.RunAnimSpeed * 1.75f, speed, precision: 4);
}
}

View file

@ -0,0 +1,257 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.App.Streaming;
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.Streaming;
/// <summary>
/// The dungeon streaming gate (#133 FPS). AC dungeons have no adjacent
/// landblocks (ACE <c>LandblockManager.GetAdjacentIDs</c> 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, <c>Tick(insideDungeon: true)</c> collapses
/// streaming to the single dungeon landblock and unloads the neighbors.
/// </summary>
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<WorldEntity>());
private sealed record Harness(
StreamingController Ctrl,
List<(uint Id, LandblockStreamJobKind Kind)> Loads,
List<uint> Unloads,
Func<int> ClearCalls,
GpuWorldState State);
private static Harness Make()
{
var loads = new List<(uint, LandblockStreamJobKind)>();
var unloads = new List<uint>();
int clearCalls = 0;
var state = new GpuWorldState();
var ctrl = new StreamingController(
enqueueLoad: (id, kind) => loads.Add((id, kind)),
enqueueUnload: unloads.Add,
drainCompletions: _ => Array.Empty<LandblockStreamResult>(),
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);
}
}

View file

@ -169,6 +169,41 @@ public class LandblockMeshTests
Assert.True(cache.Count >= 2, $"Expected mix of palette codes, got {cache.Count}");
}
[Fact]
public void Build_AllTriangles_WindCounterClockwiseInWorldXY()
{
// #108-residual winding pin: TerrainModernRenderer enables backface
// culling with FrontFace(Ccw) — the GL port of retail's single-sided
// terrain (ACRender::landPolysDraw 0x006b7040 draws a land triangle
// only when the eye is on the POSITIVE side of its plane). That cull
// is only correct if EVERY emitted triangle winds the same way:
// counter-clockwise in world XY viewed from above (+Z toward the
// viewer), i.e. cross2D(v1-v0, v2-v0) > 0. Varied heights + several
// landblock coords exercise both FSplitNESW split directions across
// the 64 cells. A future emission-order change that flips any
// triangle would silently punch terrain holes under culling.
var block = BuildFlatLandBlock();
for (int i = 0; i < 81; i++)
block.Height[i] = (byte)((i * 37) % 64); // varied, deterministic slopes
foreach (var (lbx, lby) in new[] { (0u, 0u), (0xA9u, 0xB4u), (3u, 7u) })
{
var cache = new Dictionary<uint, SurfaceInfo>();
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()
{

40
tools/verify_un2_fmul.py Normal file
View file

@ -0,0 +1,40 @@
# UN-2 verification: prove/disprove that retail CMotionInterp::get_max_speed
# (VA 0x00527cb0) multiplies by the 4.0f constant at VA 0x007C8918 on its
# return paths (the fmul the BN pseudo-C drops). Throwaway apparatus.
import struct
p = r"C:\Turbine\Asheron's Call\acclient.exe"
data = open(p, 'rb').read()
pe_off = struct.unpack_from('<I', data, 0x3C)[0]
nsec = struct.unpack_from('<H', data, pe_off + 6)[0]
opt_size = struct.unpack_from('<H', data, pe_off + 20)[0]
sec0 = pe_off + 24 + opt_size
imgbase = struct.unpack_from('<I', data, pe_off + 24 + 28)[0]
def va2off(va):
rva = va - imgbase
for i in range(nsec):
o = sec0 + i * 40
name = data[o:o + 8].rstrip(b'\x00').decode()
vsz, vaddr, rsz, roff = struct.unpack_from('<IIII', data, o + 8)
if vaddr <= rva < vaddr + max(vsz, rsz):
return roff + (rva - vaddr), name
return None, None
print('imgbase', hex(imgbase))
off, sec = va2off(0x00527CB0)
print('get_max_speed VA 0x527cb0 -> 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('<f', data, off2)[0])
off3, sec3 = va2off(0x007928B0)
print('dword @0x7928B0 sec', sec3, '=', struct.unpack_from('<f', data, off3)[0])
off4, _ = va2off(0x00527D00)
code4 = data[off4:off4 + 0x70]
print('get_adjusted_max_speed fmul count:', code4.count(FMUL))