fix(world): restore portal and distant use lifecycles
This commit is contained in:
parent
c730632075
commit
1a14812c44
18 changed files with 401 additions and 196 deletions
|
|
@ -89,6 +89,30 @@ Copy this block when adding a new issue:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## #245 — Distant Use stayed busy without turning or walking
|
||||||
|
|
||||||
|
**Status:** DONE — 2026-07-25; connected gate passed
|
||||||
|
**Severity:** HIGH
|
||||||
|
**Filed:** 2026-07-25
|
||||||
|
**Component:** interaction transport / local motion completion ownership
|
||||||
|
|
||||||
|
**Root cause / resolution:** Retail sends Use before any approach and ACE
|
||||||
|
authoritatively returns the required MoveTo chain. After restoring that order,
|
||||||
|
the live trace showed ACE's MoveTo target resolved correctly but remained
|
||||||
|
behind one orphan login Ready node. `LiveEntityAnimationPresenter` had guarded
|
||||||
|
`MotionDoneTarget` installation by the temporary spatial projection, even
|
||||||
|
though the exact logical live-record animation runtime still owned the
|
||||||
|
PartArray. The PartArray consumed Ready without popping the
|
||||||
|
`MotionInterpreter`. Completion is now bound to the logical animation owner,
|
||||||
|
and player-controller construction drains any pre-controller PartArray queue
|
||||||
|
before publishing its interpreter. Focused tests cover withdrawn spatial
|
||||||
|
projection, replacement owners, immediate one-shot Use, and busy transfer to
|
||||||
|
UseDone. The connected distant-NPC gate turns, walks, uses, and releases busy.
|
||||||
|
|
||||||
|
**Research:** `docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## #234 — Monster assessment held busy and examination used the shared panel
|
## #234 — Monster assessment held busy and examination used the shared panel
|
||||||
|
|
||||||
**Status:** DONE — 2026-07-23; corrected creature presentation visual gate pending
|
**Status:** DONE — 2026-07-23; corrected creature presentation visual gate pending
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| 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 content-deduplicated and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.Core/Rendering/Wb/EnvCellGeometryIdentity.cs` | Phase A8 retained WB's 31× hash; the 2026-07-24 full-DAT gate proved a real collision (`0x00030175`/`0x01BC0105`), so App+Bake now share a namespaced FNV-1a tuple identity and the bake rejects any full-tuple collision | A future collision outside the installed full-DAT gate could still merge different shells at runtime; the stronger 59-bit payload makes this extremely unlikely, and every bake fails loudly rather than publishing it | retail `PView::DrawCells` → per-cell drawing_bsp (cited at the former renderer `:319`) |
|
| AD-24 | EnvCell shell geometry content-deduplicated and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.Core/Rendering/Wb/EnvCellGeometryIdentity.cs` | Phase A8 retained WB's 31× hash; the 2026-07-24 full-DAT gate proved a real collision (`0x00030175`/`0x01BC0105`), so App+Bake now share a namespaced FNV-1a tuple identity and the bake rejects any full-tuple collision | A future collision outside the installed full-DAT gate could still merge different shells at runtime; the stronger 59-bit payload makes this extremely unlikely, and every bake fails loudly rather than publishing it | retail `PView::DrawCells` → per-cell drawing_bsp (cited at the former renderer `: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/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-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 | PickUp fires 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). The companion `MoveToCancelled` seam only withdraws the waiting pickup presentation/action. **Use retired 2026-07-25:** `ItemHolder::UseObject` sends `Event_UseEvent` before `CPlayerSystem::UsingItem`; acdream now does the same and leaves approach to ACE's authoritative MoveToChain. | `src/AcDream.App/Interaction/SelectionInteractionController.cs` (`OnNaturalMoveToComplete`/`OnMoveToCancelled`); `src/AcDream.App/Input/PlayerModeController.cs` (player MoveTo seam binding); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete`/`MoveToCancelled`) | ACE's server-side pickup chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. | If the server's chain has not timed out, pickup may execute twice or produce protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call); `ItemHolder::UseObject` 0x00588A80 |
|
||||||
| 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` |
|
||||||
| AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path |
|
| AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path |
|
||||||
|
|
@ -134,7 +134,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
||||||
| AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |
|
| AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |
|
||||||
| AP-21 | Entity translucency retains the invented α<0.05 fragment discard. World GfxObj/Setup instances now apply their DAT AlphaBlend/Additive/InvAlpha factors through the retail shared alpha queue, but sealed off-screen WbDrawDispatcher consumers (paperdoll/UI Studio) retain the old immediate normal-alpha pass for all three kinds | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`DrawDeferredAlphaBatch` versus immediate Phase 8) | World presentation needed exact per-surface blend for spell/particle density and translucent-object intersections. Off-screen object previews are isolated render targets and have not shown an authored additive entity surface that justifies splitting their compact immediate pass | A faint world fringe below 5% alpha is discarded; a hypothetical additive/inverse-alpha paperdoll or UI Studio entity composites darker than retail inside that private viewport | `D3DPolyRender::SetSurface`; `D3DPolyRender::RenderMeshSubset`; SurfaceType.Additive → D3DBLEND_ONE |
|
| AP-21 | Entity translucency retains the invented α<0.05 fragment discard. World GfxObj/Setup instances now apply their DAT AlphaBlend/Additive/InvAlpha factors through the retail shared alpha queue, but sealed off-screen WbDrawDispatcher consumers (paperdoll/UI Studio) retain the old immediate normal-alpha pass for all three kinds | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`DrawDeferredAlphaBatch` versus immediate Phase 8) | World presentation needed exact per-surface blend for spell/particle density and translucent-object intersections. Off-screen object previews are isolated render targets and have not shown an authored additive entity surface that justifies splitting their compact immediate pass | A faint world fringe below 5% alpha is discarded; a hypothetical additive/inverse-alpha paperdoll or UI Studio entity composites darker than retail inside that private viewport | `D3DPolyRender::SetSurface`; `D3DPolyRender::RenderMeshSubset`; SurfaceType.Additive → D3DBLEND_ONE |
|
||||||
| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 |
|
| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 |
|
||||||
| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`TryGetApproach`/`GetUseRadius`); `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs` (`BeginApproach`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b |
|
| AP-23 | Invented per-type pickup-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating plus the speculative local TurnToObject/MoveToObject install through the player's MoveToManager. **R5-V3 narrowed it:** the install threads the target's real Setup radius/height (`GetSetupCylinder`, same as wire mt-6) and the player's real radius; only the radius buckets remain invented. **Use retired from this seam 2026-07-25** and now sends immediately. | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`TryGetApproach`/`GetUseRadius`); `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs` (`BeginApproach`) | The retained pickup presentation reserves a destination slot before the authoritative transfer; its close branch still needs an arrival boundary | A target whose real UseRadius differs from the bucket misjudges the pickup gate — pickup waits forever or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b |
|
||||||
| ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.App/Input/PlayerMovementController.cs`; `src/AcDream.App/Combat/CombatAttackController.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` |
|
| ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.App/Input/PlayerMovementController.cs`; `src/AcDream.App/Combat/CombatAttackController.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` |
|
||||||
| AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) |
|
| AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) |
|
||||||
| AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 |
|
| AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 |
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,14 @@ cursor owners. Its connected state gate passed. A named-retail correction now
|
||||||
treats reset/absent useability zero as usable. The connected gate then exposed
|
treats reset/absent useability zero as usable. The connected gate then exposed
|
||||||
the delivery defect beneath that classifier: carried Use had been routed
|
the delivery defect beneath that classifier: carried Use had been routed
|
||||||
through the world-object approach query and silently cancelled because
|
through the world-object approach query and silently cancelled because
|
||||||
inventory objects have no spatial entity. Owned direct-use now sends
|
inventory objects have no spatial entity. Ordinary Use now sends immediately
|
||||||
immediately, while the registered AD-27 approach seam remains world-only;
|
for both owned and world objects, matching `ItemHolder::UseObject`; ACE owns
|
||||||
shared AutoWear reports the exact overlapping worn item that must be removed.
|
distant approach and AD-27 remains pickup-only. The connected distant-NPC
|
||||||
That correction is user-confirmed. The requested adjacent cooldown follow-up
|
gate also corrected the logical animation-owner callback across temporary
|
||||||
|
spatial projection withdrawal, so ACE MoveTo turns, walks, and completes Use
|
||||||
|
without leaving the UI busy. Shared AutoWear reports the exact overlapping
|
||||||
|
worn item that must be removed. These corrections are user-confirmed. The
|
||||||
|
requested adjacent cooldown follow-up
|
||||||
ports the optional public shared-cooldown metadata, the authoritative
|
ports the optional public shared-cooldown metadata, the authoritative
|
||||||
cooldown-enchantment lookup, the exact ten-step bucket formula, and DAT
|
cooldown-enchantment lookup, the exact ten-step bucket formula, and DAT
|
||||||
sprites `0x060067CF..0x060067D8` through the shared retained `UiItemSlot`
|
sprites `0x060067CF..0x060067D8` through the shared retained `UiItemSlot`
|
||||||
|
|
|
||||||
|
|
@ -86,10 +86,11 @@ The connected selection-state gate passed. Its follow-up exposed three deeper
|
||||||
shared-policy/delivery defects, now corrected from named retail plus the
|
shared-policy/delivery defects, now corrected from named retail plus the
|
||||||
matching binary. `ItemUses::IsUseable @ 0x004FCCC0` tests only `USEABLE_NO`;
|
matching binary. `ItemUses::IsUseable @ 0x004FCCC0` tests only `USEABLE_NO`;
|
||||||
reset/absent value zero is usable. `ItemHolder::UseObject @ 0x00588A80` sends a
|
reset/absent value zero is usable. `ItemHolder::UseObject @ 0x00588A80` sends a
|
||||||
carried direct-use item immediately; acdream had incorrectly routed that
|
Use event immediately for both owned and world objects; acdream had
|
||||||
packet through a world approach lookup, where Blackmoor's Favor has no spatial
|
incorrectly routed the packet through a local approach lookup, where
|
||||||
entity and was silently cancelled. Owned Use now bypasses that query while
|
Blackmoor's Favor has no spatial entity and was silently cancelled. Every
|
||||||
world-object Use retains the registered AD-27 approach seam. AutoWear applies
|
ordinary Use now sends once and ACE owns any authoritative MoveTo chain.
|
||||||
|
AD-27 remains only for client-side pickup completion. AutoWear applies
|
||||||
`CPlayerSystem::AutoWearIsLegal @ 0x0055EF40` through the same double-click and
|
`CPlayerSystem::AutoWearIsLegal @ 0x0055EF40` through the same double-click and
|
||||||
toolbar-hand path, resolves the overlapping worn object from the
|
toolbar-hand path, resolves the overlapping worn object from the
|
||||||
retail-ordered equipment projection, and emits the exact system line
|
retail-ordered equipment projection, and emits the exact system line
|
||||||
|
|
|
||||||
|
|
@ -446,6 +446,57 @@ state through `CPhysicsObj::set_state` `0x00514DD0`. Separately,
|
||||||
`CPhysicsObj::UpdatePositionInternal` `0x00512C30` skips `CPartArray::Update`
|
`CPhysicsObj::UpdatePositionInternal` `0x00512C30` skips `CPartArray::Update`
|
||||||
while Hidden, so ordinary animation never advances behind the portal viewport.
|
while Hidden, so ordinary animation never advances behind the portal viewport.
|
||||||
|
|
||||||
|
### Destination simulation resumes before the portal viewport exits
|
||||||
|
|
||||||
|
The retail destination has two different release edges. They must not share
|
||||||
|
one acdream gate:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SmartBox::DoSetState(player, visible):
|
||||||
|
set_state(...)
|
||||||
|
waiting_for_teleport = false
|
||||||
|
|
||||||
|
SmartBox::UseTime(next frame):
|
||||||
|
if not cell_manager.blocking_for_cells:
|
||||||
|
position_update_complete = true
|
||||||
|
CObjectMaint::UseTime()
|
||||||
|
CPhysics::UseTime()
|
||||||
|
|
||||||
|
gmSmartBoxUI::UseTime:
|
||||||
|
if SmartBox::teleport_in_progress() changed from true to false:
|
||||||
|
EndTeleportAnimation()
|
||||||
|
|
||||||
|
gmSmartBoxUI::EndTeleportAnimation @ 0x004D65A0:
|
||||||
|
teleportAnimState = TAS_TUNNEL_CONTINUE
|
||||||
|
teleportTransitionStartTime = Timer::cur_time
|
||||||
|
clear temporary FOV override
|
||||||
|
|
||||||
|
gmSmartBoxUI::UseTime, at least two seconds later:
|
||||||
|
TAS_TUNNEL_CONTINUE -> TAS_TUNNEL_FADE_OUT
|
||||||
|
...
|
||||||
|
TAS_TUNNEL_FADE_OUT -> TAS_WORLD_FADE_IN
|
||||||
|
hide portal viewport
|
||||||
|
show SmartBox world viewport
|
||||||
|
```
|
||||||
|
|
||||||
|
Therefore `TAS_TUNNEL_CONTINUE` is not a continued cell-loading freeze. The
|
||||||
|
destination world is already ticking behind the private portal viewport for at
|
||||||
|
least two seconds. That hidden interval is when the player pose, queued
|
||||||
|
Hidden/UnHide PhysicsScripts, attached emitters, and destination presentation
|
||||||
|
settle. Only the later `TUNNEL_FADE_OUT -> WORLD_FADE_IN` edge changes which
|
||||||
|
viewport is drawn.
|
||||||
|
|
||||||
|
Holding acdream's world-generation quiescence until the viewport swap is a
|
||||||
|
behavioral error: the first destination physics/effect tick occurs on the
|
||||||
|
first visible world frame, producing a character pop plus leftover recall or
|
||||||
|
purple materialization effects. The faithful split is:
|
||||||
|
|
||||||
|
- end world simulation/audio quiescence when the accepted destination is
|
||||||
|
materialized and `TunnelContinue` begins;
|
||||||
|
- retain the destination streaming/resource reservation and keep drawing only
|
||||||
|
the portal viewport until the later viewport-swap edge;
|
||||||
|
- keep LoginComplete on the final one-second `WorldFadeIn -> Off` edge.
|
||||||
|
|
||||||
ACE schedules lifestone recall teleport after the MotionTable-reported action
|
ACE schedules lifestone recall teleport after the MotionTable-reported action
|
||||||
length. With the installed human DATs, the boundary is about 15.06024 seconds;
|
length. With the installed human DATs, the boundary is about 15.06024 seconds;
|
||||||
floating-point conversion can leave acdream at frame 149.999998 of the
|
floating-point conversion can leave acdream at frame 149.999998 of the
|
||||||
|
|
|
||||||
|
|
@ -55,11 +55,11 @@ both pass `IsUseable`.
|
||||||
|
|
||||||
For a direct-use item, `ItemHolder::UseObject` then sends
|
For a direct-use item, `ItemHolder::UseObject` then sends
|
||||||
`CM_Inventory::Event_UseEvent`, increments the UI busy count, and calls
|
`CM_Inventory::Event_UseEvent`, increments the UI busy count, and calls
|
||||||
`CPlayerSystem::UsingItem`. That latter presentation function handles local
|
`CPlayerSystem::UsingItem`. `Event_UseEvent` itself is the network send
|
||||||
pickup/wield/container state; it does not require a carried source to have a
|
(`0xF7B1/0x36`); the latter function handles local pickup/wield/container
|
||||||
world position. Therefore an owned inventory object must cross the wire
|
presentation. Therefore neither an owned item nor a world object may be held
|
||||||
directly. Only world objects participate in acdream's registered AD-27 local
|
behind a client-side movement completion. ACE owns the authoritative approach
|
||||||
approach compatibility seam.
|
chain.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
AutoWearIsLegal(itemId, out alreadyWorn, silent):
|
AutoWearIsLegal(itemId, out alreadyWorn, silent):
|
||||||
|
|
@ -113,9 +113,10 @@ AutoWear(itemId, out alreadyWorn, silent):
|
||||||
ordinary Use request directly from its inventory identity; it is never sent
|
ordinary Use request directly from its inventory identity; it is never sent
|
||||||
through the world-approach query. ACE remains authoritative for its spell
|
through the world-approach query. ACE remains authoritative for its spell
|
||||||
and consumption outcome.
|
and consumption outcome.
|
||||||
- `SelectionInteractionController` keeps the AD-27 turn/approach seam only for
|
- `SelectionInteractionController` sends accepted ordinary Use immediately for
|
||||||
non-owned spatial objects. Owned self, equipped, and nested-container sources
|
both owned and spatial objects. It does not install speculative local
|
||||||
have no live world pose and send immediately.
|
TurnToObject/MoveToObject movement; ACE's authoritative movement packets use
|
||||||
|
the shared retail `MoveToManager`.
|
||||||
- `AutoWieldController` remains the shared inventory/paperdoll equip owner. It
|
- `AutoWieldController` remains the shared inventory/paperdoll equip owner. It
|
||||||
resolves AutoWear blockers from `ClientObjectTable`'s retail-ordered
|
resolves AutoWear blockers from `ClientObjectTable`'s retail-ordered
|
||||||
equipment projection and emits the retail literal through system chat.
|
equipment projection and emits the retail literal through system chat.
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
Date: 2026-07-23
|
Date: 2026-07-23
|
||||||
|
|
||||||
Scope: the retained UI busy cursor and the
|
Scope: the retained UI busy cursor and the
|
||||||
`You can only move or use one item at a time` gate while an object/NPC Use
|
`You can only move or use one item at a time` gate from an accepted object/NPC
|
||||||
crosses acdream's close-range turn/approach boundary.
|
Use through its authoritative completion.
|
||||||
|
|
||||||
## Retail oracle
|
## Retail oracle
|
||||||
|
|
||||||
|
|
@ -44,10 +44,26 @@ There is no retail timeout and movement cancellation is not a substitute for
|
||||||
- holtburger `inventory/actions.rs` and `inventory/events.rs` independently
|
- holtburger `inventory/actions.rs` and `inventory/events.rs` independently
|
||||||
confirm the one Use action / one UseDone event protocol pair.
|
confirm the one Use action / one UseDone event protocol pair.
|
||||||
|
|
||||||
The existing close-range compatibility seam is recorded as divergence AD-27:
|
The connected 2026-07-25 regression trace proved why the old AD-27 Use branch
|
||||||
acdream may finish its local turn before emitting Use. That seam creates a
|
was incorrect: a pending local Ready animation could starve the speculative
|
||||||
real pre-wire interval which retail does not have, so it needs explicit
|
TurnToObject, so no Use packet ever reached ACE. Retail has no such dependency.
|
||||||
ownership rather than pretending a server completion is already owed.
|
`ItemHolder::UseObject` emits `CM_Inventory::Event_UseEvent` first; the event
|
||||||
|
packs and sends opcode `0x36` in `CM_Inventory::Event_UseEvent @ 0x006AC3B0`.
|
||||||
|
Only then does retail call `CPlayerSystem::UsingItem`. ACE owns any required
|
||||||
|
authoritative `CreateMoveToChain`. The Use half of AD-27 is therefore retired;
|
||||||
|
its remaining client completion adaptation applies only to pickup.
|
||||||
|
|
||||||
|
The immediate-send rerun then proved a distinct presentation-owner defect:
|
||||||
|
ACE sent the correct MoveTo and acdream resolved its target, but a login Ready
|
||||||
|
node remained at the head of the local `MotionInterpreter`. During login
|
||||||
|
hydration the player's logical animation runtime remained current while its
|
||||||
|
spatial projection was temporarily withdrawn. The presenter consumed the
|
||||||
|
zero-tick PartArray motion but its spatial-only guard withheld the
|
||||||
|
`MotionDoneTarget`, so the interpreter never popped that node. The correction
|
||||||
|
binds completion to the exact logical live-record animation owner and drains
|
||||||
|
the pre-controller PartArray queue before publishing a candidate interpreter.
|
||||||
|
The connected distant-NPC gate then turned, walked, sent no duplicate Use, and
|
||||||
|
completed through authoritative UseDone.
|
||||||
|
|
||||||
## Ported ownership pseudocode
|
## Ported ownership pseudocode
|
||||||
|
|
||||||
|
|
@ -62,37 +78,15 @@ RequestUse(object, reservation):
|
||||||
reservation.CancelBeforeDispatch()
|
reservation.CancelBeforeDispatch()
|
||||||
return
|
return
|
||||||
|
|
||||||
if object is carried or equipped by the player:
|
if a non-owned world object is not usable:
|
||||||
send Use immediately
|
|
||||||
transfer reservation to UseDone
|
|
||||||
return
|
|
||||||
|
|
||||||
if world identity/approach is invalid:
|
|
||||||
reservation.CancelBeforeDispatch()
|
reservation.CancelBeforeDispatch()
|
||||||
return
|
return
|
||||||
|
|
||||||
if a close-range turn must finish first:
|
|
||||||
pending = (exact object incarnation, approach token, reservation)
|
|
||||||
if movement installation fails:
|
|
||||||
pending.reservation.CancelBeforeDispatch()
|
|
||||||
return
|
|
||||||
|
|
||||||
if SendUse(object) succeeds:
|
if SendUse(object) succeeds:
|
||||||
reservation.MarkDispatched()
|
reservation.MarkDispatched()
|
||||||
else:
|
else:
|
||||||
reservation.CancelBeforeDispatch()
|
reservation.CancelBeforeDispatch()
|
||||||
|
|
||||||
OnNaturalApproachCompletion(pending):
|
|
||||||
if pending incarnation is stale:
|
|
||||||
pending.reservation.CancelBeforeDispatch()
|
|
||||||
else if SendUse(pending.object) succeeds:
|
|
||||||
pending.reservation.MarkDispatched()
|
|
||||||
else:
|
|
||||||
pending.reservation.CancelBeforeDispatch()
|
|
||||||
|
|
||||||
OnApproachCancelledOrTargetRemoved(pending):
|
|
||||||
pending.reservation.CancelBeforeDispatch()
|
|
||||||
|
|
||||||
reservation.MarkDispatched():
|
reservation.MarkDispatched():
|
||||||
// Ownership transfers to the server completion. Keep busyCount unchanged.
|
// Ownership transfers to the server completion. Keep busyCount unchanged.
|
||||||
make later local cancellation inert
|
make later local cancellation inert
|
||||||
|
|
@ -117,10 +111,16 @@ retired session cannot decrement a new session's busy reference.
|
||||||
|
|
||||||
## Regression evidence
|
## Regression evidence
|
||||||
|
|
||||||
The connected trace showed successful NPC Use packets and matching
|
The 2026-07-25 Alcott trace captured the failure before correction:
|
||||||
`UseDone(0)`. A managed heap snapshot after the final completion showed
|
|
||||||
`ItemInteractionController._busyCount == 0`, no pending inventory request, and
|
```text
|
||||||
no pending auto-wield transaction. The permanent failure path was instead the
|
[input] UseSelected Press
|
||||||
pre-wire close-range branch: it incremented busy before local movement, but
|
[autowalk-pending] count=1 nodes=[0x41000003/ctx=0/jump=0x0]
|
||||||
cancellation or stale identity discarded the pending send, leaving no packet
|
[autowalk-target] object=0x8000D085 status=Ok ...
|
||||||
and therefore no possible `UseDone`.
|
```
|
||||||
|
|
||||||
|
There was no `[B.4b] use`, no `UseDone`, and therefore no possible
|
||||||
|
authoritative release of the busy reference. Focused tests now pin that close
|
||||||
|
and far world Use both dispatch immediately, never install speculative local
|
||||||
|
movement, never duplicate on a later movement callback, and retain busy
|
||||||
|
ownership until exactly one `UseDone`.
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,16 @@ retained-source re-gate is tracked separately below.
|
||||||
|
|
||||||
## Retail and protocol anchors
|
## Retail and protocol anchors
|
||||||
|
|
||||||
- `gmSmartBoxUI::UseTime @ 0x004D6E30`: the
|
- `SmartBox::DoSetState` clears `waiting_for_teleport` when the player becomes
|
||||||
|
visible. The next `SmartBox::UseTime` resumes `CObjectMaint` and `CPhysics`
|
||||||
|
while the portal viewport may still be active.
|
||||||
|
- `gmSmartBoxUI::UseTime @ 0x004D6E30`: the later
|
||||||
`TunnelFadeOut -> WorldFadeIn` edge hides the portal viewport, shows the
|
`TunnelFadeOut -> WorldFadeIn` edge hides the portal viewport, shows the
|
||||||
world viewport, releases cell blocking, and starts the final one-second
|
world viewport, releases the destination render reservation, and starts the
|
||||||
WorldFadeIn state.
|
final one-second WorldFadeIn state.
|
||||||
- The later WorldFadeIn completion sends LoginComplete and ends logical
|
- WorldFadeIn completion sends LoginComplete and ends logical teleport state.
|
||||||
teleport state. Viewport release and protocol completion are distinct.
|
Materialization, viewport release, and protocol completion are three
|
||||||
|
distinct edges.
|
||||||
- `gmPaperDollUI::RedressCreature @ 0x004A3BC0` creates the private inventory
|
- `gmPaperDollUI::RedressCreature @ 0x004A3BC0` creates the private inventory
|
||||||
object only when absent and a SmartBox player is available; temporary player
|
object only when absent and a SmartBox player is available; temporary player
|
||||||
absence does not delete an existing private object.
|
absence does not delete an existing private object.
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,9 @@ internal sealed class LocalPlayerControllerSlot
|
||||||
/// and completes StopCompletely, so its MotionDone relay must reach the
|
/// and completes StopCompletely, so its MotionDone relay must reach the
|
||||||
/// candidate interpreter before the complete controller is published.
|
/// candidate interpreter before the complete controller is published.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IDisposable BeginMotionPreparation(PlayerMovementController controller)
|
public IDisposable BeginMotionPreparation(
|
||||||
|
PlayerMovementController controller,
|
||||||
|
Action? drainPriorAnimationQueue = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(controller);
|
ArgumentNullException.ThrowIfNull(controller);
|
||||||
if (_preparingMotionOwner is not null)
|
if (_preparingMotionOwner is not null)
|
||||||
|
|
@ -62,6 +64,12 @@ internal sealed class LocalPlayerControllerSlot
|
||||||
"A local player motion owner is already being prepared.");
|
"A local player motion owner is already being prepared.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The live PartArray exists before the local player controller. Drain
|
||||||
|
// any animation completions produced under that prior ownership while
|
||||||
|
// they still resolve to the prior (or null) interpreter. Publishing
|
||||||
|
// the fresh interpreter first would let those old callbacks pop its
|
||||||
|
// new pending_motions queue and leave an unmatched Ready sentinel.
|
||||||
|
drainPriorAnimationQueue?.Invoke();
|
||||||
_preparingMotionOwner = controller;
|
_preparingMotionOwner = controller;
|
||||||
return new MotionPreparation(this, controller);
|
return new MotionPreparation(this, controller);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -368,6 +368,7 @@ internal sealed class PlayerModeController :
|
||||||
ApplyStepHeights(controller, playerEntity);
|
ApplyStepHeights(controller, playerEntity);
|
||||||
uint initialCellId = ResolveInitialCell(playerGuid, playerEntity);
|
uint initialCellId = ResolveInitialCell(playerGuid, playerEntity);
|
||||||
|
|
||||||
|
Action? drainPriorAnimationQueue = null;
|
||||||
if (_animations.TryGetValue(playerEntity.Id, out LiveEntityAnimationState? animation)
|
if (_animations.TryGetValue(playerEntity.Id, out LiveEntityAnimationState? animation)
|
||||||
&& animation.Sequencer is { } sequencer)
|
&& animation.Sequencer is { } sequencer)
|
||||||
{
|
{
|
||||||
|
|
@ -384,6 +385,7 @@ internal sealed class PlayerModeController :
|
||||||
sequencer.Manager.CheckForCompletedMotions;
|
sequencer.Manager.CheckForCompletedMotions;
|
||||||
controller.Motion.DefaultSink =
|
controller.Motion.DefaultSink =
|
||||||
new MotionTableDispatchSink(sequencer);
|
new MotionTableDispatchSink(sequencer);
|
||||||
|
drainPriorAnimationQueue = sequencer.Manager.HandleEnterWorld;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retail CPhysicsObj owns CMotionInterp and CPartArray throughout
|
// Retail CPhysicsObj owns CMotionInterp and CPartArray throughout
|
||||||
|
|
@ -393,7 +395,9 @@ internal sealed class PlayerModeController :
|
||||||
// public controller slot remains unpublished until every other
|
// public controller slot remains unpublished until every other
|
||||||
// player-mode edge has prepared successfully.
|
// player-mode edge has prepared successfully.
|
||||||
using IDisposable motionPreparation =
|
using IDisposable motionPreparation =
|
||||||
_controllerSlot.BeginMotionPreparation(controller);
|
_controllerSlot.BeginMotionPreparation(
|
||||||
|
controller,
|
||||||
|
drainPriorAnimationQueue);
|
||||||
|
|
||||||
ResolveResult initial = _physics.Resolve(
|
ResolveResult initial = _physics.Resolve(
|
||||||
playerEntity.Position,
|
playerEntity.Position,
|
||||||
|
|
|
||||||
|
|
@ -24,21 +24,19 @@ internal sealed class SelectionInteractionController
|
||||||
private readonly OutboundInteractionQueue _outbound = new();
|
private readonly OutboundInteractionQueue _outbound = new();
|
||||||
private readonly PlayerApproachCompletionState _approachCompletions;
|
private readonly PlayerApproachCompletionState _approachCompletions;
|
||||||
private readonly Action<string>? _toast;
|
private readonly Action<string>? _toast;
|
||||||
private PendingPostArrivalAction? _pendingPostArrival;
|
private PendingPostArrivalPickup? _pendingPostArrival;
|
||||||
|
|
||||||
private readonly record struct QueuedInteractionIdentity(
|
private readonly record struct QueuedInteractionIdentity(
|
||||||
uint ServerGuid,
|
uint ServerGuid,
|
||||||
uint? LocalEntityId,
|
uint? LocalEntityId,
|
||||||
ClientObject? ClientObject);
|
ClientObject? ClientObject);
|
||||||
|
|
||||||
private readonly record struct PendingPostArrivalAction(
|
private readonly record struct PendingPostArrivalPickup(
|
||||||
uint ServerGuid,
|
uint ServerGuid,
|
||||||
uint LocalEntityId,
|
uint LocalEntityId,
|
||||||
bool IsPickup,
|
|
||||||
uint DestinationContainerId,
|
uint DestinationContainerId,
|
||||||
int Placement,
|
int Placement,
|
||||||
ulong PendingPlacementToken,
|
ulong PendingPlacementToken,
|
||||||
ItemUseRequestReservation? UseReservation,
|
|
||||||
PlayerApproachToken ApproachToken);
|
PlayerApproachToken ApproachToken);
|
||||||
|
|
||||||
public SelectionInteractionController(
|
public SelectionInteractionController(
|
||||||
|
|
@ -238,46 +236,23 @@ internal sealed class SelectionInteractionController
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ItemHolder::UseObject @ 0x00588A80 sends Event_UseEvent for an
|
// ItemHolder::UseObject @ 0x00588A80 sends Event_UseEvent before
|
||||||
// accepted carried-item activation before CPlayerSystem::UsingItem.
|
// CPlayerSystem::UsingItem for every accepted ordinary Use. The latter
|
||||||
// A carried source has no world pose to approach: routing it through
|
// is presentation/local inventory policy; it never delays the wire
|
||||||
// TryGetApproach drops the packet because inventory objects are not
|
// request behind a client-side TurnTo/MoveTo completion. ACE owns the
|
||||||
// live spatial entities.
|
// authoritative approach chain for spatial targets.
|
||||||
if (_items.IsOwnedByPlayer(serverGuid))
|
if (_items.IsOwnedByPlayer(serverGuid))
|
||||||
{
|
{
|
||||||
SendUseNow(serverGuid, reservation);
|
SendUseNow(serverGuid, reservation);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_query.IsUseable(serverGuid)
|
if (!_query.IsUseable(serverGuid))
|
||||||
|| !_query.TryGetApproach(serverGuid, out InteractionApproach approach))
|
|
||||||
{
|
{
|
||||||
reservation?.CancelBeforeDispatch();
|
reservation?.CancelBeforeDispatch();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (approach.IsCloseRange)
|
|
||||||
{
|
|
||||||
var pending = new PendingPostArrivalAction(
|
|
||||||
serverGuid,
|
|
||||||
approach.Target.LocalEntityId,
|
|
||||||
IsPickup: false,
|
|
||||||
DestinationContainerId: 0u,
|
|
||||||
Placement: 0,
|
|
||||||
PendingPlacementToken: 0u,
|
|
||||||
UseReservation: reservation,
|
|
||||||
ApproachToken: default);
|
|
||||||
bool started = _movement.BeginApproach(
|
|
||||||
approach,
|
|
||||||
token => _pendingPostArrival = pending with { ApproachToken = token });
|
|
||||||
if (!started && _pendingPostArrival?.ServerGuid == pending.ServerGuid)
|
|
||||||
CancelPendingApproach();
|
|
||||||
else if (!started)
|
|
||||||
reservation?.CancelBeforeDispatch();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_movement.BeginApproach(approach);
|
|
||||||
SendUseNow(serverGuid, reservation);
|
SendUseNow(serverGuid, reservation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -327,14 +302,12 @@ internal sealed class SelectionInteractionController
|
||||||
|
|
||||||
if (approach.IsCloseRange)
|
if (approach.IsCloseRange)
|
||||||
{
|
{
|
||||||
var pending = new PendingPostArrivalAction(
|
var pending = new PendingPostArrivalPickup(
|
||||||
itemGuid,
|
itemGuid,
|
||||||
approach.Target.LocalEntityId,
|
approach.Target.LocalEntityId,
|
||||||
IsPickup: true,
|
|
||||||
destinationContainerId,
|
destinationContainerId,
|
||||||
placement,
|
placement,
|
||||||
pendingPlacementToken,
|
pendingPlacementToken,
|
||||||
UseReservation: null,
|
|
||||||
ApproachToken: default);
|
ApproachToken: default);
|
||||||
bool started = _movement.BeginApproach(
|
bool started = _movement.BeginApproach(
|
||||||
approach,
|
approach,
|
||||||
|
|
@ -386,46 +359,33 @@ internal sealed class SelectionInteractionController
|
||||||
_pendingPostArrival = null;
|
_pendingPostArrival = null;
|
||||||
if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId))
|
if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId))
|
||||||
{
|
{
|
||||||
if (pending.IsPickup)
|
CancelPickupPresentation(
|
||||||
CancelPickupPresentation(
|
pending.ServerGuid,
|
||||||
pending.ServerGuid,
|
pending.PendingPlacementToken);
|
||||||
pending.PendingPlacementToken);
|
|
||||||
else
|
|
||||||
pending.UseReservation?.CancelBeforeDispatch();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pending.IsPickup)
|
if (!IsCurrentPickupPresentation(
|
||||||
|
pending.ServerGuid,
|
||||||
|
pending.DestinationContainerId,
|
||||||
|
pending.Placement,
|
||||||
|
pending.PendingPlacementToken))
|
||||||
{
|
{
|
||||||
if (!IsCurrentPickupPresentation(
|
return;
|
||||||
|
}
|
||||||
|
if (!_items.TryDispatchInventoryRequest(
|
||||||
|
InventoryRequestKind.Pickup,
|
||||||
|
pending.ServerGuid,
|
||||||
|
() => _transport.TrySendPickup(
|
||||||
pending.ServerGuid,
|
pending.ServerGuid,
|
||||||
pending.DestinationContainerId,
|
pending.DestinationContainerId,
|
||||||
pending.Placement,
|
pending.Placement,
|
||||||
pending.PendingPlacementToken))
|
out _),
|
||||||
{
|
pending.PendingPlacementToken))
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!_items.TryDispatchInventoryRequest(
|
|
||||||
InventoryRequestKind.Pickup,
|
|
||||||
pending.ServerGuid,
|
|
||||||
() => _transport.TrySendPickup(
|
|
||||||
pending.ServerGuid,
|
|
||||||
pending.DestinationContainerId,
|
|
||||||
pending.Placement,
|
|
||||||
out _),
|
|
||||||
pending.PendingPlacementToken))
|
|
||||||
{
|
|
||||||
CancelPickupPresentation(
|
|
||||||
pending.ServerGuid,
|
|
||||||
pending.PendingPlacementToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
if (_transport.TrySendUse(pending.ServerGuid, out _))
|
CancelPickupPresentation(
|
||||||
pending.UseReservation?.MarkDispatched();
|
pending.ServerGuid,
|
||||||
else
|
pending.PendingPlacementToken);
|
||||||
pending.UseReservation?.CancelBeforeDispatch();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -548,12 +508,9 @@ internal sealed class SelectionInteractionController
|
||||||
if (_pendingPostArrival is not { } pending)
|
if (_pendingPostArrival is not { } pending)
|
||||||
return;
|
return;
|
||||||
_pendingPostArrival = null;
|
_pendingPostArrival = null;
|
||||||
if (pending.IsPickup)
|
CancelPickupPresentation(
|
||||||
CancelPickupPresentation(
|
pending.ServerGuid,
|
||||||
pending.ServerGuid,
|
pending.PendingPlacementToken);
|
||||||
pending.PendingPlacementToken);
|
|
||||||
else
|
|
||||||
pending.UseReservation?.CancelBeforeDispatch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CancelPickupPresentation(uint itemGuid, ulong token = 0u)
|
private void CancelPickupPresentation(uint itemGuid, ulong token = 0u)
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,12 @@ internal sealed class LiveEntityAnimationPresenter
|
||||||
ArgumentNullException.ThrowIfNull(record);
|
ArgumentNullException.ThrowIfNull(record);
|
||||||
ArgumentNullException.ThrowIfNull(animation);
|
ArgumentNullException.ThrowIfNull(animation);
|
||||||
LiveEntityRuntime runtime = _liveEntities;
|
LiveEntityRuntime runtime = _liveEntities;
|
||||||
if (!runtime.IsCurrentSpatialAnimation(record, animation)
|
// The PartArray completion relay is a logical CPhysicsObj component,
|
||||||
|
// not a render-workset component. Projection hydration can bind it
|
||||||
|
// while world rendering is intentionally quiesced during login or
|
||||||
|
// teleport; waiting for spatial visibility would orphan the initial
|
||||||
|
// StopCompletely pending-motion node before the first visible frame.
|
||||||
|
if (!runtime.IsCurrentAnimationOwner(record, animation)
|
||||||
|| !ReferenceEquals(record.WorldEntity, animation.Entity)
|
|| !ReferenceEquals(record.WorldEntity, animation.Entity)
|
||||||
|| animation.Sequencer is not { MotionDoneTarget: null } sequencer)
|
|| animation.Sequencer is not { MotionDoneTarget: null } sequencer)
|
||||||
{
|
{
|
||||||
|
|
@ -55,7 +60,12 @@ internal sealed class LiveEntityAnimationPresenter
|
||||||
sequencer.MotionDoneTarget = (motion, success) =>
|
sequencer.MotionDoneTarget = (motion, success) =>
|
||||||
{
|
{
|
||||||
LiveEntityRuntime current = _liveEntities;
|
LiveEntityRuntime current = _liveEntities;
|
||||||
if (!current.IsCurrentSpatialAnimation(record, animation)
|
// CPhysicsObj owns its PartArray and CMotionInterp across
|
||||||
|
// leave/enter-world projection edges. The callback may therefore
|
||||||
|
// complete while rendering is intentionally withheld (login or
|
||||||
|
// teleport reveal), but it must never cross an entity incarnation
|
||||||
|
// or an animation-component replacement.
|
||||||
|
if (!current.IsCurrentAnimationOwner(record, animation)
|
||||||
|| !ReferenceEquals(record.WorldEntity, capturedEntity)
|
|| !ReferenceEquals(record.WorldEntity, capturedEntity)
|
||||||
|| !ReferenceEquals(animation.Entity, capturedEntity))
|
|| !ReferenceEquals(animation.Entity, capturedEntity))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ internal sealed class WorldRevealCoordinator
|
||||||
private readonly IWorldRevealStreamingScheduler? _streaming;
|
private readonly IWorldRevealStreamingScheduler? _streaming;
|
||||||
private readonly IWorldRevealRenderResourceScheduler? _renderResources;
|
private readonly IWorldRevealRenderResourceScheduler? _renderResources;
|
||||||
private long _activeGeneration;
|
private long _activeGeneration;
|
||||||
|
private bool _worldSimulationReleased;
|
||||||
private bool _worldViewportReleased;
|
private bool _worldViewportReleased;
|
||||||
|
|
||||||
public WorldRevealCoordinator(
|
public WorldRevealCoordinator(
|
||||||
|
|
@ -80,6 +81,7 @@ internal sealed class WorldRevealCoordinator
|
||||||
_readiness.Begin();
|
_readiness.Begin();
|
||||||
long generation = _lifecycle.Begin(kind);
|
long generation = _lifecycle.Begin(kind);
|
||||||
_activeGeneration = generation;
|
_activeGeneration = generation;
|
||||||
|
_worldSimulationReleased = false;
|
||||||
_worldViewportReleased = false;
|
_worldViewportReleased = false;
|
||||||
_quiescence?.Begin(generation);
|
_quiescence?.Begin(generation);
|
||||||
_streaming?.BeginDestinationReservation(
|
_streaming?.BeginDestinationReservation(
|
||||||
|
|
@ -107,7 +109,22 @@ internal sealed class WorldRevealCoordinator
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ObserveMaterialized() => _lifecycle.ObserveMaterialized();
|
/// <summary>
|
||||||
|
/// Commits the accepted destination and resumes its hidden world
|
||||||
|
/// simulation while the private portal viewport remains in front.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Retail <c>SmartBox::UseTime @ 0x00455410</c> resumes
|
||||||
|
/// <c>CObjectMaint</c>/<c>CPhysics</c> before
|
||||||
|
/// <c>gmSmartBoxUI::EndTeleportAnimation @ 0x004D65A0</c> enters
|
||||||
|
/// <c>TAS_TUNNEL_CONTINUE</c>. The normal world viewport is not restored
|
||||||
|
/// until the later <c>TUNNEL_FADE_OUT -> WORLD_FADE_IN</c> edge.
|
||||||
|
/// </remarks>
|
||||||
|
public void ObserveMaterialized()
|
||||||
|
{
|
||||||
|
_lifecycle.ObserveMaterialized();
|
||||||
|
ReleaseWorldSimulation();
|
||||||
|
}
|
||||||
|
|
||||||
public void ObserveWorldViewportVisible() =>
|
public void ObserveWorldViewportVisible() =>
|
||||||
_lifecycle.ObserveWorldViewportVisible();
|
_lifecycle.ObserveWorldViewportVisible();
|
||||||
|
|
@ -116,9 +133,10 @@ internal sealed class WorldRevealCoordinator
|
||||||
_lifecycle.ObserveWait(elapsed);
|
_lifecycle.ObserveWait(elapsed);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reopens the destination world at retail's TunnelFadeOut ->
|
/// Releases the destination streaming/resource reservation at retail's
|
||||||
/// WorldFadeIn viewport swap without completing the one-second
|
/// TunnelFadeOut -> WorldFadeIn viewport swap without completing the
|
||||||
/// WorldFadeIn/LoginComplete tail.
|
/// one-second WorldFadeIn/LoginComplete tail. World simulation already
|
||||||
|
/// resumed when the accepted destination materialized.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void RevealWorldViewport()
|
public void RevealWorldViewport()
|
||||||
{
|
{
|
||||||
|
|
@ -128,7 +146,6 @@ internal sealed class WorldRevealCoordinator
|
||||||
|
|
||||||
_streaming?.EndDestinationReservation(generation);
|
_streaming?.EndDestinationReservation(generation);
|
||||||
_renderResources?.EndDestinationReveal(generation);
|
_renderResources?.EndDestinationReveal(generation);
|
||||||
_quiescence?.End(generation);
|
|
||||||
_worldViewportReleased = true;
|
_worldViewportReleased = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,8 +167,20 @@ internal sealed class WorldRevealCoordinator
|
||||||
if (generation == 0)
|
if (generation == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
ReleaseWorldSimulation();
|
||||||
RevealWorldViewport();
|
RevealWorldViewport();
|
||||||
_activeGeneration = 0;
|
_activeGeneration = 0;
|
||||||
|
_worldSimulationReleased = false;
|
||||||
_worldViewportReleased = false;
|
_worldViewportReleased = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ReleaseWorldSimulation()
|
||||||
|
{
|
||||||
|
long generation = _activeGeneration;
|
||||||
|
if (generation == 0 || _worldSimulationReleased)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_quiescence?.End(generation);
|
||||||
|
_worldSimulationReleased = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1901,6 +1901,18 @@ public sealed class LiveEntityRuntime
|
||||||
&& _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexed)
|
&& _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexed)
|
||||||
&& ReferenceEquals(indexed, animation);
|
&& ReferenceEquals(indexed, animation);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns whether <paramref name="animation"/> is still the canonical
|
||||||
|
/// animation component of this logical entity incarnation. Motion
|
||||||
|
/// completion belongs to that CPhysicsObj/PartArray lifetime, not to the
|
||||||
|
/// transient render-bucket projection used by per-frame presentation.
|
||||||
|
/// </summary>
|
||||||
|
internal bool IsCurrentAnimationOwner(
|
||||||
|
LiveEntityRecord record,
|
||||||
|
ILiveEntityAnimationRuntime animation) =>
|
||||||
|
IsCurrentRecord(record)
|
||||||
|
&& ReferenceEquals(record.AnimationRuntime, animation);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Copies the currently spatial animation owners through the concrete
|
/// Copies the currently spatial animation owners through the concrete
|
||||||
/// dictionary enumerator. This keeps per-frame traversal allocation-free;
|
/// dictionary enumerator. This keeps per-frame traversal allocation-free;
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,32 @@ public sealed class LocalPlayerControllerSlotTests
|
||||||
Assert.Same(controller.Motion, source.Motion);
|
Assert.Same(controller.Motion, source.Motion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MotionPreparation_DrainsPriorAnimationBeforePublishingCandidate()
|
||||||
|
{
|
||||||
|
var slot = new LocalPlayerControllerSlot();
|
||||||
|
ILocalPlayerMotionSource source = slot;
|
||||||
|
var prior = new PlayerMovementController(new PhysicsEngine());
|
||||||
|
var candidate = new PlayerMovementController(new PhysicsEngine());
|
||||||
|
slot.Controller = prior;
|
||||||
|
var order = new List<string>();
|
||||||
|
|
||||||
|
using (slot.BeginMotionPreparation(
|
||||||
|
candidate,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
order.Add("drain");
|
||||||
|
Assert.Same(prior.Motion, source.Motion);
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
order.Add("candidate");
|
||||||
|
Assert.Same(candidate.Motion, source.Motion);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(["drain", "candidate"], order);
|
||||||
|
Assert.Same(prior.Motion, source.Motion);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MotionPreparation_RejectsConcurrentCandidate()
|
public void MotionPreparation_RejectsConcurrentCandidate()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -295,15 +295,15 @@ public sealed class SelectionInteractionControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CloseUseTurnsFirstThenSendsExactlyOnceOnNaturalCompletion()
|
public void CloseUseSendsImmediatelyWithoutSpeculativeMovement()
|
||||||
{
|
{
|
||||||
var h = new Harness();
|
var h = new Harness();
|
||||||
h.SetApproach(closeRange: true);
|
h.SetApproach(closeRange: true);
|
||||||
|
|
||||||
h.Controller.SendUse(Target);
|
h.Controller.SendUse(Target);
|
||||||
|
|
||||||
Assert.Single(h.Movement.Approaches);
|
Assert.Empty(h.Movement.Approaches);
|
||||||
Assert.Empty(h.Transport.Uses);
|
Assert.Equal(new[] { Target }, h.Transport.Uses);
|
||||||
|
|
||||||
h.Controller.OnNaturalMoveToComplete();
|
h.Controller.OnNaturalMoveToComplete();
|
||||||
h.Controller.OnNaturalMoveToComplete();
|
h.Controller.OnNaturalMoveToComplete();
|
||||||
|
|
@ -312,7 +312,7 @@ public sealed class SelectionInteractionControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CancelledDeferredUseReleasesBusyWithoutServerCompletion()
|
public void AcceptedUseRemainsBusyUntilAuthoritativeUseDone()
|
||||||
{
|
{
|
||||||
var h = new Harness();
|
var h = new Harness();
|
||||||
h.SetApproach(closeRange: true);
|
h.SetApproach(closeRange: true);
|
||||||
|
|
@ -322,18 +322,22 @@ public sealed class SelectionInteractionControllerTests
|
||||||
h.Controller.DrainOutbound();
|
h.Controller.DrainOutbound();
|
||||||
|
|
||||||
Assert.Equal(1, h.Items.BusyCount);
|
Assert.Equal(1, h.Items.BusyCount);
|
||||||
Assert.Empty(h.Transport.Uses);
|
Assert.Equal(new[] { Target }, h.Transport.Uses);
|
||||||
|
|
||||||
h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled);
|
h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled);
|
||||||
h.Controller.DrainOutbound();
|
h.Controller.DrainOutbound();
|
||||||
|
|
||||||
|
Assert.Equal(1, h.Items.BusyCount);
|
||||||
|
Assert.False(h.Items.CanMakeInventoryRequest);
|
||||||
|
|
||||||
|
h.Items.CompleteUse(0u);
|
||||||
|
|
||||||
Assert.Equal(0, h.Items.BusyCount);
|
Assert.Equal(0, h.Items.BusyCount);
|
||||||
Assert.True(h.Items.CanMakeInventoryRequest);
|
Assert.True(h.Items.CanMakeInventoryRequest);
|
||||||
Assert.Empty(h.Transport.Uses);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DispatchedDeferredUseRemainsBusyUntilAuthoritativeUseDone()
|
public void DispatchedWorldUseRemainsBusyUntilAuthoritativeUseDone()
|
||||||
{
|
{
|
||||||
var h = new Harness();
|
var h = new Harness();
|
||||||
h.SetApproach(closeRange: true);
|
h.SetApproach(closeRange: true);
|
||||||
|
|
@ -341,8 +345,6 @@ public sealed class SelectionInteractionControllerTests
|
||||||
|
|
||||||
h.Controller.HandleInputAction(InputAction.UseSelected);
|
h.Controller.HandleInputAction(InputAction.UseSelected);
|
||||||
h.Controller.DrainOutbound();
|
h.Controller.DrainOutbound();
|
||||||
h.CompletionLifetime.PublishNaturalCompletion();
|
|
||||||
h.Controller.DrainOutbound();
|
|
||||||
|
|
||||||
Assert.Equal(new[] { Target }, h.Transport.Uses);
|
Assert.Equal(new[] { Target }, h.Transport.Uses);
|
||||||
Assert.Equal(1, h.Items.BusyCount);
|
Assert.Equal(1, h.Items.BusyCount);
|
||||||
|
|
@ -354,7 +356,7 @@ public sealed class SelectionInteractionControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FailedDeferredUseStartReleasesItsBusyReservation()
|
public void WorldUseDoesNotDependOnLocalApproachInstallation()
|
||||||
{
|
{
|
||||||
var h = new Harness();
|
var h = new Harness();
|
||||||
h.SetApproach(closeRange: true);
|
h.SetApproach(closeRange: true);
|
||||||
|
|
@ -364,32 +366,27 @@ public sealed class SelectionInteractionControllerTests
|
||||||
h.Controller.HandleInputAction(InputAction.UseSelected);
|
h.Controller.HandleInputAction(InputAction.UseSelected);
|
||||||
h.Controller.DrainOutbound();
|
h.Controller.DrainOutbound();
|
||||||
|
|
||||||
Assert.Equal(0, h.Items.BusyCount);
|
Assert.Equal(1, h.Items.BusyCount);
|
||||||
Assert.True(h.Items.CanMakeInventoryRequest);
|
Assert.Equal(new[] { Target }, h.Transport.Uses);
|
||||||
Assert.Empty(h.Transport.Uses);
|
Assert.Empty(h.Movement.Approaches);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StaleDeferredUseReleasesItsBusyReservation()
|
public void RejectedWorldUseReleasesItsBusyReservation()
|
||||||
{
|
{
|
||||||
var h = new Harness();
|
var h = new Harness();
|
||||||
h.SetApproach(closeRange: true);
|
h.Query.Useable = false;
|
||||||
h.Selection.Select(Target, SelectionChangeSource.World);
|
h.Selection.Select(Target, SelectionChangeSource.World);
|
||||||
|
|
||||||
h.Controller.HandleInputAction(InputAction.UseSelected);
|
h.Controller.HandleInputAction(InputAction.UseSelected);
|
||||||
h.Controller.DrainOutbound();
|
h.Controller.DrainOutbound();
|
||||||
Assert.Equal(1, h.Items.BusyCount);
|
|
||||||
|
|
||||||
h.Query.Current = false;
|
|
||||||
h.CompletionLifetime.PublishNaturalCompletion();
|
|
||||||
h.Controller.DrainOutbound();
|
|
||||||
|
|
||||||
Assert.Equal(0, h.Items.BusyCount);
|
Assert.Equal(0, h.Items.BusyCount);
|
||||||
Assert.Empty(h.Transport.Uses);
|
Assert.Empty(h.Transport.Uses);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SynchronousTurnCompletionTransfersBusyOwnershipToUseDone()
|
public void SynchronousMovementCallbackCannotDuplicateWorldUse()
|
||||||
{
|
{
|
||||||
var h = new Harness();
|
var h = new Harness();
|
||||||
h.SetApproach(closeRange: true);
|
h.SetApproach(closeRange: true);
|
||||||
|
|
@ -408,7 +405,7 @@ public sealed class SelectionInteractionControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FarUseSendsImmediatelyAndNaturalCompletionDoesNotRetry()
|
public void FarUseSendsImmediatelyWithoutClientApproachAndDoesNotRetry()
|
||||||
{
|
{
|
||||||
var h = new Harness();
|
var h = new Harness();
|
||||||
h.SetApproach(closeRange: false);
|
h.SetApproach(closeRange: false);
|
||||||
|
|
@ -416,7 +413,7 @@ public sealed class SelectionInteractionControllerTests
|
||||||
h.Controller.SendUse(Target);
|
h.Controller.SendUse(Target);
|
||||||
h.Controller.OnNaturalMoveToComplete();
|
h.Controller.OnNaturalMoveToComplete();
|
||||||
|
|
||||||
Assert.Single(h.Movement.Approaches);
|
Assert.Empty(h.Movement.Approaches);
|
||||||
Assert.Equal(new[] { Target }, h.Transport.Uses);
|
Assert.Equal(new[] { Target }, h.Transport.Uses);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -448,21 +445,21 @@ public sealed class SelectionInteractionControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HiddenOrStaleIncarnationCancelsDeferredAction()
|
public void HiddenOrStaleNotificationsCannotRetractOrDuplicateDispatchedUse()
|
||||||
{
|
{
|
||||||
var hidden = new Harness();
|
var hidden = new Harness();
|
||||||
hidden.SetApproach(closeRange: true);
|
hidden.SetApproach(closeRange: true);
|
||||||
hidden.Controller.SendUse(Target);
|
hidden.Controller.SendUse(Target);
|
||||||
hidden.Controller.OnEntityHidden(Target);
|
hidden.Controller.OnEntityHidden(Target);
|
||||||
hidden.Controller.OnNaturalMoveToComplete();
|
hidden.Controller.OnNaturalMoveToComplete();
|
||||||
Assert.Empty(hidden.Transport.Uses);
|
Assert.Equal(new[] { Target }, hidden.Transport.Uses);
|
||||||
|
|
||||||
var stale = new Harness();
|
var stale = new Harness();
|
||||||
stale.SetApproach(closeRange: true);
|
stale.SetApproach(closeRange: true);
|
||||||
stale.Controller.SendUse(Target);
|
stale.Controller.SendUse(Target);
|
||||||
stale.Query.Current = false;
|
stale.Query.Current = false;
|
||||||
stale.Controller.OnNaturalMoveToComplete();
|
stale.Controller.OnNaturalMoveToComplete();
|
||||||
Assert.Empty(stale.Transport.Uses);
|
Assert.Equal(new[] { Target }, stale.Transport.Uses);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -487,7 +484,7 @@ public sealed class SelectionInteractionControllerTests
|
||||||
h.SetApproach(closeRange: true);
|
h.SetApproach(closeRange: true);
|
||||||
h.Selection.Select(Target, SelectionChangeSource.World);
|
h.Selection.Select(Target, SelectionChangeSource.World);
|
||||||
h.Controller.HandleInputAction(InputAction.SelectionPickUp);
|
h.Controller.HandleInputAction(InputAction.SelectionPickUp);
|
||||||
h.Controller.SendUse(Target);
|
h.Controller.HandleInputAction(InputAction.UseSelected);
|
||||||
h.Items.InteractionState.EnterExamine();
|
h.Items.InteractionState.EnterExamine();
|
||||||
|
|
||||||
h.Controller.ResetSession();
|
h.Controller.ResetSession();
|
||||||
|
|
@ -501,12 +498,12 @@ public sealed class SelectionInteractionControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OldRemovalClearsCapturedActionButDoesNotClearReplacementSelection()
|
public void OldRemovalClearsCapturedPickupButDoesNotClearReplacementSelection()
|
||||||
{
|
{
|
||||||
var h = new Harness();
|
var h = new Harness();
|
||||||
h.SetApproach(closeRange: true, localEntityId: 101u);
|
h.SetApproach(closeRange: true, localEntityId: 101u);
|
||||||
h.Selection.Select(Target, SelectionChangeSource.World);
|
h.Selection.Select(Target, SelectionChangeSource.World);
|
||||||
h.Controller.SendUse(Target);
|
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
|
||||||
var oldRecord = new LiveEntityRecord(Spawn(Target, instance: 1));
|
var oldRecord = new LiveEntityRecord(Spawn(Target, instance: 1));
|
||||||
oldRecord.WorldEntity = new WorldEntity
|
oldRecord.WorldEntity = new WorldEntity
|
||||||
{
|
{
|
||||||
|
|
@ -522,11 +519,12 @@ public sealed class SelectionInteractionControllerTests
|
||||||
h.Controller.OnNaturalMoveToComplete();
|
h.Controller.OnNaturalMoveToComplete();
|
||||||
|
|
||||||
Assert.Equal(Target, h.Selection.SelectedObjectId);
|
Assert.Equal(Target, h.Selection.SelectedObjectId);
|
||||||
Assert.Empty(h.Transport.Uses);
|
Assert.Empty(h.Transport.Pickups);
|
||||||
|
Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SynchronousNaturalCompletionObservesTheArmedCloseAction()
|
public void SynchronousNaturalCompletionCannotDuplicateUse()
|
||||||
{
|
{
|
||||||
var h = new Harness();
|
var h = new Harness();
|
||||||
h.SetApproach(closeRange: true);
|
h.SetApproach(closeRange: true);
|
||||||
|
|
@ -539,16 +537,16 @@ public sealed class SelectionInteractionControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SynchronousResetCannotResurrectTheCloseAction()
|
public void SynchronousResetCannotResurrectACancelledPickup()
|
||||||
{
|
{
|
||||||
var h = new Harness();
|
var h = new Harness();
|
||||||
h.SetApproach(closeRange: true);
|
h.SetApproach(closeRange: true);
|
||||||
h.Movement.AfterArm = h.Controller.ResetSession;
|
h.Movement.AfterArm = h.Controller.ResetSession;
|
||||||
|
|
||||||
h.Controller.SendUse(Target);
|
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
|
||||||
h.Controller.OnNaturalMoveToComplete();
|
h.Controller.OnNaturalMoveToComplete();
|
||||||
|
|
||||||
Assert.Empty(h.Transport.Uses);
|
Assert.Empty(h.Transport.Pickups);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -569,7 +567,7 @@ public sealed class SelectionInteractionControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ErrorCompletionClearsCloseActionBeforeALaterSuccess()
|
public void ErrorCompletionCannotRetractOrDuplicateDispatchedUse()
|
||||||
{
|
{
|
||||||
var h = new Harness();
|
var h = new Harness();
|
||||||
h.SetApproach(closeRange: true);
|
h.SetApproach(closeRange: true);
|
||||||
|
|
@ -578,48 +576,45 @@ public sealed class SelectionInteractionControllerTests
|
||||||
h.Controller.OnMoveToCancelled(WeenieError.NoObject);
|
h.Controller.OnMoveToCancelled(WeenieError.NoObject);
|
||||||
h.Controller.OnNaturalMoveToComplete();
|
h.Controller.OnNaturalMoveToComplete();
|
||||||
|
|
||||||
Assert.Empty(h.Transport.Uses);
|
Assert.Equal(new[] { Target }, h.Transport.Uses);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Fact]
|
||||||
[InlineData(false)]
|
public void CompletionFromPriorPickupCannotAffectReplacement()
|
||||||
[InlineData(true)]
|
|
||||||
public void DeferredCompletionFromPriorApproachCannotAffectReplacement(
|
|
||||||
bool priorCompletedNaturally)
|
|
||||||
{
|
{
|
||||||
var h = new Harness();
|
var h = new Harness();
|
||||||
h.SetApproach(closeRange: true);
|
h.SetApproach(closeRange: true);
|
||||||
h.Controller.SendUse(Target);
|
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
|
||||||
if (priorCompletedNaturally)
|
h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled);
|
||||||
h.CompletionLifetime.PublishNaturalCompletion();
|
|
||||||
else
|
|
||||||
h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled);
|
|
||||||
|
|
||||||
h.Controller.SendUse(Target);
|
h.Selection.Select(Target, SelectionChangeSource.World);
|
||||||
|
h.Controller.HandleInputAction(InputAction.UseSelected);
|
||||||
h.Controller.DrainOutbound();
|
h.Controller.DrainOutbound();
|
||||||
|
|
||||||
Assert.Empty(h.Transport.Uses);
|
Assert.Equal(new[] { Target }, h.Transport.Uses);
|
||||||
|
Assert.Empty(h.Transport.Pickups);
|
||||||
|
|
||||||
h.CompletionLifetime.PublishNaturalCompletion();
|
h.CompletionLifetime.PublishNaturalCompletion();
|
||||||
h.Controller.DrainOutbound();
|
h.Controller.DrainOutbound();
|
||||||
|
|
||||||
Assert.Equal(new[] { Target }, h.Transport.Uses);
|
Assert.Equal(new[] { Target }, h.Transport.Uses);
|
||||||
|
Assert.Empty(h.Transport.Pickups);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RetiringControllerLifetimeInvalidatesQueuedArrival()
|
public void RetiringControllerLifetimeInvalidatesQueuedPickupArrival()
|
||||||
{
|
{
|
||||||
var h = new Harness();
|
var h = new Harness();
|
||||||
h.SetApproach(closeRange: true);
|
h.SetApproach(closeRange: true);
|
||||||
h.Controller.SendUse(Target);
|
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
|
||||||
h.CompletionLifetime.PublishNaturalCompletion();
|
h.CompletionLifetime.PublishNaturalCompletion();
|
||||||
|
|
||||||
h.Completions.RetireControllerLifetime(h.CompletionLifetime);
|
h.Completions.RetireControllerLifetime(h.CompletionLifetime);
|
||||||
h.Controller.DrainOutbound();
|
h.Controller.DrainOutbound();
|
||||||
|
|
||||||
Assert.Empty(h.Transport.Uses);
|
Assert.Empty(h.Transport.Pickups);
|
||||||
h.Controller.OnNaturalMoveToComplete();
|
h.Controller.OnNaturalMoveToComplete();
|
||||||
Assert.Empty(h.Transport.Uses);
|
Assert.Empty(h.Transport.Pickups);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,26 @@ public sealed class LiveEntityAnimationPresenterTests
|
||||||
Assert.Same(fixture.Record, context.LastRecord);
|
Assert.Same(fixture.Record, context.LastRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MotionDone_CurrentLogicalOwnerResolvesWhileSpatialProjectionIsWithdrawn()
|
||||||
|
{
|
||||||
|
var fixture = Build(partCount: 1, withSequencer: true);
|
||||||
|
var interpreter = new MotionInterpreter(new PhysicsBody());
|
||||||
|
interpreter.AddToQueue(0, MotionCommand.Ready, 0);
|
||||||
|
var context = new Context { Motion = interpreter };
|
||||||
|
var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), context);
|
||||||
|
|
||||||
|
Assert.True(fixture.Live.WithdrawLiveEntityProjection(Guid));
|
||||||
|
Assert.False(fixture.Record.IsSpatiallyProjected);
|
||||||
|
presenter.PrepareAnimation(fixture.Record, fixture.State);
|
||||||
|
Assert.NotNull(fixture.State.Sequencer!.MotionDoneTarget);
|
||||||
|
fixture.State.Sequencer!.MotionDoneTarget!(MotionCommand.Ready, true);
|
||||||
|
|
||||||
|
Assert.Equal(1, context.ResolveCount);
|
||||||
|
Assert.Same(fixture.Record, context.LastRecord);
|
||||||
|
Assert.False(interpreter.MotionsPending());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EffectPoseCallback_NestedRuntimeSnapshotDoesNotTruncatePresentation()
|
public void EffectPoseCallback_NestedRuntimeSnapshotDoesNotTruncatePresentation()
|
||||||
{
|
{
|
||||||
|
|
@ -535,13 +555,14 @@ public sealed class LiveEntityAnimationPresenterTests
|
||||||
private sealed class Context : ILiveAnimationPresentationContext
|
private sealed class Context : ILiveAnimationPresentationContext
|
||||||
{
|
{
|
||||||
public uint LocalPlayerGuid => 0x50000001u;
|
public uint LocalPlayerGuid => 0x50000001u;
|
||||||
|
public MotionInterpreter? Motion { get; init; }
|
||||||
public int ResolveCount { get; private set; }
|
public int ResolveCount { get; private set; }
|
||||||
public LiveEntityRecord? LastRecord { get; private set; }
|
public LiveEntityRecord? LastRecord { get; private set; }
|
||||||
public MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record)
|
public MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record)
|
||||||
{
|
{
|
||||||
ResolveCount++;
|
ResolveCount++;
|
||||||
LastRecord = record;
|
LastRecord = record;
|
||||||
return null;
|
return Motion;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using AcDream.App.Streaming;
|
using AcDream.App.Streaming;
|
||||||
|
using AcDream.App.World;
|
||||||
using AcDream.Core.Selection;
|
using AcDream.Core.Selection;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.Streaming;
|
namespace AcDream.App.Tests.Streaming;
|
||||||
|
|
@ -155,7 +156,7 @@ public sealed class WorldRevealCoordinatorTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PortalViewportReveal_ReopensWorldWithoutCompletingProtocolTail()
|
public void Materialization_ReopensWorldBeforePortalViewportRelease()
|
||||||
{
|
{
|
||||||
var availability = new WorldGenerationAvailabilityState();
|
var availability = new WorldGenerationAvailabilityState();
|
||||||
var world = new GpuWorldState(availability: availability);
|
var world = new GpuWorldState(availability: availability);
|
||||||
|
|
@ -180,6 +181,16 @@ public sealed class WorldRevealCoordinatorTests
|
||||||
WorldRevealKind.Portal,
|
WorldRevealKind.Portal,
|
||||||
0x3032001Cu);
|
0x3032001Cu);
|
||||||
|
|
||||||
|
Assert.False(availability.IsWorldAvailable);
|
||||||
|
|
||||||
|
coordinator.ObserveMaterialized();
|
||||||
|
|
||||||
|
Assert.True(availability.IsWorldAvailable);
|
||||||
|
Assert.False(coordinator.Snapshot.WorldViewportObserved);
|
||||||
|
Assert.False(coordinator.Snapshot.Completed);
|
||||||
|
Assert.Equal(1, audio.ResumeCalls);
|
||||||
|
Assert.Empty(scheduler.Ends);
|
||||||
|
|
||||||
coordinator.RevealWorldViewport();
|
coordinator.RevealWorldViewport();
|
||||||
coordinator.RevealWorldViewport();
|
coordinator.RevealWorldViewport();
|
||||||
|
|
||||||
|
|
@ -195,6 +206,53 @@ public sealed class WorldRevealCoordinatorTests
|
||||||
Assert.Equal([generation], scheduler.Ends);
|
Assert.Equal([generation], scheduler.Ends);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TunnelContinue_TicksDestinationObjectsBehindPortalViewport()
|
||||||
|
{
|
||||||
|
var availability = new WorldGenerationAvailabilityState();
|
||||||
|
var world = new GpuWorldState(availability: availability);
|
||||||
|
var quiescence = new WorldGenerationQuiescence(
|
||||||
|
availability,
|
||||||
|
new SelectionState(),
|
||||||
|
world,
|
||||||
|
audio: null);
|
||||||
|
var scheduler = new RecordingDestinationScheduler();
|
||||||
|
var coordinator = new WorldRevealCoordinator(
|
||||||
|
isRenderNeighborhoodReady: (_, _) => true,
|
||||||
|
isSpawnCellReady: _ => true,
|
||||||
|
isTerrainNeighborhoodReady: (_, _) => true,
|
||||||
|
areCompositeTexturesReady: () => true,
|
||||||
|
prepareCompositeTextures: (_, _) => { },
|
||||||
|
invalidateCompositeTextures: () => { },
|
||||||
|
isSpawnClaimUnhydratable: _ => false,
|
||||||
|
quiescence: quiescence,
|
||||||
|
streaming: scheduler);
|
||||||
|
var calls = new List<string>();
|
||||||
|
var frame = new RetailLiveFrameCoordinator(
|
||||||
|
new global::AcDream.App.Tests.TestLiveObjectFramePhase(
|
||||||
|
_ => calls.Add("objects")),
|
||||||
|
world,
|
||||||
|
new global::AcDream.App.Tests.TestLiveSessionFramePhase(
|
||||||
|
() => calls.Add("network")),
|
||||||
|
new global::AcDream.App.Tests.TestPostNetworkCommandFramePhase(
|
||||||
|
() => calls.Add("commands")),
|
||||||
|
new global::AcDream.App.Tests.TestLiveSpatialReconcilePhase(
|
||||||
|
() => calls.Add("spatial")),
|
||||||
|
availability);
|
||||||
|
|
||||||
|
coordinator.Begin(WorldRevealKind.Portal, 0x3032001Cu);
|
||||||
|
frame.Tick(1f / 60f);
|
||||||
|
Assert.Equal(["network", "commands"], calls);
|
||||||
|
|
||||||
|
calls.Clear();
|
||||||
|
coordinator.ObserveMaterialized();
|
||||||
|
frame.Tick(1f / 60f);
|
||||||
|
|
||||||
|
Assert.Equal(["objects", "network", "commands", "spatial"], calls);
|
||||||
|
Assert.Empty(scheduler.Ends);
|
||||||
|
Assert.False(coordinator.Snapshot.WorldViewportObserved);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RevealGeneration_OwnsExactDestinationReservationUntilCompletion()
|
public void RevealGeneration_OwnsExactDestinationReservationUntilCompletion()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue