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
|
||||
|
||||
**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-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 |
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -24,21 +24,19 @@ internal sealed class SelectionInteractionController
|
|||
private readonly OutboundInteractionQueue _outbound = new();
|
||||
private readonly PlayerApproachCompletionState _approachCompletions;
|
||||
private readonly Action<string>? _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,17 +359,12 @@ 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();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending.IsPickup)
|
||||
{
|
||||
if (!IsCurrentPickupPresentation(
|
||||
pending.ServerGuid,
|
||||
pending.DestinationContainerId,
|
||||
|
|
@ -420,14 +388,6 @@ internal sealed class SelectionInteractionController
|
|||
pending.PendingPlacementToken);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_transport.TrySendUse(pending.ServerGuid, out _))
|
||||
pending.UseReservation?.MarkDispatched();
|
||||
else
|
||||
pending.UseReservation?.CancelBeforeDispatch();
|
||||
}
|
||||
}
|
||||
|
||||
public void DrainOutbound()
|
||||
{
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
private void CancelPickupPresentation(uint itemGuid, ulong token = 0u)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
/// <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() =>
|
||||
_lifecycle.ObserveWorldViewportVisible();
|
||||
|
|
@ -116,9 +133,10 @@ internal sealed class WorldRevealCoordinator
|
|||
_lifecycle.ObserveWait(elapsed);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1901,6 +1901,18 @@ public sealed class LiveEntityRuntime
|
|||
&& _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexed)
|
||||
&& 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>
|
||||
/// Copies the currently spatial animation owners through the concrete
|
||||
/// dictionary enumerator. This keeps per-frame traversal allocation-free;
|
||||
|
|
|
|||
|
|
@ -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<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]
|
||||
public void MotionPreparation_RejectsConcurrentCandidate()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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]
|
||||
public void RevealGeneration_OwnsExactDestinationReservationUntilCompletion()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue