fix(world): restore portal and distant use lifecycles

This commit is contained in:
Erik 2026-07-25 09:59:25 +02:00
parent c730632075
commit 1a14812c44
18 changed files with 401 additions and 196 deletions

View file

@ -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

View file

@ -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 12 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 |

View file

@ -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`

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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`.

View file

@ -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.

View file

@ -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);
}

View file

@ -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,

View file

@ -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)

View file

@ -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))
{

View file

@ -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 -&gt; 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 -&gt;
/// WorldFadeIn viewport swap without completing the one-second
/// WorldFadeIn/LoginComplete tail.
/// Releases the destination streaming/resource reservation at retail's
/// TunnelFadeOut -&gt; 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;
}
}

View file

@ -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;

View file

@ -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()
{

View file

@ -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]

View file

@ -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;
}
}

View file

@ -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()
{