diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 0930967f..737e4e84 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 **Status:** DONE — 2026-07-23; corrected creature presentation visual gate pending diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index e707cec6..c22f4836 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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-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-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-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 | @@ -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-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-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-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 | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 2b454517..93973289 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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 the delivery defect beneath that classifier: carried Use had been routed through the world-object approach query and silently cancelled because -inventory objects have no spatial entity. Owned direct-use now sends -immediately, while the registered AD-27 approach seam remains world-only; -shared AutoWear reports the exact overlapping worn item that must be removed. -That correction is user-confirmed. The requested adjacent cooldown follow-up + inventory objects have no spatial entity. Ordinary Use now sends immediately + for both owned and world objects, matching `ItemHolder::UseObject`; ACE owns + distant approach and AD-27 remains pickup-only. The connected distant-NPC + 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 cooldown-enchantment lookup, the exact ten-step bucket formula, and DAT sprites `0x060067CF..0x060067D8` through the shared retained `UiItemSlot` diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index dffe763e..03b0658e 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -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 matching binary. `ItemUses::IsUseable @ 0x004FCCC0` tests only `USEABLE_NO`; reset/absent value zero is usable. `ItemHolder::UseObject @ 0x00588A80` sends a -carried direct-use item immediately; acdream had incorrectly routed that -packet through a world approach lookup, where Blackmoor's Favor has no spatial -entity and was silently cancelled. Owned Use now bypasses that query while -world-object Use retains the registered AD-27 approach seam. AutoWear applies + Use event immediately for both owned and world objects; acdream had + incorrectly routed the packet through a local approach lookup, where + Blackmoor's Favor has no spatial entity and was silently cancelled. Every + 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 toolbar-hand path, resolves the overlapping worn object from the retail-ordered equipment projection, and emits the exact system line diff --git a/docs/research/2026-07-15-retail-portal-space-pseudocode.md b/docs/research/2026-07-15-retail-portal-space-pseudocode.md index e1b5c945..70ff3568 100644 --- a/docs/research/2026-07-15-retail-portal-space-pseudocode.md +++ b/docs/research/2026-07-15-retail-portal-space-pseudocode.md @@ -446,6 +446,57 @@ state through `CPhysicsObj::set_state` `0x00514DD0`. Separately, `CPhysicsObj::UpdatePositionInternal` `0x00512C30` skips `CPartArray::Update` 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 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 diff --git a/docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md b/docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md index 6435025c..f850d389 100644 --- a/docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md +++ b/docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md @@ -55,11 +55,11 @@ both pass `IsUseable`. For a direct-use item, `ItemHolder::UseObject` then sends `CM_Inventory::Event_UseEvent`, increments the UI busy count, and calls -`CPlayerSystem::UsingItem`. That latter presentation function handles local -pickup/wield/container state; it does not require a carried source to have a -world position. Therefore an owned inventory object must cross the wire -directly. Only world objects participate in acdream's registered AD-27 local -approach compatibility seam. +`CPlayerSystem::UsingItem`. `Event_UseEvent` itself is the network send +(`0xF7B1/0x36`); the latter function handles local pickup/wield/container +presentation. Therefore neither an owned item nor a world object may be held +behind a client-side movement completion. ACE owns the authoritative approach +chain. ```text 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 through the world-approach query. ACE remains authoritative for its spell and consumption outcome. -- `SelectionInteractionController` keeps the AD-27 turn/approach seam only for - non-owned spatial objects. Owned self, equipped, and nested-container sources - have no live world pose and send immediately. +- `SelectionInteractionController` sends accepted ordinary Use immediately for + both owned and spatial objects. It does not install speculative local + TurnToObject/MoveToObject movement; ACE's authoritative movement packets use + the shared retail `MoveToManager`. - `AutoWieldController` remains the shared inventory/paperdoll equip owner. It resolves AutoWear blockers from `ClientObjectTable`'s retail-ordered equipment projection and emits the retail literal through system chat. diff --git a/docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md b/docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md index 4cc583be..da92749b 100644 --- a/docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md +++ b/docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md @@ -3,8 +3,8 @@ Date: 2026-07-23 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 -crosses acdream's close-range turn/approach boundary. +`You can only move or use one item at a time` gate from an accepted object/NPC +Use through its authoritative completion. ## 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 confirm the one Use action / one UseDone event protocol pair. -The existing close-range compatibility seam is recorded as divergence AD-27: -acdream may finish its local turn before emitting Use. That seam creates a -real pre-wire interval which retail does not have, so it needs explicit -ownership rather than pretending a server completion is already owed. +The connected 2026-07-25 regression trace proved why the old AD-27 Use branch +was incorrect: a pending local Ready animation could starve the speculative +TurnToObject, so no Use packet ever reached ACE. Retail has no such dependency. +`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 @@ -62,37 +78,15 @@ RequestUse(object, reservation): reservation.CancelBeforeDispatch() return - if object is carried or equipped by the player: - send Use immediately - transfer reservation to UseDone - return - - if world identity/approach is invalid: + if a non-owned world object is not usable: reservation.CancelBeforeDispatch() 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: reservation.MarkDispatched() else: 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(): // Ownership transfers to the server completion. Keep busyCount unchanged. make later local cancellation inert @@ -117,10 +111,16 @@ retired session cannot decrement a new session's busy reference. ## Regression evidence -The connected trace showed successful NPC Use packets and matching -`UseDone(0)`. A managed heap snapshot after the final completion showed -`ItemInteractionController._busyCount == 0`, no pending inventory request, and -no pending auto-wield transaction. The permanent failure path was instead the -pre-wire close-range branch: it incremented busy before local movement, but -cancellation or stale identity discarded the pending send, leaving no packet -and therefore no possible `UseDone`. +The 2026-07-25 Alcott trace captured the failure before correction: + +```text +[input] UseSelected Press +[autowalk-pending] count=1 nodes=[0x41000003/ctx=0/jump=0x0] +[autowalk-target] object=0x8000D085 status=Ok ... +``` + +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`. diff --git a/docs/research/2026-07-25-portal-regression-closeout.md b/docs/research/2026-07-25-portal-regression-closeout.md index ee54c690..e92216ec 100644 --- a/docs/research/2026-07-25-portal-regression-closeout.md +++ b/docs/research/2026-07-25-portal-regression-closeout.md @@ -17,12 +17,16 @@ retained-source re-gate is tracked separately below. ## 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 - world viewport, releases cell blocking, and starts the final one-second - WorldFadeIn state. -- The later WorldFadeIn completion sends LoginComplete and ends logical - teleport state. Viewport release and protocol completion are distinct. + world viewport, releases the destination render reservation, and starts the + final one-second WorldFadeIn state. +- WorldFadeIn completion sends LoginComplete and ends logical teleport state. + Materialization, viewport release, and protocol completion are three + distinct edges. - `gmPaperDollUI::RedressCreature @ 0x004A3BC0` creates the private inventory object only when absent and a SmartBox player is available; temporary player absence does not delete an existing private object. diff --git a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs index da50fc5c..3b9868f3 100644 --- a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs +++ b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs @@ -53,7 +53,9 @@ internal sealed class LocalPlayerControllerSlot /// and completes StopCompletely, so its MotionDone relay must reach the /// candidate interpreter before the complete controller is published. /// - public IDisposable BeginMotionPreparation(PlayerMovementController controller) + public IDisposable BeginMotionPreparation( + PlayerMovementController controller, + Action? drainPriorAnimationQueue = null) { ArgumentNullException.ThrowIfNull(controller); if (_preparingMotionOwner is not null) @@ -62,6 +64,12 @@ internal sealed class LocalPlayerControllerSlot "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; return new MotionPreparation(this, controller); } diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs index c4a3dfa5..cab9c996 100644 --- a/src/AcDream.App/Input/PlayerModeController.cs +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -368,6 +368,7 @@ internal sealed class PlayerModeController : ApplyStepHeights(controller, playerEntity); uint initialCellId = ResolveInitialCell(playerGuid, playerEntity); + Action? drainPriorAnimationQueue = null; if (_animations.TryGetValue(playerEntity.Id, out LiveEntityAnimationState? animation) && animation.Sequencer is { } sequencer) { @@ -384,6 +385,7 @@ internal sealed class PlayerModeController : sequencer.Manager.CheckForCompletedMotions; controller.Motion.DefaultSink = new MotionTableDispatchSink(sequencer); + drainPriorAnimationQueue = sequencer.Manager.HandleEnterWorld; } // Retail CPhysicsObj owns CMotionInterp and CPartArray throughout @@ -393,7 +395,9 @@ internal sealed class PlayerModeController : // public controller slot remains unpublished until every other // player-mode edge has prepared successfully. using IDisposable motionPreparation = - _controllerSlot.BeginMotionPreparation(controller); + _controllerSlot.BeginMotionPreparation( + controller, + drainPriorAnimationQueue); ResolveResult initial = _physics.Resolve( playerEntity.Position, diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index 097f23e1..1efe1ed0 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -24,21 +24,19 @@ internal sealed class SelectionInteractionController private readonly OutboundInteractionQueue _outbound = new(); private readonly PlayerApproachCompletionState _approachCompletions; private readonly Action? _toast; - private PendingPostArrivalAction? _pendingPostArrival; + private PendingPostArrivalPickup? _pendingPostArrival; private readonly record struct QueuedInteractionIdentity( uint ServerGuid, uint? LocalEntityId, ClientObject? ClientObject); - private readonly record struct PendingPostArrivalAction( + private readonly record struct PendingPostArrivalPickup( uint ServerGuid, uint LocalEntityId, - bool IsPickup, uint DestinationContainerId, int Placement, ulong PendingPlacementToken, - ItemUseRequestReservation? UseReservation, PlayerApproachToken ApproachToken); public SelectionInteractionController( @@ -238,46 +236,23 @@ internal sealed class SelectionInteractionController return; } - // ItemHolder::UseObject @ 0x00588A80 sends Event_UseEvent for an - // accepted carried-item activation before CPlayerSystem::UsingItem. - // A carried source has no world pose to approach: routing it through - // TryGetApproach drops the packet because inventory objects are not - // live spatial entities. + // ItemHolder::UseObject @ 0x00588A80 sends Event_UseEvent before + // CPlayerSystem::UsingItem for every accepted ordinary Use. The latter + // is presentation/local inventory policy; it never delays the wire + // request behind a client-side TurnTo/MoveTo completion. ACE owns the + // authoritative approach chain for spatial targets. if (_items.IsOwnedByPlayer(serverGuid)) { SendUseNow(serverGuid, reservation); return; } - if (!_query.IsUseable(serverGuid) - || !_query.TryGetApproach(serverGuid, out InteractionApproach approach)) + if (!_query.IsUseable(serverGuid)) { reservation?.CancelBeforeDispatch(); 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); } @@ -327,14 +302,12 @@ internal sealed class SelectionInteractionController if (approach.IsCloseRange) { - var pending = new PendingPostArrivalAction( + var pending = new PendingPostArrivalPickup( itemGuid, approach.Target.LocalEntityId, - IsPickup: true, destinationContainerId, placement, pendingPlacementToken, - UseReservation: null, ApproachToken: default); bool started = _movement.BeginApproach( approach, @@ -386,46 +359,33 @@ internal sealed class SelectionInteractionController _pendingPostArrival = null; if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId)) { - if (pending.IsPickup) - CancelPickupPresentation( - pending.ServerGuid, - pending.PendingPlacementToken); - else - pending.UseReservation?.CancelBeforeDispatch(); + CancelPickupPresentation( + pending.ServerGuid, + pending.PendingPlacementToken); 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.DestinationContainerId, pending.Placement, - 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 + out _), + pending.PendingPlacementToken)) { - if (_transport.TrySendUse(pending.ServerGuid, out _)) - pending.UseReservation?.MarkDispatched(); - else - pending.UseReservation?.CancelBeforeDispatch(); + CancelPickupPresentation( + pending.ServerGuid, + pending.PendingPlacementToken); } } @@ -548,12 +508,9 @@ internal sealed class SelectionInteractionController if (_pendingPostArrival is not { } pending) return; _pendingPostArrival = null; - if (pending.IsPickup) - CancelPickupPresentation( - pending.ServerGuid, - pending.PendingPlacementToken); - else - pending.UseReservation?.CancelBeforeDispatch(); + CancelPickupPresentation( + pending.ServerGuid, + pending.PendingPlacementToken); } private void CancelPickupPresentation(uint itemGuid, ulong token = 0u) diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs index 45768801..2ab4e4a5 100644 --- a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs +++ b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs @@ -44,7 +44,12 @@ internal sealed class LiveEntityAnimationPresenter ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(animation); 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) || animation.Sequencer is not { MotionDoneTarget: null } sequencer) { @@ -55,7 +60,12 @@ internal sealed class LiveEntityAnimationPresenter sequencer.MotionDoneTarget = (motion, success) => { 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(animation.Entity, capturedEntity)) { diff --git a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs index f63b9e2e..1813bbb4 100644 --- a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs +++ b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs @@ -39,6 +39,7 @@ internal sealed class WorldRevealCoordinator private readonly IWorldRevealStreamingScheduler? _streaming; private readonly IWorldRevealRenderResourceScheduler? _renderResources; private long _activeGeneration; + private bool _worldSimulationReleased; private bool _worldViewportReleased; public WorldRevealCoordinator( @@ -80,6 +81,7 @@ internal sealed class WorldRevealCoordinator _readiness.Begin(); long generation = _lifecycle.Begin(kind); _activeGeneration = generation; + _worldSimulationReleased = false; _worldViewportReleased = false; _quiescence?.Begin(generation); _streaming?.BeginDestinationReservation( @@ -107,7 +109,22 @@ internal sealed class WorldRevealCoordinator return snapshot; } - public void ObserveMaterialized() => _lifecycle.ObserveMaterialized(); + /// + /// Commits the accepted destination and resumes its hidden world + /// simulation while the private portal viewport remains in front. + /// + /// + /// Retail SmartBox::UseTime @ 0x00455410 resumes + /// CObjectMaint/CPhysics before + /// gmSmartBoxUI::EndTeleportAnimation @ 0x004D65A0 enters + /// TAS_TUNNEL_CONTINUE. The normal world viewport is not restored + /// until the later TUNNEL_FADE_OUT -> WORLD_FADE_IN edge. + /// + public void ObserveMaterialized() + { + _lifecycle.ObserveMaterialized(); + ReleaseWorldSimulation(); + } public void ObserveWorldViewportVisible() => _lifecycle.ObserveWorldViewportVisible(); @@ -116,9 +133,10 @@ internal sealed class WorldRevealCoordinator _lifecycle.ObserveWait(elapsed); /// - /// Reopens the destination world at retail's TunnelFadeOut -> - /// WorldFadeIn viewport swap without completing the one-second - /// WorldFadeIn/LoginComplete tail. + /// Releases the destination streaming/resource reservation at retail's + /// TunnelFadeOut -> WorldFadeIn viewport swap without completing the + /// one-second WorldFadeIn/LoginComplete tail. World simulation already + /// resumed when the accepted destination materialized. /// public void RevealWorldViewport() { @@ -128,7 +146,6 @@ internal sealed class WorldRevealCoordinator _streaming?.EndDestinationReservation(generation); _renderResources?.EndDestinationReveal(generation); - _quiescence?.End(generation); _worldViewportReleased = true; } @@ -150,8 +167,20 @@ internal sealed class WorldRevealCoordinator if (generation == 0) return; + ReleaseWorldSimulation(); RevealWorldViewport(); _activeGeneration = 0; + _worldSimulationReleased = false; _worldViewportReleased = false; } + + private void ReleaseWorldSimulation() + { + long generation = _activeGeneration; + if (generation == 0 || _worldSimulationReleased) + return; + + _quiescence?.End(generation); + _worldSimulationReleased = true; + } } diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 95109d79..378cac47 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -1901,6 +1901,18 @@ public sealed class LiveEntityRuntime && _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexed) && ReferenceEquals(indexed, animation); + /// + /// Returns whether 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. + /// + internal bool IsCurrentAnimationOwner( + LiveEntityRecord record, + ILiveEntityAnimationRuntime animation) => + IsCurrentRecord(record) + && ReferenceEquals(record.AnimationRuntime, animation); + /// /// Copies the currently spatial animation owners through the concrete /// dictionary enumerator. This keeps per-frame traversal allocation-free; diff --git a/tests/AcDream.App.Tests/Input/LocalPlayerControllerSlotTests.cs b/tests/AcDream.App.Tests/Input/LocalPlayerControllerSlotTests.cs index 17a9eab0..6e891311 100644 --- a/tests/AcDream.App.Tests/Input/LocalPlayerControllerSlotTests.cs +++ b/tests/AcDream.App.Tests/Input/LocalPlayerControllerSlotTests.cs @@ -42,6 +42,32 @@ public sealed class LocalPlayerControllerSlotTests 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(); + + 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] public void MotionPreparation_RejectsConcurrentCandidate() { diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index 001de805..27b0bc10 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -295,15 +295,15 @@ public sealed class SelectionInteractionControllerTests } [Fact] - public void CloseUseTurnsFirstThenSendsExactlyOnceOnNaturalCompletion() + public void CloseUseSendsImmediatelyWithoutSpeculativeMovement() { var h = new Harness(); h.SetApproach(closeRange: true); h.Controller.SendUse(Target); - Assert.Single(h.Movement.Approaches); - Assert.Empty(h.Transport.Uses); + Assert.Empty(h.Movement.Approaches); + Assert.Equal(new[] { Target }, h.Transport.Uses); h.Controller.OnNaturalMoveToComplete(); h.Controller.OnNaturalMoveToComplete(); @@ -312,7 +312,7 @@ public sealed class SelectionInteractionControllerTests } [Fact] - public void CancelledDeferredUseReleasesBusyWithoutServerCompletion() + public void AcceptedUseRemainsBusyUntilAuthoritativeUseDone() { var h = new Harness(); h.SetApproach(closeRange: true); @@ -322,18 +322,22 @@ public sealed class SelectionInteractionControllerTests h.Controller.DrainOutbound(); Assert.Equal(1, h.Items.BusyCount); - Assert.Empty(h.Transport.Uses); + Assert.Equal(new[] { Target }, h.Transport.Uses); h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled); 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.True(h.Items.CanMakeInventoryRequest); - Assert.Empty(h.Transport.Uses); } [Fact] - public void DispatchedDeferredUseRemainsBusyUntilAuthoritativeUseDone() + public void DispatchedWorldUseRemainsBusyUntilAuthoritativeUseDone() { var h = new Harness(); h.SetApproach(closeRange: true); @@ -341,8 +345,6 @@ public sealed class SelectionInteractionControllerTests h.Controller.HandleInputAction(InputAction.UseSelected); h.Controller.DrainOutbound(); - h.CompletionLifetime.PublishNaturalCompletion(); - h.Controller.DrainOutbound(); Assert.Equal(new[] { Target }, h.Transport.Uses); Assert.Equal(1, h.Items.BusyCount); @@ -354,7 +356,7 @@ public sealed class SelectionInteractionControllerTests } [Fact] - public void FailedDeferredUseStartReleasesItsBusyReservation() + public void WorldUseDoesNotDependOnLocalApproachInstallation() { var h = new Harness(); h.SetApproach(closeRange: true); @@ -364,32 +366,27 @@ public sealed class SelectionInteractionControllerTests h.Controller.HandleInputAction(InputAction.UseSelected); h.Controller.DrainOutbound(); - Assert.Equal(0, h.Items.BusyCount); - Assert.True(h.Items.CanMakeInventoryRequest); - Assert.Empty(h.Transport.Uses); + Assert.Equal(1, h.Items.BusyCount); + Assert.Equal(new[] { Target }, h.Transport.Uses); + Assert.Empty(h.Movement.Approaches); } [Fact] - public void StaleDeferredUseReleasesItsBusyReservation() + public void RejectedWorldUseReleasesItsBusyReservation() { var h = new Harness(); - h.SetApproach(closeRange: true); + h.Query.Useable = false; h.Selection.Select(Target, SelectionChangeSource.World); h.Controller.HandleInputAction(InputAction.UseSelected); 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.Empty(h.Transport.Uses); } [Fact] - public void SynchronousTurnCompletionTransfersBusyOwnershipToUseDone() + public void SynchronousMovementCallbackCannotDuplicateWorldUse() { var h = new Harness(); h.SetApproach(closeRange: true); @@ -408,7 +405,7 @@ public sealed class SelectionInteractionControllerTests } [Fact] - public void FarUseSendsImmediatelyAndNaturalCompletionDoesNotRetry() + public void FarUseSendsImmediatelyWithoutClientApproachAndDoesNotRetry() { var h = new Harness(); h.SetApproach(closeRange: false); @@ -416,7 +413,7 @@ public sealed class SelectionInteractionControllerTests h.Controller.SendUse(Target); h.Controller.OnNaturalMoveToComplete(); - Assert.Single(h.Movement.Approaches); + Assert.Empty(h.Movement.Approaches); Assert.Equal(new[] { Target }, h.Transport.Uses); } @@ -448,21 +445,21 @@ public sealed class SelectionInteractionControllerTests } [Fact] - public void HiddenOrStaleIncarnationCancelsDeferredAction() + public void HiddenOrStaleNotificationsCannotRetractOrDuplicateDispatchedUse() { var hidden = new Harness(); hidden.SetApproach(closeRange: true); hidden.Controller.SendUse(Target); hidden.Controller.OnEntityHidden(Target); hidden.Controller.OnNaturalMoveToComplete(); - Assert.Empty(hidden.Transport.Uses); + Assert.Equal(new[] { Target }, hidden.Transport.Uses); var stale = new Harness(); stale.SetApproach(closeRange: true); stale.Controller.SendUse(Target); stale.Query.Current = false; stale.Controller.OnNaturalMoveToComplete(); - Assert.Empty(stale.Transport.Uses); + Assert.Equal(new[] { Target }, stale.Transport.Uses); } [Fact] @@ -487,7 +484,7 @@ public sealed class SelectionInteractionControllerTests h.SetApproach(closeRange: true); h.Selection.Select(Target, SelectionChangeSource.World); h.Controller.HandleInputAction(InputAction.SelectionPickUp); - h.Controller.SendUse(Target); + h.Controller.HandleInputAction(InputAction.UseSelected); h.Items.InteractionState.EnterExamine(); h.Controller.ResetSession(); @@ -501,12 +498,12 @@ public sealed class SelectionInteractionControllerTests } [Fact] - public void OldRemovalClearsCapturedActionButDoesNotClearReplacementSelection() + public void OldRemovalClearsCapturedPickupButDoesNotClearReplacementSelection() { var h = new Harness(); h.SetApproach(closeRange: true, localEntityId: 101u); h.Selection.Select(Target, SelectionChangeSource.World); - h.Controller.SendUse(Target); + Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); var oldRecord = new LiveEntityRecord(Spawn(Target, instance: 1)); oldRecord.WorldEntity = new WorldEntity { @@ -522,11 +519,12 @@ public sealed class SelectionInteractionControllerTests h.Controller.OnNaturalMoveToComplete(); 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] - public void SynchronousNaturalCompletionObservesTheArmedCloseAction() + public void SynchronousNaturalCompletionCannotDuplicateUse() { var h = new Harness(); h.SetApproach(closeRange: true); @@ -539,16 +537,16 @@ public sealed class SelectionInteractionControllerTests } [Fact] - public void SynchronousResetCannotResurrectTheCloseAction() + public void SynchronousResetCannotResurrectACancelledPickup() { var h = new Harness(); h.SetApproach(closeRange: true); h.Movement.AfterArm = h.Controller.ResetSession; - h.Controller.SendUse(Target); + Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); h.Controller.OnNaturalMoveToComplete(); - Assert.Empty(h.Transport.Uses); + Assert.Empty(h.Transport.Pickups); } [Fact] @@ -569,7 +567,7 @@ public sealed class SelectionInteractionControllerTests } [Fact] - public void ErrorCompletionClearsCloseActionBeforeALaterSuccess() + public void ErrorCompletionCannotRetractOrDuplicateDispatchedUse() { var h = new Harness(); h.SetApproach(closeRange: true); @@ -578,48 +576,45 @@ public sealed class SelectionInteractionControllerTests h.Controller.OnMoveToCancelled(WeenieError.NoObject); h.Controller.OnNaturalMoveToComplete(); - Assert.Empty(h.Transport.Uses); + Assert.Equal(new[] { Target }, h.Transport.Uses); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void DeferredCompletionFromPriorApproachCannotAffectReplacement( - bool priorCompletedNaturally) + [Fact] + public void CompletionFromPriorPickupCannotAffectReplacement() { var h = new Harness(); h.SetApproach(closeRange: true); - h.Controller.SendUse(Target); - if (priorCompletedNaturally) - h.CompletionLifetime.PublishNaturalCompletion(); - else - h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled); + Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); + h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled); - h.Controller.SendUse(Target); + h.Selection.Select(Target, SelectionChangeSource.World); + h.Controller.HandleInputAction(InputAction.UseSelected); h.Controller.DrainOutbound(); - Assert.Empty(h.Transport.Uses); + Assert.Equal(new[] { Target }, h.Transport.Uses); + Assert.Empty(h.Transport.Pickups); h.CompletionLifetime.PublishNaturalCompletion(); h.Controller.DrainOutbound(); Assert.Equal(new[] { Target }, h.Transport.Uses); + Assert.Empty(h.Transport.Pickups); } [Fact] - public void RetiringControllerLifetimeInvalidatesQueuedArrival() + public void RetiringControllerLifetimeInvalidatesQueuedPickupArrival() { var h = new Harness(); h.SetApproach(closeRange: true); - h.Controller.SendUse(Target); + Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); h.CompletionLifetime.PublishNaturalCompletion(); h.Completions.RetireControllerLifetime(h.CompletionLifetime); h.Controller.DrainOutbound(); - Assert.Empty(h.Transport.Uses); + Assert.Empty(h.Transport.Pickups); h.Controller.OnNaturalMoveToComplete(); - Assert.Empty(h.Transport.Uses); + Assert.Empty(h.Transport.Pickups); } [Fact] diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs index 0450e6d3..380f5f13 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs @@ -219,6 +219,26 @@ public sealed class LiveEntityAnimationPresenterTests 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] public void EffectPoseCallback_NestedRuntimeSnapshotDoesNotTruncatePresentation() { @@ -535,13 +555,14 @@ public sealed class LiveEntityAnimationPresenterTests private sealed class Context : ILiveAnimationPresentationContext { public uint LocalPlayerGuid => 0x50000001u; + public MotionInterpreter? Motion { get; init; } public int ResolveCount { get; private set; } public LiveEntityRecord? LastRecord { get; private set; } public MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record) { ResolveCount++; LastRecord = record; - return null; + return Motion; } } diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs index 6b18fc41..c8a20886 100644 --- a/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs +++ b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs @@ -1,4 +1,5 @@ using AcDream.App.Streaming; +using AcDream.App.World; using AcDream.Core.Selection; namespace AcDream.App.Tests.Streaming; @@ -155,7 +156,7 @@ public sealed class WorldRevealCoordinatorTests } [Fact] - public void PortalViewportReveal_ReopensWorldWithoutCompletingProtocolTail() + public void Materialization_ReopensWorldBeforePortalViewportRelease() { var availability = new WorldGenerationAvailabilityState(); var world = new GpuWorldState(availability: availability); @@ -180,6 +181,16 @@ public sealed class WorldRevealCoordinatorTests WorldRevealKind.Portal, 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(); @@ -195,6 +206,53 @@ public sealed class WorldRevealCoordinatorTests 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(); + 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] public void RevealGeneration_OwnsExactDestinationReservationUntilCompletion() {