refactor(world): extract live entity network updates
Move Position, Vector, State, Movement, and equal-generation CreateObject routing out of GameWindow while preserving per-channel authority, ForcePosition acknowledgement, and motion-runtime ownership. Add adversarial authority and exact-wire coverage so reentrant updates and GUID reuse cannot publish stale state.
This commit is contained in:
parent
c203e4799a
commit
aa90c64666
19 changed files with 3701 additions and 2241 deletions
|
|
@ -195,6 +195,9 @@ src/AcDream.App/
|
||||||
│ ├── RemotePhysicsUpdater.cs # ordinary/Hidden remote narrow-tick integration
|
│ ├── RemotePhysicsUpdater.cs # ordinary/Hidden remote narrow-tick integration
|
||||||
│ ├── LiveEntityOrdinaryPhysicsUpdater.cs # manager-less canonical body Transition path
|
│ ├── LiveEntityOrdinaryPhysicsUpdater.cs # manager-less canonical body Transition path
|
||||||
│ ├── LiveEntityNetworkUpdateController.cs # Position/Vector/State/Motion App integration
|
│ ├── LiveEntityNetworkUpdateController.cs # Position/Vector/State/Motion App integration
|
||||||
|
│ ├── LiveEntityInboundAuthorityGate.cs # exact per-channel timestamp/version admission
|
||||||
|
│ ├── LiveEntityMotionRuntimeController.cs # shared host/setup/MoveTo/Sticky runtime policy
|
||||||
|
│ ├── DeferredLiveEntityMotionRuntimeBindings.cs # fail-fast construction-order bridge
|
||||||
│ ├── LiveEntityShadowPublisher.cs # authoritative exact-owner/residency collision gate
|
│ ├── LiveEntityShadowPublisher.cs # authoritative exact-owner/residency collision gate
|
||||||
│ ├── RemoteInboundMotionDispatcher.cs # shared animated/headless UpdateMotion funnel
|
│ ├── RemoteInboundMotionDispatcher.cs # shared animated/headless UpdateMotion funnel
|
||||||
│ ├── RemoteTeleportController.cs # loaded/pending teleport placement ownership
|
│ ├── RemoteTeleportController.cs # loaded/pending teleport placement ownership
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| AD-22 | Async streamed mesh loading with bounded CPU replay residency, per-frame upload budgets, and point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's mesh-missing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs` | Immutable preparation descriptors and the bounded CPU cache can re-stage an evicted mesh; dispatcher self-heal makes absence transient while upload budgets prevent a portal arrival from monopolizing a frame | A future consumer that neither retains an owner nor reaches the self-heal/replay path can remain invisible; under heavy admission pressure a valid mesh can pop in later than retail's synchronous path | retail synchronous content load; `docs/architecture/worldbuilder-inventory.md` portal-readiness and bounded-residency seams |
|
| AD-22 | Async streamed mesh loading with bounded CPU replay residency, per-frame upload budgets, and point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's mesh-missing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs` | Immutable preparation descriptors and the bounded CPU cache can re-stage an evicted mesh; dispatcher self-heal makes absence transient while upload budgets prevent a portal arrival from monopolizing a frame | A future consumer that neither retains an owner nor reaches the self-heal/replay path can remain invisible; under heavy admission pressure a valid mesh can pop in later than retail's synchronous path | retail synchronous content load; `docs/architecture/worldbuilder-inventory.md` portal-readiness and bounded-residency seams |
|
||||||
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
|
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
|
||||||
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
|
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
|
||||||
| AD-25 | **REMOTE-DR sweep only** (the player half retired 2026-07-07 by the #182 verbatim rebuild): the remote dead-reckoning post-resolve still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding. The PLAYER path now runs the ported `handle_all_collisions` (`PhysicsObjUpdate`) with retail's `should_reflect` rule — the micro-bounce spiral it guarded is gone (contact is committed BEFORE the reflect and the small-velocity-zero is ungated) | `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | The remote DR sweep hasn't been rebuilt yet (it has no fsf/SetPositionInternal chain); the old airborne-only suppression keeps remote landings from micro-bouncing on the remote landing-snap gate | Remote landing-reflection behavior (slope-landing momentum) won't reproduce; retire when the remote-DR sweep gets the same UpdateObjectInternal rebuild as the player | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
|
| AD-25 | **REMOTE-DR sweep only** (the player half retired 2026-07-07 by the #182 verbatim rebuild): the remote dead-reckoning post-resolve still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding. The PLAYER path now runs the ported `handle_all_collisions` (`PhysicsObjUpdate`) with retail's `should_reflect` rule — the micro-bounce spiral it guarded is gone (contact is committed BEFORE the reflect and the small-velocity-zero is ungated) | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (remote sweep post-resolve, #173 block) | The remote DR sweep hasn't been rebuilt yet (it has no fsf/SetPositionInternal chain); the old airborne-only suppression keeps remote landings from micro-bouncing on the remote landing-snap gate | Remote landing-reflection behavior (slope-landing momentum) won't reproduce; retire when the remote-DR sweep gets the same UpdateObjectInternal rebuild as the player | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
|
||||||
| AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it). The companion `MoveToCancelled` seam only withdraws the waiting client presentation/action. | `src/AcDream.App/Interaction/SelectionInteractionController.cs` (`OnNaturalMoveToComplete`/`OnMoveToCancelled`); `src/AcDream.App/Rendering/GameWindow.cs` (player MoveTo seam binding); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete`/`MoveToCancelled`) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract; cancellation/error only clears retained intent. | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) |
|
| AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it). The companion `MoveToCancelled` seam only withdraws the waiting client presentation/action. | `src/AcDream.App/Interaction/SelectionInteractionController.cs` (`OnNaturalMoveToComplete`/`OnMoveToCancelled`); `src/AcDream.App/Rendering/GameWindow.cs` (player MoveTo seam binding); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete`/`MoveToCancelled`) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract; cancellation/error only clears retained intent. | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) |
|
||||||
| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 |
|
| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 |
|
||||||
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
|
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
|
||||||
|
|
@ -144,7 +144,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 | The world now shares one delayed alpha queue across Wb GfxObj/Setup entities and scene particles and drains it at retail's landscape/final boundaries. Residual: the modern reconstruction uses one stable scope-global CYpt sort rather than retail's per-`CPartCell` `CShadowPart` sort followed by cell traversal; `EnvCellRenderer` transparent shell batches also remain immediate and outside this queue | `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `RetailPViewRenderer.cs` (`FlushLandscapeAlpha`); `Rendering/Wb/WbDrawDispatcher.cs`; `ParticleRenderer.cs` | The mandatory modern renderer no longer owns retail `CPartCell` shadow lists. The shared queue restores the material consequence that motivated the port—particles and ordinary translucent parts can interleave—without rebuilding a second scene graph; stable sequence retains authored order on equal CYpt | Transparent objects from different cells can exchange order at a narrow overlap compared with retail cell traversal; an alpha-blended EnvCell shell cannot interleave with a particle or Wb entity, so those rare overlaps can still overpaint differently | `RenderDeviceD3D::DrawObjCellForDummies` 0x005A0760; `CShadowPart::insertion_sort` 0x006B5130; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `PView::DrawCells` 0x005A4840 |
|
| AP-34 | The world now shares one delayed alpha queue across Wb GfxObj/Setup entities and scene particles and drains it at retail's landscape/final boundaries. Residual: the modern reconstruction uses one stable scope-global CYpt sort rather than retail's per-`CPartCell` `CShadowPart` sort followed by cell traversal; `EnvCellRenderer` transparent shell batches also remain immediate and outside this queue | `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `RetailPViewRenderer.cs` (`FlushLandscapeAlpha`); `Rendering/Wb/WbDrawDispatcher.cs`; `ParticleRenderer.cs` | The mandatory modern renderer no longer owns retail `CPartCell` shadow lists. The shared queue restores the material consequence that motivated the port—particles and ordinary translucent parts can interleave—without rebuilding a second scene graph; stable sequence retains authored order on equal CYpt | Transparent objects from different cells can exchange order at a narrow overlap compared with retail cell traversal; an alpha-blended EnvCell shell cannot interleave with a particle or Wb entity, so those rare overlaps can still overpaint differently | `RenderDeviceD3D::DrawObjCellForDummies` 0x005A0760; `CShadowPart::insertion_sort` 0x006B5130; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `PView::DrawCells` 0x005A4840 |
|
||||||
| 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-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) + `src/AcDream.App/World/LiveEntityHydrationPorts.cs` (`LiveEntityWorldOriginCoordinator.TryInitialize` — login pre-collapse) + `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition` — first accepted canonical Position) + `GameWindow:AimTeleportDestination`/`IsSealedDungeonCell` (teleport pre-collapse and DAT predicate) + `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 |
|
||||||
|
|
@ -181,14 +181,14 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
||||||
| AP-75 | **NARROWED 2026-07-19 — adapter-boundary `adjust_motion` only.** `SetCycle` remaps TurnLeft/SideStepLeft/WalkBackward to their mirror command with negated speed before dispatch. Retail performs that normalization in `CMotionInterp`; GameWindow's local-player adapter can still pass raw ids directly | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap) | Preserves raw local callers until every caller enters through `MotionInterpreter`; literal DAT velocity and omega now flow through CSequence's complete Frame | A future caller that already normalizes a raw left/back command but still passes the original id can be adjusted twice | `CMotionInterp::adjust_motion` @305343; retire with the remaining local caller unification |
|
| AP-75 | **NARROWED 2026-07-19 — adapter-boundary `adjust_motion` only.** `SetCycle` remaps TurnLeft/SideStepLeft/WalkBackward to their mirror command with negated speed before dispatch. Retail performs that normalization in `CMotionInterp`; GameWindow's local-player adapter can still pass raw ids directly | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap) | Preserves raw local callers until every caller enters through `MotionInterpreter`; literal DAT velocity and omega now flow through CSequence's complete Frame | A future caller that already normalizes a raw left/back command but still passes the original id can be adjusted twice | `CMotionInterp::adjust_motion` @305343; retire with the remaining local caller unification |
|
||||||
| AP-77 | **NARROWED 2026-07-19 — animation-less/headless movement fallback only.** When `MotionInterpreter.DefaultSink` or the local PartArray callback is absent, acdream writes grounded command-derived body velocity and applies the DAT-pinned Humanoid `TurnRight` rate (1.5 radians/second) directly to the body Frame. Production animated players/remotes bind `MotionTableDispatchSink` plus CSequence and instead consume the complete DAT-authored root Frame; that path preserves airborne orientation while suppressing only origin exactly like retail | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`); `src/AcDream.App/Input/PlayerMovementController.cs` (no-PartArray object-quantum fallback) | Keeps isolated/headless physics tests and a deliberately animation-less entity controllable without fabricating a PartArray | A future production entity missing its animation binding uses Humanoid-only yaw/velocity, can foot-slide, and can rotate through an airborne quantum differently from a real creature's DAT Frame | `CMotionInterp::apply_interpreted_movement` 0x00528600; `CPhysicsObj::UpdatePositionInternal` 0x00512C30; retire when animation-less production objects have an explicit motion owner |
|
| AP-77 | **NARROWED 2026-07-19 — animation-less/headless movement fallback only.** When `MotionInterpreter.DefaultSink` or the local PartArray callback is absent, acdream writes grounded command-derived body velocity and applies the DAT-pinned Humanoid `TurnRight` rate (1.5 radians/second) directly to the body Frame. Production animated players/remotes bind `MotionTableDispatchSink` plus CSequence and instead consume the complete DAT-authored root Frame; that path preserves airborne orientation while suppressing only origin exactly like retail | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`); `src/AcDream.App/Input/PlayerMovementController.cs` (no-PartArray object-quantum fallback) | Keeps isolated/headless physics tests and a deliberately animation-less entity controllable without fabricating a PartArray | A future production entity missing its animation binding uses Humanoid-only yaw/velocity, can foot-slide, and can rotate through an airborne quantum differently from a real creature's DAT Frame | `CMotionInterp::apply_interpreted_movement` 0x00528600; `CPhysicsObj::UpdatePositionInternal` 0x00512C30; retire when animation-less production objects have an explicit motion owner |
|
||||||
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables. The adaptation is now structurally limited to replacing Ready/Walk/Run-family states, so authoritative actions/substates (especially Dead) always win. | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`, `CanApplyVelocityCycle`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
|
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables. The adaptation is now structurally limited to replacing Ready/Walk/Run-family states, so authoritative actions/substates (especially Dead) always win. | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`, `CanApplyVelocityCycle`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
|
||||||
| AP-81 | **Remote-DR VectorUpdate adds airborne/contact state beyond retail and toggles gravity via the Gravity STATE bit**: the handler writes velocity/omega, then may set Airborne, set `Body.State \|= Gravity`, clear contact, and call `LeaveGround`; both landing blocks clear Gravity after `HitGround()`. Retail `DoVectorUpdate` only writes velocity and omega, keeps GRAVITY set for the object's whole life, and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear). | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks), moving to `LiveEntityNetworkUpdateController` in Slice 4 | The extra branch makes an inbound nonzero vertical velocity start the current remote airborne integration even without the complete retail contact-gated acceleration chain; the flag dance delivers gravity only while airborne and the #161 ordering fix keeps the retail HitGround contract satisfied. Slice 4 isolates it rather than changing accepted remote motion during extraction. | A VectorUpdate sent while contact state should remain authoritative can make the remote leave ground earlier than retail; any new call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate; grounded remotes carry a non-retail state word. | `SmartBox::DoVectorUpdate @ 0x004521C0`; `CPhysicsObj::calc_acceleration`; `set_on_walkable @ 0x00511310`; retire when the complete contact-gated acceleration path owns remote motion. |
|
| AP-81 | **Remote-DR VectorUpdate adds airborne/contact state beyond retail and toggles gravity via the Gravity STATE bit**: the handler writes velocity/omega, then may set Airborne, set `Body.State \|= Gravity`, clear contact, and call `LeaveGround`; both landing blocks clear Gravity after `HitGround()`. Retail `DoVectorUpdate` only writes velocity and omega, keeps GRAVITY set for the object's whole life, and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear). | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (VectorUpdate jump handler); `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (landing blocks) | The extra branch makes an inbound nonzero vertical velocity start the current remote airborne integration even without the complete retail contact-gated acceleration chain; the flag dance delivers gravity only while airborne and the #161 ordering fix keeps the retail HitGround contract satisfied. Slice 4 isolates it rather than changing accepted remote motion during extraction. | A VectorUpdate sent while contact state should remain authoritative can make the remote leave ground earlier than retail; any new call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate; grounded remotes carry a non-retail state word. | `SmartBox::DoVectorUpdate @ 0x004521C0`; `CPhysicsObj::calc_acceleration`; `set_on_walkable @ 0x00511310`; retire when the complete contact-gated acceleration path owns remote motion. |
|
||||||
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
|
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
|
||||||
| AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/GameWindow.cs` (`_lightPoolVisibleCells`/`_lightPoolVisibleCellsValid`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) |
|
| AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/GameWindow.cs` (`_lightPoolVisibleCells`/`_lightPoolVisibleCellsValid`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) |
|
||||||
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose through `LiveEntityDefaultPoseResolver`; retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
|
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose through `LiveEntityDefaultPoseResolver`; retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
|
||||||
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). No current mover sets PerfectClip: players never do, and shipped ordinary missiles add PathClipped only. The non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified. Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping that mover | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
|
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). No current mover sets PerfectClip: players never do, and shipped ordinary missiles add PathClipped only. The non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified. Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping that mover | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
|
||||||
| AP-91 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` |
|
| AP-91 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` |
|
||||||
| AP-86 | **Remote SHADOW-follows-resolved via a pose/cell-gated per-tick re-flood** (remote-creature de-overlap #184): every remote's collision shadow is rewritten at the resolved body position by the DR tick or authoritative UP tail, so collision remains where the creature renders and de-overlap persists. The effect matches retail, but acdream runs the full multipart cell flood whenever the body moved more than 1 cm, changed complete orientation, or crossed a cell instead of translating the existing shadow in place and relinking only when its crossed-cell set changes. Cross-cell motion now commits body/root/full-cell before the canonical rebucket callback; local and authoritative remote publishers prove exact-record spatial residency after that callback; pending projection suspends the retained shadow and cannot re-add it, including initial-pending and callback GUID-reuse cases. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs`; `src/AcDream.App/Physics/LiveEntityShadowPublisher.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (local projection + authoritative UP tails); `src/AcDream.App/World/LiveEntityPresentationController.cs` (ordinary projection residency); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The pose/cell gate is exact at de-overlap equilibrium, preserves offset/multipart shapes during in-place turns, and the resulting registered cell set matches retail; loaded/pending residency is symmetric and incarnation-scoped | A dense moving or turning crowd can still perform a full registration flood per creature per tick and create CPU/Gen0 pressure; a still crowd is gated out. Retire with an in-place move plus cell-relink-on-change implementation | `CPhysicsObj::SetPositionInternal(CTransition const*)` 0x00515330 → `change_cell`, then `remove_shadows_from_cells`/`add_shadows_to_cells` after the resolved frame/contact commit |
|
| AP-86 | **Remote SHADOW-follows-resolved via a pose/cell-gated per-tick re-flood** (remote-creature de-overlap #184): every remote's collision shadow is rewritten at the resolved body position by the DR tick or authoritative UP tail, so collision remains where the creature renders and de-overlap persists. The effect matches retail, but acdream runs the full multipart cell flood whenever the body moved more than 1 cm, changed complete orientation, or crossed a cell instead of translating the existing shadow in place and relinking only when its crossed-cell set changes. Cross-cell motion now commits body/root/full-cell before the canonical rebucket callback; local and authoritative remote publishers prove exact-record spatial residency after that callback; pending projection suspends the retained shadow and cannot re-add it, including initial-pending and callback GUID-reuse cases. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs`; `src/AcDream.App/Physics/LiveEntityShadowPublisher.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (local projection); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (authoritative UP tails); `src/AcDream.App/World/LiveEntityPresentationController.cs` (ordinary projection residency); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The pose/cell gate is exact at de-overlap equilibrium, preserves offset/multipart shapes during in-place turns, and the resulting registered cell set matches retail; loaded/pending residency is symmetric and incarnation-scoped | A dense moving or turning crowd can still perform a full registration flood per creature per tick and create CPU/Gen0 pressure; a still crowd is gated out. Retire with an in-place move plus cell-relink-on-change implementation | `CPhysicsObj::SetPositionInternal(CTransition const*)` 0x00515330 → `change_cell`, then `remove_shadows_from_cells`/`add_shadows_to_cells` after the resolved frame/contact commit |
|
||||||
| AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
|
| AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
|
||||||
| AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 − translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 − translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 − translucency`, applied to all 4 D3D9 material alpha channels) |
|
| AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 − translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 − translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 − translucency`, applied to all 4 D3D9 material alpha channels) |
|
||||||
| AP-90 | **Radar fellowship/allegiance relationship state is modeled but not yet delivered at runtime.** `RetailRadar.GetBlipShape` and `RadarBlipColors.For` implement retail's leader/member/allegiance precedence, and `RadarSnapshotProvider` exposes a `relationshipFor(guid)` seam, but acdream does not yet maintain live fellowship membership and its `AllegianceTree` is not wired into GameWindow. PK/PKLite relationship shapes do work from PWD flags. | `src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs`; `src/AcDream.Core/Ui/RetailRadar.cs`; `src/AcDream.Core/Ui/RadarBlipColors.cs` | Preserve the exact model/seam now and avoid inventing membership from names or chat; connect it when the social game-event state is ported | Fellowship members render their ordinary player color/shape instead of bright-green leader/member triangles; allegiance members render an ordinary plus instead of a hollow box | `gmRadarUI::GetBlipColor` 0x004D76F0; `gmRadarUI::GetBlipShape` 0x004D7B60 |
|
| AP-90 | **Radar fellowship/allegiance relationship state is modeled but not yet delivered at runtime.** `RetailRadar.GetBlipShape` and `RadarBlipColors.For` implement retail's leader/member/allegiance precedence, and `RadarSnapshotProvider` exposes a `relationshipFor(guid)` seam, but acdream does not yet maintain live fellowship membership and its `AllegianceTree` is not wired into GameWindow. PK/PKLite relationship shapes do work from PWD flags. | `src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs`; `src/AcDream.Core/Ui/RetailRadar.cs`; `src/AcDream.Core/Ui/RadarBlipColors.cs` | Preserve the exact model/seam now and avoid inventing membership from names or chat; connect it when the social game-event state is ported | Fellowship members render their ordinary player color/shape instead of bright-green leader/member triangles; allegiance members render an ordinary plus instead of a hollow box | `gmRadarUI::GetBlipColor` 0x004D76F0; `gmRadarUI::GetBlipShape` 0x004D7B60 |
|
||||||
| AP-92 | Paperdoll renders its creature through a private FBO/RTT and blits it into `UiViewport`; retail renders `CreatureMode` directly in the UI viewport/in-cell presentation path | `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent and the accepted pixels/camera/pose match retail; state is sealed with `GLStateScope` | FBO origin, alpha, lighting, or state isolation can diverge from direct viewport rendering | `gmPaperDollUI::PostInit @ 0x004A5360`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` |
|
| AP-92 | Paperdoll renders its creature through a private FBO/RTT and blits it into `UiViewport`; retail renders `CreatureMode` directly in the UI viewport/in-cell presentation path | `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent and the accepted pixels/camera/pose match retail; state is sealed with `GLStateScope` | FBO origin, alpha, lighting, or state isolation can diverge from direct viewport rendering | `gmPaperDollUI::PostInit @ 0x004A5360`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` |
|
||||||
|
|
@ -215,8 +215,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
||||||
| AP-116 | Default `Particle Range = Extended` multiplies DAT-authored particle degradation distances by 2; the `Retail` option restores exact values | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs`; `src/AcDream.Core/Vfx/ParticleSystem.cs` | User explicitly requested doubled range as the normal non-dev-UI behavior; it changes no terrain, scenery, entity, fog, or streaming distance, and remains reversible through settings | The default roughly enlarges the active particle area and reduces the CPU gain from MP2; distant VFX remain visible beyond retail's authored cutoff | `CPhysicsPart::GetMaxDegradeDistance @ 0x0050D510`; `GfxObjDegradeInfo::get_max_degrade_distance @ 0x0051E2D0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
|
| AP-116 | Default `Particle Range = Extended` multiplies DAT-authored particle degradation distances by 2; the `Retail` option restores exact values | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs`; `src/AcDream.Core/Vfx/ParticleSystem.cs` | User explicitly requested doubled range as the normal non-dev-UI behavior; it changes no terrain, scenery, entity, fog, or streaming distance, and remains reversible through settings | The default roughly enlarges the active particle area and reduces the CPU gain from MP2; distant VFX remain visible beyond retail's authored cutoff | `CPhysicsPart::GetMaxDegradeDistance @ 0x0050D510`; `GfxObjDegradeInfo::get_max_degrade_distance @ 0x0051E2D0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
|
||||||
| AP-117 | Outdoor particle `CLandCell::IsInView` state is reconstructed with the modern landscape renderer's per-cell frustum plus active doorway clip-plane/scissor-AABB tests; retail `LScape::landcell_check` uses `Render::get_clip_height` + `Render::block_check` on terrain-cell corner intervals | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (`CollectVisibleCells`) | The mandatory modern renderer batches terrain by landblock and has no retail `ViewIntervalType` product. Publishing cell visibility from the exact landscape draw slices preserves ownership/order and removes the former object-survivor dependency without adding a second view pipeline | At a terrain cell grazing a frustum or doorway boundary, the conservative AABB test may freeze or resume particles on a slightly different frame than retail; whole regions outside the active doorway slice are rejected, and authored distance, login/portal fail-closed behavior, and indoor PView cells remain exact | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
|
| AP-117 | Outdoor particle `CLandCell::IsInView` state is reconstructed with the modern landscape renderer's per-cell frustum plus active doorway clip-plane/scissor-AABB tests; retail `LScape::landcell_check` uses `Render::get_clip_height` + `Render::block_check` on terrain-cell corner intervals | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (`CollectVisibleCells`) | The mandatory modern renderer batches terrain by landblock and has no retail `ViewIntervalType` product. Publishing cell visibility from the exact landscape draw slices preserves ownership/order and removes the former object-survivor dependency without adding a second view pipeline | At a terrain cell grazing a frustum or doorway boundary, the conservative AABB test may freeze or resume particles on a slightly different frame than retail; whole regions outside the active doorway slice are rejected, and authored distance, login/portal fail-closed behavior, and indoor PView cells remain exact | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
|
||||||
| AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, a mode that settled without a blocker transition clears immediately; local ACE's observed pre-wield transition plus `ready -> NonCombat`, or post-wield `NonCombat -> ready -> NonCombat`, causes one normal `ChangeCombatMode` request from the trailing notice. Explicit user combat input cancels settlement. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during primary-weapon replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later explicit combat request, replacement, or logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` |
|
| AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, a mode that settled without a blocker transition clears immediately; local ACE's observed pre-wield transition plus `ready -> NonCombat`, or post-wield `NonCombat -> ready -> NonCombat`, causes one normal `ChangeCombatMode` request from the trailing notice. Explicit user combat input cancels settlement. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during primary-weapon replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later explicit combat request, replacement, or logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` |
|
||||||
| AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Rendering/GameWindow.cs` (`RouteSameGenerationCreateObject`); moving to `LiveEntityNetworkUpdateController` in Slice 4 | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` |
|
| AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ILiveEntitySameGenerationUpdateSink.OnDescription`); `LiveEntitySameGenerationUpdateRouter.cs` | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` |
|
||||||
| AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); App same-generation route in `GameWindow.cs`, moving to `LiveEntityNetworkUpdateController` in Slice 4 | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` |
|
| AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); `src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs` | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` |
|
||||||
|
|
||||||
## 4. Temporary stopgap (TS) — 34 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
|
## 4. Temporary stopgap (TS) — 34 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
|
||||||
|
|
||||||
|
|
@ -250,13 +250,13 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
||||||
| TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain. **R5-V1 CORRECTED the mechanism** (the earlier "per-cell contact-plane / doorway-jamming" guess was WRONG): the write side is the **ConstraintManager server-position rubber-band leash** — armed by `SmartBox::HandleReceivedPosition` on every inbound server position, `IsFullyConstrained` = `max*0.9 < offset`. R5-V1 ported `ConstraintManager` (`src/AcDream.Core/Physics/Motion/ConstraintManager.cs`) but does NOT arm it (no acdream `SmartBox` + two x87 distance constants BN elided) — so this read stays false. Arming = issue #167 | A body retail would consider fully constrained (still rubber-banding toward a server position inside the tight leash) never refuses the jump (0x47) — a jump succeeds mid-rubber-band where retail blocks it. Low practical risk (the leash band is tight + short-lived) | `CPhysicsObj::IsFullyConstrained` 0x0050ec60 → `ConstraintManager::IsFullyConstrained` 0x005560d0; `jump_is_allowed` 0x005282b0; arming `SmartBox::HandleReceivedPosition` 0x00453fd0 (issue #167) |
|
| TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain. **R5-V1 CORRECTED the mechanism** (the earlier "per-cell contact-plane / doorway-jamming" guess was WRONG): the write side is the **ConstraintManager server-position rubber-band leash** — armed by `SmartBox::HandleReceivedPosition` on every inbound server position, `IsFullyConstrained` = `max*0.9 < offset`. R5-V1 ported `ConstraintManager` (`src/AcDream.Core/Physics/Motion/ConstraintManager.cs`) but does NOT arm it (no acdream `SmartBox` + two x87 distance constants BN elided) — so this read stays false. Arming = issue #167 | A body retail would consider fully constrained (still rubber-banding toward a server position inside the tight leash) never refuses the jump (0x47) — a jump succeeds mid-rubber-band where retail blocks it. Low practical risk (the leash band is tight + short-lived) | `CPhysicsObj::IsFullyConstrained` 0x0050ec60 → `ConstraintManager::IsFullyConstrained` 0x005560d0; `jump_is_allowed` 0x005282b0; arming `SmartBox::HandleReceivedPosition` 0x00453fd0 (issue #167) |
|
||||||
| TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 |
|
| TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 |
|
||||||
| TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly |
|
| TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly |
|
||||||
| ~~TS-41~~ | **RETIRED 2026-07-07 (remote-creature de-overlap #184)** — the SERVERVEL synth-velocity body-drive (`Body.Velocity = ServerVelocity` / `get_state_velocity()` leg) is DELETED. Grounded NPC remotes now translate by the retail interp CATCH-UP (`RemoteMotionCombiner.ComputeOffset` → `InterpolationManager::adjust_offset` toward the MoveOrTeleport-queued server waypoint) and `MovementManager::UseTime` (`TickRemoteMoveTo`) runs UNCONDITIONALLY per tick — the retail `UpdateObjectInternal` shape (no wire-velocity leg-driver). The de-overlap sweep resolves the catch-up movement; the resolved position is written back into the SHADOW (AP-86) so it persists. Residual: the non-retail anim-cycle stale-stop heuristic (`ApplyServerControlledVelocityCycle(Zero)` on a >0.6 s velocity-staleness timer) is kept as ANIM-only and stays covered by **AP-80**; it no longer drives the body. | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch) | — | — | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` @0x00515998, unconditional); `MoveOrTeleport` 0x00516330; `InterpolationManager::adjust_offset` 0x00555d30 |
|
| ~~TS-41~~ | **RETIRED 2026-07-07 (remote-creature de-overlap #184)** — the SERVERVEL synth-velocity body-drive (`Body.Velocity = ServerVelocity` / `get_state_velocity()` leg) is DELETED. Grounded NPC remotes now translate by the retail interp CATCH-UP (`RemoteMotionCombiner.ComputeOffset` → `InterpolationManager::adjust_offset` toward the MoveOrTeleport-queued server waypoint) and `MovementManager::UseTime` (`TickRemoteMoveTo`) runs UNCONDITIONALLY per tick — the retail `UpdateObjectInternal` shape (no wire-velocity leg-driver). The de-overlap sweep resolves the catch-up movement; the resolved position is written back into the SHADOW (AP-86) so it persists. Residual: the non-retail anim-cycle stale-stop heuristic (`ApplyServerControlledVelocityCycle(Zero)` on a >0.6 s velocity-staleness timer) is kept as ANIM-only and stays covered by **AP-80**; it no longer drives the body. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (grounded NPC branch) | — | — | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` @0x00515998, unconditional); `MoveOrTeleport` 0x00516330; `InterpolationManager::adjust_offset` 0x00555d30 |
|
||||||
| TS-44 | NPC UpdatePosition **enqueue is suppressed while StickyManager is armed** (`PositionManager.GetStickyObjectId() != 0`). Position and complete orientation otherwise share the ported `InterpolateTo → Position::subtract2 → PositionManager::adjust_offset` Frame, so the former orientation hard-snap residual is retired. Retail would still enqueue the server Position and let Sticky overwrite that Frame each tick; acdream retains the gate so no queued waypoint survives the stick | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC `snapSuppressedByStick` gate) | Avoids replaying an old ACE waypoint immediately after a stick lease ends; all live during-stick pose ownership is now otherwise retail-shaped | After unstick the body waits for the next UP instead of consuming the latest waypoint already in the queue; at low packet cadence this can pause correction for one update interval | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330; retire by allowing enqueue while Sticky overwrites the shared complete Frame |
|
| TS-44 | NPC UpdatePosition **enqueue is suppressed while StickyManager is armed** (`PositionManager.GetStickyObjectId() != 0`). Position and complete orientation otherwise share the ported `InterpolateTo → Position::subtract2 → PositionManager::adjust_offset` Frame, so the former orientation hard-snap residual is retired. Retail would still enqueue the server Position and let Sticky overwrite that Frame each tick; acdream retains the gate so no queued waypoint survives the stick | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (NPC `snapSuppressedByStick` gate) | Avoids replaying an old ACE waypoint immediately after a stick lease ends; all live during-stick pose ownership is now otherwise retail-shaped | After unstick the body waits for the next UP instead of consuming the latest waypoint already in the queue; at low packet cadence this can pause correction for one update interval | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330; retire by allowing enqueue while Sticky overwrites the shared complete Frame |
|
||||||
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep now `GetSetupCylinder`-fed with a shapeless→human fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` |
|
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`GetSetupCylinder`); `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (remote sweep fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` |
|
||||||
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-91` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
|
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-91` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
|
||||||
| TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` |
|
| TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` |
|
||||||
| TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` |
|
| TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` |
|
||||||
| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Rendering/GameWindow.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` |
|
| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` |
|
||||||
| TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; frame drain in `src/AcDream.App/Rendering/GameWindow.cs` | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order |
|
| TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; frame drain in `src/AcDream.App/Rendering/GameWindow.cs` | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order |
|
||||||
| TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each object's ParticleManager and ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` also advances that static owner's managers using its whole admitted elapsed interval. | `src/AcDream.App/Rendering/GameWindow.cs` (`AdvanceLiveObjectRuntime` final `_particleSystem.Tick(dt)` / `_scriptRunner.Tick(...)`) | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and manager order faithful without pretending the shared tails have per-owner timing. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static default scripts/particles use render elapsed rather than `animate_static_object` elapsed/discard timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum |
|
| TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each object's ParticleManager and ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` also advances that static owner's managers using its whole admitted elapsed interval. | `src/AcDream.App/Rendering/GameWindow.cs` (`AdvanceLiveObjectRuntime` final `_particleSystem.Tick(dt)` / `_scriptRunner.Tick(...)`) | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and manager order faithful without pretending the shared tails have per-owner timing. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static default scripts/particles use render elapsed rather than `animate_static_object` elapsed/discard timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum |
|
||||||
| TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 |
|
| TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 |
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,47 @@ public sealed class LocalPlayerOutboundController
|
||||||
controller.SimTimeSeconds);
|
controller.SimTimeSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail ForcePosition acknowledgement sent immediately after the local
|
||||||
|
/// body is blipped. Unlike the periodic post-network sender, this bypasses
|
||||||
|
/// the elapsed/difference predicate but still requires a valid grounded
|
||||||
|
/// canonical frame.
|
||||||
|
/// </summary>
|
||||||
|
internal void SendImmediatePosition(
|
||||||
|
WorldSession? session,
|
||||||
|
PlayerMovementController? controller)
|
||||||
|
{
|
||||||
|
if (session is null
|
||||||
|
|| controller is null
|
||||||
|
|| !controller.CanSendPositionEvent)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!controller.TryGetOutboundPosition(out Position outboundPosition))
|
||||||
|
return;
|
||||||
|
uint cellId = outboundPosition.ObjCellId;
|
||||||
|
Vector3 position = outboundPosition.Frame.Origin;
|
||||||
|
Quaternion rotation = outboundPosition.Frame.Orientation;
|
||||||
|
|
||||||
|
uint sequence = session.NextGameActionSequence();
|
||||||
|
byte[] body = AutonomousPosition.Build(
|
||||||
|
gameActionSequence: sequence,
|
||||||
|
cellId: cellId,
|
||||||
|
position: position,
|
||||||
|
rotation: rotation,
|
||||||
|
instanceSequence: session.InstanceSequence,
|
||||||
|
serverControlSequence: session.ServerControlSequence,
|
||||||
|
teleportSequence: session.TeleportSequence,
|
||||||
|
forcePositionSequence: session.ForcePositionSequence,
|
||||||
|
lastContact: 1);
|
||||||
|
session.SendGameAction(body);
|
||||||
|
controller.NotePositionSent(
|
||||||
|
outboundPosition,
|
||||||
|
controller.ContactPlane,
|
||||||
|
controller.SimTimeSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
public bool TrySendMovement(
|
public bool TrySendMovement(
|
||||||
WorldSession? session,
|
WorldSession? session,
|
||||||
PlayerMovementController? controller,
|
PlayerMovementController? controller,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
using AcDream.App.Physics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Physics.Motion;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
namespace AcDream.App.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Narrow reverse seam for collaborators constructed before the live network
|
||||||
|
/// owner. It carries no state or identity: every call resolves the one bound
|
||||||
|
/// update-thread owner, and use before bind fails immediately.
|
||||||
|
/// </summary>
|
||||||
|
internal interface ILiveEntityMotionRuntimeBindings
|
||||||
|
{
|
||||||
|
(float Radius, float Height) GetSetupCylinder(uint serverGuid, WorldEntity entity);
|
||||||
|
bool RouteServerMoveTo(
|
||||||
|
MovementManager movement,
|
||||||
|
uint cellId,
|
||||||
|
WorldSession.EntityMotionUpdate update);
|
||||||
|
void StickToObjectFromWire(IPhysicsObjHost? host, uint targetGuid);
|
||||||
|
void ClearTargetForHiddenEntity(uint serverGuid);
|
||||||
|
IPhysicsObjHost? ResolvePhysicsHost(uint serverGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class DeferredLiveEntityMotionRuntimeBindings
|
||||||
|
: ILiveEntityMotionRuntimeBindings
|
||||||
|
{
|
||||||
|
private ILiveEntityMotionRuntimeBindings? _target;
|
||||||
|
|
||||||
|
public void Bind(ILiveEntityMotionRuntimeBindings target)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(target);
|
||||||
|
if (_target is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Live entity motion runtime bindings are already bound.");
|
||||||
|
}
|
||||||
|
_target = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ILiveEntityMotionRuntimeBindings Target =>
|
||||||
|
_target ?? throw new InvalidOperationException(
|
||||||
|
"Live entity motion runtime bindings were used before binding.");
|
||||||
|
|
||||||
|
public (float Radius, float Height) GetSetupCylinder(
|
||||||
|
uint serverGuid,
|
||||||
|
WorldEntity entity) => Target.GetSetupCylinder(serverGuid, entity);
|
||||||
|
|
||||||
|
public bool RouteServerMoveTo(
|
||||||
|
MovementManager movement,
|
||||||
|
uint cellId,
|
||||||
|
WorldSession.EntityMotionUpdate update) =>
|
||||||
|
Target.RouteServerMoveTo(movement, cellId, update);
|
||||||
|
|
||||||
|
public void StickToObjectFromWire(IPhysicsObjHost? host, uint targetGuid) =>
|
||||||
|
Target.StickToObjectFromWire(host, targetGuid);
|
||||||
|
|
||||||
|
public void ClearTargetForHiddenEntity(uint serverGuid) =>
|
||||||
|
Target.ClearTargetForHiddenEntity(serverGuid);
|
||||||
|
|
||||||
|
public IPhysicsObjHost? ResolvePhysicsHost(uint serverGuid) =>
|
||||||
|
Target.ResolvePhysicsHost(serverGuid);
|
||||||
|
}
|
||||||
209
src/AcDream.App/Physics/LiveEntityInboundAuthorityGate.cs
Normal file
209
src/AcDream.App/Physics/LiveEntityInboundAuthorityGate.cs
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.World;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Physics;
|
||||||
|
|
||||||
|
internal readonly record struct AcceptedMotionNetworkUpdate(
|
||||||
|
WorldSession.EntityMotionUpdate Update,
|
||||||
|
LiveEntityRecord Record,
|
||||||
|
AcceptedPhysicsTimestamps Timestamps,
|
||||||
|
ulong MovementAuthorityVersion,
|
||||||
|
ulong VelocityAuthorityVersion);
|
||||||
|
|
||||||
|
internal readonly record struct AcceptedVectorNetworkUpdate(
|
||||||
|
VectorUpdate.Parsed Update,
|
||||||
|
LiveEntityRecord Record,
|
||||||
|
ulong VectorAuthorityVersion,
|
||||||
|
ulong VelocityAuthorityVersion);
|
||||||
|
|
||||||
|
internal readonly record struct AcceptedStateNetworkUpdate(
|
||||||
|
SetState.Parsed Update,
|
||||||
|
LiveEntityRecord Record,
|
||||||
|
ulong StateAuthorityVersion);
|
||||||
|
|
||||||
|
internal readonly record struct AcceptedPositionNetworkUpdate(
|
||||||
|
WorldSession.EntityPositionUpdate Update,
|
||||||
|
LiveEntityRecord Record,
|
||||||
|
WorldSession.EntitySpawn Spawn,
|
||||||
|
AcceptedPhysicsTimestamps Timestamps,
|
||||||
|
PositionTimestampDisposition TimestampDisposition,
|
||||||
|
ulong PositionAuthorityVersion,
|
||||||
|
ulong VelocityAuthorityVersion);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns the one retail timestamp/authority admission edge for live inbound
|
||||||
|
/// Movement, Vector, State, and Position packets. The accepted context pins
|
||||||
|
/// the exact record and channel versions before any re-entrant App callback
|
||||||
|
/// can run; channel presenters must revalidate those captures after callbacks.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class LiveEntityInboundAuthorityGate
|
||||||
|
{
|
||||||
|
private readonly LiveEntityRuntime _liveEntities;
|
||||||
|
private readonly Action<uint, AcceptedPhysicsTimestamps> _publishTimestamps;
|
||||||
|
|
||||||
|
public LiveEntityInboundAuthorityGate(
|
||||||
|
LiveEntityRuntime liveEntities,
|
||||||
|
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps)
|
||||||
|
{
|
||||||
|
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||||
|
_publishTimestamps = publishTimestamps
|
||||||
|
?? throw new ArgumentNullException(nameof(publishTimestamps));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal uint? LastLivePlayerLandblockId { get; private set; }
|
||||||
|
|
||||||
|
internal void ResetSessionState() => LastLivePlayerLandblockId = null;
|
||||||
|
|
||||||
|
internal bool TryAcceptMotion(
|
||||||
|
WorldSession.EntityMotionUpdate update,
|
||||||
|
bool retainPayload,
|
||||||
|
out AcceptedMotionNetworkUpdate accepted,
|
||||||
|
out bool timestampAccepted)
|
||||||
|
{
|
||||||
|
accepted = default;
|
||||||
|
timestampAccepted = _liveEntities.TryApplyMotion(
|
||||||
|
update,
|
||||||
|
retainPayload,
|
||||||
|
out _,
|
||||||
|
out AcceptedPhysicsTimestamps timestamps);
|
||||||
|
if (!timestampAccepted)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
LiveEntityRecord? record = null;
|
||||||
|
ulong movementAuthorityVersion = 0;
|
||||||
|
ulong velocityAuthorityVersion = 0;
|
||||||
|
if (retainPayload)
|
||||||
|
{
|
||||||
|
if (!_liveEntities.TryGetRecord(update.Guid, out record))
|
||||||
|
return false;
|
||||||
|
movementAuthorityVersion = record.MovementAuthorityVersion;
|
||||||
|
velocityAuthorityVersion = record.VelocityAuthorityVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
_publishTimestamps(update.Guid, timestamps);
|
||||||
|
if (!retainPayload)
|
||||||
|
return false;
|
||||||
|
if (!_liveEntities.IsCurrentMovementAuthority(
|
||||||
|
record!,
|
||||||
|
movementAuthorityVersion)
|
||||||
|
|| !_liveEntities.IsCurrentVelocityAuthority(
|
||||||
|
record!,
|
||||||
|
velocityAuthorityVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted = new AcceptedMotionNetworkUpdate(
|
||||||
|
update,
|
||||||
|
record!,
|
||||||
|
timestamps,
|
||||||
|
movementAuthorityVersion,
|
||||||
|
velocityAuthorityVersion);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TryAcceptVector(
|
||||||
|
VectorUpdate.Parsed update,
|
||||||
|
bool payloadIsValid,
|
||||||
|
out AcceptedVectorNetworkUpdate accepted)
|
||||||
|
{
|
||||||
|
accepted = default;
|
||||||
|
if (!payloadIsValid
|
||||||
|
|| !_liveEntities.TryApplyVector(update, out _)
|
||||||
|
|| !_liveEntities.TryGetRecord(update.Guid, out LiveEntityRecord record))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted = new AcceptedVectorNetworkUpdate(
|
||||||
|
update,
|
||||||
|
record,
|
||||||
|
record.VectorAuthorityVersion,
|
||||||
|
record.VelocityAuthorityVersion);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TryAcceptState(
|
||||||
|
SetState.Parsed update,
|
||||||
|
out AcceptedStateNetworkUpdate accepted)
|
||||||
|
{
|
||||||
|
accepted = default;
|
||||||
|
if (!_liveEntities.TryApplyState(update, out _, out _)
|
||||||
|
|| !_liveEntities.TryGetRecord(update.Guid, out LiveEntityRecord record))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted = new AcceptedStateNetworkUpdate(
|
||||||
|
update,
|
||||||
|
record,
|
||||||
|
record.StateAuthorityVersion);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TryAcceptPosition(
|
||||||
|
WorldSession.EntityPositionUpdate update,
|
||||||
|
uint localPlayerGuid,
|
||||||
|
Quaternion? forcePositionRotation,
|
||||||
|
Vector3? currentLocalVelocity,
|
||||||
|
bool payloadIsValid,
|
||||||
|
out AcceptedPositionNetworkUpdate accepted)
|
||||||
|
{
|
||||||
|
accepted = default;
|
||||||
|
if (!payloadIsValid)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
bool isLocalPlayer = update.Guid == localPlayerGuid;
|
||||||
|
bool known = _liveEntities.TryApplyPosition(
|
||||||
|
update,
|
||||||
|
isLocalPlayer,
|
||||||
|
isLocalPlayer ? forcePositionRotation : null,
|
||||||
|
isLocalPlayer ? currentLocalVelocity : null,
|
||||||
|
out PositionTimestampDisposition disposition,
|
||||||
|
out WorldSession.EntitySpawn spawn,
|
||||||
|
out AcceptedPhysicsTimestamps timestamps);
|
||||||
|
if (!known)
|
||||||
|
{
|
||||||
|
if (isLocalPlayer)
|
||||||
|
LastLivePlayerLandblockId = update.Position.LandblockId;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
LiveEntityRecord? record = null;
|
||||||
|
ulong positionAuthorityVersion = 0;
|
||||||
|
ulong velocityAuthorityVersion = 0;
|
||||||
|
if (disposition is not PositionTimestampDisposition.Rejected)
|
||||||
|
{
|
||||||
|
if (!_liveEntities.TryGetRecord(update.Guid, out record))
|
||||||
|
return false;
|
||||||
|
positionAuthorityVersion = record.PositionAuthorityVersion;
|
||||||
|
velocityAuthorityVersion = record.VelocityAuthorityVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
_publishTimestamps(update.Guid, timestamps);
|
||||||
|
if (disposition is PositionTimestampDisposition.Rejected)
|
||||||
|
return false;
|
||||||
|
if (!_liveEntities.IsCurrentPositionAuthority(
|
||||||
|
record!,
|
||||||
|
positionAuthorityVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted = new AcceptedPositionNetworkUpdate(
|
||||||
|
update,
|
||||||
|
record!,
|
||||||
|
spawn,
|
||||||
|
timestamps,
|
||||||
|
disposition,
|
||||||
|
positionAuthorityVersion,
|
||||||
|
velocityAuthorityVersion);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void ObserveAcceptedLocalPosition(uint landblockId) =>
|
||||||
|
LastLivePlayerLandblockId = landblockId;
|
||||||
|
}
|
||||||
488
src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs
Normal file
488
src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs
Normal file
|
|
@ -0,0 +1,488 @@
|
||||||
|
using AcDream.App.Interaction;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.World;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
namespace AcDream.App.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Focused owner of reusable live CPhysicsObj-style host lookup, remote motion
|
||||||
|
/// binding, server MoveTo/sticky routing, setup-cylinder dimensions, and
|
||||||
|
/// Hidden target invalidation. It is independent of packet orchestration so
|
||||||
|
/// renderer, selection, and movement collaborators share one one-way service.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class LiveEntityMotionRuntimeController
|
||||||
|
: ILiveEntityMotionRuntimeBindings
|
||||||
|
{
|
||||||
|
private readonly LiveEntityRuntime _liveEntities;
|
||||||
|
private readonly PhysicsDataCache _physicsDataCache;
|
||||||
|
private readonly Func<SelectionInteractionController?> _selectionInteractions;
|
||||||
|
private readonly SelectionState _selection;
|
||||||
|
private readonly LiveWorldOriginState _origin;
|
||||||
|
|
||||||
|
private IReadOnlyDictionary<uint, WorldEntity> _entitiesByServerGuid =>
|
||||||
|
_liveEntities.MaterializedWorldEntities;
|
||||||
|
private IReadOnlyDictionary<uint, WorldEntity> _visibleEntitiesByServerGuid =>
|
||||||
|
_liveEntities.WorldEntities;
|
||||||
|
|
||||||
|
public LiveEntityMotionRuntimeController(
|
||||||
|
LiveEntityRuntime liveEntities,
|
||||||
|
PhysicsDataCache physicsDataCache,
|
||||||
|
Func<SelectionInteractionController?> selectionInteractions,
|
||||||
|
SelectionState selection,
|
||||||
|
LiveWorldOriginState origin)
|
||||||
|
{
|
||||||
|
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||||
|
_physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache));
|
||||||
|
_selectionInteractions = selectionInteractions ?? throw new ArgumentNullException(nameof(selectionInteractions));
|
||||||
|
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||||
|
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||||
|
}
|
||||||
|
internal AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings(
|
||||||
|
RemoteMotion rm, LiveEntityAnimationState? ae, uint serverGuid)
|
||||||
|
{
|
||||||
|
AcDream.Core.Physics.AnimationSequencer? sequencer = ae?.Sequencer;
|
||||||
|
if (sequencer is not null && rm.Sink is null)
|
||||||
|
{
|
||||||
|
rm.Sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(sequencer);
|
||||||
|
rm.Motion.DefaultSink = rm.Sink;
|
||||||
|
}
|
||||||
|
// #174 (2026-07-05): the RemoveLinkAnimations seam is retail
|
||||||
|
// CPhysicsObj::RemoveLinkAnimations 0x0050fe20 — a TAILCALL to
|
||||||
|
// CPartArray::HandleEnterWorld 0x00517d70 →
|
||||||
|
// MotionTableManager::HandleEnterWorld 0x0051bdd0: strip the
|
||||||
|
// sequence's link animations AND drain pending_animations
|
||||||
|
// completely (each pop fires MotionDone → CMotionInterp pops its
|
||||||
|
// pending_motions node in lockstep). The previous binding was the
|
||||||
|
// bare sequence strip — every LeaveGround (jump) stripped the
|
||||||
|
// animations that queued manager nodes were counting down on,
|
||||||
|
// orphaning them (NumAnims>0, anims gone) and permanently damming
|
||||||
|
// BOTH queues: MotionsPending() never drained again, so every
|
||||||
|
// armed moveto (ACE's walk-to-door mt-6, the close-range use turn)
|
||||||
|
// starved — the "door only works on a fresh session" bug.
|
||||||
|
if (sequencer is not null)
|
||||||
|
{
|
||||||
|
rm.Motion.RemoveLinkAnimations = () => sequencer.Manager.HandleEnterWorld();
|
||||||
|
rm.Motion.InitializeMotionTables = () => sequencer.Manager.InitializeState();
|
||||||
|
// R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions
|
||||||
|
// 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions).
|
||||||
|
rm.Motion.CheckForCompletedMotions = sequencer.Manager.CheckForCompletedMotions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host/MoveTo ownership does not depend on a render animation. AP-77
|
||||||
|
// deliberately supplies CMotionInterp's headless body fallback when a
|
||||||
|
// live object has no PartArray sink, so build the manager once anyway.
|
||||||
|
if (rm.Host is not null)
|
||||||
|
return rm.Sink;
|
||||||
|
if (_liveEntities is not { } liveEntities
|
||||||
|
|| !liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord hostRecord)
|
||||||
|
|| !ReferenceEquals(hostRecord.RemoteMotionRuntime, rm))
|
||||||
|
{
|
||||||
|
return rm.Sink;
|
||||||
|
}
|
||||||
|
// R4-V4: the verbatim MoveToManager, seam-bound per the V2 harness
|
||||||
|
// wiring (the conformance-tested reference). Positions are WORLD
|
||||||
|
// space on both sides (getPosition + the MovementStruct positions
|
||||||
|
// the UM router builds) — one consistent space, so the manager's
|
||||||
|
// distance/heading math matches the harness exactly.
|
||||||
|
// R5-V5: the construction is now the MovementManager facade's
|
||||||
|
// MoveToFactory (the acdream stand-in for the physics_obj/weenie_obj
|
||||||
|
// backpointers retail's MakeMoveToManager 0x00524000 constructs
|
||||||
|
// from); MakeMoveToManager() below invokes it once, preserving the
|
||||||
|
// pre-facade eager timing.
|
||||||
|
var rmT = rm;
|
||||||
|
var mtBody = rm.Body;
|
||||||
|
// R5-V3 (#171): real setup cylsphere radii — retail CPartArray::
|
||||||
|
// GetRadius/GetHeight (0x005180a0/0x005180b0). Lazy per-call resolve
|
||||||
|
// (a dictionary hit) so the read never races the spawn path's
|
||||||
|
// CacheSetup. Feeds GetCurrentDistance's UseSpheres cylinder math
|
||||||
|
// (own side) and StickyManager::adjust_offset's own-radius gap term.
|
||||||
|
// Replaces the R4 `() => 0f` pins ("setup cylsphere radius lands with
|
||||||
|
// R5-V3").
|
||||||
|
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var selfEntity))
|
||||||
|
return rm.Sink;
|
||||||
|
// R5-V2: forward-declared so the MoveToManager's target seams route
|
||||||
|
// into the entity's TargetManager (retail CPhysicsObj::set_target →
|
||||||
|
// TargetManager::SetTarget). Assigned right after the manager is built.
|
||||||
|
EntityPhysicsHost host = null!;
|
||||||
|
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||||
|
rm.Movement.MoveToFactory = () =>
|
||||||
|
{
|
||||||
|
var mtm = new AcDream.Core.Physics.Motion.MoveToManager(
|
||||||
|
rm.Motion,
|
||||||
|
stopCompletely: () => rmT.Movement.PerformMovement(
|
||||||
|
new AcDream.Core.Physics.MovementStruct
|
||||||
|
{
|
||||||
|
Type = AcDream.Core.Physics.MovementType.StopCompletely,
|
||||||
|
}),
|
||||||
|
getPosition: () => new AcDream.Core.Physics.Position(
|
||||||
|
rmT.CellId, mtBody.Position, mtBody.Orientation),
|
||||||
|
getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.GetHeading(
|
||||||
|
mtBody.Orientation),
|
||||||
|
setHeading: (h, _) => mtBody.Orientation =
|
||||||
|
AcDream.Core.Physics.Motion.MoveToMath.SetHeading(mtBody.Orientation, h),
|
||||||
|
getOwnRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius,
|
||||||
|
getOwnHeight: () => GetSetupCylinder(serverGuid, selfEntity).Height,
|
||||||
|
contact: () => mtBody.OnWalkable,
|
||||||
|
isInterpolating: () => rmT.Interp.IsActive,
|
||||||
|
getVelocity: () => mtBody.Velocity,
|
||||||
|
getSelfId: () => serverGuid,
|
||||||
|
// R5-V2: retail CPhysicsObj::set_target/clear_target/target_quantum
|
||||||
|
// → the entity's TargetManager (replaces the AP-79 TrackedTarget*
|
||||||
|
// poll). The manager passes (0, top_level_id, 0.5, 0.0).
|
||||||
|
setTarget: (ctx, tlid, radius, q) => host.SetTarget(ctx, tlid, radius, q),
|
||||||
|
clearTarget: () => host.ClearTarget(),
|
||||||
|
getTargetQuantum: () => host.TargetManager.GetTargetQuantum(),
|
||||||
|
setTargetQuantum: q => host.TargetManager.SetTargetQuantum(q),
|
||||||
|
// R4-V5: real clock (same epoch-seconds base the per-remote tick uses).
|
||||||
|
curTime: NowSeconds);
|
||||||
|
// R5-V3 (#171, retires TS-39): bind the sticky seams to the host's
|
||||||
|
// PositionManager (host is constructed before MakeMoveToManager
|
||||||
|
// invokes this factory) —
|
||||||
|
// * BeginNextNode's sticky-arrival handoff: PositionManager::StickTo
|
||||||
|
// (retail MoveToManager::BeginNextNode @0x00529d3a);
|
||||||
|
// * PerformMovement's head unstick: unstick_from_object →
|
||||||
|
// PositionManager::UnStick (MoveToManager.PerformMovement:414).
|
||||||
|
mtm.StickTo = (tlid, radius, height) =>
|
||||||
|
host.PositionManager.StickTo(tlid, radius, height);
|
||||||
|
mtm.Unstick = host.PositionManager.UnStick;
|
||||||
|
return mtm;
|
||||||
|
};
|
||||||
|
// TS-36 (remote side): the interp's interrupt seam is retail's
|
||||||
|
// interrupt_current_movement → MovementManager::CancelMoveTo(0x36)
|
||||||
|
// chain (V2's reentrancy tests prove the wiring is inert-safe;
|
||||||
|
// R5-V5: the chain now lands on the literal facade relay 0x005241b0).
|
||||||
|
rm.Motion.InterruptCurrentMovement =
|
||||||
|
() => rmT.Movement.CancelMoveTo(
|
||||||
|
AcDream.Core.Physics.WeenieError.ActionCancelled);
|
||||||
|
|
||||||
|
// R5-V2: the entity's CPhysicsObj stand-in + its TargetManager. Position
|
||||||
|
// is the entity's WORLD position (WorldEntity.Position — exactly the
|
||||||
|
// source the AP-79 poll used for a tracked target, so the voyeur system
|
||||||
|
// delivers the identical position for a moveto's quantum-0 case).
|
||||||
|
// HandleTargetting ticks per frame (added in the per-remote loop);
|
||||||
|
// HandleUpdateTarget fans deliveries to this entity's MoveToManager
|
||||||
|
// and (R5-V3, inside the host) its PositionManager — retail's
|
||||||
|
// CPhysicsObj::HandleUpdateTarget order.
|
||||||
|
var configuredHost = new EntityPhysicsHost(
|
||||||
|
serverGuid,
|
||||||
|
getPosition: () => new AcDream.Core.Physics.Position(
|
||||||
|
hostRecord.FullCellId,
|
||||||
|
hostRecord.WorldEntity?.Position ?? mtBody.Position,
|
||||||
|
mtBody.Orientation),
|
||||||
|
getVelocity: () => mtBody.Velocity,
|
||||||
|
getRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius,
|
||||||
|
inContact: () => mtBody.OnWalkable,
|
||||||
|
minterpMaxSpeed: () => rmT.Motion.GetMaxSpeed(),
|
||||||
|
curTime: NowSeconds,
|
||||||
|
physicsTimerTime: NowSeconds,
|
||||||
|
getObjectA: ResolvePhysicsHost,
|
||||||
|
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head:
|
||||||
|
// MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade
|
||||||
|
// relay to the moveto side); the host chains the PositionManager
|
||||||
|
// leg after it.
|
||||||
|
handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info),
|
||||||
|
interruptCurrentMovement: () => rmT.Movement.CancelMoveTo(
|
||||||
|
AcDream.Core.Physics.WeenieError.ActionCancelled));
|
||||||
|
host = EntityPhysicsHost.InstallOrRebind(
|
||||||
|
liveEntities,
|
||||||
|
hostRecord,
|
||||||
|
configuredHost);
|
||||||
|
rm.MarkFullPhysicsHostBound();
|
||||||
|
|
||||||
|
// R5-V5: retail MakeMoveToManager (0x00524000) — constructs the
|
||||||
|
// MoveToManager via the factory above (host now exists for the
|
||||||
|
// sticky binds inside it). The UM funnel head's unstick
|
||||||
|
// (CPhysicsObj::unstick_from_object 0x0050eaea, invoked at the mt-0
|
||||||
|
// routing sites) binds on the interp beside it.
|
||||||
|
rm.Movement.MakeMoveToManager();
|
||||||
|
rm.Motion.UnstickFromObject = host.PositionManager.UnStick;
|
||||||
|
return rm.Sink;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R5-V2 — retail <c>CObjectMaint::GetObjectA(id)</c>: resolve any entity's
|
||||||
|
/// <see cref="AcDream.Core.Physics.Motion.IPhysicsObjHost"/> by guid, the
|
||||||
|
/// cross-entity seam every host's <c>GetObjectA</c> uses. If the entity has
|
||||||
|
/// a bound host (from <see cref="EnsureRemoteMotionBindings"/> or
|
||||||
|
/// <c>EnterPlayerModeNow</c>) return it; otherwise, for any entity that
|
||||||
|
/// EXISTS in the world, lazily create a minimal position-only host. Retail
|
||||||
|
/// gives every <c>CPhysicsObj</c> the capacity to host a
|
||||||
|
/// <c>target_manager</c> — a STATIC object (chest, corpse) must still answer
|
||||||
|
/// <c>add_voyeur</c> so a mover can moveto/stick to it (without this,
|
||||||
|
/// auto-walk to a never-animated object would arm the moveto but never
|
||||||
|
/// receive the immediate target snapshot, and never start). The minimal
|
||||||
|
/// host is registered so subsequent lookups reuse it; it is NOT ticked
|
||||||
|
/// (a static object never drifts, so the AddVoyeur immediate snapshot is
|
||||||
|
/// all a watcher needs; despawn still fires ExitWorld via the registry).
|
||||||
|
/// </summary>
|
||||||
|
public AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id)
|
||||||
|
{
|
||||||
|
if (_liveEntities is not { } liveEntities)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
bool isActive = liveEntities.TryGetRecord(id, out LiveEntityRecord activeRecord);
|
||||||
|
if (!isActive)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// TS-49: CObjCell::hide_object removes Hidden objects from the
|
||||||
|
// relationship surface until DetectionManager is ported. Pending,
|
||||||
|
// attached, and otherwise non-render-visible active objects remain in
|
||||||
|
// CObjectMaint's object table and must still resolve here.
|
||||||
|
if (liveEntities.IsHidden(id))
|
||||||
|
{
|
||||||
|
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[autowalk-host-miss] object=0x{id:X8} "
|
||||||
|
+ $"materialized={_entitiesByServerGuid.ContainsKey(id)} "
|
||||||
|
+ $"registered={liveEntities.TryGetPhysicsHost(id, out _)} "
|
||||||
|
+ $"hidden={liveEntities.IsHidden(id)}");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (liveEntities.TryGetPhysicsHost(id, out var existing))
|
||||||
|
return existing;
|
||||||
|
|
||||||
|
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||||
|
var minimal = EntityPhysicsHost.CreateMinimal(
|
||||||
|
activeRecord,
|
||||||
|
ResolvePhysicsHost,
|
||||||
|
NowSeconds);
|
||||||
|
liveEntities.InstallPhysicsHost(activeRecord, minimal);
|
||||||
|
return minimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R5-V3 (#171): retail <c>CPartArray::GetRadius</c>/<c>GetHeight</c>
|
||||||
|
/// (0x005180a0/0x005180b0 — <c>setup->radius/height × scale</c>; ACE
|
||||||
|
/// <c>PartArray.cs:189-207</c> reads the same Setup-level fields). Returns
|
||||||
|
/// (0, 0) when the entity has no resolvable Setup (bare-GfxObj props) —
|
||||||
|
/// ACE's <c>PartArray != null ? … : 0</c> fallback shape
|
||||||
|
/// (<c>PhysicsObj.cs:951-952</c>). Live spawns cache their Setup in
|
||||||
|
/// <c>_physicsDataCache</c> at spawn time (CacheSetup, ~3250), so this is
|
||||||
|
/// a dictionary hit for every creature.
|
||||||
|
/// </summary>
|
||||||
|
public (float Radius, float Height) GetSetupCylinder(
|
||||||
|
uint serverGuid, AcDream.Core.World.WorldEntity entity)
|
||||||
|
{
|
||||||
|
var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
|
||||||
|
if (setup is null)
|
||||||
|
return (0f, 0f);
|
||||||
|
// Live spawns bake ObjScale into MeshRefs and never populate
|
||||||
|
// WorldEntity.Scale (a scenery-pipeline field, default 1.0) — the
|
||||||
|
// authoritative per-entity scale is the SPAWN RECORD's ObjScale, the
|
||||||
|
// same source the collision registration's entScale uses (~4137).
|
||||||
|
// entity.Scale remains the fallback for non-spawn entities (scenery —
|
||||||
|
// never a chase/sticky participant). Diff-review find: without this,
|
||||||
|
// a server-scaled creature variant read unscaled radii and kept the
|
||||||
|
// #171 interpenetration exactly for scaled bodies.
|
||||||
|
float scale =
|
||||||
|
_liveEntities.Snapshots.TryGetValue(serverGuid, out var sp)
|
||||||
|
&& sp.ObjScale is { } objScale && objScale > 0f
|
||||||
|
? objScale
|
||||||
|
: (entity.Scale > 0f ? entity.Scale : 1f);
|
||||||
|
return (setup.Radius * scale, setup.Height * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #184 Slice 2a: ApplyPositionManagerDelta + SyncRemoteShadowToBody moved to
|
||||||
|
// AcDream.App.Physics.RemotePhysicsUpdater. ApplyPositionManagerDelta had no
|
||||||
|
// caller outside the DR tick; SyncRemoteShadowToBody is now called back via
|
||||||
|
// _remotePhysicsUpdater from the NPC UP-branch tail (below).
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R5-V4: retail <c>CPhysicsObj::stick_to_object</c> (0x005127e0) — the
|
||||||
|
/// mt-0 WIRE sticky (unpack_movement case 0 @00524589, gated on the
|
||||||
|
/// motionFlags 0x1 trailer guid): resolve the target, read its PartArray
|
||||||
|
/// radius/height (0 when shapeless — retail's null-PartArray arm), then
|
||||||
|
/// <c>PositionManager::StickTo</c>. Unresolvable target → no stick at
|
||||||
|
/// all (retail's GetObjectA-null path). Retail resolves the target's
|
||||||
|
/// top-level PARENT first (<c>parent ?? self</c>); acdream's entity
|
||||||
|
/// table is flat (no client-side parenting), so the guid is used as-is —
|
||||||
|
/// the same convention as RouteServerMoveTo's TopLevelId.
|
||||||
|
/// </summary>
|
||||||
|
public void StickToObjectFromWire(
|
||||||
|
AcDream.Core.Physics.Motion.IPhysicsObjHost? host,
|
||||||
|
uint targetGuid)
|
||||||
|
{
|
||||||
|
if (host is not EntityPhysicsHost entityHost)
|
||||||
|
return;
|
||||||
|
if (_liveEntities is not { } liveEntities
|
||||||
|
|| !liveEntities.TryGetInteractionEligibleEntity(targetGuid, out var tgtEnt))
|
||||||
|
return;
|
||||||
|
var (radius, height) = GetSetupCylinder(targetGuid, tgtEnt);
|
||||||
|
entityHost.PositionManager.StickTo(targetGuid, radius, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearTargetForHiddenEntity(uint serverGuid)
|
||||||
|
{
|
||||||
|
if (_selectionInteractions() is { } interactions)
|
||||||
|
interactions.OnEntityHidden(serverGuid);
|
||||||
|
else if (_selection.SelectedObjectId == serverGuid)
|
||||||
|
_selection.Clear(
|
||||||
|
AcDream.Core.Selection.SelectionChangeSource.System,
|
||||||
|
AcDream.Core.Selection.SelectionChangeReason.Cleared);
|
||||||
|
|
||||||
|
if (_liveEntities?.TryGetPhysicsHost(serverGuid, out var hiddenHost) == true
|
||||||
|
&& hiddenHost is EntityPhysicsHost hiddenEntityHost)
|
||||||
|
hiddenEntityHost.NotifyHidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R4-V5: shared retail <c>unpack_movement</c> type-6..9 routing
|
||||||
|
/// (<c>0x00524440</c> cases 6/7/8/9, decomp §2f) — one body for remotes
|
||||||
|
/// (R4-V4) and the local player (R4-V5). Writes the wire run rate to
|
||||||
|
/// the interp (<c>my_run_rate</c>, unpack @300603/@300660 — plan M13),
|
||||||
|
/// builds the <c>MovementStruct</c> (mt 6/8 resolve the target guid
|
||||||
|
/// against the entity table; unresolvable degrades to
|
||||||
|
/// MoveToPosition(wire origin) / TurnToHeading(wire heading) per §2f —
|
||||||
|
/// NOT an error), and calls the R5-V5 facade's <c>PerformMovement</c>
|
||||||
|
/// (<c>MovementManager::PerformMovement</c> 0x005240d0 — types 6-9
|
||||||
|
/// MakeMoveToManager + delegate; retail's cases call MakeMoveToManager
|
||||||
|
/// at each head, a no-op here since the bind sites create eagerly).
|
||||||
|
/// Returns true when the event was a type-6..9 moveto (consumed); false
|
||||||
|
/// for every other movement type (caller falls through to its funnel /
|
||||||
|
/// skip posture).
|
||||||
|
/// </summary>
|
||||||
|
public bool RouteServerMoveTo(
|
||||||
|
AcDream.Core.Physics.Motion.MovementManager movement,
|
||||||
|
uint cellId,
|
||||||
|
AcDream.Core.Net.WorldSession.EntityMotionUpdate update)
|
||||||
|
{
|
||||||
|
if (update.MotionState.IsServerControlledMoveTo
|
||||||
|
&& update.MotionState.MoveToPath is { } path)
|
||||||
|
{
|
||||||
|
// my_run_rate write (unpack_movement @300603).
|
||||||
|
if (update.MotionState.MoveToRunRate is { } mtRunRate)
|
||||||
|
movement.Minterp.MyRunRate = mtRunRate;
|
||||||
|
|
||||||
|
var destWorld = AcDream.Core.Physics.Motion.MoveToMath
|
||||||
|
.OriginToWorld(
|
||||||
|
path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ,
|
||||||
|
_origin.CenterX, _origin.CenterY);
|
||||||
|
var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWire(
|
||||||
|
path.Bitfield,
|
||||||
|
path.DistanceToObject,
|
||||||
|
path.MinDistance,
|
||||||
|
path.FailDistance,
|
||||||
|
update.MotionState.MoveToSpeed ?? 1f,
|
||||||
|
path.WalkRunThreshold,
|
||||||
|
path.DesiredHeading);
|
||||||
|
|
||||||
|
var ms = new AcDream.Core.Physics.MovementStruct
|
||||||
|
{
|
||||||
|
Params = mp,
|
||||||
|
};
|
||||||
|
// mt 6 with a resolvable target → MoveToObject (the P4 tracker
|
||||||
|
// feeds position updates per tick); else degrade to
|
||||||
|
// MoveToPosition at the wire origin (§2f).
|
||||||
|
if (update.MotionState.MovementType == 6
|
||||||
|
&& path.TargetGuid is { } tgtGuid
|
||||||
|
&& _liveEntities is { } liveMoveEntities
|
||||||
|
&& liveMoveEntities.TryGetInteractionEligibleEntity(tgtGuid, out var tgtEnt))
|
||||||
|
{
|
||||||
|
ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
|
||||||
|
ms.ObjectId = tgtGuid;
|
||||||
|
ms.TopLevelId = tgtGuid;
|
||||||
|
// R5-V3 (#171): retail resolves the TARGET object's PartArray
|
||||||
|
// radius/height at the MoveToObject call site
|
||||||
|
// (CPhysicsObj::MoveToObject 0x005128e9/0x00512903; ACE
|
||||||
|
// PhysicsObj.cs:951-952) — they become SoughtObjectRadius/
|
||||||
|
// Height, feeding GetCurrentDistance's edge-to-edge arrival
|
||||||
|
// (UseSpheres) and the sticky-arrival handoff's target radius.
|
||||||
|
// Was unset (0): every attacker closed ~one body-radius deeper
|
||||||
|
// than retail before stopping — the #171 dogpile term.
|
||||||
|
(ms.Radius, ms.Height) = GetSetupCylinder(tgtGuid, tgtEnt);
|
||||||
|
ms.Pos = new AcDream.Core.Physics.Position(
|
||||||
|
cellId, tgtEnt.Position,
|
||||||
|
System.Numerics.Quaternion.Identity);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ms.Type = AcDream.Core.Physics.MovementType.MoveToPosition;
|
||||||
|
ms.Pos = new AcDream.Core.Physics.Position(
|
||||||
|
cellId, destWorld,
|
||||||
|
System.Numerics.Quaternion.Identity);
|
||||||
|
}
|
||||||
|
movement.PerformMovement(ms);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update.MotionState.IsServerControlledTurnTo
|
||||||
|
&& update.MotionState.TurnToPath is { } turnPath)
|
||||||
|
{
|
||||||
|
var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo(
|
||||||
|
turnPath.Bitfield,
|
||||||
|
turnPath.Speed,
|
||||||
|
turnPath.DesiredHeading);
|
||||||
|
|
||||||
|
var ms = new AcDream.Core.Physics.MovementStruct { Params = mp };
|
||||||
|
if (update.MotionState.MovementType == 8
|
||||||
|
&& turnPath.TargetGuid is { } turnTgt
|
||||||
|
&& _liveEntities is { } liveTurnEntities
|
||||||
|
&& liveTurnEntities.TryGetInteractionEligibleEntity(turnTgt, out var turnEnt))
|
||||||
|
{
|
||||||
|
ms.Type = AcDream.Core.Physics.MovementType.TurnToObject;
|
||||||
|
ms.ObjectId = turnTgt;
|
||||||
|
ms.TopLevelId = turnTgt;
|
||||||
|
ms.Pos = new AcDream.Core.Physics.Position(
|
||||||
|
cellId, turnEnt.Position,
|
||||||
|
System.Numerics.Quaternion.Identity);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ms.Type = AcDream.Core.Physics.MovementType.TurnToHeading;
|
||||||
|
// Retail's mt-8 unresolvable-object fallback substitutes the
|
||||||
|
// STANDALONE wire heading into the params before degrading
|
||||||
|
// (decomp §2f case 8: `params.desired_heading = wire_heading`).
|
||||||
|
// Invisible against ACE (P6: both heading fields written from
|
||||||
|
// the same source) but required for the verbatim degrade —
|
||||||
|
// closes the V4 carry-over the adversarial review caught
|
||||||
|
// (TurnToPathData.WireHeading was parsed but never consumed).
|
||||||
|
if (update.MotionState.MovementType == 8
|
||||||
|
&& turnPath.WireHeading is { } wireHeading)
|
||||||
|
{
|
||||||
|
mp.DesiredHeading = wireHeading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
movement.PerformMovement(ms);
|
||||||
|
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||||
|
{
|
||||||
|
string target = turnPath.TargetGuid is { } targetGuid
|
||||||
|
? $"0x{targetGuid:X8}" : "null";
|
||||||
|
bool targetVisible = turnPath.TargetGuid is { } visibleGuid
|
||||||
|
&& _visibleEntitiesByServerGuid.ContainsKey(visibleGuid);
|
||||||
|
bool targetHost = turnPath.TargetGuid is { } hostGuid
|
||||||
|
&& _liveEntities?.TryGetPhysicsHost(hostGuid, out _) == true;
|
||||||
|
var moveTo = movement.MoveTo;
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[autowalk-turn-route] wire=0x{update.MotionState.MovementType:X2} "
|
||||||
|
+ $"routed={ms.Type} target={target} visible={targetVisible} "
|
||||||
|
+ $"host={targetHost} stop={mp.StopCompletelyFlag} "
|
||||||
|
+ $"initialized={moveTo?.Initialized ?? false} "
|
||||||
|
+ $"nodes={moveTo?.PendingActions.Count() ?? 0} "
|
||||||
|
+ $"command=0x{moveTo?.CurrentCommand ?? 0u:X8} "
|
||||||
|
+ $"pendingMotions={movement.Minterp.MotionsPending()}");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #184 Slice 2a: TickRemoteMoveTo (retail MovementManager::UseTime
|
||||||
|
// 0x005242f0 — moveto steering / arrival / fail-distance; the P4 poll is
|
||||||
|
// gone, TargetManager voyeur delivers positions) moved to
|
||||||
|
// RemotePhysicsUpdater. The DR tick was its only caller.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 6.6: the server says an entity's motion has changed. Look up
|
||||||
|
/// the LiveEntityAnimationState for that guid, re-resolve the idle cycle with the
|
||||||
|
/// new (stance, forward-command) override, and if the cycle is still
|
||||||
|
/// animated, swap in the new animation/frame range. Entities not in
|
||||||
|
/// the animated map (static props, entities rejected at spawn time)
|
||||||
|
/// are simply ignored — there's nothing to tick for them.
|
||||||
|
/// </summary>
|
||||||
|
}
|
||||||
1789
src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
Normal file
1789
src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
Normal file
File diff suppressed because it is too large
Load diff
34
src/AcDream.App/Physics/LiveEntityVectorRoute.cs
Normal file
34
src/AcDream.App/Physics/LiveEntityVectorRoute.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
namespace AcDream.App.Physics;
|
||||||
|
|
||||||
|
internal enum LiveEntityVectorRoute
|
||||||
|
{
|
||||||
|
Projectile,
|
||||||
|
CanonicalBody,
|
||||||
|
OrdinaryRemote,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins retail's one-CPhysicsObj vector-update priority. A missile owns its
|
||||||
|
/// canonical body first; otherwise a retained static body consumes the wire
|
||||||
|
/// vector; only an ordinary remote reaches MovementManager storage.
|
||||||
|
/// </summary>
|
||||||
|
internal static class LiveEntityVectorRouter
|
||||||
|
{
|
||||||
|
internal static LiveEntityVectorRoute Route(
|
||||||
|
Func<bool> tryProjectile,
|
||||||
|
Func<bool> tryCanonicalBody,
|
||||||
|
Action applyOrdinaryRemote)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(tryProjectile);
|
||||||
|
ArgumentNullException.ThrowIfNull(tryCanonicalBody);
|
||||||
|
ArgumentNullException.ThrowIfNull(applyOrdinaryRemote);
|
||||||
|
|
||||||
|
if (tryProjectile())
|
||||||
|
return LiveEntityVectorRoute.Projectile;
|
||||||
|
if (tryCanonicalBody())
|
||||||
|
return LiveEntityVectorRoute.CanonicalBody;
|
||||||
|
|
||||||
|
applyOrdinaryRemote();
|
||||||
|
return LiveEntityVectorRoute.OrdinaryRemote;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/AcDream.App/Physics/LocalForcePositionTransaction.cs
Normal file
29
src/AcDream.App/Physics/LocalForcePositionTransaction.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
namespace AcDream.App.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Preserves retail ForcePosition's atomic local order: validate the accepted
|
||||||
|
/// Position authority, blip once, acknowledge once, then stop if the
|
||||||
|
/// acknowledgement synchronously displaced that authority.
|
||||||
|
/// </summary>
|
||||||
|
internal static class LocalForcePositionTransaction
|
||||||
|
{
|
||||||
|
internal static bool Apply(
|
||||||
|
bool isForcePosition,
|
||||||
|
Func<bool> isCurrent,
|
||||||
|
Action blip,
|
||||||
|
Action acknowledge)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(isCurrent);
|
||||||
|
ArgumentNullException.ThrowIfNull(blip);
|
||||||
|
ArgumentNullException.ThrowIfNull(acknowledge);
|
||||||
|
|
||||||
|
if (!isForcePosition)
|
||||||
|
return true;
|
||||||
|
if (!isCurrent())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
blip();
|
||||||
|
acknowledge();
|
||||||
|
return isCurrent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,11 +24,10 @@ namespace AcDream.App.Physics;
|
||||||
/// the <c>!IsPlayerGuid</c>-gated stale-velocity animation-cycle stop. See
|
/// the <c>!IsPlayerGuid</c>-gated stale-velocity animation-cycle stop. See
|
||||||
/// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para>
|
/// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para>
|
||||||
///
|
///
|
||||||
/// <para>Shared helpers that GameWindow also calls elsewhere are injected:
|
/// <para>Shared policy arrives through focused Physics delegates:
|
||||||
/// <c>GetSetupCylinder</c> (a general Setup-dimension helper with ~9 callers,
|
/// <c>GetSetupCylinder</c> is owned by <c>LiveEntityMotionRuntimeController</c>,
|
||||||
/// incl. the local player's own cylinder — kept on GameWindow) and
|
/// while <c>ApplyServerControlledVelocityCycle</c> is stateless policy also
|
||||||
/// <c>ApplyServerControlledVelocityCycle</c> (anim-cycle selection, also called
|
/// consumed by the UpdatePosition route. <c>SyncRemoteShadowToBody</c>
|
||||||
/// from the UP handler) arrive as delegates. <c>SyncRemoteShadowToBody</c>
|
|
||||||
/// (remote-physics-specific) moved here and is called back from the UP-branch
|
/// (remote-physics-specific) moved here and is called back from the UP-branch
|
||||||
/// tail; <c>ApplyPositionManagerDelta</c> / <c>TickRemoteMoveTo</c> had no other
|
/// tail; <c>ApplyPositionManagerDelta</c> / <c>TickRemoteMoveTo</c> had no other
|
||||||
/// callers and moved here outright.</para>
|
/// callers and moved here outright.</para>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
|
||||||
|
namespace AcDream.App.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stateless AP-80 compatibility refinement for server-controlled non-player
|
||||||
|
/// locomotion. Kept outside network routing because the per-frame remote
|
||||||
|
/// physics updater and accepted Position tail consume the same operation.
|
||||||
|
/// </summary>
|
||||||
|
internal static class RemoteServerControlledVelocityCycle
|
||||||
|
{
|
||||||
|
private static bool IsPlayerGuid(uint guid) =>
|
||||||
|
(guid & 0xFF000000u) == 0x50000000u;
|
||||||
|
public static void Apply(
|
||||||
|
uint serverGuid,
|
||||||
|
LiveEntityAnimationState ae,
|
||||||
|
RemoteMotion rm,
|
||||||
|
System.Numerics.Vector3 velocity)
|
||||||
|
{
|
||||||
|
if (rm.Airborne) return;
|
||||||
|
if (ae.Sequencer is null) return;
|
||||||
|
// R4-V4: an active MoveToManager owns the cycle (BeginMoveForward's
|
||||||
|
// get_command dispatch). Velocity-estimated cycle planning would
|
||||||
|
// fight it — same rationale as the pre-V4 ServerMoveToActive guard.
|
||||||
|
if (rm.MoveTo is { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid }) return;
|
||||||
|
|
||||||
|
if (IsPlayerGuid(serverGuid))
|
||||||
|
{
|
||||||
|
// L.2g S5 (2026-07-02, DEV-2 DELETED): player remotes get NO
|
||||||
|
// pace-derived cycle refinement — retail has no such mechanism
|
||||||
|
// anywhere in its inbound pipeline (deviation map DEV-2, two
|
||||||
|
// independent decomp dives + ACE cross-check). The #39-era
|
||||||
|
// premise ("retail's outbound goes silent on Shift toggle") was
|
||||||
|
// refuted at all three oracles + the S0 live capture: retail
|
||||||
|
// sends a fresh MoveToState on HoldRun toggle while moving
|
||||||
|
// (CommandInterpreter 0x006b37a8 → SendMovementEvent), ACE
|
||||||
|
// rebroadcasts every MoveToState (GameActionMoveToState.cs:36),
|
||||||
|
// and the wire shows explicit 0x0005↔0x0007 UMs on each toggle
|
||||||
|
// (launch-s0-wireprobe.log). The refinement layer's re-promote
|
||||||
|
// after legitimate flags=0 Ready UMs was itself the observed
|
||||||
|
// Ready↔Run thrash. Cycle changes for player remotes come from
|
||||||
|
// UpdateMotion ONLY, exactly like retail; position error is the
|
||||||
|
// InterpolationManager chase's job.
|
||||||
|
//
|
||||||
|
// NPC/monster remotes below keep PlanFromVelocity until S6
|
||||||
|
// unifies all entity classes onto the CMotionInterp funnel.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint currentMotion = ae.Sequencer.CurrentMotion;
|
||||||
|
// AP-80 is a position-only compatibility adaptation, not an
|
||||||
|
// animation authority. Restrict it to Ready/Walk/Run so late death
|
||||||
|
// position deltas cannot replace the authoritative Dead substate.
|
||||||
|
if (!AcDream.Core.Physics.ServerControlledLocomotion
|
||||||
|
.CanApplyVelocityCycle(currentMotion))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var plan = AcDream.Core.Physics.ServerControlledLocomotion
|
||||||
|
.PlanFromVelocity(velocity);
|
||||||
|
|
||||||
|
uint style = ae.Sequencer.CurrentStyle != 0
|
||||||
|
? ae.Sequencer.CurrentStyle
|
||||||
|
: 0x8000003Du;
|
||||||
|
|
||||||
|
// D2 (Commit A 2026-05-03): UPCYCLE diag — proves whether
|
||||||
|
// ApplyServerControlledVelocityCycle is racing UpdateMotion-driven
|
||||||
|
// SetCycle for non-player remotes (NPCs / monsters).
|
||||||
|
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||||||
|
{
|
||||||
|
System.Console.WriteLine(
|
||||||
|
$"[UPCYCLE] guid={serverGuid:X8} "
|
||||||
|
+ $"vel=({velocity.X:F2},{velocity.Y:F2},{velocity.Z:F2}) "
|
||||||
|
+ $"|v|={velocity.Length():F2} "
|
||||||
|
+ $"-> motion=0x{plan.Motion:X8} speedMod={plan.SpeedMod:F2} "
|
||||||
|
+ $"prev=0x{currentMotion:X8} "
|
||||||
|
+ $"airborne={rm.Airborne} moveTo={rm.MoveTo?.MovementTypeState ?? AcDream.Core.Physics.MovementType.Invalid}");
|
||||||
|
}
|
||||||
|
ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,48 @@
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
|
||||||
|
namespace AcDream.App.World;
|
||||||
|
|
||||||
|
internal interface ILiveEntitySameGenerationUpdateSink
|
||||||
|
{
|
||||||
|
void OnDescription(uint ownerGuid, PhysicsSpawnData description);
|
||||||
|
void OnAppearance(ObjDescEvent.Parsed appearance);
|
||||||
|
void OnParent(CreateParentUpdate parent);
|
||||||
|
void OnPosition(WorldSession.EntityPositionUpdate position);
|
||||||
|
void OnPickup(PickupEvent.Parsed pickup);
|
||||||
|
void OnMovement(WorldSession.EntityMotionUpdate movement);
|
||||||
|
void OnState(SetState.Parsed state);
|
||||||
|
void OnVector(VectorUpdate.Parsed vector);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the equal-generation <c>CObjectMaint::CreateObject</c> tail order from
|
||||||
|
/// retail <c>SmartBox::HandleCreateObject @ 0x00454C80</c>. The leading
|
||||||
|
/// PhysicsDesc effect-profile refresh is the isolated AP-119 adaptation; the
|
||||||
|
/// retail tail then remains ObjDesc, one Position relation, Movement, State,
|
||||||
|
/// and Vector. Each channel still owns an independent timestamp gate.
|
||||||
|
/// </summary>
|
||||||
|
internal static class LiveEntitySameGenerationUpdateRouter
|
||||||
|
{
|
||||||
|
public static void Apply(
|
||||||
|
SameGenerationCreateObjectEvents refresh,
|
||||||
|
ILiveEntitySameGenerationUpdateSink sink)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(sink);
|
||||||
|
sink.OnDescription(refresh.Appearance.Guid, refresh.Description);
|
||||||
|
sink.OnAppearance(refresh.Appearance);
|
||||||
|
|
||||||
|
if (refresh.Parent is { } parent)
|
||||||
|
sink.OnParent(parent);
|
||||||
|
else if (refresh.Position is { } position)
|
||||||
|
sink.OnPosition(position);
|
||||||
|
else if (refresh.Pickup is { } pickup)
|
||||||
|
sink.OnPickup(pickup);
|
||||||
|
|
||||||
|
if (refresh.Movement is { } movement)
|
||||||
|
sink.OnMovement(movement);
|
||||||
|
sink.OnState(refresh.State);
|
||||||
|
sink.OnVector(refresh.Vector);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Net;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Input;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Input;
|
||||||
|
|
||||||
|
public sealed class LocalPlayerImmediatePositionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ForcePositionAcknowledgement_SendsOneExactAutonomousPositionAndStampsIt()
|
||||||
|
{
|
||||||
|
using var session = new WorldSession(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 9000));
|
||||||
|
session.PublishAcceptedLocalPhysicsTimestamps(
|
||||||
|
instance: 11,
|
||||||
|
serverControlledMove: 12,
|
||||||
|
teleport: 13,
|
||||||
|
forcePosition: 14);
|
||||||
|
var sent = new List<byte[]>();
|
||||||
|
session.GameActionCapture = sent.Add;
|
||||||
|
|
||||||
|
PlayerMovementController player = GroundedPlayer();
|
||||||
|
Position preTurnCanonical = player.CellPosition;
|
||||||
|
Quaternion rotation = Quaternion.Normalize(
|
||||||
|
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.7f));
|
||||||
|
player.SetBodyOrientation(rotation);
|
||||||
|
Assert.NotEqual(rotation, preTurnCanonical.Frame.Orientation);
|
||||||
|
Assert.True(player.TryGetOutboundPosition(out Position outboundPosition));
|
||||||
|
var outbound = new LocalPlayerOutboundController((_, _, _, _, _, _) => { });
|
||||||
|
|
||||||
|
outbound.SendImmediatePosition(session, player);
|
||||||
|
|
||||||
|
byte[] body = Assert.Single(sent);
|
||||||
|
Assert.Equal(56, body.Length);
|
||||||
|
Assert.Equal(AutonomousPosition.GameActionOpcode, U32(body, 0));
|
||||||
|
Assert.Equal(1u, U32(body, 4));
|
||||||
|
Assert.Equal(AutonomousPosition.AutonomousPositionAction, U32(body, 8));
|
||||||
|
Assert.Equal(outboundPosition.ObjCellId, U32(body, 12));
|
||||||
|
Assert.Equal(outboundPosition.Frame.Origin.X, F32(body, 16));
|
||||||
|
Assert.Equal(outboundPosition.Frame.Origin.Y, F32(body, 20));
|
||||||
|
Assert.Equal(outboundPosition.Frame.Origin.Z, F32(body, 24));
|
||||||
|
Assert.Equal(rotation.W, F32(body, 28), precision: 6);
|
||||||
|
Assert.Equal(rotation.X, F32(body, 32), precision: 6);
|
||||||
|
Assert.Equal(rotation.Y, F32(body, 36), precision: 6);
|
||||||
|
Assert.Equal(rotation.Z, F32(body, 40), precision: 6);
|
||||||
|
Assert.Equal((ushort)11, U16(body, 44));
|
||||||
|
Assert.Equal((ushort)12, U16(body, 46));
|
||||||
|
Assert.Equal((ushort)13, U16(body, 48));
|
||||||
|
Assert.Equal((ushort)14, U16(body, 50));
|
||||||
|
Assert.Equal((byte)1, body[52]);
|
||||||
|
Assert.True(player.TryGetOutboundPosition(out Position currentOutbound));
|
||||||
|
Assert.False(player.ShouldSendPositionEvent(
|
||||||
|
currentOutbound,
|
||||||
|
player.ContactPlane,
|
||||||
|
player.SimTimeSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ImmediateAcknowledgement_RequiresSessionPlayerAndGroundContact()
|
||||||
|
{
|
||||||
|
using var session = new WorldSession(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 9000));
|
||||||
|
var sent = new List<byte[]>();
|
||||||
|
session.GameActionCapture = sent.Add;
|
||||||
|
var outbound = new LocalPlayerOutboundController((_, _, _, _, _, _) => { });
|
||||||
|
PlayerMovementController grounded = GroundedPlayer();
|
||||||
|
|
||||||
|
outbound.SendImmediatePosition(null, grounded);
|
||||||
|
outbound.SendImmediatePosition(session, null);
|
||||||
|
outbound.SendImmediatePosition(
|
||||||
|
session,
|
||||||
|
new PlayerMovementController(new PhysicsEngine()));
|
||||||
|
|
||||||
|
Assert.Empty(sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerMovementController GroundedPlayer()
|
||||||
|
{
|
||||||
|
var engine = new PhysicsEngine();
|
||||||
|
var heights = new byte[81];
|
||||||
|
Array.Fill(heights, (byte)50);
|
||||||
|
var heightTable = new float[256];
|
||||||
|
for (int i = 0; i < heightTable.Length; i++)
|
||||||
|
heightTable[i] = i;
|
||||||
|
engine.AddLandblock(
|
||||||
|
0xA9B4FFFFu,
|
||||||
|
new TerrainSurface(heights, heightTable),
|
||||||
|
Array.Empty<CellSurface>(),
|
||||||
|
Array.Empty<PortalPlane>(),
|
||||||
|
worldOffsetX: 0f,
|
||||||
|
worldOffsetY: 0f);
|
||||||
|
var player = new PlayerMovementController(engine);
|
||||||
|
player.SetPosition(
|
||||||
|
new Vector3(96f, 97f, 50f),
|
||||||
|
0xA9B40001u,
|
||||||
|
new Vector3(96f, 97f, 50f));
|
||||||
|
Assert.True(player.CanSendPositionEvent);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint U32(byte[] body, int offset) =>
|
||||||
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(offset, 4));
|
||||||
|
|
||||||
|
private static ushort U16(byte[] body, int offset) =>
|
||||||
|
BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(offset, 2));
|
||||||
|
|
||||||
|
private static float F32(byte[] body, int offset) =>
|
||||||
|
BitConverter.Int32BitsToSingle(
|
||||||
|
BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(offset, 4)));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Physics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.World;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Physics.Motion;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Physics;
|
||||||
|
|
||||||
|
public sealed class DeferredLiveEntityMotionRuntimeBindingsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void EveryReverseEdgeFailsBeforeBind()
|
||||||
|
{
|
||||||
|
var bridge = new DeferredLiveEntityMotionRuntimeBindings();
|
||||||
|
var entity = Entity();
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
bridge.GetSetupCylinder(1, entity));
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
bridge.RouteServerMoveTo(null!, 0, default));
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
bridge.StickToObjectFromWire(null, 1));
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
bridge.ClearTargetForHiddenEntity(1));
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
bridge.ResolvePhysicsHost(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BindIsSingleAssignmentAndAllCallsReachExactOwner()
|
||||||
|
{
|
||||||
|
var bridge = new DeferredLiveEntityMotionRuntimeBindings();
|
||||||
|
var owner = new RecordingBindings();
|
||||||
|
bridge.Bind(owner);
|
||||||
|
|
||||||
|
Assert.Equal((2f, 3f), bridge.GetSetupCylinder(1, Entity()));
|
||||||
|
Assert.True(bridge.RouteServerMoveTo(null!, 4, default));
|
||||||
|
bridge.StickToObjectFromWire(null, 5);
|
||||||
|
bridge.ClearTargetForHiddenEntity(6);
|
||||||
|
Assert.Null(bridge.ResolvePhysicsHost(7));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
["cylinder:1", "moveto:4", "stick:5", "hidden:6", "host:7"],
|
||||||
|
owner.Calls);
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
bridge.Bind(new RecordingBindings()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorldEntity Entity() => new()
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
SourceGfxObjOrSetupId = 0x02000001u,
|
||||||
|
Position = Vector3.Zero,
|
||||||
|
Rotation = Quaternion.Identity,
|
||||||
|
MeshRefs = Array.Empty<MeshRef>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class RecordingBindings : ILiveEntityMotionRuntimeBindings
|
||||||
|
{
|
||||||
|
public List<string> Calls { get; } = [];
|
||||||
|
public (float Radius, float Height) GetSetupCylinder(uint guid, WorldEntity entity)
|
||||||
|
{
|
||||||
|
Calls.Add($"cylinder:{guid}");
|
||||||
|
return (2f, 3f);
|
||||||
|
}
|
||||||
|
public bool RouteServerMoveTo(
|
||||||
|
MovementManager movement,
|
||||||
|
uint cellId,
|
||||||
|
WorldSession.EntityMotionUpdate update)
|
||||||
|
{
|
||||||
|
Calls.Add($"moveto:{cellId}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
public void StickToObjectFromWire(IPhysicsObjHost? host, uint targetGuid) =>
|
||||||
|
Calls.Add($"stick:{targetGuid}");
|
||||||
|
public void ClearTargetForHiddenEntity(uint guid) => Calls.Add($"hidden:{guid}");
|
||||||
|
public IPhysicsObjHost? ResolvePhysicsHost(uint guid)
|
||||||
|
{
|
||||||
|
Calls.Add($"host:{guid}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,408 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Physics;
|
||||||
|
using AcDream.App.Streaming;
|
||||||
|
using AcDream.App.World;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Physics;
|
||||||
|
|
||||||
|
public sealed class LiveEntityInboundAuthorityGateTests
|
||||||
|
{
|
||||||
|
private const uint Guid = 0x50000021u;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Motion_StrictFreshnessAndWrapAreOwnedBeforePresentation()
|
||||||
|
{
|
||||||
|
LiveEntityRuntime runtime = Runtime();
|
||||||
|
runtime.RegisterLiveEntity(Spawn(Guid, instance: 7, movement: 0xFFFE));
|
||||||
|
var published = new List<AcceptedPhysicsTimestamps>();
|
||||||
|
var gate = new LiveEntityInboundAuthorityGate(
|
||||||
|
runtime,
|
||||||
|
(_, timestamps) => published.Add(timestamps));
|
||||||
|
|
||||||
|
Assert.False(gate.TryAcceptMotion(
|
||||||
|
Motion(instance: 7, movement: 0xFFFE, server: 1),
|
||||||
|
retainPayload: true,
|
||||||
|
out _,
|
||||||
|
out bool equalAccepted));
|
||||||
|
Assert.False(equalAccepted);
|
||||||
|
|
||||||
|
Assert.True(gate.TryAcceptMotion(
|
||||||
|
Motion(instance: 7, movement: 1, server: 1),
|
||||||
|
retainPayload: true,
|
||||||
|
out AcceptedMotionNetworkUpdate wrapped,
|
||||||
|
out bool wrapAccepted));
|
||||||
|
Assert.True(wrapAccepted);
|
||||||
|
Assert.Equal((ulong)2, wrapped.Record.MovementAuthorityVersion);
|
||||||
|
Assert.Single(published);
|
||||||
|
|
||||||
|
Assert.False(gate.TryAcceptMotion(
|
||||||
|
Motion(instance: 7, movement: 1, server: 1),
|
||||||
|
retainPayload: true,
|
||||||
|
out _,
|
||||||
|
out bool duplicateAccepted));
|
||||||
|
Assert.False(duplicateAccepted);
|
||||||
|
Assert.Single(published);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutonomousLocalMotion_ConsumesTimestampButDoesNotPublishPayload()
|
||||||
|
{
|
||||||
|
LiveEntityRuntime runtime = Runtime();
|
||||||
|
runtime.RegisterLiveEntity(Spawn(Guid, instance: 1));
|
||||||
|
int publishCount = 0;
|
||||||
|
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => publishCount++);
|
||||||
|
|
||||||
|
Assert.False(gate.TryAcceptMotion(
|
||||||
|
Motion(instance: 1, movement: 2, server: 1, autonomous: true),
|
||||||
|
retainPayload: false,
|
||||||
|
out _,
|
||||||
|
out bool timestampAccepted));
|
||||||
|
|
||||||
|
Assert.True(timestampAccepted);
|
||||||
|
Assert.Equal(1, publishCount);
|
||||||
|
Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord record));
|
||||||
|
Assert.Equal((ulong)1, record.MovementAuthorityVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vector_InvalidPayloadDoesNotConsumeSequenceAndDuplicateIsRejected()
|
||||||
|
{
|
||||||
|
LiveEntityRuntime runtime = Runtime();
|
||||||
|
runtime.RegisterLiveEntity(Spawn(Guid, instance: 2));
|
||||||
|
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
|
||||||
|
var update = new VectorUpdate.Parsed(
|
||||||
|
Guid,
|
||||||
|
new Vector3(1f, 2f, 3f),
|
||||||
|
Vector3.Zero,
|
||||||
|
InstanceSequence: 2,
|
||||||
|
VectorSequence: 2);
|
||||||
|
|
||||||
|
Assert.False(gate.TryAcceptVector(update, payloadIsValid: false, out _));
|
||||||
|
Assert.True(gate.TryAcceptVector(
|
||||||
|
update,
|
||||||
|
payloadIsValid: true,
|
||||||
|
out AcceptedVectorNetworkUpdate accepted));
|
||||||
|
Assert.Equal((ulong)2, accepted.VectorAuthorityVersion);
|
||||||
|
Assert.False(gate.TryAcceptVector(update, payloadIsValid: true, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vector_WrappedSequenceIsAccepted()
|
||||||
|
{
|
||||||
|
LiveEntityRuntime runtime = Runtime();
|
||||||
|
runtime.RegisterLiveEntity(Spawn(Guid, instance: 2, vector: 0xFFFE));
|
||||||
|
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
|
||||||
|
|
||||||
|
Assert.True(gate.TryAcceptVector(
|
||||||
|
new VectorUpdate.Parsed(
|
||||||
|
Guid,
|
||||||
|
Vector3.One,
|
||||||
|
Vector3.Zero,
|
||||||
|
InstanceSequence: 2,
|
||||||
|
VectorSequence: 1),
|
||||||
|
payloadIsValid: true,
|
||||||
|
out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void State_EqualIsRejectedAndWrappedSequenceIsAccepted()
|
||||||
|
{
|
||||||
|
LiveEntityRuntime runtime = Runtime();
|
||||||
|
runtime.RegisterLiveEntity(Spawn(Guid, instance: 3, state: 0xFFFE));
|
||||||
|
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
|
||||||
|
|
||||||
|
Assert.False(gate.TryAcceptState(
|
||||||
|
new SetState.Parsed(Guid, (uint)PhysicsStateFlags.Hidden, 3, 0xFFFE),
|
||||||
|
out _));
|
||||||
|
Assert.True(gate.TryAcceptState(
|
||||||
|
new SetState.Parsed(Guid, (uint)PhysicsStateFlags.Hidden, 3, 1),
|
||||||
|
out AcceptedStateNetworkUpdate accepted));
|
||||||
|
Assert.Equal((ulong)2, accepted.StateAuthorityVersion);
|
||||||
|
Assert.False(gate.TryAcceptState(
|
||||||
|
new SetState.Parsed(Guid, 0, 3, 1),
|
||||||
|
out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Position_UnknownLocalHintAndResetDoNotConsumeFuturePacket()
|
||||||
|
{
|
||||||
|
LiveEntityRuntime runtime = Runtime();
|
||||||
|
int publishCount = 0;
|
||||||
|
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => publishCount++);
|
||||||
|
WorldSession.EntityPositionUpdate update = Position(
|
||||||
|
Guid,
|
||||||
|
instance: 4,
|
||||||
|
position: 2,
|
||||||
|
cell: 0xAABB0001u);
|
||||||
|
|
||||||
|
Assert.False(gate.TryAcceptPosition(
|
||||||
|
update,
|
||||||
|
Guid,
|
||||||
|
Quaternion.Identity,
|
||||||
|
Vector3.Zero,
|
||||||
|
payloadIsValid: true,
|
||||||
|
out _));
|
||||||
|
Assert.Equal(0xAABB0001u, gate.LastLivePlayerLandblockId);
|
||||||
|
Assert.Equal(0, publishCount);
|
||||||
|
|
||||||
|
runtime.RegisterLiveEntity(Spawn(Guid, instance: 4, position: 1));
|
||||||
|
Assert.True(gate.TryAcceptPosition(
|
||||||
|
update,
|
||||||
|
Guid,
|
||||||
|
Quaternion.Identity,
|
||||||
|
Vector3.Zero,
|
||||||
|
payloadIsValid: true,
|
||||||
|
out AcceptedPositionNetworkUpdate accepted));
|
||||||
|
Assert.Equal(PositionTimestampDisposition.Apply, accepted.TimestampDisposition);
|
||||||
|
Assert.Equal((ulong)2, accepted.PositionAuthorityVersion);
|
||||||
|
Assert.Equal(1, publishCount);
|
||||||
|
|
||||||
|
gate.ResetSessionState();
|
||||||
|
Assert.Null(gate.LastLivePlayerLandblockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Position_InvalidPayloadDoesNotConsumeSequence()
|
||||||
|
{
|
||||||
|
LiveEntityRuntime runtime = Runtime();
|
||||||
|
runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, position: 1));
|
||||||
|
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
|
||||||
|
WorldSession.EntityPositionUpdate update = Position(Guid, 5, 2, 0x01010001u);
|
||||||
|
|
||||||
|
Assert.False(gate.TryAcceptPosition(
|
||||||
|
update, Guid, Quaternion.Identity, Vector3.Zero, false, out _));
|
||||||
|
Assert.True(gate.TryAcceptPosition(
|
||||||
|
update, Guid, Quaternion.Identity, Vector3.Zero, true, out _));
|
||||||
|
Assert.False(gate.TryAcceptPosition(
|
||||||
|
update, Guid, Quaternion.Identity, Vector3.Zero, true, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Position_WrappedSequenceIsAccepted()
|
||||||
|
{
|
||||||
|
LiveEntityRuntime runtime = Runtime();
|
||||||
|
runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, position: 0xFFFE));
|
||||||
|
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
|
||||||
|
|
||||||
|
Assert.True(gate.TryAcceptPosition(
|
||||||
|
Position(Guid, 5, 1, 0x01010002u),
|
||||||
|
Guid,
|
||||||
|
Quaternion.Identity,
|
||||||
|
Vector3.Zero,
|
||||||
|
payloadIsValid: true,
|
||||||
|
out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vector_NewerSameIncarnationPacketInvalidatesCapturedAuthority()
|
||||||
|
{
|
||||||
|
LiveEntityRuntime runtime = Runtime();
|
||||||
|
runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, vector: 1));
|
||||||
|
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
|
||||||
|
Assert.True(gate.TryAcceptVector(
|
||||||
|
new VectorUpdate.Parsed(Guid, Vector3.One, Vector3.Zero, 5, 2),
|
||||||
|
payloadIsValid: true,
|
||||||
|
out AcceptedVectorNetworkUpdate accepted));
|
||||||
|
|
||||||
|
Assert.True(runtime.TryApplyVector(
|
||||||
|
new VectorUpdate.Parsed(Guid, Vector3.UnitX, Vector3.Zero, 5, 3),
|
||||||
|
out _));
|
||||||
|
Assert.False(runtime.IsCurrentVectorAuthority(
|
||||||
|
accepted.Record,
|
||||||
|
accepted.VectorAuthorityVersion));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void State_NewerSameIncarnationPacketInvalidatesCapturedAuthority()
|
||||||
|
{
|
||||||
|
LiveEntityRuntime runtime = Runtime();
|
||||||
|
runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, state: 1));
|
||||||
|
var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
|
||||||
|
Assert.True(gate.TryAcceptState(
|
||||||
|
new SetState.Parsed(Guid, (uint)PhysicsStateFlags.Hidden, 5, 2),
|
||||||
|
out AcceptedStateNetworkUpdate accepted));
|
||||||
|
|
||||||
|
Assert.True(runtime.TryApplyState(
|
||||||
|
new SetState.Parsed(Guid, 0, 5, 3),
|
||||||
|
out _));
|
||||||
|
Assert.False(runtime.IsCurrentStateAuthority(
|
||||||
|
accepted.Record,
|
||||||
|
accepted.StateAuthorityVersion));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Motion_PublisherReplacementInvalidatesCapturedIncarnation()
|
||||||
|
{
|
||||||
|
LiveEntityRuntime runtime = Runtime();
|
||||||
|
runtime.RegisterLiveEntity(Spawn(Guid, instance: 6));
|
||||||
|
var gate = new LiveEntityInboundAuthorityGate(
|
||||||
|
runtime,
|
||||||
|
(_, _) => runtime.RegisterLiveEntity(Spawn(Guid, instance: 7)));
|
||||||
|
|
||||||
|
Assert.False(gate.TryAcceptMotion(
|
||||||
|
Motion(instance: 6, movement: 2, server: 1),
|
||||||
|
retainPayload: true,
|
||||||
|
out _,
|
||||||
|
out bool timestampAccepted));
|
||||||
|
Assert.True(timestampAccepted);
|
||||||
|
Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord replacement));
|
||||||
|
Assert.Equal((ushort)7, replacement.Generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Position_PublisherNewerChannelInvalidatesCapturedAuthority()
|
||||||
|
{
|
||||||
|
LiveEntityRuntime runtime = Runtime();
|
||||||
|
runtime.RegisterLiveEntity(Spawn(Guid, instance: 8, position: 1));
|
||||||
|
var nested = Position(Guid, 8, 3, 0x01010002u);
|
||||||
|
LiveEntityInboundAuthorityGate? gate = null;
|
||||||
|
gate = new LiveEntityInboundAuthorityGate(
|
||||||
|
runtime,
|
||||||
|
(_, _) => runtime.TryApplyPosition(
|
||||||
|
nested,
|
||||||
|
isLocalPlayer: true,
|
||||||
|
forcePositionRotation: Quaternion.Identity,
|
||||||
|
currentLocalVelocity: Vector3.Zero,
|
||||||
|
out _,
|
||||||
|
out _,
|
||||||
|
out _));
|
||||||
|
|
||||||
|
Assert.False(gate.TryAcceptPosition(
|
||||||
|
Position(Guid, 8, 2, 0x01010001u),
|
||||||
|
Guid,
|
||||||
|
Quaternion.Identity,
|
||||||
|
Vector3.Zero,
|
||||||
|
payloadIsValid: true,
|
||||||
|
out _));
|
||||||
|
Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord record));
|
||||||
|
Assert.Equal((ulong)3, record.PositionAuthorityVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Position_PublisherNewerVectorPreservesIndependentPositionAuthority()
|
||||||
|
{
|
||||||
|
LiveEntityRuntime runtime = Runtime();
|
||||||
|
runtime.RegisterLiveEntity(Spawn(Guid, instance: 9, position: 1, vector: 1));
|
||||||
|
var gate = new LiveEntityInboundAuthorityGate(
|
||||||
|
runtime,
|
||||||
|
(_, _) => runtime.TryApplyVector(
|
||||||
|
new VectorUpdate.Parsed(
|
||||||
|
Guid,
|
||||||
|
Vector3.UnitX,
|
||||||
|
Vector3.Zero,
|
||||||
|
InstanceSequence: 9,
|
||||||
|
VectorSequence: 2),
|
||||||
|
out _));
|
||||||
|
|
||||||
|
Assert.True(gate.TryAcceptPosition(
|
||||||
|
Position(Guid, 9, 2, 0x01010003u),
|
||||||
|
Guid,
|
||||||
|
Quaternion.Identity,
|
||||||
|
Vector3.Zero,
|
||||||
|
payloadIsValid: true,
|
||||||
|
out AcceptedPositionNetworkUpdate accepted));
|
||||||
|
Assert.True(runtime.IsCurrentPositionAuthority(
|
||||||
|
accepted.Record,
|
||||||
|
accepted.PositionAuthorityVersion));
|
||||||
|
Assert.False(runtime.IsCurrentVelocityAuthority(
|
||||||
|
accepted.Record,
|
||||||
|
accepted.VelocityAuthorityVersion));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LiveEntityRuntime Runtime() => new(
|
||||||
|
new GpuWorldState(),
|
||||||
|
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
|
||||||
|
|
||||||
|
private static WorldSession.EntityMotionUpdate Motion(
|
||||||
|
ushort instance,
|
||||||
|
ushort movement,
|
||||||
|
ushort server,
|
||||||
|
bool autonomous = false) => new(
|
||||||
|
Guid,
|
||||||
|
default,
|
||||||
|
instance,
|
||||||
|
movement,
|
||||||
|
server,
|
||||||
|
autonomous);
|
||||||
|
|
||||||
|
private static WorldSession.EntityPositionUpdate Position(
|
||||||
|
uint guid,
|
||||||
|
ushort instance,
|
||||||
|
ushort position,
|
||||||
|
uint cell) => new(
|
||||||
|
guid,
|
||||||
|
new CreateObject.ServerPosition(
|
||||||
|
cell, 10f, 11f, 12f, 1f, 0f, 0f, 0f),
|
||||||
|
Velocity: null,
|
||||||
|
PlacementId: null,
|
||||||
|
IsGrounded: true,
|
||||||
|
InstanceSequence: instance,
|
||||||
|
PositionSequence: position,
|
||||||
|
TeleportSequence: 0,
|
||||||
|
ForcePositionSequence: 0);
|
||||||
|
|
||||||
|
private static WorldSession.EntitySpawn Spawn(
|
||||||
|
uint guid,
|
||||||
|
ushort instance,
|
||||||
|
ushort position = 1,
|
||||||
|
ushort movement = 1,
|
||||||
|
ushort state = 1,
|
||||||
|
ushort vector = 1)
|
||||||
|
{
|
||||||
|
var serverPosition = new CreateObject.ServerPosition(
|
||||||
|
0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
||||||
|
var timestamps = new PhysicsTimestamps(
|
||||||
|
position,
|
||||||
|
movement,
|
||||||
|
state,
|
||||||
|
vector,
|
||||||
|
Teleport: 0,
|
||||||
|
ServerControlledMove: 1,
|
||||||
|
ForcePosition: 0,
|
||||||
|
ObjDesc: 1,
|
||||||
|
Instance: instance);
|
||||||
|
var physics = new PhysicsSpawnData(
|
||||||
|
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||||
|
Position: serverPosition,
|
||||||
|
Movement: null,
|
||||||
|
AnimationFrame: null,
|
||||||
|
SetupTableId: 0x02000001u,
|
||||||
|
MotionTableId: 0x09000001u,
|
||||||
|
SoundTableId: null,
|
||||||
|
PhysicsScriptTableId: null,
|
||||||
|
Parent: null,
|
||||||
|
Children: null,
|
||||||
|
Scale: null,
|
||||||
|
Friction: null,
|
||||||
|
Elasticity: null,
|
||||||
|
Translucency: null,
|
||||||
|
Velocity: null,
|
||||||
|
Acceleration: null,
|
||||||
|
AngularVelocity: null,
|
||||||
|
DefaultScriptType: null,
|
||||||
|
DefaultScriptIntensity: null,
|
||||||
|
Timestamps: timestamps);
|
||||||
|
return new WorldSession.EntitySpawn(
|
||||||
|
guid,
|
||||||
|
serverPosition,
|
||||||
|
0x02000001u,
|
||||||
|
Array.Empty<CreateObject.AnimPartChange>(),
|
||||||
|
Array.Empty<CreateObject.TextureChange>(),
|
||||||
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"fixture",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
0x09000001u,
|
||||||
|
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||||
|
InstanceSequence: instance,
|
||||||
|
MovementSequence: movement,
|
||||||
|
ServerControlSequence: 1,
|
||||||
|
PositionSequence: position,
|
||||||
|
Physics: physics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
using AcDream.App.Physics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Physics;
|
||||||
|
|
||||||
|
public sealed class LiveEntityNetworkBranchRoutingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Vector_ProjectileStopsCanonicalAndOrdinaryRoutes()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
|
||||||
|
LiveEntityVectorRoute route = LiveEntityVectorRouter.Route(
|
||||||
|
() => { calls.Add("projectile"); return true; },
|
||||||
|
() => { calls.Add("canonical"); return true; },
|
||||||
|
() => calls.Add("ordinary"));
|
||||||
|
|
||||||
|
Assert.Equal(LiveEntityVectorRoute.Projectile, route);
|
||||||
|
Assert.Equal(["projectile"], calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vector_CanonicalBodyRunsOnlyAfterProjectileDeclines()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
|
||||||
|
LiveEntityVectorRoute route = LiveEntityVectorRouter.Route(
|
||||||
|
() => { calls.Add("projectile"); return false; },
|
||||||
|
() => { calls.Add("canonical"); return true; },
|
||||||
|
() => calls.Add("ordinary"));
|
||||||
|
|
||||||
|
Assert.Equal(LiveEntityVectorRoute.CanonicalBody, route);
|
||||||
|
Assert.Equal(["projectile", "canonical"], calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vector_OrdinaryRemoteIsTheLastFallback()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
|
||||||
|
LiveEntityVectorRoute route = LiveEntityVectorRouter.Route(
|
||||||
|
() => { calls.Add("projectile"); return false; },
|
||||||
|
() => { calls.Add("canonical"); return false; },
|
||||||
|
() => calls.Add("ordinary"));
|
||||||
|
|
||||||
|
Assert.Equal(LiveEntityVectorRoute.OrdinaryRemote, route);
|
||||||
|
Assert.Equal(["projectile", "canonical", "ordinary"], calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForcePosition_BlipsAndAcknowledgesExactlyOnce()
|
||||||
|
{
|
||||||
|
int currentChecks = 0;
|
||||||
|
int blips = 0;
|
||||||
|
int acknowledgements = 0;
|
||||||
|
|
||||||
|
bool completed = LocalForcePositionTransaction.Apply(
|
||||||
|
isForcePosition: true,
|
||||||
|
() => { currentChecks++; return true; },
|
||||||
|
() => blips++,
|
||||||
|
() => acknowledgements++);
|
||||||
|
|
||||||
|
Assert.True(completed);
|
||||||
|
Assert.Equal(2, currentChecks);
|
||||||
|
Assert.Equal(1, blips);
|
||||||
|
Assert.Equal(1, acknowledgements);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForcePosition_AcknowledgementInvalidationStopsTheTail()
|
||||||
|
{
|
||||||
|
bool current = true;
|
||||||
|
int acknowledgements = 0;
|
||||||
|
|
||||||
|
bool completed = LocalForcePositionTransaction.Apply(
|
||||||
|
isForcePosition: true,
|
||||||
|
() => current,
|
||||||
|
() => { },
|
||||||
|
() => { acknowledgements++; current = false; });
|
||||||
|
|
||||||
|
Assert.False(completed);
|
||||||
|
Assert.Equal(1, acknowledgements);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OrdinaryPositionDoesNotBlipOrAcknowledge()
|
||||||
|
{
|
||||||
|
int calls = 0;
|
||||||
|
|
||||||
|
Assert.True(LocalForcePositionTransaction.Apply(
|
||||||
|
isForcePosition: false,
|
||||||
|
() => { calls++; return false; },
|
||||||
|
() => calls++,
|
||||||
|
() => calls++));
|
||||||
|
Assert.Equal(0, calls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,23 @@ public sealed class GameWindowLiveEntityCompositionTests
|
||||||
[InlineData("OnLiveEntityPruned")]
|
[InlineData("OnLiveEntityPruned")]
|
||||||
[InlineData("TearDownLiveEntityRuntimeComponents")]
|
[InlineData("TearDownLiveEntityRuntimeComponents")]
|
||||||
[InlineData("CreateLiveEntityRuntimeTeardownPlan")]
|
[InlineData("CreateLiveEntityRuntimeTeardownPlan")]
|
||||||
|
[InlineData("RouteSameGenerationCreateObject")]
|
||||||
|
[InlineData("OnLiveMotionUpdated")]
|
||||||
|
[InlineData("OnLivePositionUpdated")]
|
||||||
|
[InlineData("OnLiveVectorUpdated")]
|
||||||
|
[InlineData("OnLiveStateUpdated")]
|
||||||
|
[InlineData("EnsureRemoteMotionBindings")]
|
||||||
|
[InlineData("ResolvePhysicsHost")]
|
||||||
|
[InlineData("GetSetupCylinder")]
|
||||||
|
[InlineData("RouteServerMoveTo")]
|
||||||
|
[InlineData("StickToObjectFromWire")]
|
||||||
|
[InlineData("ClearTargetForHiddenEntity")]
|
||||||
|
[InlineData("ApplyServerControlledVelocityCycle")]
|
||||||
|
[InlineData("RunRemoteTeleportHook")]
|
||||||
|
[InlineData("SendImmediateLocalPositionEvent")]
|
||||||
|
[InlineData("DispatchRemoteInboundMotion")]
|
||||||
|
[InlineData("CreateRemoteMotion")]
|
||||||
|
[InlineData("WillAdvanceRemoteMotion")]
|
||||||
public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName)
|
public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName)
|
||||||
{
|
{
|
||||||
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation));
|
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation));
|
||||||
|
|
@ -52,6 +69,10 @@ public sealed class GameWindowLiveEntityCompositionTests
|
||||||
[InlineData(typeof(LiveEntityHydrationController))]
|
[InlineData(typeof(LiveEntityHydrationController))]
|
||||||
[InlineData(typeof(DatLiveEntityProjectionMaterializer))]
|
[InlineData(typeof(DatLiveEntityProjectionMaterializer))]
|
||||||
[InlineData(typeof(LiveEntityRuntimeTeardownController))]
|
[InlineData(typeof(LiveEntityRuntimeTeardownController))]
|
||||||
|
[InlineData(typeof(LiveEntityNetworkUpdateController))]
|
||||||
|
[InlineData(typeof(LiveEntityMotionRuntimeController))]
|
||||||
|
[InlineData(typeof(LiveEntityInboundAuthorityGate))]
|
||||||
|
[InlineData(typeof(DeferredLiveEntityMotionRuntimeBindings))]
|
||||||
public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType)
|
public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType)
|
||||||
{
|
{
|
||||||
foreach (FieldInfo field in helperType.GetFields(
|
foreach (FieldInfo field in helperType.GetFields(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
using AcDream.App.World;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.World;
|
||||||
|
|
||||||
|
public sealed class LiveEntitySameGenerationUpdateRouterTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, "parent")]
|
||||||
|
[InlineData(1, "position")]
|
||||||
|
[InlineData(2, "pickup")]
|
||||||
|
[InlineData(3, null)]
|
||||||
|
public void Apply_PreservesRetailTailOrderAndExclusiveSpatialBranch(
|
||||||
|
int spatialKind,
|
||||||
|
string? expectedSpatial)
|
||||||
|
{
|
||||||
|
var sink = new RecordingSink();
|
||||||
|
SameGenerationCreateObjectEvents refresh = new(
|
||||||
|
Description: default,
|
||||||
|
Appearance: default,
|
||||||
|
Parent: spatialKind == 0 ? default(CreateParentUpdate?) : null,
|
||||||
|
Position: spatialKind == 1
|
||||||
|
? default(WorldSession.EntityPositionUpdate?)
|
||||||
|
: null,
|
||||||
|
Pickup: spatialKind == 2 ? default(PickupEvent.Parsed?) : null,
|
||||||
|
Movement: default(WorldSession.EntityMotionUpdate?),
|
||||||
|
State: default,
|
||||||
|
Vector: default);
|
||||||
|
|
||||||
|
// Nullable default(T?) has no value; explicitly wrap the selected
|
||||||
|
// zero-valued fixture so the branch itself remains observable.
|
||||||
|
refresh = refresh with
|
||||||
|
{
|
||||||
|
Parent = spatialKind == 0
|
||||||
|
? (CreateParentUpdate?)new CreateParentUpdate()
|
||||||
|
: null,
|
||||||
|
Position = spatialKind == 1
|
||||||
|
? (WorldSession.EntityPositionUpdate?)new WorldSession.EntityPositionUpdate()
|
||||||
|
: null,
|
||||||
|
Pickup = spatialKind == 2
|
||||||
|
? (PickupEvent.Parsed?)new PickupEvent.Parsed()
|
||||||
|
: null,
|
||||||
|
Movement = new WorldSession.EntityMotionUpdate(),
|
||||||
|
};
|
||||||
|
|
||||||
|
LiveEntitySameGenerationUpdateRouter.Apply(refresh, sink);
|
||||||
|
|
||||||
|
var expected = new List<string> { "description", "appearance" };
|
||||||
|
if (expectedSpatial is not null)
|
||||||
|
expected.Add(expectedSpatial);
|
||||||
|
expected.AddRange(["movement", "state", "vector"]);
|
||||||
|
Assert.Equal(expected, sink.Events);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParentTakesPrecedenceWhenMalformedFixtureOffersAllSpatialForms()
|
||||||
|
{
|
||||||
|
var sink = new RecordingSink();
|
||||||
|
var refresh = new SameGenerationCreateObjectEvents(
|
||||||
|
default,
|
||||||
|
default,
|
||||||
|
new CreateParentUpdate(),
|
||||||
|
new WorldSession.EntityPositionUpdate(),
|
||||||
|
new PickupEvent.Parsed(),
|
||||||
|
null,
|
||||||
|
default,
|
||||||
|
default);
|
||||||
|
|
||||||
|
LiveEntitySameGenerationUpdateRouter.Apply(refresh, sink);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
["description", "appearance", "parent", "state", "vector"],
|
||||||
|
sink.Events);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingSink : ILiveEntitySameGenerationUpdateSink
|
||||||
|
{
|
||||||
|
public List<string> Events { get; } = [];
|
||||||
|
public void OnDescription(uint ownerGuid, PhysicsSpawnData description) =>
|
||||||
|
Events.Add("description");
|
||||||
|
public void OnAppearance(ObjDescEvent.Parsed appearance) =>
|
||||||
|
Events.Add("appearance");
|
||||||
|
public void OnParent(CreateParentUpdate parent) => Events.Add("parent");
|
||||||
|
public void OnPosition(WorldSession.EntityPositionUpdate position) =>
|
||||||
|
Events.Add("position");
|
||||||
|
public void OnPickup(PickupEvent.Parsed pickup) => Events.Add("pickup");
|
||||||
|
public void OnMovement(WorldSession.EntityMotionUpdate movement) =>
|
||||||
|
Events.Add("movement");
|
||||||
|
public void OnState(SetState.Parsed state) => Events.Add("state");
|
||||||
|
public void OnVector(VectorUpdate.Parsed vector) => Events.Add("vector");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue