fix(teleport): preserve dungeon cell identity on respawn (#215)
Classify teleport landblock transitions from the source and destination Position cell IDs instead of render-space coordinates. This keeps same-dungeon death respawns on their resident floor physics while preserving the existing true cross-landblock streaming path. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
b0c175afc0
commit
0ad6700a07
7 changed files with 296 additions and 9 deletions
|
|
@ -46,6 +46,46 @@ Copy this block when adding a new issue:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## #215 — Same-dungeon death respawn removed the dungeon floor physics
|
||||||
|
|
||||||
|
**Status:** IN-PROGRESS — root fix implemented; Release build + 5,054 tests green, pending user gate
|
||||||
|
**Severity:** HIGH
|
||||||
|
**Component:** teleport / streaming / physics cell identity
|
||||||
|
|
||||||
|
**Description:** After `/die` in the starter dungeon, the character respawned
|
||||||
|
at the dungeon start but fell through the world as soon as movement began.
|
||||||
|
|
||||||
|
**Root cause:** `OnLivePositionUpdated` decided whether a teleport crossed a
|
||||||
|
landblock by flooring the player's render-space XYZ against the current
|
||||||
|
streaming center. Dungeon EnvCells can have valid negative local frame origins;
|
||||||
|
at the captured `0x8C0401AD` position, Y `-30.4 m` falsely reconstructed source
|
||||||
|
landblock Y `3` instead of authoritative Y `4`. The same-cell respawn was
|
||||||
|
therefore treated as cross-landblock travel, and the `0x8C04FFFF` floor physics
|
||||||
|
was removed. Streaming still considered that collapsed landblock resident, so
|
||||||
|
the idempotent dungeon gate correctly did not enqueue a duplicate load.
|
||||||
|
|
||||||
|
**Resolution:** A dedicated `TeleportLandblockTransition` now compares the
|
||||||
|
player controller's current cell ID with the received destination cell ID,
|
||||||
|
matching retail's complete `Position::objcell_id + Frame` flow. XYZ is not used
|
||||||
|
for teleport identity. A zero pre-placement source falls back to the current
|
||||||
|
streaming-center ID, never a coordinate inference. Same-landblock respawns keep
|
||||||
|
the resident physics; true cross-landblock teleports retain the existing
|
||||||
|
recenter and hydration path.
|
||||||
|
|
||||||
|
**Files:** `src/AcDream.App/Streaming/TeleportLandblockTransition.cs`;
|
||||||
|
`src/AcDream.App/Rendering/GameWindow.cs`;
|
||||||
|
`tests/AcDream.App.Tests/Streaming/TeleportLandblockTransitionTests.cs`.
|
||||||
|
|
||||||
|
**Research:**
|
||||||
|
`docs/research/2026-07-13-same-dungeon-respawn-landblock-identity-pseudocode.md`.
|
||||||
|
|
||||||
|
**Acceptance:** In the starter dungeon, use `/die`, confirm the death dialog,
|
||||||
|
respawn in `0x8C0401AD`, then move in several directions. The character remains
|
||||||
|
on the dungeon floor and the teleport log reports old/new landblock `(140,4)`
|
||||||
|
without a physics removal or ten-second readiness timeout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## #214 — Dungeon login could replace a complete EnvCell render with a partial second load
|
## #214 — Dungeon login could replace a complete EnvCell render with a partial second load
|
||||||
|
|
||||||
**Status:** IN-PROGRESS — architectural fix complete 2026-07-13, pending user visual gate
|
**Status:** IN-PROGRESS — architectural fix complete 2026-07-13, pending user visual gate
|
||||||
|
|
|
||||||
|
|
@ -412,6 +412,24 @@ replace state produced by another job. CPU mesh extraction may still be schedule
|
||||||
onto `ObjectMeshManager`'s thread-safe work queue, but the worker never publishes
|
onto `ObjectMeshManager`'s thread-safe work queue, but the worker never publishes
|
||||||
partially hydrated cell membership or shell placement to the renderer.
|
partially hydrated cell membership or shell placement to the renderer.
|
||||||
|
|
||||||
|
### Teleport cell identity at the streaming boundary
|
||||||
|
|
||||||
|
The network and physics layers carry retail's complete `Position`: an
|
||||||
|
authoritative `objcell_id` plus a frame. Any streaming decision that asks
|
||||||
|
whether a teleport crossed a landblock compares the source and destination cell
|
||||||
|
IDs. It must never reconstruct either landblock by flooring render/world XYZ.
|
||||||
|
Dungeon EnvCells have arbitrary frame origins, including valid negative local
|
||||||
|
coordinates, so XYZ-to-grid inference can evict the landblock the player is
|
||||||
|
still standing in.
|
||||||
|
|
||||||
|
`TeleportLandblockTransition` owns this App-layer classification. When the
|
||||||
|
source cell is unavailable before first placement, it uses the typed current
|
||||||
|
streaming-center landblock as the fallback identity. The existing async
|
||||||
|
recenter/hydration path runs only for a real cell-ID landblock change; a
|
||||||
|
same-landblock teleport keeps the already-published render and physics unit.
|
||||||
|
This is the asynchronous adapter for retail's direct full-`Position` placement
|
||||||
|
(`SmartBox::TeleportPlayer @ 0x00453910`).
|
||||||
|
|
||||||
## Roadmap Model
|
## Roadmap Model
|
||||||
|
|
||||||
The old R1-R8 architecture sequence was a useful early refactor sketch, but it
|
The old R1-R8 architecture sequence was a useful early refactor sketch, but it
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
||||||
| 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). **#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-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). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#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/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `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. The cell-ID comparison matches retail's complete `Position` flow. | 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; retail `SmartBox::TeleportPlayer` 0x00453910 |
|
||||||
| 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 meters collapse Type-3 slice descendants into `UiMeter.BackLeft..FrontRight` and reuse `UiMeter.DrawHBar` rather than building those media descendants and dispatching retail `UIElement_Meter::DrawChildren`. Non-Type-3 meter children are imported normally. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`); `LayoutImporter.cs` meter child predicate | The current vitals/character meter shapes are visually accepted and fixture-pinned; this is a representation adaptation, not a controller overlay. | A meter with a different descendant/media structure can render empty or with incorrect clipping/direction | `UIElement_Meter::DrawChildren @ 0x0046FBD0`; production meter LayoutDesc fixtures |
|
| AP-37 | LayoutDesc meters collapse Type-3 slice descendants into `UiMeter.BackLeft..FrontRight` and reuse `UiMeter.DrawHBar` rather than building those media descendants and dispatching retail `UIElement_Meter::DrawChildren`. Non-Type-3 meter children are imported normally. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`); `LayoutImporter.cs` meter child predicate | The current vitals/character meter shapes are visually accepted and fixture-pinned; this is a representation adaptation, not a controller overlay. | A meter with a different descendant/media structure can render empty or with incorrect clipping/direction | `UIElement_Meter::DrawChildren @ 0x0046FBD0`; production meter LayoutDesc fixtures |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
# Same-dungeon respawn landblock identity — retail pseudocode
|
||||||
|
|
||||||
|
## Symptom and captured cause
|
||||||
|
|
||||||
|
After `/die` in the starter dungeon, ACE respawned the player in the same cell,
|
||||||
|
`0x8C0401AD`. The player stood correctly until the first movement input, then
|
||||||
|
lost floor contact and fell through the world.
|
||||||
|
|
||||||
|
The destination update and the player controller both named cell
|
||||||
|
`0x8C0401AD`, but acdream reconstructed the source landblock from render-space
|
||||||
|
XYZ. The dungeon cell's valid local Y was about `-30.4 m`, so this expression:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sourceLbY = streamingCenterY + floor(renderY / 192 m)
|
||||||
|
= 4 + floor(-30.4 / 192)
|
||||||
|
= 3
|
||||||
|
```
|
||||||
|
|
||||||
|
misidentified the source as landblock `(140,3)`. The destination was `(140,4)`,
|
||||||
|
so the same-cell respawn entered the cross-landblock branch, removed
|
||||||
|
`0x8C04FFFF` from the physics engine, and left the player with no hydrated floor.
|
||||||
|
|
||||||
|
Dungeon EnvCells may have arbitrary negative frame origins. Their XYZ values
|
||||||
|
therefore cannot identify an AC landblock.
|
||||||
|
|
||||||
|
## Named retail oracle
|
||||||
|
|
||||||
|
Retail stores cell identity inside every position:
|
||||||
|
|
||||||
|
```c
|
||||||
|
// acclient.h:30658-30662
|
||||||
|
struct Position : PackObj
|
||||||
|
{
|
||||||
|
unsigned int objcell_id;
|
||||||
|
Frame frame;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The local-player teleport path preserves that complete value end to end:
|
||||||
|
|
||||||
|
- `SmartBox::TeleportPlayer @ 0x00453910` passes its `Position const*` directly
|
||||||
|
to `CPhysicsObj::SetPositionSimple`.
|
||||||
|
- `CPhysicsObj::SetPositionSimple @ 0x005162B0` copies that complete `Position`
|
||||||
|
into a `SetPositionStruct`, then calls `CPhysicsObj::SetPosition`.
|
||||||
|
- `SmartBox::HandleReceivedPosition`'s local-player teleport branch
|
||||||
|
(`0x0045415F`–`0x00454168`) passes the received `Position` to
|
||||||
|
`SmartBox::TeleportPlayer`; it does not derive a cell from XYZ.
|
||||||
|
|
||||||
|
References:
|
||||||
|
|
||||||
|
- `docs/research/named-retail/acclient.h:30658`
|
||||||
|
- `docs/research/named-retail/acclient_2013_pseudo_c.txt:92514`
|
||||||
|
- `docs/research/named-retail/acclient_2013_pseudo_c.txt:93013`
|
||||||
|
- `docs/research/named-retail/acclient_2013_pseudo_c.txt:284276`
|
||||||
|
|
||||||
|
## Reference-client/server cross-check
|
||||||
|
|
||||||
|
The existing L.3 research traces the same contract through two independent
|
||||||
|
implementations:
|
||||||
|
|
||||||
|
- ACE sends death respawn as `GameMessagePlayerTeleport`, immediately followed
|
||||||
|
by an `UpdatePosition` whose `PositionPack` contains the destination cell and
|
||||||
|
frame (`docs/research/2026-05-04-l3-port/12-hard-teleport-branch.md`,
|
||||||
|
sections 7 and 9).
|
||||||
|
- The retail packet path unpacks `PositionPack.position` as
|
||||||
|
`objcell_id + Frame` and routes that complete value into the hard-teleport
|
||||||
|
branch (`docs/research/2026-05-04-l3-port/03-up-routing.md`, sections 0 and 3).
|
||||||
|
|
||||||
|
The repository's old external ACE checkout is no longer present after the
|
||||||
|
WorldBuilder extraction, so the checked-in cross-reference research is the
|
||||||
|
stable ACE source citation for this change.
|
||||||
|
|
||||||
|
## Faithful pseudocode
|
||||||
|
|
||||||
|
Retail has synchronous cell access and can install the received `Position`
|
||||||
|
directly. acdream keeps its asynchronous loading cover, but the identity
|
||||||
|
decision must use the same `Position.objcell_id` values:
|
||||||
|
|
||||||
|
```text
|
||||||
|
on local player teleport destination(receivedPosition):
|
||||||
|
sourceCell = player.currentPosition.objcell_id
|
||||||
|
destinationCell = receivedPosition.objcell_id
|
||||||
|
|
||||||
|
if sourceCell is zero:
|
||||||
|
# acdream-only pre-placement fallback; no XYZ inference
|
||||||
|
sourceLandblock = currentStreamingCenterLandblock
|
||||||
|
else:
|
||||||
|
sourceLandblock = landblockOf(sourceCell)
|
||||||
|
|
||||||
|
destinationLandblock = landblockOf(destinationCell)
|
||||||
|
crossesLandblock = sourceLandblock != destinationLandblock
|
||||||
|
|
||||||
|
if crossesLandblock:
|
||||||
|
evict the old streaming-origin physics landblock
|
||||||
|
recenter streaming on destinationLandblock
|
||||||
|
start the existing destination hydration path
|
||||||
|
|
||||||
|
retain receivedPosition.objcell_id as the pending destination cell
|
||||||
|
when the existing readiness gate opens:
|
||||||
|
place the player with the complete destination cell + frame
|
||||||
|
```
|
||||||
|
|
||||||
|
`landblockOf(cell)` means `(cell & 0xFFFF0000) | 0xFFFF`. At no point does
|
||||||
|
the decision divide or floor position coordinates.
|
||||||
|
|
||||||
|
## Conformance cases
|
||||||
|
|
||||||
|
1. Source and destination are both `0x8C0401AD`, while render Y is negative:
|
||||||
|
same landblock; do not evict or recenter.
|
||||||
|
2. Source and destination are different EnvCells in `0x8C04`:
|
||||||
|
same landblock; do not evict or recenter.
|
||||||
|
3. Source `0x8C0401AD`, destination `0xA9B40022`:
|
||||||
|
cross-landblock; retain the existing recenter/hydration behavior.
|
||||||
|
4. Source cell is zero before first placement:
|
||||||
|
compare against the current streaming-center ID; never infer from XYZ.
|
||||||
|
|
@ -5985,13 +5985,24 @@ public sealed class GameWindow : IDisposable
|
||||||
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
|
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
|
||||||
&& update.Guid == _playerServerGuid)
|
&& update.Guid == _playerServerGuid)
|
||||||
{
|
{
|
||||||
// Compute old landblock coords from controller position (using the
|
|
||||||
// current streaming origin as the reference center).
|
|
||||||
var oldPos = _playerController.Position;
|
var oldPos = _playerController.Position;
|
||||||
int oldLbX = _liveCenterX + (int)System.Math.Floor(oldPos.X / 192f);
|
uint streamingOriginLandblockId =
|
||||||
int oldLbY = _liveCenterY + (int)System.Math.Floor(oldPos.Y / 192f);
|
AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(
|
||||||
|
_liveCenterX, _liveCenterY);
|
||||||
|
|
||||||
bool differentLandblock = (lbX != oldLbX || lbY != oldLbY);
|
// Retail preserves the full Position (objcell_id + Frame) through
|
||||||
|
// SmartBox::TeleportPlayer @ 0x00453910. Dungeon EnvCells can have
|
||||||
|
// negative local frame origins, so XYZ cannot identify the source
|
||||||
|
// landblock (#215). Compare the controller's authoritative current
|
||||||
|
// cell with the received destination cell instead.
|
||||||
|
var landblockTransition =
|
||||||
|
AcDream.App.Streaming.TeleportLandblockTransition.Classify(
|
||||||
|
_playerController.CellId,
|
||||||
|
p.LandblockId,
|
||||||
|
streamingOriginLandblockId);
|
||||||
|
int oldLbX = (int)((landblockTransition.SourceLandblockId >> 24) & 0xFFu);
|
||||||
|
int oldLbY = (int)((landblockTransition.SourceLandblockId >> 16) & 0xFFu);
|
||||||
|
bool differentLandblock = landblockTransition.CrossesLandblock;
|
||||||
|
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
|
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
|
||||||
|
|
@ -6013,9 +6024,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// position (Resolve's NO-LANDBLOCK verbatim branch, :605) until the
|
// position (Resolve's NO-LANDBLOCK verbatim branch, :605) until the
|
||||||
// destination streams in. Only the offset-(0,0) center can collide with the
|
// destination streams in. Only the offset-(0,0) center can collide with the
|
||||||
// destination-local position, so removing it alone is sufficient.
|
// destination-local position, so removing it alone is sufficient.
|
||||||
uint staleCenterId = AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(
|
_physicsEngine.RemoveLandblock(streamingOriginLandblockId);
|
||||||
_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
|
||||||
|
|
|
||||||
42
src/AcDream.App/Streaming/TeleportLandblockTransition.cs
Normal file
42
src/AcDream.App/Streaming/TeleportLandblockTransition.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
namespace AcDream.App.Streaming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classifies whether a local-player teleport crosses an AC landblock boundary.
|
||||||
|
/// Cell identity comes from the complete retail <c>Position</c>, never from its
|
||||||
|
/// frame coordinates: dungeon EnvCells may have valid negative local origins.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Retail carries <c>Position::objcell_id</c> through
|
||||||
|
/// <c>SmartBox::TeleportPlayer</c> (0x00453910) into
|
||||||
|
/// <c>CPhysicsObj::SetPositionSimple</c> (0x005162B0). acdream's asynchronous
|
||||||
|
/// streaming layer needs this extra comparison, but preserves that identity rule.
|
||||||
|
/// See <c>docs/research/2026-07-13-same-dungeon-respawn-landblock-identity-pseudocode.md</c>.
|
||||||
|
/// </remarks>
|
||||||
|
internal readonly record struct TeleportLandblockTransition(
|
||||||
|
uint SourceLandblockId,
|
||||||
|
uint DestinationLandblockId)
|
||||||
|
{
|
||||||
|
public bool CrossesLandblock => SourceLandblockId != DestinationLandblockId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the transition from the player's current cell and the received
|
||||||
|
/// destination cell. A zero source can occur before initial placement; in
|
||||||
|
/// that case the current streaming center is the only valid identity fallback.
|
||||||
|
/// </summary>
|
||||||
|
public static TeleportLandblockTransition Classify(
|
||||||
|
uint sourceCellId,
|
||||||
|
uint destinationCellId,
|
||||||
|
uint currentStreamingCenterLandblockId)
|
||||||
|
{
|
||||||
|
uint sourceLandblockId = sourceCellId != 0
|
||||||
|
? NormalizeLandblockId(sourceCellId)
|
||||||
|
: NormalizeLandblockId(currentStreamingCenterLandblockId);
|
||||||
|
|
||||||
|
return new TeleportLandblockTransition(
|
||||||
|
sourceLandblockId,
|
||||||
|
NormalizeLandblockId(destinationCellId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint NormalizeLandblockId(uint cellOrLandblockId)
|
||||||
|
=> (cellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
using AcDream.App.Streaming;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Streaming;
|
||||||
|
|
||||||
|
public sealed class TeleportLandblockTransitionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SameDungeonRespawn_WithNegativeLocalCoordinate_DoesNotCrossLandblock()
|
||||||
|
{
|
||||||
|
const uint cell = 0x8C0401ADu;
|
||||||
|
|
||||||
|
// The removed implementation inferred the source from render-space XYZ.
|
||||||
|
// A valid dungeon Y of -30.412 m floors into the previous grid row even
|
||||||
|
// though the authoritative source cell still belongs to 0x8C04.
|
||||||
|
int legacyInferredLandblockY = 4 + (int)Math.Floor(-30.412f / 192f);
|
||||||
|
Assert.Equal(3, legacyInferredLandblockY);
|
||||||
|
|
||||||
|
var transition = TeleportLandblockTransition.Classify(
|
||||||
|
sourceCellId: cell,
|
||||||
|
destinationCellId: cell,
|
||||||
|
currentStreamingCenterLandblockId: 0x8C04FFFFu);
|
||||||
|
|
||||||
|
Assert.False(transition.CrossesLandblock);
|
||||||
|
Assert.Equal(0x8C04FFFFu, transition.SourceLandblockId);
|
||||||
|
Assert.Equal(0x8C04FFFFu, transition.DestinationLandblockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentEnvCells_InSameLandblock_DoNotCrossLandblock()
|
||||||
|
{
|
||||||
|
var transition = TeleportLandblockTransition.Classify(
|
||||||
|
sourceCellId: 0x8C0401ADu,
|
||||||
|
destinationCellId: 0x8C040145u,
|
||||||
|
currentStreamingCenterLandblockId: 0x8C04FFFFu);
|
||||||
|
|
||||||
|
Assert.False(transition.CrossesLandblock);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentPositionCellLandblocks_CrossLandblock()
|
||||||
|
{
|
||||||
|
var transition = TeleportLandblockTransition.Classify(
|
||||||
|
sourceCellId: 0x8C0401ADu,
|
||||||
|
destinationCellId: 0xA9B40022u,
|
||||||
|
currentStreamingCenterLandblockId: 0x8C04FFFFu);
|
||||||
|
|
||||||
|
Assert.True(transition.CrossesLandblock);
|
||||||
|
Assert.Equal(0x8C04FFFFu, transition.SourceLandblockId);
|
||||||
|
Assert.Equal(0xA9B4FFFFu, transition.DestinationLandblockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplacedSource_UsesStreamingCenterWithoutCoordinateInference()
|
||||||
|
{
|
||||||
|
var transition = TeleportLandblockTransition.Classify(
|
||||||
|
sourceCellId: 0,
|
||||||
|
destinationCellId: 0x8C0401ADu,
|
||||||
|
currentStreamingCenterLandblockId: 0x8C04FFFFu);
|
||||||
|
|
||||||
|
Assert.False(transition.CrossesLandblock);
|
||||||
|
Assert.Equal(0x8C04FFFFu, transition.SourceLandblockId);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue