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
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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue