fix(streaming): #145 — teleport re-use via server-authoritative placement
Portals only worked once per session: teleporting OUT of a dungeon
mis-rooted the player into the SOURCE dungeon's coordinate frame, so every
move was sent dungeon-framed and ACE rejected it ("failed transition") —
the player couldn't move, never reached a portal, and the world wouldn't
re-render (only skybox).
Root cause: acdream's streaming-relative frame recenters on teleport, but
resident physics landblocks keep their load-time world-offset. After
recentering onto the outdoor destination, the collapsed source dungeon
(offset 0,0 as the prior center) and the destination (offset 0,0 as the
new center) overlap, and the Z-agnostic outdoor cell-snap returns the
dungeon for both the arrival placement and every per-frame resolve.
Fix (server-authoritative teleport placement):
- Drop the stale source center landblock from physics at the teleport
recenter (GameWindow.OnLivePositionUpdated) so the resolve falls through
to the server position (Resolve NO-LANDBLOCK verbatim) until the
destination streams in.
- Place outdoor teleports immediately (TeleportArrivalRules) — holding is
futile because streaming does not progress during a PortalSpace hold.
- Clear a dangling CellGraph.CurrCell when its landblock is removed
(PhysicsEngine.RemoveLandblock) — otherwise the dungeon-streaming gate
keeps streaming collapsed onto the gone dungeon (only skybox renders).
Keeps DungeonStreamingGate (gate suppression during the hold). Indoor
(dungeon-entry) placement is unchanged (cell-keyed, IsSpawnCellReady).
User-verified: in->out->re-enter works repeatedly, no ACE errors, world
renders. Remaining facets (server objects + own avatar not rendering after
a teleport-out) are entity render/lifecycle — split to #138.
Registers AP-36 + AD-2 updated. New: DungeonStreamingGate (+4 tests),
TeleportArrivalRules (+4 tests). Build + 2727 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f6a576af8e
commit
a15bd3b56d
9 changed files with 290 additions and 32 deletions
|
|
@ -48,18 +48,25 @@ Copy this block when adding a new issue:
|
||||||
|
|
||||||
## #145 — Portals only work once per session (can't run in, run out, re-enter)
|
## #145 — Portals only work once per session (can't run in, run out, re-enter)
|
||||||
|
|
||||||
**Status:** OPEN
|
**Status:** CORE FIXED + user-verified 2026-06-20 (in→out→re-enter repeatedly works; no ACE errors; world renders). Remaining facets (server objects + own-avatar not rendering after a teleport-out) split to **#138** — they are entity render/lifecycle, a different subsystem from the cell-rooting bug this issue was.
|
||||||
**Severity:** HIGH (blocks repeated portal/dungeon travel — user, 2026-06-20)
|
**Severity:** HIGH (blocks repeated portal/dungeon travel — user, 2026-06-20)
|
||||||
**Filed:** 2026-06-20
|
**Filed:** 2026-06-20
|
||||||
**Component:** net/streaming — teleport (0xF751) + PortalSpace + arrival
|
**Component:** net/streaming — teleport (0xF751) + PortalSpace + arrival + cell rooting
|
||||||
|
|
||||||
**Description (user, 2026-06-20):** A portal can only be used ONCE per session. The first teleport works (run into a portal); a subsequent portal use (run out again, or re-enter) does not. Goal: run into a portal, run out again, repeatedly — portals usable any number of times in one session.
|
**Description (user, 2026-06-20):** A portal can only be used ONCE per session. The first teleport works; a subsequent portal use (run out, or re-enter) does not. Goal: run in, run out, repeatedly.
|
||||||
|
|
||||||
**Root cause / status (UNVERIFIED):** Likely a stuck teleport-state issue — after the first 0xF751 PlayerTeleport (PortalSpace hold → `TeleportArrivalController` places → back to InWorld), some state isn't reset for the second teleport: candidates are PortalSpace not cleared, the arrival `Phase` stuck at `Holding`, a cell-membership/streaming gap, or the dungeon collapse↔expand state (#133/#135 machinery). Likely overlaps **#138** (teleport OUT of a dungeon loads incompletely) — may be the same root or a sibling. **First step:** instrument a SECOND teleport and find WHERE it stalls (does the 0xF751 arrive? does PortalSpace toggle? does `TeleportArrivalController.Tick` place? does the streaming window re-expand?).
|
**Root cause (CONFIRMED — code trace + live cdb-style [phys-lb] probe):** the teleport OUT of a dungeon mis-rooted the player into the SOURCE dungeon's coordinate frame, desyncing all movement from ACE. acdream uses a streaming-RELATIVE coordinate frame (positions relative to `_liveCenterX/Y`) and recenters on teleport, but **resident physics landblocks keep their load-time world-offset**. After recentering onto the outdoor destination, the collapsed source dungeon (loaded at offset (0,0) when it was the center) and the destination (also the new center → offset (0,0)) **overlap**, and the Z-agnostic outdoor cell-snap (`AdjustPosition`, iterating `_physicsEngine._landblocks`) returns the dungeon — for the arrival placement AND every per-frame resolve — so the player was rooted at a dungeon cell (`0x00070019`) at Holtburg's position. acdream then sent dungeon-frame positions; ACE (which knows the player is at Holtburg) rejected every one (`WARN: failed transition … to 0x0007…`), so the player couldn't move, never reached a portal, and ACE never re-broadcast the Holtburg objects. Teleport IN works because its placement is cell-keyed (indoor `FindVisibleChildCell` validates the specific claimed cell) and it pre-collapses; the OUTDOOR resolve is the grid-snap, which the overlap fools.
|
||||||
|
|
||||||
**Files:** `WorldSession.cs:999` (0xF751 handler); `PlayerMovementController.cs:75/840` (`PlayerState.PortalSpace`); `TeleportArrivalController.cs` (`Phase` Idle/Holding, the placement flip); `GameWindow.cs:4903/5332/5362` (PortalSpace observer + arrival placement); streaming collapse↔expand (see #138).
|
**Fix (2026-06-20) — server-authoritative teleport placement (user-approved approach):**
|
||||||
|
1. **Drop the stale source center landblock from physics at the teleport recenter** (`GameWindow.OnLivePositionUpdated`, `differentLandblock` branch → `_physicsEngine.RemoveLandblock(EncodeLandblockId(oldCenter))`). Only the offset-(0,0) center collides with the destination-local position, so removing it alone clears the overlap; the arrival + per-frame resolve then fall through to the server position (`PhysicsEngine.Resolve` NO-LANDBLOCK verbatim, `:605`) until the destination streams in.
|
||||||
|
2. **Place outdoor teleports immediately** (`TeleportArrivalRules.Decide` — outdoor → Ready). Holding is futile: streaming does NOT progress while the player is held in PortalSpace (the destination only loads once placement flips to InWorld). Indoor unchanged (`IsSpawnCellReady` hold). Gate suppression during the hold kept (`DungeonStreamingGate`).
|
||||||
|
3. **Clear a dangling `CellGraph.CurrCell` when its landblock is removed** (`PhysicsEngine.RemoveLandblock`). Without this, dropping the dungeon left CurrCell pointing at the orphaned dungeon cell; the dungeon-streaming gate (keyed on CurrCell) kept streaming collapsed onto the gone landblock, so the destination never streamed → only skybox. Clearing it lets the gate read "not in a dungeon" → `ExitDungeonExpand` → destination streams in → CurrCell re-acquires.
|
||||||
|
|
||||||
**Acceptance:** run into a portal, run out, re-enter — repeatedly in one session, each time placing + streaming correctly.
|
Tests: `DungeonStreamingGateTests` (4), `TeleportArrivalRulesTests` (4). Registers AP-36 + AD-2. Build + 2727 tests green. Edge logs added to `EnterDungeonCollapse`/`ExitDungeonExpand`.
|
||||||
|
|
||||||
|
**Files:** `GameWindow.cs` `OnLivePositionUpdated` (drop stale center) + `TeleportArrivalReadiness`; `src/AcDream.App/World/TeleportArrivalController.cs` (`TeleportArrivalRules`); `src/AcDream.App/Streaming/DungeonStreamingGate.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`RemoveLandblock` CurrCell clear + NO-LANDBLOCK verbatim).
|
||||||
|
|
||||||
|
**Acceptance:** run into a portal, run out, re-enter — repeatedly in one session, each time placing + streaming correctly. Verify the dungeon→outdoor exit completes promptly (log `teleport complete`, not `teleport HOLD gave up … force-snapping`) with `streaming: dungeon EXIT-expand`, the outdoor world fully streamed, collision working. Also re-checks **#138**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -211,13 +218,15 @@ exit; (c) the position desync = the player controller / streaming observer disag
|
||||||
post-exit world position (the avatar moves in one frame, the streaming/camera in another).
|
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.
|
Pairs with #135 (`712f17f`/`2c92375`) — same collapse machinery; the EXIT path is the gap.
|
||||||
|
|
||||||
**Files:** `src/AcDream.App/Streaming/StreamingController.cs` (`ExitDungeonExpand`, the
|
**UPDATE 2026-06-20 — the position-desync HALF is FIXED via #145; the remaining half is narrowed + RE-SCOPED to entity render/lifecycle:** #145 fixed the cell-rooting (the player was rooted into the source dungeon's frame on teleport-out → ACE rejected all movement → "avatar moves but position doesn't track"). With that fixed (server-authoritative placement + drop-stale-center + CurrCell clear), the outdoor TERRAIN now streams + renders and movement is accepted. What REMAINS, observed in the #145 verification run:
|
||||||
collapse/expand hysteresis), `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated`
|
- **(A) Server-spawned objects don't render after teleport-back.** NOT a re-broadcast gap — the `ACDREAM_DUMP_LIVE_SPAWNS` trace shows ACE DOES re-send `CreateObject` for all Holtburg weenies on return (Doors, NPCs "Agent of the Arcanum"/"Wedding Planner", "Holtburg Meeting Hall Portal", chests, …) and acdream receives + processes them (`OnLiveEntitySpawnedLocked` → `AppendLiveEntity`). They just don't appear. So the bug is downstream: entity render/storage during the teleport streaming churn — candidates: the re-added live entities are dropped by a subsequent `GpuWorldState.AddLandblock` record-replace for the same landblock, OR the per-instance render data (`WbEntitySpawnAdapter.OnCreate`) isn't built/registered, OR a render-root/visibility gate while `CurrCell` re-acquires.
|
||||||
teleport recenter ~4912, the streaming Tick gate ~6890, the PortalSpace observer branch),
|
- **(B) Own avatar stops rendering after a couple of round-trips.** The player entity (persistent, rescued+re-injected via `GpuWorldState.DrainRescued`→`AppendLiveEntity` at `GameWindow` ~:7421) is lost/duplicated across repeated rescue/re-inject cycles. Cumulative (first trip OK, later trips vanish).
|
||||||
`TeleportArrivalController`. Cross-check the post-exit shadow-object/collision registration.
|
|
||||||
|
|
||||||
**Acceptance:** portal out of the 0x0007 dungeon → full outdoor world streams (trees/scenery
|
Both are the **entity-lifecycle/render path across a teleport**, NOT the streaming collapse/expand (which now works — terrain streams). Start here: instrument `GpuWorldState` (AppendLiveEntity / AddLandblock / RemoveLandblock / DrainRescued) to trace one guid (a Door + the player 0x5000000B) across an in→out cycle and find where it leaves the rendered set.
|
||||||
present), collision works, and the player position tracks correctly (no avatar-vs-camera desync).
|
|
||||||
|
**Files:** `src/AcDream.App/Streaming/GpuWorldState.cs` (`AddLandblock` pending-merge vs record-replace, `AppendLiveEntity`, `RemoveLandblock`/rescue, `DrainRescued`, `RelocateEntity`), `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawnedLocked` ~:2696, rescued re-inject ~:7421), the per-instance render adapter (`WbEntitySpawnAdapter`). The streaming collapse/expand (`StreamingController`) is no longer the suspect.
|
||||||
|
|
||||||
|
**Acceptance:** portal out of the 0x0007 dungeon → full outdoor world streams (trees/scenery present), **server objects (doors/NPCs/portals) render**, **own avatar renders across repeated round-trips**, collision works, position tracks (no avatar-vs-camera desync).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
| # | 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-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
|
||||||
| AD-2 | Async spawn gates replacing retail's synchronous cell load. **#135 refinement:** an INDOOR spawn/teleport (cell ≥ 0x0100, hydratable) gates ONLY on the EnvCell floor (`IsSpawnCellReady`), NOT the terrain heightmap; an OUTDOOR spawn (or an unhydratable indoor claim that demotes outdoor) gates on the terrain-ready hold (**#106**). A dungeon's negative-offset cells can place the spawn's WORLD position in a neighbour terrain landblock the #135 dungeon collapse doesn't load, so a terrain requirement would hang indoor login/teleport forever (cellReady true, terrain null) — the player lands on the cell floor, terrain is irrelevant indoors. Claims beyond NumCells skip the gate (demoted) | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportArrivalReadiness` ~5012) (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge. Indoor-on-cellReady is the faithful equivalent of retail's synchronous cell load + place-on-floor (terrain under a dungeon is meaningless; the pre-#135 terrain hold only passed because the 25×25 window streamed the neighbour terrain) | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode; an indoor spawn whose cell never hydrates now holds on cellReady alone (no terrain backstop) — but that path is exactly the #107 hold | retail synchronous cell load before SetPosition (no gate exists) |
|
| AD-2 | Async spawn gates replacing retail's synchronous cell load. **#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). **#145 refinement:** an OUTDOOR teleport additionally requires the destination's OWN landblock to be resident (`_worldState.IsLoaded(destLandblock)`) before placing — and `SampleTerrainZ` is short-circuited behind it. On a teleport OUT of a dungeon the collapsed SOURCE dungeon stays resident at the same streaming-local coords and answers `SampleTerrainZ` (terrain 0), so without the load check the resolve ran against the dungeon's cells and rooted the player at a dungeon-frame cell id (ACE then rejected all movement as "failed transition"). Decision factored into `TeleportArrivalRules.Decide` | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportArrivalReadiness` ~5405 → `src/AcDream.App/World/TeleportArrivalController.cs` `TeleportArrivalRules.Decide`) (+ `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-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-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) |
|
| 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) |
|
||||||
|
|
@ -135,7 +135,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| 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-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-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-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-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). **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression, called from `GameWindow.OnUpdate` ~:7401) + `GameWindow: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-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`, unchanged) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) |
|
| AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`, unchanged) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) |
|
||||||
| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 |
|
| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; 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`. Vitals number elements are meter children (not recursed); `VitalsController` attaches a centered `UiText` child for the cur/max number (Task 8 landed — retail `gmVitalsUI` uses `UIElement_Text`), so `UiMeter.Label` is no longer used for vitals (`UiText.Centered` reuses the meter's former centering formula → pixel-identical, user-confirmed). 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 is deferred to Plan 2. 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 renders an empty/wrong meter — no oracle diff until the Plan-2 port lands | `UIElement_Meter::DrawChildren` @0x46fbd0; `docs/research/2026-06-15-layoutdesc-format.md` |
|
| 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`. Vitals number elements are meter children (not recursed); `VitalsController` attaches a centered `UiText` child for the cur/max number (Task 8 landed — retail `gmVitalsUI` uses `UIElement_Text`), so `UiMeter.Label` is no longer used for vitals (`UiText.Centered` reuses the meter's former centering formula → pixel-identical, user-confirmed). 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 is deferred to Plan 2. 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 renders an empty/wrong meter — no oracle diff until the Plan-2 port lands | `UIElement_Meter::DrawChildren` @0x46fbd0; `docs/research/2026-06-15-layoutdesc-format.md` |
|
||||||
|
|
|
||||||
|
|
@ -5349,6 +5349,23 @@ public sealed class GameWindow : IDisposable
|
||||||
System.Numerics.Vector3 newWorldPos;
|
System.Numerics.Vector3 newWorldPos;
|
||||||
if (differentLandblock)
|
if (differentLandblock)
|
||||||
{
|
{
|
||||||
|
// #145: drop the stale SOURCE center landblock from the physics engine
|
||||||
|
// BEFORE recentering. The destination loads at world-offset (0,0) (the new
|
||||||
|
// center), but the prior center was ALSO loaded at offset (0,0) and its
|
||||||
|
// offset is never re-based — so the two overlap, and the Z-agnostic outdoor
|
||||||
|
// cell-snap (AdjustPosition / Resolve, iterating _landblocks) resolves the
|
||||||
|
// player into the STALE source landblock (a dungeon's frame). That happens
|
||||||
|
// for the arrival snap AND every per-frame resolve until the source finally
|
||||||
|
// unloads, so outbound movement is sent in the dungeon's frame and the server
|
||||||
|
// rejects every move as a failed transition (#145 / #138). Removing the stale
|
||||||
|
// center here makes both resolves fall through to the server-authoritative
|
||||||
|
// position (Resolve's NO-LANDBLOCK verbatim branch, :605) until the
|
||||||
|
// destination streams in. Only the offset-(0,0) center can collide with the
|
||||||
|
// destination-local position, so removing it alone is sufficient.
|
||||||
|
uint staleCenterId = AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(
|
||||||
|
_liveCenterX, _liveCenterY);
|
||||||
|
_physicsEngine.RemoveLandblock(staleCenterId);
|
||||||
|
|
||||||
// Recenter the streaming controller on the new landblock NOW (kick
|
// Recenter the streaming controller on the new landblock NOW (kick
|
||||||
// off the dungeon load). After recentering, the destination is
|
// off the dungeon load). After recentering, the destination is
|
||||||
// (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin.
|
// (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin.
|
||||||
|
|
@ -5405,23 +5422,20 @@ public sealed class GameWindow : IDisposable
|
||||||
private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness(
|
private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness(
|
||||||
System.Numerics.Vector3 destPos, uint destCell)
|
System.Numerics.Vector3 destPos, uint destCell)
|
||||||
{
|
{
|
||||||
if (IsSpawnClaimUnhydratable(destCell))
|
bool claimUnhydratable = IsSpawnClaimUnhydratable(destCell);
|
||||||
return AcDream.App.World.ArrivalReadiness.Impossible;
|
|
||||||
|
|
||||||
// #135: an INDOOR destination (sealed dungeon / building interior) gates on the
|
// #135: an INDOOR destination (sealed dungeon / building interior) gates on the
|
||||||
// EnvCell FLOOR, not the terrain heightmap. A dungeon's negative-offset cells can
|
// EnvCell FLOOR hydrating. Retail places on the cell floor. An OUTDOOR destination
|
||||||
// place destPos in a NEIGHBOUR terrain landblock the #135 collapse doesn't load,
|
// places immediately on the server-authoritative position — see TeleportArrivalRules
|
||||||
// so SampleTerrainZ would stay null forever (the cell IS ready). Retail places on
|
// (#145: holding is futile because streaming doesn't progress during a PortalSpace
|
||||||
// the cell floor. Outdoor: the terrain heightmap is the ground.
|
// hold; the stale source landblock is dropped at recenter so the resolve trusts the
|
||||||
|
// server cell until the destination streams in).
|
||||||
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
||||||
if (indoor)
|
bool indoorCellReady = indoor && !claimUnhydratable
|
||||||
return _physicsEngine.IsSpawnCellReady(destCell)
|
&& _physicsEngine.IsSpawnCellReady(destCell);
|
||||||
? AcDream.App.World.ArrivalReadiness.Ready
|
|
||||||
: AcDream.App.World.ArrivalReadiness.NotReady;
|
|
||||||
|
|
||||||
if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null)
|
return AcDream.App.World.TeleportArrivalRules.Decide(
|
||||||
return AcDream.App.World.ArrivalReadiness.NotReady;
|
claimUnhydratable, indoor, indoorCellReady);
|
||||||
return AcDream.App.World.ArrivalReadiness.Ready;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
|
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
|
||||||
|
|
@ -7398,11 +7412,20 @@ public sealed class GameWindow : IDisposable
|
||||||
// delayed the collapse and let the full 25×25 neighbor window churn in
|
// delayed the collapse and let the full 25×25 neighbor window churn in
|
||||||
// first (the "~30s to stabilize" report). CurrCell.SeenOutside is set the
|
// first (the "~30s to stabilize" report). CurrCell.SeenOutside is set the
|
||||||
// moment the player is placed, so the collapse now engages at the snap.
|
// moment the player is placed, so the collapse now engages at the snap.
|
||||||
bool insideDungeon = false;
|
// AP-36 / #145: during a teleport HOLD the player is unplaced and CurrCell is
|
||||||
if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell pcEnv
|
// the frozen SOURCE cell. Suppress the source-cell gate so a teleport OUT of a
|
||||||
&& !pcEnv.SeenOutside)
|
// dungeon follows the destination (the PortalSpace observer pin above) instead
|
||||||
|
// of staying pinned to the source dungeon — see DungeonStreamingGate.
|
||||||
|
bool isTeleportHold =
|
||||||
|
_playerController is { State: AcDream.App.Input.PlayerState.PortalSpace };
|
||||||
|
var currCell = _physicsEngine.DataCache?.CellGraph.CurrCell;
|
||||||
|
bool currSealedDungeon =
|
||||||
|
currCell is AcDream.Core.World.Cells.EnvCell pcEnv && !pcEnv.SeenOutside;
|
||||||
|
var gate = AcDream.App.Streaming.DungeonStreamingGate.Compute(
|
||||||
|
isTeleportHold, currSealedDungeon, currCell?.Id ?? 0u);
|
||||||
|
bool insideDungeon = gate.InsideDungeon;
|
||||||
|
if (gate.ObserverLandblockKey is { } cellLb)
|
||||||
{
|
{
|
||||||
insideDungeon = true;
|
|
||||||
// Pin the collapse to the cell's OWN landblock (cell id high 16 bits),
|
// Pin the collapse to the cell's OWN landblock (cell id high 16 bits),
|
||||||
// NOT the position-derived observer landblock. A dungeon's EnvCells sit
|
// NOT the position-derived observer landblock. A dungeon's EnvCells sit
|
||||||
// at arbitrary world coords (the "ocean" placement) with negative local
|
// at arbitrary world coords (the "ocean" placement) with negative local
|
||||||
|
|
@ -7410,7 +7433,6 @@ public sealed class GameWindow : IDisposable
|
||||||
// onto the WRONG landblock and unloads the real dungeon, nulling CurrCell
|
// onto the WRONG landblock and unloads the real dungeon, nulling CurrCell
|
||||||
// and breaking the render (the Bug-A coordinate class). The cell id is the
|
// and breaking the render (the Bug-A coordinate class). The cell id is the
|
||||||
// authoritative landblock.
|
// authoritative landblock.
|
||||||
uint cellLb = pcEnv.Id >> 16;
|
|
||||||
observerCx = (int)((cellLb >> 8) & 0xFFu);
|
observerCx = (int)((cellLb >> 8) & 0xFFu);
|
||||||
observerCy = (int)(cellLb & 0xFFu);
|
observerCy = (int)(cellLb & 0xFFu);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
51
src/AcDream.App/Streaming/DungeonStreamingGate.cs
Normal file
51
src/AcDream.App/Streaming/DungeonStreamingGate.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
namespace AcDream.App.Streaming;
|
||||||
|
|
||||||
|
/// <summary>Result of the per-frame dungeon streaming-gate decision.</summary>
|
||||||
|
/// <param name="InsideDungeon">Passed to <see cref="StreamingController.Tick"/> — collapse
|
||||||
|
/// streaming to the single dungeon landblock.</param>
|
||||||
|
/// <param name="ObserverLandblockKey">When non-null, override the streaming observer to this
|
||||||
|
/// landblock key (the cell id's high 16 bits, 0xXXYY). Null leaves the caller's observer as-is.</param>
|
||||||
|
public readonly record struct DungeonGateResult(bool InsideDungeon, uint? ObserverLandblockKey);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AP-36: the dungeon streaming gate (#133 FPS). When the player stands in a SEALED
|
||||||
|
/// EnvCell (an indoor cell that doesn't see outside), streaming collapses to the single
|
||||||
|
/// dungeon landblock — AC dungeons have no adjacent landblocks, so the normal 25×25
|
||||||
|
/// window would pull in ~129 unrelated ocean-grid dungeons. The trigger is the player's
|
||||||
|
/// CURRENT cell (<c>CellGraph.CurrCell</c>, set the moment the player is placed), and the
|
||||||
|
/// observer is pinned to that cell's OWN landblock (the cell id high 16 bits) because a
|
||||||
|
/// dungeon's EnvCells sit at arbitrary ocean-grid world coords with negative local offsets.
|
||||||
|
///
|
||||||
|
/// <para>Extracted from <c>GameWindow.OnUpdate</c> as a pure function so the
|
||||||
|
/// teleport-hold rule (below) is unit-testable without the GL/dat/network stack.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class DungeonStreamingGate
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Decide the streaming gate from the player's current cell.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="isTeleportHold">True while a teleport arrival is held (the controller is in
|
||||||
|
/// PortalSpace): the player is NOT yet placed, so <paramref name="currCellId"/> is the frozen
|
||||||
|
/// SOURCE cell, not where the player is going.</param>
|
||||||
|
/// <param name="currCellIsSealedDungeon"><c>CurrCell is EnvCell && !SeenOutside</c>.</param>
|
||||||
|
/// <param name="currCellId">The current cell id (0xXXYYNNNN).</param>
|
||||||
|
public static DungeonGateResult Compute(
|
||||||
|
bool isTeleportHold, bool currCellIsSealedDungeon, uint currCellId)
|
||||||
|
{
|
||||||
|
// #145/#138: during a teleport hold the player is NOT yet placed, so CurrCell is
|
||||||
|
// the frozen SOURCE cell — where the player IS, not where they're going. Streaming
|
||||||
|
// must follow the DESTINATION, which the PortalSpace observer pin already does, so
|
||||||
|
// the source-cell gate is suppressed. Otherwise a teleport OUT of a dungeon keeps
|
||||||
|
// streaming collapsed on the source dungeon (CurrCell still sealed) → the outdoor
|
||||||
|
// destination never hydrates → TeleportArrivalReadiness holds 600 frames → force-
|
||||||
|
// snap to ocean. A teleport INTO a dungeon is handled explicitly upstream by
|
||||||
|
// StreamingController.PreCollapseToDungeon (and the controller's _collapsed latch
|
||||||
|
// holds it through the hold), so suppressing the gate here doesn't regress it.
|
||||||
|
if (isTeleportHold)
|
||||||
|
return new DungeonGateResult(false, null);
|
||||||
|
|
||||||
|
if (currCellIsSealedDungeon)
|
||||||
|
return new DungeonGateResult(true, currCellId >> 16);
|
||||||
|
return new DungeonGateResult(false, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -215,6 +215,8 @@ public sealed class StreamingController
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void EnterDungeonCollapse(int cx, int cy, uint centerId)
|
private void EnterDungeonCollapse(int cx, int cy, uint centerId)
|
||||||
{
|
{
|
||||||
|
if (!_collapsed || _collapsedCenter != centerId)
|
||||||
|
Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}");
|
||||||
_collapsed = true;
|
_collapsed = true;
|
||||||
_collapsedCenter = centerId;
|
_collapsedCenter = centerId;
|
||||||
_clearPendingLoads?.Invoke();
|
_clearPendingLoads?.Invoke();
|
||||||
|
|
@ -263,6 +265,9 @@ public sealed class StreamingController
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void ExitDungeonExpand(int observerCx, int observerCy)
|
private void ExitDungeonExpand(int observerCx, int observerCy)
|
||||||
{
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " +
|
||||||
|
$"(was collapsed on 0x{_collapsedCenter:X8})");
|
||||||
_collapsed = false;
|
_collapsed = false;
|
||||||
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,3 +103,42 @@ public sealed class TeleportArrivalController
|
||||||
_place(_destPos, _destCell, forced);
|
_place(_destPos, _destCell, forced);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure readiness decision for a teleport arrival, factored out of
|
||||||
|
/// <c>GameWindow.TeleportArrivalReadiness</c> for testing.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeleportArrivalRules
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Decide whether a held teleport arrival can place now.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="claimUnhydratable">The destination cell can never hydrate.</param>
|
||||||
|
/// <param name="indoor">Destination is an indoor cell (cell index ≥ 0x0100).</param>
|
||||||
|
/// <param name="indoorCellReady">For an indoor destination, the EnvCell floor has hydrated.</param>
|
||||||
|
public static ArrivalReadiness Decide(
|
||||||
|
bool claimUnhydratable,
|
||||||
|
bool indoor,
|
||||||
|
bool indoorCellReady)
|
||||||
|
{
|
||||||
|
if (claimUnhydratable)
|
||||||
|
return ArrivalReadiness.Impossible;
|
||||||
|
|
||||||
|
// Indoor (sealed dungeon / building interior): gate on the EnvCell floor hydrating,
|
||||||
|
// exactly as #135. The dungeon-IN path pre-collapses + waits for the cell struct, and
|
||||||
|
// its placement validates the specific claimed cell (the indoor resolve is cell-keyed,
|
||||||
|
// not the grid-snap), so it is unaffected by the overlap that broke teleport-OUT.
|
||||||
|
if (indoor)
|
||||||
|
return indoorCellReady ? ArrivalReadiness.Ready : ArrivalReadiness.NotReady;
|
||||||
|
|
||||||
|
// Outdoor: place IMMEDIATELY on the server-authoritative position. #145/#138 — holding
|
||||||
|
// for the destination to stream in is futile: streaming does NOT progress while the
|
||||||
|
// player is held in PortalSpace (the destination only loads once placement flips the
|
||||||
|
// state to InWorld). The stale source center landblock is dropped at recenter (see
|
||||||
|
// GameWindow.OnLivePositionUpdated), so the arrival resolve falls through to the
|
||||||
|
// server-authoritative cell/position (Resolve NO-LANDBLOCK verbatim) and the player is
|
||||||
|
// placed grounded there; streaming then loads the destination and the per-frame resolve
|
||||||
|
// grounds onto it. This trusts the server's teleport position — retail-faithful.
|
||||||
|
return ArrivalReadiness.Ready;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,19 @@ public sealed class PhysicsEngine
|
||||||
_landblocks.Remove(landblockId);
|
_landblocks.Remove(landblockId);
|
||||||
ShadowObjects.RemoveLandblock(landblockId);
|
ShadowObjects.RemoveLandblock(landblockId);
|
||||||
|
|
||||||
|
// #145: if the player's current cell belonged to the landblock being removed (a teleport
|
||||||
|
// drops the stale source center via OnLivePositionUpdated), clear it. Otherwise CurrCell
|
||||||
|
// dangles on an orphaned cell and the dungeon-streaming gate — keyed on CurrCell — keeps
|
||||||
|
// streaming collapsed onto the gone landblock, so the destination never streams in and
|
||||||
|
// only the skybox renders. Clearing it lets the gate read "not in a dungeon" → the
|
||||||
|
// controller ExitDungeonExpands to the destination, and CurrCell re-acquires once the
|
||||||
|
// destination landblock hydrates and the per-frame resolve roots into it.
|
||||||
|
if (DataCache?.CellGraph is { } cg && cg.CurrCell is { } cur
|
||||||
|
&& (cur.Id & 0xFFFF0000u) == (landblockId & 0xFFFF0000u))
|
||||||
|
{
|
||||||
|
cg.CurrCell = null;
|
||||||
|
}
|
||||||
|
|
||||||
// UCG Stage 1: mirror removal into the unified graph (inert this stage).
|
// UCG Stage 1: mirror removal into the unified graph (inert this stage).
|
||||||
DataCache?.CellGraph.RemoveLandblock(landblockId);
|
DataCache?.CellGraph.RemoveLandblock(landblockId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs
Normal file
52
tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
using AcDream.App.World;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.World;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The teleport-arrival readiness decision (#145/#138).
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Unhydratable claim → place immediately via the caller's safety-net (Impossible).</item>
|
||||||
|
/// <item>Indoor (sealed dungeon / building interior) → hold until the EnvCell floor hydrates.</item>
|
||||||
|
/// <item>Outdoor → place IMMEDIATELY on the server-authoritative position. Holding is futile —
|
||||||
|
/// streaming does not progress while the player is held in PortalSpace, so the
|
||||||
|
/// destination can't stream in during a hold. The stale source landblock is dropped at
|
||||||
|
/// recenter (GameWindow.OnLivePositionUpdated), so the resolve falls through to the
|
||||||
|
/// server cell (Resolve NO-LANDBLOCK verbatim) until the destination streams in.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public class TeleportArrivalRulesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Unhydratable_IsImpossible()
|
||||||
|
{
|
||||||
|
Assert.Equal(ArrivalReadiness.Impossible,
|
||||||
|
TeleportArrivalRules.Decide(claimUnhydratable: true, indoor: false, indoorCellReady: false));
|
||||||
|
// …even for an indoor claim.
|
||||||
|
Assert.Equal(ArrivalReadiness.Impossible,
|
||||||
|
TeleportArrivalRules.Decide(claimUnhydratable: true, indoor: true, indoorCellReady: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Indoor_CellReady_IsReady()
|
||||||
|
{
|
||||||
|
Assert.Equal(ArrivalReadiness.Ready,
|
||||||
|
TeleportArrivalRules.Decide(claimUnhydratable: false, indoor: true, indoorCellReady: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Indoor_CellNotReady_HoldsNotReady()
|
||||||
|
{
|
||||||
|
Assert.Equal(ArrivalReadiness.NotReady,
|
||||||
|
TeleportArrivalRules.Decide(claimUnhydratable: false, indoor: true, indoorCellReady: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Outdoor_PlacesImmediately()
|
||||||
|
{
|
||||||
|
// #145: never hold an outdoor arrival — place at once on the server position. The
|
||||||
|
// indoorCellReady flag is irrelevant for an outdoor destination.
|
||||||
|
Assert.Equal(ArrivalReadiness.Ready,
|
||||||
|
TeleportArrivalRules.Decide(claimUnhydratable: false, indoor: false, indoorCellReady: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
using AcDream.App.Streaming;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Streaming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The GameWindow side of the dungeon streaming gate (AP-36): given the player's
|
||||||
|
/// CURRENT cell, decide whether to collapse streaming and where to pin the observer.
|
||||||
|
/// The StreamingController's reaction to these inputs is covered by
|
||||||
|
/// <see cref="StreamingControllerDungeonGateTests"/>; this fixture covers the
|
||||||
|
/// INPUT computation — in particular the teleport-hold rule (#145/#138).
|
||||||
|
/// </summary>
|
||||||
|
public class DungeonStreamingGateTests
|
||||||
|
{
|
||||||
|
// A 0x0007 Town Network dungeon cell (landblock 0x0007, cell 0x0145).
|
||||||
|
private const uint DungeonCell = 0x00070145u;
|
||||||
|
// An outdoor terrain cell near Holtburg (landblock 0xAB34, structure cell 0x0001).
|
||||||
|
private const uint OutdoorCell = 0xAB340001u;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SealedDungeonCell_NotHold_CollapsesAndPinsObserverToCellLandblock()
|
||||||
|
{
|
||||||
|
var r = DungeonStreamingGate.Compute(
|
||||||
|
isTeleportHold: false, currCellIsSealedDungeon: true, currCellId: DungeonCell);
|
||||||
|
|
||||||
|
Assert.True(r.InsideDungeon);
|
||||||
|
Assert.Equal(0x0007u, r.ObserverLandblockKey); // 0x00070145 >> 16
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OutdoorCell_NotHold_NoCollapse_NoObserverOverride()
|
||||||
|
{
|
||||||
|
var r = DungeonStreamingGate.Compute(
|
||||||
|
isTeleportHold: false, currCellIsSealedDungeon: false, currCellId: OutdoorCell);
|
||||||
|
|
||||||
|
Assert.False(r.InsideDungeon);
|
||||||
|
Assert.Null(r.ObserverLandblockKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TeleportHold_StaleSealedDungeonCurrCell_SuppressesGate()
|
||||||
|
{
|
||||||
|
// #145/#138 — teleport OUT of a dungeon. During the arrival hold the player is
|
||||||
|
// unplaced, so CurrCell is still the frozen SOURCE dungeon cell. The gate MUST be
|
||||||
|
// suppressed: streaming has to follow the DESTINATION (the PortalSpace observer
|
||||||
|
// pin), not re-pin onto the source dungeon. The pre-fix logic returned
|
||||||
|
// (true, 0x0007) here, which kept streaming collapsed on the source dungeon → the
|
||||||
|
// outdoor destination never hydrated → 600-frame timeout → force-snap to ocean.
|
||||||
|
var r = DungeonStreamingGate.Compute(
|
||||||
|
isTeleportHold: true, currCellIsSealedDungeon: true, currCellId: DungeonCell);
|
||||||
|
|
||||||
|
Assert.False(r.InsideDungeon);
|
||||||
|
Assert.Null(r.ObserverLandblockKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TeleportHold_OutdoorCurrCell_AlsoNoCollapse()
|
||||||
|
{
|
||||||
|
// Sanity: an outdoor→outdoor or building→outdoor hold is already correct, but the
|
||||||
|
// suppression rule must not change that.
|
||||||
|
var r = DungeonStreamingGate.Compute(
|
||||||
|
isTeleportHold: true, currCellIsSealedDungeon: false, currCellId: OutdoorCell);
|
||||||
|
|
||||||
|
Assert.False(r.InsideDungeon);
|
||||||
|
Assert.Null(r.ObserverLandblockKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue