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:
commit
5ac9d8c19c
53 changed files with 6691 additions and 439 deletions
20
CLAUDE.md
20
CLAUDE.md
|
|
@ -108,14 +108,18 @@ movement queries.
|
|||
|
||||
## Current state
|
||||
|
||||
**Currently working toward: M1.5 — Indoor world feels right**
|
||||
(M1 — Walkable + clickable world — landed 2026-05-16 via Phase B.6).
|
||||
The holistic building-render port (Option A: ONE `DrawInside(viewer_cell)`,
|
||||
no inside/outside branch; BR-2..BR-7/T1..T6) is SHIPPED and user-gated,
|
||||
as are the 2026-06-12 closes: #119/#128 tower stairs, #112 cottage
|
||||
transparency. Open render/physics ledger: #113 re-check, #124, #129,
|
||||
#130, #108-residual, #116, #127 (leads in ISSUES.md). Keep this
|
||||
paragraph ≤5 lines + pointers — detail lives in the docs below, NOT here.
|
||||
**Currently working toward: M1.5 — Indoor world feels right.** Dungeons RENDER +
|
||||
are navigable; **login into a dungeon** now loads + places the player and is
|
||||
**FPS-steady from the start** (#135 pre-collapse + indoor cell-floor spawn gate,
|
||||
`712f17f`+`2c92375`). The dungeon **"red cone"** was an editor-only placement marker
|
||||
acdream inherited from WB (retail hides it via distance degrade) — FIXED (#136 `6f81e2c`).
|
||||
REMAINING for M1.5: **A7 dungeon lighting** (LightBake Core landed `3b93f91`; per-vertex
|
||||
bake integration + the per-pixel torch OVER-blow still open — #79/#93); **#137 dungeon
|
||||
collision** (doors / wall openings); **#138 teleport-OUT of a dungeon** loads the outdoor
|
||||
world incompletely + position desync (the collapse→EXPAND gap — same machinery as #135).
|
||||
M2 (CombatMath) deferred. Detail in ISSUES (#135–#138) + the render/physics digests.
|
||||
Recent closes (2026-06-14): #135, #136. Keep this paragraph ≤6 lines + pointers — detail
|
||||
in the docs below, NOT here.
|
||||
|
||||
For canonical state, read in this order:
|
||||
- [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone
|
||||
|
|
|
|||
843
docs/ISSUES.md
843
docs/ISSUES.md
|
|
@ -46,6 +46,356 @@ Copy this block when adding a new issue:
|
|||
|
||||
---
|
||||
|
||||
## #138 — Teleport OUT of a dungeon loads the outdoor world incompletely + position desync
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM (breaks the dungeon→outdoor transition; collision + visuals wrong after exit)
|
||||
**Filed:** 2026-06-14
|
||||
**Component:** streaming — dungeon collapse↔expand (the #133/#135 collapse) + teleport-arrival
|
||||
|
||||
**Description (user):** taking a portal OUT of a dungeon to the outdoor world often loads
|
||||
the world incompletely — **fewer objects than expected (e.g. missing trees/scenery)**, and
|
||||
**collision doesn't work properly**. There's also a **position desync**: "it's like I'm not
|
||||
moving while my character is moving" (the avatar animates/advances but the player's
|
||||
actual position / camera doesn't track, or vice-versa).
|
||||
|
||||
**Root cause / status (hypothesis — needs investigation):** very likely a gap in the
|
||||
dungeon-streaming **collapse→expand** introduced for #133/#135. Inside a dungeon, streaming
|
||||
is COLLAPSED to the single dungeon landblock (radius-0). On teleport OUT,
|
||||
`StreamingController.ExitDungeonExpand` must rebuild the full 25×25 outdoor window at the new
|
||||
center. Suspects: (a) the expand doesn't fully re-enqueue / re-hydrate the outdoor landblocks
|
||||
(→ missing trees/scenery + no collision because shadow-object registration never ran for the
|
||||
un-hydrated blocks); (b) the teleport-arrival recenter (`OnLivePositionUpdated`) +
|
||||
`PreCollapseToDungeon`/observer interaction leaves the streaming observer pinned wrong after
|
||||
exit; (c) the position desync = the player controller / streaming observer disagree on the
|
||||
post-exit world position (the avatar moves in one frame, the streaming/camera in another).
|
||||
Pairs with #135 (`712f17f`/`2c92375`) — same collapse machinery; the EXIT path is the gap.
|
||||
|
||||
**Files:** `src/AcDream.App/Streaming/StreamingController.cs` (`ExitDungeonExpand`, the
|
||||
collapse/expand hysteresis), `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated`
|
||||
teleport recenter ~4912, the streaming Tick gate ~6890, the PortalSpace observer branch),
|
||||
`TeleportArrivalController`. Cross-check the post-exit shadow-object/collision registration.
|
||||
|
||||
**Acceptance:** portal out of the 0x0007 dungeon → full outdoor world streams (trees/scenery
|
||||
present), collision works, and the player position tracks correctly (no avatar-vs-camera desync).
|
||||
|
||||
---
|
||||
|
||||
## #137 — Dungeon collision incorrect at doors and wall openings
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM (movement/collision correctness in dungeons)
|
||||
**Filed:** 2026-06-14
|
||||
**Component:** physics — EnvCell collision (doors, portal openings, cell geometry)
|
||||
|
||||
**Description (user):** collision is still wrong in dungeons — **doors** and **openings in
|
||||
walls** in particular. (Symptoms not fully characterized yet: likely walking through
|
||||
openings that should block / blocking at openings that should pass, and door collision not
|
||||
matching the door's open/closed state.)
|
||||
|
||||
**Root cause / status (to investigate):** dungeon collision is EnvCell-based — the cell's
|
||||
collision BSP + portal openings + per-cell static objects (doors). Candidates: door
|
||||
apparatus collision in EnvCells (open/closed BSP swap) not fully ported; portal-opening
|
||||
(wall gap) collision geometry handled differently from buildings; the per-cell
|
||||
shadow-object registration (A6.P4, see the physics digest) for dungeon EnvCell statics.
|
||||
Related families: #32 (edge-slide), #116 (slide-response), the door-collision saga
|
||||
(see `feedback_dedup_keys_after_cardinality_change`, `feedback_retail_per_cell_shadow_list`).
|
||||
Needs a targeted repro (which door / which opening, expected vs actual) before fixing —
|
||||
oracle-first per the physics digest.
|
||||
|
||||
**Files:** `src/AcDream.Core/Physics/` (EnvCell collision, CellTransit, the door apparatus),
|
||||
`src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (per-cell registration). See
|
||||
`claude-memory/project_physics_collision_digest.md` (the collision SSOT + DO-NOT-RETRY table).
|
||||
|
||||
**Acceptance:** doors block/pass per their open/closed state; wall openings pass; solid walls
|
||||
block — matching retail, in the 0x0007 dungeon.
|
||||
|
||||
---
|
||||
|
||||
## #136 — DONE — "red cone" in the 0x0007 dungeon was an editor-only placement marker acdream drew (retail hides it)
|
||||
|
||||
**Status:** FIXED `6f81e2c` (2026-06-14) — verified live via frame dump: the red cone +
|
||||
green floor "petals" are gone, all real dungeon decorations still render. User-approved
|
||||
frozen-phase fix.
|
||||
**Severity:** LOW (cosmetic; one marker in one dungeon)
|
||||
**Filed/Fixed:** 2026-06-14
|
||||
**Component:** rendering — EnvCell static-object hydration (WB-derived path) vs retail degrade
|
||||
|
||||
**Description:** In the `0x0007` Town Network dungeon a bright-RED downward cone (+ a
|
||||
green/red shape on the floor) rendered ~6 m from the login spawn; the user's side-by-side
|
||||
retail client showed NOTHING there. Became visible only after the #135 login-into-dungeon
|
||||
fix placed the player at the exact saved spawn next to it.
|
||||
|
||||
**Root cause (definitive):** the cone is ONE dat-hydrated EnvCell static object (`guid=0`,
|
||||
`id=0x40000835`, Setup `0x02000C39` / GfxObj `0x010028CA`) baked into cell `0x00070145`,
|
||||
using pure red+green MARKER surfaces (`0x08000109` red, `0x0800010A` green). It is an
|
||||
**editor-only placement marker**: its `DIDDegrade` table `0x11000118` =
|
||||
`{slot0 Id=mesh MaxDist=0, slot1 Id=0 MaxDist=FLT_MAX}` — visible ONLY at distance 0 (the
|
||||
WorldBuilder editor origin) and degraded to GfxObj **id 0 (= nothing)** at any real distance.
|
||||
Retail's distance-based degrade (`CPhysicsPart::UpdateViewerDistance` 0x0050E030 → `Draw`
|
||||
0x0050D7A0 draws `gfxobj[deg_level]`) therefore never draws it in the live client. acdream's
|
||||
render path is extracted from **WorldBuilder**, which — being an editor — renders every cell
|
||||
static's base mesh directly and has **no degrade handling at all** (zero `DIDDegrade` refs in
|
||||
`references/WorldBuilder`), so acdream inherited "show the marker" and drew it forever. (NOT
|
||||
a texture/lighting bug — the cone's *own* object 0x70007055 decodes tan and was a red
|
||||
herring; the marker is a separate `guid=0` dat static.)
|
||||
|
||||
**Fix (`6f81e2c`):** `GfxObjDegradeResolver.IsRuntimeHiddenMarker()` detects the editor-marker
|
||||
pattern (`HasDIDDegrade` + `Degrades[0].MaxDist==0` + a degrade entry with `Id==0`). EnvCell
|
||||
static-object hydration (`GameWindow.cs` ~5793) skips such GfxObjs — whole-stab for bare
|
||||
GfxObj stabs, per-part for Setup stabs (an all-marker Setup then drops via `meshRefs.Count==0`).
|
||||
Faithful equivalent of retail's runtime degrade for static geometry (always viewed at
|
||||
distance > 0); real LOD objects (`slot0.MaxDist>0`) and degrade-to-real-mesh objects are
|
||||
untouched. 4 new `GfxObjDegradeResolver` unit tests.
|
||||
|
||||
**Follow-up (not done):** outdoor `LandBlockInfo.Objects` stabs could carry the same markers;
|
||||
apply `IsRuntimeHiddenMarker` there too if any surface. Also revealed (separate): the per-
|
||||
pixel point-light shader overblows close torches (no per-channel `min(scale·color,color)` cap
|
||||
vs retail `calc_point_light`) — the bright-red dungeon WALL under normal lighting; tracked
|
||||
under the #79/#93 A7 lighting umbrella.
|
||||
|
||||
---
|
||||
|
||||
## #135 — ~30 s low-FPS ramp at login (≈10 fps → high) before streaming settles
|
||||
|
||||
**Status:** DONE `712f17f`+`2c92375` (2026-06-14) — user-verified: login into the 0x0007 dungeon is FPS-steady from the start; dungeon loads + places the player. (NOTE: the teleport-OUT path has a separate streaming gap — see #138.)
|
||||
**Severity:** LOW (startup-only; self-corrects)
|
||||
**Filed:** 2026-06-14
|
||||
**Component:** streaming — first-frame bootstrap vs the dungeon collapse
|
||||
|
||||
**FIX (2026-06-14):** pre-collapse streaming the instant we recenter onto a SEALED
|
||||
dungeon cell at login/teleport, before the first `NormalTick` bootstraps the window.
|
||||
- `StreamingController.PreCollapseToDungeon(cx,cy)` — fires the existing `EnterDungeonCollapse`
|
||||
early (idempotent), so the expensive ocean-grid neighbour window is never enqueued
|
||||
(teleport) / is enqueued-then-immediately-cleared for a cheap Holtburg frame (login).
|
||||
- `GameWindow.IsSealedDungeonCell(cellId)` — reads the `EnvCell` dat `SeenOutside` flag
|
||||
(the same flag the hydrated `ObjCell.SeenOutside` + the per-frame gate use) so a cottage/inn
|
||||
interior keeps its outdoor surround; excludes the 0xFFFE/0xFFFF shell ids.
|
||||
- Hooks in `OnLiveEntitySpawnedLocked` (login) + `OnLivePositionUpdated` (teleport).
|
||||
- Observer robustness: during a teleport `PortalSpace` hold the observer follows the
|
||||
recentered destination (not the frozen position); `_lastLivePlayerLandblockId` is now
|
||||
filtered to the player guid (resolving a Phase A.1 TODO) so a stray NPC update can't drift
|
||||
the login-hold observer off the dungeon and trip `ExitDungeonExpand`.
|
||||
Adversarially reviewed (3 lenses); register row AP-36 amended. Tests in
|
||||
`StreamingControllerDungeonGateTests` (5 new, incl. the real Tick-then-PreCollapse ordering).
|
||||
|
||||
**Description:** On login into a dungeon, FPS starts ~10 and climbs over ~30 s before
|
||||
settling (then 1000+ fps). User: "we still have about 30ish seconds before FPS is ramped
|
||||
up; when logging in I get like 10 then it slowly increases."
|
||||
|
||||
**Root cause / status:** The #133 streaming collapse (`5686050`/`d9e7dd6`/`7d8da99`) only
|
||||
engages once CurrCell resolves to a sealed cell (the snap, a few s in). Before that the
|
||||
first Tick bootstraps the full 25×25 window, so ~24 neighbour ocean-grid dungeons (+ their
|
||||
~19k entities) load, then unload when the collapse fires. The collapse-at-snap change moved
|
||||
the trigger from finalize-time (~30 s) toward snap-time but the bootstrap churn remains.
|
||||
Clean fix = pre-collapse at login when the spawn cell is a sealed dungeon cell so the full
|
||||
window never enqueues (touches the sensitive login spawn path — do carefully; no band-aid).
|
||||
|
||||
**Files:** `GameWindow.cs:6885` (streaming Tick gate); `StreamingController.cs` (collapse);
|
||||
login recenter `OnLiveEntitySpawnedLocked` ~2470.
|
||||
|
||||
**Acceptance:** Login into a dungeon reaches steady-state FPS within ~1–2 s (no full-window
|
||||
neighbour load/unload churn).
|
||||
|
||||
---
|
||||
|
||||
## #134 — Player "lags downward" instead of gliding along a dungeon ramp edge
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW-MEDIUM (movement feel; not a hard traversal block)
|
||||
**Filed:** 2026-06-14
|
||||
**Component:** physics — slope-walk / edge-slide response
|
||||
|
||||
**Description:** Running up or down against a dungeon ramp's edge, the player "sort of lags
|
||||
downwards" instead of gliding/sliding ALONG the ramp surface (up when running up, down when
|
||||
running down). Reported in the 0x0007 Town Network dungeon ramp after #133.
|
||||
|
||||
**Root cause / status:** Surfaced (not caused) by the #133 connector-cell physics
|
||||
registration (`3e006d3`): the ramp connector cell's collision is now fully resident in the
|
||||
physics graph, so the slope-walk / edge-slide response on it is exercised for the first time.
|
||||
"Lag down" suggests the slide velocity is projected toward gravity rather than along the
|
||||
contact plane (the slope tangent). Likely the retail edge-slide / slope-slide response is
|
||||
incomplete — see #32 (retail edge-slide/cliff-slide/precipice-slide incomplete) and the
|
||||
AP-6 / TS-1 / TS-4 slide rows in the divergence register. NO band-aid — port the retail
|
||||
slide-response.
|
||||
|
||||
**Files:** `src/AcDream.Core/Physics/` (slide-response in TransitionTypes / BSPQuery); ramp
|
||||
cell 0x0007014D + neighbours.
|
||||
|
||||
**Acceptance:** Running up a walkable ramp climbs it smoothly; running into the edge slides
|
||||
along the slope (up/down per input direction), matching retail feel.
|
||||
|
||||
---
|
||||
|
||||
## #133 — Teleport into a dungeon snaps the player BEFORE the dungeon landblock streams in → lands at the old landblock's frame (ocean), not the dungeon
|
||||
|
||||
**Status:** OPEN — promoted to **Phase G.3** (Dungeon streaming + portal
|
||||
space + `PlayerTeleport` handling), **PULLED INTO M1.5** (user decision
|
||||
2026-06-13: the indoor world isn't done while dungeons are broken; full
|
||||
G.3 scope chosen). Spec: `docs/superpowers/specs/2026-06-13-dungeon-support-design.md`;
|
||||
G.3a plan: `docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md`.
|
||||
This is now an M1.5 exit-gate blocker, not deferred.
|
||||
|
||||
**PROGRESS (2026-06-13 PM — G.3a core LANDED + Bug A fixed; gate exposed #95):**
|
||||
the teleport-timing root cause IS fixed. G.3a shipped the `TeleportArrivalController`
|
||||
hold-until-hydration (`7947d7a`/`aca4b46`/`f22121b`) + the validated-claim
|
||||
landblock-prefix fix (`2ce5e5c`, "Bug A"). Live gate proof: a real `PlayerTeleport`
|
||||
into the `0x0007` dungeon held through the 46 km jump and grounded the player on the
|
||||
dungeon's walkable floor (`[snap] claim=0x00070143 VALIDATED -> z=0.000`) — **no
|
||||
ocean.** The "terrain-less landblock" framing was refuted earlier (dat probe: dungeon
|
||||
= flat-terrain LandBlock + EnvCells). REMAINING blockers, both exposed at the gate:
|
||||
(1) **#95 CONFIRMED LIVE** — the dungeon renders as "thin air" because WB-DIAG blows
|
||||
up to ~9.1M instances/frame at `0x0007` (see #95); (2) **possible Bug C** — per-tick
|
||||
membership may still drift in the dungeon's negative-local-Y frame (ACE `movement
|
||||
pre-validation failed` spam) — re-gate after Bug A to confirm. NOTE: a render-only
|
||||
EnvCell hydration decouple was tried in G.3a and REVERTED (`e7058ca`) — it made the
|
||||
player character invisible at Holtburg (it touched the shared building hydration
|
||||
path); re-approach separately if a geometry-less collision cell ever needs it.
|
||||
|
||||
**NEW GAP (2026-06-13 PM — login-INTO-a-dungeon):** logging in while the saved
|
||||
character is inside a far dungeon hangs at the auto-entry hold (player frozen,
|
||||
no `[snap]`/`auto-entered player mode`, movement input ignored). Root: the
|
||||
streaming center is set ONCE at startup to the default (`_liveCenterX/Y = centerX/
|
||||
centerY`, `GameWindow.cs:1942` → "centered on 0xA9B4FFFF") and the login spawn never
|
||||
recenters it; a dungeon spawn 46 km away never streams, so `IsSpawnCellReady(spawn
|
||||
cell)` stays false and the #107 hold waits forever. The TELEPORT-arrival path
|
||||
recenters (G.3a `TeleportArrivalController`); the LOGIN path does not. Fix shape =
|
||||
recenter streaming onto the spawn landblock when the login spawn first arrives
|
||||
(mind the #107 auto-entry hold's `SampleTerrainZ(pe.Position)` frame after the
|
||||
recenter). Pre-existing; only surfaces now that the test character can be saved in
|
||||
a dungeon. Workaround to unblock testing: move `+Acdream` out of the dungeon
|
||||
server-side (ACE) before logging in. **FIXED 2026-06-13 (`47ae237`)** — the login
|
||||
player-spawn path now recenters `_liveCenterX/Y` onto the spawn landblock (mirrors
|
||||
the teleport-arrival recenter; no-op for a same-landblock Holtburg login). Verified
|
||||
live: `live: login spawn — recentering streaming from (169,180) to (0,7)` → dungeon
|
||||
streams → `auto-entered player mode` in the dungeon.
|
||||
|
||||
**✅ DUNGEON RENDERS — M1.5 milestone (2026-06-13 PM, autonomous /loop, objectively
|
||||
verified).** With Bug A (`2ce5e5c`) + login-into-dungeon (`47ae237`), a live launch
|
||||
into the `0x0007` dungeon: player grounded on the dungeon floor (`[snap] claim=0x00070143
|
||||
VALIDATED z=0.000`), correct membership (cell stays `0x0007…`, ZERO ACE `failed
|
||||
transition` spam), and the render budget is sane — **WB-DIAG instances ~39,000
|
||||
(meshMissing=0)** vs the 9.1M pre-Bug-A blowup (#95, now RESOLVED as a Bug-A symptom).
|
||||
User-confirmed: "no errors from ACE this time."
|
||||
|
||||
**✅ DUNGEON FPS FIXED + GREY BARRIER FIXED (2026-06-14, user-confirmed).** Two
|
||||
separate causes, both resolved:
|
||||
|
||||
- **FPS (was 14–30, now ~1000+):** AC dungeons sit adjacent in the "ocean" landblock
|
||||
grid, so the 25×25 (farRadius=12) streaming window pulled ~129 neighbour dungeons +
|
||||
their ~19k particle emitters / entities each frame. Fix = **collapse streaming to the
|
||||
player's single dungeon landblock** when CurrCell is a sealed EnvCell (`!SeenOutside`),
|
||||
with landblock-level hysteresis to stop collapse↔expand thrash. Confirmed against ACE
|
||||
(`landblock.IsDungeon → return adjacents` with no neighbours): dungeons have no neighbour
|
||||
landblocks, so collapsing to the one block is retail-faithful. Commits `5686050` (collapse)
|
||||
+ `d9e7dd6` (hysteresis) + `2561918` (pin to CurrCell's landblock, not the position-derived
|
||||
one — the negative cell-local-Y made `floor(pp.Y/192)` land one block off and unload the
|
||||
REAL dungeon). Divergence register: AP-36.
|
||||
|
||||
- **GREY BARRIER (the "barrier above the ramp" / cellar-mouth grey):** portals-only
|
||||
connector cells (ramp mouths, stair landings, cellar throats) build **0 drawable
|
||||
sub-meshes**, and BOTH cell-registration gates (`BuildLoadedCell` → visibility
|
||||
`_cellVisibility`, and `CacheCellStruct` → the physics cell graph) were gated on
|
||||
`cellSubMeshes.Count > 0`. So a connector cell never registered → the portal flood
|
||||
hit a **lookup-miss** at its opening (the un-flooded opening shows the clear/grey
|
||||
colour) AND the camera eye-sweep couldn't transit through it. Fix = register EVERY
|
||||
cell with a valid cellStruct for visibility + physics; only the *drawing* registration
|
||||
stays gated on having sub-meshes. Commits `d90c538` (visibility) + `3e006d3` (physics
|
||||
graph). The physics-graph half EXPOSED the ramp slide-response feel (now **#134**).
|
||||
Three render-MATH theories (portal_side centroid, on-screen clip, near-eye projection)
|
||||
were instrumented and REFUTED before the real lookup-miss cause was found — apparatus
|
||||
discipline held. Render-pipeline digest updated.
|
||||
|
||||
Residual (filed separately): login FPS ramp **#135**; ramp slide-response **#134**; the
|
||||
A7 per-vertex lighting bake (below) is the remaining "lighting off" work.
|
||||
|
||||
**✅ A7 dungeon lighting — selection fix LANDED + objectively verified (`a80061b`).** The
|
||||
"lighting off" report was NOT missing torches — the `ACDREAM_PROBE_LIGHT` diagnostic
|
||||
(`d6fb788`) showed the dungeon correctly gets retail's flat 0.2 indoor ambient + sun zeroed
|
||||
(`UpdateSunFromSky`, `playerInsideCell` true) AND **2227 torch/point-lights register**. The
|
||||
bug was the active-light SELECTION: `LightManager.Tick` dropped any light whose range didn't
|
||||
reach the VIEWER (`DistSq > Range²·slack² → skip`), so a room with 2227 torches lit only the
|
||||
~1 the player stood inside (`activeLights≈1`, rest at flat 0.2). Retail's D3D model picks the
|
||||
8 NEAREST lights and applies the hard range-cutoff PER SURFACE in the shader
|
||||
(`mesh_modern.frag: if (d < range)`). Fix = drop the viewer-range candidacy filter, take the
|
||||
nearest 8. Probe after: **`activeLights` 2→8** in the dungeon (the room's 8 nearest torches now
|
||||
light it). Core lighting suite green. Then `Range = Falloff × 1.5` (retail `rangeAdjust`,
|
||||
`config_hardware_light` 0x0059adc, `a80061b`+) widened the pools. Ambient 0.20 is
|
||||
retail-faithful (`SmartBox::SetWorldAmbientLight(0.2f)`); the 0.30 was a red herring
|
||||
(`CreatureMode` paperdoll renderer, not world cells).
|
||||
|
||||
**⚠️ REAL remaining cause — REVISED 2026-06-14 (the earlier "mis-read intensity" theory is
|
||||
REFUTED).** `intensity=100` is the **REAL dat value** (raw-byte verified `00 00 C8 42` = 100.0f;
|
||||
DatReaderWriter 2.1.7 parses it correctly; the garbage `cone` is MSVC `CD CD CD CD`
|
||||
uninitialized fill Turbine baked into the dat — point lights never read it). **DO NOT `÷100`.**
|
||||
The actual divergence is the **[HIGH] `no-static-light-burnin`**: retail bakes ALL of a cell's
|
||||
reaching static lights **PER-VERTEX once** (`D3DPolyRender::SetStaticLightingVertexColors`
|
||||
0x0059cfe0 → `calc_point_light` 0x0059c8b0, Gouraud-interpolated → uniform, never blown out via
|
||||
the per-channel min-to-colour clamp), while we light **per-PIXEL with only the 8 nearest-to-
|
||||
CAMERA lights** → bright pools near torches, dark between, and a crescent that slides as the
|
||||
camera re-ranks the 8-slot list. Diagnosed via a 5-agent investigation + a clean Ghidra
|
||||
decompile (the BN pseudo-C is x87-mangled). **LANDED:** the per-pixel `(1-dist/falloff_eff)`
|
||||
shader ramp (`007e287`, necessary but NOT sufficient — it can't fix the per-vertex-vs-per-pixel
|
||||
structure) + the GL-free `LightBake` Core (`3b93f91`: the verbatim `calc_point_light` port +
|
||||
7 conformance tests). **REMAINING — the A7 integration:** add a per-vertex linear-RGB colour
|
||||
attribute to the cell mesh + a bake driver keyed on `envCellId` (NOT the dedup `cellGeomId` —
|
||||
adjacent rooms share a geom but not their torches) + consume it in `mesh_modern.frag` for cell
|
||||
draws; bound the bake's light set to the player dungeon (#133's FPS collapse already does this).
|
||||
Belongs to the #79/#93 indoor-lighting umbrella; outdoor static objects + building shells still
|
||||
use the per-pixel-8 path (the same spottiness — separate follow-up). **NOTE — dungeon FPS is
|
||||
FIXED** (was 14–30 from streaming ~129 neighbour ocean-grid dungeons; now ~1000+ fps after the
|
||||
#133 streaming collapse + the allocation-free 8-light partial-select, `5872bcf`/`5686050`).
|
||||
**Severity:** HIGH (any far/dungeon teleport is unusable)
|
||||
**Filed:** 2026-06-13 (M1.5 dungeon-demo gate attempt — meeting-hall portal)
|
||||
**Component:** physics/streaming — teleport-arrival snap vs async landblock hydration
|
||||
|
||||
**Symptom (user):** used the meeting-hall portal to a dungeon; "no
|
||||
dungeon, just ocean (where the dungeon is placed)." ACE spams `failed
|
||||
transition for +Acdream from 0x01250126 [30 -60 6.0] to 0xA9B0000E
|
||||
[-32227 -26748 5.9]` … marching south through `0xA993/0xA97F/…/0xA969`
|
||||
at Z≈−0.9 (underwater) — the server keeps rejecting the client's bogus
|
||||
outdoor movement.
|
||||
|
||||
**Root cause (confirmed against code + the diagnostic log
|
||||
`launch-dungeon-diag.log`):** ACE correctly placed the player in the
|
||||
meeting-hall dungeon cell `0x01250126` (landblock `0x0125` = (1,37)). The
|
||||
acdream teleport-arrival handler (`GameWindow.cs:4877-4960`) DOES recenter
|
||||
the streaming origin to (1,37) (`_liveCenterX/Y`, :4910-4912), but then
|
||||
**immediately** calls `_physicsEngine.Resolve(pos=(30,-60,6.005),
|
||||
cell=0x01250126)` to snap the player (:4928-4931) — BEFORE the dungeon
|
||||
landblock has streamed in. The physics engine still has only the OLD
|
||||
Holtburg landblocks resident (A9B4 + neighbours), so `Resolve` can't find
|
||||
the dungeon cell and falls back to an OUTDOOR scan against the resident
|
||||
landblocks: local (30,−60) maps into A9B3 (the loaded block south of the
|
||||
A9B4 spawn) → snaps to `0xA9B3000E`, terrainZ=94, indoor=False (the
|
||||
`[snap]` line). The player is now at Holtburg's south edge; streaming then
|
||||
shifts the frame out from under them and they slide south into ocean
|
||||
(the `[cell-transit] A9B3→A9B2→…` chain mirrors ACE's failed-transition
|
||||
sequence exactly).
|
||||
|
||||
**Fix shape (G.3):** on a far/different-landblock teleport, recenter +
|
||||
HOLD the snap until the destination dungeon landblock/cell hydrates (reuse
|
||||
the #107 `IsSpawnCellReady` spawn-ready gate, applied to the teleport-
|
||||
arrival path instead of only login), then place into the indoor cell via
|
||||
the validated-claim path (#107/#111 `SetPositionInternal` shape). Also
|
||||
audit the streaming controller actually LOADS the far dungeon landblock on
|
||||
recenter (the 5×5 Chebyshev window around the new center), and that the
|
||||
old landblocks unload without stranding the player mid-frame-shift.
|
||||
|
||||
**Files:** `GameWindow.cs:4877-4960` (teleport arrival),
|
||||
`PhysicsEngine.Resolve` (the outdoor fallback), the #107 `IsSpawnCellReady`
|
||||
gate, `StreamingController` recenter.
|
||||
|
||||
**Acceptance:** teleport into the meeting-hall dungeon → the player stands
|
||||
in the dungeon cell, the dungeon renders (3-5 rooms), walls block, no
|
||||
ocean / no ACE `failed transition` spam.
|
||||
|
||||
**Apparatus:** `ACDREAM_PROBE_CELL=1` ([cell-transit]) + `ACDREAM_PROBE_VIEWER=1`
|
||||
([viewer]) + `ACDREAM_WB_DIAG=1` + the always-on `[snap]`/`live: teleport`
|
||||
lines capture the whole chain (`launch-dungeon-diag.log`, this session).
|
||||
|
||||
---
|
||||
|
||||
## #104 — Scene VFX particles not clipped to the PView visible cell set
|
||||
|
||||
**Status:** OPEN
|
||||
|
|
@ -827,7 +1177,19 @@ Retail oracle for cell-id hysteresis: `acclient_2013_pseudo_c.txt:308742-308783`
|
|||
|
||||
## #95 — Dungeon portal-graph visibility blowup (see-through-walls / other dungeons rendered)
|
||||
|
||||
**Status:** OPEN — **explains user-observed "dungeons are broken"**
|
||||
**Status:** RESOLVED 2026-06-13 — **the 9.1M-instance blowup was a SYMPTOM of Bug A
|
||||
(wrong dungeon membership), NOT an unbounded portal flood.** Chain of evidence: (1) a
|
||||
headless diagnostic on the real `0x0007` dungeon (`Issue95DungeonFloodDiagnosticTests`,
|
||||
`95d9dab`) measured `PortalVisibilityBuilder` visiting only **1–17 cells** per root —
|
||||
already tightly bounded and a strict *subset* of the stab_list (`VisibleCells`, which is
|
||||
the BIG set: avg 120, max 204 of 205 cells). So porting `grab_visible_cells` stab_list
|
||||
bounding would have made it WORSE — **DO NOT do that.** (2) The 9.1M blowup was captured at
|
||||
the G.3a gate *before* Bug A's fix (`2ce5e5c`), when the player's membership wrongly
|
||||
resolved to `0xA9B3` (Holtburg) → the render rooted at the wrong place. (3) With Bug A +
|
||||
login-into-dungeon (`47ae237`) fixed, a live launch into `0x0007` measured
|
||||
**instances=~39,000 (down from 9.1M, ~230×), meshMissing=0**, dungeon renders, no ACE
|
||||
errors. The flood was never the bug. **Originally** also: explained user-observed
|
||||
"dungeons are broken"
|
||||
**Severity:** HIGH (blocks all dungeon navigation visually)
|
||||
**Filed:** 2026-05-21
|
||||
**Component:** rendering, visibility, EnvCell portal traversal
|
||||
|
|
@ -3701,27 +4063,50 @@ Unverified. The likely culprits, ranked by suspected probability:
|
|||
|
||||
---
|
||||
|
||||
## #108 — Cellar↔main-floor transition: terrain (grass) sweeps across the upstairs door opening — [REOPENED 2026-06-11 · narrowed residual]
|
||||
## #108 — Cellar↔main-floor transition: terrain (grass) sweeps across the upstairs door opening — [CLOSED 2026-06-12 · user-gated]
|
||||
|
||||
**Status:** REOPENED (narrowed) — the broad symptom is GONE (T5 +
|
||||
re-gate #2: "Yes, but…"), but a residual remains in ONE window: during
|
||||
the cellar ASCENT, while the eye is still below ground level, the
|
||||
upstairs exit-door opening is covered with grass — "like the ground
|
||||
level rose to the top of the door … as soon as my head pops up it falls
|
||||
back to ground level" (user, re-gate 2026-06-11). The original
|
||||
BR-2-era diagnosis stands: grass-sweep frames render through the
|
||||
OUTDOOR root (membership/viewer-cell flips outdoor mid-cellar), and the
|
||||
#117 depth-gated punch then correctly refuses to punch the aperture
|
||||
where terrain depth is NEARER than the door fan (eye below grade ⇒ the
|
||||
visible front-facing terrain can sit between the eye and the door in
|
||||
depth). The punch must STAY depth-gated (DO-NOT-RETRY) — the fix is on
|
||||
the membership/viewer side (why is the root outdoor while the eye is in
|
||||
the cellar stairwell below grade?). Apparatus shape: a vertical
|
||||
cellar-ascent variant of the #118 exit-walk harness (drive the eye up
|
||||
the stair path; log root resolution + the punch's mark-pass outcome per
|
||||
step). Prior history below.
|
||||
**Status:** CLOSED — user visual gate 2026-06-12 ("Yes it is fixed.")
|
||||
after the terrain-backface-cull fix (`96a425a`). Root cause: terrain
|
||||
drew double-sided; the grass was the grade sheet's underside seen from
|
||||
a below-grade cellar eye. Membership/viewer EXONERATED by the vertical
|
||||
cellar-ascent harness (`007af13`).
|
||||
|
||||
**ROOT CAUSE (2026-06-12): terrain was drawn DOUBLE-SIDED — the grass was
|
||||
the UNDERSIDE of the grade sheet.** Two steps:
|
||||
1. The membership/viewer re-diagnosis below is **REFUTED** by the vertical
|
||||
cellar-ascent harness (`Issue108CellarAscentViewerReplayTests`, dat-backed
|
||||
A9B4 corner-building cellar 0x0174→0x0175→0x0171, production
|
||||
FindCellList pick + the camera probe chain mirrored verbatim): 0
|
||||
outdoor/null viewer resolutions while the eye is below grade, 0 sweep
|
||||
failures, 0 fallback branches across boom distance {2.61, 5} × damping
|
||||
lag {0, 0.3}. The viewer enters 0x0171 at eye z 94.01 — exactly as the
|
||||
head pops above grade (the stairwell portal sits at grade), matching the
|
||||
user's wording. The root is INTERIOR the whole window.
|
||||
2. Retail terrain is SINGLE-SIDED: `ACRender::landPolysDraw` (0x006b7040)
|
||||
draws each land triangle ONLY when the camera is on the POSITIVE (upper)
|
||||
side of its plane (`Plane::which_side2` vs `Render::FrameCurrent`). A
|
||||
below-grade eye gets NO terrain — through the door retail shows sky.
|
||||
WB renders the world with face culling DISABLED frame-globally (WB
|
||||
`GameScene.cs:841` — editor heritage), and `TerrainModernRenderer.Draw`
|
||||
set no cull state of its own → terrain drew double-sided. From a
|
||||
below-grade eye every aperture sight-ray RISES, so the only "terrain" it
|
||||
can see is the underside of the z≈94 grade sheet — which painted the
|
||||
whole exit-door aperture (the landscape slice's 2D NDC clip planes
|
||||
`(nx,ny,0,dw)` have no depth axis and cannot exclude it) and slid down
|
||||
off the door exactly as the eye crossed grade.
|
||||
**Fix: port the landPolysDraw eye-side gate as terrain backface culling**
|
||||
— `TerrainModernRenderer.Draw` now owns Enable(CullFace) + Cull(Back) +
|
||||
FrontFace(Ccw) (set→draw→restore; 7th instance of the self-contained-GL-
|
||||
state rule). Pins: `LandblockMeshTests.Build_AllTriangles_WindCounter-
|
||||
ClockwiseInWorldXY` (every emitted triangle CCW in world XY — cull-safe
|
||||
winding) + `TerrainCullOrientationTests` (above-eye ⇒ CCW window winding
|
||||
kept / below-eye ⇒ CW culled under the production camera convention).
|
||||
**Gate:** climb out of the corner-building cellar — the grass window over
|
||||
the exit door must be gone (sky/world through the door instead); plus a
|
||||
general outdoor sanity glance (terrain intact from above — a wrong
|
||||
FrontFace would blank it).
|
||||
**Severity:** MEDIUM
|
||||
**Component:** ~~render / indoor PView~~ → **physics / membership** (cellar-transition root flip)
|
||||
**Component:** render / terrain (single-sidedness) — membership/viewer EXONERATED
|
||||
|
||||
During the cellar→main-floor ascent (Holtburg), the door opening visible on the main floor
|
||||
shows the outdoor GRASS texture sweeping over it — "like outdoor ground rising up from the
|
||||
|
|
@ -3907,13 +4292,47 @@ retail's viewer-distance smoothing (update_viewer region) before touching.
|
|||
|
||||
## #116 — Slide-response divergence family: near-perpendicular lateral slide lost + first-airborne-frame in-frame slide vs hard stop
|
||||
|
||||
**Status:** OPEN
|
||||
**Status:** OPEN (narrowed) — one Ghidra-confirmed faithfulness fix
|
||||
SHIPPED 2026-06-12; both reported shapes still need a runtime trace.
|
||||
**Severity:** LOW-MEDIUM (over-blocking, never under-blocking — no
|
||||
walk-throughs; feel-level divergence at walls/doors)
|
||||
**Filed:** 2026-06-11 (BR-7 / A6.P4 ship session)
|
||||
**Component:** physics (slide response — `SlideSphere` degenerate-offset
|
||||
guard + first-contact-frame behavior)
|
||||
|
||||
**GHIDRA SESSION 2026-06-12 (the BN branch-sign ambiguity RESOLVED via a
|
||||
second decompiler — Ghidra MCP, patchmem.gpr, full PDB):**
|
||||
- **SHIPPED (faithfulness fix):** `CSphere::slide_sphere` (Ghidra
|
||||
`0x00537440`) compares its SQUARED magnitudes against `::F_EPSILON`
|
||||
(= 0.000199999995 ≈ 0.0002 = `PhysicsGlobals.EPSILON`): `if (::F_EPSILON
|
||||
<= |cross|²)` (crease) and `if (|offset|² < ::F_EPSILON) return
|
||||
COLLIDED_TS` (degenerate guard). Our port compared against `EpsilonSq`
|
||||
(0.0002² = 4e-8) — a ~5000× too-tight threshold (the BN `test ah,5`
|
||||
obscured it). Fixed at `TransitionTypes.cs:3098,3105`; full physics
|
||||
suite (612) + full Core (1443) green, no regression. Crease now needs
|
||||
≥0.81° between normals (was 0.011°); the guard stops slides under
|
||||
~1.41 cm like retail (was 0.2 mm). NOT a register deviation (no row
|
||||
existed — it was an undocumented porting error; the fix matches retail).
|
||||
⚠️ This does NOT fix either reported shape below.
|
||||
- **Shape-1 RE-DIAGNOSED — our `cn=UnitZ` default is RETAIL-FAITHFUL.**
|
||||
Ghidra `validate_transition` (`0x0050aa70`) does exactly our
|
||||
`TransitionTypes.cs:3701-3702`: `if (collision_normal_valid == 0)
|
||||
set_collision_normal(UnitZ)`. So the harness `cn=(0,0,1)` is the
|
||||
faithful FALLBACK; the real divergence is UPSTREAM — at tick-22760 our
|
||||
`collision_normal_valid` was FALSE (→ UnitZ) where retail's was TRUE
|
||||
(it had recorded the door-face normal `(0,+1,0)`). The bug is in the
|
||||
COLLISION-RECORDING path (find_collisions / collide_with_environment),
|
||||
not slide/validate. Next: replay tick-22760
|
||||
(`DoorBugTrajectoryReplayTests`) instrumented to see where our
|
||||
collision-normal recording drops the wall normal.
|
||||
- **Shape-2 NARROWED — D4 stays skipped.** Ghidra confirms slide_sphere
|
||||
applies the slide IN-FRAME (`add_offset_to_check_pos` → SLID_TS), so our
|
||||
Z=1.92 is faithful TO slide_sphere and the D4 Z=2.0 hard-stop pin is the
|
||||
SUSPECT half. But the threshold fix did NOT change D4 (its offset is a
|
||||
real slide, not degenerate), so whether retail's first airborne frame
|
||||
REACHES slide_sphere (→1.92) or hard-stops upstream still needs a cdb
|
||||
trace of an airborne wall hit before flipping the assertion.
|
||||
|
||||
**Two pinned shapes, both pre-dating BR-7 (the per-cell shadow port left
|
||||
them byte-identical):**
|
||||
|
||||
|
|
@ -3945,6 +4364,51 @@ them byte-identical):**
|
|||
fixture as the acceptance pair. Do NOT patch the degenerate-offset guard
|
||||
ad hoc — the DO-NOT-RETRY table's slide entries (physics digest) apply.
|
||||
|
||||
**ORACLE DESK READ DONE (2026-06-12) — needs a LIVE cdb session to
|
||||
finish.** Both sides quoted + verified against source (our
|
||||
`CSphere::slide_sphere` port = `TransitionTypes.cs:3054-3133`; retail
|
||||
`CSphere::slide_sphere` = decomp `0x00537440`, lines 321403-321532).
|
||||
Three concrete leads, none safely fixable from the static BN decomp:
|
||||
|
||||
1. **Shape-1 re-attributed — it is NOT the degenerate-offset guard
|
||||
threshold.** Retail's guard kills slides under ~1.4 cm (`|offset|² <
|
||||
0.000199999995` at `0x537735`); the lost tick-22760 slide was 3.57 cm
|
||||
(`X −0.0357`), well above it — retail would keep it too. The real
|
||||
divergence is the COLLISION-NORMAL SOURCE: our harness recorded
|
||||
`cn=(0,0,1)` (ground), live retail `cn=(0,+1,0)` (the door face).
|
||||
Strong lead: `TransitionTypes.cs:3701-3702` — on a blocked move with
|
||||
no valid collision normal we DEFAULT `cn = Vector3.UnitZ` ("push up");
|
||||
that exact (0,0,1) is what the harness sees. Whether retail has an
|
||||
equivalent default (vs keeping the wall normal) is a runtime question.
|
||||
|
||||
2. **Shape-2 — retail's slide_sphere applies the slide IN-FRAME**
|
||||
(`add_offset_to_check_pos` @`0x53777e`, returns 4=SLID), so our
|
||||
in-frame slide to Z=1.92 on frame 1 is likely retail-faithful and the
|
||||
D4 frame-1 hard-stop pin (`BSPStepUpTests.D4_*`, expects Z=2.0) is the
|
||||
STALE expectation. BUT retail always uses `contact_plane` OR
|
||||
`last_known_contact_plane` (`0x53755a`); it has no "airborne wall-only,
|
||||
no plane" third branch like ours (`TransitionTypes.cs:3080-3092`) — the
|
||||
first-airborne-frame plane state needs a trace before flipping the pin.
|
||||
|
||||
3. **Candidate epsilon-squaring divergence (real, but explains neither
|
||||
shape).** Retail compares SQUARED quantities (`|cross|²` @`0x5375a5`,
|
||||
`|offset|²` @`0x537735`) against `0.000199999995` (≈0.0002, NON-squared);
|
||||
our port compares against `EpsilonSq = 0.0002²` (line 3105 + the
|
||||
`dirLenSq >= EpsilonSq` branch @3098) — potentially ~10⁴× too small.
|
||||
DO NOT change this without cdb confirmation: the BN `test ah, 0x5`
|
||||
branch polarity (lines 321466-321467/321484-321485) is the exact
|
||||
undecodable construct the PosHitsSphere saga warned about, and the
|
||||
register reuse garbles which quantity is squared. A wrong guess here
|
||||
regresses ALL wall-slide behavior.
|
||||
|
||||
**Next (cdb session, well-scoped):** (a) `cdb -z uf
|
||||
acclient!CSphere::slide_sphere` OR a live attach to disassemble
|
||||
`0x00537440` and settle the two `test ah,5` branch signs + the
|
||||
squared-vs-not threshold (prefer LIVE attach — prior lesson: static
|
||||
`-z uf` misdecodes at OMAP boundaries); (b) live trace the tick-22760
|
||||
door push to confirm whether the `cn=(0,0,1)` comes from our
|
||||
`UnitZ`-default (lead 1) and what retail's normal is at that instant.
|
||||
|
||||
---
|
||||
|
||||
## #117 — Aperture-shaped see-through: doors/interiors visible through terrain hills and through nearer buildings — [DONE 2026-06-11 · 478c549, user re-gate "Yes solved"]
|
||||
|
|
@ -4304,35 +4768,56 @@ of which draw list the building's shell left.
|
|||
|
||||
## #124 — Looking out through an opening: far buildings with openings show missing/transparent back walls
|
||||
|
||||
**Status:** OPEN
|
||||
**Status:** CLOSED (user-gated 2026-06-12 evening: "124, that one is solved")
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-06-11 (re-gate; pre-existing — "still have that issue")
|
||||
**Filed:** 2026-06-11 (re-gate; pre-existing — "still have that issue";
|
||||
user 2026-06-12: "especially visible when I look out through a door
|
||||
opening when inside a building")
|
||||
**Component:** render — per-building look-in floods under INTERIOR roots
|
||||
|
||||
From inside a building, looking out through a door/window at ANOTHER
|
||||
building that has an opening: the far building's back walls are
|
||||
missing/transparent (see the world through it). **Lead (by read):** the
|
||||
per-building look-in floods (`MergeNearbyBuildingFloods`) run ONLY for
|
||||
outdoor roots — `RetailPViewDrawContext.NearbyBuildingCells` is
|
||||
documented "Null for interior roots." So under an interior root the far
|
||||
building's INTERIOR never floods: through its window you see the shell
|
||||
only, and a shell has no interior back-wall faces → transparent.
|
||||
Retail runs the building look-in inside `LScape::draw` (DrawBlock →
|
||||
DrawPortal → ConstructView(CBldPortal)), which executes for ANY root
|
||||
whose outside view is non-empty — including interior roots looking out
|
||||
a doorway. Fix shape: provide the nearby-building gather + per-building
|
||||
floods for interior roots too, with look-in apertures getting PUNCH
|
||||
semantics (the `forceFarZ` selector currently keys on
|
||||
`clipRoot.IsOutdoorNode`, which under-punches this case). Needs its own
|
||||
focused pass — touches the gather, the merge, and the depth-mask
|
||||
selector.
|
||||
missing/transparent. The lead confirmed by decomp: retail runs the
|
||||
look-in INSIDE the landscape stage for ANY root — `LScape::draw` is the
|
||||
FIRST call of `PView::DrawCells`' outside-view branch (pc:432719),
|
||||
strictly BEFORE the depth clear (pc:432732) and the seals (pc:432785);
|
||||
`ConstructView(CBldPortal)`'s GetClip runs under the INSTALLED view
|
||||
(the doorway region), and all apertures far-Z punch (pass 1) before any
|
||||
interior cell draws (pass 2).
|
||||
|
||||
**Fix (2026-06-12):**
|
||||
- The per-building gather (frustum pre-gate on `Building.PortalBounds`)
|
||||
now runs for interior roots too; the root's own doorway self-excludes
|
||||
via the seed eye-side test.
|
||||
- `BuildFromExterior` gained `seedRegion` — the port of retail's
|
||||
installed-view clip: interior-root look-ins seed clipped against the
|
||||
OutsideView (doorway) polygons, so a building not visible through the
|
||||
doorway never floods. Outdoor roots keep the full-screen default.
|
||||
- NEW `DrawBuildingLookIns` sub-pass inside the LANDSCAPE stage (before
|
||||
the depth clear + seals): per building, punch ALL apertures
|
||||
(`DrawLookInPortalPunch`, always far-Z), then draw the flooded cells'
|
||||
shells + statics far→near. NOT merged into the main frame — a merged
|
||||
cell would draw post-clear and z-fail against the root's seal.
|
||||
- Look-in cells join the Prepare/partition set (shells get batches,
|
||||
statics route to ByCell, consumed only by the sub-pass).
|
||||
|
||||
Pins: `Issue124LookInSeedRegionTests` (containing region floods ⊆
|
||||
full-screen flood; disjoint region floods nothing; interior-side eye
|
||||
never seeds its own exit door). Register: AP-33 (look-in statics drawn
|
||||
whole — no per-part viewcone; look-in DYNAMICS deferred — an NPC inside
|
||||
a far building stays invisible; both documented).
|
||||
|
||||
**Gate:** from inside a building, look out the door at another building
|
||||
with an open door/window — its interior/back walls render through its
|
||||
aperture instead of see-through to the world behind.
|
||||
|
||||
---
|
||||
|
||||
## #125 — GL InvalidOperation during staged texture upload: failed uploads are STICKY (never retried) + uncaught crash in GenerateMipmaps
|
||||
|
||||
**Status:** ROOT CAUSE FIXED 2026-06-11 (`fcade06`, live-verified) —
|
||||
remaining: the sticky-drop design debt (below).
|
||||
**Status:** CLOSED 2026-06-12 — the GL root cause was fixed `fcade06`
|
||||
(2026-06-11, live-verified); the remaining sticky-drop DESIGN DEBT is now
|
||||
fixed too (bounded upload retry, below). No visual gate (robustness).
|
||||
|
||||
**RESOLVED (root cause):** the GL errors were the gpu_us QUERY RING's own
|
||||
— a glGenQueries name isn't a query object until first glBeginQuery, and
|
||||
|
|
@ -4347,11 +4832,28 @@ slot; read only begun queries. Live-verified in-tower: 0 [wb-error]
|
|||
time under pview, meshMissing=0. **Normal runs (WB_DIAG off) never had
|
||||
these errors — this mechanism is RETIRED for #119.**
|
||||
|
||||
**Remaining debt (keep open under this number):** UploadMeshData removes
|
||||
the preparation task BEFORE uploading, so any genuinely-failed upload is
|
||||
never retried — permanently invisible mesh with one [wb-error] line.
|
||||
The trigger is gone but the design flaw isn't; add retry/re-prepare
|
||||
semantics in a maintenance pass.
|
||||
**Remaining debt — FIXED 2026-06-12 (bounded upload retry):** the exact
|
||||
stick was the CPU-cache short-circuit, not just the early `TryRemove`: a
|
||||
failed `UploadMeshData` (catch → null) consumed the staged item and left
|
||||
`_renderData` empty while the prepared data lingered in `_cpuMeshCache`,
|
||||
so `PrepareMeshDataAsync`'s cache-hit path (`ObjectMeshManager.cs:448-453`)
|
||||
returned it WITHOUT re-staging → never re-uploaded until CPU-cache
|
||||
eviction (effectively session-sticky under low cache pressure). Fix: the
|
||||
Tick drain (`WbMeshAdapter.cs`) now re-stages a failed upload for the NEXT
|
||||
frame via `ObjectMeshManager.UploadOrRequeue`, bounded by
|
||||
`MaxUploadRetries` (3) using a counter on the `ObjectMeshData` object
|
||||
(resets to 0 on re-prepare). Re-stages are collected and re-enqueued
|
||||
AFTER the drain loop — never inside it — so a deterministic failure can't
|
||||
spin the queue in one frame; past the cap it gives up with a loud
|
||||
`[up-retry] … giving up` line (surfaces a genuine GL defect instead of
|
||||
the old silent permanent drop). Retail loads synchronously and has no
|
||||
such failure mode; this converges the async pipeline toward that
|
||||
guarantee. Build + App.Tests (264) green; no GL-context test seam exists
|
||||
for the upload path so the retry is verified by construction + the
|
||||
regression suite. The uncaught `GenerateMipmaps` path (open-question c)
|
||||
is INTENTIONALLY left to surface errors — adding a blanket catch there
|
||||
would mask future real defects (no-workarounds rule); its trigger
|
||||
(`fcade06`) is already retired.
|
||||
**Filed:** 2026-06-11 (in-tower WB_DIAG launch, `tower-wbdiag3.log` — preserved in the worktree root)
|
||||
**Component:** render — WB staged texture pipeline (ObjectMeshManager / ManagedGLTextureArray)
|
||||
|
||||
|
|
@ -4417,8 +4919,21 @@ not raw terrain. Note the snap line even shows a candidate it rejected
|
|||
|
||||
## #127 — Per-building flood admissions are BISTABLE per frame under the outdoor root (the building-flap mechanism)
|
||||
|
||||
**Status:** OPEN — HIGH (the live mechanism behind the tower roof/edge
|
||||
flap; almost certainly #123 and related flap reports)
|
||||
**Status:** CLOSED 2026-06-12 — user re-gate ("Seems to have been
|
||||
fixed" — ran past distant buildings, no flicker/vanish) + desk
|
||||
confirmation. The bistable-admission mechanism died with the **W=0
|
||||
polyClipFinish clip port** (`987313a`, the #119/#120 work that
|
||||
"kills the knife-edge class everywhere") plus the #120 containment-
|
||||
rejection growth fix. NOTE the captured-pair evidence in
|
||||
`tower-viewer-capture.log` predates all of those fixes — it was the
|
||||
near-eye knife edge, the same class. Pins (both green at HEAD):
|
||||
`Issue127FloodFlipReplayTests.CapturedFlipPair_AdmissionIsStable`
|
||||
(the original 4 cm flip pair now |A|=|B|, zero diff, all FOVs, both
|
||||
pre-gate states) + `DistantBuildingStrafe_NoAdmissionChurn` (the
|
||||
regression pin: 0 churn across 21 building groups × {10,30,60,120,190} m
|
||||
× 100 mm-steps run-past strafe, both pre-gate states). DO-NOT-RETRY:
|
||||
do not re-open the BuildFromExterior seed gates for flap symptoms
|
||||
without a FRESH repro at HEAD — the captured-pair lead is dead.
|
||||
**Filed:** 2026-06-11 (tower capture run)
|
||||
**Component:** render — BuildFromExterior seed admission / per-building
|
||||
flood stability
|
||||
|
|
@ -4477,73 +4992,213 @@ staircase entity's per-frame draw decision.
|
|||
|
||||
## #129 — Doors/doorways leak through terrain and houses from over a landblock away
|
||||
|
||||
**Status:** OPEN
|
||||
**Status:** FIX SHIPPED — awaiting user visual gate
|
||||
**Severity:** MEDIUM (visible at distance during normal outdoor play)
|
||||
**Filed:** 2026-06-12 (user report, post-#119-close session)
|
||||
**Component:** render — aperture depth punch at distance (#117 family)
|
||||
**Component:** render — aperture depth punch at distance (#117 family, AD-18)
|
||||
|
||||
**Symptom (user):** "leakage of like doors and doorways through the
|
||||
terrain and houses over a landblock" — door/doorway-shaped patches
|
||||
visible THROUGH intervening terrain and nearer buildings when the
|
||||
source building is roughly a landblock (~192 m) or more away.
|
||||
|
||||
**Leads:**
|
||||
1. **The #117 stencil depth-gate bias at long range (top suspect).**
|
||||
#117's fix (`478c549`) marks aperture pixels at biased true depth
|
||||
(LEQUAL, bias 0.0005 NDC) then far-Z punches only marked pixels. With
|
||||
a non-linear depth buffer, 0.0005 NDC at ~200 m spans many METERS of
|
||||
view depth — the bias can exceed the separation between the aperture
|
||||
and a hill/house in front of it, marking occluder pixels and punching
|
||||
them → the occluder shows the interior/background behind. The #108
|
||||
coverage constraint pulls the bias up; distance pulls it wrong —
|
||||
re-derive the bias in eye-space (or scale by w) instead of constant
|
||||
NDC.
|
||||
2. Per-building look-in floods admitting distant buildings (the #127
|
||||
churn family) — would gate WHICH buildings punch, not the
|
||||
through-occluder leak itself.
|
||||
**Root cause (lead 1 confirmed analytically, `Issue129PunchBiasTests`):**
|
||||
the #117 mark-pass bias was a CONSTANT 0.0005 NDC. NDC depth is
|
||||
non-linear — a constant NDC bias `b` spans ≈ `b·d²/near` meters of eye
|
||||
depth at distance `d`. With retail's znear 0.1 that is 0.125 m at 5 m
|
||||
but **~190 m at a landblock**: every hill/house in front of a distant
|
||||
aperture passed the LEQUAL mark and was far-Z punched → the door-shaped
|
||||
leak. Exactly AD-18's recorded "Risk if assumption breaks".
|
||||
|
||||
**Next:** capture at the spot (ACDREAM_PROBE_VIEWER=1 + a screenshot +
|
||||
player/eye position from [snap]/[viewer]); confirm whether the leak
|
||||
patch matches an aperture polygon of the distant building; then test
|
||||
the eye-space-bias hypothesis headlessly (the #117 commit has the bias
|
||||
math).
|
||||
**Fix (2026-06-12):** cap the bias's EYE-SPACE span —
|
||||
`biasNdc(d) = min(0.0005, 0.5 m × near / d²)`
|
||||
(`PortalDepthMaskRenderer.MarkBiasNdc`, mirrored in the vertex shader).
|
||||
Below the ~10 m crossover the constant term wins, bit-identical to the
|
||||
T5-validated behavior (#108 grass coverage untouched); beyond it the
|
||||
punch can never reach an occluder more than 0.5 m in front of the
|
||||
aperture plane. Pins: `Issue129PunchBiasTests` (old form spans >100 m
|
||||
at a landblock; capped form ≤0.5 m at all distances; close range
|
||||
unchanged).
|
||||
|
||||
**Gate:** the original spot — distant building doors no longer show
|
||||
through terrain/houses at ~a landblock; AND the #108 cellar grass-sweep
|
||||
stays gone up close. If a >10 m-range #108-class residue appears, the
|
||||
cap constant (0.5 m) is the tuning knob — see AD-18.
|
||||
|
||||
---
|
||||
|
||||
## #130 — Background-color strip along the TOP outer edge of a doorway when looking out from inside
|
||||
|
||||
**Status:** OPEN
|
||||
**Status:** FIX 2 SHIPPED — awaiting user visual re-gate
|
||||
**Severity:** LOW-MEDIUM (small strip, but on the most-stared-at pixels in the game)
|
||||
**Filed:** 2026-06-12 (user report, post-#119-close session; "also NOW" —
|
||||
possibly new since the W=0 clip port `987313a`)
|
||||
**Component:** render — doorway aperture edge (seal/punch/OutsideView seam)
|
||||
**Filed:** 2026-06-12 (user report, post-#119-close session)
|
||||
**Component:** render — drawn-shell lift vs draw-space portal consumers (AP-32)
|
||||
|
||||
**Symptom (user):** standing inside looking out through a doorway, a
|
||||
thin strip of background (clear/world) color runs along the OUTER edge
|
||||
of the TOP of the doorway opening.
|
||||
of the TOP of the doorway opening. Survived the scissor fix (`6c4b6d6`)
|
||||
— user screenshot 2026-06-12 evening, "very subtle".
|
||||
|
||||
**Leads (capture first — plausibly a `987313a` regression):**
|
||||
1. The W=0 port changed `ProjectToClip` (exact w>=0, no 1e-4 epsilon)
|
||||
and DELETED the `EyeInsidePortalOpening` rescue — the OutsideView
|
||||
region through a near doorway is computed slightly differently now.
|
||||
If the OutsideView's top edge sits ~1 px BELOW the aperture's drawn
|
||||
shell edge, terrain/outdoor geometry isn't drawn in that strip while
|
||||
the interior seal/punch still cleared it → background color.
|
||||
Suspects within the port: `MergeSubPixelVertices` shaving a top
|
||||
vertex; the exact-w boundary vs the old epsilon shifting the
|
||||
projected edge; the deleted rescue no longer substituting the full
|
||||
view for an eye-pressed doorway.
|
||||
2. The interior SEAL depth vs the shell top edge (the #118-era
|
||||
machinery) — a 1-px mismatch between the seal polygon and the shell
|
||||
aperture would show the clear color exactly at an edge.
|
||||
**Root cause (the REAL strip, pinned by
|
||||
`Issue130DoorwayStripTests.UnliftedGate_LeavesTheStripAtTheDrawnTopEdge`):
|
||||
the +0.02 m shell render lift.** Cell shells DRAW 2 cm above the dat
|
||||
origin (z-fight vs terrain, AP-32); since `f35cb8b` (the #119-residual
|
||||
fix) the visibility graph deliberately uses the PHYSICS (unlifted)
|
||||
transform — but the OutsideView color gate and the seal fans, which are
|
||||
DRAW-space consumers, kept the unlifted polygons. The drawn lintel
|
||||
therefore sits one lift-projection ABOVE the gate's top edge —
|
||||
**6.7 px at a 2.4 m doorway** (measured) — and that band gets no
|
||||
terrain/sky color while the seal also stamps 2 cm low. Regression from
|
||||
`f35cb8b` (2026-06-11), NOT from the W=0 clip port. Vertical edges are
|
||||
immune (the lift slides them along themselves) — top edge only, exactly
|
||||
as reported.
|
||||
|
||||
**Next:** screenshot + [viewer]/[pv-dump] capture at a doorway showing
|
||||
the strip; diff the OutsideView top edge NDC vs the aperture polygon's
|
||||
projected top edge for that frame (the CornerFloodReplay harness
|
||||
machinery can replay the frame headlessly once the eye/cell are
|
||||
captured). If it reproduces at the same doorway with `987313a` reverted
|
||||
locally, it's the port's edge math; fix the math, never re-add the
|
||||
rescue.
|
||||
**Fix 2:** draw-space consumers re-apply the lift —
|
||||
`PortalVisibilityBuilder.Build(drawLiftZ:)` projects the exit-portal
|
||||
OutsideView region with the lifted transform (flood admission, side
|
||||
tests, CellViews stay physics-space per f35cb8b), and the seal/punch
|
||||
fans lift their world verts. One shared constant
|
||||
`PortalVisibilityBuilder.ShellDrawLiftZ` now feeds the shell
|
||||
registration, the gate, and the fans. AP-32 register row added (the
|
||||
lift had no row). Pins: the lifted gate covers the drawn aperture to
|
||||
0.00 px across the 147-combo sweep; the unlifted gate shows the 6.7 px
|
||||
strip (sensitivity).
|
||||
|
||||
**Fix 1 (also real, sub-pixel): `6c4b6d6`** — the doorway-slice scissor
|
||||
`Floor(origin)+Ceiling(size)` cut up to 1 px off the top/right edges;
|
||||
now a conservative outer bound (`NdcScissorRect`, AD-17 doctrine).
|
||||
The W=0 clip port `987313a` is exonerated (CPU pipeline sub-pixel exact
|
||||
in like-for-like space).
|
||||
|
||||
**Gate:** stand inside, look out the door with the lintel on screen,
|
||||
sweep the gaze — no background strip at the top edge at any alignment
|
||||
or distance.
|
||||
|
||||
---
|
||||
|
||||
## #131 — Portal swirl invisible when viewed from inside a building through the doorway
|
||||
|
||||
**Status:** CLOSED (user-gated 2026-06-12 night: "Ok now it works" — fix 4, `d208002`)
|
||||
**Severity:** MEDIUM (portals are landmark objects; the through-door view is common)
|
||||
**Filed:** 2026-06-12 (user report, #124 gate session)
|
||||
**Component:** render — UNATTACHED emitters have no pass under interior roots
|
||||
|
||||
**Symptom (user, axiom):** "the portal swirl is missing, when I look out
|
||||
from inside a house. Appears when I walk out again."
|
||||
|
||||
**Root cause (confirmed by read + the [outstage] capture):** every
|
||||
particle pass under an interior root is id-FILTERED: the landscape
|
||||
slice's Scene pass and the cell/dynamics passes all require
|
||||
`emitter.AttachedObjectId != 0` and membership in an owner set. An
|
||||
UNATTACHED emitter (`AttachedObjectId == 0` — portal swirls, campfires,
|
||||
ground effects anchored at a position) therefore draws NOWHERE when the
|
||||
root is interior. The outdoor root has the dedicated T3 pass for
|
||||
exactly this class (its own comment: "unattached ones had NO pass on
|
||||
outdoor-node frames") — the identical hole on interior-root frames was
|
||||
never plugged. Walk out → the T3 pass picks the swirl up → "appears
|
||||
when I walk out again". The capture corroborated the rest of the chain
|
||||
healthy: outside-stage routing + cone PASS for the dynamics, 57
|
||||
attached emitters matched and drawn through the doorway.
|
||||
|
||||
**Fix (2026-06-12):** `DrawUnattachedSceneParticles` — invoked ONCE per
|
||||
interior-root frame at the end of the landscape stage (pre-clear; drawn
|
||||
later they would z-fail against the doorway seal), after the #124
|
||||
look-ins so swirls blend over far interiors, NOT per slice (alpha
|
||||
particles must not double-draw — the #121 lesson). Mutually exclusive
|
||||
with the outdoor T3 pass by root kind. Residual (documented): unattached
|
||||
INDOOR emitters now draw pre-clear and are overpainted by the room's
|
||||
shells — same invisibility as before this fix; the proper per-emitter
|
||||
cell classification is a future port.
|
||||
|
||||
**Apparatus (kept, env-gated):** `ACDREAM_PROBE_OUTSTAGE=1` —
|
||||
`[outstage]` (per-slice routing + cone verdicts) + `[outstage-pt]`
|
||||
(slice id set, attached matched count, unattached count).
|
||||
|
||||
**FIX 1 INSUFFICIENT (user screenshots, same evening):** the swirl is
|
||||
the portal's TRANSLUCENT MESH, not (only) unattached particles. The
|
||||
real mechanism — shared with #132 — is the #124 look-in ordering: the
|
||||
slice drew the portal mesh (and all scene particles) BEFORE the look-in
|
||||
sub-pass; translucents write no depth, so the far building's interior
|
||||
(drawn into its far-Z-punched aperture) overpainted them wherever a
|
||||
look-in opening sat behind them on screen. Both screenshots show the
|
||||
swirl exactly in front of the hall's doorway. Retail cannot have this
|
||||
bug: all landscape-stage alpha draws are deferred into ONE flush after
|
||||
LScape::draw (`D3DPolyRender::FlushAlphaList`, DrawCells pc:432722).
|
||||
|
||||
**FIX 2 (the FlushAlphaList deferral, same commit family as #124):**
|
||||
the landscape stage is now TWO phases per frame — EARLY per slice: sky,
|
||||
terrain, outdoor static meshes (the look-in punches need their depth, the
|
||||
#117 lesson); then the #124 look-ins; then LATE per slice: outside-stage
|
||||
dynamics' meshes + ALL attached scene particles + weather + the
|
||||
unattached pass. (This FIXED #132 indoors but not the portal.)
|
||||
|
||||
**ROOT CAUSE (fix 4 — structurally forced; fixes 1–3 were
|
||||
real-but-adjacent):** the teleport capture flipped `pCell` to
|
||||
**0xA9B4017A — the hall's porch EnvCell** (the portal is a SERVER
|
||||
object standing inside a look-in cell), and the headless replay of the
|
||||
captured indoor frame proved the look-in flood ADMITS 0x017A (14 cells
|
||||
incl. the porch — `Issue131SetupProbeTests.Diagnostic_LookInFlood_*`).
|
||||
The partition routes server objects to the dynamics-last pass, where
|
||||
(a) the viewcone has NO entries for look-in cells → culled, and (b)
|
||||
even un-culled they would z-fail post-seal beyond the root's door plane
|
||||
(the #118 lesson). This is exactly AP-33's recorded "look-in DYNAMICS
|
||||
are not drawn (deferred)" — the deferred case was the town portal.
|
||||
Outdoors the merge path puts the porch in the main cone → drawn →
|
||||
"appears when I walk out."
|
||||
|
||||
**Fix 4:** look-in-cell DYNAMICS draw inside `DrawBuildingLookIns`
|
||||
pass 2 (with the statics, whole — AP-33's over-include), and their
|
||||
emitters ride the same `DrawCellParticles` call (fix 3). Retail
|
||||
equivalent: the nested DrawCells draws the cell's objects
|
||||
(`DrawObjCellForDummies` pc:432878+). No double-draw: dynamics-last
|
||||
keeps culling them (cell absent from the main cone);
|
||||
DrawDynamicsParticles only sees dynamics-last cone survivors.
|
||||
|
||||
**Gate:** stand inside, look out the doorway at the town portal — the
|
||||
swirl renders through the door.
|
||||
|
||||
---
|
||||
|
||||
## #132 — Candle flame disappears when the through-opening background is behind it
|
||||
|
||||
**Status:** CLOSED (user-gated 2026-06-12: indoors "now the candle light is visible", outdoors "Candle works now")
|
||||
**Severity:** LOW-MEDIUM
|
||||
**Filed:** 2026-06-12 (user report, #124 gate session)
|
||||
**Component:** render — slice particles drawn before the #124 look-ins
|
||||
|
||||
**Symptom (user, axiom):** "I have a candle, when I look at the candle
|
||||
when a wall is behind it it shows, but if I turn a bit and the opening
|
||||
through a house is behind it candle light disappears."
|
||||
|
||||
**Root cause (= #131's fix-2 mechanism):** the candle/lantern's flame
|
||||
is an attached emitter drawn in the landscape slice's Scene-particle
|
||||
pass, which ran BEFORE the #124 look-in sub-pass. Particles write no
|
||||
depth; whenever a look-in opening ("the opening through a house") sat
|
||||
behind the flame on screen, the far building's interior — drawn into
|
||||
its far-Z-punched aperture — overpainted the flame. Against a plain
|
||||
wall (no look-in aperture behind), nothing overdraws it → visible.
|
||||
Background-dependence explained exactly.
|
||||
|
||||
**Fix:** the landscape stage's two-phase split (see #131 FIX 2): all
|
||||
scene particles moved to the LATE phase, after the look-ins.
|
||||
|
||||
**Gate 1 result (user):** indoors FIXED ("now the candle light is
|
||||
visible when I'm in the house when it is in front of the opening") —
|
||||
but the OUTDOOR sibling surfaced ("when I go out it is not showing
|
||||
unless I turn so the angle doesn't put it in front of the opening"):
|
||||
under an OUTDOOR root the merged building interiors draw AFTER the
|
||||
landscape stage, so a slice-drawn flame is overpainted by the punched
|
||||
aperture's interior — the residual AP-34 had already recorded.
|
||||
|
||||
**Fix 2 (outdoor):** outdoor roots skip the slice Scene pass; attached
|
||||
outdoor-static scene emitters draw in the POST-FRAME pass alongside the
|
||||
T3 unattached pass (depth complete there — flames composite correctly
|
||||
against interiors). The owner-id filter carries over; cell-pass and
|
||||
dynamics-pass emitters keep their own passes (owners never in the
|
||||
outdoor-static set → no double-draw).
|
||||
|
||||
**Gate:** both sides — indoors with the opening behind the candle, and
|
||||
outdoors at the angle that previously erased it.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
# Night-session handoff (2026-06-12): nine closes shipped; NEXT = #108-residual
|
||||
|
||||
**Branch state:** `claude/thirsty-goldberg-51bb9b`, pushed to BOTH remotes at
|
||||
`49cffe6`. Suites green at every commit: App 261+1skip / Core 1439+2skips /
|
||||
UI 420 / Net 294. CLAUDE.md "Current state" + the render digest
|
||||
(`claude-memory/project_render_pipeline_digest.md`) are refreshed to this
|
||||
truth — orient there first.
|
||||
|
||||
## 1. What this session closed (all user-gated; do NOT re-litigate)
|
||||
|
||||
| Closed | Root cause | Commits |
|
||||
|---|---|---|
|
||||
| **#130** doorway top-edge strip | TWO stacked causes: scissor box `Floor(origin)+Ceiling(size)` under-covers top/right (sub-pixel, `NdcScissorRect`); THE strip = the +0.02 m shell draw-lift missing from draw-space portal consumers post-f35cb8b (6.7 px @2.4 m, measured) | `6c4b6d6`, `5135066` (AP-32 row added) |
|
||||
| **#129** doors leak through terrain at ~a landblock | constant 0.0005 NDC punch bias spans ~190 m of eye depth at distance; capped to 0.5 m eye-space (`MarkBiasNdc`) | `4ba7148` (AD-18 updated) |
|
||||
| **#113** hill-cottage phantom stairs | dead via `2163308` (cache cross-serving) — re-gate confirmed | — |
|
||||
| **#124** far-building back walls through openings | interior-root look-ins ported as a LANDSCAPE-STAGE sub-pass (decomp: LScape::draw runs FIRST in DrawCells' outside branch, pc:432719, pre-clear/pre-seal; seeds clip vs the INSTALLED view → `BuildFromExterior(seedRegion:)`; punch-all-then-draw). NEVER merge look-ins into the main frame (post-clear seal z-kill) | `77cef4c` (AP-33 added) |
|
||||
| **#132** candle flame vs through-opening background | slice particles drew BEFORE the look-ins / merged interiors (no depth self-protection) — the FlushAlphaList deferral ported as the two-phase slice split + outdoor post-frame attached pass | `20d1730`, `87afbc0` (AP-34 added) |
|
||||
| **#131** portal swirl missing through doorways | FOUR layers (see lesson below); final: the portal is a SERVER object inside the hall's PORCH cell (look-in cell) → partition.Dynamics → dynamics-last culls it (no look-in cells in the main cone) + post-seal z-fail. Fix: `DrawBuildingLookIns` draws look-in-cell dynamics + emitters (retail nested DrawCells/`DrawObjCellForDummies`) | `1d3f9a8`, `47f32cd`, `d208002` |
|
||||
| **UN-2** GetMaxSpeed ×4 contradiction | the implementation was retail-correct; BN pseudo-C drops x87 fmuls — byte-verified (3× `fmul [0x7C8918]`=4.0f); doc rewritten, weenie-null default aligned to literal 1.0; row deleted | `0cb97aa` (verifier `tools/verify_un2_fmul.py`) |
|
||||
|
||||
## 2. THE #131 LESSON (cost: 4 fix iterations)
|
||||
|
||||
**Identify the ENTITY before theorizing about draw passes.** Three
|
||||
real-but-adjacent fixes shipped before the elimination chain (teleport pCell
|
||||
flip → owner cell; headless replay → flood admits it; partition routing →
|
||||
exactly one possible drop site) forced the answer. Two tools that would have
|
||||
shortened it to one iteration:
|
||||
- **The pick line**: left-click prints `[B.4b] pick guid=… name=…` +
|
||||
`[B.7] pick-info … setup=…` — names any clickable object in the log.
|
||||
- **The teleport/pCell flip**: walking onto/into a thing prints its cell.
|
||||
Both need zero new code. The register also already KNEW the answer (AP-33's
|
||||
"look-in DYNAMICS are not drawn — deferred") — scan-the-register-on-symptom
|
||||
applies to rows YOU wrote hours earlier.
|
||||
|
||||
## 3. NEXT (the queue to the M1.5 → M2 boundary)
|
||||
|
||||
1. **#108-residual — cellar-ascent grass window (NEXT, desk-first).**
|
||||
Climbing out of a cellar, grass covers the exit door until the eye pops
|
||||
above grade. Punch/seal exonerated; it is MEMBERSHIP/VIEWER-side (which
|
||||
cell the camera resolves while the eye is below grade). Apparatus
|
||||
designed: a VERTICAL exit-walk-harness variant (HouseExitWalkReplayTests
|
||||
machinery driving the camera up cellar stairs, watching viewer-cell
|
||||
resolution per step). Read the physics digest + ISSUES #108 before
|
||||
starting. User needed only for the final cellar gate.
|
||||
2. **#127 — distant-building admission churn** (flood size oscillates ±1–3
|
||||
cells at mm eye deltas; suspect list includes the PortalBounds frustum
|
||||
pre-gate — machinery #124 now reuses for interior roots).
|
||||
3. **#116 — slide-response family** (physics, oracle-first: one cdb session).
|
||||
4. **#125 sticky-drop debt** — failed texture uploads never retried
|
||||
(session-sticky invisible meshes); robustness, no visual gate.
|
||||
|
||||
## 4. Apparatus added this session (all env-gated, kept)
|
||||
|
||||
| Tool | How | For |
|
||||
|---|---|---|
|
||||
| `[outstage]`/`[outstage-pt]`/`[outstage-own]` | `ACDREAM_PROBE_OUTSTAGE=1` (+`ACDREAM_DUMP_ENTITY=<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.
|
||||
```
|
||||
205
docs/research/2026-06-13-dungeon-g3-handoff.md
Normal file
205
docs/research/2026-06-13-dungeon-g3-handoff.md
Normal 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.
|
||||
633
docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md
Normal file
633
docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md
Normal 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).
|
||||
455
docs/superpowers/specs/2026-06-13-dungeon-support-design.md
Normal file
455
docs/superpowers/specs/2026-06-13-dungeon-support-design.md
Normal 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 **3–5 rooms**; **walls block** movement.
|
||||
- **No ocean / no ACE `failed transition` spam.**
|
||||
- (Implicitly) the portal flood does **not** blow up (#95 check) and collision
|
||||
works in every room (hydration-coupling check).
|
||||
|
||||
`ACDREAM_PROBE_CELL=1` + `ACDREAM_PROBE_VIEWER=1` + `ACDREAM_WB_DIAG=1` + the
|
||||
always-on `[snap]` / `live: teleport` lines capture the chain (the
|
||||
`launch-dungeon-diag.log` protocol from this session).
|
||||
|
||||
### 6.3 Per-installment build/test gates
|
||||
Each installment: `dotnet build` green + `dotnet test` green
|
||||
(App / Core / UI / Net suites) before it's "done"; G.3a additionally requires the
|
||||
visual gate.
|
||||
|
||||
---
|
||||
|
||||
## 7. Retail divergence register impact
|
||||
|
||||
- **G.3a timeout force-snap** → NEW row (adaptation: async streaming hold has no
|
||||
synchronous-retail analog; retail loads the cell set synchronously before
|
||||
`SetPositionInternal`).
|
||||
- **Hydration decouple** → NO row (bug fix retiring an incidental render↔physics
|
||||
coupling; restores retail-correct independence).
|
||||
- **G.3c** → only a row if a faithful asset can't be reproduced (e.g. the tunnel
|
||||
viewport scene) and a documented courtesy substitute is shipped.
|
||||
- **#95 close-as-superseded** (if G.3b not triggered) → ISSUES.md note only.
|
||||
|
||||
---
|
||||
|
||||
## 8. Component boundaries (what each unit does / depends on)
|
||||
|
||||
| Unit | Location | Does | Depends on |
|
||||
|---|---|---|---|
|
||||
| `TeleportArrivalController` | `AcDream.App/World/` | Owns the `Idle/Holding/Placing` phase + `_pendingArrival`; decides hold-vs-place each frame | readiness predicate (injected), `Resolve` (injected), PortalSpace state |
|
||||
| readiness predicate | `PhysicsEngine` (reused #107 triplet) | `SampleTerrainZ(pos)` ∧ (outdoor ∨ `IsSpawnCellReady(cell)`); `IsSpawnClaimUnhydratable(cell)` | `DataCache`, dat `LandBlockInfo` |
|
||||
| hydration decouple | `GameWindow.BuildInteriorEntitiesForStreaming` | `BuildLoadedCell` + `CacheCellStruct` gated on cellStruct/BSP, not render mesh | `cellStruct`, `PhysicsBSP` |
|
||||
| `TeleportAnimState` FSM (G.3c) | `AcDream.App` UI/render | Portal-tunnel fade FSM; hold-exit gated on the readiness predicate | `m_pPortalSpace` viewport, the readiness predicate |
|
||||
| recall builders (G.3d) | `AcDream.Core/Network/Actions` | Zero-payload outbound game actions | command bus |
|
||||
|
||||
`AcDream.Core` gains no GL/window dependency. The controller + FSM live in
|
||||
`AcDream.App`; the readiness predicate's physics half lives in `AcDream.Core`
|
||||
(pure), its streaming half in `AcDream.App`.
|
||||
|
||||
---
|
||||
|
||||
## 9. References cited
|
||||
|
||||
- **Current code (verified this session):** `GameWindow.cs` 4877-4961 (arrival),
|
||||
~4971-4976 (`OnTeleportStarted`), 1010-1024 (#107 login gate), 11728-11748
|
||||
(`IsSpawnClaimUnhydratable`), 5564-5651 (EnvCell hydration guard), 5941-6150
|
||||
(`ApplyLoadedTerrainLocked`); `PhysicsEngine.cs` 468-472 (`IsSpawnCellReady`),
|
||||
626-646 (#111 validated claim), 383-406 (`WalkableFloorZNearest`), 536-570
|
||||
(Resolve safety net); `StreamingRegion.cs` 180-283 (`RecenterTo`);
|
||||
`StreamingController.cs` 120-149 (drain); `PortalVisibilityBuilder.cs` 131
|
||||
(lbMask), 165 (enqueue-once); `CellTransit.cs` 515-516 (null-skip);
|
||||
`PhysicsDataCache.cs` 172 (null-BSP early-return).
|
||||
- **Decomp (named-retail):** `BeginTeleportAnimation` `004d6300` (line 218888) +
|
||||
the `TeleportAnimState` FSM 219405-219774; `m_pPortalSpace` viewport
|
||||
218829/219363; `CEnvCell::grab_visible_cells` `:311878` (G.3b stab_list).
|
||||
- **holtburger:** `messages.rs:434` (client re-sends `LoginComplete` on teleport).
|
||||
- **ACE:** `Player_Location.Teleport:686` (send order); `Landblock.cs:575`
|
||||
(`IsDungeon`); `Player_Tick.cs:548-560` (single-landblock dungeons); recall
|
||||
handlers + `Portal.ActOnUse`/`AdjustDungeon`.
|
||||
- **r09 deepdive:** `docs/research/deepdives/r09-dungeon-portal-space.md` (EnvCell
|
||||
/ CellPortal wire layout, recall taxonomy, the retail contract).
|
||||
- **Issues:** [#133](../../ISSUES.md), [#95](../../ISSUES.md).
|
||||
- **Digests (DO-NOT-RETRY tables apply):** `project_render_pipeline_digest`,
|
||||
`project_physics_collision_digest`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Open questions (resolved here; revisit only if the gate disagrees)
|
||||
|
||||
1. **Loading visual now or later?** Faithful `TeleportAnimState` in G.3c (user
|
||||
decision). Unified with the G.3a hold (the tunnel IS the hold's visual).
|
||||
2. **Hold timeout/failure?** Reject impossible claims instantly
|
||||
(`IsSpawnClaimUnhydratable`); hold plausible-but-slow with a ~10 s ceiling;
|
||||
on timeout force-snap + loud log (fail visibly, never freeze).
|
||||
3. **Big-jump streaming?** Verified to work (Chebyshev recenter). Add only
|
||||
dest-coord validation; the readiness gate reuses `SampleTerrainZ` (no new
|
||||
streaming query).
|
||||
4. **EnvCell placement vs flat terrain?** The #111 `WalkableFloorZNearest` EnvCell
|
||||
path (identical to the cellar path that already works); the flat terrain
|
||||
renders below. The gate guarantees the cell is hydrated before Resolve runs.
|
||||
5. **(New, deferred to G.3b/implementation)** Does the dat carry a parsed
|
||||
`stab_list` for `grab_visible_cells` bounding? Only matters if the gate shows
|
||||
the #95 blowup.
|
||||
|
|
@ -1015,21 +1015,36 @@ public sealed class GameWindow : IDisposable
|
|||
// integrates gravity against an empty world and free-falls
|
||||
// the player into the void (retail loads cells synchronously;
|
||||
// this is the async-streaming equivalent of that invariant).
|
||||
isSpawnGroundReady: () => _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)
|
||||
&& _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null
|
||||
// #107 gate-2 extension (2026-06-10): an INDOOR spawn claim
|
||||
// additionally waits for the claimed cell's hydration so the
|
||||
// entry snap's AdjustPosition validation can act (retail loads
|
||||
// the cell synchronously before SetPosition; this is the
|
||||
// async-streaming equivalent). Claims that can never hydrate
|
||||
// (id outside the landblock's NumCells range per the dat)
|
||||
// don't hold the gate — the Resolve-head safety net demotes
|
||||
// them loudly.
|
||||
&& (!_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
|
||||
|| sp.Position is not { } spawnClaim
|
||||
|| spawnClaim.LandblockId == 0
|
||||
|| _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId)
|
||||
|| IsSpawnClaimUnhydratable(spawnClaim.LandblockId)),
|
||||
isSpawnGroundReady: () =>
|
||||
{
|
||||
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return false;
|
||||
|
||||
// #107 / #135: spawn-ground readiness is spawn-claim aware. For an
|
||||
// INDOOR claim (sealed dungeon / building interior) the ground the
|
||||
// player lands on is the EnvCell FLOOR (its BSP), so gate on the
|
||||
// cell's hydration (IsSpawnCellReady) — NOT the terrain heightmap.
|
||||
// A dungeon's cells sit in their landblock at an arbitrary (often
|
||||
// negative) offset, so the spawn's WORLD position can fall in a
|
||||
// NEIGHBOUR terrain landblock that the #135 dungeon collapse
|
||||
// deliberately does not load; requiring terrain there hangs login
|
||||
// forever (cellReady true, SampleTerrainZ null). Retail loads the
|
||||
// cell synchronously and places the player on the cell floor —
|
||||
// cellReady is the faithful indoor equivalent (#106/#107, AD-2).
|
||||
// (Before #135 this only passed by accident: the 25×25 window
|
||||
// happened to stream the neighbour terrain.)
|
||||
if (_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
|
||||
&& sp.Position is { } spawnClaim
|
||||
&& spawnClaim.LandblockId != 0
|
||||
&& (spawnClaim.LandblockId & 0xFFFFu) >= 0x0100u
|
||||
&& !IsSpawnClaimUnhydratable(spawnClaim.LandblockId))
|
||||
return _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId);
|
||||
|
||||
// Outdoor spawn, OR an unhydratable indoor claim that will demote to
|
||||
// an outdoor position: hold until the terrain under the spawn streams
|
||||
// (the original #106 gate — entering against an empty world free-falls
|
||||
// the player into the void).
|
||||
return _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null;
|
||||
},
|
||||
enterPlayerMode: EnterPlayerModeFromAutoEntry);
|
||||
}
|
||||
|
||||
|
|
@ -2086,6 +2101,7 @@ public sealed class GameWindow : IDisposable
|
|||
state: _worldState,
|
||||
nearRadius: _nearRadius,
|
||||
farRadius: _farRadius,
|
||||
clearPendingLoads: _streamer.ClearPendingLoads,
|
||||
removeTerrain: id =>
|
||||
{
|
||||
// Phase G.2: release any LightSources attached to entities
|
||||
|
|
@ -2608,6 +2624,57 @@ public sealed class GameWindow : IDisposable
|
|||
// landblock; each neighbor landblock is offset by 192 units per step.
|
||||
int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
|
||||
int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
|
||||
|
||||
// G.3 (#133): recenter streaming onto the player's spawn landblock at
|
||||
// login. The streaming center (_liveCenterX/_liveCenterY) is pinned to
|
||||
// the startup default (Holtburg, 0xA9B4) and is otherwise only moved by
|
||||
// the teleport-arrival path (OnLivePositionUpdated, ~line 4901). A
|
||||
// character saved INSIDE a far dungeon spawns with that dungeon's
|
||||
// landblock id, but the center never followed it, so the dungeon (tens
|
||||
// of km away in world space) never streamed and the #107 auto-entry
|
||||
// gate's SampleTerrainZ(pe.Position) waited forever — the player hung
|
||||
// frozen at login. Mirror the teleport-arrival recenter HERE, for the
|
||||
// PLAYER's spawn only, BEFORE the world-space translation below: when
|
||||
// the spawn landblock differs from the current center, move the center
|
||||
// onto it so the spawn maps to (PositionX, PositionY, PositionZ) in the
|
||||
// new center frame (identical to the teleport path's
|
||||
// `newWorldPos = new Vector3(p.PositionX, p.PositionY, p.PositionZ)`),
|
||||
// and the next StreamingController.Tick observes the new center and
|
||||
// streams the spawn landblock.
|
||||
//
|
||||
// No-op for a normal Holtburg login: the saved spawn landblock equals
|
||||
// the default center, so the guard is false and origin/worldPos are
|
||||
// byte-identical to the pre-fix path. Gated on the player guid so NPC /
|
||||
// object spawns never move the center. Idempotent + thrash-free: a
|
||||
// re-sent CreateObject for the same spawn landblock leaves the center
|
||||
// already-equal, so the guard is false on every repeat.
|
||||
if (spawn.Guid == _playerServerGuid
|
||||
&& (lbX != _liveCenterX || lbY != _liveCenterY))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: login spawn — recentering streaming from ({_liveCenterX},{_liveCenterY}) " +
|
||||
$"to ({lbX},{lbY}) for player spawn @0x{p.LandblockId:X8}");
|
||||
_liveCenterX = lbX;
|
||||
_liveCenterY = lbY;
|
||||
}
|
||||
|
||||
// #135: the instant we know the player spawned into a SEALED dungeon,
|
||||
// pre-collapse streaming to that single landblock — BEFORE the first
|
||||
// StreamingController.Tick bootstraps the 25×25 ocean-grid window. The
|
||||
// player isn't placed yet (physics CurrCell is null), so the per-frame
|
||||
// insideDungeon gate stays false for the entire hydration window and
|
||||
// NormalTick would otherwise load ~24 neighbor dungeons then unload them
|
||||
// (the login FPS ramp the user reported — 10 fps slowly climbing). Sealed-
|
||||
// dungeon only: a cottage/inn interior (SeenOutside) keeps its outdoor
|
||||
// surround. We hold _datLock here, and IsSealedDungeonCell re-takes it
|
||||
// (reentrant); the controller call is render-thread-safe (Channel writes).
|
||||
if (spawn.Guid == _playerServerGuid
|
||||
&& _streamingController is not null
|
||||
&& IsSealedDungeonCell(p.LandblockId))
|
||||
{
|
||||
_streamingController.PreCollapseToDungeon(lbX, lbY);
|
||||
}
|
||||
|
||||
var origin = new System.Numerics.Vector3(
|
||||
(lbX - _liveCenterX) * 192f,
|
||||
(lbY - _liveCenterY) * 192f,
|
||||
|
|
@ -4621,10 +4688,18 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
|
||||
{
|
||||
// Phase A.1: track the most recently updated entity's landblock so the
|
||||
// streaming controller can follow the player. TODO: filter by our own
|
||||
// character guid once we reliably know it from CharacterList.
|
||||
_lastLivePlayerLandblockId = update.Position.LandblockId;
|
||||
// Phase A.1 / #135: track the PLAYER's last server-known landblock so the
|
||||
// streaming controller can follow the player in the fly-camera / pre-player-mode
|
||||
// (login hold) views. Filtered to our OWN character guid — resolving the original
|
||||
// Phase A.1 TODO. An arbitrary NPC's UpdatePosition from a far outdoor landblock
|
||||
// must NOT move the streaming observer: during a dungeon-login hold (player not
|
||||
// yet placed, so _playerController is null and the PortalSpace observer branch
|
||||
// can't apply) that would drift the observer off the pre-collapsed dungeon
|
||||
// landblock and trip ExitDungeonExpand, re-streaming the 25×25 neighbor window
|
||||
// the pre-collapse just suppressed. _playerServerGuid is set from CharacterList
|
||||
// (~line 1984) before world entry, so it is valid by the time updates arrive.
|
||||
if (update.Guid == _playerServerGuid)
|
||||
_lastLivePlayerLandblockId = update.Position.LandblockId;
|
||||
|
||||
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
|
||||
|
||||
|
|
@ -5046,7 +5121,7 @@ public sealed class GameWindow : IDisposable
|
|||
entity.Rotation = rmState.Body.Orientation;
|
||||
}
|
||||
|
||||
// Phase B.3: portal-space arrival detection.
|
||||
// Phase B.3 / G.3a (#133): portal-space arrival detection.
|
||||
// Only runs for our own player character while in PortalSpace.
|
||||
if (_playerController is not null
|
||||
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
|
||||
|
|
@ -5060,79 +5135,127 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
bool differentLandblock = (lbX != oldLbX || lbY != oldLbY);
|
||||
|
||||
// #107 (2026-06-10): ANY player position update while in PortalSpace
|
||||
// IS the teleport arrival. Retail/holtburger exit portal space on the
|
||||
// next position event unconditionally (holtburger messages.rs
|
||||
// PlayerTeleport handler: log + LoginComplete; the destination applies
|
||||
// through the normal position flow — no distance test). The old
|
||||
// `differentLandblock || farAway(>100m)` arrival gate was an
|
||||
// invention: ACE's same-landblock short-hop position corrections
|
||||
// (e.g. right after an indoor login) matched neither condition, so
|
||||
// PortalSpace never exited and movement input stayed frozen for the
|
||||
// whole session (the #107 "input ignored" wedge shape —
|
||||
// flood-fix-gate2.log: `teleport started (seq=1)` with no arrival).
|
||||
Console.WriteLine(
|
||||
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
|
||||
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
|
||||
|
||||
System.Numerics.Vector3 newWorldPos;
|
||||
if (differentLandblock)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
|
||||
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
|
||||
// Recenter the streaming controller on the new landblock NOW (kick
|
||||
// off the dungeon load). After recentering, the destination is
|
||||
// (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin.
|
||||
_liveCenterX = lbX;
|
||||
_liveCenterY = lbY;
|
||||
newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
|
||||
|
||||
System.Numerics.Vector3 newWorldPos;
|
||||
if (differentLandblock)
|
||||
{
|
||||
// 1. Recenter the streaming controller on the new landblock.
|
||||
_liveCenterX = lbX;
|
||||
_liveCenterY = lbY;
|
||||
|
||||
// Recompute worldPos with new center (it becomes local-to-center).
|
||||
// After recentering, the new position is (p.PositionX, p.PositionY, p.PositionZ)
|
||||
// relative to the new origin — which maps to world-space (0,0,0) + local offset.
|
||||
// The streamingController.Tick will pick up _liveCenterX/_liveCenterY automatically.
|
||||
newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
|
||||
// (after recentering, origin is (0,0,0) since lb == center)
|
||||
}
|
||||
else
|
||||
{
|
||||
// Same landblock: worldPos is already in the current center frame.
|
||||
newWorldPos = worldPos;
|
||||
}
|
||||
|
||||
// 2. Resolve through physics for the correct ground Z.
|
||||
uint newCellId = p.LandblockId;
|
||||
var resolved = _physicsEngine.Resolve(
|
||||
newWorldPos, newCellId,
|
||||
System.Numerics.Vector3.Zero, _playerController.StepUpHeight);
|
||||
var snappedPos = new System.Numerics.Vector3(
|
||||
resolved.Position.X, resolved.Position.Y, resolved.Position.Z);
|
||||
|
||||
// 3. Snap player entity + controller.
|
||||
entity.SetPosition(snappedPos);
|
||||
entity.ParentCellId = resolved.CellId;
|
||||
entity.Rotation = rot;
|
||||
_playerController.SetPosition(snappedPos, resolved.CellId);
|
||||
|
||||
// 4. Recenter chase camera on the new position.
|
||||
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
|
||||
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
|
||||
playerVelocity: System.Numerics.Vector3.Zero,
|
||||
isOnGround: true,
|
||||
contactPlaneNormal: System.Numerics.Vector3.UnitZ,
|
||||
dt: 1f / 60f);
|
||||
|
||||
// 5. Return to InWorld.
|
||||
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
|
||||
Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
|
||||
|
||||
// 5. Send LoginComplete to tell the server the client finished loading.
|
||||
// Per holtburger's PlayerTeleport handler (client/messages.rs:434-440),
|
||||
// retail clients call send_login_complete() after each portal transition.
|
||||
// ResetLoginComplete() clears the latch so the 0xF746 PlayerCreate path
|
||||
// doesn't also send one. We send directly here instead.
|
||||
_liveSession?.SendGameAction(
|
||||
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
|
||||
// #135: pre-collapse on teleport into a sealed dungeon too — same
|
||||
// race as login. The destination isn't placed until it hydrates, so
|
||||
// without this NormalTick loads the full neighbor window during the
|
||||
// arrival hold. The PortalSpace observer branch (OnUpdate) keeps the
|
||||
// observer pinned to _liveCenterX/Y while held, so the stale frozen
|
||||
// player position can't drift the observer off the dungeon and re-expand.
|
||||
if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
|
||||
_streamingController.PreCollapseToDungeon(lbX, lbY);
|
||||
}
|
||||
else
|
||||
{
|
||||
newWorldPos = worldPos;
|
||||
}
|
||||
|
||||
// G.3a: do NOT snap here. The destination dungeon landblock has not
|
||||
// streamed in yet; an immediate Resolve falls back to the resident
|
||||
// (old) landblocks and lands the player in ocean (#133). HOLD the snap
|
||||
// in portal space — TeleportArrivalController.Tick (per frame) places
|
||||
// the player via PlaceTeleportArrival once the destination cell
|
||||
// hydrates (TeleportArrivalReadiness == Ready), or force-places on an
|
||||
// impossible claim / timeout. PortalSpace keeps input frozen meanwhile.
|
||||
EnsureTeleportArrivalController();
|
||||
_pendingTeleportRot = rot;
|
||||
_teleportArrival!.BeginArrival(newWorldPos, p.LandblockId);
|
||||
}
|
||||
}
|
||||
|
||||
// G.3a (#133): holds a teleport arrival in portal space until the destination
|
||||
// dungeon landblock/cell has hydrated, then places the player via the unchanged
|
||||
// validated-claim Resolve path. Lazily constructed on the first teleport (all
|
||||
// runtime deps are wired by then).
|
||||
private AcDream.App.World.TeleportArrivalController? _teleportArrival;
|
||||
private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity;
|
||||
|
||||
private void EnsureTeleportArrivalController()
|
||||
{
|
||||
if (_teleportArrival is not null) return;
|
||||
_teleportArrival = new AcDream.App.World.TeleportArrivalController(
|
||||
readiness: TeleportArrivalReadiness,
|
||||
place: PlaceTeleportArrival);
|
||||
}
|
||||
|
||||
// Reuses the #107 login readiness triplet (GameWindow.cs:1010-1024), evaluated
|
||||
// against the teleport's (destPos, destCell): an impossible indoor claim short-
|
||||
// circuits to immediate placement; otherwise hold until terrain is sampled and,
|
||||
// for an indoor cell, the cell struct has hydrated.
|
||||
private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness(
|
||||
System.Numerics.Vector3 destPos, uint destCell)
|
||||
{
|
||||
if (IsSpawnClaimUnhydratable(destCell))
|
||||
return AcDream.App.World.ArrivalReadiness.Impossible;
|
||||
|
||||
// #135: an INDOOR destination (sealed dungeon / building interior) gates on the
|
||||
// EnvCell FLOOR, not the terrain heightmap. A dungeon's negative-offset cells can
|
||||
// place destPos in a NEIGHBOUR terrain landblock the #135 collapse doesn't load,
|
||||
// so SampleTerrainZ would stay null forever (the cell IS ready). Retail places on
|
||||
// the cell floor. Outdoor: the terrain heightmap is the ground.
|
||||
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
||||
if (indoor)
|
||||
return _physicsEngine.IsSpawnCellReady(destCell)
|
||||
? AcDream.App.World.ArrivalReadiness.Ready
|
||||
: AcDream.App.World.ArrivalReadiness.NotReady;
|
||||
|
||||
if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null)
|
||||
return AcDream.App.World.ArrivalReadiness.NotReady;
|
||||
return AcDream.App.World.ArrivalReadiness.Ready;
|
||||
}
|
||||
|
||||
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
|
||||
// once the destination is ready (or force-run on impossible/timeout, logged loud).
|
||||
private void PlaceTeleportArrival(
|
||||
System.Numerics.Vector3 destPos, uint destCell, bool forced)
|
||||
{
|
||||
var resolved = _physicsEngine.Resolve(
|
||||
destPos, destCell, System.Numerics.Vector3.Zero, _playerController!.StepUpHeight);
|
||||
var snappedPos = new System.Numerics.Vector3(
|
||||
resolved.Position.X, resolved.Position.Y, resolved.Position.Z);
|
||||
|
||||
if (forced)
|
||||
Console.WriteLine(
|
||||
$"live: teleport HOLD gave up (impossible/timeout) — force-snapping " +
|
||||
$"cell=0x{destCell:X8} pos={destPos} -> 0x{resolved.CellId:X8} {snappedPos}");
|
||||
|
||||
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
|
||||
{
|
||||
pe.SetPosition(snappedPos);
|
||||
pe.ParentCellId = resolved.CellId;
|
||||
pe.Rotation = _pendingTeleportRot;
|
||||
}
|
||||
_playerController.SetPosition(snappedPos, resolved.CellId);
|
||||
|
||||
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
|
||||
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
|
||||
playerVelocity: System.Numerics.Vector3.Zero,
|
||||
isOnGround: true,
|
||||
contactPlaneNormal: System.Numerics.Vector3.UnitZ,
|
||||
dt: 1f / 60f);
|
||||
|
||||
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
|
||||
Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
|
||||
|
||||
// Tell the server the client finished loading the new landblock (holtburger
|
||||
// client/messages.rs:434 — re-send LoginComplete after each portal transition).
|
||||
_liveSession?.SendGameAction(
|
||||
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase B.3: fires when the server sends a PlayerTeleport (0xF751).
|
||||
/// Freeze movement input by setting the player controller to PortalSpace.
|
||||
|
|
@ -5144,6 +5267,7 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
if (_playerController is not null)
|
||||
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
|
||||
EnsureTeleportArrivalController();
|
||||
Console.WriteLine($"live: teleport started (seq={sequence})");
|
||||
}
|
||||
|
||||
|
|
@ -5266,6 +5390,11 @@ public sealed class GameWindow : IDisposable
|
|||
private static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity)
|
||||
=> entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id;
|
||||
|
||||
// #131 [outstage-pt] probe state (throwaway — strip when #131 closes).
|
||||
private string? _lastOutStagePtSig;
|
||||
private readonly HashSet<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,22 +5894,56 @@ public sealed class GameWindow : IDisposable
|
|||
// Static objects inside the cell continue to flow through the dispatcher
|
||||
// as WorldEntity records below — they have real GfxObj MeshRefs that work
|
||||
// fine; EnvCellRenderer.RegisterCell receives an empty staticObjects list.
|
||||
// Transforms — needed by the portal-visibility cell (unlifted) AND the
|
||||
// render/physics path. Computed for EVERY cell with a valid cellStruct,
|
||||
// not just drawable ones. Keep the small render lift out of physics; retail
|
||||
// BSP contact planes use the EnvCell origin verbatim. The lift constant is
|
||||
// shared with every draw-space consumer of portal polygons (OutsideView
|
||||
// gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130).
|
||||
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
|
||||
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
|
||||
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
|
||||
var cellTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
|
||||
var physicsCellTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
|
||||
|
||||
// PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless
|
||||
// of whether CellMesh.Build produced drawable sub-meshes. A portals-only
|
||||
// pass-through connector (a ramp / stair / cellar mouth) yields 0 render
|
||||
// sub-meshes but MUST be in the visibility graph so the flood can traverse it
|
||||
// to the cells beyond — otherwise the flood lookup-misses the unregistered
|
||||
// neighbour and the grey clear shows through the opening (#133: ramp
|
||||
// neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
|
||||
// at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace]
|
||||
// skip=lookup-miss). Retail keeps the whole landblock cell array resident
|
||||
// before the flood runs; BuildLoadedCell reads the cellStruct portals, NOT
|
||||
// the render sub-meshes. The +0.02 m render lift is a DRAW concern only and
|
||||
// is intentionally NOT fed into the visibility transform (#119-residual: the
|
||||
// lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells).
|
||||
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
|
||||
|
||||
// PHYSICS cell graph: cache EVERY cell with a valid cellStruct, regardless of
|
||||
// drawable sub-meshes. The camera-collision sweep (SmartBox::update_viewer →
|
||||
// sphere_path.curr_cell, pc:92870) and the player cell-transit must be able to
|
||||
// TRANSIT THROUGH a portals-only connector — otherwise the viewer/curr cell can
|
||||
// never reach it and lags one cell behind the eye (#133 residual: the camera sat
|
||||
// 1.32 m past the ramp portal's plane while the viewer cell stalled in
|
||||
// 0x00070103 — the sweep transited every cached neighbour but NEVER the
|
||||
// un-cached connector 0x014D — so the side test culled the on-screen connector
|
||||
// portal and the grey clear showed through). Retail keeps the whole landblock
|
||||
// cell array resident for the sweep; a portals-only connector has an empty
|
||||
// collision BSP but its portals drive the transit. CacheCellStruct reads the
|
||||
// cellStruct directly, not the render sub-meshes.
|
||||
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
|
||||
|
||||
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
|
||||
if (cellSubMeshes.Count > 0)
|
||||
{
|
||||
_pendingCellMeshes[envCellId] = cellSubMeshes;
|
||||
|
||||
// Keep the small render lift out of physics; retail BSP
|
||||
// contact planes use the EnvCell origin verbatim.
|
||||
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
|
||||
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(0f, 0f, 0.02f);
|
||||
var cellTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
|
||||
var physicsCellTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
|
||||
|
||||
// Phase A8: register the cell with EnvCellRenderer for rendering.
|
||||
// staticObjects is empty — cell stabs continue as separate WorldEntity
|
||||
// records via the dispatcher (see lines below for the unchanged stab path).
|
||||
|
|
@ -5793,25 +5956,6 @@ public sealed class GameWindow : IDisposable
|
|||
cellWorldPosition: cellOrigin,
|
||||
cellRotation: envCell.Position.Orientation,
|
||||
staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>());
|
||||
|
||||
// Step 4: build LoadedCell for portal visibility — with the
|
||||
// PHYSICS (unlifted) transform. The +0.02 m render lift above
|
||||
// is a DRAW concern (shell z-fighting vs terrain); feeding it
|
||||
// into the visibility graph shifted every HORIZONTAL portal
|
||||
// plane 2 cm up, putting an eye standing on a deck/landing
|
||||
// 10–20 mm BELOW the lifted plane — outside the side test's
|
||||
// ±10 mm in-plane window — so the cell behind the portal was
|
||||
// side-culled: the tower-top staircase vanish + roof flap
|
||||
// (#119-residual; captured live at eye z=126.803 vs the
|
||||
// 010A→0107 plane at 126.80, reproduced ONLY with the lift in
|
||||
// TowerAscentReplayTests.CapturedTopOfStairs_*). Vertical
|
||||
// doorways were immune (the lift slides their planes along
|
||||
// themselves), which is why this hit exactly stairs, decks,
|
||||
// and cellar mouths.
|
||||
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
|
||||
|
||||
// Cache CellStruct physics BSP for indoor collision (UNCHANGED).
|
||||
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5828,6 +5972,17 @@ public sealed class GameWindow : IDisposable
|
|||
.DumpEntitySourceIds.Contains(stab.Id);
|
||||
int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0;
|
||||
|
||||
// #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to
|
||||
// nothing (GfxObj id 0) at any runtime distance, so retail's distance-based
|
||||
// degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the
|
||||
// WorldBuilder editor shows it at the origin. acdream's render path came from
|
||||
// WB (no distance LOD), so without this skip it draws the marker forever (the
|
||||
// red/green dungeon "cone"). Bare-GfxObj stabs are checked here; Setup stabs
|
||||
// skip per-part below (a Setup that is ALL markers drops via meshRefs.Count==0).
|
||||
if ((stab.Id & 0xFF000000u) == 0x01000000u
|
||||
&& AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, stab.Id))
|
||||
continue;
|
||||
|
||||
var meshRefs = new List<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,12 +7142,28 @@ public sealed class GameWindow : IDisposable
|
|||
observerCy = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
|
||||
}
|
||||
else if (_liveSession is not null
|
||||
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld
|
||||
&& _lastLivePlayerLandblockId is { } lid)
|
||||
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld)
|
||||
{
|
||||
// Live mode (fly camera): follow the server's last-known player position.
|
||||
observerCx = (int)((lid >> 24) & 0xFFu);
|
||||
observerCy = (int)((lid >> 16) & 0xFFu);
|
||||
// Live, not yet in player mode: the login auto-entry hold, or a live
|
||||
// fly-camera spectator. Follow the PLAYER's server-known landblock; if it
|
||||
// hasn't arrived yet, KEEP the _liveCenterX/_liveCenterY default — which is
|
||||
// the spawn/teleport recenter (the dungeon landblock at a dungeon login).
|
||||
//
|
||||
// #135 regression fix (2026-06-14): this MUST NOT fall through to the
|
||||
// fly-camera projection below. During a dungeon-login hold the streaming is
|
||||
// pre-collapsed onto the spawn landblock; a camera-derived observer far from
|
||||
// it trips ExitDungeonExpand and unloads the dungeon before it can hydrate —
|
||||
// the player is never placed and login hangs with no dungeon. Previously
|
||||
// _lastLivePlayerLandblockId was set by ANY entity, so a dungeon-local NPC
|
||||
// kept this branch on the dungeon; once it was filtered to the player guid
|
||||
// (line ~4507), a not-yet-arrived player UP dropped to the camera branch.
|
||||
// The fly camera is the OFFLINE observer only.
|
||||
if (_lastLivePlayerLandblockId is { } lid)
|
||||
{
|
||||
observerCx = (int)((lid >> 24) & 0xFFu);
|
||||
observerCy = (int)((lid >> 16) & 0xFFu);
|
||||
}
|
||||
// else: keep the _liveCenterX/_liveCenterY default (the spawn recenter).
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -6980,7 +7177,37 @@ public sealed class GameWindow : IDisposable
|
|||
observerCy = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f);
|
||||
}
|
||||
|
||||
_streamingController.Tick(observerCx, observerCy);
|
||||
// Dungeon gate (#133 FPS): when the player stands in a SEALED EnvCell
|
||||
// (indoor cell that doesn't see outside — the same predicate that kills
|
||||
// the sun/sky, playerInsideCell below), collapse streaming to the single
|
||||
// dungeon landblock. AC dungeons have no adjacent landblocks; the 25×25
|
||||
// window otherwise pulls in ~129 unrelated ocean-grid dungeons. Building
|
||||
// interiors (cottage/inn) have SeenOutside cells, so they are NOT gated
|
||||
// and keep their surrounding terrain.
|
||||
// True only for a sealed indoor cell. Read the physics CurrCell's own
|
||||
// SeenOutside (ObjCell.SeenOutside, set from the EnvCell dat flags) rather
|
||||
// than the render registry: the registry lookup only succeeds AFTER the
|
||||
// landblock FINALIZES (~tens of seconds for a 205-cell dungeon), which
|
||||
// delayed the collapse and let the full 25×25 neighbor window churn in
|
||||
// first (the "~30s to stabilize" report). CurrCell.SeenOutside is set the
|
||||
// moment the player is placed, so the collapse now engages at the snap.
|
||||
bool insideDungeon = false;
|
||||
if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell pcEnv
|
||||
&& !pcEnv.SeenOutside)
|
||||
{
|
||||
insideDungeon = true;
|
||||
// Pin the collapse to the cell's OWN landblock (cell id high 16 bits),
|
||||
// NOT the position-derived observer landblock. A dungeon's EnvCells sit
|
||||
// at arbitrary world coords (the "ocean" placement) with negative local
|
||||
// offsets, so floor(pp.Y/192) lands one landblock off — which collapses
|
||||
// onto the WRONG landblock and unloads the real dungeon, nulling CurrCell
|
||||
// and breaking the render (the Bug-A coordinate class). The cell id is the
|
||||
// authoritative landblock.
|
||||
uint cellLb = pcEnv.Id >> 16;
|
||||
observerCx = (int)((cellLb >> 8) & 0xFFu);
|
||||
observerCy = (int)(cellLb & 0xFFu);
|
||||
}
|
||||
_streamingController.Tick(observerCx, observerCy, insideDungeon);
|
||||
|
||||
// Re-inject persistent entities rescued from unloaded landblocks
|
||||
// into the current center landblock (the one the observer is in).
|
||||
|
|
@ -7000,6 +7227,12 @@ public sealed class GameWindow : IDisposable
|
|||
// Step 2: routed through the controller; functionally identical.
|
||||
_liveSessionController?.Tick();
|
||||
|
||||
// G.3a (#133): advance any held teleport arrival. Runs AFTER streaming
|
||||
// (which applies the destination landblock) and the live-session drain
|
||||
// (which may have just called BeginArrival), so a destination that
|
||||
// hydrated this frame is placed the same frame.
|
||||
_teleportArrival?.Tick();
|
||||
|
||||
// Phase K.1a — tick the input dispatcher so Hold-type bindings
|
||||
// re-fire while their chord is held. K.1b adds the subscribers
|
||||
// that actually consume the events.
|
||||
|
|
@ -7138,10 +7371,24 @@ public sealed class GameWindow : IDisposable
|
|||
// so it doesn't get frustum-culled when the player walks away from
|
||||
// the spawn landblock. Without this, the entity stays in the spawn
|
||||
// landblock's entity list and disappears when that landblock is culled.
|
||||
var pp = _playerController.Position;
|
||||
int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
|
||||
int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
|
||||
uint currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
|
||||
uint currentLb;
|
||||
if (result.CellId != 0 && (result.CellId & 0xFFFFu) >= 0x0100u)
|
||||
{
|
||||
// Indoor cell (dungeon/building EnvCell): the entity's landblock is
|
||||
// the CELL's landblock. Dungeon EnvCells sit at arbitrary "ocean"
|
||||
// world coords with negative local-Y, so floor(pp.Y/192) lands one
|
||||
// landblock off (the Bug-A class) — relocating the player into the
|
||||
// landblock the dungeon collapse unloaded, making the avatar
|
||||
// invisible. The cell id is authoritative.
|
||||
currentLb = (result.CellId & 0xFFFF0000u) | 0xFFFFu;
|
||||
}
|
||||
else
|
||||
{
|
||||
var pp = _playerController.Position;
|
||||
int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
|
||||
int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
|
||||
currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
|
||||
}
|
||||
_worldState.RelocateEntity(pe, currentLb);
|
||||
}
|
||||
|
||||
|
|
@ -7717,6 +7964,25 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
_sceneLightingUbo?.Upload(ubo);
|
||||
|
||||
// #133 A7 (2026-06-13): objective dungeon-lighting probe. One
|
||||
// rate-limited [light] line — insideCell / ambient / sun /
|
||||
// registered-point-lights / active-slot-count / player cell — so
|
||||
// the dungeon-dim question is self-verifiable from launch.log
|
||||
// without a screenshot. RegisteredCount is point/spot lights only
|
||||
// (the sun lives in LightManager.Sun, never in the _all list);
|
||||
// ubo.CellAmbient.W is the shader active-slot count, which counts
|
||||
// the (zeroed) sun slot indoors. Inert unless ACDREAM_PROBE_LIGHT=1.
|
||||
AcDream.Core.Rendering.RenderingDiagnostics.EmitLight(
|
||||
insideCell: playerInsideCell,
|
||||
ambientR: Lighting.CurrentAmbient.AmbientColor.X,
|
||||
ambientG: Lighting.CurrentAmbient.AmbientColor.Y,
|
||||
ambientB: Lighting.CurrentAmbient.AmbientColor.Z,
|
||||
sunIntensity: Lighting.Sun?.Intensity ?? 0f,
|
||||
registeredLights: Lighting.RegisteredCount,
|
||||
activeLights: (int)ubo.CellAmbient.W,
|
||||
playerCellId: playerRoot?.CellId ?? 0u,
|
||||
lights: Lighting);
|
||||
|
||||
// Never cull the landblock the player is currently on.
|
||||
uint? playerLb = null;
|
||||
if (_playerMode && _playerController is not null)
|
||||
|
|
@ -7796,9 +8062,9 @@ public sealed class GameWindow : IDisposable
|
|||
// OutdoorCellNode.Build filters to exit portals internally. The clipRoot flip +
|
||||
// OutsideView terrain integration that consumes this is the next (cutover) step.
|
||||
_outdoorNode = null;
|
||||
if (viewerRoot is null && viewerCellId != 0u)
|
||||
_outdoorNodeBuildingCells.Clear();
|
||||
if (viewerRoot is not null || viewerCellId != 0u)
|
||||
{
|
||||
_outdoorNodeBuildingCells.Clear();
|
||||
// T2 (BR-4): draw-driven flood gating. Retail floods a building's
|
||||
// interior exactly when its shell DRAWS and an aperture survives
|
||||
// the view (DrawBuilding Ghidra 0x0059f2a0: per-view viewconeCheck
|
||||
|
|
@ -7813,6 +8079,12 @@ public sealed class GameWindow : IDisposable
|
|||
// Per-building iteration is also the FPS fix the 2026-06-07
|
||||
// Chebyshev hack approximated: dozens of AABB tests instead of an
|
||||
// O(all loaded cells) portal sweep.
|
||||
// #124: the gather now runs for INTERIOR roots too — retail's
|
||||
// look-in executes inside LScape::draw for ANY root with a
|
||||
// non-empty outside view (DrawCells pc:432719). The renderer
|
||||
// routes interior-root look-ins to its landscape-stage sub-pass
|
||||
// (DrawBuildingLookIns); the root's own building self-excludes
|
||||
// via the seed eye-side test.
|
||||
foreach (var registry in _buildingRegistries.Values)
|
||||
{
|
||||
foreach (var b in registry.All())
|
||||
|
|
@ -7827,10 +8099,11 @@ public sealed class GameWindow : IDisposable
|
|||
_outdoorNodeBuildingCells.Add(bc);
|
||||
}
|
||||
}
|
||||
_outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId);
|
||||
if (viewerRoot is null)
|
||||
_outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId);
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[outdoor-node] cell=0x{viewerCellId:X8} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)"));
|
||||
$"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)"));
|
||||
}
|
||||
|
||||
uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u;
|
||||
|
|
@ -7956,10 +8229,10 @@ public sealed class GameWindow : IDisposable
|
|||
var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewDrawContext
|
||||
{
|
||||
RootCell = clipRoot,
|
||||
// R-A2: outdoor root floods each nearby building per-building (not via the root). The
|
||||
// gather above populates _outdoorNodeBuildingCells only on outdoor-node frames, so it
|
||||
// is fresh here exactly when clipRoot.IsOutdoorNode; null for interior roots.
|
||||
NearbyBuildingCells = clipRoot.IsOutdoorNode ? _outdoorNodeBuildingCells : null,
|
||||
// R-A2: outdoor root floods each nearby building per-building (not via the root).
|
||||
// #124: interior roots get the gather too — the renderer routes them to the
|
||||
// landscape-stage look-in sub-pass instead of the merge.
|
||||
NearbyBuildingCells = _outdoorNodeBuildingCells,
|
||||
ViewerEyePos = viewerEyePos,
|
||||
ViewProjection = envCellViewProj,
|
||||
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
|
||||
|
|
@ -7985,6 +8258,22 @@ public sealed class GameWindow : IDisposable
|
|||
renderWeather: playerSeenOutside,
|
||||
kf,
|
||||
environOverrideActive),
|
||||
// #131/#132: the late phase — dynamics meshes + scene
|
||||
// particles + weather AFTER the look-ins (FlushAlphaList
|
||||
// deferral).
|
||||
DrawLandscapeSliceLate = lateCtx =>
|
||||
DrawRetailPViewLandscapeSliceLate(
|
||||
lateCtx,
|
||||
camera,
|
||||
frustum,
|
||||
camPos,
|
||||
playerLb,
|
||||
animatedIds,
|
||||
renderSky,
|
||||
renderWeather: playerSeenOutside,
|
||||
kf,
|
||||
environOverrideActive,
|
||||
isOutdoorRoot: clipRoot.IsOutdoorNode),
|
||||
// T1: retail's depth discipline (PView::DrawCells, Ghidra 0x005a4840).
|
||||
// INTERIOR roots: one FULL depth clear between the outside stage and
|
||||
// the interior stage, then SEALS re-stamp every outside-leading
|
||||
|
|
@ -8005,6 +8294,26 @@ public sealed class GameWindow : IDisposable
|
|||
DrawExitPortalMasks = sliceCtx =>
|
||||
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
|
||||
forceFarZ: clipRoot.IsOutdoorNode),
|
||||
// #124: look-in apertures are ALWAYS the punch (retail
|
||||
// maxZ1), independent of the root-keyed selector above.
|
||||
DrawLookInPortalPunch = sliceCtx =>
|
||||
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
|
||||
forceFarZ: true),
|
||||
// #131: unattached emitters under an interior root — the
|
||||
// landscape-stage pass (the outdoor T3 pass below is gated
|
||||
// IsOutdoorNode, so the two never both run).
|
||||
DrawUnattachedSceneParticles = () =>
|
||||
{
|
||||
if (_particleSystem is null || _particleRenderer is null)
|
||||
return;
|
||||
DisableClipDistances();
|
||||
_particleRenderer.Draw(
|
||||
_particleSystem,
|
||||
camera,
|
||||
camPos,
|
||||
AcDream.Core.Vfx.ParticleRenderPass.Scene,
|
||||
emitter => emitter.AttachedObjectId == 0);
|
||||
},
|
||||
DrawCellParticles = sliceCtx =>
|
||||
DrawRetailPViewCellParticles(sliceCtx, camera, camPos),
|
||||
DrawDynamicsParticles = survivors =>
|
||||
|
|
@ -8125,20 +8434,26 @@ public sealed class GameWindow : IDisposable
|
|||
&& _particleSystem is not null && _particleRenderer is not null)
|
||||
{
|
||||
// T3 (BR-5): unattached emitters (campfires, ground effects —
|
||||
// AttachedObjectId == 0) under the OUTDOOR root. The unified
|
||||
// path's attached emitters draw via the landscape slice + the
|
||||
// per-cell callbacks; unattached ones had NO pass on
|
||||
// outdoor-node frames (the unattached-particles-dropped-
|
||||
// outdoors divergence, adjusted-confirmed). The outdoor root's
|
||||
// outside view is full-screen (cone pass-all); depth test
|
||||
// composites them against the world.
|
||||
// AttachedObjectId == 0) under the OUTDOOR root. The outdoor
|
||||
// root's outside view is full-screen (cone pass-all); depth
|
||||
// test composites them against the world.
|
||||
// #132 outdoor sibling: ATTACHED outdoor-static scene emitters
|
||||
// (lantern/candle flames) moved here too — drawn in the
|
||||
// landscape slice they were overpainted by merged building
|
||||
// interiors (drawn later) whenever a punched aperture sat
|
||||
// behind them. Post-frame, depth is complete and the flames
|
||||
// composite correctly. The owner-id set is the late slice's
|
||||
// (full-screen cone outdoors). Cell-pass and dynamics-pass
|
||||
// emitters keep their own passes (no double-draw: their owners
|
||||
// are never in the outdoor-static id set).
|
||||
sigSceneParticles = sigSceneParticles == "none" ? "unattached" : sigSceneParticles + "+unattached";
|
||||
_particleRenderer.Draw(
|
||||
_particleSystem,
|
||||
camera,
|
||||
camPos,
|
||||
AcDream.Core.Vfx.ParticleRenderPass.Scene,
|
||||
emitter => emitter.AttachedObjectId == 0);
|
||||
emitter => emitter.AttachedObjectId == 0
|
||||
|| _outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId));
|
||||
}
|
||||
|
||||
// Bug A fix (post-#26 worktree, 2026-04-26): weather sky
|
||||
|
|
@ -9800,12 +10115,113 @@ public sealed class GameWindow : IDisposable
|
|||
animatedEntityIds: animatedIds);
|
||||
}
|
||||
|
||||
_outdoorSceneParticleEntityIds.Clear();
|
||||
foreach (var entity in sliceCtx.OutdoorEntities)
|
||||
_outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity));
|
||||
// #131/#132: scene particles + weather MOVED to the LATE phase
|
||||
// (DrawRetailPViewLandscapeSliceLate) — they must composite AFTER the
|
||||
// #124 look-ins (retail's FlushAlphaList deferral, DrawCells
|
||||
// pc:432722); drawn here they were overpainted by far-building
|
||||
// interiors wherever a look-in aperture sat behind them.
|
||||
|
||||
if (scissor)
|
||||
_gl!.Disable(EnableCap.ScissorTest);
|
||||
|
||||
DisableClipDistances();
|
||||
if (_outdoorSceneParticleEntityIds.Count > 0
|
||||
}
|
||||
|
||||
// #131/#132: the LATE landscape phase — per slice, invoked by the renderer
|
||||
// AFTER the #124 look-in sub-pass, still pre-clear. Outside-stage
|
||||
// dynamics' meshes (a translucent portal swirl blends over a far interior
|
||||
// instead of being overpainted by it — translucents write no depth to
|
||||
// protect themselves) + ALL attached scene particles (statics' flames
|
||||
// included — the #132 candle) + weather. Retail equivalent: alpha draws
|
||||
// collected during LScape::draw flush ONCE after it
|
||||
// (D3DPolyRender::FlushAlphaList, PView::DrawCells pc:432722).
|
||||
private void DrawRetailPViewLandscapeSliceLate(
|
||||
AcDream.App.Rendering.RetailPViewLandscapeLateSliceContext lateCtx,
|
||||
ICamera camera,
|
||||
FrustumPlanes? frustum,
|
||||
System.Numerics.Vector3 camPos,
|
||||
uint? playerLb,
|
||||
HashSet<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;
|
||||
|
|
|
|||
45
src/AcDream.App/Rendering/NdcScissorRect.cs
Normal file
45
src/AcDream.App/Rendering/NdcScissorRect.cs
Normal 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(max−min) lands up to one pixel SHORT of the true max edge
|
||||
// at unlucky fractional alignments, scissoring away the aperture's top/right
|
||||
// pixel row for the whole slice (sky, terrain, statics, weather) while the
|
||||
// seal still stamps it — a strip of clear color no later pass can fill.
|
||||
//
|
||||
// Correct outer bound: floor both mins, ceil both maxes, width = difference.
|
||||
// A fragment at pixel (i,j) rasterizes iff its CENTER (i+0.5, j+0.5) lies in
|
||||
// the region ⊆ the NDC box [X0,X1]×[Y0,Y1] (pixel units). Center-inside ⇒
|
||||
// i ≥ X0−0.5 ⇒ i ≥ floor(X0) and i ≤ X1−0.5 ⇒ i < ceil(X1). So
|
||||
// [floor(X0), ceil(X1)) admits every center-inside pixel, over-including by
|
||||
// at most one pixel per edge — safe per AD-17's doctrine (the wall shell /
|
||||
// plane clip repaints or kills the surplus).
|
||||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
public static class NdcScissorRect
|
||||
{
|
||||
/// <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));
|
||||
}
|
||||
}
|
||||
|
|
@ -52,7 +52,8 @@ uniform mat4 uViewProjection;
|
|||
uniform int uPlaneCount;
|
||||
uniform vec4 uPlanes[8];
|
||||
uniform int uForceFarZ;
|
||||
uniform float uDepthBias; // NDC bias toward the viewer (mark pass only)
|
||||
uniform float uDepthBias; // NDC bias toward the viewer (mark pass only)
|
||||
uniform float uDepthBiasEyeCapN; // eye-span cap x near plane (#129; see MarkBiasNdc)
|
||||
out float gl_ClipDistance[8];
|
||||
void main()
|
||||
{
|
||||
|
|
@ -62,7 +63,14 @@ void main()
|
|||
if (uForceFarZ == 1)
|
||||
clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail)
|
||||
else if (uDepthBias > 0.0)
|
||||
clipPos.z -= uDepthBias * clipPos.w; // #117 mark-pass bias (see DrawDepthFan)
|
||||
{
|
||||
// #117 mark-pass bias, #129 eye-space cap. clipPos.w = eye depth d;
|
||||
// an NDC bias b spans ~b*d*d/near meters of eye depth, so the
|
||||
// constant-NDC form alone reached METERS at distance (door-shaped
|
||||
// leaks through hills/houses). Keep in sync with MarkBiasNdc.
|
||||
float biasNdc = min(uDepthBias, uDepthBiasEyeCapN / max(clipPos.w * clipPos.w, 1e-6));
|
||||
clipPos.z -= biasNdc * clipPos.w;
|
||||
}
|
||||
gl_Position = clipPos;
|
||||
}";
|
||||
|
||||
|
|
@ -79,6 +87,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
|
|||
private readonly int _locPlanes;
|
||||
private readonly int _locForceFarZ;
|
||||
private readonly int _locDepthBias;
|
||||
private readonly int _locDepthBiasEyeCapN;
|
||||
|
||||
private const int MaxFanVerts = 32;
|
||||
private readonly float[] _scratch = new float[MaxFanVerts * 3];
|
||||
|
|
@ -104,6 +113,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
|
|||
_locPlanes = _gl.GetUniformLocation(_program, "uPlanes");
|
||||
_locForceFarZ = _gl.GetUniformLocation(_program, "uForceFarZ");
|
||||
_locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias");
|
||||
_locDepthBiasEyeCapN = _gl.GetUniformLocation(_program, "uDepthBiasEyeCapN");
|
||||
|
||||
_vao = _gl.GenVertexArray();
|
||||
_vbo = _gl.GenBuffer();
|
||||
|
|
@ -144,10 +154,37 @@ void main() { } // depth-only: color writes are masked off by the caller state
|
|||
/// stencil below). The bias keeps the #108 case covered — terrain
|
||||
/// hugging the door plane (centimeters in front of the aperture) must
|
||||
/// still be punched; a hill or another house meters nearer must not.
|
||||
/// 0.0005 NDC ≈ 6 cm at 5 m / ≈ 1 m at 20 m with znear=0.1.
|
||||
/// </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/(f−n) 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;
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
105
src/AcDream.App/World/TeleportArrivalController.cs
Normal file
105
src/AcDream.App/World/TeleportArrivalController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
101
src/AcDream.Core/Lighting/LightBake.cs
Normal file
101
src/AcDream.Core/Lighting/LightBake.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 < 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);
|
||||
foreach (var light in _all)
|
||||
{
|
||||
if (!light.IsLit) continue;
|
||||
if (light.Kind == LightKind.Directional)
|
||||
{
|
||||
// Directional lights don't participate in this ranking —
|
||||
// the sun is always slot 0.
|
||||
continue;
|
||||
}
|
||||
|
||||
Vector3 delta = light.WorldPosition - viewerWorldPos;
|
||||
light.DistSq = delta.LengthSquared();
|
||||
|
||||
float rangeSq = light.Range * light.Range * RangeSlack * RangeSlack;
|
||||
if (light.DistSq > rangeSq) continue;
|
||||
candidates.Add(light);
|
||||
}
|
||||
|
||||
// Pass 2: sort by DistSq ascending, take up to 7.
|
||||
candidates.Sort((a, b) => a.DistSq.CompareTo(b.DistSq));
|
||||
|
||||
// Retail D3D-style fixed-pipeline lighting takes the nearest (MaxActiveLights-1)
|
||||
// point lights (slot 0 is the sun) and applies each light's hard range cutoff
|
||||
// PER SURFACE in the shader (mesh_modern.frag: `if (d < range && range > 1e-3)`),
|
||||
// NOT a viewer-range candidacy filter — a torch the viewer stands outside the
|
||||
// range of must still light the wall it sits on.
|
||||
//
|
||||
// Allocation-free partial selection: the old path built `new List<>(N)` and
|
||||
// ran an O(N log N) Sort EVERY FRAME; in a dungeon N is thousands of torches,
|
||||
// so that allocated a large list per frame (GC pressure → FPS). Instead keep
|
||||
// the nearest maxPoint directly in the _active window, maintained sorted by
|
||||
// insertion. O(N · maxPoint), maxPoint ≤ 8, zero allocation.
|
||||
Array.Clear(_active);
|
||||
_activeCount = 0;
|
||||
|
||||
// Slot 0 = sun when present.
|
||||
// Slot 0 = sun when present (directional; never ranked by distance).
|
||||
int baseSlot = 0;
|
||||
if (Sun is not null)
|
||||
{
|
||||
_active[0] = Sun;
|
||||
_activeCount = 1;
|
||||
baseSlot = 1;
|
||||
}
|
||||
|
||||
int maxPoint = MaxActiveLights - _activeCount;
|
||||
int pointCount = Math.Min(maxPoint, candidates.Count);
|
||||
for (int i = 0; i < pointCount; i++)
|
||||
int maxPoint = MaxActiveLights - baseSlot;
|
||||
int filled = 0;
|
||||
if (maxPoint > 0)
|
||||
{
|
||||
_active[_activeCount + i] = candidates[i];
|
||||
foreach (var light in _all)
|
||||
{
|
||||
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
|
||||
|
||||
Vector3 delta = light.WorldPosition - viewerWorldPos;
|
||||
light.DistSq = delta.LengthSquared();
|
||||
|
||||
// Maintain _active[baseSlot .. baseSlot+filled) sorted ascending by
|
||||
// DistSq. Insert if there's room or this light is nearer than the
|
||||
// current farthest (then the farthest falls off the end).
|
||||
if (filled < maxPoint)
|
||||
{
|
||||
int j = baseSlot + filled;
|
||||
while (j > baseSlot && _active[j - 1]!.DistSq > light.DistSq)
|
||||
{
|
||||
_active[j] = _active[j - 1];
|
||||
j--;
|
||||
}
|
||||
_active[j] = light;
|
||||
filled++;
|
||||
}
|
||||
else if (light.DistSq < _active[baseSlot + maxPoint - 1]!.DistSq)
|
||||
{
|
||||
int j = baseSlot + maxPoint - 1;
|
||||
while (j > baseSlot && _active[j - 1]!.DistSq > light.DistSq)
|
||||
{
|
||||
_active[j] = _active[j - 1];
|
||||
j--;
|
||||
}
|
||||
_active[j] = light;
|
||||
}
|
||||
}
|
||||
}
|
||||
_activeCount += pointCount;
|
||||
|
||||
_activeCount = baseSlot + filled;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 > 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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->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->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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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=<bool> ambient=(r,g,b) sun=<intensity>
|
||||
/// registeredLights=<N> activeLights=<uCellAmbient.w> playerCell=0x<id>
|
||||
/// </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=<Falloff m> intensity=<I> cone=<rad> color=(r,g,b) distToViewer=<m>
|
||||
/// </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";
|
||||
|
||||
|
|
|
|||
|
|
@ -6,17 +6,26 @@ using AcDream.Core.Physics; // TerrainSurface
|
|||
namespace AcDream.Core.World.Cells;
|
||||
|
||||
/// <summary>
|
||||
/// The unified cell graph: the authoritative id->cell resolver and registry.
|
||||
/// Built alongside the legacy render/physics cell systems in Stage 1 and consumed
|
||||
/// by nobody (zero behavior change). Retail anchor: CObjCell::GetVisible (pseudo_c:308209).
|
||||
/// Worker-thread populated; reads are concurrency-safe.
|
||||
/// The unified cell graph: the active, authoritative id->cell resolver and registry.
|
||||
/// Populated unconditionally from
|
||||
/// <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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
68
tests/AcDream.App.Tests/Rendering/Issue129PunchBiasTests.cs
Normal file
68
tests/AcDream.App.Tests/Rendering/Issue129PunchBiasTests.cs
Normal 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²·(f−n)/(f·n) meters of eye depth at eye distance d
|
||||
/// — 0.125 m at 5 m but ~190 m at a landblock (znear 0.1), so distant
|
||||
/// occluders in front of an aperture passed the mark and were far-Z punched:
|
||||
/// the door-shaped leak. The fix caps the bias's eye-space span
|
||||
/// (PortalDepthMaskRenderer.MarkBiasNdc): identical to the validated constant
|
||||
/// below the ~10 m crossover, never more than the cap beyond it.
|
||||
/// </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(d−n)/((f−n)d) ⇒ d(ndc) inverse ⇒
|
||||
/// span = b·d²·(f−n)/(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"));
|
||||
}
|
||||
}
|
||||
435
tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs
Normal file
435
tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
112
tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs
Normal file
112
tests/AcDream.App.Tests/Rendering/Issue131SetupProbeTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
80
tests/AcDream.App.Tests/Rendering/NdcScissorRectTests.cs
Normal file
80
tests/AcDream.App.Tests/Rendering/NdcScissorRectTests.cs
Normal 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(max−min), 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
147
tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs
Normal file
147
tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
109
tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs
Normal file
109
tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs
Normal 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>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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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·(y−5.73)+90.25 (stairs) → lip 93.25→94 over
|
||||
/// y 9.3→10.4 → main floor 94.0. The boom (retail defaults: distance 2.61,
|
||||
/// pitch 0.291, pivot feet+1.5) trails SOUTH into the stairwell — mid-stairs
|
||||
/// the desired eye sits beyond the cellar's south wall (y≈4.87) and above its
|
||||
/// ceiling: in no-cell dirt below grade. Stub terrain (−1000) — the membership
|
||||
/// pick never reads terrain height (XY-column only), which is exactly the
|
||||
/// mechanism under test.
|
||||
///
|
||||
/// ── RESULT (2026-06-12): the MEMBERSHIP/VIEWER LAYER IS EXONERATED ──────
|
||||
/// 0 grass-window steps, 0 sweep failures, 0 fallback branches across boom
|
||||
/// distance {2.61, 5.0} × damping lag {0, 0.3 m}. The viewer resolves
|
||||
/// 0x0174 → 0x0175 (eye z 93.65, below grade) → 0x0171 at eye z 94.01 —
|
||||
/// the viewer enters the main-floor room EXACTLY as the head pops above
|
||||
/// grade (the stairwell portal sits at grade), matching the user's wording.
|
||||
/// The handoff's "it is MEMBERSHIP/VIEWER-side" diagnosis is therefore
|
||||
/// REFUTED for the current pipeline; #108-residual is RENDER-side: the
|
||||
/// landscape slice clips terrain by 2D NDC planes only ((nx,ny,0,dw) —
|
||||
/// ClipFrame.cs:178, terrain_modern.vert:173), so terrain BETWEEN the eye
|
||||
/// and the exit portal (the grade sheet at z≈94, which from a below-grade
|
||||
/// eye projects into the aperture band at y 9.8–17) paints the doorway.
|
||||
/// These tests stay as the characterization pin for the healthy layer.
|
||||
/// </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)");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 & 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 >= 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>(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
40
tools/verify_un2_fmul.py
Normal 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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue