From 0ad6700a07618c8fdf39ccd1e395ecccf93297f4 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 Jul 2026 20:08:21 +0200 Subject: [PATCH] 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 --- docs/ISSUES.md | 40 ++++++ docs/architecture/acdream-architecture.md | 18 +++ .../retail-divergence-register.md | 2 +- ...n-respawn-landblock-identity-pseudocode.md | 115 ++++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 25 ++-- .../Streaming/TeleportLandblockTransition.cs | 42 +++++++ .../TeleportLandblockTransitionTests.cs | 63 ++++++++++ 7 files changed, 296 insertions(+), 9 deletions(-) create mode 100644 docs/research/2026-07-13-same-dungeon-respawn-landblock-identity-pseudocode.md create mode 100644 src/AcDream.App/Streaming/TeleportLandblockTransition.cs create mode 100644 tests/AcDream.App.Tests/Streaming/TeleportLandblockTransitionTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index a3d1df73..6b9f9e17 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 **Status:** IN-PROGRESS — architectural fix complete 2026-07-13, pending user visual gate diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 7b633f0b..c1bdc49e 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -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 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 The old R1-R8 architecture sequence was a useful early refactor sketch, but it diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 2ef898d4..c058cb24 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | | AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | -| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#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-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 | diff --git a/docs/research/2026-07-13-same-dungeon-respawn-landblock-identity-pseudocode.md b/docs/research/2026-07-13-same-dungeon-respawn-landblock-identity-pseudocode.md new file mode 100644 index 00000000..3b07a2f2 --- /dev/null +++ b/docs/research/2026-07-13-same-dungeon-respawn-landblock-identity-pseudocode.md @@ -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. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 53824db3..fea729d0 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5985,13 +5985,24 @@ public sealed class GameWindow : IDisposable && _playerController.State == AcDream.App.Input.PlayerState.PortalSpace && update.Guid == _playerServerGuid) { - // Compute old landblock coords from controller position (using the - // current streaming origin as the reference center). var oldPos = _playerController.Position; - int oldLbX = _liveCenterX + (int)System.Math.Floor(oldPos.X / 192f); - int oldLbY = _liveCenterY + (int)System.Math.Floor(oldPos.Y / 192f); + uint streamingOriginLandblockId = + 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( $"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 // 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); + _physicsEngine.RemoveLandblock(streamingOriginLandblockId); // Recenter the streaming controller on the new landblock NOW (kick // off the dungeon load). After recentering, the destination is diff --git a/src/AcDream.App/Streaming/TeleportLandblockTransition.cs b/src/AcDream.App/Streaming/TeleportLandblockTransition.cs new file mode 100644 index 00000000..c982d24f --- /dev/null +++ b/src/AcDream.App/Streaming/TeleportLandblockTransition.cs @@ -0,0 +1,42 @@ +namespace AcDream.App.Streaming; + +/// +/// Classifies whether a local-player teleport crosses an AC landblock boundary. +/// Cell identity comes from the complete retail Position, never from its +/// frame coordinates: dungeon EnvCells may have valid negative local origins. +/// +/// +/// Retail carries Position::objcell_id through +/// SmartBox::TeleportPlayer (0x00453910) into +/// CPhysicsObj::SetPositionSimple (0x005162B0). acdream's asynchronous +/// streaming layer needs this extra comparison, but preserves that identity rule. +/// See docs/research/2026-07-13-same-dungeon-respawn-landblock-identity-pseudocode.md. +/// +internal readonly record struct TeleportLandblockTransition( + uint SourceLandblockId, + uint DestinationLandblockId) +{ + public bool CrossesLandblock => SourceLandblockId != DestinationLandblockId; + + /// + /// 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. + /// + 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; +} diff --git a/tests/AcDream.App.Tests/Streaming/TeleportLandblockTransitionTests.cs b/tests/AcDream.App.Tests/Streaming/TeleportLandblockTransitionTests.cs new file mode 100644 index 00000000..027d4665 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/TeleportLandblockTransitionTests.cs @@ -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); + } +}