refactor(world): canonicalize live physics host ownership

This commit is contained in:
Erik 2026-07-21 14:05:34 +02:00
parent 5882b308c1
commit fcb66198fc
21 changed files with 1546 additions and 323 deletions

View file

@ -61,7 +61,7 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 2. Adaptation (AD) — 37 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover) ## 2. Adaptation (AD) — 38 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -103,6 +103,7 @@ accepted-divergence entries (#96, #49, #50).
| AD-42 | Enter-world placement is split across two Core calls: legacy `Resolve` performs retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` runs the verbatim object-aware `find_placement_pos` ring search. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.App/Rendering/GameWindow.cs` (`EnterPlayerModeNow`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. Keeping the split preserves the proven indoor-login snap while adding the missing occupied-position behavior | A spawn that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 | | AD-42 | Enter-world placement is split across two Core calls: legacy `Resolve` performs retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` runs the verbatim object-aware `find_placement_pos` ring search. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.App/Rendering/GameWindow.cs` (`EnterPlayerModeNow`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. Keeping the split preserves the proven indoor-login snap while adding the missing occupied-position behavior | A spawn that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 |
| AD-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 | | AD-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 |
| AD-44 | acdream has no retained character-management screen: startup deterministically selects the first active, non-greyed CharacterList identity, and native-window close performs retail's complete character-logoff handshake plus transport disconnect before exiting instead of returning to character selection. One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.App/Rendering/GameWindow.cs` (live-session bootstrap, moving to `LiveSessionController` in Slice 3); `src/AcDream.Core.Net/WorldSession.cs` (`SelectCharacterForEnterWorld`, `Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | This preserves unattended startup and immediate ACE endpoint release while validating that the chosen identity is active/non-greyed and using the server's canonical account. A future retained character-management owner is separate UI/session work. | An account with multiple playable characters enters the first wire-order identity without retail's explicit choice. An eventual in-client "log off character" action cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` | | AD-44 | acdream has no retained character-management screen: startup deterministically selects the first active, non-greyed CharacterList identity, and native-window close performs retail's complete character-logoff handshake plus transport disconnect before exiting instead of returning to character selection. One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.App/Rendering/GameWindow.cs` (live-session bootstrap, moving to `LiveSessionController` in Slice 3); `src/AcDream.Core.Net/WorldSession.cs` (`SelectCharacterForEnterWorld`, `Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | This preserves unattended startup and immediate ACE endpoint release while validating that the chosen identity is active/non-greyed and using the server's canonical account. A future retained character-management owner is separate UI/session work. | An account with multiple playable characters enters the first wire-order identity without retail's explicit choice. An eventual in-client "log off character" action cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` |
| AD-45 | App teardown can overlap a newer `INSTANCE_TS` record after retiring the old active identity. `TargetManager` therefore retains the exact target host and each `TargettedVoyeurInfo` retains the exact watcher host; unsubscribe, Sticky live-target reads, inbound sender validation, and ExitWorld delivery compare/use those pointer-like tokens rather than resolving a reused GUID. Retail stores only GUIDs because `DeleteObject` finishes `exit_world`/`leave_world` while the retiring `CPhysicsObj` remains the sole object-table entry. | `src/AcDream.Core/Physics/Motion/TargetManager.cs`; `StickyManager.cs`; `TargettedVoyeurInfo.cs`; `IPhysicsObjHost` exact relationship seams | This preserves retail's effective object-pointer identity while allowing App resource teardown to fail and retry without blocking an accepted newer server generation. Ordinary `GetObjectA` remains active-record-only, so tombstones cannot accept new relationships. | If any target/voyeur path bypasses the exact token, retrying an old teardown can remove or notify a newer same-GUID relationship, or Sticky can steer toward the replacement; retained tokens also keep the small manager graph alive until teardown converges. | `CPhysicsObj::exit_world @ 0x00514E60`; `CObjectMaint::DeleteObject(CPhysicsObj*) @ 0x00508460`; `ACCObjectMaint::DeleteObject(uint) @ 0x005576F0`; `TargetManager::SetTarget @ 0x0051AC30`; `ClearTarget @ 0x0051A7E0`; `AddVoyeur @ 0x0051A830`; `RemoveVoyeur @ 0x0051AD90` |
--- ---
@ -255,7 +256,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) | | ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
| TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` | | TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` |
| TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` | | TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` |
| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Rendering/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` | | TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Rendering/GameWindow.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` |
| TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; frame drain in `src/AcDream.App/Rendering/GameWindow.cs` | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order | | TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; frame drain in `src/AcDream.App/Rendering/GameWindow.cs` | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order |
| TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each object's ParticleManager and ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` also advances that static owner's managers using its whole admitted elapsed interval. | `src/AcDream.App/Rendering/GameWindow.cs` (`AdvanceLiveObjectRuntime` final `_particleSystem.Tick(dt)` / `_scriptRunner.Tick(...)`) | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and manager order faithful without pretending the shared tails have per-owner timing. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static default scripts/particles use render elapsed rather than `animate_static_object` elapsed/discard timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum | | TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each object's ParticleManager and ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` also advances that static owner's managers using its whole admitted elapsed interval. | `src/AcDream.App/Rendering/GameWindow.cs` (`AdvanceLiveObjectRuntime` final `_particleSystem.Tick(dt)` / `_scriptRunner.Tick(...)`) | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and manager order faithful without pretending the shared tails have per-owner timing. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static default scripts/particles use render elapsed rather than `animate_static_object` elapsed/discard timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum |
| TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 | | TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 |

View file

@ -39,7 +39,6 @@ internal sealed class LiveSessionResetBindings
public required Action AnimationHookFrames { get; init; } public required Action AnimationHookFrames { get; init; }
public required Action LivePresentation { get; init; } public required Action LivePresentation { get; init; }
public required Action RemoteMovementDiagnostics { get; init; } public required Action RemoteMovementDiagnostics { get; init; }
public required Action PhysicsHostIndex { get; init; }
} }
/// <summary> /// <summary>
@ -92,7 +91,6 @@ internal static class LiveSessionResetManifest
new("animation hook frames", bindings.AnimationHookFrames), new("animation hook frames", bindings.AnimationHookFrames),
new("live presentation", bindings.LivePresentation), new("live presentation", bindings.LivePresentation),
new("remote movement diagnostics", bindings.RemoteMovementDiagnostics), new("remote movement diagnostics", bindings.RemoteMovementDiagnostics),
new("physics host index", bindings.PhysicsHostIndex),
]); ]);
} }
} }

View file

@ -0,0 +1,314 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.App.World;
namespace AcDream.App.Physics;
/// <summary>
/// R5-V2 — the App-side <see cref="IPhysicsObjHost"/> per entity: acdream's
/// stand-in for retail's <c>CPhysicsObj</c> as the movement managers see it.
/// One is built per entity (a remote <c>RemoteMotion</c> or the local player)
/// in <c>GameWindow.EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c>
/// and stored on the exact <c>LiveEntityRecord</c>, so
/// <see cref="GetObjectA"/> can resolve OTHER entities' hosts — the
/// cross-entity delivery path the <see cref="TargetManager"/> voyeur system
/// needs.
///
/// <para>Owns a <see cref="TargetManager"/> (retail
/// <c>CPhysicsObj::target_manager</c>). Its <c>set_target</c>/<c>clear_target</c>/
/// <c>add_voyeur</c>/<c>remove_voyeur</c>/<c>receive_target_update</c> seams
/// forward to it exactly as retail's CPhysicsObj does; the movement managers'
/// target seams are repointed here, replacing the AP-79 poll adapter. The
/// per-entity accessors (position/velocity/radius/contact/clocks) and the
/// <see cref="HandleUpdateTarget"/> fan-out are injected by GameWindow so this
/// class stays free of GameWindow's internals (code-structure rule #1).</para>
///
/// <para>R5-V3: owns a <see cref="PositionManager"/> too (retail
/// <c>CPhysicsObj::position_manager</c> — retail creates it lazily via
/// <c>get_position_manager</c>; acdream constructs it eagerly, which is
/// behaviorally identical because the empty facade no-ops until its first
/// <c>StickTo</c>/<c>ConstrainTo</c>). <see cref="HandleUpdateTarget"/> fans
/// deliveries to the injected MoveToManager fan FIRST, then the
/// PositionManager — retail <c>CPhysicsObj::HandleUpdateTarget</c> order
/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager
/// @0x00512c1a).</para>
/// </summary>
public sealed class EntityPhysicsHost : IPhysicsObjHost
{
private Func<Position> _getPosition;
private Func<Vector3> _getVelocity;
private Func<float> _getRadius;
private Func<bool> _inContact;
private Func<float?> _minterpMaxSpeed;
private Func<double> _curTime;
private Func<double> _physicsTimerTime;
private Func<uint, IPhysicsObjHost?> _getObjectA;
private Action<TargetInfo> _handleUpdateTarget;
private Action _interruptCurrentMovement;
private readonly TargetManager _targetManager;
public EntityPhysicsHost(
uint id,
Func<Position> getPosition,
Func<Vector3> getVelocity,
Func<float> getRadius,
Func<bool> inContact,
Func<float?> minterpMaxSpeed,
Func<double> curTime,
Func<double> physicsTimerTime,
Func<uint, IPhysicsObjHost?> getObjectA,
Action<TargetInfo> handleUpdateTarget,
Action interruptCurrentMovement)
{
Id = id;
_getPosition = null!;
_getVelocity = null!;
_getRadius = null!;
_inContact = null!;
_minterpMaxSpeed = null!;
_curTime = null!;
_physicsTimerTime = null!;
_getObjectA = null!;
_handleUpdateTarget = null!;
_interruptCurrentMovement = null!;
Rebind(
getPosition,
getVelocity,
getRadius,
inContact,
minterpMaxSpeed,
curTime,
physicsTimerTime,
getObjectA,
handleUpdateTarget,
interruptCurrentMovement);
_targetManager = new TargetManager(this);
PositionManager = new PositionManager(this);
}
/// <summary>
/// Rebinds the changing CPhysicsObj access seams without replacing this
/// object's retail-lifetime managers. A static/minimal live object can gain
/// a MovementManager later, and the local player controller can be rebuilt,
/// but retail keeps the same CPhysicsObj, TargetManager, and PositionManager
/// for the accepted INSTANCE_TS incarnation. Existing voyeur, sticky, and
/// constraint state therefore survives the upgrade.
/// </summary>
private void Rebind(
Func<Position> getPosition,
Func<Vector3> getVelocity,
Func<float> getRadius,
Func<bool> inContact,
Func<float?> minterpMaxSpeed,
Func<double> curTime,
Func<double> physicsTimerTime,
Func<uint, IPhysicsObjHost?> getObjectA,
Action<TargetInfo> handleUpdateTarget,
Action interruptCurrentMovement)
{
ArgumentNullException.ThrowIfNull(getPosition);
ArgumentNullException.ThrowIfNull(getVelocity);
ArgumentNullException.ThrowIfNull(getRadius);
ArgumentNullException.ThrowIfNull(inContact);
ArgumentNullException.ThrowIfNull(minterpMaxSpeed);
ArgumentNullException.ThrowIfNull(curTime);
ArgumentNullException.ThrowIfNull(physicsTimerTime);
ArgumentNullException.ThrowIfNull(getObjectA);
ArgumentNullException.ThrowIfNull(handleUpdateTarget);
ArgumentNullException.ThrowIfNull(interruptCurrentMovement);
_getPosition = getPosition;
_getVelocity = getVelocity;
_getRadius = getRadius;
_inContact = inContact;
_minterpMaxSpeed = minterpMaxSpeed;
_curTime = curTime;
_physicsTimerTime = physicsTimerTime;
_getObjectA = getObjectA;
_handleUpdateTarget = handleUpdateTarget;
_interruptCurrentMovement = interruptCurrentMovement;
}
/// <summary>
/// Applies a freshly composed delegate configuration while retaining this
/// host's exact identity and manager instances.
/// </summary>
private void RebindFrom(EntityPhysicsHost configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
if (configuration.Id != Id)
throw new ArgumentException(
"A physics-host configuration must match the existing host GUID.",
nameof(configuration));
Rebind(
configuration._getPosition,
configuration._getVelocity,
configuration._getRadius,
configuration._inContact,
configuration._minterpMaxSpeed,
configuration._curTime,
configuration._physicsTimerTime,
configuration._getObjectA,
configuration._handleUpdateTarget,
configuration._interruptCurrentMovement);
}
/// <summary>
/// Publishes the first host for an exact live-object incarnation, or
/// enriches that incarnation's existing minimal host in place. This is the
/// shared remote/player composition seam; it deliberately never replaces
/// TargetManager or PositionManager ownership.
/// </summary>
internal static EntityPhysicsHost InstallOrRebind(
LiveEntityRuntime runtime,
LiveEntityRecord expectedRecord,
EntityPhysicsHost configuration)
{
ArgumentNullException.ThrowIfNull(runtime);
ArgumentNullException.ThrowIfNull(expectedRecord);
ArgumentNullException.ThrowIfNull(configuration);
if (!runtime.TryGetRecord(expectedRecord.ServerGuid, out LiveEntityRecord current)
|| !ReferenceEquals(current, expectedRecord))
{
throw new InvalidOperationException(
$"Live entity 0x{expectedRecord.ServerGuid:X8} changed incarnation during physics-host composition.");
}
if (current.PhysicsHost is null)
{
runtime.InstallPhysicsHost(current, configuration);
return configuration;
}
if (current.PhysicsHost is not EntityPhysicsHost existing)
{
throw new InvalidOperationException(
$"Live entity 0x{expectedRecord.ServerGuid:X8} owns an incompatible physics-host implementation.");
}
existing.RebindFrom(configuration);
return existing;
}
/// <summary>
/// Builds the position-only CPhysicsObj facade used before an animated or
/// player movement owner exists. The delegates capture the exact live
/// record rather than the visible-world projection, so exit_world
/// notifications retain the object's last cell and frame after the picker
/// view has already withdrawn it.
/// </summary>
internal static EntityPhysicsHost CreateMinimal(
LiveEntityRecord record,
Func<uint, IPhysicsObjHost?> resolve,
Func<double> now)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(resolve);
ArgumentNullException.ThrowIfNull(now);
return new EntityPhysicsHost(
record.ServerGuid,
getPosition: () => new Position(
record.FullCellId,
record.WorldEntity?.Position ?? Vector3.Zero,
record.WorldEntity?.Rotation ?? Quaternion.Identity),
getVelocity: () => Vector3.Zero,
getRadius: () => 0f,
inContact: () => true,
minterpMaxSpeed: () => null,
curTime: now,
physicsTimerTime: now,
getObjectA: resolve,
handleUpdateTarget: _ => { },
interruptCurrentMovement: () => { });
}
// ── IPhysicsObjHost accessors ──────────────────────────────────────────
public uint Id { get; }
public Position Position => _getPosition();
public Vector3 Velocity => _getVelocity();
public float Radius => _getRadius();
public bool InContact => _inContact();
public float? MinterpMaxSpeed => _minterpMaxSpeed();
public double CurTime => _curTime();
public double PhysicsTimerTime => _physicsTimerTime();
/// <summary>The owned voyeur manager (retail
/// <c>CPhysicsObj::target_manager</c>).</summary>
public TargetManager TargetManager => _targetManager;
/// <summary>R5-V3 — the owned <see cref="PositionManager"/> facade (retail
/// <c>CPhysicsObj::position_manager</c>): sticky follow + (unarmed)
/// constraint leash. Seam targets: <c>MoveToManager.StickTo/Unstick</c>,
/// <c>MotionInterpreter.UnstickFromObject</c>, the per-tick
/// <c>AdjustOffset</c>/<c>UseTime</c> drivers.</summary>
public PositionManager PositionManager { get; }
// ── IPhysicsObjHost fan-out / target-tracking seams ────────────────────
public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id);
public IPhysicsObjHost? GetRelationshipTarget(uint objectId) =>
_targetManager.GetRelationshipTarget(objectId);
public void HandleUpdateTarget(TargetInfo info)
{
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order:
// MovementManager (the injected MoveToManager fan) first, then
// PositionManager (@0x00512c1a — the R5-V3 sticky consumer).
_handleUpdateTarget(info);
PositionManager.HandleUpdateTarget(info);
}
public void InterruptCurrentMovement() => _interruptCurrentMovement();
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
=> _targetManager.SetTarget(contextId, objectId, radius, quantum);
public void ClearTarget() => _targetManager.ClearTarget();
public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) =>
_targetManager.ReceiveUpdate(info, sender);
public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum)
=> _targetManager.AddVoyeur(watcher, radius, quantum);
public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) =>
_targetManager.RemoveVoyeur(watcherId, expectedWatcher);
// ── per-tick driver + lifecycle (called by GameWindow) ─────────────────
/// <summary>Retail <c>TargetManager::HandleTargetting</c> — the per-tick
/// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
/// for every entity in <c>UpdateObjectInternal</c>, BEFORE the movement
/// managers' <c>UseTime</c>.</summary>
public void HandleTargetting() => _targetManager.HandleTargetting();
/// <summary>Retail <c>CPhysicsObj::exit_world</c>'s
/// <c>TargetManager::NotifyVoyeurOfEvent(ExitWorld)</c> — tell every
/// watcher of this entity that it left the world (they drop the
/// stick/moveto). Called on despawn before the host is removed from the
/// registry.</summary>
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
/// <summary>
/// A Hidden object remains logically alive but is removed from the cell
/// lookup/interaction surface. Retail's DetectionManager sends
/// <c>LeftDetection</c> from <c>CObjCell::hide_object</c>; that manager is
/// not ported yet, so the App bridges the same unavailability edge through
/// the existing target fan-out. The non-Ok update tears down watcher
/// MoveTo/Sticky consumers, and the watched-role table is cleared so an
/// UnHide re-subscription receives a fresh initial snapshot. The object's
/// own watcher role is deliberately preserved.
/// </summary>
public void NotifyHidden() =>
_targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld);
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
/// (drop this entity's OWN subscription) then
/// <c>NotifyVoyeurOfEvent(Teleported)</c> (every entity watching THIS one
/// drops its stick/moveto — <c>StickyManager::HandleUpdateTarget</c>'s
/// non-Ok teardown path). Called after a teleport placement.</summary>
public void NotifyTeleported()
{
_targetManager.ClearTarget();
_targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
}
}

View file

@ -1,4 +1,3 @@
using AcDream.App.Rendering;
using AcDream.App.World; using AcDream.App.World;
namespace AcDream.App.Physics; namespace AcDream.App.Physics;
@ -22,7 +21,8 @@ namespace AcDream.App.Physics;
/// </para> /// </para>
/// </summary> /// </summary>
internal sealed class RemoteMotion : internal sealed class RemoteMotion :
AcDream.App.World.ILiveEntityRemotePlacementRuntime AcDream.App.World.ILiveEntityRemotePlacementRuntime,
AcDream.App.World.ILiveEntityCanonicalRuntimeConsumer
{ {
public AcDream.Core.Physics.PhysicsBody Body { get; } public AcDream.Core.Physics.PhysicsBody Body { get; }
AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body; AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body;
@ -82,8 +82,13 @@ internal sealed class RemoteMotion :
// voyeur system (retail CPhysicsObj::target_manager). Replaces the // voyeur system (retail CPhysicsObj::target_manager). Replaces the
// AP-79 TrackedTarget* poll fields: the MoveToManager's set_target/ // AP-79 TrackedTarget* poll fields: the MoveToManager's set_target/
// clear_target/quantum seams route here, HandleTargetting ticks per // clear_target/quantum seams route here, HandleTargetting ticks per
// frame, and OTHER entities resolve this one via GameWindow._physicsHosts. // frame, and OTHER entities resolve this one through LiveEntityRuntime's
public EntityPhysicsHost? Host; // exact-incarnation PhysicsHost slot.
private Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?>? _physicsHostReader;
private bool _fullPhysicsHostBound;
public EntityPhysicsHost? Host => _fullPhysicsHostBound
? _physicsHostReader?.Invoke() as EntityPhysicsHost
: null;
/// <summary> /// <summary>
/// True while a server MoveToObject/MoveToPosition packet is the /// True while a server MoveToObject/MoveToPosition packet is the
/// active locomotion source. Retail runs these through MoveToManager /// active locomotion source. Retail runs these through MoveToManager
@ -299,9 +304,42 @@ internal sealed class RemoteMotion :
}); });
} }
public void BindCanonicalRuntime(
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost,
Func<uint> readCell,
Action<uint> writeCell)
{
ArgumentNullException.ThrowIfNull(readPhysicsHost);
ArgumentNullException.ThrowIfNull(readCell);
ArgumentNullException.ThrowIfNull(writeCell);
if (_physicsHostReader is not null
|| _canonicalCellReader is not null
|| _canonicalCellWriter is not null)
{
throw new InvalidOperationException("The remote canonical runtime context is already bound.");
}
// All validation precedes publication. Nothing below can throw, so a
// rejected context never leaves a half-bound reusable component.
_physicsHostReader = readPhysicsHost;
_canonicalCellReader = readCell;
_canonicalCellWriter = writeCell;
}
public void BindCanonicalCell(Func<uint> read, Action<uint> write) public void BindCanonicalCell(Func<uint> read, Action<uint> write)
{ {
_canonicalCellReader = read ?? throw new ArgumentNullException(nameof(read)); ArgumentNullException.ThrowIfNull(read);
_canonicalCellWriter = write ?? throw new ArgumentNullException(nameof(write)); ArgumentNullException.ThrowIfNull(write);
if (_canonicalCellReader is not null || _canonicalCellWriter is not null)
throw new InvalidOperationException("The remote canonical cell source is already bound.");
_canonicalCellReader = read;
_canonicalCellWriter = write;
}
public void MarkFullPhysicsHostBound()
{
if (_physicsHostReader is null)
throw new InvalidOperationException("The canonical physics-host source is not bound.");
_fullPhysicsHostBound = true;
} }
} }

View file

@ -1,162 +0,0 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
namespace AcDream.App.Rendering;
/// <summary>
/// R5-V2 — the App-side <see cref="IPhysicsObjHost"/> per entity: acdream's
/// stand-in for retail's <c>CPhysicsObj</c> as the movement managers see it.
/// One is built per entity (a remote <c>RemoteMotion</c> or the local player)
/// in <c>GameWindow.EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c>
/// and registered in <c>GameWindow._physicsHosts</c> (guid → host), so
/// <see cref="GetObjectA"/> can resolve OTHER entities' hosts — the
/// cross-entity delivery path the <see cref="TargetManager"/> voyeur system
/// needs.
///
/// <para>Owns a <see cref="TargetManager"/> (retail
/// <c>CPhysicsObj::target_manager</c>). Its <c>set_target</c>/<c>clear_target</c>/
/// <c>add_voyeur</c>/<c>remove_voyeur</c>/<c>receive_target_update</c> seams
/// forward to it exactly as retail's CPhysicsObj does; the movement managers'
/// target seams are repointed here, replacing the AP-79 poll adapter. The
/// per-entity accessors (position/velocity/radius/contact/clocks) and the
/// <see cref="HandleUpdateTarget"/> fan-out are injected by GameWindow so this
/// class stays free of GameWindow's internals (code-structure rule #1).</para>
///
/// <para>R5-V3: owns a <see cref="PositionManager"/> too (retail
/// <c>CPhysicsObj::position_manager</c> — retail creates it lazily via
/// <c>get_position_manager</c>; acdream constructs it eagerly, which is
/// behaviorally identical because the empty facade no-ops until its first
/// <c>StickTo</c>/<c>ConstrainTo</c>). <see cref="HandleUpdateTarget"/> fans
/// deliveries to the injected MoveToManager fan FIRST, then the
/// PositionManager — retail <c>CPhysicsObj::HandleUpdateTarget</c> order
/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager
/// @0x00512c1a).</para>
/// </summary>
public sealed class EntityPhysicsHost : IPhysicsObjHost
{
private readonly Func<Position> _getPosition;
private readonly Func<Vector3> _getVelocity;
private readonly Func<float> _getRadius;
private readonly Func<bool> _inContact;
private readonly Func<float?> _minterpMaxSpeed;
private readonly Func<double> _curTime;
private readonly Func<double> _physicsTimerTime;
private readonly Func<uint, IPhysicsObjHost?> _getObjectA;
private readonly Action<TargetInfo> _handleUpdateTarget;
private readonly Action _interruptCurrentMovement;
private readonly TargetManager _targetManager;
public EntityPhysicsHost(
uint id,
Func<Position> getPosition,
Func<Vector3> getVelocity,
Func<float> getRadius,
Func<bool> inContact,
Func<float?> minterpMaxSpeed,
Func<double> curTime,
Func<double> physicsTimerTime,
Func<uint, IPhysicsObjHost?> getObjectA,
Action<TargetInfo> handleUpdateTarget,
Action interruptCurrentMovement)
{
Id = id;
_getPosition = getPosition ?? throw new ArgumentNullException(nameof(getPosition));
_getVelocity = getVelocity ?? throw new ArgumentNullException(nameof(getVelocity));
_getRadius = getRadius ?? throw new ArgumentNullException(nameof(getRadius));
_inContact = inContact ?? throw new ArgumentNullException(nameof(inContact));
_minterpMaxSpeed = minterpMaxSpeed ?? throw new ArgumentNullException(nameof(minterpMaxSpeed));
_curTime = curTime ?? throw new ArgumentNullException(nameof(curTime));
_physicsTimerTime = physicsTimerTime ?? throw new ArgumentNullException(nameof(physicsTimerTime));
_getObjectA = getObjectA ?? throw new ArgumentNullException(nameof(getObjectA));
_handleUpdateTarget = handleUpdateTarget ?? throw new ArgumentNullException(nameof(handleUpdateTarget));
_interruptCurrentMovement = interruptCurrentMovement
?? throw new ArgumentNullException(nameof(interruptCurrentMovement));
_targetManager = new TargetManager(this);
PositionManager = new PositionManager(this);
}
// ── IPhysicsObjHost accessors ──────────────────────────────────────────
public uint Id { get; }
public Position Position => _getPosition();
public Vector3 Velocity => _getVelocity();
public float Radius => _getRadius();
public bool InContact => _inContact();
public float? MinterpMaxSpeed => _minterpMaxSpeed();
public double CurTime => _curTime();
public double PhysicsTimerTime => _physicsTimerTime();
/// <summary>The owned voyeur manager (retail
/// <c>CPhysicsObj::target_manager</c>).</summary>
public TargetManager TargetManager => _targetManager;
/// <summary>R5-V3 — the owned <see cref="PositionManager"/> facade (retail
/// <c>CPhysicsObj::position_manager</c>): sticky follow + (unarmed)
/// constraint leash. Seam targets: <c>MoveToManager.StickTo/Unstick</c>,
/// <c>MotionInterpreter.UnstickFromObject</c>, the per-tick
/// <c>AdjustOffset</c>/<c>UseTime</c> drivers.</summary>
public PositionManager PositionManager { get; }
// ── IPhysicsObjHost fan-out / target-tracking seams ────────────────────
public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id);
public void HandleUpdateTarget(TargetInfo info)
{
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order:
// MovementManager (the injected MoveToManager fan) first, then
// PositionManager (@0x00512c1a — the R5-V3 sticky consumer).
_handleUpdateTarget(info);
PositionManager.HandleUpdateTarget(info);
}
public void InterruptCurrentMovement() => _interruptCurrentMovement();
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
=> _targetManager.SetTarget(contextId, objectId, radius, quantum);
public void ClearTarget() => _targetManager.ClearTarget();
public void ReceiveTargetUpdate(TargetInfo info) => _targetManager.ReceiveUpdate(info);
public void AddVoyeur(uint watcherId, float radius, double quantum)
=> _targetManager.AddVoyeur(watcherId, radius, quantum);
public void RemoveVoyeur(uint watcherId) => _targetManager.RemoveVoyeur(watcherId);
// ── per-tick driver + lifecycle (called by GameWindow) ─────────────────
/// <summary>Retail <c>TargetManager::HandleTargetting</c> — the per-tick
/// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
/// for every entity in <c>UpdateObjectInternal</c>, BEFORE the movement
/// managers' <c>UseTime</c>.</summary>
public void HandleTargetting() => _targetManager.HandleTargetting();
/// <summary>Retail <c>CPhysicsObj::exit_world</c>'s
/// <c>TargetManager::NotifyVoyeurOfEvent(ExitWorld)</c> — tell every
/// watcher of this entity that it left the world (they drop the
/// stick/moveto). Called on despawn before the host is removed from the
/// registry.</summary>
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
/// <summary>
/// A Hidden object remains logically alive but is removed from the cell
/// lookup/interaction surface. Retail's DetectionManager sends
/// <c>LeftDetection</c> from <c>CObjCell::hide_object</c>; that manager is
/// not ported yet, so the App bridges the same unavailability edge through
/// the existing target fan-out. The non-Ok update tears down watcher
/// MoveTo/Sticky consumers, and the watched-role table is cleared so an
/// UnHide re-subscription receives a fresh initial snapshot. The object's
/// own watcher role is deliberately preserved.
/// </summary>
public void NotifyHidden() =>
_targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld);
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
/// (drop this entity's OWN subscription) then
/// <c>NotifyVoyeurOfEvent(Teleported)</c> (every entity watching THIS one
/// drops its stick/moveto — <c>StickyManager::HandleUpdateTarget</c>'s
/// non-Ok teardown path). Called after a teleport placement.</summary>
public void NotifyTeleported()
{
_targetManager.ClearTarget();
_targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
}
}

View file

@ -777,7 +777,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns => private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns =>
_liveEntities?.Snapshots ?? EmptyLiveSpawnMap; _liveEntities?.Snapshots ?? EmptyLiveSpawnMap;
// R5-V2: the local player's CPhysicsObj stand-in — owns the player's // R5-V2: the local player's CPhysicsObj stand-in — owns the player's
// TargetManager voyeur system, registered in _physicsHosts so remote // TargetManager voyeur system, stored on the exact live record so remote
// entities chasing the player resolve it. Replaces the AP-79 // entities chasing the player resolve it. Replaces the AP-79
// _playerMoveToTarget* poll fields. // _playerMoveToTarget* poll fields.
private EntityPhysicsHost? _playerHost; private EntityPhysicsHost? _playerHost;
@ -787,7 +787,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// giving the TargetManager voyeur round-trip its cross-entity delivery // giving the TargetManager voyeur round-trip its cross-entity delivery
// path. Populated in EnsureRemoteMotionBindings (remotes) + EnterPlayerModeNow // path. Populated in EnsureRemoteMotionBindings (remotes) + EnterPlayerModeNow
// (player); pruned only by logical LiveEntityRuntime teardown. // (player); pruned only by logical LiveEntityRuntime teardown.
private readonly Dictionary<uint, AcDream.Core.Physics.Motion.IPhysicsObjHost> _physicsHosts = new();
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
// ServerControlledVelocityStaleSeconds moved to RemotePhysicsUpdater (#184 // ServerControlledVelocityStaleSeconds moved to RemotePhysicsUpdater (#184
@ -2644,7 +2643,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
AnimationHookFrames = () => _animationHookFrames?.Clear(), AnimationHookFrames = () => _animationHookFrames?.Clear(),
LivePresentation = () => _liveEntityPresentation?.Clear(), LivePresentation = () => _liveEntityPresentation?.Clear(),
RemoteMovementDiagnostics = _remoteLastMove.Clear, RemoteMovementDiagnostics = _remoteLastMove.Clear,
PhysicsHostIndex = _physicsHosts.Clear,
}); });
private void ResetSessionMouseCapture() private void ResetSessionMouseCapture()
@ -4714,7 +4712,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// live object has no PartArray sink, so build the manager once anyway. // live object has no PartArray sink, so build the manager once anyway.
if (rm.Host is not null) if (rm.Host is not null)
return rm.Sink; return rm.Sink;
if (_liveEntities is not { } liveEntities
|| !liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord hostRecord)
|| !ReferenceEquals(hostRecord.RemoteMotionRuntime, rm))
{
return rm.Sink;
}
// R4-V4: the verbatim MoveToManager, seam-bound per the V2 harness // R4-V4: the verbatim MoveToManager, seam-bound per the V2 harness
// wiring (the conformance-tested reference). Positions are WORLD // wiring (the conformance-tested reference). Positions are WORLD
// space on both sides (getPosition + the MovementStruct positions // space on both sides (getPosition + the MovementStruct positions
@ -4799,12 +4802,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// HandleUpdateTarget fans deliveries to this entity's MoveToManager // HandleUpdateTarget fans deliveries to this entity's MoveToManager
// and (R5-V3, inside the host) its PositionManager — retail's // and (R5-V3, inside the host) its PositionManager — retail's
// CPhysicsObj::HandleUpdateTarget order. // CPhysicsObj::HandleUpdateTarget order.
host = new EntityPhysicsHost( var configuredHost = new EntityPhysicsHost(
serverGuid, serverGuid,
getPosition: () => new AcDream.Core.Physics.Position( getPosition: () => new AcDream.Core.Physics.Position(
rmT.CellId, hostRecord.FullCellId,
_entitiesByServerGuid.TryGetValue(serverGuid, out var selfEnt) hostRecord.WorldEntity?.Position ?? mtBody.Position,
? selfEnt.Position : mtBody.Position,
mtBody.Orientation), mtBody.Orientation),
getVelocity: () => mtBody.Velocity, getVelocity: () => mtBody.Velocity,
getRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius, getRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius,
@ -4820,8 +4822,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info), handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info),
interruptCurrentMovement: () => rmT.Movement.CancelMoveTo( interruptCurrentMovement: () => rmT.Movement.CancelMoveTo(
AcDream.Core.Physics.WeenieError.ActionCancelled)); AcDream.Core.Physics.WeenieError.ActionCancelled));
rm.Host = host; host = EntityPhysicsHost.InstallOrRebind(
_physicsHosts[serverGuid] = host; liveEntities,
hostRecord,
configuredHost);
rm.MarkFullPhysicsHostBound();
// R5-V5: retail MakeMoveToManager (0x00524000) — constructs the // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the
// MoveToManager via the factory above (host now exists for the // MoveToManager via the factory above (host now exists for the
@ -4851,42 +4856,38 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
/// </summary> /// </summary>
private AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id) private AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id)
{ {
// CObjCell::hide_object removes Hidden objects from the lookup surface if (_liveEntities is not { } liveEntities)
// used to establish new target/voyeur relationships. Existing return null;
// subscriptions are cleared by LiveEntityPresentationController.
if (!_visibleEntitiesByServerGuid.ContainsKey(id)) bool isActive = liveEntities.TryGetRecord(id, out LiveEntityRecord activeRecord);
if (!isActive)
return null;
// TS-49: CObjCell::hide_object removes Hidden objects from the
// relationship surface until DetectionManager is ported. Pending,
// attached, and otherwise non-render-visible active objects remain in
// CObjectMaint's object table and must still resolve here.
if (liveEntities.IsHidden(id))
{ {
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{ {
Console.WriteLine( Console.WriteLine(
$"[autowalk-host-miss] object=0x{id:X8} " $"[autowalk-host-miss] object=0x{id:X8} "
+ $"materialized={_entitiesByServerGuid.ContainsKey(id)} " + $"materialized={_entitiesByServerGuid.ContainsKey(id)} "
+ $"registered={_physicsHosts.ContainsKey(id)} " + $"registered={liveEntities.TryGetPhysicsHost(id, out _)} "
+ $"hidden={_liveEntities?.IsHidden(id) == true}"); + $"hidden={liveEntities.IsHidden(id)}");
} }
return null; return null;
} }
if (_physicsHosts.TryGetValue(id, out var existing)) if (liveEntities.TryGetPhysicsHost(id, out var existing))
return existing; return existing;
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
var minimal = new EntityPhysicsHost( var minimal = EntityPhysicsHost.CreateMinimal(
id, activeRecord,
getPosition: () => new AcDream.Core.Physics.Position( ResolvePhysicsHost,
0u, NowSeconds);
_visibleEntitiesByServerGuid.TryGetValue(id, out var e) liveEntities.InstallPhysicsHost(activeRecord, minimal);
? e.Position : System.Numerics.Vector3.Zero,
System.Numerics.Quaternion.Identity),
getVelocity: () => System.Numerics.Vector3.Zero, // static target
getRadius: () => 0f,
inContact: () => true,
minterpMaxSpeed: () => null,
curTime: NowSeconds,
physicsTimerTime: NowSeconds,
getObjectA: ResolvePhysicsHost,
handleUpdateTarget: _ => { }, // not a watcher
interruptCurrentMovement: () => { });
_physicsHosts[id] = minimal;
return minimal; return minimal;
} }
@ -4973,7 +4974,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
AcDream.Core.Selection.SelectionChangeSource.System, AcDream.Core.Selection.SelectionChangeSource.System,
AcDream.Core.Selection.SelectionChangeReason.Cleared); AcDream.Core.Selection.SelectionChangeReason.Cleared);
if (_physicsHosts.TryGetValue(serverGuid, out var hiddenHost) if (_liveEntities?.TryGetPhysicsHost(serverGuid, out var hiddenHost) == true
&& hiddenHost is EntityPhysicsHost hiddenEntityHost) && hiddenHost is EntityPhysicsHost hiddenEntityHost)
hiddenEntityHost.NotifyHidden(); hiddenEntityHost.NotifyHidden();
} }
@ -5038,15 +5039,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// moveto/stick. This is the ONLY clean-up for a watcher whose target // moveto/stick. This is the ONLY clean-up for a watcher whose target
// already sent an Ok (once status != Undefined the 10 s staleness // already sent an Ok (once status != Undefined the 10 s staleness
// timeout never fires), so it must run before the host is pruned. // timeout never fires), so it must run before the host is pruned.
EntityPhysicsHost? ephGone = rmGone?.Host; EntityPhysicsHost? ephGone = record.PhysicsHost as EntityPhysicsHost;
if (ephGone is null)
{
incarnation.RunIfNoReplacement(() =>
{
if (_physicsHosts.TryGetValue(serverGuid, out var hostGone))
ephGone = hostGone as EntityPhysicsHost;
});
}
if (ephGone is not null) if (ephGone is not null)
{ {
// R5-V3 (#171): retail exit_world (0x00514e60) order — the // R5-V3 (#171): retail exit_world (0x00514e60) order — the
@ -5061,8 +5054,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
cleanups.Add(ephGone.PositionManager.UnStick); cleanups.Add(ephGone.PositionManager.UnStick);
cleanups.Add(ephGone.ClearTarget); cleanups.Add(ephGone.ClearTarget);
cleanups.Add(ephGone.NotifyExitWorld); cleanups.Add(ephGone.NotifyExitWorld);
EntityPhysicsHost capturedHost = ephGone;
cleanups.Add(() => incarnation.RemoveCaptured(_physicsHosts, capturedHost));
} }
cleanups.Add(() => _animatedEntities.Remove(existingEntity.Id)); cleanups.Add(() => _animatedEntities.Remove(existingEntity.Id));
cleanups.Add(() => _classificationCache.InvalidateEntity(existingEntity.Id)); cleanups.Add(() => _classificationCache.InvalidateEntity(existingEntity.Id));
@ -5259,7 +5250,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
bool targetVisible = turnPath.TargetGuid is { } visibleGuid bool targetVisible = turnPath.TargetGuid is { } visibleGuid
&& _visibleEntitiesByServerGuid.ContainsKey(visibleGuid); && _visibleEntitiesByServerGuid.ContainsKey(visibleGuid);
bool targetHost = turnPath.TargetGuid is { } hostGuid bool targetHost = turnPath.TargetGuid is { } hostGuid
&& _physicsHosts.ContainsKey(hostGuid); && _liveEntities?.TryGetPhysicsHost(hostGuid, out _) == true;
var moveTo = movement.MoveTo; var moveTo = movement.MoveTo;
Console.WriteLine( Console.WriteLine(
$"[autowalk-turn-route] wire=0x{update.MotionState.MovementType:X2} " $"[autowalk-turn-route] wire=0x{update.MotionState.MovementType:X2} "
@ -10922,9 +10913,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
Func<bool> isCurrent) Func<bool> isCurrent)
{ {
_remoteDeadReckon.TryGetValue(serverGuid, out RemoteMotion? remote); _remoteDeadReckon.TryGetValue(serverGuid, out RemoteMotion? remote);
EntityPhysicsHost? host = _physicsHosts.TryGetValue(serverGuid, out var registered) EntityPhysicsHost? host =
? registered as EntityPhysicsHost _liveEntities?.TryGetPhysicsHost(serverGuid, out var registered) == true
: remote?.Host; ? registered as EntityPhysicsHost
: null;
return AcDream.App.Physics.RemoteTeleportHook.Execute( return AcDream.App.Physics.RemoteTeleportHook.Execute(
new AcDream.App.Physics.RemoteTeleportHookActions( new AcDream.App.Physics.RemoteTeleportHookActions(
CancelMoveTo: error => remote?.Movement.CancelMoveTo(error), CancelMoveTo: error => remote?.Movement.CancelMoveTo(error),
@ -13139,18 +13131,19 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
return false; return false;
} }
LiveEntityRecord? playerRecord = null; if (_liveEntities is not { } playerLiveEntities
if (_liveEntities?.TryGetRecord( || !playerLiveEntities.TryGetRecord(
_playerServerGuid, _playerServerGuid,
out LiveEntityRecord foundPlayerRecord) == true) out LiveEntityRecord playerRecord))
{ {
playerRecord = foundPlayerRecord; Console.WriteLine(
$"live: {loggingTag} — player record 0x{_playerServerGuid:X8} not found yet");
return false;
} }
_playerController = new AcDream.App.Input.PlayerMovementController( _playerController = new AcDream.App.Input.PlayerMovementController(
_physicsEngine, _physicsEngine,
playerRecord?.ObjectClock); playerRecord.ObjectClock);
if (playerRecord is not null) _playerController.ApplyPhysicsState(playerRecord.FinalPhysicsState);
_playerController.ApplyPhysicsState(playerRecord.FinalPhysicsState);
// R4-V5: the local player's verbatim MoveToManager — same seam // R4-V5: the local player's verbatim MoveToManager — same seam
// wiring shape as EnsureRemoteMotionBindings, with three // wiring shape as EnsureRemoteMotionBindings, with three
@ -13232,15 +13225,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// R5-V2: the player's CPhysicsObj stand-in + TargetManager. Position is // R5-V2: the player's CPhysicsObj stand-in + TargetManager. Position is
// the player's WORLD position (WorldEntity.Position — what the AP-79 // the player's WORLD position (WorldEntity.Position — what the AP-79
// poll used), so an NPC watching the player receives the identical // poll used), so an NPC watching the player receives the identical
// position. Registered in _physicsHosts so those NPCs' GetObjectA // position. Stored on the exact live record so those NPCs' GetObjectA
// resolves the player; HandleTargetting is ticked in the player // resolves the player; HandleTargetting is ticked in the player
// pre-Update block. // pre-Update block.
playerHost = new EntityPhysicsHost( var exactPlayerMovement = pcMoveTo.Movement;
var configuredPlayerHost = new EntityPhysicsHost(
_playerServerGuid, _playerServerGuid,
getPosition: () => new AcDream.Core.Physics.Position( getPosition: () => new AcDream.Core.Physics.Position(
pcMoveTo.CellId, playerRecord.FullCellId,
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pSelf) playerRecord.WorldEntity?.Position ?? pcMoveTo.Position,
? pSelf.Position : pcMoveTo.Position,
pcMoveTo.BodyOrientation), pcMoveTo.BodyOrientation),
getVelocity: () => pcMoveTo.BodyVelocity, getVelocity: () => pcMoveTo.BodyVelocity,
getRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius, getRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius,
@ -13263,12 +13256,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
+ $"{info.TargetPosition.Frame.Origin.Y:F2}," + $"{info.TargetPosition.Frame.Origin.Y:F2},"
+ $"{info.TargetPosition.Frame.Origin.Z:F2})"); + $"{info.TargetPosition.Frame.Origin.Z:F2})");
} }
_playerController?.Movement.HandleUpdateTarget(info); exactPlayerMovement.HandleUpdateTarget(info);
}, },
interruptCurrentMovement: () => _playerController?.Movement.CancelMoveTo( interruptCurrentMovement: () => exactPlayerMovement.CancelMoveTo(
AcDream.Core.Physics.WeenieError.ActionCancelled)); AcDream.Core.Physics.WeenieError.ActionCancelled));
playerHost = EntityPhysicsHost.InstallOrRebind(
playerLiveEntities,
playerRecord,
configuredPlayerHost);
_playerHost = playerHost; _playerHost = playerHost;
_physicsHosts[_playerServerGuid] = playerHost;
// R5-V5: retail MakeMoveToManager (0x00524000) — constructs the // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the
// player MoveToManager via the factory above (playerHost now exists // player MoveToManager via the factory above (playerHost now exists

View file

@ -24,6 +24,29 @@ public interface ILiveEntityRemoteMotionRuntime
PhysicsBody Body { get; } PhysicsBody Body { get; }
} }
/// <summary>
/// Optional seam for a remote-motion component that reads the exact
/// incarnation's canonical movement-manager host from
/// <see cref="LiveEntityRecord"/>.
/// </summary>
public interface ILiveEntityPhysicsHostConsumer
{
void BindPhysicsHost(Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> read);
}
/// <summary>
/// Atomic composition seam for a component that consumes both canonical host
/// and cell identity. Implementations validate every delegate before
/// publishing any of them, so a failed bind leaves the component reusable.
/// </summary>
public interface ILiveEntityCanonicalRuntimeConsumer
{
void BindCanonicalRuntime(
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost,
Func<uint> readCell,
Action<uint> writeCell);
}
/// <summary> /// <summary>
/// Optional seam for a remote-motion component that consumes the canonical /// Optional seam for a remote-motion component that consumes the canonical
/// full-cell identity owned by <see cref="LiveEntityRecord"/>. The runtime /// full-cell identity owned by <see cref="LiveEntityRecord"/>. The runtime
@ -216,6 +239,8 @@ public sealed class LiveEntityRecord
internal bool PhysicsBodyAcquisitionInProgress { get; set; } internal bool PhysicsBodyAcquisitionInProgress { get; set; }
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; } public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; } public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; }
internal bool RemoteMotionBindingInProgress { get; set; }
public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost { get; internal set; }
internal bool RequiresRemotePlacementRuntime { get; set; } internal bool RequiresRemotePlacementRuntime { get; set; }
public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; } public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; }
public ILiveEntityEffectProfile? EffectProfile { get; internal set; } public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
@ -1201,10 +1226,30 @@ public sealed class LiveEntityRuntime
ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(runtime);
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)) if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists."); throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists.");
if (record.RemoteMotionBindingInProgress)
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} remote-motion binding is already in progress.");
PhysicsBody candidateBody = runtime.Body
?? throw new InvalidOperationException("A remote-motion runtime returned no physics body.");
if (ReferenceEquals(record.RemoteMotionRuntime, runtime))
{
if (!ReferenceEquals(record.PhysicsBody, candidateBody))
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} remote motion changed its canonical physics body.");
}
// Idempotent publication is important when motion and vector
// packets converge on the same runtime in one update turn. The
// component seams are incarnation-bound and must never be rebound.
candidateBody.State = record.FinalPhysicsState;
SynchronizePhysicsBodyActiveState(record);
_remoteMotionByGuid[serverGuid] = runtime;
RefreshSpatialRuntimeIndexes(record);
return;
}
if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null) if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null)
throw new InvalidOperationException( throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} cannot bind remote motion during physics-body acquisition."); $"Live entity 0x{serverGuid:X8} cannot bind remote motion during physics-body acquisition.");
PhysicsBody candidateBody = runtime.Body;
if (record.PhysicsBody is { } canonicalBody if (record.PhysicsBody is { } canonicalBody
&& !ReferenceEquals(canonicalBody, candidateBody)) && !ReferenceEquals(canonicalBody, candidateBody))
{ {
@ -1217,38 +1262,134 @@ public sealed class LiveEntityRuntime
throw new InvalidOperationException( throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} cannot discard its remote placement contract within one incarnation."); $"Live entity 0x{serverGuid:X8} cannot discard its remote placement contract within one incarnation.");
} }
record.RequiresRemotePlacementRuntime |= // Bind every possibly-throwing exact-incarnation seam before
runtime is ILiveEntityRemotePlacementRuntime; // publishing any canonical record/index state. An already-bound
record.RemoteMotionRuntime = runtime; // component from an older GUID generation must fail without poisoning
record.PhysicsBody = candidateBody; // the replacement record. The callback is arbitrary App code, so the
candidateBody.State = record.FinalPhysicsState; // exact record, authority epoch, and expected owners are revalidated
SynchronizePhysicsBodyActiveState(record); // before commit just like resource/physics-body acquisition.
if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer) LiveEntityRecord incarnation = record;
ulong sessionVersion = _sessionLifetimeVersion;
ulong lifetimeVersion = CurrentLifetimeMutation(serverGuid);
PhysicsBody? expectedBody = record.PhysicsBody;
ILiveEntityRemoteMotionRuntime? expectedRuntime = record.RemoteMotionRuntime;
bool expectedPlacementContract = record.RequiresRemotePlacementRuntime;
bool expectedIndexPresent = _remoteMotionByGuid.TryGetValue(
serverGuid,
out ILiveEntityRemoteMotionRuntime? expectedIndexedRuntime);
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost = () =>
_recordsByGuid.TryGetValue(serverGuid, out var current)
&& ReferenceEquals(current, incarnation)
&& ReferenceEquals(current.RemoteMotionRuntime, runtime)
? current.PhysicsHost
: null;
// Reads remain bound to the exact CPhysicsObj incarnation through its
// exit_world notifications. Only writes require active ownership.
Func<uint> readCell = () => incarnation.FullCellId;
Action<uint> writeCell = cellId =>
{ {
// Capture the incarnation, not only its GUID. A callback retained if (cellId != 0
// by an old MovementManager must never mutate a replacement that && _recordsByGuid.TryGetValue(serverGuid, out var current)
// later reuses the same server GUID. && ReferenceEquals(current, incarnation)
LiveEntityRecord incarnation = record; && ReferenceEquals(current.RemoteMotionRuntime, runtime))
cellConsumer.BindCanonicalCell( {
read: () => RebucketLiveEntity(serverGuid, cellId);
_recordsByGuid.TryGetValue(serverGuid, out var current) }
&& ReferenceEquals(current, incarnation) };
&& ReferenceEquals(current.RemoteMotionRuntime, runtime)
? current.FullCellId record.RemoteMotionBindingInProgress = true;
: 0u, try
write: cellId => {
if (runtime is ILiveEntityCanonicalRuntimeConsumer canonicalConsumer)
{
canonicalConsumer.BindCanonicalRuntime(
readPhysicsHost,
readCell,
writeCell);
}
else
{
bool consumesHost = runtime is ILiveEntityPhysicsHostConsumer;
bool consumesCell = runtime is ILiveEntityCanonicalCellConsumer;
if (consumesHost && consumesCell)
{ {
if (cellId != 0 throw new InvalidOperationException(
&& _recordsByGuid.TryGetValue(serverGuid, out var current) "A remote runtime that consumes both canonical host and cell identity must bind them atomically.");
&& ReferenceEquals(current, incarnation) }
&& ReferenceEquals(current.RemoteMotionRuntime, runtime)) if (runtime is ILiveEntityPhysicsHostConsumer hostConsumer)
{ hostConsumer.BindPhysicsHost(readPhysicsHost);
RebucketLiveEntity(serverGuid, cellId); if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer)
} cellConsumer.BindCanonicalCell(readCell, writeCell);
}); }
bool indexUnchanged = _remoteMotionByGuid.TryGetValue(
serverGuid,
out ILiveEntityRemoteMotionRuntime? currentIndexedRuntime)
== expectedIndexPresent
&& (!expectedIndexPresent
|| ReferenceEquals(currentIndexedRuntime, expectedIndexedRuntime));
if (_sessionLifetimeVersion != sessionVersion
|| CurrentLifetimeMutation(serverGuid) != lifetimeVersion
|| !_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current)
|| !ReferenceEquals(current, incarnation)
|| !ReferenceEquals(current.PhysicsBody, expectedBody)
|| !ReferenceEquals(current.RemoteMotionRuntime, expectedRuntime)
|| current.RequiresRemotePlacementRuntime != expectedPlacementContract
|| !indexUnchanged)
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} changed incarnation or component ownership during remote-motion binding.");
}
record.RequiresRemotePlacementRuntime |=
runtime is ILiveEntityRemotePlacementRuntime;
record.RemoteMotionRuntime = runtime;
record.PhysicsBody = candidateBody;
candidateBody.State = record.FinalPhysicsState;
SynchronizePhysicsBodyActiveState(record);
_remoteMotionByGuid[serverGuid] = runtime;
RefreshSpatialRuntimeIndexes(record);
} }
_remoteMotionByGuid[serverGuid] = runtime; finally
RefreshSpatialRuntimeIndexes(record); {
record.RemoteMotionBindingInProgress = false;
}
}
public void InstallPhysicsHost(
LiveEntityRecord expectedRecord,
AcDream.Core.Physics.Motion.IPhysicsObjHost host)
{
ArgumentNullException.ThrowIfNull(expectedRecord);
ArgumentNullException.ThrowIfNull(host);
uint serverGuid = expectedRecord.ServerGuid;
if (host.Id != serverGuid)
throw new ArgumentException("A physics host must match its live entity GUID.", nameof(host));
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|| !ReferenceEquals(record, expectedRecord))
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} changed incarnation during physics-host installation.");
}
if (record.PhysicsHost is not null)
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} already owns its incarnation-stable physics host.");
record.PhysicsHost = host;
}
public bool TryGetPhysicsHost(
uint serverGuid,
out AcDream.Core.Physics.Motion.IPhysicsObjHost host)
{
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& record.PhysicsHost is { } existing)
{
host = existing;
return true;
}
host = null!;
return false;
} }
public bool TryGetRemoteMotionRuntime(uint serverGuid, out ILiveEntityRemoteMotionRuntime runtime) => public bool TryGetRemoteMotionRuntime(uint serverGuid, out ILiveEntityRemoteMotionRuntime runtime) =>
@ -2374,6 +2515,7 @@ public sealed class LiveEntityRuntime
} }
record.AnimationRuntime = null; record.AnimationRuntime = null;
record.RemoteMotionRuntime = null; record.RemoteMotionRuntime = null;
record.PhysicsHost = null;
record.RequiresRemotePlacementRuntime = false; record.RequiresRemotePlacementRuntime = false;
record.PhysicsBody = null; record.PhysicsBody = null;
record.ProjectileRuntime = null; record.ProjectileRuntime = null;

View file

@ -16,9 +16,9 @@ namespace AcDream.Core.Physics.Motion;
/// <c>RemoteMotion</c> or the local player), wiring the accessors to the live /// <c>RemoteMotion</c> or the local player), wiring the accessors to the live
/// <see cref="PhysicsBody"/> and the <see cref="MoveToManager"/> / /// <see cref="PhysicsBody"/> and the <see cref="MoveToManager"/> /
/// <see cref="PositionManager"/> / <see cref="TargetManager"/> it owns. /// <see cref="PositionManager"/> / <see cref="TargetManager"/> it owns.
/// <see cref="GetObjectA"/> is backed by the App's live entity table /// <see cref="GetObjectA"/> is backed by the App's canonical
/// (<c>_entitiesByServerGuid</c>), giving the voyeur round-trip its /// <c>LiveEntityRuntime</c> records, giving the voyeur round-trip its
/// cross-entity delivery path.</para> /// cross-entity delivery path without a second GUID index.</para>
/// </summary> /// </summary>
public interface IPhysicsObjHost public interface IPhysicsObjHost
{ {
@ -57,11 +57,21 @@ public interface IPhysicsObjHost
double PhysicsTimerTime { get; } double PhysicsTimerTime { get; }
/// <summary>Retail <c>CObjectMaint::GetObjectA(id)</c> — resolve another /// <summary>Retail <c>CObjectMaint::GetObjectA(id)</c> — resolve another
/// physics object by guid, or <c>null</c> if not currently known/visible. /// physics object by guid from the object table. The App additionally
/// The cross-entity seam for the voyeur round-trip and sticky live-target /// withholds Hidden objects from ordinary relationship creation through
/// its registered TS-49 DetectionManager adaptation. This is the
/// cross-entity seam for the voyeur round-trip and sticky live-target
/// resolve.</summary> /// resolve.</summary>
IPhysicsObjHost? GetObjectA(uint id); IPhysicsObjHost? GetObjectA(uint id);
/// <summary>
/// Returns the exact target incarnation captured by this host's current
/// TargetManager relationship. App teardown can overlap a newer
/// INSTANCE_TS with the same GUID, so StickyManager must not re-resolve the
/// relationship through the active GUID table.
/// </summary>
IPhysicsObjHost? GetRelationshipTarget(uint objectId);
/// <summary>Retail <c>CPhysicsObj::HandleUpdateTarget</c> — fans a /// <summary>Retail <c>CPhysicsObj::HandleUpdateTarget</c> — fans a
/// <see cref="TargetInfo"/> to this host's <see cref="MoveToManager"/> /// <see cref="TargetInfo"/> to this host's <see cref="MoveToManager"/>
/// (move-to steering) AND <see cref="PositionManager"/> (sticky follow). /// (move-to steering) AND <see cref="PositionManager"/> (sticky follow).
@ -86,15 +96,19 @@ public interface IPhysicsObjHost
/// <summary>Retail <c>CPhysicsObj::receive_target_update</c> → /// <summary>Retail <c>CPhysicsObj::receive_target_update</c> →
/// <see cref="TargetManager.ReceiveUpdate"/>. The inbound side a SENDER's /// <see cref="TargetManager.ReceiveUpdate"/>. The inbound side a SENDER's
/// <c>SendVoyeurUpdate</c> tail-calls on the watcher.</summary> /// <c>SendVoyeurUpdate</c> tail-calls on the watcher.</summary>
void ReceiveTargetUpdate(TargetInfo info); void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender);
/// <summary>Retail <c>CPhysicsObj::add_voyeur(id, radius, quantum)</c> → /// <summary>Retail <c>CPhysicsObj::add_voyeur(id, radius, quantum)</c> →
/// <see cref="TargetManager.AddVoyeur"/> (lazily creating the /// <see cref="TargetManager.AddVoyeur"/> (lazily creating the
/// TargetManager). Called on the TARGET when a watcher subscribes.</summary> /// TargetManager). Called on the TARGET when a watcher subscribes. The
void AddVoyeur(uint watcherId, float radius, double quantum); /// exact watcher host preserves retail's unique object-table identity
/// while App teardown overlaps a newer same-GUID generation.</summary>
void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum);
/// <summary>Retail <c>CPhysicsObj::remove_voyeur(id)</c> → /// <summary>Retail <c>CPhysicsObj::remove_voyeur(id)</c> →
/// <see cref="TargetManager.RemoveVoyeur"/>. Called on the TARGET when a /// <see cref="TargetManager.RemoveVoyeur"/>. Called on the TARGET when a
/// watcher unsubscribes.</summary> /// watcher unsubscribes. The expected watcher identity prevents a retiring
void RemoveVoyeur(uint watcherId); /// same-GUID incarnation from removing a newer incarnation's subscription
/// while App teardown callbacks converge.</summary>
void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher);
} }

View file

@ -210,7 +210,7 @@ public sealed class StickyManager
return; return;
var self = _host.Position; var self = _host.Position;
var target = _host.GetObjectA(TargetId); var target = _host.GetRelationshipTarget(TargetId);
var targetPos = target != null ? target.Position : TargetPosition; var targetPos = target != null ? target.Position : TargetPosition;
// offset = local-frame, Z-flattened vector from self to target. // offset = local-frame, Z-flattened vector from self to target.

View file

@ -49,6 +49,7 @@ public sealed class TargetManager
private readonly IPhysicsObjHost _host; private readonly IPhysicsObjHost _host;
private TargetInfo? _targetInfo; // retail target_info (watcher role) private TargetInfo? _targetInfo; // retail target_info (watcher role)
private IPhysicsObjHost? _targetHost; // exact App incarnation token
private Dictionary<uint, TargettedVoyeurInfo>? _voyeurTable; // retail voyeur_table (watched role) private Dictionary<uint, TargettedVoyeurInfo>? _voyeurTable; // retail voyeur_table (watched role)
private double _lastUpdateTime; // retail last_update_time (throttle base) private double _lastUpdateTime; // retail last_update_time (throttle base)
@ -67,6 +68,12 @@ public sealed class TargetManager
/// quantum, 0 when tracking nothing.</summary> /// quantum, 0 when tracking nothing.</summary>
public double GetTargetQuantum() => _targetInfo?.Quantum ?? 0.0; public double GetTargetQuantum() => _targetInfo?.Quantum ?? 0.0;
/// <summary>The pointer-like target identity captured by SetTarget.</summary>
public IPhysicsObjHost? GetRelationshipTarget(uint objectId) =>
_targetInfo is { } info && info.ObjectId == objectId
? _targetHost
: null;
// ── watcher role ─────────────────────────────────────────────────────── // ── watcher role ───────────────────────────────────────────────────────
/// <summary> /// <summary>
@ -101,7 +108,8 @@ public sealed class TargetManager
LastUpdateTime: _host.CurTime); LastUpdateTime: _host.CurTime);
var target = _host.GetObjectA(objectId); var target = _host.GetObjectA(objectId);
target?.AddVoyeur(_host.Id, radius, quantum); _targetHost = target;
target?.AddVoyeur(_host, radius, quantum);
} }
/// <summary> /// <summary>
@ -115,8 +123,9 @@ public sealed class TargetManager
return; return;
_targetInfo = ti with { Quantum = quantum }; _targetInfo = ti with { Quantum = quantum };
var target = _host.GetObjectA(ti.ObjectId); var target = _targetHost ?? _host.GetObjectA(ti.ObjectId);
target?.AddVoyeur(_host.Id, ti.Radius, quantum); _targetHost = target;
target?.AddVoyeur(_host, ti.Radius, quantum);
} }
/// <summary> /// <summary>
@ -128,9 +137,10 @@ public sealed class TargetManager
if (_targetInfo is not { } ti) if (_targetInfo is not { } ti)
return; return;
var target = _host.GetObjectA(ti.ObjectId); var target = _targetHost ?? _host.GetObjectA(ti.ObjectId);
target?.RemoveVoyeur(_host.Id); target?.RemoveVoyeur(_host.Id, _host);
_targetInfo = null; _targetInfo = null;
_targetHost = null;
} }
/// <summary> /// <summary>
@ -141,10 +151,17 @@ public sealed class TargetManager
/// time, recomputes the self→target interpolated heading (falls back to +Z /// time, recomputes the self→target interpolated heading (falls back to +Z
/// when degenerate), fans the snapshot to the host, and drops the /// when degenerate), fans the snapshot to the host, and drops the
/// subscription on an ExitWorld status. /// subscription on an ExitWorld status.
/// Receives the update only from the exact target incarnation that
/// established this relationship. The sender token stays outside the
/// retail TargetInfo payload.
/// </summary> /// </summary>
public void ReceiveUpdate(TargetInfo update) public void ReceiveUpdate(TargetInfo update, IPhysicsObjHost sender)
{ {
if (_targetInfo is not { } ti || ti.ObjectId != update.ObjectId) ArgumentNullException.ThrowIfNull(sender);
if (_targetInfo is not { } ti
|| ti.ObjectId != update.ObjectId
|| _targetHost is null
|| !ReferenceEquals(_targetHost, sender))
return; return;
// Copy radius/quantum/positions/velocity/status from the wire; keep our // Copy radius/quantum/positions/velocity/status from the wire; keep our
@ -181,25 +198,53 @@ public sealed class TargetManager
/// radius/quantum in place (no immediate send); otherwise creates the entry /// radius/quantum in place (no immediate send); otherwise creates the entry
/// and pushes an immediate initial snapshot (<c>Ok</c>). /// and pushes an immediate initial snapshot (<c>Ok</c>).
/// </summary> /// </summary>
public void AddVoyeur(uint watcherId, float radius, double quantum) public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum)
{ {
ArgumentNullException.ThrowIfNull(watcher);
uint watcherId = watcher.Id;
_voyeurTable ??= new Dictionary<uint, TargettedVoyeurInfo>(); _voyeurTable ??= new Dictionary<uint, TargettedVoyeurInfo>();
if (_voyeurTable.TryGetValue(watcherId, out var existing)) if (_voyeurTable.TryGetValue(watcherId, out var existing))
{ {
existing.Radius = radius; if (ReferenceEquals(existing.WatcherHost, watcher))
existing.Quantum = quantum; {
return; existing.Radius = radius;
existing.Quantum = quantum;
return;
}
// A later INSTANCE_TS reused the GUID. Replace the pointer-like
// subscription; the retiring watcher can no longer remove it via
// its exact identity token.
_voyeurTable.Remove(watcherId);
} }
var voyeur = new TargettedVoyeurInfo(watcherId, radius, quantum); var voyeur = new TargettedVoyeurInfo(
watcherId,
radius,
quantum,
watcher);
_voyeurTable[watcherId] = voyeur; _voyeurTable[watcherId] = voyeur;
SendVoyeurUpdate(voyeur, _host.Position, TargetStatus.Ok); SendVoyeurUpdate(voyeur, _host.Position, TargetStatus.Ok);
} }
/// <summary>Retail <c>TargetManager::RemoveVoyeur</c> (0x0051ad90).</summary> /// <summary>
public bool RemoveVoyeur(uint watcherId) /// Retail <c>TargetManager::RemoveVoyeur</c> (0x0051ad90). Removes a
=> _voyeurTable?.Remove(watcherId) ?? false; /// relationship only when it still belongs to the exact watcher
/// incarnation that established it in the App lifetime model.
/// </summary>
public bool RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher)
{
ArgumentNullException.ThrowIfNull(expectedWatcher);
if (_voyeurTable is null
|| !_voyeurTable.TryGetValue(watcherId, out var existing)
|| !ReferenceEquals(existing.WatcherHost, expectedWatcher))
{
return false;
}
return _voyeurTable.Remove(watcherId);
}
/// <summary> /// <summary>
/// Retail <c>TargetManager::HandleTargetting</c> (0x0051aa90). THE per-tick /// Retail <c>TargetManager::HandleTargetting</c> (0x0051aa90). THE per-tick
@ -280,8 +325,7 @@ public sealed class TargetManager
Quantum: voyeur.Quantum, Quantum: voyeur.Quantum,
Velocity: _host.Velocity); Velocity: _host.Velocity);
var voyeurObj = _host.GetObjectA(voyeur.ObjectId); voyeur.WatcherHost.ReceiveTargetUpdate(info, _host);
voyeurObj?.ReceiveTargetUpdate(info);
} }
/// <summary> /// <summary>

View file

@ -11,6 +11,14 @@ namespace AcDream.Core.Physics.Motion;
/// </summary> /// </summary>
public sealed class TargettedVoyeurInfo public sealed class TargettedVoyeurInfo
{ {
/// <summary>
/// Exact watcher incarnation captured when this subscription is created.
/// Retail object IDs are process-lifetime object-table identities; the App
/// preserves that pointer-like identity explicitly while an old
/// INSTANCE_TS teardown may overlap a newer record with the same GUID.
/// </summary>
internal IPhysicsObjHost WatcherHost { get; }
/// <summary>+0x00 retail <c>object_id</c> — the subscriber's guid.</summary> /// <summary>+0x00 retail <c>object_id</c> — the subscriber's guid.</summary>
public uint ObjectId { get; } public uint ObjectId { get; }
@ -27,10 +35,23 @@ public sealed class TargettedVoyeurInfo
/// delivered to this subscriber (updated by <c>SendVoyeurUpdate</c>).</summary> /// delivered to this subscriber (updated by <c>SendVoyeurUpdate</c>).</summary>
public Position LastSentPosition { get; set; } public Position LastSentPosition { get; set; }
public TargettedVoyeurInfo(uint objectId, float radius, double quantum) public TargettedVoyeurInfo(
uint objectId,
float radius,
double quantum,
IPhysicsObjHost watcherHost)
{ {
ArgumentNullException.ThrowIfNull(watcherHost);
if (watcherHost.Id != objectId)
{
throw new ArgumentException(
"Watcher identity must match the voyeur object ID.",
nameof(watcherHost));
}
ObjectId = objectId; ObjectId = objectId;
Radius = radius; Radius = radius;
Quantum = quantum; Quantum = quantum;
WatcherHost = watcherHost;
} }
} }

View file

@ -242,7 +242,6 @@ public sealed class LiveSessionResetPlanTests
AnimationHookFrames = Stage("animation hook frames"), AnimationHookFrames = Stage("animation hook frames"),
LivePresentation = Stage("live presentation"), LivePresentation = Stage("live presentation"),
RemoteMovementDiagnostics = Stage("remote movement diagnostics"), RemoteMovementDiagnostics = Stage("remote movement diagnostics"),
PhysicsHostIndex = Stage("physics host index"),
}); });
Assert.Equal(ExpectedManifestNames(), plan.StageNames); Assert.Equal(ExpectedManifestNames(), plan.StageNames);
@ -255,7 +254,6 @@ public sealed class LiveSessionResetPlanTests
.Select(static error => error.StageName)); .Select(static error => error.StageName));
Assert.Equal(playerA, sessionIdentity); Assert.Equal(playerA, sessionIdentity);
Assert.Equal(1, live.PendingTeardownCount); Assert.Equal(1, live.PendingTeardownCount);
Assert.False(dirty["physics host index"]);
Assert.Contains(staticEntity, spatial.Entities); Assert.Contains(staticEntity, spatial.Entities);
Assert.Equal(1, spatial.PersistentGuidCount); Assert.Equal(1, spatial.PersistentGuidCount);
@ -319,7 +317,6 @@ public sealed class LiveSessionResetPlanTests
"animation hook frames", "animation hook frames",
"live presentation", "live presentation",
"remote movement diagnostics", "remote movement diagnostics",
"physics host index",
]; ];
private static WorldEntity Entity(uint id, uint guid) => new() private static WorldEntity Entity(uint id, uint guid) => new()

View file

@ -1,5 +1,6 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Input; using AcDream.App.Input;
using AcDream.App.Physics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics; using AcDream.Core.Physics;

View file

@ -1202,7 +1202,10 @@ public sealed class ProjectileControllerTests
fixture.Live.SetRemoteMotionRuntime(Guid, replacement); fixture.Live.SetRemoteMotionRuntime(Guid, replacement);
remote.CellId = CellB; remote.CellId = CellB;
Assert.Equal(CellA, record.FullCellId); Assert.Equal(CellA, record.FullCellId);
Assert.Equal(0u, remote.CellId); // A displaced component retains read-only access to its exact
// incarnation through exit_world; its guarded write cannot mutate the
// current canonical owner.
Assert.Equal(CellA, remote.CellId);
replacement.CellId = CellB; replacement.CellId = CellB;
Assert.Equal(CellB, record.FullCellId); Assert.Equal(CellB, record.FullCellId);
} }

View file

@ -1,5 +1,5 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering; using AcDream.App.Physics;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion; using AcDream.Core.Physics.Motion;

View file

@ -0,0 +1,745 @@
using System.Numerics;
using AcDream.App.Physics;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
namespace AcDream.App.Tests.World;
public sealed class LiveEntityPhysicsHostOwnershipTests
{
[Fact]
public void Host_IsOwnedByExactRecordAndStaleRemoteReaderCannotSeeReplacement()
{
const uint guid = 0x70000010u;
var runtime = Runtime();
LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
var firstMotion = new HostConsumerMotion();
runtime.SetRemoteMotionRuntime(guid, firstMotion);
EntityPhysicsHost firstHost = Host(guid);
runtime.InstallPhysicsHost(first, firstHost);
Assert.Same(firstHost, firstMotion.Host);
Assert.True(runtime.TryGetPhysicsHost(guid, out IPhysicsObjHost resolved));
Assert.Same(firstHost, resolved);
LiveEntityRecord second = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
var secondMotion = new HostConsumerMotion();
runtime.SetRemoteMotionRuntime(guid, secondMotion);
EntityPhysicsHost replacement = Host(guid);
runtime.InstallPhysicsHost(second, replacement);
Assert.Null(firstMotion.Host);
Assert.Same(replacement, secondMotion.Host);
Assert.Same(replacement, second.PhysicsHost);
}
[Fact]
public void Install_RejectsWrongGuidDuplicateAndStaleRecord()
{
const uint guid = 0x70000011u;
var runtime = Runtime();
LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
EntityPhysicsHost original = Host(guid);
runtime.InstallPhysicsHost(first, original);
Assert.Throws<ArgumentException>(() =>
runtime.InstallPhysicsHost(first, Host(guid + 1)));
Assert.Throws<InvalidOperationException>(() =>
runtime.InstallPhysicsHost(first, Host(guid)));
LiveEntityRecord second = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
Assert.Throws<InvalidOperationException>(() =>
runtime.InstallPhysicsHost(first, Host(guid)));
Assert.Null(second.PhysicsHost);
}
[Fact]
public void InstallOrRebind_PreservesHostManagersAndLiveTrackingState()
{
const uint watcherGuid = 0x70000020u;
const uint targetGuid = 0x70000021u;
var runtime = Runtime();
LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!;
LiveEntityRecord targetRecord = runtime.RegisterLiveEntity(Spawn(targetGuid, 1)).Record!;
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
: null;
EntityPhysicsHost watcher = Host(
watcherGuid,
resolve: Resolve,
position: new Vector3(1f, 0f, 0f));
EntityPhysicsHost target = Host(
targetGuid,
resolve: Resolve,
position: new Vector3(4f, 0f, 0f));
runtime.InstallPhysicsHost(watcherRecord, watcher);
runtime.InstallPhysicsHost(targetRecord, target);
watcher.PositionManager.StickTo(targetGuid, 0.5f, 1f);
watcher.PositionManager.ConstrainTo(
new Position(0x01010001u, Vector3.Zero, Quaternion.Identity),
1f,
5f);
TargetManager targetManager = watcher.TargetManager;
PositionManager positionManager = watcher.PositionManager;
StickyManager sticky = Assert.IsType<StickyManager>(positionManager.Sticky);
ConstraintManager constraint = Assert.IsType<ConstraintManager>(positionManager.Constraint);
Assert.Equal(targetGuid, sticky.TargetId);
Assert.NotNull(watcher.TargetManager.TargetInfo);
Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(watcherGuid));
EntityPhysicsHost configured = Host(
watcherGuid,
resolve: Resolve,
position: new Vector3(9f, 0f, 0f));
EntityPhysicsHost rebound = EntityPhysicsHost.InstallOrRebind(
runtime,
watcherRecord,
configured);
Assert.Same(watcher, rebound);
Assert.Same(targetManager, rebound.TargetManager);
Assert.Same(positionManager, rebound.PositionManager);
Assert.Same(sticky, rebound.PositionManager.Sticky);
Assert.Same(constraint, rebound.PositionManager.Constraint);
Assert.Equal(targetGuid, rebound.PositionManager.GetStickyObjectId());
Assert.Equal(9f, rebound.Position.Frame.Origin.X);
Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(watcherGuid));
EntityPhysicsHost secondConfiguration = Host(
watcherGuid,
resolve: Resolve,
position: new Vector3(12f, 0f, 0f));
Assert.Same(
watcher,
EntityPhysicsHost.InstallOrRebind(runtime, watcherRecord, secondConfiguration));
Assert.Same(targetManager, watcher.TargetManager);
Assert.Same(positionManager, watcher.PositionManager);
Assert.Equal(12f, watcher.Position.Frame.Origin.X);
}
[Fact]
public void SameGuidReplacement_HostCallbacksRemainIncarnationLocal()
{
const uint guid = 0x70000022u;
var runtime = Runtime();
LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
int firstUpdates = 0;
int firstInterrupts = 0;
var firstHost = CallbackHost(
guid,
_ => firstUpdates++,
() => firstInterrupts++);
runtime.InstallPhysicsHost(first, firstHost);
LiveEntityRecord replacement = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
int replacementUpdates = 0;
int replacementInterrupts = 0;
var replacementHost = CallbackHost(
guid,
_ => replacementUpdates++,
() => replacementInterrupts++);
runtime.InstallPhysicsHost(replacement, replacementHost);
firstHost.HandleUpdateTarget(default);
firstHost.InterruptCurrentMovement();
Assert.Equal(1, firstUpdates);
Assert.Equal(1, firstInterrupts);
Assert.Equal(0, replacementUpdates);
Assert.Equal(0, replacementInterrupts);
}
[Fact]
public void RemoteMotion_FullHostGateUsesCanonicalStableHost()
{
const uint guid = 0x70000030u;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
var motion = new RemoteMotion(new PhysicsBody());
runtime.SetRemoteMotionRuntime(guid, motion);
EntityPhysicsHost minimal = Host(guid);
runtime.InstallPhysicsHost(record, minimal);
Assert.Null(motion.Host);
motion.MarkFullPhysicsHostBound();
Assert.Same(minimal, motion.Host);
EntityPhysicsHost configured = Host(guid, position: new Vector3(7f, 0f, 0f));
Assert.Same(
minimal,
EntityPhysicsHost.InstallOrRebind(runtime, record, configured));
Assert.Same(minimal, motion.Host);
Assert.Equal(7f, motion.Host!.Position.Frame.Origin.X);
runtime.SetRemoteMotionRuntime(guid, motion);
Assert.Same(minimal, motion.Host);
}
[Fact]
public void RemoteMotionBindingFailure_DoesNotPublishPartialCanonicalState()
{
const uint guid = 0x70000031u;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
var failing = new ThrowingCellConsumerMotion();
Assert.Throws<InvalidOperationException>(() =>
runtime.SetRemoteMotionRuntime(guid, failing));
Assert.Null(record.RemoteMotionRuntime);
Assert.Null(record.PhysicsBody);
Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _));
runtime.SetRemoteMotionRuntime(guid, failing);
Assert.Same(failing, record.RemoteMotionRuntime);
Assert.Same(failing.Body, record.PhysicsBody);
}
[Fact]
public void AlreadyBoundRemoteFromOldGeneration_CannotPoisonReplacement()
{
const uint guid = 0x70000032u;
var runtime = Runtime();
runtime.RegisterLiveEntity(Spawn(guid, 1));
var stale = new RemoteMotion(new PhysicsBody());
runtime.SetRemoteMotionRuntime(guid, stale);
LiveEntityRecord replacement = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
Assert.Throws<InvalidOperationException>(() =>
runtime.SetRemoteMotionRuntime(guid, stale));
Assert.Null(replacement.RemoteMotionRuntime);
Assert.Null(replacement.PhysicsBody);
Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _));
var fresh = new RemoteMotion(new PhysicsBody());
runtime.SetRemoteMotionRuntime(guid, fresh);
Assert.Same(fresh, replacement.RemoteMotionRuntime);
}
[Fact]
public void SameRemoteRuntime_CannotChangeCanonicalBodyOnIdempotentBind()
{
const uint guid = 0x70000033u;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
var firstBody = new PhysicsBody();
var secondBody = new PhysicsBody();
var alternating = new AlternatingBodyMotion(firstBody, secondBody);
runtime.SetRemoteMotionRuntime(guid, alternating);
Assert.Same(firstBody, record.PhysicsBody);
Assert.Throws<InvalidOperationException>(() =>
runtime.SetRemoteMotionRuntime(guid, alternating));
Assert.Same(firstBody, record.PhysicsBody);
Assert.Same(alternating, record.RemoteMotionRuntime);
Assert.True(runtime.TryGetRemoteMotionRuntime(guid, out var indexed));
Assert.Same(alternating, indexed);
}
[Fact]
public void IdempotentRemoteBind_ReadsBodyOnceAndMutatesOnlyCanonicalBody()
{
const uint guid = 0x70000036u;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
var canonical = new PhysicsBody();
var unrelated = new PhysicsBody();
PhysicsStateFlags unrelatedInitialState = unrelated.State;
var scripted = new SequenceBodyMotion(canonical, canonical, unrelated);
runtime.SetRemoteMotionRuntime(guid, scripted);
runtime.SetRemoteMotionRuntime(guid, scripted);
Assert.Same(canonical, record.PhysicsBody);
Assert.Equal(record.FinalPhysicsState, canonical.State);
Assert.Equal(unrelatedInitialState, unrelated.State);
Assert.Equal(2, scripted.ReadCount);
}
[Fact]
public void ReentrantBind_SameGuidReplacementPreventsOuterPublication()
{
const uint guid = 0x70000034u;
var runtime = Runtime();
LiveEntityRecord retired = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
var motion = new CallbackCanonicalMotion(() =>
runtime.RegisterLiveEntity(Spawn(guid, 2)));
Assert.Throws<InvalidOperationException>(() =>
runtime.SetRemoteMotionRuntime(guid, motion));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement));
Assert.Equal((ushort)2, replacement.Generation);
Assert.Null(replacement.RemoteMotionRuntime);
Assert.Null(replacement.PhysicsBody);
Assert.Null(retired.RemoteMotionRuntime);
Assert.Null(retired.PhysicsBody);
Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _));
}
[Fact]
public void ReentrantBind_SessionClearPreventsOuterPublication()
{
const uint guid = 0x70000035u;
var runtime = Runtime();
LiveEntityRecord retired = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
var motion = new CallbackCanonicalMotion(runtime.Clear);
Assert.Throws<InvalidOperationException>(() =>
runtime.SetRemoteMotionRuntime(guid, motion));
Assert.Equal(0, runtime.Count);
Assert.Equal(0, runtime.PendingTeardownCount);
Assert.Null(retired.RemoteMotionRuntime);
Assert.Null(retired.PhysicsBody);
Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _));
}
[Fact]
public void Delete_TeardownHostsRemainPeerResolvableUntilExitWorldConverges()
{
const uint leftGuid = 0x70000040u;
const uint rightGuid = 0x70000041u;
LiveEntityRuntime runtime = null!;
runtime = Runtime(record => CleanPhysicsHost(record));
LiveEntityRecord leftRecord = runtime.RegisterLiveEntity(Spawn(leftGuid, 1)).Record!;
LiveEntityRecord rightRecord = runtime.RegisterLiveEntity(Spawn(rightGuid, 1)).Record!;
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
: null;
EntityPhysicsHost left = Host(leftGuid, Resolve, new Vector3(0f, 0f, 0f));
EntityPhysicsHost right = Host(rightGuid, Resolve, new Vector3(2f, 0f, 0f));
runtime.InstallPhysicsHost(leftRecord, left);
runtime.InstallPhysicsHost(rightRecord, right);
left.PositionManager.StickTo(rightGuid, 0.5f, 1f);
right.PositionManager.StickTo(leftGuid, 0.5f, 1f);
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(leftGuid, InstanceSequence: 1),
isLocalPlayer: false));
Assert.Null(leftRecord.PhysicsHost);
Assert.Equal(0u, right.PositionManager.GetStickyObjectId());
Assert.Null(right.TargetManager.TargetInfo);
Assert.False(right.TargetManager.VoyeurTable?.ContainsKey(leftGuid) ?? false);
Assert.False(runtime.TryGetPhysicsHost(leftGuid, out _));
}
[Fact]
public void SessionClear_TeardownHostsResolveAcrossTombstones()
{
const uint leftGuid = 0x70000042u;
const uint rightGuid = 0x70000043u;
LiveEntityRuntime runtime = null!;
runtime = Runtime(record => CleanPhysicsHost(record));
LiveEntityRecord leftRecord = runtime.RegisterLiveEntity(Spawn(leftGuid, 1)).Record!;
LiveEntityRecord rightRecord = runtime.RegisterLiveEntity(Spawn(rightGuid, 1)).Record!;
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
: null;
EntityPhysicsHost left = Host(leftGuid, Resolve, Vector3.Zero);
EntityPhysicsHost right = Host(rightGuid, Resolve, new Vector3(2f, 0f, 0f));
runtime.InstallPhysicsHost(leftRecord, left);
runtime.InstallPhysicsHost(rightRecord, right);
left.PositionManager.StickTo(rightGuid, 0.5f, 1f);
right.PositionManager.StickTo(leftGuid, 0.5f, 1f);
runtime.Clear();
Assert.Equal(0, runtime.PendingTeardownCount);
Assert.Null(leftRecord.PhysicsHost);
Assert.Null(rightRecord.PhysicsHost);
Assert.Equal(0u, left.PositionManager.GetStickyObjectId());
Assert.Equal(0u, right.PositionManager.GetStickyObjectId());
}
[Fact]
public void FailedTeardown_RetryCannotMutateReplacementRelationship()
{
const uint guid = 0x70000044u;
const uint targetGuid = 0x70000045u;
bool failOnce = true;
EntityPhysicsHost? replacementHost = null;
LiveEntityRecord? replacementRecord = null;
LiveEntityRuntime runtime = null!;
runtime = Runtime(record =>
{
if (failOnce)
{
failOnce = false;
replacementRecord = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
: null;
replacementHost = Host(guid, Resolve);
runtime.InstallPhysicsHost(replacementRecord, replacementHost);
replacementHost.PositionManager.StickTo(targetGuid, 0.5f, 1f);
throw new InvalidOperationException("fixture failure before old host cleanup");
}
CleanPhysicsHost(record);
});
LiveEntityRecord oldRecord = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
LiveEntityRecord targetRecord = runtime.RegisterLiveEntity(Spawn(targetGuid, 1)).Record!;
IPhysicsObjHost? InitialResolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
: null;
EntityPhysicsHost oldHost = Host(guid, InitialResolve);
EntityPhysicsHost targetHost = Host(targetGuid, InitialResolve, new Vector3(2f, 0f, 0f));
runtime.InstallPhysicsHost(oldRecord, oldHost);
runtime.InstallPhysicsHost(targetRecord, targetHost);
oldHost.PositionManager.StickTo(targetGuid, 0.5f, 1f);
Assert.Throws<AggregateException>(() => runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(guid, InstanceSequence: 1),
isLocalPlayer: false));
Assert.Same(oldHost, oldRecord.PhysicsHost);
Assert.Same(replacementHost, replacementRecord!.PhysicsHost);
Assert.Equal(targetGuid, replacementHost!.PositionManager.GetStickyObjectId());
Assert.True(targetHost.TargetManager.VoyeurTable!.ContainsKey(guid));
Assert.Equal(1, runtime.RetryPendingTeardowns());
Assert.Null(oldRecord.PhysicsHost);
Assert.Same(replacementHost, replacementRecord.PhysicsHost);
Assert.Equal(targetGuid, replacementHost.PositionManager.GetStickyObjectId());
Assert.True(targetHost.TargetManager.VoyeurTable!.ContainsKey(guid));
}
[Fact]
public void FailedTeardown_TombstoneCannotAcceptNewRelationshipsOutsideTeardown()
{
const uint retiredGuid = 0x70000046u;
const uint watcherGuid = 0x70000047u;
bool fail = true;
LiveEntityRuntime runtime = null!;
runtime = Runtime(record =>
{
CleanPhysicsHost(record);
if (fail)
{
fail = false;
throw new InvalidOperationException("fixture post-exit failure");
}
});
LiveEntityRecord retiredRecord = runtime.RegisterLiveEntity(Spawn(retiredGuid, 1)).Record!;
LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!;
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
: null;
EntityPhysicsHost retired = Host(retiredGuid, Resolve);
EntityPhysicsHost watcher = Host(watcherGuid, Resolve);
runtime.InstallPhysicsHost(retiredRecord, retired);
runtime.InstallPhysicsHost(watcherRecord, watcher);
Assert.Throws<AggregateException>(() => runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(retiredGuid, InstanceSequence: 1),
isLocalPlayer: false));
watcher.SetTarget(0, retiredGuid, 0.5f, 0.5);
Assert.Null(retired.TargetManager.VoyeurTable);
Assert.Equal(TargetStatus.Undefined, watcher.TargetManager.TargetInfo!.Value.Status);
Assert.False(runtime.TryGetPhysicsHost(retiredGuid, out _));
Assert.Equal(1, runtime.RetryPendingTeardowns());
}
[Fact]
public void MinimalHost_ExitPositionRetainsExactRecordFrame()
{
const uint guid = 0x70000048u;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
record.FullCellId = 0x01010123u;
record.WorldEntity = new AcDream.Core.World.WorldEntity
{
Id = 1_000_123u,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(12f, 34f, 56f),
Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f),
MeshRefs = Array.Empty<AcDream.Core.World.MeshRef>(),
};
EntityPhysicsHost host = EntityPhysicsHost.CreateMinimal(record, _ => null, () => 0);
Assert.Equal(record.FullCellId, host.Position.ObjCellId);
Assert.Equal(record.WorldEntity.Position, host.Position.Frame.Origin);
Assert.Equal(record.WorldEntity.Rotation, host.Position.Frame.Orientation);
}
[Fact]
public void FullHost_ExitSnapshotRetainsOldFrameAcrossSameGuidReplacement()
{
const uint departingGuid = 0x70000049u;
const uint watcherGuid = 0x7000004Au;
var delivered = new List<TargetInfo>();
LiveEntityRuntime runtime = null!;
runtime = Runtime(record =>
{
if (record.ServerGuid == departingGuid)
{
LiveEntityRecord replacement = runtime.RegisterLiveEntity(
Spawn(departingGuid, 2)).Record!;
replacement.FullCellId = 0x02020202u;
replacement.WorldEntity = Entity(
departingGuid,
new Vector3(999f, 999f, 999f),
Quaternion.Identity);
runtime.InstallPhysicsHost(replacement, Host(departingGuid));
}
CleanPhysicsHost(record);
});
LiveEntityRecord departing = runtime.RegisterLiveEntity(Spawn(departingGuid, 1)).Record!;
LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!;
departing.FullCellId = 0x01010123u;
Quaternion departingRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f);
departing.WorldEntity = Entity(
departingGuid,
new Vector3(12f, 34f, 56f),
departingRotation);
watcherRecord.WorldEntity = Entity(watcherGuid, Vector3.Zero, Quaternion.Identity);
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) ? host : null;
var body = new PhysicsBody
{
Position = departing.WorldEntity.Position,
Orientation = departingRotation,
};
var remote = new RemoteMotion(body);
runtime.SetRemoteMotionRuntime(departingGuid, remote);
var departingHost = new EntityPhysicsHost(
departingGuid,
getPosition: () => new Position(
departing.FullCellId,
departing.WorldEntity?.Position ?? body.Position,
body.Orientation),
getVelocity: () => body.Velocity,
getRadius: () => 0.5f,
inContact: () => true,
minterpMaxSpeed: () => 1f,
curTime: () => 0,
physicsTimerTime: () => 0,
getObjectA: Resolve,
handleUpdateTarget: _ => { },
interruptCurrentMovement: () => { });
var watcher = new EntityPhysicsHost(
watcherGuid,
getPosition: () => new Position(0x01010001u, Vector3.Zero, Quaternion.Identity),
getVelocity: () => Vector3.Zero,
getRadius: () => 0.5f,
inContact: () => true,
minterpMaxSpeed: () => 1f,
curTime: () => 0,
physicsTimerTime: () => 0,
getObjectA: Resolve,
handleUpdateTarget: delivered.Add,
interruptCurrentMovement: () => { });
runtime.InstallPhysicsHost(departing, departingHost);
runtime.InstallPhysicsHost(watcherRecord, watcher);
watcher.SetTarget(0, departingGuid, 0.5f, 0.5);
delivered.Clear();
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(departingGuid, InstanceSequence: 1),
isLocalPlayer: false));
TargetInfo exit = Assert.Single(delivered);
Assert.Equal(TargetStatus.ExitWorld, exit.Status);
Assert.Equal(0x01010123u, exit.TargetPosition.ObjCellId);
Assert.Equal(new Vector3(12f, 34f, 56f), exit.TargetPosition.Frame.Origin);
Assert.Equal(departingRotation, exit.TargetPosition.Frame.Orientation);
Assert.Equal(0x01010123u, remote.CellId);
}
[Fact]
public void ActiveNonVisibleRecord_CanOwnResolvablePhysicsHost()
{
const uint guid = 0x7000004Bu;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
EntityPhysicsHost host = EntityPhysicsHost.CreateMinimal(record, _ => null, () => 0);
runtime.InstallPhysicsHost(record, host);
Assert.Empty(runtime.WorldEntities);
Assert.True(runtime.TryGetPhysicsHost(guid, out IPhysicsObjHost resolved));
Assert.Same(host, resolved);
}
private static void CleanPhysicsHost(LiveEntityRecord record)
{
if (record.PhysicsHost is not EntityPhysicsHost host)
return;
host.PositionManager.UnStick();
host.ClearTarget();
host.NotifyExitWorld();
}
private static LiveEntityRuntime Runtime(Action<LiveEntityRecord>? teardown = null) =>
teardown is null
? new LiveEntityRuntime(
new GpuWorldState(),
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }))
: new LiveEntityRuntime(
new GpuWorldState(),
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }),
teardown);
private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) =>
new(
Guid: guid,
Position: null,
SetupTableId: null,
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
BasePaletteId: null,
ObjScale: null,
Name: "fixture",
ItemType: null,
MotionState: null,
MotionTableId: null,
InstanceSequence: instance);
private static AcDream.Core.World.WorldEntity Entity(
uint guid,
Vector3 position,
Quaternion rotation) =>
new()
{
Id = 1_000_000u + (guid & 0xFFFFu),
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = position,
Rotation = rotation,
MeshRefs = Array.Empty<AcDream.Core.World.MeshRef>(),
};
private static EntityPhysicsHost Host(
uint guid,
Func<uint, IPhysicsObjHost?>? resolve = null,
Vector3? position = null) =>
new(
guid,
getPosition: () => new Position(
0x01010001u,
position ?? Vector3.Zero,
Quaternion.Identity),
getVelocity: () => Vector3.Zero,
getRadius: () => 0.5f,
inContact: () => true,
minterpMaxSpeed: () => 1f,
curTime: () => 0,
physicsTimerTime: () => 0,
getObjectA: resolve ?? (_ => null),
handleUpdateTarget: _ => { },
interruptCurrentMovement: () => { });
private static EntityPhysicsHost CallbackHost(
uint guid,
Action<TargetInfo> handleUpdateTarget,
Action interruptCurrentMovement) =>
new(
guid,
getPosition: () => new Position(
0x01010001u,
Vector3.Zero,
Quaternion.Identity),
getVelocity: () => Vector3.Zero,
getRadius: () => 0.5f,
inContact: () => true,
minterpMaxSpeed: () => 1f,
curTime: () => 0,
physicsTimerTime: () => 0,
getObjectA: _ => null,
handleUpdateTarget,
interruptCurrentMovement);
private sealed class HostConsumerMotion :
ILiveEntityRemoteMotionRuntime,
ILiveEntityPhysicsHostConsumer
{
private Func<IPhysicsObjHost?>? _read;
public PhysicsBody Body { get; } = new();
public IPhysicsObjHost? Host => _read?.Invoke();
public void BindPhysicsHost(Func<IPhysicsObjHost?> read)
{
if (_read is not null)
throw new InvalidOperationException("already bound");
_read = read;
}
}
private sealed class ThrowingCellConsumerMotion :
ILiveEntityRemoteMotionRuntime,
ILiveEntityCanonicalRuntimeConsumer
{
private bool _failOnce = true;
public PhysicsBody Body { get; } = new();
public void BindCanonicalRuntime(
Func<IPhysicsObjHost?> readPhysicsHost,
Func<uint> readCell,
Action<uint> writeCell)
{
ArgumentNullException.ThrowIfNull(readPhysicsHost);
ArgumentNullException.ThrowIfNull(readCell);
ArgumentNullException.ThrowIfNull(writeCell);
if (_failOnce)
{
_failOnce = false;
throw new InvalidOperationException("fixture context bind failure");
}
}
}
private sealed class AlternatingBodyMotion(
PhysicsBody first,
PhysicsBody second) : ILiveEntityRemoteMotionRuntime
{
private int _reads;
public PhysicsBody Body => _reads++ == 0 ? first : second;
}
private sealed class SequenceBodyMotion(params PhysicsBody[] bodies) :
ILiveEntityRemoteMotionRuntime
{
public int ReadCount { get; private set; }
public PhysicsBody Body
{
get
{
int index = Math.Min(ReadCount, bodies.Length - 1);
ReadCount++;
return bodies[index];
}
}
}
private sealed class CallbackCanonicalMotion(Action callback) :
ILiveEntityRemoteMotionRuntime,
ILiveEntityCanonicalRuntimeConsumer
{
public PhysicsBody Body { get; } = new();
public void BindCanonicalRuntime(
Func<IPhysicsObjHost?> readPhysicsHost,
Func<uint> readCell,
Action<uint> writeCell)
{
ArgumentNullException.ThrowIfNull(readPhysicsHost);
ArgumentNullException.ThrowIfNull(readCell);
ArgumentNullException.ThrowIfNull(writeCell);
callback();
}
}
}

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Numerics; using System.Numerics;
using AcDream.App.Input; using AcDream.App.Input;
using AcDream.App.Physics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion; using AcDream.Core.Physics.Motion;

View file

@ -62,6 +62,9 @@ internal sealed class R5Host : IPhysicsObjHost
public IPhysicsObjHost? GetObjectA(uint id) public IPhysicsObjHost? GetObjectA(uint id)
=> World.TryGetValue(id, out var h) && h.Resolvable ? h : null; => World.TryGetValue(id, out var h) && h.Resolvable ? h : null;
public IPhysicsObjHost? GetRelationshipTarget(uint objectId) =>
_targetManager?.GetRelationshipTarget(objectId);
public void HandleUpdateTarget(TargetInfo info) public void HandleUpdateTarget(TargetInfo info)
{ {
HandleUpdateTargetCalls.Add(info); HandleUpdateTargetCalls.Add(info);
@ -79,12 +82,14 @@ internal sealed class R5Host : IPhysicsObjHost
public void ClearTarget() => _targetManager?.ClearTarget(); public void ClearTarget() => _targetManager?.ClearTarget();
public void ReceiveTargetUpdate(TargetInfo info) => _targetManager?.ReceiveUpdate(info); public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) =>
_targetManager?.ReceiveUpdate(info, sender);
public void AddVoyeur(uint watcherId, float radius, double quantum) public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum)
=> TargetManager.AddVoyeur(watcherId, radius, quantum); => TargetManager.AddVoyeur(watcher, radius, quantum);
public void RemoveVoyeur(uint watcherId) => _targetManager?.RemoveVoyeur(watcherId); public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) =>
_targetManager?.RemoveVoyeur(watcherId, expectedWatcher);
// ── test helpers ─────────────────────────────────────────────────────── // ── test helpers ───────────────────────────────────────────────────────
public void SetOrigin(Vector3 origin) public void SetOrigin(Vector3 origin)

View file

@ -352,6 +352,8 @@ internal sealed class RemoteChaseHarness
public double PhysicsTimerTime => _h.Now; public double PhysicsTimerTime => _h.Now;
public IPhysicsObjHost? GetObjectA(uint id) public IPhysicsObjHost? GetObjectA(uint id)
=> id == PlayerGuid ? _h._playerHost : null; => id == PlayerGuid ? _h._playerHost : null;
public IPhysicsObjHost? GetRelationshipTarget(uint objectId)
=> GetObjectA(objectId);
public void HandleUpdateTarget(TargetInfo info) public void HandleUpdateTarget(TargetInfo info)
{ {
@ -373,9 +375,9 @@ internal sealed class RemoteChaseHarness
} }
public void ClearTarget() => _h._targetArmed = false; public void ClearTarget() => _h._targetArmed = false;
public void ReceiveTargetUpdate(TargetInfo info) { } public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) { }
public void AddVoyeur(uint watcherId, float radius, double quantum) { } public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) { }
public void RemoveVoyeur(uint watcherId) { } public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) { }
} }
private sealed class TargetHost : IPhysicsObjHost private sealed class TargetHost : IPhysicsObjHost
@ -391,13 +393,14 @@ internal sealed class RemoteChaseHarness
public double CurTime => _h.Now; public double CurTime => _h.Now;
public double PhysicsTimerTime => _h.Now; public double PhysicsTimerTime => _h.Now;
public IPhysicsObjHost? GetObjectA(uint id) => null; public IPhysicsObjHost? GetObjectA(uint id) => null;
public IPhysicsObjHost? GetRelationshipTarget(uint objectId) => null;
public void HandleUpdateTarget(TargetInfo info) { } public void HandleUpdateTarget(TargetInfo info) { }
public void InterruptCurrentMovement() { } public void InterruptCurrentMovement() { }
public void SetTarget(uint contextId, uint objectId, float radius, double quantum) { } public void SetTarget(uint contextId, uint objectId, float radius, double quantum) { }
public void ClearTarget() { } public void ClearTarget() { }
public void ReceiveTargetUpdate(TargetInfo info) { } public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) { }
public void AddVoyeur(uint watcherId, float radius, double quantum) { } public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) { }
public void RemoveVoyeur(uint watcherId) { } public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) { }
} }
// ── The per-tick pipeline (GameWindow.TickAnimations order) ──────────── // ── The per-tick pipeline (GameWindow.TickAnimations order) ────────────

View file

@ -218,11 +218,11 @@ public sealed class StickyManagerTests
var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f }; var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f };
var target = new R5Host(20u, world); var target = new R5Host(20u, world);
var sticky = new StickyManager(self); var sticky = new StickyManager(self);
target.Resolvable = false;
sticky.StickTo(target.Id, 0.5f, 1.0f); sticky.StickTo(target.Id, 0.5f, 1.0f);
// Cache a position via HandleUpdateTarget, then make the target vanish. // Cache a position without ever establishing an exact target token.
var tp = new Position(1u, new Vector3(4f, 0f, 0f), Quaternion.Identity); var tp = new Position(1u, new Vector3(4f, 0f, 0f), Quaternion.Identity);
sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp)); sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
target.Resolvable = false; // GetObjectA(target) → null → fall back to cached
var frame = new MotionDeltaFrame(); var frame = new MotionDeltaFrame();
sticky.AdjustOffset(frame, quantum: 0.1); sticky.AdjustOffset(frame, quantum: 0.1);

View file

@ -76,7 +76,9 @@ public sealed class TargetManagerTests
int before = self.HandleUpdateTargetCalls.Count; int before = self.HandleUpdateTargetCalls.Count;
var p = new Position(1u, Vector3.Zero, Quaternion.Identity); var p = new Position(1u, Vector3.Zero, Quaternion.Identity);
self.TargetManager.ReceiveUpdate(new TargetInfo(999u, TargetStatus.Ok, p, p)); self.TargetManager.ReceiveUpdate(
new TargetInfo(999u, TargetStatus.Ok, p, p),
target);
Assert.Equal(before, self.HandleUpdateTargetCalls.Count); Assert.Equal(before, self.HandleUpdateTargetCalls.Count);
} }
@ -88,7 +90,9 @@ public sealed class TargetManagerTests
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
var p = new Position(1u, Vector3.Zero, Quaternion.Identity); var p = new Position(1u, Vector3.Zero, Quaternion.Identity);
self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.ExitWorld, p, p)); self.TargetManager.ReceiveUpdate(
new TargetInfo(target.Id, TargetStatus.ExitWorld, p, p),
target);
Assert.Null(self.TargetManager.TargetInfo); // cleared Assert.Null(self.TargetManager.TargetInfo); // cleared
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); // unsubscribed Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); // unsubscribed
@ -102,7 +106,9 @@ public sealed class TargetManagerTests
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
var tp = new Position(1u, new Vector3(0f, 5f, 0f), Quaternion.Identity); var tp = new Position(1u, new Vector3(0f, 5f, 0f), Quaternion.Identity);
self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp)); self.TargetManager.ReceiveUpdate(
new TargetInfo(target.Id, TargetStatus.Ok, tp, tp),
target);
// self→target interp position (0,5,0) normalized = +Y. // self→target interp position (0,5,0) normalized = +Y.
var heading = self.TargetManager.TargetInfo!.Value.InterpolatedHeading; var heading = self.TargetManager.TargetInfo!.Value.InterpolatedHeading;
@ -110,6 +116,46 @@ public sealed class TargetManagerTests
Assert.Equal(1f, heading.Y, 3); Assert.Equal(1f, heading.Y, 3);
} }
[Fact]
public void ReceiveUpdate_SameGuidReplacementSender_IsIgnored()
{
var (self, originalTarget, world) = TwoHosts();
self.TargetManager.SetTarget(0, originalTarget.Id, 1.0f, 0.0);
self.HandleUpdateTargetCalls.Clear();
var replacement = new R5Host(originalTarget.Id, world);
var p = new Position(1u, new Vector3(99f, 0f, 0f), Quaternion.Identity);
self.ReceiveTargetUpdate(
new TargetInfo(originalTarget.Id, TargetStatus.ExitWorld, p, p),
replacement);
Assert.Empty(self.HandleUpdateTargetCalls);
Assert.NotNull(self.TargetManager.TargetInfo);
Assert.Same(
originalTarget,
self.TargetManager.GetRelationshipTarget(originalTarget.Id));
}
[Fact]
public void StickyAdjustOffset_UsesExactTargetInsteadOfSameGuidReplacement()
{
var (self, originalTarget, world) = TwoHosts();
self.SetOrigin(Vector3.Zero);
originalTarget.SetOrigin(new Vector3(5f, 0f, 0f));
self.PositionManager.StickTo(originalTarget.Id, 0.5f, 1f);
var replacement = new R5Host(originalTarget.Id, world);
replacement.SetOrigin(new Vector3(10f, 0f, 0f));
originalTarget.SetOrigin(new Vector3(-5f, 0f, 0f));
var delta = new MotionDeltaFrame();
self.PositionManager.AdjustOffset(delta, 0.1);
Assert.True(delta.Origin.X < 0f);
Assert.Same(
originalTarget,
self.TargetManager.GetRelationshipTarget(originalTarget.Id));
}
[Fact] [Fact]
public void HandleTargetting_UndefinedTarget_TimesOutAfter10Seconds() public void HandleTargetting_UndefinedTarget_TimesOutAfter10Seconds()
{ {
@ -133,9 +179,8 @@ public sealed class TargetManagerTests
public void HandleTargetting_Throttles_Within500ms() public void HandleTargetting_Throttles_Within500ms()
{ {
var (self, target, _) = TwoHosts(); var (self, target, _) = TwoHosts();
var w = new TargettedVoyeurInfo(self.Id, radius: 0.1f, quantum: 0.0);
// seed a voyeur directly + advance so a sweep WOULD send if not throttled. // seed a voyeur directly + advance so a sweep WOULD send if not throttled.
target.TargetManager.AddVoyeur(self.Id, 0.1f, 0.0); target.TargetManager.AddVoyeur(self, 0.1f, 0.0);
target.SetOrigin(new Vector3(10f, 0f, 0f)); // far past radius target.SetOrigin(new Vector3(10f, 0f, 0f)); // far past radius
target.PhysicsTimerTime = 0.3; // < 0.5 throttle target.PhysicsTimerTime = 0.3; // < 0.5 throttle
@ -186,12 +231,12 @@ public sealed class TargetManagerTests
{ {
var (self, target, _) = TwoHosts(); var (self, target, _) = TwoHosts();
target.SetOrigin(Vector3.Zero); target.SetOrigin(Vector3.Zero);
target.TargetManager.AddVoyeur(self.Id, radius: 1.0f, quantum: 0.0); target.TargetManager.AddVoyeur(self, radius: 1.0f, quantum: 0.0);
var voyeur = target.TargetManager.VoyeurTable![self.Id]; var voyeur = target.TargetManager.VoyeurTable![self.Id];
Assert.Equal(Vector3.Zero, voyeur.LastSentPosition.Frame.Origin); Assert.Equal(Vector3.Zero, voyeur.LastSentPosition.Frame.Origin);
target.SetOrigin(new Vector3(5f, 0f, 0f)); target.SetOrigin(new Vector3(5f, 0f, 0f));
target.TargetManager.AddVoyeur(self.Id, radius: 2.5f, quantum: 0.3); // existing → update only target.TargetManager.AddVoyeur(self, radius: 2.5f, quantum: 0.3); // existing → update only
Assert.Equal(2.5f, voyeur.Radius); Assert.Equal(2.5f, voyeur.Radius);
Assert.Equal(0.3, voyeur.Quantum); Assert.Equal(0.3, voyeur.Quantum);
@ -202,10 +247,27 @@ public sealed class TargetManagerTests
public void RemoveVoyeur_RemovesEntry() public void RemoveVoyeur_RemovesEntry()
{ {
var (self, target, _) = TwoHosts(); var (self, target, _) = TwoHosts();
target.TargetManager.AddVoyeur(self.Id, 1.0f, 0.0); target.TargetManager.AddVoyeur(self, 1.0f, 0.0);
Assert.True(target.TargetManager.RemoveVoyeur(self.Id)); Assert.True(target.TargetManager.RemoveVoyeur(self.Id, self));
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
Assert.False(target.TargetManager.RemoveVoyeur(self.Id)); // already gone Assert.False(target.TargetManager.RemoveVoyeur(self.Id, self)); // already gone
}
[Fact]
public void HiddenWatcher_GuidReuseCannotRedirectOrRemoveExactSubscription()
{
var (watcher, target, world) = TwoHosts();
watcher.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
watcher.HandleUpdateTargetCalls.Clear();
watcher.Resolvable = false;
var replacement = new R5Host(watcher.Id, world);
target.TargetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
Assert.Single(watcher.HandleUpdateTargetCalls);
Assert.Empty(replacement.HandleUpdateTargetCalls);
Assert.False(target.TargetManager.RemoveVoyeur(watcher.Id, replacement));
Assert.True(target.TargetManager.RemoveVoyeur(watcher.Id, watcher));
} }
[Fact] [Fact]