feat(physics): R5-V1 — port PositionManager/Sticky/Constraint + TargetManager (Core, unwired)
The retail movement-manager family the R4 MoveToManager port left as do-not-invent seams (decomp §9f/§9g). Faithful C# ports of retail's PositionManager facade + StickyManager + ConstraintManager + the TargetManager voyeur system, with full conformance tests. NO wiring yet — purely additive, no behavior change. Wiring (retiring TS-39 sticky + AP-79 target adapter) is R5-V2/V3. New Core classes (src/AcDream.Core/Physics/Motion/): - StickyManager (0x00555400): follow-a-target steering. adjust_offset's dense x87 mush decoded via ACE (StickyRadius 0.3, StickyTime 1.0, follow speed ×5 / fallback 15) — speed-clamped signed-distance steer + bounded turn-to-face; 1 s watchdog; Ok→initialized / non-Ok→teardown. - ConstraintManager (0x00556090): the server-position rubber-band leash. 90% IsFullyConstrained jump gate + grounded linear brake taper. Structural only — acdream never ARMS it (retail arms from SmartBox::HandleReceivedPosition, which acdream lacks, with two x87 constants BN elided). IsFullyConstrained stays false = TS-35 behavior; leash-arming + the unknown constants are a deferred issue. - PositionManager facade (0x00555160): lazy Sticky/Constraint + fan-out. - TargetManager (0x0051a370) + TargettedVoyeurInfo: the peer-to-peer voyeur subscription system (0.5 s throttle, 10 s staleness, send-on-drift-past-radius, dead-reckon GetInterpolatedPosition). A faithful superset of the AP-79 adapter — SetTarget subscribes ON the target; the target's HandleTargetting pushes updates back. - IPhysicsObjHost: the CPhysicsObj back-pointer seam (position/velocity/ radius/contact/GetObjectA + target-tracking fan-out) the App wires per entity in V2/V3. MotionDeltaFrame: mutable retail-Frame delta accumulator. Supporting: - TargetInfo extended to the full retail 10-field struct (additive defaults keep the R4 4-arg call sites compiling). - MoveToMath: signed CylinderDistanceNoZ, NormalizeCheckSmall, GlobalToLocalVec. - Rename: the misnamed AcDream.Core.Physics.PositionManager (a remote anim+interp per-frame combiner, NOT the retail facade) → RemoteMotion Combiner, freeing the name and removing the ambiguity that breaks every file importing both Physics + Physics.Motion (GameWindow will in V2/V3). Tests: 42 new conformance cases (Sticky/Constraint/Position facade + TargetManager incl. the full cross-entity voyeur round-trip). Full suite 4006 green (+2 skipped), no regressions. Decomp + ACE cross-ref + port plan: docs/research/2026-07-03-r5-managers/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
517bbfdae4
commit
3d89446d98
25 changed files with 7279 additions and 12 deletions
272
docs/research/2026-07-03-r5-managers/r5-acdream-seams.md
Normal file
272
docs/research/2026-07-03-r5-managers/r5-acdream-seams.md
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
# R5 seam recon — current acdream integration points MovementManager/PositionManager/TargetManager will replace
|
||||||
|
|
||||||
|
Repo root: `C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`
|
||||||
|
Read-only recon. All line numbers verified against files as of this session (2026-07-03).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. MoveToManager seams — `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (1605 lines)
|
||||||
|
|
||||||
|
### Ctor-required seam delegates (lines 78-138, ctor 184-222)
|
||||||
|
All `Action`/`Func` fields, all REQUIRED (non-nullable, thrown in ctor if null):
|
||||||
|
- `_stopCompletely` (L78) — retail `CPhysicsObj::StopCompletely`
|
||||||
|
- `_getPosition` (L83) — retail `physics_obj->m_position`
|
||||||
|
- `_getHeading` (L87) — retail `CPhysicsObj::get_heading`
|
||||||
|
- `_setHeading` (L94) — retail `CPhysicsObj::set_heading(heading, send)` — the ONE heading snap, fired only from `HandleTurnToHeading` (L1168)
|
||||||
|
- `_getOwnRadius` (L99), `_getOwnHeight` (L103) — mover's own cylinder dims
|
||||||
|
- `_contact` (L107) — retail `transient_state & 1` (CONTACT bit) — `UseTime`'s tick gate
|
||||||
|
- `_isInterpolating` (L112) — retail `CPhysicsObj::IsInterpolating` → PositionManager (R5 body doesn't exist yet)
|
||||||
|
- `_getVelocity` (L116) — feeds Phase-3 TargetManager quantum retune
|
||||||
|
- `_getSelfId` (L120) — self-target detection
|
||||||
|
- `_setTarget` (L126) — retail `CPhysicsObj::set_target` → **TargetManager::SetTarget (R5, call-shape only)**
|
||||||
|
- `_clearTarget` (L130) — retail `CPhysicsObj::clear_target` → **TargetManager::ClearTarget (R5, call-shape only)**
|
||||||
|
- `_getTargetQuantum` (L134) / `_setTargetQuantum` (L138) — **TargetManager quantum (R5, call-shape only)**
|
||||||
|
- `_curTime` (L175, optional param, defaults to a per-call 1/30s stub)
|
||||||
|
|
||||||
|
### AP-79 target seams (setTarget/clearTarget)
|
||||||
|
- `_setTarget(0, TopLevelObjectId, 0.5f, 0.0)` called from `MoveToObject` (L471) and `TurnToObject` (L592) — always context 0, radius 0.5, quantum 0.0.
|
||||||
|
- `_clearTarget()` called from `CleanUp` (L1499) when `TopLevelObjectId != 0 && MovementTypeState != Invalid`.
|
||||||
|
- Consumer of the callback: `HandleUpdateTarget(TargetInfo info)` (L1229-1283) — the P4 TargetTracker feed point. Ignores updates for any `info.ObjectId != TopLevelObjectId` (L1237). Two paths: deferred-start (`Initialized==false`, first callback) vs retarget-while-running.
|
||||||
|
- **Today's producer of `TargetInfo` is NOT a real TargetManager** — it's `GameWindow.TickRemoteMoveTo` (App-layer, §2c below) polling `_entitiesByServerGuid` each tick and manually diffing against `TrackedTargetRadius`.
|
||||||
|
|
||||||
|
### TS-39 sticky seams (StickTo/Unstick)
|
||||||
|
- `public Action? Unstick { get; set; }` (L145) — retail `CPhysicsObj::unstick_from_object` → **PositionManager::UnStick (R5 body)**. Called unconditionally at the head of `PerformMovement` (L414). Optional — null = silent no-op.
|
||||||
|
- `public Action<uint,float,float>? StickTo { get; set; }` (L151) — retail `PositionManager::StickTo(object_id, radius, height)` (**R5 StickyManager body**). Called from `BeginNextNode`'s sticky arrival handoff (L708) with pre-CleanUp values `(tlid, radius, height)`. Optional — null = silent no-op.
|
||||||
|
- **Today, NEITHER `Unstick` nor `StickTo` is bound to anything** in GameWindow.cs — grep of `EnsureRemoteMotionBindings` and `EnterPlayerModeNow` shows no `rm.MoveTo.StickTo = ...` or `.Unstick = ...` assignment anywhere. Both are permanently null (silent no-op) in production today. (Separately, `MotionInterpreter.UnstickFromObject` — a DIFFERENT, unrelated seam on the interp, not the MoveToManager — IS bound in some contexts; see §3.)
|
||||||
|
|
||||||
|
### setHeading seam
|
||||||
|
- `_setHeading: Action<float,bool>` — bound per-manager at construction (remote: `mtBody.Orientation = MoveToMath.SetHeading(...)`, GameWindow.cs:4277-4278; player: `pcMoveTo.Yaw = MoveToMath.YawFromHeading(h)`, GameWindow.cs:12996-12997). The `bool send` (network-echo flag) param is **UNCONSUMED** on both sides — register row TS-33 (GameWindow.cs:12983-12988 comment): a stationary heading snap never reaches the wire.
|
||||||
|
|
||||||
|
### Public surface (entry points + drivers)
|
||||||
|
- `PerformMovement(MovementStruct mvs)` (L411) — the type-6..9 dispatch entry, called from `RouteServerMoveTo` (GameWindow.cs, §5below)
|
||||||
|
- `MoveToObject` / `MoveToPosition` / `TurnToObject` / `TurnToHeading` (L450, 501, 560, 613)
|
||||||
|
- `UseTime()` (L953) — THE TICK GATE: `!_contact()` / no pending node / (object-move `Initialized` gate) → dispatches `HandleMoveToPosition` / `HandleTurnToHeading`. Called from `GameWindow.TickRemoteMoveTo` (L4504) for remotes; not shown called for the player in the same function (player pump is via `_playerController`/`OnUpdateFrame` — see §2c).
|
||||||
|
- `HandleUpdateTarget(TargetInfo info)` (L1229) — AP-79 feed point (see above)
|
||||||
|
- `HitGround()` (L1292) — no-op if `MovementTypeState==Invalid`, else `BeginNextNode()`. Called from GameWindow at L5426 (remote landing) and L10070 (another remote landing site — see §2d).
|
||||||
|
- `CancelMoveTo(WeenieError error)` (L1461) — reentrancy-guarded drain+cleanup+stop; the `error` param is NEVER read in retail's body (kept for parity/logging only)
|
||||||
|
- `IsMovingTo()` (L397), `Destroy()` (L387), `CleanUp()` (L1485), `CleanUpAndCallWeenie()` (L1516)
|
||||||
|
- `MoveToComplete` (L168) — **CLIENT ADDITION, not retail** — fires `WeenieError.None` on natural completion only (never on cancel). Bound in `EnterPlayerModeNow` (GameWindow.cs:13024-13030) to re-fire the deferred AD-27 Use/PickUp action.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. GameWindow wiring — `src/AcDream.App/Rendering/GameWindow.cs` (13,587 lines)
|
||||||
|
|
||||||
|
### 2a. `EnsureRemoteMotionBindings` (GameWindow.cs:4234-4308)
|
||||||
|
Binds, once per `RemoteMotion` (guarded by `rm.Sink is not null` early-return, L4239):
|
||||||
|
- `rm.Sink = new MotionTableDispatchSink(ae.Sequencer)` with `TurnApplied`/`TurnStopped` callbacks (L4243-4255) feeding `rmForSink.ObservedOmega`
|
||||||
|
- `rm.Motion.DefaultSink = rm.Sink` (L4256)
|
||||||
|
- `rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations` (L4257)
|
||||||
|
- `rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState()` (L4258)
|
||||||
|
- `rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions` (L4261) — R3-W5 per-op zero-tick flush
|
||||||
|
- `rm.MoveTo = new MoveToManager(...)` (L4270-4300) — full seam wiring: `stopCompletely`, `getPosition` (world-space, `rmT.CellId` + `mtBody.Position/Orientation`), `getHeading`/`setHeading` via `MoveToMath`, `getOwnRadius`/`getOwnHeight` **both hardcoded `0f`** (comment: "pin P4 note — setup cylsphere radius lands with R5's TargetManager port"), `contact: () => mtBody.OnWalkable`, `isInterpolating: () => rmT.Interp.IsActive`, `getVelocity`, `getSelfId: () => serverGuid`, `setTarget`/`clearTarget` writing `rmT.TrackedTargetGuid/Radius/FedOnce` (L4285-4291), `getTargetQuantum`/`setTargetQuantum` on `rmT.TargetQuantum`, real-clock `curTime` (L4294-4300, R4-V5 fix — was per-call stub before)
|
||||||
|
- `rm.Motion.InterruptCurrentMovement = () => rmT.MoveTo?.CancelMoveTo(WeenieError.ActionCancelled)` (L4304-4306) — TS-36, retail's `interrupt_current_movement → MovementManager::CancelMoveTo(0x36)` chain
|
||||||
|
- **NOT bound here**: `rm.MoveTo.StickTo`, `rm.MoveTo.Unstick`, `rm.MoveTo.MoveToComplete` — all left null for remotes.
|
||||||
|
|
||||||
|
### 2b. `EnterPlayerModeNow` (GameWindow.cs:12960-13145+)
|
||||||
|
Player-side construction, mirrors 2a with player-specific deltas (documented at L12970-12988):
|
||||||
|
- (a) heading via `pcMoveTo.Yaw` bridge, not a quaternion write directly
|
||||||
|
- (b) `contact: () => pcMoveTo.BodyInContact` — real Contact transient bit, not OnWalkable proxy
|
||||||
|
- (c) `isInterpolating: () => false` — player has no InterpolationManager
|
||||||
|
- `playerMoveTo = new MoveToManager(...)` (L12990-13013)
|
||||||
|
- `playerMoveTo.MoveToComplete = err => { ...; if (err==None) OnAutoWalkArrivedSendDeferredAction(); }` (L13024-13030) — the ONLY MoveToComplete binding in the codebase (AD-27 reanchor)
|
||||||
|
- `_playerController.MoveTo = playerMoveTo` (L13031)
|
||||||
|
- `_playerController.Motion.InterruptCurrentMovement = () => playerMoveTo.CancelMoveTo(WeenieError.ActionCancelled)` (L13039-13045) — TS-36 player side
|
||||||
|
- `_playerController.Motion.CheckForCompletedMotions = playerSeq.Manager.CheckForCompletedMotions` (L13145-13146), inside the `if (_animatedEntities.TryGetValue(...))` sequencer-bind block (L13135+) — the R4-V5 "moveto-stall fix #2" comment (L13126-13134) documents this MUST run before the initial `SetPosition` or the sequencer's pending_motions node orphans.
|
||||||
|
- **NOT bound**: `playerMoveTo.StickTo`, `playerMoveTo.Unstick`.
|
||||||
|
- Player MoveToManager tick pump: **not inside `EnterPlayerModeNow`** — search shows no direct `playerMoveTo.UseTime()` call in that function; it's driven per-frame from `OnUpdateFrame` via `_playerMoveToTarget*` fields (see §2c) and the `RouteServerMoveTo` call at L4772 (§2e).
|
||||||
|
|
||||||
|
### 2c. AP-79 tracker block + player pre-Update feed
|
||||||
|
- **Remote-side tracker**: `TickRemoteMoveTo(RemoteMotion rm)` (GameWindow.cs:4469-4505). Feeds `HandleUpdateTarget(Ok)` from `_entitiesByServerGuid` lookup when the tracked target moved beyond `rm.TrackedTargetRadius` (voyeur-radius diff, L4479-4491), or `ExitWorld` if the guid vanished from the live table (L4493-4502); then unconditionally calls `mtm.UseTime()` (L4504).
|
||||||
|
- **Player-side fields**: `_playerMoveToTargetGuid` / `_playerMoveToTargetRadius` / `_playerMoveToTargetFedOnce` / `_playerMoveToTargetQuantum` are declared and seeded in `EnterPlayerModeNow` (L13006-13016) via the ctor's `setTarget`/`clearTarget`/`getTargetQuantum`/`setTargetQuantum` closures, but **the actual per-tick "feed HandleUpdateTarget + call UseTime()" loop for the player was NOT found inside `EnterPlayerModeNow`** — grep of `TickRemoteMoveTo`-equivalent player logic did not surface a distinct function; it likely lives in `OnUpdateFrame` (13,587-line file — not fully read this pass). **Flag for R5 authors: locate/confirm the player pump site before assuming parity with `TickRemoteMoveTo`.**
|
||||||
|
|
||||||
|
### 2d. HitGround / LeaveGround / MotionDone / UseTime / CheckForCompletedMotions call sites
|
||||||
|
- `rm.Motion.LeaveGround()` — GameWindow.cs:5100 (remote ground departure, retail `LeaveGround` 0x00528b00)
|
||||||
|
- `rmState.Motion.HitGround()` + `rmState.MoveTo?.HitGround()` — GameWindow.cs:5425-5426 (remote landing; comment cites #161, retail `MovementManager::HitGround`)
|
||||||
|
- `rm.Motion.HitGround()` + `rm.MoveTo?.HitGround()` — GameWindow.cs:10062, 10070 (a SECOND remote-landing site, same pattern — the file has two independent landing branches)
|
||||||
|
- `ae.Sequencer.MotionDoneTarget = (m, ok) => { ...; interp?.MotionDone(m, ok); }` — GameWindow.cs:10142-10160 (R3-W2, bound once per sequencer; retail `CPhysicsObj::MotionDone` 0x0050fdb0 chain)
|
||||||
|
- `ae.Sequencer.Manager.UseTime()` — GameWindow.cs:10194 (this is the ANIMATION sequencer's own UseTime, not MoveToManager's — do not conflate)
|
||||||
|
- `mtm.UseTime()` — GameWindow.cs:4504 inside `TickRemoteMoveTo` (the MoveToManager tick, per remote, per frame)
|
||||||
|
- `rm.Motion.CheckForCompletedMotions = ...` — GameWindow.cs:4261 (bind site, in `EnsureRemoteMotionBindings`)
|
||||||
|
|
||||||
|
### 2e. RouteServerMoveTo (the type-6..9 funnel entry — see §5 also)
|
||||||
|
`RouteServerMoveTo(MoveToManager mgr, MotionInterpreter interp, uint cellId, EntityMotionUpdate update)` — GameWindow.cs:4361-4457. Two call sites:
|
||||||
|
- Player: GameWindow.cs:4772 — `RouteServerMoveTo(playerMoveTo, _playerController.Motion, _playerController.CellId, update)`, inside the `update.Guid == _playerServerGuid` branch (L4716), gated behind `_playerController is not null` (L4753) and preceded by `_playerController.Motion.InterruptCurrentMovement?.Invoke()` + `.UnstickFromObject?.Invoke()` (L4768-4769) — the retail unpack_movement head (@300566).
|
||||||
|
- Remote: GameWindow.cs:4835 — `RouteServerMoveTo(moveMgr, remoteMot.Motion, remoteMot.CellId, update)`, inside the `else` (non-player) branch (L4784), also preceded by `remoteMot.Motion.InterruptCurrentMovement?.Invoke()` + `.UnstickFromObject?.Invoke()` (L4828-4829).
|
||||||
|
|
||||||
|
### 2f. RemoteMotion class — GameWindow.cs:411-510+ (nested `internal sealed class RemoteMotion`, NOT a separate file)
|
||||||
|
Manager-related fields:
|
||||||
|
- `PhysicsBody Body`, `MotionInterpreter Motion` (L413-414)
|
||||||
|
- `MotionTableDispatchSink? Sink` (L419)
|
||||||
|
- `MoveToManager? MoveTo` (L436) — R4-V4
|
||||||
|
- AP-79 tracker state: `TrackedTargetGuid`, `TrackedTargetRadius`, `TrackedTargetLastFedPos`, `TrackedTargetFedOnce`, `TargetQuantum` (L442-446)
|
||||||
|
- Legacy/parallel `RemoteMoveToDriver`-oriented fields still present: `ServerMoveToActive`, `HasMoveToDestination`, `MoveToDestinationWorld`, `MoveToMinDistance`, `MoveToDistanceToObject`, `MoveToMoveTowards`, `LastMoveToPacketTime` (L448-503) — comments reference `AcDream.Core.Physics.RemoteMoveToDriver`, which per the R4 handoff docs was **retired** in the R4 "three approximations" cleanup (per MEMORY.md: "RemoteMoveToDriver/PlanMoveToStart/B.6-auto-walk all deleted"). These fields may now be dead weight predating that retirement — worth confirming during R5 cleanup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. MotionInterpreter sticky/action seams — `src/AcDream.Core/Physics/MotionInterpreter.cs` (3,298 lines)
|
||||||
|
|
||||||
|
- `StickTo`/`Unstick`/`stick` grep: **NO `StickTo` seam exists on `MotionInterpreter`** — only `UnstickFromObject` (L779: `public Action? UnstickFromObject { get; set; }`, doc L771-807), the R3-W2 no-op seam standing in for retail `CPhysicsObj::unstick_from_object`. This is a DIFFERENT seam from `MoveToManager.Unstick`/`.StickTo` (§1) — MotionInterpreter's copy is called at L2329 and L2375 inside its own dispatch/cancel paths, unrelated to the MoveToManager sticky handoff.
|
||||||
|
- `StandingLongJump`: field `public bool StandingLongJump;` (L747). Arms ONLY at `ChargeJump` (L25 comment, L1970 `StandingLongJump = true;`). Cleared at L2023 and L2511 (`StandingLongJump = false;`). Consumed in `ApplyInterpretedMovement`'s three-way branch (L2947, quoted below) and in `DoInterpretedMotion`'s verbatim retail body pseudocode (L2995-2997, `label_528440` goto).
|
||||||
|
|
||||||
|
### The #164 action-dispatch loop — `ApplyInterpretedMovement` (MotionInterpreter.cs:2920-2981)
|
||||||
|
```csharp
|
||||||
|
public void ApplyInterpretedMovement(
|
||||||
|
uint currentStyle, IInterpretedMotionSink? sink,
|
||||||
|
bool cancelMoveTo = false, bool allowJump = false)
|
||||||
|
{
|
||||||
|
if (PhysicsObj is null) return;
|
||||||
|
|
||||||
|
// Retail's rewritten var_2c (raw 305778 decoded; ACE 444-449):
|
||||||
|
// Speed is re-set per axis below, everything else rides the pass.
|
||||||
|
var p = new MovementParameters
|
||||||
|
{
|
||||||
|
SetHoldKey = false,
|
||||||
|
ModifyInterpretedState = false,
|
||||||
|
CancelMoveTo = cancelMoveTo,
|
||||||
|
DisableJumpDuringLink = !allowJump,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
|
||||||
|
MyRunRate = InterpretedState.ForwardSpeed;
|
||||||
|
|
||||||
|
p.Speed = 1.0f;
|
||||||
|
DoInterpretedMotion(currentStyle, p, sink);
|
||||||
|
|
||||||
|
if (!contact_allows_move(InterpretedState.ForwardCommand))
|
||||||
|
{
|
||||||
|
p.Speed = 1.0f; // raw 305728: var_18_2 = 0x3f800000
|
||||||
|
DoInterpretedMotion(MotionCommand.Falling, p, sink);
|
||||||
|
}
|
||||||
|
else if (StandingLongJump)
|
||||||
|
{
|
||||||
|
p.Speed = 1.0f;
|
||||||
|
DoInterpretedMotion(MotionCommand.Ready, p, sink);
|
||||||
|
StopInterpretedMotion(MotionCommand.SideStepRight, p, sink);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
p.Speed = InterpretedState.ForwardSpeed;
|
||||||
|
DoInterpretedMotion(InterpretedState.ForwardCommand, p, sink);
|
||||||
|
if (InterpretedState.SideStepCommand == 0)
|
||||||
|
{
|
||||||
|
StopInterpretedMotion(MotionCommand.SideStepRight, p, sink);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
p.Speed = InterpretedState.SideStepSpeed;
|
||||||
|
DoInterpretedMotion(InterpretedState.SideStepCommand, p, sink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (InterpretedState.TurnCommand != 0)
|
||||||
|
{
|
||||||
|
p.Speed = InterpretedState.TurnSpeed;
|
||||||
|
DoInterpretedMotion(InterpretedState.TurnCommand, p, sink);
|
||||||
|
return; // retail early return — no idle-stop this call
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tail: unconditional StopInterpretedMotion(TurnRight, params).
|
||||||
|
StopInterpretedMotion(MotionCommand.TurnRight, p, sink);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**Correction to the task framing**: the dispatched `MovementParameters p` is built ONCE at the top with `Autonomous` implicitly `false` (default, never set) and **explicitly** sets `SetHoldKey=false`, `ModifyInterpretedState=false`, `CancelMoveTo=cancelMoveTo`, `DisableJumpDuringLink=!allowJump` — only `p.Speed` is mutated per-branch (not re-constructed per dispatch as `new MovementParameters { Speed }`). The #161 doc comment (L2892-2918) explicitly discusses this exact bitfield and calls `ModifyInterpretedState=false` "load-bearing." The `Autonomous` bit is never read or written anywhere in this method — confirms the framing that the loop drops/never-carries the Autonomous bit through to per-dispatch `MovementParameters`.
|
||||||
|
|
||||||
|
This is called from `MoveToInterpretedState` (L2820-2866, the funnel entry) at L2843: `ApplyInterpretedMovement(ims.CurrentStyle, sink, cancelMoveTo: true, allowJump: allowJump);` — always `cancelMoveTo: true` for inbound wire state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. UpdateMotion wire parsing — `src/AcDream.Core.Net/Messages/UpdateMotion.cs` (479 lines)
|
||||||
|
|
||||||
|
### mt-0 header flag parsing
|
||||||
|
- **MotionFlags byte** (wire term "header" in decomp comments) parsed at L161: `byte motionFlags = body[pos]; pos += 1;` — comment (L148-149) documents `0x1 = StickToObject, 0x2 = StandingLongJump` (ACE `MotionFlags` enum names). **This is NOT the same as the "0x100"/"0x200" framing in the task** — those are decomp-doc shorthand for the SAME bits at a different byte-shift (`header & 0x100` in the decomp refers to bit 0 of this byte after the 8-bit `motionFlags` is logically at bit-position 8 of the larger unpack_movement header dword). The actual C# test is `(motionFlags & 0x1) != 0` for sticky (L290) — there is no separate literal `0x100`/`0x200` mask in this file; grep for those literals returns nothing in UpdateMotion.cs.
|
||||||
|
- **0x1 (StickToObject) parse site**: L279-295, comment block cites ACE `MovementInvalid.Write` + decomp §2f case 0 @0052455d. Code:
|
||||||
|
```csharp
|
||||||
|
if ((motionFlags & 0x1) != 0 && body.Length - pos >= 4)
|
||||||
|
{
|
||||||
|
stickyObjectGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||||
|
pos += 4;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Only fires for `movementType == 0` (InterpretedMotionState branch, L188). The trailing u32 is read AFTER the InterpretedMotionState body + Commands list (matches ACE's write order).
|
||||||
|
- **0x2 (StandingLongJump) — NOT consumed**: comment L153-160 explicitly states "DOCUMENTED here but NOT consumed — retail's `unpack_movement` case 0 writes it onto `motion_interpreter->standing_longjump` (§2f @0052458e), an R5 unpack_movement item (this parser has no MotionInterpreter reference to write it onto...)". No code reads `motionFlags & 0x2` anywhere in the file.
|
||||||
|
|
||||||
|
### Downstream consumers (grep of whole src tree)
|
||||||
|
- `StickyObjectGuid` (the parsed field, carried on `CreateObject.ServerMotionState`, L317-326 return + `CreateObject.cs:265`): grep across `src/` shows it is **assigned into the record and returned, but never read by any production consumer** — the only non-definition hits are in `tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs` (asserting the parsed value, L662/683/716). No site in `GameWindow.cs`, `MotionInterpreter.cs`, or `MoveToManager.cs` reads `.StickyObjectGuid` off `ServerMotionState`.
|
||||||
|
- `motionFlags & 0x2` / `MotionFlags.StandingLongJump`: **zero matches anywhere in the repo** outside the UpdateMotion.cs documentation comment itself.
|
||||||
|
- **Conclusion for R5**: both the sticky-guid trailer and the standing-longjump wire bit are parsed-and-shelved. R5's PositionManager::StickTo (per the UpdateMotion.cs comment, "R5's PositionManager::StickTo body is the eventual consumer") and MovementManager (for standing-longjump) are the intended consumers — neither exists yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. UM dispatch path for movement types 6-9 — funnel + head-stance question
|
||||||
|
|
||||||
|
- **Funnel/router**: `RouteServerMoveTo` (GameWindow.cs:4361-4457, quoted in part in §2e) is the ONE function that inspects `update.MotionState.IsServerControlledMoveTo`/`IsServerControlledTurnTo` + `MovementType` (6/7 → MoveToObject/MoveToPosition via `path`; 8/9 → TurnToObject/TurnToHeading via `turnPath`) and calls `mgr.PerformMovement(ms)`. Both the player call site (GameWindow.cs:4772) and remote call site (GameWindow.cs:4835) funnel through this SAME function — confirmed shared body, not duplicated per the R4-V5 extraction comment (L4459-4468).
|
||||||
|
- **Does any site do a head stance-change dispatch (`InqStyle != wire style -> DoMotion(style)`) BEFORE the type-6..9 switch?** **NO separate dispatch exists.** For the remote path, the stance/style value folds directly into `InboundInterpretedState.CurrentStyle` (GameWindow.cs:4867: `CurrentStyle = stance != 0 ? (0x80000000u | (uint)stance) : 0x8000003Du`), which is built and consumed only in the `else` branch AFTER `RouteServerMoveTo` returns `false` (i.e., only for mt-0 / non-moveto events) — see GameWindow.cs:4834-4839 (`RouteServerMoveTo` call, early-`return` on true) followed by the `ims` construction at L4865-4874. So: for a wire event that IS a type-6..9 moveto, the style/stance field is **never separately dispatched** at this layer — `MoveToManager.PerformMovement` doesn't take or apply a style at all (it only sets `MovementTypeState` and queues nodes; no `DoMotion(style)` call anywhere in `PerformMovement`/`MoveToObject`/`MoveToPosition`/`TurnToObject`/`TurnToHeading`, confirmed by reading all of §1's L411-637). The style-on-change dispatch is explicitly flagged as unported: `RetailObserverTraceConformanceTests.cs:33` documents an EXCLUSION — "motion==0x80000000 entries (the unpack-level DoMotion(command_ids[0]) style call for wire style index 0) — outside move_to_interpreted_state; **S3 wires the unpack-level style-on-change**" (not yet done). This is a concrete gap for R5/MovementManager: retail's real `unpack_movement` head does an `InqStyle`-vs-wire-style compare and issues a style `DoMotion` unconditionally (independent of movement type 0 vs 6-9), and acdream currently only applies style via the mt-0 `InboundInterpretedState.CurrentStyle` path — mt 6-9 events carry no style application at all today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. PhysicsBody — `src/AcDream.Core/Physics/PhysicsBody.cs` (576 lines)
|
||||||
|
|
||||||
|
- **IsFullyConstrained (TS-35, doc calls it TS-35 but file comment says "R3-W3 stub")**: `public bool IsFullyConstrained { get; set; }` (L263), doc block L251-262. Retail `CPhysicsObj::IsFullyConstrained` (0x0050f730), read by `CMotionInterp::jump_is_allowed` (`if (IsFullyConstrained(physics_obj) != 0) return 0x47`). Comment: "acdream has no equivalent constraint-tracking yet... stubbed false (never fires) — a real port needs the per-cell shadow-list contact accounting the physics digest tracks." **Always false in production** (settable property, but nothing in the grep'd files sets it true).
|
||||||
|
- **TransientState bits available** (`TransientStateFlags`, L62-70, `[Flags] enum`):
|
||||||
|
- `Contact = 0x1` (bit 0) — touching any surface
|
||||||
|
- `OnWalkable = 0x2` (bit 1) — standing on walkable surface
|
||||||
|
- `Sliding = 0x4` (bit 2) — carry sliding normal into next transition
|
||||||
|
- `Active = 0x80` (bit 7) — needs per-frame update
|
||||||
|
- Convenience getters: `OnWalkable` (L286), `IsActive` (L287), `InContact` (L288) properties on `PhysicsBody`.
|
||||||
|
- **PositionManager**: **does not exist as a class anywhere in the repo.** Grep for `PositionManager` across `src/` returns zero hits outside doc comments referencing it as a future R5 port target (e.g. MoveToManager.cs:24, L109-112 doc, UpdateMotion.cs comment). `IsInterpolating` (the seam MoveToManager consumes) is currently satisfied by ad-hoc booleans: `rmT.Interp.IsActive` for remotes (GameWindow.cs:4282) and a hardcoded `false` for the player (GameWindow.cs:13001) — neither is a real PositionManager.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Test files (locations, not full case enumeration)
|
||||||
|
|
||||||
|
**MoveToManager suite** — `tests/AcDream.Core.Tests/Physics/Motion/`:
|
||||||
|
- `MoveToManagerTestHarness.cs` — shared harness/fixture
|
||||||
|
- `MoveToManagerLifecycleTests.cs`
|
||||||
|
- `MoveToManagerNodePlanTests.cs`
|
||||||
|
- `MoveToManagerBeginMoveForwardTests.cs`
|
||||||
|
- `MoveToManagerAuxTurnTests.cs`
|
||||||
|
- `MoveToManagerArrivalAndProgressTests.cs`
|
||||||
|
- `MoveToManagerTurnToHeadingTests.cs`
|
||||||
|
- `MoveToManagerHandleUpdateTargetTests.cs`
|
||||||
|
- `MoveToManagerStickyAndCancelTests.cs`
|
||||||
|
- `MoveToManagerUseTimeGateTests.cs`
|
||||||
|
- `MoveToManagerEndToEndTableDriveTests.cs`
|
||||||
|
- `MoveToManagerCompletionSeamTests.cs`
|
||||||
|
- `MovementTypeWideningTests.cs`
|
||||||
|
- `MoveToMathCylinderDistanceTests.cs`
|
||||||
|
- `MotionTableDispatchSinkTests.cs`
|
||||||
|
- `InterpretedMotionStateActionFifoTests.cs`
|
||||||
|
|
||||||
|
**Funnel / 183-case conformance suite**:
|
||||||
|
- `tests/AcDream.Core.Tests/Physics/RetailObserverTraceConformanceTests.cs` (35+) — the live-retail-observer-cdb-trace conformance harness ("183/183" cited in MEMORY.md refers to this file's case count). Contains `LoginQueue_DrainsToEmpty_UnderProductionFeed` at **line 279** (`[Fact]`).
|
||||||
|
- `tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs`
|
||||||
|
- `tests/AcDream.Core.Tests/Physics/Motion/` (see above — dispatch-adjacent)
|
||||||
|
|
||||||
|
**DispatchInterpreted / MotionInterpreter core suite** — `tests/AcDream.Core.Tests/Physics/`:
|
||||||
|
- `MotionInterpreterTests.cs`
|
||||||
|
- `MotionInterpreterDoMotionFamilyTests.cs`
|
||||||
|
- `MotionInterpreterGroundLifecycleTests.cs`
|
||||||
|
- `MotionInterpreterJumpFamilyTests.cs`
|
||||||
|
- `MotionInterpreterPendingMotionsTests.cs`
|
||||||
|
- `ServerControlledLocomotionTests.cs`
|
||||||
|
- `RemoteWeenieRunRateTests.cs`
|
||||||
|
- `InWorldLinkGuardTests.cs`
|
||||||
|
- `MotionNormalizationTests.cs`
|
||||||
|
- `MotionVelocityPipelineTests.cs`
|
||||||
|
- `AnimationSequencerCutoverTraceTests.cs`
|
||||||
|
- `WeenieErrorCodeTableTests.cs`
|
||||||
|
|
||||||
|
**Player moveto cutover**:
|
||||||
|
- `tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary answers to the task's specific questions
|
||||||
|
|
||||||
|
**Q4 (UpdateMotion sticky/longjump)**: 0x1 (StickToObject) trailer IS parsed (`UpdateMotion.cs:279-295`, code at 290-293) into `stickyObjectGuid` → `ServerMotionState.StickyObjectGuid`; 0x2 (StandingLongJump) is documented but the bit is never even read. **Neither has a production consumer** — grep-wide, `StickyObjectGuid` is read only by unit tests; `MotionFlags.StandingLongJump`/`0x2` has zero non-comment hits anywhere.
|
||||||
|
|
||||||
|
**Q5 (type 6-9 head-stance dispatch)**: No such dispatch exists. `RouteServerMoveTo` (GameWindow.cs:4361) is the shared funnel for both player and remote; it never touches style/stance. `MoveToManager.PerformMovement`/its four entry points never call `DoMotion(style)`. Style application only happens on the mt-0 path via `InboundInterpretedState.CurrentStyle` folded at GameWindow.cs:4867. `RetailObserverTraceConformanceTests.cs:33`'s exclusion comment explicitly flags the unpack-level style-on-change dispatch as **not yet wired** ("S3 wires the unpack-level style-on-change") — this is a known, documented gap, not an oversight this recon discovered fresh.
|
||||||
|
|
||||||
|
**Q6 (PhysicsBody / PositionManager)**: `IsFullyConstrained` is a settable bool stub, always false in practice (PhysicsBody.cs:263). `TransientStateFlags` has `Contact`/`OnWalkable`/`Sliding`/`Active` (PhysicsBody.cs:63-70). No `PositionManager` class exists anywhere in the repo — `IsInterpolating` is faked per-caller (`Interp.IsActive` for remotes, hardcoded `false` for the player).
|
||||||
|
|
||||||
|
**File path of the written map**: `C:/Users/erikn/AppData/Local/Temp/claude/C--Users-erikn-source-repos-acdream--claude-worktrees-vigorous-joliot-f0c3ad/dd264e44-6468-4b4b-8d17-a8b3b15991c9/scratchpad/r5/acdream-seams.md`
|
||||||
332
docs/research/2026-07-03-r5-managers/r5-ace-crossref.md
Normal file
332
docs/research/2026-07-03-r5-managers/r5-ace-crossref.md
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
# ACE cross-reference: MovementManager / PositionManager / StickyManager / ConstraintManager / TargetManager
|
||||||
|
|
||||||
|
Source root: `C:/Users/erikn/source/repos/acdream/references/ACE/Source/ACE.Server/Physics/Managers/`
|
||||||
|
All five files read in full (MovementManager.cs 216 lines, PositionManager.cs 132 lines,
|
||||||
|
StickyManager.cs 136 lines, ConstraintManager.cs 80 lines, TargetManager.cs 182 lines).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. MovementManager.cs (namespace `ACE.Server.Physics.Animation`)
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
| Field | Type | Initial value |
|
||||||
|
|---|---|---|
|
||||||
|
| `MotionInterpreter` | `MotionInterp` | null (set in ctor or lazily) |
|
||||||
|
| `MoveToManager` | `MoveToManager` | null (set in ctor or lazily) |
|
||||||
|
| `PhysicsObj` | `PhysicsObj` | null (set in ctor) |
|
||||||
|
| `WeenieObj` | `WeenieObject` | null (set in ctor) |
|
||||||
|
|
||||||
|
No timers, no quanta — this class is a pure facade/dispatcher with no owned state beyond the two sub-managers + back-refs. Confirms it's a **facade**, not a state machine itself.
|
||||||
|
|
||||||
|
### Constructors
|
||||||
|
- `MovementManager()` — parameterless, all-null (default-constructed / deserialization shape).
|
||||||
|
- `MovementManager(PhysicsObj obj, WeenieObject wobj)` (L18-25) — sets `PhysicsObj`/`WeenieObj`, eagerly constructs BOTH `MotionInterpreter = new MotionInterp(obj, wobj)` and `MoveToManager = new MoveToManager(obj, wobj)`. Note: this differs from the lazy-init pattern used everywhere else in the file (see below) — the 2-arg ctor is the only path that eagerly builds both children.
|
||||||
|
|
||||||
|
### Methods (signature + mechanical summary)
|
||||||
|
|
||||||
|
- `CancelMoveTo(WeenieError error)` (L27-31) — null-guards `MoveToManager`, forwards to `MoveToManager.CancelMoveTo(error)`.
|
||||||
|
- `static Create(PhysicsObj obj, WeenieObject wobj)` (L33-36) — factory, `return new MovementManager(obj, wobj)`.
|
||||||
|
- `EnterDefaultState()` (L38-46) — early-returns if `PhysicsObj == null`. **Lazy-inits** `MotionInterpreter` via `MotionInterp.Create(PhysicsObj, WeenieObj)` if null, then calls `MotionInterpreter.enter_default_state()`. This is the canonical lazy-init pattern reused in 5 other methods below.
|
||||||
|
- `HandleEnterWorld()` (L48-52) — **entirely commented out** (`//NoticeHandler.RecvNotice_PrevSpellSelection(MotionInterpreter)`). Dead stub in ACE — no-op.
|
||||||
|
- `HandleExitWorld()` (L54-58) — null-guards, forwards to `MotionInterpreter.HandleExitWorld()`. Does NOT touch `MoveToManager` (no `MoveToManager.HandleExitWorld` call exists in this class at all).
|
||||||
|
- `HandleUpdateTarget(TargetInfo targetInfo)` (L60-64) — null-guards `MoveToManager`, forwards to `MoveToManager.HandleUpdateTarget(targetInfo)`. **Only route into MoveToManager for target updates** — MotionInterpreter is not involved.
|
||||||
|
- `HitGround()` (L66-73) — forwards to BOTH `MotionInterpreter.HitGround()` AND `MoveToManager.HitGround()`, each separately null-guarded. Order: MotionInterpreter first, then MoveToManager.
|
||||||
|
- `InqInterpretedMotionState()` (L75-84) — lazy-init pattern (create + `enter_default_state()` if `PhysicsObj != null`), returns `MotionInterpreter.InterpretedState`.
|
||||||
|
- `InqRawMotionState()` (L86-95) — identical lazy-init pattern, returns `MotionInterpreter.RawState`.
|
||||||
|
- `IsMovingTo()` (L97-102) — `MoveToManager == null` → `false`, else `MoveToManager.is_moving_to()`.
|
||||||
|
- `LeaveGround()` (L104-110) — null-guarded forward to `MotionInterpreter.LeaveGround()` only. Comment `// NoticeHandler::RecvNotice_PrevSpellSection` — dead/no-op reference, no MoveToManager call (asymmetric vs `HitGround`).
|
||||||
|
- `MakeMoveToManager()` (L112-116) — if `MoveToManager == null`, `MoveToManager = MoveToManager.Create(PhysicsObj, WeenieObj)`.
|
||||||
|
- `MotionDone(uint motion, bool success)` (L118-122) — null-guarded forward to `MotionInterpreter.MotionDone(success)`. **Note: the `motion` parameter is accepted but never used/passed through** — only `success` reaches `MotionInterp.MotionDone`.
|
||||||
|
- `PerformMovement(MovementStruct mvs)` (L124-157) — dispatch hub:
|
||||||
|
- `PhysicsObj.set_active(true)` unconditionally first.
|
||||||
|
- `switch (mvs.Type)`:
|
||||||
|
- `RawCommand`, `InterpretedCommand`, `StopRawCommand`, `StopInterpretedCommand`, `StopCompletely` → lazy-init `MotionInterpreter` (create + enter_default_state), then `return MotionInterpreter.PerformMovement(mvs)`.
|
||||||
|
- `MoveToObject`, `MoveToPosition`, `TurnToObject`, `TurnToHeading` → lazy-init `MoveToManager` (create only, no default-state analog), then `return MoveToManager.PerformMovement(mvs)`.
|
||||||
|
- `default` → `return WeenieError.GeneralMovementFailure`.
|
||||||
|
- This is the **dispatch order** requested: motion-type enum branches to exactly one of the two subsystems, never both.
|
||||||
|
- `ReportExhaustion()` (L159-165) — null-guarded forward to `MotionInterpreter.ReportExhaustion()`. Comment `// NoticeHandler::RecvNotice_PrevSpellSelection` again (dead).
|
||||||
|
- `SetWeenieObject(WeenieObject wobj)` (L167-174) — sets `WeenieObj = wobj`, then propagates to both children if non-null: `MotionInterpreter.SetWeenieObject(wobj)`, `MoveToManager.SetWeenieObject(wobj)`.
|
||||||
|
- `UseTime()` (L176-179) — **only forwards to `MoveToManager.UseTime()`**. Does NOT call anything on `MotionInterpreter`. This is the per-tick pump entry for movement — MotionInterp presumably gets its ticks from elsewhere (not in this file).
|
||||||
|
- `get_minterp()` (L181-190) — lazy-init identical to `InqInterpretedMotionState`, returns `MotionInterpreter` itself (not a sub-state).
|
||||||
|
- `motions_pending()` (L195-200) — `MotionInterpreter == null` → `false`, else `MotionInterpreter.motions_pending()`. Doc comment recommends `PhysicsObj.IsAnimating` instead for perf (L192-194).
|
||||||
|
- `move_to_interpreted_state(InterpretedMotionState state)` (L202-211) — lazy-init pattern, then `MotionInterpreter.move_to_interpreted_state(state)`.
|
||||||
|
- `unpack_movement(object addr, uint size)` (L213) — **empty body**. Client-only wire-unpack stub; server doesn't need to unpack a movement buffer since it authors movement itself. This is the clearest ACE-adaptation marker in the file (see divergences).
|
||||||
|
|
||||||
|
### Divergences / ACE adaptations
|
||||||
|
1. **`unpack_movement` is a no-op** (L213) — retail client presumably deserializes a raw movement buffer here (used for network movement replay/interpolation); ACE server doesn't need this path since it's the authoritative source, not a consumer, of movement state. No comment explaining it, just an empty `{ }` body — a clear "removed client-only branch."
|
||||||
|
2. **`HandleEnterWorld` is fully commented out** (L48-52) — the notice-handler call for previous spell selection was presumably meaningful in the retail client's UI/notice pipeline; ACE has no client-side notice UI so it's dead.
|
||||||
|
3. `unused `motion` parameter in `MotionDone`** — likely a vestige of a retail signature that carried a motion ID for validation/logging that ACE doesn't use server-side.
|
||||||
|
4. Uses `ACE.Entity.Enum.WeenieError` (ACE server type) as the return type for `PerformMovement`/`CancelMoveTo` — this is ACE's message-to-client error enum, not something retail's internal MovementManager would return (server-specific plumbing for GameActionFailure-style responses).
|
||||||
|
5. No `Dispose`/serialization glue for save-to-DB; the two nested managers are always live objects, not lazily persisted — normal for a live-object emulator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. PositionManager.cs (namespace `ACE.Server.Physics.Animation`)
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
| Field | Type | Initial value |
|
||||||
|
|---|---|---|
|
||||||
|
| `InterpolationManager` | `InterpolationManager` | null |
|
||||||
|
| `StickyManager` | `StickyManager` | null |
|
||||||
|
| `ConstraintManager` | `ConstraintManager` | null |
|
||||||
|
| `PhysicsObj` | `PhysicsObj` | null (set via ctor/`SetPhysicsObject`) |
|
||||||
|
|
||||||
|
All three sub-managers are lazily created on first real use (`MakeStickyManager`, `ConstrainTo`, `InterpolateTo`) — none are eagerly constructed in the ctor, unlike `MovementManager`'s eager 2-arg ctor.
|
||||||
|
|
||||||
|
### Constructors
|
||||||
|
- `PositionManager()` — parameterless/default.
|
||||||
|
- `PositionManager(PhysicsObj obj)` (L15-18) — calls `SetPhysicsObject(obj)`.
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
- **`AdjustOffset(AFrame frame, double quantum)`** (L20-28) — **the requested composition method.** Null-guards each of the three sub-managers independently and calls, **in this exact order**:
|
||||||
|
1. `InterpolationManager.adjust_offset(frame, quantum)`
|
||||||
|
2. `StickyManager.adjust_offset(frame, quantum)`
|
||||||
|
3. `ConstraintManager.adjust_offset(frame, quantum)`
|
||||||
|
|
||||||
|
All three mutate the SAME `frame` (an `AFrame`, passed by reference since it's a class/struct with mutable `Origin`) sequentially — each sub-adjustment composes on top of whatever the previous one wrote into `frame.Origin`/heading. This is a **strict, hard-coded pipeline order**: interpolation offset first, then sticky-follow offset, then constraint clamp — not a generic list of "adjusters." Constraint is explicitly LAST because `ConstraintManager.adjust_offset` scales/clamps `offset.Origin` based on what's already accumulated (it reads `offset.Origin.Length()` at the end to update `ConstraintPosOffset` — see ConstraintManager notes), i.e., it operates on the composed net displacement from both prior systems, not an independent contribution.
|
||||||
|
|
||||||
|
- `ConstrainTo(Position position, float startDistance, float maxDistance)` (L30-36) — lazy-inits `ConstraintManager` via `ConstraintManager.Create(PhysicsObj)` if null, forwards args to `ConstraintManager.ConstrainTo(...)`.
|
||||||
|
- `static Create(PhysicsObj physicsObj)` (L38-41) — factory.
|
||||||
|
- `GetStickyObjectID()` (L43-47) — `StickyManager == null` → `0`, else `StickyManager.TargetID`.
|
||||||
|
- `HandleUpdateTarget(TargetInfo targetInfo)` (L49-53) — null-guarded forward to `StickyManager.HandleUpdateTarget(targetInfo)` only (Constraint/Interpolation don't participate in target updates).
|
||||||
|
- `InterpolateTo(Position position, bool keepHeading)` (L55-61) — lazy-inits `InterpolationManager` via `InterpolationManager.Create(PhysicsObj)`, forwards to `InterpolationManager.InterpolateTo(position, keepHeading)`.
|
||||||
|
- `IsFullyConstrained()` (L63-69) — `ConstraintManager == null` → `false`, else `ConstraintManager.IsFullyConstrained()`.
|
||||||
|
- `IsInterpolating()` (L71-74) — `InterpolationManager != null && InterpolationManager.IsInterpolating()`.
|
||||||
|
- `MakeStickyManager()` (L76-80) — if `StickyManager == null`, `StickyManager = StickyManager.Create(PhysicsObj)`.
|
||||||
|
- `SetPhysicsObject(PhysicsObj obj)` (L82-91) — sets `PhysicsObj = obj`, propagates to all three sub-managers if non-null (each gets its own `SetPhysicsObject(obj)` call).
|
||||||
|
- `StickTo(uint objectID, float radius, float height)` (L93-99) — if `StickyManager == null`, calls `MakeStickyManager()` (note: goes through the helper, unlike `ConstrainTo`/`InterpolateTo` which inline their own lazy-init), then `StickyManager.StickTo(objectID, radius, height)`.
|
||||||
|
- `StopInterpolating()` (L101-105) — null-guarded forward.
|
||||||
|
- `Unconstrain()` (L107-111) — null-guarded forward to `ConstraintManager.Unconstrain()`.
|
||||||
|
- `Unstick()` (L113-117) — null-guarded forward, but calls `StickyManager.HandleExitWorld()` (NOT a method literally named "Unstick" on StickyManager — it reuses the exit-world path, which internally calls `ClearTarget()`).
|
||||||
|
- `UseTime()` (L119-129) — per-tick pump: forwards to all three sub-managers' `UseTime()` if non-null, in order Interpolation → Sticky → Constraint (same order as `AdjustOffset`).
|
||||||
|
|
||||||
|
### Divergences / ACE adaptations
|
||||||
|
- None visually flagged with comments — this class is pure composition/delegation, symmetric with how a client would implement it. No server-only branches visible. The `StickTo` method routing through `MakeStickyManager()` rather than inlining (unlike its two siblings) is a minor asymmetry but not a functional divergence.
|
||||||
|
- `HandleUpdateTarget` only routing to `StickyManager` (not `ConstraintManager` or `InterpolationManager`) matches the design: constraint following is by static target position/radius set once via `ConstrainTo`, not from live target updates; only sticky-follow needs live target position updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. StickyManager.cs (namespace `ACE.Server.Physics.Animation`)
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
| Field | Type | Initial value |
|
||||||
|
|---|---|---|
|
||||||
|
| `TargetID` | `uint` | 0 |
|
||||||
|
| `TargetRadius` | `float` | 0 |
|
||||||
|
| `TargetPosition` | `Position` | null |
|
||||||
|
| `PhysicsObj` | `PhysicsObj` | null |
|
||||||
|
| `Initialized` | `bool` | false |
|
||||||
|
| `StickyTimeoutTime` | `double` | 0 |
|
||||||
|
| `StickyRadius` | `const float` | **0.3f** (L20) |
|
||||||
|
| `StickyTime` | `const float` | **1.0f** (L22) |
|
||||||
|
|
||||||
|
### Constructors
|
||||||
|
- `StickyManager()` — default.
|
||||||
|
- `StickyManager(PhysicsObj obj)` (L28-31) — calls `SetPhysicsObject(obj)`.
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
- `ClearTarget()` (L33-42) — early-return if `TargetID == 0`. Else: `TargetID = 0`, `Initialized = false`, then `PhysicsObj.clear_target()` and `PhysicsObj.cancel_moveto()`.
|
||||||
|
- `static Create(PhysicsObj obj)` (L44-47) — factory.
|
||||||
|
- `HandleExitWorld()` (L49-52) — calls `ClearTarget()`.
|
||||||
|
- `HandleUpdateTarget(TargetInfo targetInfo)` (L54-66) — guards `targetInfo.ObjectID != TargetID` → return (ignore stale/foreign updates). If `targetInfo.Status == TargetStatus.OK` → `Initialized = true`, `TargetPosition = targetInfo.TargetPosition`. Else if `TargetID != 0` → `ClearTarget()` (i.e., any non-OK status for our current target clears it).
|
||||||
|
- `SetPhysicsObject(PhysicsObj obj)` (L68-71) — trivial setter.
|
||||||
|
- **`StickTo(uint objectID, float targetRadius, float targetHeight)`** (L73-83) — if already targeting something (`TargetID != 0`), first `ClearTarget()`. Then:
|
||||||
|
- `TargetID = objectID`
|
||||||
|
- `Initialized = false`
|
||||||
|
- `TargetRadius = targetRadius`
|
||||||
|
- `StickyTimeoutTime = PhysicsTimer.CurrentTime + StickyTime` (i.e., `now + 1.0f`)
|
||||||
|
- `PhysicsObj.set_target(0, objectID, 0.5f, 0.5f)` — registers with the target-tracking system using **hard-coded radius=0.5f, quantum=0.5f** regardless of the `targetRadius`/`targetHeight` params passed in. **`targetHeight` parameter is accepted but never used anywhere in this method or class.**
|
||||||
|
- `UseTime()` (L85-89) — if `PhysicsTimer.CurrentTime > StickyTimeoutTime` → `ClearTarget()`. This is the sticky-target watchdog: if no target update refreshes `Initialized`/position before the 1-second timeout, drop the stick. (Note: nothing in this file resets `StickyTimeoutTime` on `HandleUpdateTarget` — it's set once in `StickTo` and never refreshed, meaning a sticky-follow only survives 1 second of wall-clock time total unless re-triggered by a fresh `StickTo` call. This looks intentional given no other write-site exists.)
|
||||||
|
|
||||||
|
- **`adjust_offset(AFrame offset, double quantum)`** (L91-133) — **the requested sticky-position math.**
|
||||||
|
1. Guard: `PhysicsObj == null || TargetID == 0 || !Initialized` → return (no-op if not ready).
|
||||||
|
2. `target = PhysicsObj.GetObjectA(TargetID)` — resolve live object if in scope; `targetPosition = target == null ? TargetPosition : target.Position` (falls back to last-known cached `TargetPosition` if target object isn't locally resolvable — e.g., out of landblock range).
|
||||||
|
3. **Offset vector (world → local, flattened to XY):**
|
||||||
|
```
|
||||||
|
offset.Origin = PhysicsObj.Position.GetOffset(targetPosition);
|
||||||
|
offset.Origin = PhysicsObj.Position.GlobalToLocalVec(offset.Origin);
|
||||||
|
offset.Origin.Z = 0.0f;
|
||||||
|
```
|
||||||
|
i.e., compute the world-space vector from self to target, rotate it into the object's own local frame, then zero the vertical component — sticky-follow only steers horizontally.
|
||||||
|
4. **Distance computation:**
|
||||||
|
```
|
||||||
|
var radius = PhysicsObj.GetRadius();
|
||||||
|
var dist = Position.CylinderDistanceNoZ(radius, PhysicsObj.Position, TargetRadius, targetPosition) - StickyRadius;
|
||||||
|
```
|
||||||
|
`CylinderDistanceNoZ` = surface-to-surface horizontal distance between two cylinders (self radius vs `TargetRadius`), then subtract the **0.3f StickyRadius** constant — this yields how far past the "stick zone" (0.3m gap) the follower currently is; can be negative if already inside the desired gap.
|
||||||
|
5. **Normalize direction** (with small-vector guard):
|
||||||
|
```
|
||||||
|
if (Vec.NormalizeCheckSmall(ref offset.Origin))
|
||||||
|
offset.Origin = Vector3.Zero;
|
||||||
|
```
|
||||||
|
`NormalizeCheckSmall` normalizes in place and returns true if the vector was too small to normalize meaningfully (near-zero) — in that case zero it out entirely (don't chase jitter at near-zero range).
|
||||||
|
6. **Speed selection:**
|
||||||
|
```
|
||||||
|
var speed = 0.0f;
|
||||||
|
var minterp = PhysicsObj.get_minterp();
|
||||||
|
if (minterp != null)
|
||||||
|
speed = minterp.get_max_speed() * 5.0f;
|
||||||
|
|
||||||
|
if (speed < PhysicsGlobals.EPSILON)
|
||||||
|
speed = 15.0f;
|
||||||
|
```
|
||||||
|
Sticky-follow speed is **5× the object's own max movement speed** (so the follow catches up faster than normal walk/run speed would allow), falling back to a **hard-coded 15.0f** if no motion interpreter is available or computed speed is ~0.
|
||||||
|
7. **Delta-clamp to distance:**
|
||||||
|
```
|
||||||
|
var delta = speed * (float)quantum;
|
||||||
|
if (delta >= Math.Abs(dist))
|
||||||
|
delta = dist;
|
||||||
|
offset.Origin *= delta;
|
||||||
|
```
|
||||||
|
Standard "don't overshoot" clamp: proposed per-quantum step is `speed * quantum`; if that step would travel farther than the remaining `dist` (in absolute value), snap the step to exactly `dist` instead (this can produce a *negative* delta scaling — meaning the offset direction gets inverted/scaled backward — when `dist` is negative, i.e., when already past the 0.3m sticky radius and needing to back off).
|
||||||
|
8. **Heading alignment:**
|
||||||
|
```
|
||||||
|
var curHeading = PhysicsObj.Position.Frame.get_heading();
|
||||||
|
var targetHeading = PhysicsObj.Position.heading(targetPosition);
|
||||||
|
var heading = targetHeading - curHeading;
|
||||||
|
if (Math.Abs(heading) < PhysicsGlobals.EPSILON) heading = 0.0f;
|
||||||
|
if (heading < -PhysicsGlobals.EPSILON) heading += 360.0f;
|
||||||
|
offset.set_heading(heading);
|
||||||
|
```
|
||||||
|
Computes the heading delta needed to face the target (degrees), snapping near-zero deltas to exactly 0, and normalizing negative deltas by wrapping `+360` (note: this wrap only triggers for deltas below `-EPSILON`, not a full `[-180,180]` normalize — deltas in e.g. `(-360, -epsilon)` all get `+360` added once, which is only a correct wrap if `heading` is already constrained to `(-360, 360)` by the subtraction of two `[0,360)` headings, which it is). Result is written into `offset.set_heading(heading)` — i.e., the frame's rotation is set to the RELATIVE turn amount needed this tick, not an absolute heading (consistent with `offset` being a per-tick delta-frame consumed elsewhere, likely integrated by the caller).
|
||||||
|
- Dead commented-out diagnostic: `//Console.WriteLine($"StickyManager.AdjustOffset(...)")` (L131).
|
||||||
|
|
||||||
|
### Divergences / ACE adaptations
|
||||||
|
1. **`targetHeight` parameter of `StickTo` is entirely unused** — accepted into the signature (matches the client API surface presumably) but never read. Could be a client-only positional-height computation retail uses that ACE's server-authoritative model doesn't need (server just re-derives Z from the physics/terrain resolve, not from a fixed offset height).
|
||||||
|
2. **`set_target(0, objectID, 0.5f, 0.5f)` hard-codes context=0, radius=0.5f, quantum=0.5f** — the `targetRadius` argument passed into `StickTo` is stored in `TargetRadius` for use in `adjust_offset`'s distance math, but is NOT what's passed to `set_target`'s tracking-radius parameter; that's a separate fixed 0.5f. This looks like two distinct radii serving different purposes (voyeur/update-triggering radius vs. desired-follow-gap radius) rather than a bug, but it's worth flagging as a spot to verify against the retail decomp — ACE naming makes them look conflatable.
|
||||||
|
3. No explicit "ACE custom" comments in this file — the divergence is purely inferred from unused-parameter/hardcoded-constant patterns, not documented in-line.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. ConstraintManager.cs (namespace `ACE.Server.Physics.Animation`)
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
| Field | Type | Initial value |
|
||||||
|
|---|---|---|
|
||||||
|
| `PhysicsObj` | `PhysicsObj` | null |
|
||||||
|
| `IsConstrained` | `bool` | false |
|
||||||
|
| `ConstraintPosOffset` | `float` | 0 |
|
||||||
|
| `ConstraintPos` | `Position` | null |
|
||||||
|
| `ConstraintDistanceStart` | `float` | 0 |
|
||||||
|
| `ConstraintDistanceMax` | `float` | 0 |
|
||||||
|
|
||||||
|
### Constructors
|
||||||
|
- `ConstraintManager()` — default.
|
||||||
|
- `ConstraintManager(PhysicsObj obj)` (L17-20) — calls `SetPhysicsObject(obj)`.
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
- `static Create(PhysicsObj obj)` (L22-25) — factory.
|
||||||
|
- `ConstrainTo(Position position, float startDistance, float maxDistance)` (L27-35) — sets `IsConstrained = true`; `ConstraintPos = new Position(position)` (deep copy); `ConstraintDistanceStart = startDistance`; `ConstraintDistanceMax = maxDistance`; **`ConstraintPosOffset = position.Distance(PhysicsObj.Position)`** — i.e., initializes the tracked offset to the CURRENT straight-line distance between the constraint anchor and the object's live position at the moment constraining begins (not zero).
|
||||||
|
- `IsFullyConstrained()` (L37-40) — `return ConstraintDistanceMax * 0.9f < ConstraintPosOffset;` — **"fully constrained" means the object's tracked offset has exceeded 90% of the max allowed distance.** This is a soft/early trigger, not requiring the offset to hit 100% of max.
|
||||||
|
- `SetPhysicsObject(PhysicsObj obj)` (L42-50) — if `PhysicsObj != null` (i.e., there was a previous object), reset `IsConstrained = false` and `ConstraintPosOffset = 0.0f` BEFORE reassigning `PhysicsObj = obj`. Net effect: constraint state is cleared whenever the manager is rebound to a (possibly different, possibly the same) physics object — except on the very first bind where `PhysicsObj` starts null and the reset branch is skipped (constraint fields keep their default-initialized zero/false values anyway).
|
||||||
|
- `Unconstrain()` (L52-55) — `IsConstrained = false` only (does NOT reset `ConstraintPosOffset`, `ConstraintPos`, `ConstraintDistanceStart/Max` — those linger stale until the next `ConstrainTo` call).
|
||||||
|
- `UseTime()` (L57-60) — **empty body**, comment `// empty`. No time-based ticking logic at all in this manager (unlike Sticky's timeout-watchdog).
|
||||||
|
|
||||||
|
- **`adjust_offset(AFrame offset, double quantum)`** (L62-77) — **the requested constraint spring math.**
|
||||||
|
1. Guard: `PhysicsObj == null || !IsConstrained` → return.
|
||||||
|
2. **Contact-gated branch:**
|
||||||
|
```
|
||||||
|
if (PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact))
|
||||||
|
{
|
||||||
|
if (ConstraintPosOffset < ConstraintDistanceMax)
|
||||||
|
{
|
||||||
|
if (ConstraintPosOffset > ConstraintDistanceStart)
|
||||||
|
offset.Origin *= (ConstraintDistanceMax - ConstraintPosOffset) / (ConstraintDistanceMax - ConstraintDistanceStart);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
offset.Origin = Vector3.Zero;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The scaling logic **only runs when the object is in ground/surface contact** (`TransientStateFlags.Contact`) — while airborne, this whole inner block is skipped and `offset.Origin` passes through completely unmodified from whatever `StickyManager`/`InterpolationManager` already wrote into it (constraint has no effect while not in contact).
|
||||||
|
|
||||||
|
When in contact:
|
||||||
|
- If current offset (`ConstraintPosOffset`, tracked from the PREVIOUS tick's final value — see step 3) is **≥ ConstraintDistanceMax** → hard-clamp: `offset.Origin = Vector3.Zero` (object cannot move further this tick at all — fully pinned).
|
||||||
|
- Else if offset is **< ConstraintDistanceMax**:
|
||||||
|
- If offset is **also > ConstraintDistanceStart** (i.e., in the "braking zone" between start and max) → scale the incoming `offset.Origin` (the proposed displacement already computed by prior pipeline stages) by the **linear falloff factor**:
|
||||||
|
```
|
||||||
|
(ConstraintDistanceMax - ConstraintPosOffset) / (ConstraintDistanceMax - ConstraintDistanceStart)
|
||||||
|
```
|
||||||
|
This ranges from ~1.0 (when `ConstraintPosOffset` is just past `ConstraintDistanceStart`) down toward 0.0 (as `ConstraintPosOffset` approaches `ConstraintDistanceMax`) — a **linear spring/brake taper** that progressively resists outward motion as the object nears its max leash distance.
|
||||||
|
- If offset is **≤ ConstraintDistanceStart** (still well within the free-movement zone) → no scaling at all, `offset.Origin` passes through unchanged.
|
||||||
|
3. **State update (runs unconditionally, both when in contact and when airborne):**
|
||||||
|
```
|
||||||
|
ConstraintPosOffset = offset.Origin.Length();
|
||||||
|
```
|
||||||
|
**This line is outside the `if (Contact)` block** — meaning `ConstraintPosOffset` is recomputed EVERY call from the magnitude of the (possibly just-scaled) `offset.Origin`, not from the actual distance to `ConstraintPos`/anchor. This is notable: the tracked "offset" is a proxy — the length of the per-tick displacement vector — not a running total distance from the constraint anchor; it's being used as a same-tick feedback value that the NEXT call's contact-branch will compare against Start/Max. Given `AdjustOffset`'s pipeline order (Interpolation → Sticky → Constraint), this length is measuring the magnitude of the net per-tick offset produced by all prior systems, right before constraint clamps it — an odd quantity to call "ConstraintPosOffset" (it looks more like "last tick's step distance" than "distance from anchor"), but that's exactly what the code does — flag this if the acdream port needs to match it precisely; it's easy to misread as "distance from ConstraintPos."
|
||||||
|
|
||||||
|
### Divergences / ACE adaptations
|
||||||
|
- No explicit ACE-only comments; the whole file reads as a fairly literal, compact port. The main subtlety (not a divergence, but a correctness trap) is the `ConstraintPosOffset = offset.Origin.Length()` semantics noted above — this should be triple-checked against retail decomp before the acdream port trusts the "distance from constraint anchor" mental model that the field name suggests. `ConstraintPos` (the actual anchor position, L11) is stored but **never read anywhere in this file** after being set in `ConstrainTo` — it's write-only in this class, meaning either (a) the true distance-to-anchor math happens in the caller/elsewhere using `ConstraintPos`, or (b) it's vestigial state carried for inspection/debugging only. Worth checking a retail decomp or other ACE call sites for a read of `PositionManager`/`ConstraintManager.ConstraintPos` — none found in this file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. TargetManager.cs (namespace `ACE.Server.Physics.Combat`)
|
||||||
|
|
||||||
|
Note: this one lives in the `Combat` namespace, not `Animation`, unlike the other four.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
| Field | Type | Initial value |
|
||||||
|
|---|---|---|
|
||||||
|
| `PhysicsObj` | `PhysicsObj` | null |
|
||||||
|
| `TargetInfo` | `TargetInfo` | null |
|
||||||
|
| `VoyeurTable` | `Dictionary<uint, TargettedVoyeurInfo>` | null (lazily created in `AddVoyeur`) |
|
||||||
|
| `LastUpdateTime` | `double` | 0 |
|
||||||
|
|
||||||
|
### Constructors
|
||||||
|
- `TargetManager()` — default.
|
||||||
|
- `TargetManager(PhysicsObj physObj)` (L18-21) — sets `PhysicsObj = physObj` directly (no `SetPhysicsObject` helper here, unlike the other four classes).
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
- `SetTarget(uint contextID, uint objectID, float radius, double quantum)` (L23-42) — null-guard `PhysicsObj`. Calls `ClearTarget()` first (always clears any existing target before setting a new one — no early-return-if-same-target check). If `objectID == 0` (clear/cancel request): builds a `TargetInfo` with `Status = TargetStatus.TimedOut` and calls `PhysicsObj.HandleUpdateTarget(failedTargetInfo)` directly, then returns (does NOT set `TargetInfo` field — leaves it null, i.e., "targeting nothing" is represented by `TargetInfo == null`). Otherwise: `TargetInfo = new TargetInfo(contextID, objectID, radius, quantum)`; resolves `target = PhysicsObj.GetObjectA(objectID)`; if resolvable, calls `target.add_voyeur(PhysicsObj.ID, radius, quantum)` — i.e., registers itself as a voyeur ON the target object (bidirectional relationship: I track it, and it tracks me watching it).
|
||||||
|
- `SetTargetQuantum(double quantum)` (L44-54) — null-guards `PhysicsObj`/`TargetInfo`. Updates `TargetInfo.Quantum = quantum`, resolves the target object, and if found, re-registers via `targetObj.add_voyeur(PhysicsObj.ID, TargetInfo.Radius, quantum)` (refreshes the voyeur registration on the target with the new quantum, keeping the stored `Radius`).
|
||||||
|
|
||||||
|
- **`HandleTargetting()`** (L56-79) — **the requested quantum-scheduling dispatcher** (note British-double-t spelling matches the ACE source literally). Guards:
|
||||||
|
1. `PhysicsObj == null` → return.
|
||||||
|
2. **Throttle: `PhysicsTimer.CurrentTime - LastUpdateTime < 0.5f` → return.** This whole method only actually runs its body once every **0.5 seconds of wall clock**, regardless of how often it's called (looks like it's called every physics tick from elsewhere, and self-throttles).
|
||||||
|
3. If `TargetInfo != null && TargetInfo.TargetPosition == null` → return (target info exists but has no resolved position yet — commented-out diagnostic `//Console.WriteLine("...null position")` at L64).
|
||||||
|
4. **Timeout check:** if `TargetInfo != null && TargetInfo.Status == TargetStatus.Undefined && TargetInfo.LastUpdateTime + 10.0f < PhysicsTimer.CurrentTime` → mark `TargetInfo.Status = TargetStatus.TimedOut` and call `PhysicsObj.HandleUpdateTarget(new TargetInfo(TargetInfo))` (passes a COPY, not the live reference — comment `// ref?` at L71 suggests the ACE porter was unsure whether retail passes by value or reference here). **10-second target-info staleness timeout**, separate from `StickyManager`'s 1-second timeout — these are two independently-tuned quanta for two different subsystems.
|
||||||
|
5. **Voyeur sweep:** if `VoyeurTable != null`, iterate `VoyeurTable.Values.ToList()` (materializes a copy of the values to iterate safely against in-loop mutation) and call `CheckAndUpdateVoyeur(voyeur)` for each.
|
||||||
|
6. Finally, `LastUpdateTime = PhysicsTimer.CurrentTime` — resets the 0.5s throttle window.
|
||||||
|
|
||||||
|
- `CheckAndUpdateVoyeur(TargettedVoyeurInfo voyeur)` (L81-89) — **the requested per-voyeur quantum check.** `newPos = GetInterpolatedPosition(voyeur.Quantum)`; if non-null and `newPos.Distance(voyeur.LastSentPosition) > voyeur.Radius` → `SendVoyeurUpdate(voyeur, newPos, TargetStatus.OK)`. I.e., only pushes an update to a voyeur if the tracked object has moved farther than that voyeur's registered `Radius` threshold since the last position sent to THAT voyeur — a dirty/delta-threshold gate, not a blanket broadcast.
|
||||||
|
- `GetInterpolatedPosition(double quantum)` (L91-98) — null-guards `PhysicsObj`. `pos = new Position(PhysicsObj.Position)` (deep copy); `pos.Frame.Origin += PhysicsObj.get_velocity() * (float)quantum` — **dead-reckoning extrapolation**: current position plus velocity times the requested lookahead quantum. This is the same "quantum" concept used throughout — it's a forward-prediction time horizon, not a physics tick delta.
|
||||||
|
- `ClearTarget()` (L100-111) — if `TargetInfo == null` → return. Else resolves the CURRENT target object and calls `targetObj.remove_voyeur(PhysicsObj.ID)` (un-registers self as a voyeur on the old target) — note this call is unconditional on `targetObj != null` check (guarded) but NOT unconditional on whether `TargetInfo.ObjectID` was actually valid; then sets `TargetInfo = null` (redundant inner null-check `if (TargetInfo != null)` immediately after already having checked `TargetInfo == null` at entry — harmless dead conditional, definitely true at that point).
|
||||||
|
- `NotifyVoyeurOfEvent(TargetStatus status)` (L113-119) — null-guards `PhysicsObj`/`VoyeurTable`. Broadcasts `SendVoyeurUpdate(voyeur, PhysicsObj.Position, status)` to EVERY voyeur in the table unconditionally (no distance-threshold gate here, unlike `CheckAndUpdateVoyeur`) — used for discrete events (e.g., death, teleport) rather than routine movement polling.
|
||||||
|
|
||||||
|
- **`ReceiveUpdate(TargetInfo update)`** (L121-136) — **the requested inbound-update handler** (the other half of the "ReceiveUpdate/CheckAndUpdateVoyeur" pair requested). Guards: `PhysicsObj == null || TargetInfo == null || TargetInfo.ObjectID != update.ObjectID` → return (ignore updates for a target we're not currently tracking, or if we have no target at all).
|
||||||
|
- `TargetInfo = new TargetInfo(update)` (copy-construct from the incoming update — comment `// ref?` again at L125, same porter uncertainty).
|
||||||
|
- `TargetInfo.LastUpdateTime = PhysicsTimer.CurrentTime` — stamps receipt time (used later by the 10-second-timeout check in `HandleTargetting`).
|
||||||
|
- **Heading computation:**
|
||||||
|
```
|
||||||
|
TargetInfo.InterpolatedHeading = PhysicsObj.Position.GetOffset(TargetInfo.InterpolatedPosition);
|
||||||
|
if (Vec.NormalizeCheckSmall(ref TargetInfo.InterpolatedHeading))
|
||||||
|
TargetInfo.InterpolatedHeading = Vector3.UnitZ;
|
||||||
|
```
|
||||||
|
Computes the direction vector from self to the target's (already-interpolated-by-sender) position, normalizes it in place, and if the vector was too small to normalize (near-coincident positions), falls back to `Vector3.UnitZ` (a fixed "up" vector as a degenerate-case default — presumably any non-zero placeholder direction is acceptable when the target is essentially on top of the observer).
|
||||||
|
- `PhysicsObj.HandleUpdateTarget(new TargetInfo(TargetInfo))` — forwards a COPY of the now-updated `TargetInfo` to the owning `PhysicsObj` (which presumably routes it onward to `PositionManager.HandleUpdateTarget` → `StickyManager.HandleUpdateTarget`, wiring back to file #3 above).
|
||||||
|
- If `update.Status == TargetStatus.ExitWorld` → `ClearTarget()` (target left the world; stop tracking it entirely, in addition to whatever `HandleUpdateTarget` propagation already did).
|
||||||
|
|
||||||
|
- `AddVoyeur(uint objectID, float radius, double quantum)` (L138-157) — if `VoyeurTable != null`, try to find an existing entry for `objectID`; if found, just update its `Radius`/`Quantum` in place and return early (no duplicate entries, no re-send of an initial update on refresh). Else (table doesn't exist yet), `VoyeurTable = new Dictionary<...>()`. Creates a new `TargettedVoyeurInfo(objectID, radius, quantum)`, adds it to the table, and **immediately** calls `SendVoyeurUpdate(info, PhysicsObj.Position, TargetStatus.OK)` — new voyeurs get an immediate initial position push (not gated by the distance threshold that `CheckAndUpdateVoyeur` applies for routine updates).
|
||||||
|
- `SendVoyeurUpdate(TargettedVoyeurInfo voyeur, Position pos, TargetStatus status)` (L159-172) — sets `voyeur.LastSentPosition = new Position(pos)` (deep copy, records what was just sent for future delta-threshold comparisons — this is what `CheckAndUpdateVoyeur` compares `newPos.Distance(...)` against). Builds an outbound `TargetInfo(0, PhysicsObj.ID, voyeur.Radius, voyeur.Quantum)` (contextID hard-coded to 0), sets `TargetPosition = PhysicsObj.Position` (current authoritative position), `InterpolatedPosition = new Position(pos)` (the possibly-extrapolated position that triggered/represents this update), `Velocity = PhysicsObj.get_velocity()`, `Status = status`. Resolves the voyeur's own physics object (`GetObjectA(voyeur.ObjectID)`) and if found, calls `voyeurObj.receive_target_update(info)` — this is the reverse-direction hop that presumably lands back in the voyeur's own `TargetManager.ReceiveUpdate`.
|
||||||
|
- `RemoveVoyeur(uint objectID)` (L174-179) — `VoyeurTable == null` → `false`, else `VoyeurTable.Remove(objectID)` (dictionary's own bool-return removal).
|
||||||
|
|
||||||
|
### Divergences / ACE adaptations
|
||||||
|
1. **Two independent "// ref?" comments** (L71, L125) mark spots where the ACE porter was unsure whether retail passes `TargetInfo` by reference or performs a defensive copy — ACE chose copy-construct (`new TargetInfo(...)`) in both cases as the conservative/safe choice for a multi-threaded server. This is the clearest **explicit uncertainty marker** in the whole set of five files — flag as a spot acdream should verify against the retail decomp directly rather than trust ACE's guess, since ACE itself flagged its own uncertainty.
|
||||||
|
2. **The voyeur pattern is inherently server-authoritative plumbing** — `SendVoyeurUpdate`/`receive_target_update`/bidirectional `add_voyeur`/`remove_voyeur` registration between two `PhysicsObj`s is exactly the kind of "who-is-watching-whom" bookkeeping a server needs to decide what to push to which client-controlled object, whereas a retail single-player-perspective client would only need to track ITS OWN target, not maintain a reverse table of watchers. This entire voyeur/broadcast half of the class is very plausibly ACE-server infrastructure sitting alongside a more literally-ported "my own target" half (`SetTarget`/`ReceiveUpdate`/`HandleTargetting`'s timeout logic). No comment marks this explicitly, but structurally it's the load-bearing "adaptation" in this file — acdream (a client) likely needs ONLY the target-tracking half (`SetTarget`, `ReceiveUpdate`, the 10s timeout, `GetInterpolatedPosition`), not the voyeur/broadcast half, UNLESS acdream's server-facing code also needs to originate voyeur registrations (unlikely for a pure client).
|
||||||
|
3. `HandleTargetting`'s two independently-tuned timing constants — **0.5f throttle** (L60) and **10.0f staleness timeout** (L68) — are worth citing precisely if porting; these are likely real retail-tuned quanta, not ACE inventions, but should be cross-checked against `docs/research/named-retail/` before treating as ground truth given the file's own admitted uncertainty elsewhere.
|
||||||
|
4. Namespace: this class lives under `ACE.Server.Physics.Combat`, while all four other classes here live under `ACE.Server.Physics.Animation` — matches retail's likely split (targeting/voyeur being combat/perception machinery vs. movement/position being animation machinery), not an ACE-specific reorganization, but worth preserving that namespace distinction in acdream's own port structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-class composition notes (for the R5 MovementManager/PositionManager facade work)
|
||||||
|
|
||||||
|
- **Dispatch chain confirmed:** `MovementManager.PerformMovement` branches by `MovementStruct.Type` into either `MotionInterp.PerformMovement` (raw/interpreted commands) or `MoveToManager.PerformMovement` (MoveTo/TurnTo variants) — never both, never a shared pre/post hook in this file.
|
||||||
|
- **`MovementManager.UseTime()` only pumps `MoveToManager`** — `MotionInterp` presumably ticks via a different call site (not shown in these 5 files) — do not assume `MovementManager.UseTime` is the sole per-tick driver for animation state when porting the R5 facade; the "three approximations" pattern retired in R4 pertained to `MoveToManager`-adjacent code, and this file confirms `MoveToManager.UseTime()` is exactly one call, unconditioned, from `MovementManager.UseTime()`.
|
||||||
|
- **`PositionManager.AdjustOffset` and `PositionManager.UseTime` share the identical fixed pipeline order**: Interpolation → Sticky → Constraint. Any acdream port of `PositionManager` MUST preserve this order — `ConstraintManager.adjust_offset`'s scaling operates on the ALREADY-composed `offset.Origin` written by the two prior stages (confirmed by reading `ConstraintManager.adjust_offset` itself: it treats incoming `offset.Origin` as pre-populated and only clamps/scales it, never zeroes-then-rebuilds it).
|
||||||
|
- **Quantum is used with (at least) three distinct meanings across these files** — worth flagging for the R5 doc's "quantum model" section:
|
||||||
|
1. `AdjustOffset(AFrame frame, double quantum)` — a per-tick **time delta** (seconds since last adjustment), used identically by all three sub-adjusters as a `speed * quantum = distance` integration step.
|
||||||
|
2. `TargetInfo.Quantum` / `TargettedVoyeurInfo.Quantum` / `SetTarget(..., double quantum)` — a **lookahead/extrapolation horizon** fed into `GetInterpolatedPosition(quantum)` for dead-reckoning prediction, unrelated to the physics-tick delta above (it's a per-voyeur-registered constant, not a live delta).
|
||||||
|
3. Implicit "throttle interval" constants (`0.5f` in `HandleTargetting`, `1.0f` `StickyTime`, `10.0f` staleness) — not literally named "quantum" in code but functionally the same category of scheduling constant the R5 doc should probably fold into the same table.
|
||||||
|
|
@ -0,0 +1,679 @@
|
||||||
|
# ConstraintManager — retail decomp extract
|
||||||
|
|
||||||
|
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named).
|
||||||
|
Struct source: `docs/research/named-retail/acclient.h`.
|
||||||
|
|
||||||
|
**Address correction:** the task listed `CPhysicsObj::IsFullyConstrained` at `0x0050f730`.
|
||||||
|
The actual address in the corpus is **`0x0050ec60`**. Verified by grepping the definition
|
||||||
|
line (`276520:0050ec60 int32_t __fastcall CPhysicsObj::IsFullyConstrained(...)`)
|
||||||
|
and cross-checked against its caller in `CMotionInterp::jump_is_allowed` at `0x005282fd`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## struct ConstraintManager (acclient.h, comment `/* 3467 */`)
|
||||||
|
|
||||||
|
```c
|
||||||
|
/* 3467 */
|
||||||
|
struct __cppobj ConstraintManager
|
||||||
|
{
|
||||||
|
CPhysicsObj *physics_obj;
|
||||||
|
int is_constrained;
|
||||||
|
float constraint_pos_offset;
|
||||||
|
Position constraint_pos;
|
||||||
|
float constraint_distance_start;
|
||||||
|
float constraint_distance_max;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## struct PositionManager (acclient.h, comment `/* 3468 */`) — the owning object
|
||||||
|
|
||||||
|
```c
|
||||||
|
/* 3468 */
|
||||||
|
struct __cppobj PositionManager
|
||||||
|
{
|
||||||
|
InterpolationManager *interpolation_manager;
|
||||||
|
StickyManager *sticky_manager;
|
||||||
|
ConstraintManager *constraint_manager;
|
||||||
|
CPhysicsObj *physics_obj;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the field order in `PositionManager` (`interpolation_manager`, `sticky_manager`,
|
||||||
|
`constraint_manager`, `physics_obj`) matches the `PositionManager::Create` allocation
|
||||||
|
writes to offsets `0x0`, `0x4`, `0x8`, `0xc` respectively (see below) and the
|
||||||
|
`PositionManager::Destroy` teardown order (`interpolation_manager` → `sticky_manager` →
|
||||||
|
`constraint_manager`).
|
||||||
|
|
||||||
|
`ConstraintManager` field layout maps onto `ConstraintManager::Create`'s raw offset
|
||||||
|
writes: `physics_obj`=`0x0`, `is_constrained`=`0x4`, `constraint_pos_offset`=`0x8` — wait,
|
||||||
|
per the decompiled writes below the offsets are actually `0x0`/`0x4`/`0x8`/`0xc`
|
||||||
|
(`vtable` field of `Position constraint_pos` at `0xc`)/`0x10`.../`0x48` is
|
||||||
|
`constraint_distance_start`, `0x4c` is `constraint_distance_max`. The compiler emits a
|
||||||
|
`Position` (which itself embeds a `Frame` with its own vtable-looking sentinel field —
|
||||||
|
see NOTE in `~ConstraintManager` below) between `constraint_pos_offset` and
|
||||||
|
`constraint_distance_start`, consistent with the struct's declared member order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ConstraintManager::SetPhysicsObject — `0x00556090`
|
||||||
|
|
||||||
|
```c
|
||||||
|
00556090 void __thiscall ConstraintManager::SetPhysicsObject(class ConstraintManager* this, class CPhysicsObj* arg2)
|
||||||
|
|
||||||
|
00556090 {
|
||||||
|
00556096 if (this->physics_obj == 0)
|
||||||
|
00556096 {
|
||||||
|
005560ad this->physics_obj = arg2;
|
||||||
|
005560af return;
|
||||||
|
00556096 }
|
||||||
|
00556096
|
||||||
|
00556098 this->physics_obj = 0;
|
||||||
|
0055609a this->is_constrained = 0;
|
||||||
|
0055609d this->constraint_pos_offset = 0f;
|
||||||
|
005560a4 this->physics_obj = arg2;
|
||||||
|
00556090 }
|
||||||
|
```
|
||||||
|
|
||||||
|
## ConstraintManager::UnConstrain — `0x005560c0`
|
||||||
|
|
||||||
|
```c
|
||||||
|
005560c0 void __fastcall ConstraintManager::UnConstrain(class ConstraintManager* this)
|
||||||
|
|
||||||
|
005560c0 {
|
||||||
|
005560c0 this->is_constrained = 0;
|
||||||
|
005560c0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
## ConstraintManager::IsFullyConstrained — `0x005560d0`
|
||||||
|
|
||||||
|
```c
|
||||||
|
005560d0 int32_t __fastcall ConstraintManager::IsFullyConstrained(class ConstraintManager const* this)
|
||||||
|
|
||||||
|
005560d0 {
|
||||||
|
005560d0 long double x87_r7 = ((long double)this->constraint_pos_offset);
|
||||||
|
005560d6 long double x87_r6_1 = (((long double)this->constraint_distance_max) * ((long double)0.90000000000000002));
|
||||||
|
005560dc (x87_r6_1 - x87_r7);
|
||||||
|
005560de int32_t eax;
|
||||||
|
005560de eax = ((((x87_r6_1 < x87_r7) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r6_1, x87_r7))) ? 1 : 0) << 0xa) | ((((x87_r6_1 == x87_r7) ? 1 : 0) << 0xe) | 0))));
|
||||||
|
005560e0 bool p = /* bool p = unimplemented {test ah, 0x5} */;
|
||||||
|
005560e0
|
||||||
|
005560e3 if (p)
|
||||||
|
005560ed return 0;
|
||||||
|
005560ed
|
||||||
|
005560ea return 1;
|
||||||
|
005560d0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTE (garbled bitfield mush / x87 flags mush): the `eax = (...)` line is Binary Ninja's
|
||||||
|
attempt to render the x87 `FCOMI`/`FSTSW`+`SAHF`-style compare-and-test-flags sequence as
|
||||||
|
bit-packed pseudocode. It is computing `constraint_distance_max * 0.9 <=> constraint_pos_offset`
|
||||||
|
and then `test ah, 0x5` checks the ZF/CF-equivalent bits packed into `ah` after `fnstsw ax`.
|
||||||
|
The semantic read: `p` is true when `(constraint_distance_max * 0.9) < constraint_pos_offset`
|
||||||
|
OR the compare was unordered (NaN) — i.e. `test ah,5` tests bits 0 (C0/"below") and 2
|
||||||
|
(C3/"equal") of the FPU status word as loaded into AH, the classic x87 `jbe`-equivalent
|
||||||
|
pattern. So: **`IsFullyConstrained` returns `false` (0) if `constraint_pos_offset >=
|
||||||
|
0.9 * constraint_distance_max` (or unordered), else returns `true` (1)**. In plain terms:
|
||||||
|
the object counts as "fully constrained" while it is still within 90% of the max leash
|
||||||
|
distance; once it has drifted past 90% of that distance it is no longer "fully" constrained
|
||||||
|
(this is the gate `CMotionInterp::jump_is_allowed` reads to block jump attempts while
|
||||||
|
straining at the very end of a constraint leash).
|
||||||
|
|
||||||
|
## ConstraintManager::~ConstraintManager — `0x005560f0`
|
||||||
|
|
||||||
|
```c
|
||||||
|
005560f0 void __fastcall ConstraintManager::~ConstraintManager(class ConstraintManager* this)
|
||||||
|
|
||||||
|
005560f0 {
|
||||||
|
005560f2 this->is_constrained = 0;
|
||||||
|
005560f5 this->constraint_pos_offset = 0f;
|
||||||
|
005560f8 this->physics_obj = 0;
|
||||||
|
005560fa this->constraint_pos.vtable = 0x79285c;
|
||||||
|
005560f0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTE: `this->constraint_pos.vtable = 0x79285c` — `constraint_pos` is a `Position` field
|
||||||
|
(struct member, not a pointer), so this is Binary Ninja's rendering of the embedded
|
||||||
|
`Frame`'s vtable-pointer slot being reset to its static vtable address as part of the
|
||||||
|
`Position`/`Frame` subobject's implicit destructor inlining. Not a real "vtable swap";
|
||||||
|
just the compiler zeroing/resetting the embedded Frame's identity field during teardown.
|
||||||
|
|
||||||
|
## ConstraintManager::Create — `0x00556110` (factory)
|
||||||
|
|
||||||
|
```c
|
||||||
|
00556110 class ConstraintManager* ConstraintManager::Create(class CPhysicsObj* arg1)
|
||||||
|
|
||||||
|
00556110 {
|
||||||
|
00556114 void* result = operator new(0x5c);
|
||||||
|
00556114
|
||||||
|
00556122 if (result == 0)
|
||||||
|
00556177 return 0;
|
||||||
|
00556177
|
||||||
|
00556124 *(uint32_t*)result = 0;
|
||||||
|
00556126 *(uint32_t*)((char*)result + 4) = 0;
|
||||||
|
00556129 *(uint32_t*)((char*)result + 8) = 0;
|
||||||
|
0055612f *(uint32_t*)((char*)result + 0xc) = 0x796910;
|
||||||
|
00556136 *(uint32_t*)((char*)result + 0x10) = 0;
|
||||||
|
00556139 *(uint32_t*)((char*)result + 0x14) = 0x3f800000;
|
||||||
|
0055613f *(uint32_t*)((char*)result + 0x18) = 0;
|
||||||
|
00556142 *(uint32_t*)((char*)result + 0x1c) = 0;
|
||||||
|
00556145 *(uint32_t*)((char*)result + 0x20) = 0;
|
||||||
|
00556148 *(uint32_t*)((char*)result + 0x48) = 0;
|
||||||
|
0055614b *(uint32_t*)((char*)result + 0x4c) = 0;
|
||||||
|
0055614e *(uint32_t*)((char*)result + 0x50) = 0;
|
||||||
|
00556151 Frame::cache(((char*)result + 0x14));
|
||||||
|
00556156 *(uint32_t*)((char*)result + 0x54) = 0;
|
||||||
|
00556159 *(uint32_t*)((char*)result + 0x58) = 0;
|
||||||
|
00556159
|
||||||
|
0055615e if (*(uint32_t*)result != 0)
|
||||||
|
0055615e {
|
||||||
|
00556160 *(uint32_t*)((char*)result + 4) = 0;
|
||||||
|
00556163 *(uint32_t*)((char*)result + 8) = 0;
|
||||||
|
00556166 *(uint32_t*)result = 0;
|
||||||
|
0055615e }
|
||||||
|
0055615e
|
||||||
|
0055616c *(uint32_t*)result = arg1;
|
||||||
|
00556172 return result;
|
||||||
|
00556110 }
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTE: allocation is `0x5c` (92) bytes — `sizeof(ConstraintManager)`. Field-offset mapping
|
||||||
|
against the struct decl: `+0x0`=`physics_obj`, `+0x4`=`is_constrained`,
|
||||||
|
`+0x8`=`constraint_pos_offset`, `+0xc..0x50`=`constraint_pos` (embedded `Position`, whose
|
||||||
|
own `objcell_id`/`frame` subfields explain the `0x796910` vtable-like constant at `+0xc`
|
||||||
|
and the `Frame::cache` call seeding the rotation quaternion identity `w=1.0`
|
||||||
|
i.e. `0x3f800000` at `+0x14`), `+0x48`=`constraint_distance_start`,
|
||||||
|
`+0x4c`=`constraint_distance_max`. The trailing `+0x54`/`+0x58` zeroing is past the
|
||||||
|
declared struct fields in the header excerpt we have — likely padding/alignment or a
|
||||||
|
field the header comment block truncated; not load-bearing for the port (all consumed
|
||||||
|
fields — `physics_obj`, `is_constrained`, `constraint_pos_offset`, `constraint_pos`,
|
||||||
|
`constraint_distance_start`, `constraint_distance_max` — are accounted for).
|
||||||
|
|
||||||
|
The odd `if (*(uint32_t*)result != 0) { zero everything }` right after construction reads
|
||||||
|
as dead/defensive code from an inlined check that can't actually trigger here (the fields
|
||||||
|
were all just zeroed above) — flagging as NOTE, not altering.
|
||||||
|
|
||||||
|
## ConstraintManager::adjust_offset — `0x00556180`
|
||||||
|
|
||||||
|
```c
|
||||||
|
00556180 void __thiscall ConstraintManager::adjust_offset(class ConstraintManager* this, class Frame* arg2, double arg3)
|
||||||
|
|
||||||
|
00556180 {
|
||||||
|
00556186 class CPhysicsObj* physics_obj = this->physics_obj;
|
||||||
|
00556186
|
||||||
|
0055618a if (physics_obj != 0)
|
||||||
|
0055618a {
|
||||||
|
00556190 int32_t is_constrained = this->is_constrained;
|
||||||
|
00556190
|
||||||
|
00556195 if (is_constrained != 0)
|
||||||
|
00556195 {
|
||||||
|
005561a7 if ((physics_obj->transient_state & 1) != 0)
|
||||||
|
005561a7 {
|
||||||
|
005561a9 long double x87_r7_1 = ((long double)this->constraint_pos_offset);
|
||||||
|
005561ac long double temp1_1 = ((long double)this->constraint_distance_max);
|
||||||
|
005561ac (x87_r7_1 - temp1_1);
|
||||||
|
005561af physics_obj = ((((x87_r7_1 < temp1_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_1, temp1_1))) ? 1 : 0) << 0xa) | ((((x87_r7_1 == temp1_1) ? 1 : 0) << 0xe) | 0))));
|
||||||
|
005561af
|
||||||
|
005561b4 if ((*(uint8_t*)((char*)physics_obj)[1] & 1) != 0)
|
||||||
|
005561b4 {
|
||||||
|
005561e7 long double x87_r7_2 = ((long double)this->constraint_pos_offset);
|
||||||
|
005561ea long double temp2_1 = ((long double)this->constraint_distance_start);
|
||||||
|
005561ea (x87_r7_2 - temp2_1);
|
||||||
|
005561ed physics_obj = ((((x87_r7_2 < temp2_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_2, temp2_1))) ? 1 : 0) << 0xa) | ((((x87_r7_2 == temp2_1) ? 1 : 0) << 0xe) | 0))));
|
||||||
|
005561ef bool p_1 = /* bool p_1 = unimplemented {test ah, 0x41} */;
|
||||||
|
005561ef
|
||||||
|
005561f2 if (p_1)
|
||||||
|
005561f2 {
|
||||||
|
005561f7 int32_t is_constrained_1 = is_constrained;
|
||||||
|
00556209 Vector3::operator*=(&arg2->m_fOrigin, ((float)((((long double)this->constraint_distance_max) - ((long double)this->constraint_pos_offset)) / (((long double)this->constraint_distance_max) - ((long double)this->constraint_distance_start)))));
|
||||||
|
005561f2 }
|
||||||
|
005561b4 }
|
||||||
|
005561b4 else
|
||||||
|
005561b4 {
|
||||||
|
005561c2 arg2->m_fOrigin.x = 0;
|
||||||
|
005561c2 arg2->m_fOrigin.y = 0f;
|
||||||
|
005561c2 arg2->m_fOrigin.z = 0f;
|
||||||
|
005561b4 }
|
||||||
|
005561a7 }
|
||||||
|
005561a7
|
||||||
|
0055620e arg2->m_fOrigin;
|
||||||
|
00556211 arg2->m_fOrigin;
|
||||||
|
00556233 this->constraint_pos_offset = ((float)(((long double)arg2->m_fOrigin.x) + ((long double)this->constraint_pos_offset)));
|
||||||
|
00556195 }
|
||||||
|
0055618a }
|
||||||
|
00556180 }
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTE (garbled bitfield mush + BN variable-reuse artifact): `physics_obj` gets *reused* as
|
||||||
|
a scratch int32 to hold the packed x87 comparison-flags value at `005561af` and again at
|
||||||
|
`005561ed` — this is Binary Ninja recycling the SSA slot, NOT a real reassignment of the
|
||||||
|
`CPhysicsObj*` pointer. The `this->physics_obj` local captured at `00556186` is what's
|
||||||
|
actually read at `005561a7` (`physics_obj->transient_state`) and `005561b4`
|
||||||
|
(`*(uint8_t*)((char*)physics_obj)[1] & 1` — this is checking a byte of `transient_state`
|
||||||
|
again, offset `+1`, i.e. a second flag byte inside the same bitfield/word). Do not port
|
||||||
|
the reused `physics_obj` int32 as if it becomes a different physics object; it's the same
|
||||||
|
pointer, just overwritten as dead-value scratch space by the decompiler's register
|
||||||
|
allocator view.
|
||||||
|
|
||||||
|
`bool p_1 = /* unimplemented {test ah, 0x41} */` is the same x87-flags-in-AH pattern as
|
||||||
|
`IsFullyConstrained` above, testing bits 0 and 6 this time (C0 + C6/C2 combo depending on
|
||||||
|
encoding) — the classic `jbe`-vs-`jae` variant. Given the surrounding compare
|
||||||
|
(`constraint_pos_offset < constraint_distance_start` or equal), and that the guarded body
|
||||||
|
computes a **lerp fraction** `(constraint_distance_max - constraint_pos_offset) /
|
||||||
|
(constraint_distance_max - constraint_distance_start)` applied to `arg2->m_fOrigin` via
|
||||||
|
`*=`, the semantic read is: **when the object has NOT yet reached (or has just reached)
|
||||||
|
`constraint_distance_start`, scale the frame's offset delta by how far through the
|
||||||
|
start→max leash band the object currently sits** (a ramp/taper multiplier — presumably
|
||||||
|
smoothly reducing how much of the requested frame delta gets applied as the leash
|
||||||
|
tightens). `test ah,0x41` semantically reads as "less-than-or-unordered-or-equal"
|
||||||
|
(`p_1` true when NOT clearly greater), so the ramp only applies while still inside the
|
||||||
|
band; once past `constraint_distance_start` typical port intent should skip the scale
|
||||||
|
(leave `arg2->m_fOrigin` alone) — consistent with the `else` branch two levels up which
|
||||||
|
zeroes `m_fOrigin` outright when `transient_state`'s second flag bit is clear.
|
||||||
|
|
||||||
|
Mechanically, regardless of the exact flag polarity (worth a live cdb single-step check
|
||||||
|
if the port's leash-taper feel diverges from retail), the function's shape is:
|
||||||
|
1. No-op if no `physics_obj` or not `is_constrained`.
|
||||||
|
2. If `transient_state & 1` (some "active"/"in contact" style flag):
|
||||||
|
- If a second transient-state flag byte's bit 0 is set: scale the incoming frame delta
|
||||||
|
`arg2->m_fOrigin` by a lerp fraction based on `(max - pos_offset) / (max - start)`,
|
||||||
|
gated by a comparison of `pos_offset` vs `constraint_distance_start`.
|
||||||
|
- Else: zero the incoming frame delta entirely (fully clamp movement).
|
||||||
|
3. Unconditionally (after the above), accumulate: `constraint_pos_offset +=
|
||||||
|
arg2->m_fOrigin.x` (note: only the `.x` component is added — `arg2->m_fOrigin` is read
|
||||||
|
twice at `0055620e`/`00556211` with no visible effect, likely a debug/no-op dead read
|
||||||
|
from the decompiler, or hints there's a per-component variant BN collapsed; only the
|
||||||
|
final `.x`-add survived as an observable store).
|
||||||
|
|
||||||
|
## ConstraintManager::ConstrainTo — `0x00556240`
|
||||||
|
|
||||||
|
```c
|
||||||
|
00556240 void __thiscall ConstraintManager::ConstrainTo(class ConstraintManager* this, class Position const* arg2, float arg3, float arg4)
|
||||||
|
|
||||||
|
00556240 {
|
||||||
|
00556248 this->is_constrained = 1;
|
||||||
|
00556259 this->constraint_pos.objcell_id = arg2->objcell_id;
|
||||||
|
0055625c Frame::operator=(&this->constraint_pos.frame, &arg2->frame);
|
||||||
|
00556271 this->constraint_distance_start = arg3;
|
||||||
|
00556274 this->constraint_distance_max = arg4;
|
||||||
|
0055627c this->constraint_pos_offset = ((float)Position::distance(arg2, &this->physics_obj->m_position));
|
||||||
|
00556240 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Straightforward: pins the leash anchor (`constraint_pos` = copy of `arg2`'s cell+frame),
|
||||||
|
sets `is_constrained = true`, sets the start/max leash-band radii from `arg3`/`arg4`, and
|
||||||
|
initializes `constraint_pos_offset` to the CURRENT distance from the anchor to the
|
||||||
|
physics object's live position (`Position::distance(arg2, &physics_obj->m_position)`) —
|
||||||
|
i.e. the leash starts already "extended" to wherever the object presently is relative to
|
||||||
|
the constraint anchor, not to zero.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PositionManager-level seams (the actual public API — ConstraintManager is
|
||||||
|
## lazily-created and private underneath)
|
||||||
|
|
||||||
|
`ConstraintManager` is never touched directly from outside `PositionManager`.
|
||||||
|
`PositionManager` lazily creates it on first `ConstrainTo` call and forwards through it.
|
||||||
|
|
||||||
|
```c
|
||||||
|
00555190 void __thiscall PositionManager::adjust_offset(class PositionManager* this, class Frame* arg2, double arg3)
|
||||||
|
00555190 {
|
||||||
|
00555191 int32_t ebx = arg3;
|
||||||
|
0055519d class InterpolationManager* interpolation_manager = this->interpolation_manager;
|
||||||
|
005551a2 int32_t edi = *(uint32_t*)((char*)arg3)[4];
|
||||||
|
005551a2
|
||||||
|
005551a6 if (interpolation_manager != 0)
|
||||||
|
005551a6 {
|
||||||
|
005551a8 int32_t var_14_1 = edi;
|
||||||
|
005551ab InterpolationManager::adjust_offset(interpolation_manager, arg2, ebx);
|
||||||
|
005551a6 }
|
||||||
|
005551a6
|
||||||
|
005551b0 class StickyManager* sticky_manager = this->sticky_manager;
|
||||||
|
005551b0
|
||||||
|
005551b5 if (sticky_manager != 0)
|
||||||
|
005551b5 {
|
||||||
|
005551b7 int32_t var_14_2 = edi;
|
||||||
|
005551ba StickyManager::adjust_offset(sticky_manager, arg2, ebx);
|
||||||
|
005551b5 }
|
||||||
|
005551b5
|
||||||
|
005551bf class ConstraintManager* constraint_manager = this->constraint_manager;
|
||||||
|
005551bf
|
||||||
|
005551c4 if (constraint_manager != 0)
|
||||||
|
005551c4 {
|
||||||
|
005551c6 int32_t var_14_3 = edi;
|
||||||
|
005551c9 ConstraintManager::adjust_offset(constraint_manager, arg2, ebx);
|
||||||
|
005551c4 }
|
||||||
|
00555190 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Chains ALL THREE sub-managers' `adjust_offset` in a fixed order:
|
||||||
|
`InterpolationManager` → `StickyManager` → `ConstraintManager`, each optional (only
|
||||||
|
called if that sub-manager exists). This is the per-frame(?) offset-adjustment
|
||||||
|
dispatcher `PositionManager` uses to let interpolation/sticky/constraint all have a say
|
||||||
|
in shaping a `Frame` delta before it's applied.
|
||||||
|
|
||||||
|
```c
|
||||||
|
00555280 void __thiscall PositionManager::ConstrainTo(class PositionManager* this, class Position const* arg2, float arg3, float arg4)
|
||||||
|
00555280 {
|
||||||
|
00555288 if (this->constraint_manager == 0)
|
||||||
|
00555296 this->constraint_manager = ConstraintManager::Create(this->physics_obj);
|
||||||
|
00555296
|
||||||
|
00555299 class ConstraintManager* constraint_manager = this->constraint_manager;
|
||||||
|
00555299
|
||||||
|
0055529f if (constraint_manager == 0)
|
||||||
|
005552a6 return;
|
||||||
|
005552a6
|
||||||
|
005552a1 /* tailcall */
|
||||||
|
005552a1 return ConstraintManager::ConstrainTo(constraint_manager, arg2, arg3, arg4);
|
||||||
|
00555280 }
|
||||||
|
|
||||||
|
005552b0 void __fastcall PositionManager::UnConstrain(class PositionManager* this)
|
||||||
|
005552b0 {
|
||||||
|
005552b0 class ConstraintManager* constraint_manager = this->constraint_manager;
|
||||||
|
005552b0
|
||||||
|
005552b5 if (constraint_manager == 0)
|
||||||
|
005552bc return;
|
||||||
|
005552bc
|
||||||
|
005552b7 /* tailcall */
|
||||||
|
005552b7 return ConstraintManager::UnConstrain(constraint_manager);
|
||||||
|
005552b0 }
|
||||||
|
|
||||||
|
005552c0 int32_t __fastcall PositionManager::IsFullyConstrained(class PositionManager const* this)
|
||||||
|
005552c0 {
|
||||||
|
005552c0 class ConstraintManager* constraint_manager = this->constraint_manager;
|
||||||
|
005552c0
|
||||||
|
005552c5 if (constraint_manager == 0)
|
||||||
|
005552ce return 0;
|
||||||
|
005552ce
|
||||||
|
005552c7 /* tailcall */
|
||||||
|
005552c7 return ConstraintManager::IsFullyConstrained(constraint_manager);
|
||||||
|
005552c0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
`PositionManager::Create` (`0x005552d0`) wires a freshly-allocated `PositionManager`'s
|
||||||
|
`physics_obj` back-pointer into any already-non-null sub-managers (only relevant for
|
||||||
|
copy/re-init paths since a fresh `PositionManager` starts with all-null sub-managers):
|
||||||
|
|
||||||
|
```c
|
||||||
|
005552d0 class PositionManager* PositionManager::Create(class CPhysicsObj* arg1)
|
||||||
|
005552d0 {
|
||||||
|
005552d3 void* result = operator new(0x10);
|
||||||
|
...
|
||||||
|
0055531d class ConstraintManager* ecx_2 = *(uint32_t*)((char*)result + 8);
|
||||||
|
0055531d
|
||||||
|
00555322 if (ecx_2 != 0)
|
||||||
|
00555325 ConstraintManager::SetPhysicsObject(ecx_2, arg1);
|
||||||
|
00555325
|
||||||
|
0055532e return result;
|
||||||
|
005552d0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
`PositionManager::Destroy` (`0x00555340`) tears down and `delete`s all three
|
||||||
|
sub-managers, `ConstraintManager` last:
|
||||||
|
|
||||||
|
```c
|
||||||
|
00555340 void __fastcall PositionManager::Destroy(class PositionManager* this)
|
||||||
|
00555340 {
|
||||||
|
...
|
||||||
|
00555377 class ConstraintManager* constraint_manager = this->constraint_manager;
|
||||||
|
0055537c this->sticky_manager = nullptr;
|
||||||
|
0055537c
|
||||||
|
00555383 if (constraint_manager != 0)
|
||||||
|
00555383 {
|
||||||
|
00555387 ConstraintManager::~ConstraintManager(constraint_manager);
|
||||||
|
0055538d operator delete(constraint_manager);
|
||||||
|
00555383 }
|
||||||
|
00555383
|
||||||
|
00555396 this->constraint_manager = nullptr;
|
||||||
|
00555340 }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CPhysicsObj-level seams (public API callers actually use)
|
||||||
|
|
||||||
|
```c
|
||||||
|
0050ec10 void __fastcall CPhysicsObj::GetMaxConstraintDistance(class CPhysicsObj const* this)
|
||||||
|
0050ec10 {
|
||||||
|
0050ec16 if (this == CPhysicsObj::player_object)
|
||||||
|
0050ec16 {
|
||||||
|
0050ec18 this->m_position;
|
||||||
|
0050ec2d return;
|
||||||
|
0050ec16 }
|
||||||
|
0050ec16
|
||||||
|
0050ec35 this->m_position;
|
||||||
|
0050ec10 }
|
||||||
|
|
||||||
|
0050ebc0 void __fastcall CPhysicsObj::GetStartConstraintDistance(class CPhysicsObj const* this)
|
||||||
|
0050ebc0 {
|
||||||
|
0050ebc6 if (this == CPhysicsObj::player_object)
|
||||||
|
0050ebc6 {
|
||||||
|
0050ebc8 this->m_position;
|
||||||
|
0050ebdd return;
|
||||||
|
0050ebc6 }
|
||||||
|
0050ebc6
|
||||||
|
0050ebe5 this->m_position;
|
||||||
|
0050ebc0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTE (BN decompilation artifact / x87 return-value elision): both functions have `void`
|
||||||
|
signatures per BN's guessed prototype but are clearly meant to RETURN a float (they're
|
||||||
|
called as `ecx_26 = CPhysicsObj::GetMaxConstraintDistance(arg2)` immediately followed by
|
||||||
|
`(float)st0_6` casts at the call sites — an x87 FPU return value living in `st0` that
|
||||||
|
Binary Ninja failed to attach to the declared return type). Body-wise all we get is
|
||||||
|
`this->m_position;` as a bare expression on both the player-branch and fallthrough
|
||||||
|
paths — BN elided the actual field read/constant selection (this is likely
|
||||||
|
`this->m_position.something` or a per-type constant lookup that got collapsed to a
|
||||||
|
dead-looking statement). **This function needs a live cdb read of `st0` after the call,
|
||||||
|
or a Ghidra re-decompile with a corrected float-return signature, to recover the actual
|
||||||
|
values.** Given the call-site pattern (constraining the player and other movers to a
|
||||||
|
"home" position after teleport/movement-timeout in `SmartBox::HandleReceivedPosition`),
|
||||||
|
the two functions almost certainly return small fixed-radius constants (a "start easing"
|
||||||
|
radius and a "max leash" radius), likely DIFFERENT for the player vs. non-player case
|
||||||
|
(hence the `this == CPhysicsObj::player_object` branch in both). Do not guess the literal
|
||||||
|
values — flag as an open research item before porting numeric constants.
|
||||||
|
|
||||||
|
```c
|
||||||
|
00510520 void __thiscall CPhysicsObj::ConstrainTo(class CPhysicsObj* this, class Position const* arg2, float arg3, float arg4)
|
||||||
|
00510520 {
|
||||||
|
00510523 CPhysicsObj::MakePositionManager(this);
|
||||||
|
00510528 class PositionManager* position_manager = this->position_manager;
|
||||||
|
00510528
|
||||||
|
00510531 if (position_manager == 0)
|
||||||
|
00510538 return;
|
||||||
|
00510538
|
||||||
|
00510533 /* tailcall */
|
||||||
|
00510533 return PositionManager::ConstrainTo(position_manager, arg2, arg3, arg4);
|
||||||
|
00510520 }
|
||||||
|
```
|
||||||
|
|
||||||
|
`CPhysicsObj::ConstrainTo` lazily ensures a `PositionManager` exists
|
||||||
|
(`MakePositionManager`) then forwards. This is the entry point external code calls.
|
||||||
|
|
||||||
|
```c
|
||||||
|
0050ec60 int32_t __fastcall CPhysicsObj::IsFullyConstrained(class CPhysicsObj const* this)
|
||||||
|
0050ec60 {
|
||||||
|
0050ec60 class PositionManager* position_manager = this->position_manager;
|
||||||
|
0050ec60
|
||||||
|
0050ec68 if (position_manager == 0)
|
||||||
|
0050ec71 return 0;
|
||||||
|
0050ec71
|
||||||
|
0050ec6a /* tailcall */
|
||||||
|
0050ec6a return PositionManager::IsFullyConstrained(position_manager);
|
||||||
|
0050ec60 }
|
||||||
|
```
|
||||||
|
|
||||||
|
(Address correction noted at top of doc: this is `0x0050ec60`, not the task-supplied
|
||||||
|
`0x0050f730`.)
|
||||||
|
|
||||||
|
There is no separate `CPhysicsObj::UnConstrain` — callers go straight to
|
||||||
|
`PositionManager::UnConstrain(this->position_manager)` (see caller list below); only
|
||||||
|
`ConstrainTo` and `IsFullyConstrained` got a `CPhysicsObj`-level convenience wrapper.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CALLERS — when does retail actually constrain an object?
|
||||||
|
|
||||||
|
### 1. `SmartBox::HandleReceivedPosition` (`0x00453fd0`) — THE constrain call site
|
||||||
|
|
||||||
|
All three live `CPhysicsObj::ConstrainTo` calls in the entire corpus are inside this one
|
||||||
|
function, at three different branches of its position-update-reconciliation logic:
|
||||||
|
|
||||||
|
**Branch A — non-player mover, after a successful move/teleport resolve (`0x00454254`):**
|
||||||
|
```c
|
||||||
|
0045414d if (arg2 != this->player)
|
||||||
|
0045414d {
|
||||||
|
00454254 if (CPhysicsObj::MoveOrTeleport(arg2, &var_48, arg8, arg5, arg6) != 0)
|
||||||
|
00454254 {
|
||||||
|
00454258 int32_t ecx_26;
|
||||||
|
00454258 ecx_26 = CPhysicsObj::GetMaxConstraintDistance(arg2);
|
||||||
|
0045425d int32_t var_68_14 = ecx_26;
|
||||||
|
00454263 ecx_28 = CPhysicsObj::GetStartConstraintDistance(arg2);
|
||||||
|
00454268 int32_t var_6c_9 = ecx_28;
|
||||||
|
00454272 CPhysicsObj::ConstrainTo(arg2, &arg2->m_position, ((float)st0_7), ((float)st0_6));
|
||||||
|
00454254 }
|
||||||
|
00454254
|
||||||
|
00454254 return;
|
||||||
|
0045414d }
|
||||||
|
```
|
||||||
|
For a non-player object (`arg2`), once `MoveOrTeleport` succeeds, it is constrained
|
||||||
|
**to its own current position** (`&arg2->m_position` as the anchor) with start/max radii
|
||||||
|
from `GetStartConstraintDistance`/`GetMaxConstraintDistance`.
|
||||||
|
|
||||||
|
**Branch B — player, on a fresh TELEPORT timestamp event (`0x0045415f`):**
|
||||||
|
```c
|
||||||
|
0045415f if (CPhysicsObj::newer_event(arg2, TELEPORT_TS, arg8) != 0)
|
||||||
|
0045415f {
|
||||||
|
00454168 SmartBox::TeleportPlayer(this, &var_48);
|
||||||
|
0045416f ecx_14 = CPhysicsObj::GetMaxConstraintDistance(arg2);
|
||||||
|
0045417a ecx_16 = CPhysicsObj::GetStartConstraintDistance(arg2);
|
||||||
|
0045418a CPhysicsObj::ConstrainTo(arg2, &var_48, ((float)st0_2), ((float)st0_1));
|
||||||
|
0045418f class CPhysicsObj* player_2 = this->player;
|
||||||
|
0045419c int32_t var_54 = 0; // zero vector
|
||||||
|
004541b4 CPhysicsObj::set_velocity(player_2, &var_54, 1);
|
||||||
|
004541c0 return;
|
||||||
|
0045415f }
|
||||||
|
```
|
||||||
|
On a server teleport of the local player, `SmartBox::TeleportPlayer` snaps position, then
|
||||||
|
the player is constrained **to the newly-received server position** (`&var_48`, the
|
||||||
|
decoded incoming `Position`), and velocity is zeroed.
|
||||||
|
|
||||||
|
**Branch C — player, fallthrough / non-teleport received-position path (`0x004541c9`):**
|
||||||
|
```c
|
||||||
|
004541c9 ecx_19 = CPhysicsObj::GetMaxConstraintDistance(this->player);
|
||||||
|
004541d8 ecx_21 = CPhysicsObj::GetStartConstraintDistance(this->player);
|
||||||
|
004541ec CPhysicsObj::ConstrainTo(this->player, &var_48, ((float)st0_5), ((float)st0_4));
|
||||||
|
004541f1 class CommandInterpreter* cmdinterp_1 = this->cmdinterp;
|
||||||
|
0045420a if ((cmdinterp_1->vtable->UsePositionFromServer(cmdinterp_1) != 0 && arg5 != 0))
|
||||||
|
0045420a {
|
||||||
|
... CPhysicsObj::InterpolateTo(arg2, &var_48, ...);
|
||||||
|
```
|
||||||
|
Every OTHER received server position update for the local player (not a teleport-flagged
|
||||||
|
event) ALSO constrains the player to the received position (`&var_48`), and then —
|
||||||
|
depending on `UsePositionFromServer`/autonomy settings — may additionally kick off
|
||||||
|
`InterpolateTo`. So the leash gets re-anchored on essentially every server position
|
||||||
|
correction, whether or not interpolation is used to visually smooth toward it.
|
||||||
|
|
||||||
|
**Summary for Branch A/B/C:** retail constrains an object to a `Position` (self or
|
||||||
|
server-received) with a start/max leash-band pair **every time `SmartBox` processes an
|
||||||
|
inbound position update for that object** — this is the "rubber-band" leash mechanism
|
||||||
|
that keeps the client's locally-simulated position from drifting too far from the
|
||||||
|
server-authoritative position between updates. It's re-armed (re-`ConstrainTo`'d) on
|
||||||
|
every inbound position packet, not set once.
|
||||||
|
|
||||||
|
### 2. `CPhysicsObj::teleport_hook` (`0x00514ed0`) — THE unconstrain call site
|
||||||
|
|
||||||
|
```c
|
||||||
|
00514ed0 void __fastcall CPhysicsObj::teleport_hook(class CPhysicsObj* this, int32_t arg2)
|
||||||
|
00514ed0 {
|
||||||
|
00514ed3 class MovementManager* movement_manager = this->movement_manager;
|
||||||
|
00514edb if (movement_manager != 0)
|
||||||
|
00514edb MovementManager::CancelMoveTo(movement_manager, edx);
|
||||||
|
00514ee4 class PositionManager* position_manager = this->position_manager;
|
||||||
|
00514eec if (position_manager != 0)
|
||||||
|
00514eee PositionManager::UnStick(position_manager);
|
||||||
|
00514ef3 class PositionManager* position_manager_1 = this->position_manager;
|
||||||
|
00514efb if (position_manager_1 != 0)
|
||||||
|
00514efd PositionManager::StopInterpolating(position_manager_1);
|
||||||
|
00514f02 class PositionManager* position_manager_2 = this->position_manager;
|
||||||
|
00514f0a if (position_manager_2 != 0)
|
||||||
|
00514f0c PositionManager::UnConstrain(position_manager_2);
|
||||||
|
00514f11 class TargetManager* target_manager = this->target_manager;
|
||||||
|
00514f19 if (target_manager != 0)
|
||||||
|
00514f19 {
|
||||||
|
00514f1b TargetManager::ClearTarget(target_manager);
|
||||||
|
00514f28 TargetManager::NotifyVoyeurOfEvent(this->target_manager, Teleported_TargetStatus);
|
||||||
|
00514f19 }
|
||||||
|
00514f31 CPhysicsObj::report_collision_end(this, 1);
|
||||||
|
00514ed0 }
|
||||||
|
```
|
||||||
|
The ONLY `UnConstrain` call in the corpus. `teleport_hook` is a general "this object just
|
||||||
|
got relocated in a way that invalidates all continuity state" cleanup: it cancels any
|
||||||
|
active `MoveTo`, un-sticks (StickyManager), stops interpolation, **un-constrains**, clears
|
||||||
|
target tracking, and ends collision reporting. So the leash is torn down whenever an
|
||||||
|
object teleports (any teleport, not just the player's) — makes sense, since a teleport by
|
||||||
|
definition means "the position just legitimately jumped," so the anti-drift leash from
|
||||||
|
the PREVIOUS anchor must be dropped rather than fight the teleport.
|
||||||
|
|
||||||
|
### 3. `CMotionInterp::jump_is_allowed` (`0x005282b0`) — THE read call site
|
||||||
|
|
||||||
|
```c
|
||||||
|
005282b0 uint32_t __thiscall CMotionInterp::jump_is_allowed(class CMotionInterp* this, float arg2, int32_t* arg3)
|
||||||
|
005282b0 {
|
||||||
|
005282b8 if (this->physics_obj != 0)
|
||||||
|
005282b8 {
|
||||||
|
...
|
||||||
|
005282fd if (CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0)
|
||||||
|
00528305 return 0x47;
|
||||||
|
00528305
|
||||||
|
00528308 class LListData* head_ = this->pending_motions.head_;
|
||||||
|
...
|
||||||
|
```
|
||||||
|
`jump_is_allowed` reads `IsFullyConstrained` and, if true, immediately returns error code
|
||||||
|
`0x47` (rejecting the jump attempt) before even checking pending motions / jump-charge
|
||||||
|
state. This is the ONLY read-site of `IsFullyConstrained` in the corpus. Ties back to the
|
||||||
|
mechanical read of `ConstraintManager::IsFullyConstrained` above: while the object is
|
||||||
|
still within 90% of its max leash distance from the constraint anchor, it counts as
|
||||||
|
"fully constrained" and jumping is blocked outright — i.e. **you cannot jump while the
|
||||||
|
client's simulated position is being actively rubber-banded back toward a server-received
|
||||||
|
position inside the tight leash band.** Only once you've drifted past 90% of the leash
|
||||||
|
(or the leash has been dropped via `UnConstrain`/teleport) does the jump-blocking gate
|
||||||
|
open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CPhysicsObj-level "constrain" seam grep (exhaustive)
|
||||||
|
|
||||||
|
Full result of `grep "::ConstrainTo(\|::UnConstrain(\|::IsFullyConstrained("` across the
|
||||||
|
corpus — every call site, no filtering:
|
||||||
|
|
||||||
|
```
|
||||||
|
93007 CPhysicsObj::ConstrainTo(arg2, &arg2->m_position, ...) [SmartBox::HandleReceivedPosition, Branch A]
|
||||||
|
93024 CPhysicsObj::ConstrainTo(arg2, &var_48, ...) [SmartBox::HandleReceivedPosition, Branch B]
|
||||||
|
93041 CPhysicsObj::ConstrainTo(this->player, &var_48, ...) [SmartBox::HandleReceivedPosition, Branch C]
|
||||||
|
276520 CPhysicsObj::IsFullyConstrained (definition)
|
||||||
|
276529 -> PositionManager::IsFullyConstrained (tailcall)
|
||||||
|
278353 CPhysicsObj::ConstrainTo (definition)
|
||||||
|
278363 -> PositionManager::ConstrainTo (tailcall)
|
||||||
|
283140 PositionManager::UnConstrain(position_manager_2) [CPhysicsObj::teleport_hook]
|
||||||
|
305524 CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0 [CMotionInterp::jump_is_allowed]
|
||||||
|
352186 PositionManager::ConstrainTo (definition)
|
||||||
|
352198 -> ConstraintManager::ConstrainTo (tailcall)
|
||||||
|
352203 PositionManager::UnConstrain (definition)
|
||||||
|
352212 -> ConstraintManager::UnConstrain (tailcall)
|
||||||
|
352217 PositionManager::IsFullyConstrained (definition)
|
||||||
|
352226 -> ConstraintManager::IsFullyConstrained (tailcall)
|
||||||
|
353405 ConstraintManager::UnConstrain (definition)
|
||||||
|
353413 ConstraintManager::IsFullyConstrained (definition)
|
||||||
|
353528 ConstraintManager::ConstrainTo (definition)
|
||||||
|
```
|
||||||
|
|
||||||
|
No other call sites exist anywhere in the 1.4M-line corpus. The entire constrain
|
||||||
|
mechanism is used EXCLUSIVELY by:
|
||||||
|
- `SmartBox::HandleReceivedPosition` to arm/re-arm the leash on inbound position updates
|
||||||
|
(3 branches: self-anchor for remote movers, server-anchor for player teleport, server-
|
||||||
|
anchor for player non-teleport updates), and
|
||||||
|
- `CPhysicsObj::teleport_hook` to disarm it on any teleport, and
|
||||||
|
- `CMotionInterp::jump_is_allowed` to read it as a jump-blocking gate.
|
||||||
|
|
||||||
|
This is a narrow, special-purpose "server position rubber-band leash," NOT a general
|
||||||
|
physics constraint/joint system.
|
||||||
1015
docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md
Normal file
1015
docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md
Normal file
File diff suppressed because it is too large
Load diff
338
docs/research/2026-07-03-r5-managers/r5-port-plan.md
Normal file
338
docs/research/2026-07-03-r5-managers/r5-port-plan.md
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
# R5 port plan — MovementManager facade + PositionManager (Sticky/Constraint) + TargetManager
|
||||||
|
|
||||||
|
Synthesis of the R5 recon (2026-07-03). Verbatim retail decomp lives in the
|
||||||
|
sibling files (`r5-*-decomp.md`); ACE cross-reference in `r5-ace-crossref.md`;
|
||||||
|
current acdream integration seams in `r5-acdream-seams.md`. This doc is the
|
||||||
|
**pseudocode + integration + sub-slice plan** (mandatory workflow step 3).
|
||||||
|
|
||||||
|
Retirement targets: **TS-39** (sticky no-op seams) and **AP-79** (the P4
|
||||||
|
TargetTracker adapter). ConstraintManager is ported for structural completeness
|
||||||
|
of PositionManager but its arming stays unported (see §Constraint scope) — **TS-35
|
||||||
|
stays open**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. The retail structure vs acdream's reality
|
||||||
|
|
||||||
|
**Retail** (`CPhysicsObj` owns three managers, all lazily created):
|
||||||
|
|
||||||
|
```
|
||||||
|
CPhysicsObj
|
||||||
|
├── movement_manager : MovementManager (facade → MotionInterp + MoveToManager)
|
||||||
|
├── position_manager : PositionManager (facade → Interpolation + Sticky + Constraint)
|
||||||
|
└── target_manager : TargetManager (voyeur subscription system)
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-tick, `CPhysicsObj::UpdateObjectInternal` (0x005156b0) fans out in THIS order
|
||||||
|
(decomp §positionmanager-sticky callers):
|
||||||
|
|
||||||
|
1. `DetectionManager::CheckDetection`
|
||||||
|
2. `TargetManager::HandleTargetting` ← voyeur tick (no separate UseTime)
|
||||||
|
3. `MovementManager::UseTime` → `MoveToManager::UseTime` only
|
||||||
|
4. `CPartArray::HandleMovement`
|
||||||
|
5. `PositionManager::UseTime` → Interp/Sticky/Constraint UseTime
|
||||||
|
|
||||||
|
And `UpdatePositionInternal` (0x00512c30) composes the frame delta:
|
||||||
|
`Frame::cache(&d)` → `CPartArray::Update(d)` (animation) →
|
||||||
|
`PositionManager::adjust_offset(&d, quantum)` (interp+sticky+constraint ADD into
|
||||||
|
the SAME delta `d`) → `Frame::combine(out, m_position.frame, d)` → transition
|
||||||
|
(collision) → commit. **Sticky/constraint steering composes with animation motion
|
||||||
|
in the same per-tick delta frame, before collision resolves it.**
|
||||||
|
|
||||||
|
**acdream** has no per-entity `CPhysicsObj`. The per-entity owner today is:
|
||||||
|
|
||||||
|
| Retail | acdream (remote) | acdream (player) |
|
||||||
|
|---|---|---|
|
||||||
|
| `CPhysicsObj` | `GameWindow.RemoteMotion` (nested class, GameWindow.cs:411) | `_playerController` (`PlayerMovementController`) + GameWindow fields |
|
||||||
|
| `movement_manager` | `rm.Motion` (MotionInterpreter) + `rm.MoveTo` (MoveToManager) | `_playerController.Motion` + `playerMoveTo` |
|
||||||
|
| `position_manager` | **absent** | **absent** |
|
||||||
|
| `target_manager` | **AP-79 adapter**: `rm.TrackedTarget*` fields + `TickRemoteMoveTo` | **AP-79 adapter**: `_playerMoveToTarget*` fields + `OnUpdateFrame` block |
|
||||||
|
| `UpdateObjectInternal` tick | `TickRemoteMoveTo(rm)` (GameWindow.cs:4469) | `OnUpdateFrame` block (GameWindow.cs:7994) + `_playerController.Update` |
|
||||||
|
|
||||||
|
R5 adds the two missing managers per-entity. The MovementManager facade (collapsing
|
||||||
|
`Motion`+`MoveTo` into one owner) is the optional capstone (§V4) — the retirement
|
||||||
|
targets don't require it, so it lands last and only if the arc has budget.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Struct/field map (acclient.h → C#)
|
||||||
|
|
||||||
|
```
|
||||||
|
MovementManager (0x10): motion_interpreter, moveto_manager, physics_obj, weenie_obj
|
||||||
|
PositionManager (0x10): interpolation_manager, sticky_manager, constraint_manager, physics_obj
|
||||||
|
StickyManager (0x60): target_id:uint, target_radius:float, target_position:Position,
|
||||||
|
physics_obj, initialized:int, sticky_timeout_time:double
|
||||||
|
ConstraintManager(0x5c): physics_obj, is_constrained:int, constraint_pos_offset:float,
|
||||||
|
constraint_pos:Position, constraint_distance_start:float,
|
||||||
|
constraint_distance_max:float
|
||||||
|
TargetManager (0x18): physobj, target_info:TargetInfo*, voyeur_table:Hash<TargettedVoyeurInfo>,
|
||||||
|
last_update_time:double
|
||||||
|
TargetInfo (0xd0): context_id:uint, object_id:uint, radius:float, quantum:double,
|
||||||
|
target_position:Position, interpolated_position:Position,
|
||||||
|
interpolated_heading:Vec3, velocity:Vec3, status:TargetStatus,
|
||||||
|
last_update_time:double
|
||||||
|
TargettedVoyeurInfo(0x60): object_id:uint, quantum:double, radius:float,
|
||||||
|
last_sent_position:Position
|
||||||
|
```
|
||||||
|
|
||||||
|
acdream already has `TargetInfo` (4-field record) + `TargetStatus` enum in
|
||||||
|
`MoveToManager.cs` — R5 EXTENDS `TargetInfo` to the full 10-field shape (add
|
||||||
|
`ContextId`, `Radius`, `Quantum`, `InterpolatedHeading`, `Velocity`,
|
||||||
|
`LastUpdateTime`). The existing `MoveToManager.HandleUpdateTarget` only reads
|
||||||
|
`ObjectId`/`Status`/`InterpolatedPosition`, so the extension is additive-safe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The math — decoded (ACE is the oracle for the x87 mush)
|
||||||
|
|
||||||
|
All three `adjust_offset` bodies and both timeout gates were dense x87 mush in the
|
||||||
|
BN decomp. ACE's ports resolve them cleanly and were confirmed against the mush
|
||||||
|
structure (fld/fmul/fsqrt/fcomp shapes match). **Port ACE's structure; cite the
|
||||||
|
retail address.**
|
||||||
|
|
||||||
|
### 2a. StickyManager.adjust_offset (retail 0x00555430, ACE StickyManager.cs:91)
|
||||||
|
|
||||||
|
```
|
||||||
|
// Guard: no-op unless PhysicsObj && target_id != 0 && initialized
|
||||||
|
target = GetObjectA(target_id) // live resolve
|
||||||
|
targetPos = target != null ? target.Position : cached target_position // fallback to last-known
|
||||||
|
offset = self.Position.GetOffset(targetPos) // world vector self→target
|
||||||
|
offset = self.Position.GlobalToLocalVec(offset) // rotate into own frame
|
||||||
|
offset.Z = 0 // horizontal-only
|
||||||
|
|
||||||
|
radius = self.GetRadius()
|
||||||
|
dist = Position.CylinderDistanceNoZ(radius, self.Position, target_radius, targetPos)
|
||||||
|
- 0.3f // StickyRadius const (ACE StickyRadius=0.3f)
|
||||||
|
|
||||||
|
if NormalizeCheckSmall(ref offset): offset = Vector3.Zero // too close → don't chase jitter
|
||||||
|
|
||||||
|
speed = 0
|
||||||
|
minterp = self.get_minterp()
|
||||||
|
if minterp != null: speed = minterp.get_max_speed() * 5.0f // 5× own max speed (catch up)
|
||||||
|
if speed < EPSILON: speed = 15.0f // fallback
|
||||||
|
|
||||||
|
delta = speed * quantum
|
||||||
|
if delta >= abs(dist): delta = dist // don't overshoot (dist can be NEGATIVE)
|
||||||
|
offset.Origin *= delta
|
||||||
|
|
||||||
|
curHeading = self.Position.Frame.get_heading()
|
||||||
|
targetHeading = self.Position.heading(targetPos)
|
||||||
|
h = targetHeading - curHeading
|
||||||
|
if abs(h) < EPSILON: h = 0
|
||||||
|
if h < -EPSILON: h += 360
|
||||||
|
offset.set_heading(h) // per-tick RELATIVE turn toward target
|
||||||
|
```
|
||||||
|
|
||||||
|
Net: a speed-clamped horizontal steer + bounded turn toward the stuck target,
|
||||||
|
written into the shared per-tick delta frame. `StickyRadius=0.3f`, `StickyTime=1.0f`
|
||||||
|
are the two named constants.
|
||||||
|
|
||||||
|
### 2b. ConstraintManager.adjust_offset (retail 0x00556180, ACE ConstraintManager.cs:62)
|
||||||
|
|
||||||
|
```
|
||||||
|
// Guard: no-op unless PhysicsObj && is_constrained
|
||||||
|
if (self.TransientState & Contact): // ONLY clamps while grounded
|
||||||
|
if constraint_pos_offset < constraint_distance_max:
|
||||||
|
if constraint_pos_offset > constraint_distance_start:
|
||||||
|
offset.Origin *= (constraint_distance_max - constraint_pos_offset)
|
||||||
|
/ (constraint_distance_max - constraint_distance_start) // linear brake taper
|
||||||
|
else:
|
||||||
|
offset.Origin = Vector3.Zero // fully pinned
|
||||||
|
// UNCONDITIONAL (grounded or airborne):
|
||||||
|
constraint_pos_offset = offset.Origin.Length() // NB: length of the per-tick step, NOT distance-to-anchor
|
||||||
|
```
|
||||||
|
|
||||||
|
`ConstraintPos` (the anchor) is stored by ConstrainTo but never read by
|
||||||
|
adjust_offset — the taper uses `constraint_pos_offset` (last tick's step length)
|
||||||
|
vs start/max. See §ConstraintScope for why acdream never arms this.
|
||||||
|
|
||||||
|
### 2c. ConstraintManager.IsFullyConstrained (retail 0x005560d0, ACE:37)
|
||||||
|
|
||||||
|
```
|
||||||
|
return constraint_distance_max * 0.9f < constraint_pos_offset; // ≥90% of leash → "fully constrained"
|
||||||
|
```
|
||||||
|
|
||||||
|
Read by `jump_is_allowed`: if true → return 0x47 (block jump). Since acdream never
|
||||||
|
arms the constraint, this stays false → jump never blocked here (= TS-35 current
|
||||||
|
behavior).
|
||||||
|
|
||||||
|
### 2d. TargetManager quantum gates (retail 0x0051aa90 / 0x0051a650)
|
||||||
|
|
||||||
|
```
|
||||||
|
HandleTargetting(): // per-tick, self-throttled
|
||||||
|
if PhysicsTimer.CurrentTime - last_update_time < 0.5f: return // 0.5s throttle
|
||||||
|
if target_info != null && target_info.target_position == null: return
|
||||||
|
if target_info != null && target_info.status == Undefined
|
||||||
|
&& target_info.last_update_time + 10.0f < Timer.cur_time: // 10s staleness
|
||||||
|
target_info.status = TimedOut
|
||||||
|
PhysicsObj.HandleUpdateTarget(copy(target_info))
|
||||||
|
for voyeur in voyeur_table.Values.ToList():
|
||||||
|
CheckAndUpdateVoyeur(voyeur)
|
||||||
|
last_update_time = PhysicsTimer.CurrentTime
|
||||||
|
|
||||||
|
CheckAndUpdateVoyeur(voyeur):
|
||||||
|
newPos = GetInterpolatedPosition(voyeur.quantum) // self.pos + velocity*quantum (dead-reckon)
|
||||||
|
if newPos.Distance(voyeur.last_sent_position) > voyeur.radius: // send-on-significant-move
|
||||||
|
SendVoyeurUpdate(voyeur, newPos, Ok)
|
||||||
|
|
||||||
|
GetInterpolatedPosition(quantum):
|
||||||
|
pos = copy(self.Position); pos.Origin += self.get_velocity() * quantum; return pos
|
||||||
|
```
|
||||||
|
|
||||||
|
`0.5f` throttle, `10.0f` staleness — retail-tuned quanta (cross-checked ACE; verify
|
||||||
|
against named-retail before treating literally, per ACE's own `// ref?` caveats).
|
||||||
|
|
||||||
|
### 2e. The voyeur round-trip (peer-to-peer position notification)
|
||||||
|
|
||||||
|
```
|
||||||
|
Watcher W wants to track target T:
|
||||||
|
W.SetTarget(ctx=0, T.id, radius=0.5, quantum=q)
|
||||||
|
→ W.target_info = new TargetInfo(...)
|
||||||
|
→ T.add_voyeur(W.id, 0.5, q) // W subscribes ON T's voyeur_table
|
||||||
|
→ T immediately SendVoyeurUpdate(W-voyeur, T.pos, Ok) // initial snapshot
|
||||||
|
→ W.receive_target_update(info) → W.ReceiveUpdate(info)
|
||||||
|
→ W.target_info updated; W.HandleUpdateTarget(info)
|
||||||
|
→ W.movement_manager.HandleUpdateTarget (MoveToManager steers)
|
||||||
|
→ W.position_manager.HandleUpdateTarget (StickyManager follows)
|
||||||
|
|
||||||
|
Each tick, T.HandleTargetting → CheckAndUpdateVoyeur(W-voyeur):
|
||||||
|
if T drifted > radius since last sent → SendVoyeurUpdate → W.ReceiveUpdate → W.HandleUpdateTarget
|
||||||
|
|
||||||
|
W stops: W.ClearTarget → T.remove_voyeur(W.id)
|
||||||
|
T despawns: T.exit_world → NotifyVoyeurOfEvent(ExitWorld) → all watchers get ExitWorld
|
||||||
|
T teleports: T.teleport_hook → NotifyVoyeurOfEvent(Teleported)
|
||||||
|
```
|
||||||
|
|
||||||
|
This is exactly what the AP-79 adapter approximates (poll target pos, feed
|
||||||
|
HandleUpdateTarget when drift>radius). The voyeur port is a **faithful superset**:
|
||||||
|
same moveto behavior + correct sticky + timeout/exit/teleport events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Two consumers of set_target (both radius 0.5)
|
||||||
|
|
||||||
|
| Caller | quantum | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `MoveToManager.MoveToObject/TurnToObject` | **0.0** | resend as fast as the 0.5s tick allows |
|
||||||
|
| `StickyManager.StickTo` | **0.5** | throttled resend |
|
||||||
|
|
||||||
|
Both go through the SAME `CPhysicsObj::set_target → TargetManager::SetTarget`, and
|
||||||
|
both receive updates through `CPhysicsObj::HandleUpdateTarget →
|
||||||
|
{MovementManager, PositionManager}::HandleUpdateTarget`. In acdream the
|
||||||
|
`_setTarget`/`_clearTarget` seams (already on MoveToManager) get repointed at the
|
||||||
|
real TargetManager; StickyManager gets its own seam to the same TargetManager.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. acdream integration seams (delegate contracts)
|
||||||
|
|
||||||
|
The Core classes stay engine-agnostic via ctor-injected seams (same convention as
|
||||||
|
MoveToManager). Per entity the App layer supplies:
|
||||||
|
|
||||||
|
- `getPosition : () → Position` (world-space, cell + frame)
|
||||||
|
- `getVelocity : () → Vector3`
|
||||||
|
- `getRadius : () → float`, `getHeight : () → float` (from setup cylsphere — **finally
|
||||||
|
needed here; today MoveToManager passes 0** — see the P4 note)
|
||||||
|
- `getMinterpMaxSpeed : () → float?` (null if no interp) — StickyManager speed
|
||||||
|
- `getContact : () → bool` (transient Contact bit) — ConstraintManager gate
|
||||||
|
- `getObjectA : uint guid → IPhysicsTargetHandle?` — cross-entity resolve (the guid→entity
|
||||||
|
seam; drives voyeur delivery + sticky live-target). Backed by GameWindow's
|
||||||
|
`_entitiesByServerGuid`.
|
||||||
|
- `handleUpdateTarget : TargetInfo → void` — fans to MoveToManager + PositionManager(Sticky)
|
||||||
|
- `clearTarget`/`interruptCurrentMovement` — teardown (already exist on MoveToManager/Motion)
|
||||||
|
- clock : `() → double` (real clock, as R4-V5 fixed)
|
||||||
|
|
||||||
|
`getObjectA` returns a small handle exposing the OTHER entity's
|
||||||
|
`receive_target_update(TargetInfo)` (→ its TargetManager.ReceiveUpdate) + its live
|
||||||
|
Position — enough for `SendVoyeurUpdate` and sticky's live-target resolve.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Sub-slice plan
|
||||||
|
|
||||||
|
### R5-V1 — Core classes + conformance tests (NO wiring; low risk)
|
||||||
|
New files under `src/AcDream.Core/Physics/Motion/` (Position/Sticky/Constraint) and
|
||||||
|
`src/AcDream.Core/Physics/Combat/` (TargetManager — retail namespace split):
|
||||||
|
- `PositionManager` (facade: adjust_offset chain interp→sticky→constraint, UseTime,
|
||||||
|
StickTo/Unstick/ConstrainTo/UnConstrain/IsFullyConstrained/HandleUpdateTarget).
|
||||||
|
- `StickyManager` (§2a math, StickTo/UnStick/UseTime timeout/HandleUpdateTarget/adjust_offset).
|
||||||
|
- `ConstraintManager` (§2b/§2c, ConstrainTo/UnConstrain/IsFullyConstrained/adjust_offset).
|
||||||
|
- `TargetManager` (§2d/§2e full voyeur system) + `TargettedVoyeurInfo` + extended `TargetInfo`.
|
||||||
|
- InterpolationManager: acdream already has a remote `Interp` (PhysicsBody interp) —
|
||||||
|
keep it; PositionManager's interp slot delegates to a thin adapter or is left null
|
||||||
|
for the player. (Interp is NOT an R5 retirement target; don't re-port it.)
|
||||||
|
- Seam interfaces + a test harness with a fake `getObjectA` wiring two managers to
|
||||||
|
each other. Golden values from ACE + the decoded math above. Tests:
|
||||||
|
sticky steer/clamp/heading, sticky 1s timeout, constraint taper + IsFullyConstrained
|
||||||
|
90% gate, voyeur subscribe/immediate-snapshot/distance-gate/10s-timeout/exit/teleport,
|
||||||
|
ReceiveUpdate match+heading-fallback, the full W↔T round-trip.
|
||||||
|
- `dotnet build`+`dotnet test` green. Commit. **No behavior change** (nothing wired).
|
||||||
|
|
||||||
|
### R5-V2 — Wire TargetManager per-entity; retire AP-79
|
||||||
|
- Add `TargetManager` to `RemoteMotion` + a player-side owner; construct beside the
|
||||||
|
MoveToManager binds. Repoint MoveToManager's `_setTarget`/`_clearTarget`/quantum
|
||||||
|
seams at `TargetManager.SetTarget`/`ClearTarget`/quantum.
|
||||||
|
- `getObjectA` backed by `_entitiesByServerGuid` (returns a handle to the target's
|
||||||
|
TargetManager). Per-tick: call `TargetManager.HandleTargetting()` in
|
||||||
|
`TickRemoteMoveTo` + the player block, BEFORE `MoveToManager.UseTime()` (retail order).
|
||||||
|
- Delete the AP-79 tracker fields (`TrackedTarget*` / `_playerMoveToTarget*`) and the
|
||||||
|
manual poll blocks — the voyeur round-trip replaces them.
|
||||||
|
- Register: delete AP-79 row same commit. **Visual gate**: a server-directed creature
|
||||||
|
chasing the player still tracks + moves identically (the exact behavior AP-79 drove).
|
||||||
|
|
||||||
|
### R5-V3 — Wire PositionManager (Sticky); retire TS-39; apply mt-0 sticky guid
|
||||||
|
- Add `PositionManager` to each entity. Bind `MoveToManager.StickTo →
|
||||||
|
PositionManager.StickTo`, `MoveToManager.Unstick → PositionManager.UnStick`.
|
||||||
|
- Integrate `PositionManager.adjust_offset` into the per-frame body integration
|
||||||
|
(the composed delta-frame chokepoint — retail's UpdatePositionInternal). This is
|
||||||
|
the load-bearing wiring: sticky steer must ADD to the body's per-tick motion.
|
||||||
|
- Per-tick `PositionManager.UseTime()` AFTER MoveToManager.UseTime (retail order).
|
||||||
|
- Apply the mt-0 wire flags (UpdateMotion.cs already parses them, unconsumed):
|
||||||
|
`0x1 StickToObject` → `stick_to_object(guid)` → `PositionManager.StickTo`;
|
||||||
|
`0x2 StandingLongJump` → `MotionInterpreter.StandingLongJump`.
|
||||||
|
- Register: delete TS-39 row same commit. **Visual gate**: a sticky scenario (server
|
||||||
|
/follow-style sticky moveto or a moving-platform stick) holds the target instead of
|
||||||
|
stopping.
|
||||||
|
|
||||||
|
### R5-V4 (capstone, optional) — MovementManager facade + #164 + head stance dispatch + docs
|
||||||
|
- Collapse per-entity `Motion`+`MoveTo` into a `MovementManager` owner (structural;
|
||||||
|
must keep the 183-case + funnel + moveto suites green UNMODIFIED).
|
||||||
|
- #164: action-replay Autonomous bit (params 0x1000 from per-action autonomy flag,
|
||||||
|
raw 305982) in `MotionInterpreter.MoveToInterpretedState` action loop.
|
||||||
|
- Head stance-change dispatch for mt 6-9 (verification item 1 / the S3 exclusion at
|
||||||
|
`RetailObserverTraceConformanceTests.cs:33`).
|
||||||
|
- Final register/ISSUES/roadmap/memory + successor handoff.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constraint scope (why TS-35 stays open)
|
||||||
|
|
||||||
|
ConstraintManager is the **server-position rubber-band leash**, armed ONLY from
|
||||||
|
`SmartBox::HandleReceivedPosition` (arm on every inbound position packet, anchor =
|
||||||
|
self or server-received position) with two constants from `GetStart/MaxConstraintDistance`
|
||||||
|
(0x0050ebc0 / 0x0050ec10) that are **x87 float returns BN elided — literal values
|
||||||
|
unknown** (need a live cdb read or Ghidra float-return fix). acdream has no SmartBox
|
||||||
|
leash (its position reconciliation is separate), so:
|
||||||
|
- Port the class + wire it into PositionManager (IsFullyConstrained routes through it;
|
||||||
|
adjust_offset participates in the chain) for structural faithfulness.
|
||||||
|
- **Do NOT arm it** (no ConstrainTo call site) — the leash + jump-blocking + the two
|
||||||
|
unknown constants are out of R5 scope. IsFullyConstrained stays false = TS-35's
|
||||||
|
current behavior. Update TS-35's `where` to point at the now-real-but-unarmed
|
||||||
|
ConstraintManager; keep the row OPEN. Filing an issue for the leash port + the two
|
||||||
|
unknown constants.
|
||||||
|
|
||||||
|
## Open verification items (carry into V1 tests / cdb)
|
||||||
|
1. `GetStart/MaxConstraintDistance` x87 return constants — unknown (constraint arming,
|
||||||
|
deferred). File as issue.
|
||||||
|
2. `HandleTargetting`'s 0.5s/10s quanta + `StickyTime` 1.0s / `StickyRadius` 0.3f /
|
||||||
|
sticky speed `×5` + `15.0f` fallback — ACE values; verify against named-retail.
|
||||||
|
3. mt-0 header flags: confirm `0x1`/`0x2` (wire) == decomp `0x100`/`0x200` shift
|
||||||
|
(recon §4 confirms: same bits, 8-bit motionFlags at bit-8 of the unpack header dword).
|
||||||
|
4. `TargetInfo` pass-by-value-vs-ref (ACE's two `// ref?` markers) — retail copy-constructs
|
||||||
|
(`TargetInfo::TargetInfo(©, src)` at every fan-out); port as copy.
|
||||||
|
|
||||||
|
## Do-NOT-re-port (already shipped)
|
||||||
|
MoveToManager (R4, full incl. queue + HandleUpdateTarget consumer), MotionInterpreter
|
||||||
|
(R3, #161 apply-pass), MotionTableManager+GetObjectSequence (R2), CSequence (R1),
|
||||||
|
RemoteWeenie + PhysicsBody.InWorld (R4-V5), InterpolationManager (acdream's remote
|
||||||
|
Interp — not an R5 target).
|
||||||
File diff suppressed because it is too large
Load diff
1256
docs/research/2026-07-03-r5-managers/r5-targetmanager-decomp.md
Normal file
1256
docs/research/2026-07-03-r5-managers/r5-targetmanager-decomp.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -548,8 +548,8 @@ public sealed class GameWindow : IDisposable
|
||||||
/// queue catch-up REPLACES anim when active; anim stands when queue
|
/// queue catch-up REPLACES anim when active; anim stands when queue
|
||||||
/// is idle.
|
/// is idle.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public AcDream.Core.Physics.PositionManager Position { get; } =
|
public AcDream.Core.Physics.RemoteMotionCombiner Position { get; } =
|
||||||
new AcDream.Core.Physics.PositionManager();
|
new AcDream.Core.Physics.RemoteMotionCombiner();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): the
|
/// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): the
|
||||||
|
|
|
||||||
|
|
@ -457,7 +457,7 @@ public sealed class AnimationSequencer
|
||||||
/// <see cref="MotionTableManager.PerformMovement"/>, then (on a
|
/// <see cref="MotionTableManager.PerformMovement"/>, then (on a
|
||||||
/// successful InterpretedCommand) the locomotion velocity synthesis
|
/// successful InterpretedCommand) the locomotion velocity synthesis
|
||||||
/// (register AP-75; the consumers are remote body translation via
|
/// (register AP-75; the consumers are remote body translation via
|
||||||
/// PositionManager.ComputeOffset and the local Option-B
|
/// RemoteMotionCombiner.ComputeOffset and the local Option-B
|
||||||
/// get_state_velocity — retire in R6 when root motion drives the body).
|
/// get_state_velocity — retire in R6 when root motion drives the body).
|
||||||
/// Omega is deliberately NOT synthesized here: remote rotation is the
|
/// Omega is deliberately NOT synthesized here: remote rotation is the
|
||||||
/// ObservedOmega seam (MotionTableDispatchSink callbacks, retire R6);
|
/// ObservedOmega seam (MotionTableDispatchSink callbacks, retire R6);
|
||||||
|
|
|
||||||
120
src/AcDream.Core/Physics/Motion/ConstraintManager.cs
Normal file
120
src/AcDream.Core/Physics/Motion/ConstraintManager.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
using System;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Physics.Motion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R5 — verbatim port of retail's <c>ConstraintManager</c> (acclient.h:31529,
|
||||||
|
/// struct #3467; decomp 0x00556090-0x005562xx,
|
||||||
|
/// <c>r5-constraintmanager-decomp.md</c>). The server-position <b>rubber-band
|
||||||
|
/// leash</b>: while the owning object is in ground contact it progressively
|
||||||
|
/// resists movement past a start→max distance band from a pinned anchor, and
|
||||||
|
/// hard-clamps at the max. Read as a jump gate by
|
||||||
|
/// <c>CMotionInterp::jump_is_allowed</c> (block jump while
|
||||||
|
/// <see cref="IsFullyConstrained"/>).
|
||||||
|
///
|
||||||
|
/// <para><b>Arming is UNPORTED in acdream (R5).</b> Retail arms the leash ONLY
|
||||||
|
/// from <c>SmartBox::HandleReceivedPosition</c> (on every inbound server
|
||||||
|
/// position packet) with two constants from
|
||||||
|
/// <c>CPhysicsObj::GetStart/MaxConstraintDistance</c> whose values BN elided
|
||||||
|
/// (x87 returns — unknown, need a cdb read). acdream's position reconciliation
|
||||||
|
/// is not SmartBox, so nothing calls <see cref="ConstrainTo"/> — the leash
|
||||||
|
/// stays disarmed and <see cref="IsFullyConstrained"/> stays false, matching
|
||||||
|
/// register TS-35's current stub behavior. The class is ported for structural
|
||||||
|
/// completeness of <see cref="PositionManager"/>; the leash-arming port + the
|
||||||
|
/// two unknown constants are a deferred issue (port-plan §Constraint scope).</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConstraintManager
|
||||||
|
{
|
||||||
|
private readonly IPhysicsObjHost _host;
|
||||||
|
|
||||||
|
/// <summary>+0x04 retail <c>is_constrained</c>.</summary>
|
||||||
|
public bool IsConstrained { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>+0x08 retail <c>constraint_pos_offset</c> — recomputed every
|
||||||
|
/// <see cref="AdjustOffset"/> as the LENGTH of that tick's step (NOT the
|
||||||
|
/// distance to the anchor — see the ACE correctness note, port-plan §2b);
|
||||||
|
/// the next tick's contact branch compares it against start/max.</summary>
|
||||||
|
public float ConstraintPosOffset { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>+0x0c retail <c>constraint_pos</c> — the leash anchor. Stored
|
||||||
|
/// by <see cref="ConstrainTo"/>, never read by <see cref="AdjustOffset"/>
|
||||||
|
/// (retail + ACE — write-only in this class).</summary>
|
||||||
|
public Position ConstraintPos { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>+0x48 retail <c>constraint_distance_start</c> — the near edge
|
||||||
|
/// of the brake band.</summary>
|
||||||
|
public float ConstraintDistanceStart { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>+0x4c retail <c>constraint_distance_max</c> — the far edge
|
||||||
|
/// (full clamp).</summary>
|
||||||
|
public float ConstraintDistanceMax { get; private set; }
|
||||||
|
|
||||||
|
public ConstraintManager(IPhysicsObjHost host)
|
||||||
|
=> _host = host ?? throw new ArgumentNullException(nameof(host));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>ConstraintManager::ConstrainTo</c> (0x00556240). Pin the leash
|
||||||
|
/// anchor and band; initialize <see cref="ConstraintPosOffset"/> to the
|
||||||
|
/// CURRENT distance from anchor to the mover (the leash starts already
|
||||||
|
/// extended to wherever the object is, not at zero).
|
||||||
|
/// </summary>
|
||||||
|
public void ConstrainTo(Position anchor, float startDistance, float maxDistance)
|
||||||
|
{
|
||||||
|
IsConstrained = true;
|
||||||
|
ConstraintPos = anchor;
|
||||||
|
ConstraintDistanceStart = startDistance;
|
||||||
|
ConstraintDistanceMax = maxDistance;
|
||||||
|
ConstraintPosOffset = Vector3.Distance(anchor.Frame.Origin, _host.Position.Frame.Origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retail <c>ConstraintManager::UnConstrain</c> (0x005560c0) —
|
||||||
|
/// clears the constrained flag only (leaves the band/anchor/offset stale
|
||||||
|
/// until the next <see cref="ConstrainTo"/>).</summary>
|
||||||
|
public void UnConstrain() => IsConstrained = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>ConstraintManager::IsFullyConstrained</c> (0x005560d0):
|
||||||
|
/// <c>constraint_distance_max * 0.9 < constraint_pos_offset</c> — the
|
||||||
|
/// object counts as fully constrained once it has strained past 90 % of the
|
||||||
|
/// max leash. Read by <c>jump_is_allowed</c> to block jumps. Always false
|
||||||
|
/// while the leash is disarmed (acdream never arms it — see class note).
|
||||||
|
/// </summary>
|
||||||
|
public bool IsFullyConstrained()
|
||||||
|
=> ConstraintDistanceMax * 0.9f < ConstraintPosOffset;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>ConstraintManager::adjust_offset</c> (0x00556180). The last
|
||||||
|
/// stage of <see cref="PositionManager.AdjustOffset"/>'s chain — clamps the
|
||||||
|
/// ALREADY-composed per-tick <paramref name="offset"/> (interp + sticky)
|
||||||
|
/// while grounded, then records its length for the next tick. No-op unless
|
||||||
|
/// <see cref="IsConstrained"/>. See port-plan §2b.
|
||||||
|
/// </summary>
|
||||||
|
public void AdjustOffset(MotionDeltaFrame offset, double quantum)
|
||||||
|
{
|
||||||
|
_ = quantum; // retail body doesn't use the quantum directly.
|
||||||
|
if (!IsConstrained)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_host.InContact) // transient_state & 1 — clamp only while grounded.
|
||||||
|
{
|
||||||
|
if (ConstraintPosOffset < ConstraintDistanceMax)
|
||||||
|
{
|
||||||
|
if (ConstraintPosOffset > ConstraintDistanceStart)
|
||||||
|
{
|
||||||
|
// Linear brake taper: 1.0 just past start → 0.0 at max.
|
||||||
|
float taper = (ConstraintDistanceMax - ConstraintPosOffset)
|
||||||
|
/ (ConstraintDistanceMax - ConstraintDistanceStart);
|
||||||
|
offset.Origin *= taper;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
offset.Origin = Vector3.Zero; // past max — fully pinned.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unconditional (grounded OR airborne): track this tick's step length.
|
||||||
|
ConstraintPosOffset = offset.Origin.Length();
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs
Normal file
100
src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Physics.Motion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R5 seam — the acdream stand-in for retail's <c>CPhysicsObj</c> as seen BY
|
||||||
|
/// its owned managers. Retail's <c>StickyManager</c> / <c>ConstraintManager</c>
|
||||||
|
/// / <c>TargetManager</c> each hold a raw <c>physics_obj</c> pointer and call
|
||||||
|
/// back through it (position/velocity/radius accessors, target-tracking
|
||||||
|
/// registration, the <c>HandleUpdateTarget</c> fan-out) and — for the voyeur
|
||||||
|
/// system — resolve OTHER physics objects via <c>CObjectMaint::GetObjectA</c>
|
||||||
|
/// and drive their <c>add_voyeur</c> / <c>receive_target_update</c> /
|
||||||
|
/// <c>remove_voyeur</c> entry points. This interface is that back-pointer.
|
||||||
|
///
|
||||||
|
/// <para>The App layer implements one host per entity (a remote
|
||||||
|
/// <c>RemoteMotion</c> or the local player), wiring the accessors to the live
|
||||||
|
/// <see cref="PhysicsBody"/> and the <see cref="MoveToManager"/> /
|
||||||
|
/// <see cref="PositionManager"/> / <see cref="TargetManager"/> it owns.
|
||||||
|
/// <see cref="GetObjectA"/> is backed by the App's live entity table
|
||||||
|
/// (<c>_entitiesByServerGuid</c>), giving the voyeur round-trip its
|
||||||
|
/// cross-entity delivery path.</para>
|
||||||
|
/// </summary>
|
||||||
|
public interface IPhysicsObjHost
|
||||||
|
{
|
||||||
|
/// <summary>Retail <c>physics_obj->id</c> — this object's guid.</summary>
|
||||||
|
uint Id { get; }
|
||||||
|
|
||||||
|
/// <summary>Retail <c>physics_obj->m_position</c> — world-space cell +
|
||||||
|
/// frame (acdream seams carry WORLD space; see the MoveToManager binding
|
||||||
|
/// note).</summary>
|
||||||
|
Position Position { get; }
|
||||||
|
|
||||||
|
/// <summary>Retail <c>CPhysicsObj::get_velocity</c>.</summary>
|
||||||
|
Vector3 Velocity { get; }
|
||||||
|
|
||||||
|
/// <summary>Retail <c>CPhysicsObj::GetRadius</c> — the mover's cylinder
|
||||||
|
/// radius.</summary>
|
||||||
|
float Radius { get; }
|
||||||
|
|
||||||
|
/// <summary>Retail <c>physics_obj->transient_state & 1</c> — the
|
||||||
|
/// CONTACT bit (ConstraintManager's grounded gate).</summary>
|
||||||
|
bool InContact { get; }
|
||||||
|
|
||||||
|
/// <summary>Retail <c>CPhysicsObj::get_minterp()->get_max_speed()</c> —
|
||||||
|
/// the mover's max locomotion speed, or <c>null</c> if it has no motion
|
||||||
|
/// interpreter yet (StickyManager falls back to a 15.0 constant).</summary>
|
||||||
|
float? MinterpMaxSpeed { get; }
|
||||||
|
|
||||||
|
/// <summary>Retail <c>Timer::cur_time</c> — the wall/game clock (seconds).
|
||||||
|
/// Drives the sticky 1 s timeout and target 10 s staleness deadlines.</summary>
|
||||||
|
double CurTime { get; }
|
||||||
|
|
||||||
|
/// <summary>Retail <c>PhysicsTimer::curr_time</c> — the physics-tick clock
|
||||||
|
/// (seconds). Drives <c>TargetManager::HandleTargetting</c>'s 0.5 s
|
||||||
|
/// throttle. Retail uses a DIFFERENT clock here than <see cref="CurTime"/>;
|
||||||
|
/// acdream may bind both to the same source.</summary>
|
||||||
|
double PhysicsTimerTime { get; }
|
||||||
|
|
||||||
|
/// <summary>Retail <c>CObjectMaint::GetObjectA(id)</c> — resolve another
|
||||||
|
/// physics object by guid, or <c>null</c> if not currently known/visible.
|
||||||
|
/// The cross-entity seam for the voyeur round-trip and sticky live-target
|
||||||
|
/// resolve.</summary>
|
||||||
|
IPhysicsObjHost? GetObjectA(uint id);
|
||||||
|
|
||||||
|
/// <summary>Retail <c>CPhysicsObj::HandleUpdateTarget</c> — fans a
|
||||||
|
/// <see cref="TargetInfo"/> to this host's <see cref="MoveToManager"/>
|
||||||
|
/// (move-to steering) AND <see cref="PositionManager"/> (sticky follow).
|
||||||
|
/// Called from <see cref="TargetManager.ReceiveUpdate"/> and the timeout
|
||||||
|
/// path.</summary>
|
||||||
|
void HandleUpdateTarget(TargetInfo info);
|
||||||
|
|
||||||
|
/// <summary>Retail <c>CPhysicsObj::interrupt_current_movement</c> →
|
||||||
|
/// <c>MovementManager::CancelMoveTo(0x36)</c>.</summary>
|
||||||
|
void InterruptCurrentMovement();
|
||||||
|
|
||||||
|
/// <summary>Retail <c>CPhysicsObj::set_target(ctx, objId, radius,
|
||||||
|
/// quantum)</c> → <see cref="TargetManager.SetTarget"/> (lazily creating
|
||||||
|
/// the TargetManager). Called by <c>StickyManager::StickTo</c> and
|
||||||
|
/// <c>MoveToManager</c>'s object-move entry points.</summary>
|
||||||
|
void SetTarget(uint contextId, uint objectId, float radius, double quantum);
|
||||||
|
|
||||||
|
/// <summary>Retail <c>CPhysicsObj::clear_target</c> →
|
||||||
|
/// <see cref="TargetManager.ClearTarget"/>.</summary>
|
||||||
|
void ClearTarget();
|
||||||
|
|
||||||
|
/// <summary>Retail <c>CPhysicsObj::receive_target_update</c> →
|
||||||
|
/// <see cref="TargetManager.ReceiveUpdate"/>. The inbound side a SENDER's
|
||||||
|
/// <c>SendVoyeurUpdate</c> tail-calls on the watcher.</summary>
|
||||||
|
void ReceiveTargetUpdate(TargetInfo info);
|
||||||
|
|
||||||
|
/// <summary>Retail <c>CPhysicsObj::add_voyeur(id, radius, quantum)</c> →
|
||||||
|
/// <see cref="TargetManager.AddVoyeur"/> (lazily creating the
|
||||||
|
/// TargetManager). Called on the TARGET when a watcher subscribes.</summary>
|
||||||
|
void AddVoyeur(uint watcherId, float radius, double quantum);
|
||||||
|
|
||||||
|
/// <summary>Retail <c>CPhysicsObj::remove_voyeur(id)</c> →
|
||||||
|
/// <see cref="TargetManager.RemoveVoyeur"/>. Called on the TARGET when a
|
||||||
|
/// watcher unsubscribes.</summary>
|
||||||
|
void RemoveVoyeur(uint watcherId);
|
||||||
|
}
|
||||||
40
src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs
Normal file
40
src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Physics.Motion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mutable stand-in for retail's <c>Frame</c> when it is used as the per-tick
|
||||||
|
/// <b>delta accumulator</b> that <c>PositionManager::adjust_offset</c> and its
|
||||||
|
/// three sub-managers (Interpolation / Sticky / Constraint) write into
|
||||||
|
/// (retail arg2, e.g. <c>StickyManager::adjust_offset</c> 0x00555430,
|
||||||
|
/// <c>ConstraintManager::adjust_offset</c> 0x00556180). acdream's
|
||||||
|
/// <see cref="CellFrame"/> is an immutable record — retail's per-tick math
|
||||||
|
/// mutates <c>m_fOrigin</c> in place across the interp→sticky→constraint chain
|
||||||
|
/// (each sub-manager composes on top of the previous one's write), so the
|
||||||
|
/// accumulator needs a mutable shape.
|
||||||
|
///
|
||||||
|
/// <para><see cref="Origin"/> = retail <c>m_fOrigin</c> (the accumulated
|
||||||
|
/// position delta, in the mover's LOCAL frame after
|
||||||
|
/// <c>Position::globaltolocalvec</c>). <see cref="Orientation"/> carries the
|
||||||
|
/// heading retail's <c>Frame::set_heading</c> writes; read/write it as a
|
||||||
|
/// compass heading via <see cref="GetHeading"/> / <see cref="SetHeading"/>
|
||||||
|
/// (P5 convention, degrees).</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MotionDeltaFrame
|
||||||
|
{
|
||||||
|
/// <summary>Retail <c>m_fOrigin</c> — the accumulated per-tick position
|
||||||
|
/// delta (mover-local frame).</summary>
|
||||||
|
public Vector3 Origin;
|
||||||
|
|
||||||
|
/// <summary>Retail <c>Frame</c> rotation — carries the
|
||||||
|
/// <c>Frame::set_heading</c> output.</summary>
|
||||||
|
public Quaternion Orientation = Quaternion.Identity;
|
||||||
|
|
||||||
|
/// <summary>Retail <c>Frame::get_heading</c> (P5 compass degrees).</summary>
|
||||||
|
public float GetHeading() => MoveToMath.GetHeading(Orientation);
|
||||||
|
|
||||||
|
/// <summary>Retail <c>Frame::set_heading(headingDeg)</c> — pure
|
||||||
|
/// yaw-about-Z setter (P5 compass degrees).</summary>
|
||||||
|
public void SetHeading(float headingDeg) =>
|
||||||
|
Orientation = MoveToMath.SetHeading(Orientation, headingDeg);
|
||||||
|
}
|
||||||
|
|
@ -1558,10 +1558,21 @@ public sealed class MoveToManager
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail <c>TargetInfo</c> (acclient.h:31591, struct #3482) — the callback
|
/// Retail <c>TargetInfo</c> (acclient.h:31591, struct #3482) — the callback
|
||||||
/// payload <see cref="MoveToManager.HandleUpdateTarget"/> consumes. The P4
|
/// payload <see cref="MoveToManager.HandleUpdateTarget"/> consumes AND the wire
|
||||||
/// TargetTracker adapter (App-side, R4-V4 scope) is the ONLY producer;
|
/// record the R5 <see cref="TargetManager"/> voyeur system exchanges between
|
||||||
/// <see cref="MoveToManager"/> itself has no target-tracking machinery of its
|
/// hosts (<c>SendVoyeurUpdate</c> → <c>receive_target_update</c> →
|
||||||
/// own (TargetManager bodies are R5 — decomp §9f, do-not-invent list).
|
/// <see cref="TargetManager.ReceiveUpdate"/>).
|
||||||
|
///
|
||||||
|
/// <para>R5 EXTENDED this from the R4 4-field callback shape to the full retail
|
||||||
|
/// 10-field struct. The extra fields (<see cref="ContextId"/>,
|
||||||
|
/// <see cref="Radius"/>, <see cref="Quantum"/>,
|
||||||
|
/// <see cref="InterpolatedHeading"/>, <see cref="Velocity"/>,
|
||||||
|
/// <see cref="LastUpdateTime"/>) default to zero, so the existing 4-argument
|
||||||
|
/// <c>new TargetInfo(id, status, tp, ip)</c> call sites (MoveToManager tests,
|
||||||
|
/// the AP-79 adapter pre-V2) still compile unchanged.
|
||||||
|
/// <see cref="MoveToManager.HandleUpdateTarget"/> only reads
|
||||||
|
/// <see cref="ObjectId"/>/<see cref="Status"/>/<see cref="InterpolatedPosition"/>;
|
||||||
|
/// the extra fields are consumed by the voyeur system.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ObjectId">Retail <c>object_id</c> — matched against
|
/// <param name="ObjectId">Retail <c>object_id</c> — matched against
|
||||||
/// <see cref="MoveToManager.TopLevelObjectId"/>; a mismatch is silently
|
/// <see cref="MoveToManager.TopLevelObjectId"/>; a mismatch is silently
|
||||||
|
|
@ -1572,11 +1583,30 @@ public sealed class MoveToManager
|
||||||
/// <param name="InterpolatedPosition">Retail <c>interpolated_position</c> —
|
/// <param name="InterpolatedPosition">Retail <c>interpolated_position</c> —
|
||||||
/// the smoothed tracking point <see cref="MoveToManager.MoveToObject_Internal"/>
|
/// the smoothed tracking point <see cref="MoveToManager.MoveToObject_Internal"/>
|
||||||
/// steers toward.</param>
|
/// steers toward.</param>
|
||||||
|
/// <param name="ContextId">Retail <c>context_id</c> — the tracking context
|
||||||
|
/// (0 = the movement context; <c>CPhysicsObj::HandleUpdateTarget</c> only fans
|
||||||
|
/// out context 0).</param>
|
||||||
|
/// <param name="Radius">Retail <c>radius</c> — the voyeur's send-on-move
|
||||||
|
/// threshold (game units).</param>
|
||||||
|
/// <param name="Quantum">Retail <c>quantum</c> — the dead-reckoning lookahead
|
||||||
|
/// horizon (seconds) for <c>GetInterpolatedPosition</c>.</param>
|
||||||
|
/// <param name="InterpolatedHeading">Retail <c>interpolated_heading</c> —
|
||||||
|
/// normalized self→target direction (falls back to +Z when degenerate).</param>
|
||||||
|
/// <param name="Velocity">Retail <c>velocity</c> — the target's velocity at
|
||||||
|
/// send time.</param>
|
||||||
|
/// <param name="LastUpdateTime">Retail <c>last_update_time</c> — receipt
|
||||||
|
/// timestamp (drives the 10 s staleness timeout).</param>
|
||||||
public readonly record struct TargetInfo(
|
public readonly record struct TargetInfo(
|
||||||
uint ObjectId,
|
uint ObjectId,
|
||||||
TargetStatus Status,
|
TargetStatus Status,
|
||||||
Position TargetPosition,
|
Position TargetPosition,
|
||||||
Position InterpolatedPosition);
|
Position InterpolatedPosition,
|
||||||
|
uint ContextId = 0,
|
||||||
|
float Radius = 0f,
|
||||||
|
double Quantum = 0.0,
|
||||||
|
Vector3 InterpolatedHeading = default,
|
||||||
|
Vector3 Velocity = default,
|
||||||
|
double LastUpdateTime = 0.0);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail <c>TargetStatus</c> (acclient.h:7264). Only <see cref="Ok"/> vs
|
/// Retail <c>TargetStatus</c> (acclient.h:7264). Only <see cref="Ok"/> vs
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,58 @@ public static class MoveToMath
|
||||||
return edgeDist > 0f ? edgeDist : 0f;
|
return edgeDist > 0f ? edgeDist : 0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>Position::cylinder_distance_no_z</c> — the <b>signed</b>
|
||||||
|
/// horizontal (X/Y) edge-to-edge distance between two cylinders:
|
||||||
|
/// <c>centerDist − ownRadius − targetRadius</c>. Unlike
|
||||||
|
/// <see cref="CylinderDistance"/> (the arrival-gate variant, which CLAMPS at
|
||||||
|
/// 0), this variant is NOT clamped — overlapping cylinders report a NEGATIVE
|
||||||
|
/// value. <c>StickyManager::adjust_offset</c> (0x00555430) relies on the
|
||||||
|
/// sign: when the follower is inside the desired 0.3 m stick gap the
|
||||||
|
/// distance goes negative, the per-tick delta inverts, and the mover backs
|
||||||
|
/// off to restore the gap (ACE StickyManager.cs:156).
|
||||||
|
/// </summary>
|
||||||
|
public static float CylinderDistanceNoZ(
|
||||||
|
float ownRadius, Vector3 ownPos, float targetRadius, Vector3 targetPos)
|
||||||
|
{
|
||||||
|
float dx = targetPos.X - ownPos.X;
|
||||||
|
float dy = targetPos.Y - ownPos.Y;
|
||||||
|
float centerDist = MathF.Sqrt(dx * dx + dy * dy);
|
||||||
|
return centerDist - ownRadius - targetRadius;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>AC1Legacy::Vector3::normalize_check_small</c> — normalize
|
||||||
|
/// <paramref name="v"/> in place, returning <c>true</c> if the vector was
|
||||||
|
/// too small to normalize (near-zero) and leaving it unchanged in that
|
||||||
|
/// case. Consumed by <c>StickyManager::adjust_offset</c> (don't chase
|
||||||
|
/// jitter when already at the target) and
|
||||||
|
/// <see cref="TargetManager.ReceiveUpdate"/> (interpolated-heading
|
||||||
|
/// fallback). A public shared twin of the private helper in
|
||||||
|
/// <c>ParticleSystem</c>; same 1e-8 near-zero length guard.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns><c>true</c> = too small (left unchanged); <c>false</c> =
|
||||||
|
/// normalized.</returns>
|
||||||
|
public static bool NormalizeCheckSmall(ref Vector3 v)
|
||||||
|
{
|
||||||
|
float length = v.Length();
|
||||||
|
if (length < 1e-8f)
|
||||||
|
return true;
|
||||||
|
v /= length;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>Position::globaltolocalvec</c> — rotate a WORLD-space vector
|
||||||
|
/// into a frame's LOCAL coordinates by the inverse of the frame's
|
||||||
|
/// orientation. Consumed by <c>StickyManager::adjust_offset</c>
|
||||||
|
/// (0x00555430) to express the self→target offset in the mover's own frame
|
||||||
|
/// before flattening Z and steering. Pure rotation (no translation) —
|
||||||
|
/// <paramref name="worldVec"/> is a direction/offset, not a point.
|
||||||
|
/// </summary>
|
||||||
|
public static Vector3 GlobalToLocalVec(Quaternion frameOrientation, Vector3 worldVec)
|
||||||
|
=> Vector3.Transform(worldVec, Quaternion.Conjugate(frameOrientation));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Landblock-local wire origin → world space (verbatim relocation from
|
/// Landblock-local wire origin → world space (verbatim relocation from
|
||||||
/// the deleted <c>RemoteMoveToDriver.OriginToWorld</c>, R4-V4): MoveTo /
|
/// the deleted <c>RemoteMoveToDriver.OriginToWorld</c>, R4-V4): MoveTo /
|
||||||
|
|
|
||||||
102
src/AcDream.Core/Physics/Motion/PositionManager.cs
Normal file
102
src/AcDream.Core/Physics/Motion/PositionManager.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Physics.Motion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R5 — port of retail's <c>PositionManager</c> facade (acclient.h:30952,
|
||||||
|
/// struct #3468; decomp 0x00555160-0x005553d0,
|
||||||
|
/// <c>r5-positionmanager-sticky-decomp.md</c>). A thin fan-out over three
|
||||||
|
/// sub-managers: Interpolation, Sticky, Constraint. Owned 1:1 by the entity's
|
||||||
|
/// <see cref="IPhysicsObjHost"/> (retail <c>CPhysicsObj::position_manager</c>,
|
||||||
|
/// lazily created).
|
||||||
|
///
|
||||||
|
/// <para><b>Interpolation note:</b> retail's <c>adjust_offset</c> chains
|
||||||
|
/// Interpolation → Sticky → Constraint. acdream's interpolation stage lives in
|
||||||
|
/// <see cref="RemoteMotionCombiner"/> (the R5-renamed remote-motion combiner,
|
||||||
|
/// formerly the misnamed <c>Physics.PositionManager</c>) and is NOT chained
|
||||||
|
/// here in V1 — this facade owns only the two R5 targets (Sticky retires TS-39;
|
||||||
|
/// Constraint is structural — see <see cref="ConstraintManager"/>). Folding the
|
||||||
|
/// combiner in as the interp stage is a wiring-slice cleanup.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PositionManager
|
||||||
|
{
|
||||||
|
private readonly IPhysicsObjHost _host;
|
||||||
|
|
||||||
|
// Lazily created (retail: sticky on first StickTo, constraint on first
|
||||||
|
// ConstrainTo — 0x00555230 / 0x00555280).
|
||||||
|
private StickyManager? _sticky;
|
||||||
|
private ConstraintManager? _constraint;
|
||||||
|
|
||||||
|
public PositionManager(IPhysicsObjHost host)
|
||||||
|
=> _host = host ?? throw new ArgumentNullException(nameof(host));
|
||||||
|
|
||||||
|
/// <summary>Exposed for wiring/tests — the lazily-created sub-managers
|
||||||
|
/// (null until first use).</summary>
|
||||||
|
public StickyManager? Sticky => _sticky;
|
||||||
|
public ConstraintManager? Constraint => _constraint;
|
||||||
|
|
||||||
|
/// <summary>Retail <c>PositionManager::StickTo</c> (0x00555230) — lazily
|
||||||
|
/// create the <see cref="StickyManager"/> and begin following
|
||||||
|
/// <paramref name="objectId"/>.</summary>
|
||||||
|
public void StickTo(uint objectId, float radius, float height)
|
||||||
|
{
|
||||||
|
_sticky ??= new StickyManager(_host);
|
||||||
|
_sticky.StickTo(objectId, radius, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retail <c>PositionManager::UnStick</c> (0x005551e0) — forward
|
||||||
|
/// to the sticky sub-manager if it exists.</summary>
|
||||||
|
public void UnStick() => _sticky?.UnStick();
|
||||||
|
|
||||||
|
/// <summary>Retail <c>PositionManager::GetStickyObjectID</c>
|
||||||
|
/// (0x00555270).</summary>
|
||||||
|
public uint GetStickyObjectId() => _sticky?.TargetId ?? 0u;
|
||||||
|
|
||||||
|
/// <summary>Retail <c>PositionManager::ConstrainTo</c> (0x00555280) —
|
||||||
|
/// lazily create the <see cref="ConstraintManager"/> and arm the leash.
|
||||||
|
/// (Unused in acdream — no arming call site; see
|
||||||
|
/// <see cref="ConstraintManager"/>.)</summary>
|
||||||
|
public void ConstrainTo(Position anchor, float startDistance, float maxDistance)
|
||||||
|
{
|
||||||
|
_constraint ??= new ConstraintManager(_host);
|
||||||
|
_constraint.ConstrainTo(anchor, startDistance, maxDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retail <c>PositionManager::UnConstrain</c>
|
||||||
|
/// (0x005552b0).</summary>
|
||||||
|
public void UnConstrain() => _constraint?.UnConstrain();
|
||||||
|
|
||||||
|
/// <summary>Retail <c>PositionManager::IsFullyConstrained</c>
|
||||||
|
/// (0x005552c0) — false when no constraint sub-manager exists.</summary>
|
||||||
|
public bool IsFullyConstrained() => _constraint?.IsFullyConstrained() ?? false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>PositionManager::HandleUpdateTarget</c> (0x005553d0) — only
|
||||||
|
/// the sticky sub-manager cares about live target positions (interpolation
|
||||||
|
/// and constraint don't). Fanned out from
|
||||||
|
/// <c>CPhysicsObj::HandleUpdateTarget</c>.
|
||||||
|
/// </summary>
|
||||||
|
public void HandleUpdateTarget(TargetInfo info) => _sticky?.HandleUpdateTarget(info);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>PositionManager::adjust_offset</c> (0x00555190) — chains the
|
||||||
|
/// sub-managers' contributions into the SAME <paramref name="offset"/>
|
||||||
|
/// accumulator, in retail order. Retail runs Interpolation → Sticky →
|
||||||
|
/// Constraint; acdream's interpolation stays in the separate remote-motion
|
||||||
|
/// combiner (see class note), so this chains Sticky → Constraint only.
|
||||||
|
/// Constraint is LAST because it clamps the already-composed displacement.
|
||||||
|
/// </summary>
|
||||||
|
public void AdjustOffset(MotionDeltaFrame offset, double quantum)
|
||||||
|
{
|
||||||
|
_sticky?.AdjustOffset(offset, quantum);
|
||||||
|
_constraint?.AdjustOffset(offset, quantum);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>PositionManager::UseTime</c> (0x00555160) — per-tick pump.
|
||||||
|
/// Retail order Interpolation → Constraint → Sticky; acdream runs the two
|
||||||
|
/// owned managers (constraint's UseTime is a retail no-op, so effectively
|
||||||
|
/// just the sticky 1 s watchdog).
|
||||||
|
/// </summary>
|
||||||
|
public void UseTime() => _sticky?.UseTime();
|
||||||
|
}
|
||||||
235
src/AcDream.Core/Physics/Motion/StickyManager.cs
Normal file
235
src/AcDream.Core/Physics/Motion/StickyManager.cs
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
using System;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Physics.Motion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R5 — verbatim port of retail's <c>StickyManager</c> (acclient.h:31518,
|
||||||
|
/// struct #3466; decomp block 0x00555400-0x00555866,
|
||||||
|
/// <c>r5-positionmanager-sticky-decomp.md</c>). Makes the owning object
|
||||||
|
/// FOLLOW a target object at a bounded gap: each tick
|
||||||
|
/// <see cref="AdjustOffset"/> steers the mover horizontally toward the
|
||||||
|
/// target's live (or last-known) position and turns it to face the target,
|
||||||
|
/// speed- and turn-rate-limited. A 1-second watchdog (<see cref="UseTime"/>)
|
||||||
|
/// drops the stick if no target-position update arrives.
|
||||||
|
///
|
||||||
|
/// <para>Owned by <see cref="PositionManager"/> (lazily created on first
|
||||||
|
/// <see cref="PositionManager.StickTo"/>). Establishes its target-tracking
|
||||||
|
/// subscription through the owning <see cref="IPhysicsObjHost"/>'s
|
||||||
|
/// <c>set_target</c> (→ <see cref="TargetManager"/>); receives the target's
|
||||||
|
/// live position back through <see cref="HandleUpdateTarget"/>, fanned out from
|
||||||
|
/// <c>CPhysicsObj::HandleUpdateTarget</c> → <see cref="PositionManager"/>.</para>
|
||||||
|
///
|
||||||
|
/// <para>The dense x87 back half of retail's <c>adjust_offset</c> is decoded
|
||||||
|
/// against ACE's <c>StickyManager.cs</c> (the two constants
|
||||||
|
/// <see cref="StickyRadius"/>=0.3 and <see cref="StickyTime"/>=1.0 are ACE's,
|
||||||
|
/// verified against the retail mush structure — see the port-plan §2a).</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StickyManager
|
||||||
|
{
|
||||||
|
/// <summary>Retail <c>StickyRadius</c> const (ACE: 0.3f) — the desired
|
||||||
|
/// follow gap subtracted from the cylinder distance.</summary>
|
||||||
|
public const float StickyRadius = 0.3f;
|
||||||
|
|
||||||
|
/// <summary>Retail <c>StickyTime</c> const (ACE: 1.0f) — the one-shot grace
|
||||||
|
/// window: if no target update refreshes the stick within this many
|
||||||
|
/// seconds of <see cref="StickTo"/>, <see cref="UseTime"/> drops it.</summary>
|
||||||
|
public const float StickyTime = 1.0f;
|
||||||
|
|
||||||
|
/// <summary>Retail <c>get_max_speed</c> multiplier for the follow speed
|
||||||
|
/// (ACE: ×5 — the follower catches up faster than a normal walk/run).</summary>
|
||||||
|
private const float FollowSpeedFactor = 5.0f;
|
||||||
|
|
||||||
|
/// <summary>Retail fallback follow speed when no motion interpreter exists
|
||||||
|
/// (ACE: 15.0f, i.e. the <c>MAX_VELOCITY</c> constant the mush loads).</summary>
|
||||||
|
private const float FallbackFollowSpeed = 15.0f;
|
||||||
|
|
||||||
|
private readonly IPhysicsObjHost _host;
|
||||||
|
|
||||||
|
/// <summary>+0x00 retail <c>target_id</c> — the object we are stuck to
|
||||||
|
/// (0 = not stuck).</summary>
|
||||||
|
public uint TargetId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>+0x04 retail <c>target_radius</c> — the target's cylinder
|
||||||
|
/// radius (from <c>CPartArray::GetRadius</c> of the stuck-to object).</summary>
|
||||||
|
public float TargetRadius { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>+0x08 retail <c>target_position</c> — last-known target
|
||||||
|
/// position (from <see cref="HandleUpdateTarget"/>), used when the live
|
||||||
|
/// <c>GetObjectA</c> resolve fails.</summary>
|
||||||
|
public Position TargetPosition { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>+0x54 retail <c>initialized</c> — false until the first
|
||||||
|
/// <c>Ok</c> target update arrives (gates <see cref="AdjustOffset"/> and
|
||||||
|
/// the <see cref="UseTime"/> timeout).</summary>
|
||||||
|
public bool Initialized { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>+0x58 retail <c>sticky_timeout_time</c> — the wall-clock
|
||||||
|
/// deadline set once at <see cref="StickTo"/> time (now + 1 s).</summary>
|
||||||
|
public double StickyTimeoutTime { get; private set; }
|
||||||
|
|
||||||
|
public StickyManager(IPhysicsObjHost host)
|
||||||
|
=> _host = host ?? throw new ArgumentNullException(nameof(host));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>StickyManager::UnStick</c> (0x00555400). No-op if not stuck;
|
||||||
|
/// otherwise the standard 4-step teardown: clear <see cref="TargetId"/> +
|
||||||
|
/// <see cref="Initialized"/>, tell the host to <c>clear_target</c> (drop
|
||||||
|
/// the voyeur subscription), then <c>interrupt_current_movement</c>.
|
||||||
|
/// </summary>
|
||||||
|
public void UnStick()
|
||||||
|
{
|
||||||
|
if (TargetId == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
TargetId = 0;
|
||||||
|
Initialized = false;
|
||||||
|
_host.ClearTarget();
|
||||||
|
_host.InterruptCurrentMovement();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>StickyManager::StickTo</c> (0x00555710). Begin following
|
||||||
|
/// <paramref name="objectId"/>. If already stuck, tears the old stick down
|
||||||
|
/// first (same 4-step sequence as <see cref="UnStick"/>). Sets the 1 s
|
||||||
|
/// timeout deadline and registers as a voyeur of the target via the host's
|
||||||
|
/// <c>set_target(context=0, objectId, radius=0.5, quantum=0.5)</c> — the
|
||||||
|
/// live target position then arrives asynchronously through
|
||||||
|
/// <see cref="HandleUpdateTarget"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="objectId">Retail <c>arg2</c> — target object id.</param>
|
||||||
|
/// <param name="targetRadius">Retail <c>arg3</c> — the target's cylinder
|
||||||
|
/// radius (feeds <see cref="AdjustOffset"/>'s distance math).</param>
|
||||||
|
/// <param name="targetHeight">Retail <c>arg4</c> — accepted for call-shape
|
||||||
|
/// parity but UNUSED in the body (matches retail + ACE; the height feeds
|
||||||
|
/// the caller-side geometry only).</param>
|
||||||
|
public void StickTo(uint objectId, float targetRadius, float targetHeight)
|
||||||
|
{
|
||||||
|
_ = targetHeight; // retail/ACE: arg4 is read nowhere in this body.
|
||||||
|
|
||||||
|
if (TargetId != 0)
|
||||||
|
{
|
||||||
|
// Inlined 4-step teardown of the previous stick (retail 0x00555716).
|
||||||
|
TargetId = 0;
|
||||||
|
Initialized = false;
|
||||||
|
_host.ClearTarget();
|
||||||
|
_host.InterruptCurrentMovement();
|
||||||
|
}
|
||||||
|
|
||||||
|
TargetRadius = targetRadius;
|
||||||
|
TargetId = objectId;
|
||||||
|
Initialized = false;
|
||||||
|
StickyTimeoutTime = _host.CurTime + StickyTime;
|
||||||
|
// set_target(context_id=0, objectId, radius=0.5, quantum=0.5).
|
||||||
|
_host.SetTarget(0, objectId, 0.5f, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>StickyManager::UseTime</c> (0x00555610). The 1 s watchdog: if
|
||||||
|
/// <c>Timer::cur_time >= sticky_timeout_time</c>, force-unstick (same
|
||||||
|
/// 4-step teardown). The deadline is set once in <see cref="StickTo"/> and
|
||||||
|
/// NOT refreshed by <see cref="HandleUpdateTarget"/> (retail + ACE) — a
|
||||||
|
/// stick survives at most 1 s of wall-clock unless re-issued.
|
||||||
|
/// </summary>
|
||||||
|
public void UseTime()
|
||||||
|
{
|
||||||
|
if (TargetId == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_host.CurTime >= StickyTimeoutTime)
|
||||||
|
{
|
||||||
|
TargetId = 0;
|
||||||
|
Initialized = false;
|
||||||
|
_host.ClearTarget();
|
||||||
|
_host.InterruptCurrentMovement();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>StickyManager::HandleUpdateTarget</c> (0x00555780). The
|
||||||
|
/// target-position callback (fanned out from
|
||||||
|
/// <c>CPhysicsObj::HandleUpdateTarget</c> → <see cref="PositionManager"/>).
|
||||||
|
/// Ignores updates whose <see cref="TargetInfo.ObjectId"/> doesn't match
|
||||||
|
/// our <see cref="TargetId"/>. On <see cref="TargetStatus.Ok"/>: cache the
|
||||||
|
/// target position and mark <see cref="Initialized"/>. On any other status
|
||||||
|
/// (lost/exit/teleport): tear the stick down (4-step).
|
||||||
|
/// </summary>
|
||||||
|
public void HandleUpdateTarget(TargetInfo info)
|
||||||
|
{
|
||||||
|
if (info.ObjectId != TargetId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (info.Status == TargetStatus.Ok)
|
||||||
|
{
|
||||||
|
Initialized = true;
|
||||||
|
TargetPosition = info.TargetPosition;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TargetId != 0)
|
||||||
|
{
|
||||||
|
TargetId = 0;
|
||||||
|
Initialized = false;
|
||||||
|
_host.ClearTarget();
|
||||||
|
_host.InterruptCurrentMovement();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>StickyManager::adjust_offset</c> (0x00555430). Writes this
|
||||||
|
/// tick's follow steering into the shared <paramref name="offset"/>
|
||||||
|
/// accumulator: a speed-clamped horizontal position delta toward the
|
||||||
|
/// target plus a bounded turn to face it. No-op unless stuck AND
|
||||||
|
/// initialized. See port-plan §2a for the x87-mush decode.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="offset">The per-tick delta frame
|
||||||
|
/// (<see cref="PositionManager.AdjustOffset"/>'s shared accumulator).</param>
|
||||||
|
/// <param name="quantum">Elapsed time this tick, seconds.</param>
|
||||||
|
public void AdjustOffset(MotionDeltaFrame offset, double quantum)
|
||||||
|
{
|
||||||
|
if (TargetId == 0 || !Initialized)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var self = _host.Position;
|
||||||
|
var target = _host.GetObjectA(TargetId);
|
||||||
|
var targetPos = target != null ? target.Position : TargetPosition;
|
||||||
|
|
||||||
|
// offset = local-frame, Z-flattened vector from self to target.
|
||||||
|
Vector3 worldOffset = targetPos.Frame.Origin - self.Frame.Origin; // Position::get_offset
|
||||||
|
Vector3 local = MoveToMath.GlobalToLocalVec(self.Frame.Orientation, worldOffset);
|
||||||
|
local.Z = 0f;
|
||||||
|
offset.Origin = local;
|
||||||
|
|
||||||
|
// Signed horizontal cylinder distance past the 0.3 m stick gap.
|
||||||
|
float dist = MoveToMath.CylinderDistanceNoZ(
|
||||||
|
_host.Radius, self.Frame.Origin, TargetRadius, targetPos.Frame.Origin) - StickyRadius;
|
||||||
|
|
||||||
|
if (MoveToMath.NormalizeCheckSmall(ref offset.Origin))
|
||||||
|
offset.Origin = Vector3.Zero;
|
||||||
|
|
||||||
|
// Follow speed = 5× own max locomotion speed (catch up), fallback 15.
|
||||||
|
float speed = 0f;
|
||||||
|
float? maxSpeed = _host.MinterpMaxSpeed;
|
||||||
|
if (maxSpeed.HasValue)
|
||||||
|
speed = maxSpeed.Value * FollowSpeedFactor;
|
||||||
|
if (speed < MoveToMath.Epsilon)
|
||||||
|
speed = FallbackFollowSpeed;
|
||||||
|
|
||||||
|
// Don't overshoot: clamp the per-tick step to the remaining (signed)
|
||||||
|
// distance — a negative dist inverts the direction (back off).
|
||||||
|
float delta = speed * (float)quantum;
|
||||||
|
if (delta >= MathF.Abs(dist))
|
||||||
|
delta = dist;
|
||||||
|
offset.Origin *= delta;
|
||||||
|
|
||||||
|
// Bounded turn to face the target (relative heading this tick).
|
||||||
|
float curHeading = MoveToMath.GetHeading(self.Frame.Orientation);
|
||||||
|
float targetHeading = MoveToMath.PositionHeading(self.Frame.Origin, targetPos.Frame.Origin);
|
||||||
|
float heading = targetHeading - curHeading;
|
||||||
|
if (MathF.Abs(heading) < MoveToMath.Epsilon)
|
||||||
|
heading = 0f;
|
||||||
|
if (heading < -MoveToMath.Epsilon)
|
||||||
|
heading += 360f;
|
||||||
|
offset.SetHeading(heading);
|
||||||
|
}
|
||||||
|
}
|
||||||
300
src/AcDream.Core/Physics/Motion/TargetManager.cs
Normal file
300
src/AcDream.Core/Physics/Motion/TargetManager.cs
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Physics.Motion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R5 — port of retail's <c>TargetManager</c> (acclient.h:31024, struct #3484;
|
||||||
|
/// decomp 0x0051a370-0x0051ad90, <c>r5-targetmanager-decomp.md</c>). A
|
||||||
|
/// peer-to-peer <b>voyeur subscription</b> system with two roles per object:
|
||||||
|
///
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><b>Watcher</b> (<see cref="TargetInfo"/>): <see cref="SetTarget"/>
|
||||||
|
/// registers this object as a voyeur ON a target; the target's per-tick
|
||||||
|
/// <see cref="HandleTargetting"/> pushes position updates back here via
|
||||||
|
/// <see cref="ReceiveUpdate"/>, which fans them to the owning host's
|
||||||
|
/// MoveToManager + PositionManager (sticky) through
|
||||||
|
/// <see cref="IPhysicsObjHost.HandleUpdateTarget"/>.</item>
|
||||||
|
/// <item><b>Watched</b> (<see cref="VoyeurTable"/>): other objects'
|
||||||
|
/// <see cref="AddVoyeur"/> subscribe to THIS object; each tick
|
||||||
|
/// <see cref="HandleTargetting"/> → <see cref="CheckAndUpdateVoyeur"/> sends a
|
||||||
|
/// dead-reckoned update to any subscriber the object has drifted past the
|
||||||
|
/// subscriber's radius from.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>This REPLACES the AP-79 minimal TargetTracker adapter (GameWindow
|
||||||
|
/// polling the entity table). It is a faithful superset: the same
|
||||||
|
/// move-to tracking (distance > radius → HandleUpdateTarget(Ok)) plus the
|
||||||
|
/// correct sticky, 10 s timeout, and exit/teleport event handling.</para>
|
||||||
|
///
|
||||||
|
/// <para>Owned 1:1 by an <see cref="IPhysicsObjHost"/> (retail
|
||||||
|
/// <c>CPhysicsObj::target_manager</c>, lazily created on first
|
||||||
|
/// <c>set_target</c>/<c>add_voyeur</c>). The two throttle constants
|
||||||
|
/// (<see cref="ThrottleSeconds"/>=0.5, <see cref="StalenessSeconds"/>=10) are
|
||||||
|
/// ACE's, verified against the retail x87 mush — port-plan §2d.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TargetManager
|
||||||
|
{
|
||||||
|
/// <summary>Retail <c>HandleTargetting</c> per-tick throttle (ACE: 0.5s) —
|
||||||
|
/// the voyeur sweep runs at most this often.</summary>
|
||||||
|
public const double ThrottleSeconds = 0.5;
|
||||||
|
|
||||||
|
/// <summary>Retail target-info staleness timeout (ACE: 10.0s) — an
|
||||||
|
/// Undefined-status target with no update for this long is marked
|
||||||
|
/// TimedOut.</summary>
|
||||||
|
public const double StalenessSeconds = 10.0;
|
||||||
|
|
||||||
|
private readonly IPhysicsObjHost _host;
|
||||||
|
|
||||||
|
private TargetInfo? _targetInfo; // retail target_info (watcher role)
|
||||||
|
private Dictionary<uint, TargettedVoyeurInfo>? _voyeurTable; // retail voyeur_table (watched role)
|
||||||
|
private double _lastUpdateTime; // retail last_update_time (throttle base)
|
||||||
|
|
||||||
|
public TargetManager(IPhysicsObjHost host)
|
||||||
|
=> _host = host ?? throw new ArgumentNullException(nameof(host));
|
||||||
|
|
||||||
|
/// <summary>The current watched-target info, or null when tracking
|
||||||
|
/// nothing.</summary>
|
||||||
|
public TargetInfo? TargetInfo => _targetInfo;
|
||||||
|
|
||||||
|
/// <summary>The subscriber table (null until the first
|
||||||
|
/// <see cref="AddVoyeur"/>).</summary>
|
||||||
|
public IReadOnlyDictionary<uint, TargettedVoyeurInfo>? VoyeurTable => _voyeurTable;
|
||||||
|
|
||||||
|
/// <summary>Retail <c>get_target_quantum</c> — the current target's
|
||||||
|
/// quantum, 0 when tracking nothing.</summary>
|
||||||
|
public double GetTargetQuantum() => _targetInfo?.Quantum ?? 0.0;
|
||||||
|
|
||||||
|
// ── watcher role ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>TargetManager::SetTarget</c> (0x0051ac30). Tear down any
|
||||||
|
/// existing target, then: if <paramref name="objectId"/> is 0, synthesize a
|
||||||
|
/// TimedOut clear-update to the host and leave <see cref="TargetInfo"/>
|
||||||
|
/// null; otherwise create a fresh <see cref="TargetInfo"/> (status
|
||||||
|
/// Undefined) and subscribe as a voyeur ON the target
|
||||||
|
/// (<c>target.add_voyeur(self.id, radius, quantum)</c>). The target's live
|
||||||
|
/// position arrives asynchronously via <see cref="ReceiveUpdate"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
|
||||||
|
{
|
||||||
|
ClearTarget();
|
||||||
|
|
||||||
|
if (objectId == 0)
|
||||||
|
{
|
||||||
|
// Clear/cancel: report a TimedOut update carrying the context,
|
||||||
|
// leave _targetInfo null. (Retail var_10_1 = 6 = TimedOut.)
|
||||||
|
var cleared = new TargetInfo(
|
||||||
|
ObjectId: 0, Status: TargetStatus.TimedOut,
|
||||||
|
TargetPosition: default, InterpolatedPosition: default,
|
||||||
|
ContextId: contextId);
|
||||||
|
_host.HandleUpdateTarget(cleared);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_targetInfo = new TargetInfo(
|
||||||
|
ObjectId: objectId, Status: TargetStatus.Undefined,
|
||||||
|
TargetPosition: default, InterpolatedPosition: default,
|
||||||
|
ContextId: contextId, Radius: radius, Quantum: quantum,
|
||||||
|
LastUpdateTime: _host.CurTime);
|
||||||
|
|
||||||
|
var target = _host.GetObjectA(objectId);
|
||||||
|
target?.AddVoyeur(_host.Id, radius, quantum);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>TargetManager::SetTargetQuantum</c> (0x0051a4a0). Update the
|
||||||
|
/// current target's resend interval and re-register the voyeur subscription
|
||||||
|
/// on the target with the new quantum.
|
||||||
|
/// </summary>
|
||||||
|
public void SetTargetQuantum(double quantum)
|
||||||
|
{
|
||||||
|
if (_targetInfo is not { } ti)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_targetInfo = ti with { Quantum = quantum };
|
||||||
|
var target = _host.GetObjectA(ti.ObjectId);
|
||||||
|
target?.AddVoyeur(_host.Id, ti.Radius, quantum);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>TargetManager::ClearTarget</c> (0x0051a7e0). Unsubscribe from
|
||||||
|
/// the current target's voyeur table and drop <see cref="TargetInfo"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void ClearTarget()
|
||||||
|
{
|
||||||
|
if (_targetInfo is not { } ti)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var target = _host.GetObjectA(ti.ObjectId);
|
||||||
|
target?.RemoveVoyeur(_host.Id);
|
||||||
|
_targetInfo = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>TargetManager::ReceiveUpdate</c> (0x0051a930). The inbound
|
||||||
|
/// handler when a target we watch sends us its position (via
|
||||||
|
/// <c>SendVoyeurUpdate</c> → <c>receive_target_update</c>). Ignores updates
|
||||||
|
/// for anything but our current target. Copies the payload, stamps receipt
|
||||||
|
/// time, recomputes the self→target interpolated heading (falls back to +Z
|
||||||
|
/// when degenerate), fans the snapshot to the host, and drops the
|
||||||
|
/// subscription on an ExitWorld status.
|
||||||
|
/// </summary>
|
||||||
|
public void ReceiveUpdate(TargetInfo update)
|
||||||
|
{
|
||||||
|
if (_targetInfo is not { } ti || ti.ObjectId != update.ObjectId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Copy radius/quantum/positions/velocity/status from the wire; keep our
|
||||||
|
// object_id; stamp receipt time.
|
||||||
|
Vector3 interpHeading = update.InterpolatedPosition.Frame.Origin
|
||||||
|
- _host.Position.Frame.Origin;
|
||||||
|
if (MoveToMath.NormalizeCheckSmall(ref interpHeading))
|
||||||
|
interpHeading = Vector3.UnitZ;
|
||||||
|
|
||||||
|
var updated = ti with
|
||||||
|
{
|
||||||
|
Radius = update.Radius,
|
||||||
|
Quantum = update.Quantum,
|
||||||
|
TargetPosition = update.TargetPosition,
|
||||||
|
InterpolatedPosition = update.InterpolatedPosition,
|
||||||
|
Velocity = update.Velocity,
|
||||||
|
Status = update.Status,
|
||||||
|
InterpolatedHeading = interpHeading,
|
||||||
|
LastUpdateTime = _host.CurTime,
|
||||||
|
};
|
||||||
|
_targetInfo = updated;
|
||||||
|
|
||||||
|
_host.HandleUpdateTarget(updated);
|
||||||
|
|
||||||
|
if (update.Status == TargetStatus.ExitWorld)
|
||||||
|
ClearTarget();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── watched role ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>TargetManager::AddVoyeur</c> (0x0051a830). A subscriber
|
||||||
|
/// registers to watch this object. If already subscribed, updates its
|
||||||
|
/// radius/quantum in place (no immediate send); otherwise creates the entry
|
||||||
|
/// and pushes an immediate initial snapshot (<c>Ok</c>).
|
||||||
|
/// </summary>
|
||||||
|
public void AddVoyeur(uint watcherId, float radius, double quantum)
|
||||||
|
{
|
||||||
|
_voyeurTable ??= new Dictionary<uint, TargettedVoyeurInfo>();
|
||||||
|
|
||||||
|
if (_voyeurTable.TryGetValue(watcherId, out var existing))
|
||||||
|
{
|
||||||
|
existing.Radius = radius;
|
||||||
|
existing.Quantum = quantum;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var voyeur = new TargettedVoyeurInfo(watcherId, radius, quantum);
|
||||||
|
_voyeurTable[watcherId] = voyeur;
|
||||||
|
SendVoyeurUpdate(voyeur, _host.Position, TargetStatus.Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retail <c>TargetManager::RemoveVoyeur</c> (0x0051ad90).</summary>
|
||||||
|
public bool RemoveVoyeur(uint watcherId)
|
||||||
|
=> _voyeurTable?.Remove(watcherId) ?? false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>TargetManager::HandleTargetting</c> (0x0051aa90). THE per-tick
|
||||||
|
/// driver (no separate <c>UseTime</c>): self-throttled to
|
||||||
|
/// <see cref="ThrottleSeconds"/>, promotes a stale target to TimedOut after
|
||||||
|
/// <see cref="StalenessSeconds"/>, then sweeps every subscriber through
|
||||||
|
/// <see cref="CheckAndUpdateVoyeur"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void HandleTargetting()
|
||||||
|
{
|
||||||
|
if (_host.PhysicsTimerTime - _lastUpdateTime < ThrottleSeconds)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_targetInfo is { } ti)
|
||||||
|
{
|
||||||
|
if (ti.Status == TargetStatus.Undefined
|
||||||
|
&& ti.LastUpdateTime + StalenessSeconds < _host.CurTime)
|
||||||
|
{
|
||||||
|
var timedOut = ti with { Status = TargetStatus.TimedOut };
|
||||||
|
_targetInfo = timedOut;
|
||||||
|
_host.HandleUpdateTarget(timedOut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_voyeurTable != null)
|
||||||
|
{
|
||||||
|
foreach (var voyeur in _voyeurTable.Values.ToList())
|
||||||
|
CheckAndUpdateVoyeur(voyeur);
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastUpdateTime = _host.PhysicsTimerTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>TargetManager::CheckAndUpdateVoyeur</c> (0x0051a650). Push an
|
||||||
|
/// update to <paramref name="voyeur"/> only if this object's dead-reckoned
|
||||||
|
/// position has drifted more than the voyeur's radius since the last send.
|
||||||
|
/// </summary>
|
||||||
|
public void CheckAndUpdateVoyeur(TargettedVoyeurInfo voyeur)
|
||||||
|
{
|
||||||
|
Position newPos = GetInterpolatedPosition(voyeur.Quantum);
|
||||||
|
float drift = Vector3.Distance(
|
||||||
|
newPos.Frame.Origin, voyeur.LastSentPosition.Frame.Origin);
|
||||||
|
if (drift > voyeur.Radius)
|
||||||
|
SendVoyeurUpdate(voyeur, newPos, TargetStatus.Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>TargetManager::GetInterpolatedPosition</c> (0x0051a5e0).
|
||||||
|
/// Dead-reckon this object's position forward by <paramref name="quantum"/>
|
||||||
|
/// seconds using its current velocity.
|
||||||
|
/// </summary>
|
||||||
|
public Position GetInterpolatedPosition(double quantum)
|
||||||
|
{
|
||||||
|
var pos = _host.Position;
|
||||||
|
Vector3 origin = pos.Frame.Origin + _host.Velocity * (float)quantum;
|
||||||
|
return new Position(pos.ObjCellId, origin, pos.Frame.Orientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>TargetManager::SendVoyeurUpdate</c> (0x0051a4f0). Record the
|
||||||
|
/// sent position on the voyeur, build a <see cref="TargetInfo"/> carrying
|
||||||
|
/// this object's CURRENT authoritative position + the extrapolated
|
||||||
|
/// <paramref name="pos"/> + velocity + status, and deliver it to the
|
||||||
|
/// subscriber's <see cref="ReceiveUpdate"/> (via its host).
|
||||||
|
/// </summary>
|
||||||
|
public void SendVoyeurUpdate(TargettedVoyeurInfo voyeur, Position pos, TargetStatus status)
|
||||||
|
{
|
||||||
|
voyeur.LastSentPosition = pos;
|
||||||
|
|
||||||
|
var info = new TargetInfo(
|
||||||
|
ObjectId: _host.Id,
|
||||||
|
Status: status,
|
||||||
|
TargetPosition: _host.Position, // current authoritative
|
||||||
|
InterpolatedPosition: pos, // the extrapolated position
|
||||||
|
ContextId: 0,
|
||||||
|
Radius: voyeur.Radius,
|
||||||
|
Quantum: voyeur.Quantum,
|
||||||
|
Velocity: _host.Velocity);
|
||||||
|
|
||||||
|
var voyeurObj = _host.GetObjectA(voyeur.ObjectId);
|
||||||
|
voyeurObj?.ReceiveTargetUpdate(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>TargetManager::NotifyVoyeurOfEvent</c> (0x0051a6f0). Broadcast
|
||||||
|
/// a discrete status event (e.g. ExitWorld, Teleported) to every subscriber
|
||||||
|
/// with this object's CURRENT position — no distance gate.
|
||||||
|
/// </summary>
|
||||||
|
public void NotifyVoyeurOfEvent(TargetStatus status)
|
||||||
|
{
|
||||||
|
if (_voyeurTable == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var voyeur in _voyeurTable.Values.ToList())
|
||||||
|
SendVoyeurUpdate(voyeur, _host.Position, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs
Normal file
36
src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
namespace AcDream.Core.Physics.Motion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R5 — port of retail's <c>TargettedVoyeurInfo</c> (acclient.h:52807,
|
||||||
|
/// struct #5801). One entry in a <see cref="TargetManager"/>'s voyeur table:
|
||||||
|
/// a subscriber watching THIS object, the send-on-move <see cref="Radius"/>
|
||||||
|
/// threshold it registered, its dead-reckoning <see cref="Quantum"/>, and the
|
||||||
|
/// <see cref="LastSentPosition"/> already delivered to it (the delta baseline
|
||||||
|
/// <c>CheckAndUpdateVoyeur</c> compares against). Mutable class (retail heap
|
||||||
|
/// record updated in place by <c>AddVoyeur</c>/<c>SendVoyeurUpdate</c>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TargettedVoyeurInfo
|
||||||
|
{
|
||||||
|
/// <summary>+0x00 retail <c>object_id</c> — the subscriber's guid.</summary>
|
||||||
|
public uint ObjectId { get; }
|
||||||
|
|
||||||
|
/// <summary>+0x04 retail <c>quantum</c> — the subscriber's dead-reckoning
|
||||||
|
/// lookahead horizon (seconds).</summary>
|
||||||
|
public double Quantum { get; set; }
|
||||||
|
|
||||||
|
/// <summary>+0x10 retail <c>radius</c> — the send-on-move threshold: an
|
||||||
|
/// update is pushed only when the tracked object drifts more than this from
|
||||||
|
/// <see cref="LastSentPosition"/>.</summary>
|
||||||
|
public float Radius { get; set; }
|
||||||
|
|
||||||
|
/// <summary>+0x14 retail <c>last_sent_position</c> — the position last
|
||||||
|
/// delivered to this subscriber (updated by <c>SendVoyeurUpdate</c>).</summary>
|
||||||
|
public Position LastSentPosition { get; set; }
|
||||||
|
|
||||||
|
public TargettedVoyeurInfo(uint objectId, float radius, double quantum)
|
||||||
|
{
|
||||||
|
ObjectId = objectId;
|
||||||
|
Radius = radius;
|
||||||
|
Quantum = quantum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,8 +17,16 @@ namespace AcDream.Core.Physics;
|
||||||
/// active locomotion cycle). We rotate that by the body's orientation
|
/// active locomotion cycle). We rotate that by the body's orientation
|
||||||
/// to get a world-space delta, then add the InterpolationManager's
|
/// to get a world-space delta, then add the InterpolationManager's
|
||||||
/// world-space correction.
|
/// world-space correction.
|
||||||
|
///
|
||||||
|
/// <para><b>Renamed R5</b> (was <c>PositionManager</c>): this class is only the
|
||||||
|
/// InterpolationManager-composition portion of retail's
|
||||||
|
/// <c>PositionManager::adjust_offset</c> — NOT the retail PositionManager
|
||||||
|
/// facade. The faithful facade (Sticky/Constraint, owned per entity) is
|
||||||
|
/// <see cref="Motion.PositionManager"/>. The name was freed to remove the
|
||||||
|
/// ambiguity that broke every file importing both
|
||||||
|
/// <c>AcDream.Core.Physics</c> and <c>AcDream.Core.Physics.Motion</c>.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PositionManager
|
public sealed class RemoteMotionCombiner
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compute the per-frame world-space delta to add to body.Position.
|
/// Compute the per-frame world-space delta to add to body.Position.
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Physics.Motion;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics.Motion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R5 conformance — <see cref="ConstraintManager"/> (retail 0x00556090-…).
|
||||||
|
/// The leash-band taper + the 90 % IsFullyConstrained jump gate (port-plan §2b/§2c).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConstraintManagerTests
|
||||||
|
{
|
||||||
|
private static (R5Host host, ConstraintManager cm) Setup()
|
||||||
|
{
|
||||||
|
var world = new Dictionary<uint, R5Host>();
|
||||||
|
var host = new R5Host(10u, world);
|
||||||
|
return (host, new ConstraintManager(host));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Position Anchor(float x) => new(1u, new Vector3(x, 0f, 0f), Quaternion.Identity);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConstrainTo_InitializesOffsetToCurrentDistanceFromAnchor()
|
||||||
|
{
|
||||||
|
var (host, cm) = Setup();
|
||||||
|
host.SetOrigin(Vector3.Zero);
|
||||||
|
|
||||||
|
cm.ConstrainTo(Anchor(5f), startDistance: 2f, maxDistance: 10f);
|
||||||
|
|
||||||
|
Assert.True(cm.IsConstrained);
|
||||||
|
Assert.Equal(5f, cm.ConstraintPosOffset, 3); // distance(anchor(5,0,0), self(0,0,0))
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsFullyConstrained_TrueOnlyBeyond90PercentOfMax()
|
||||||
|
{
|
||||||
|
var (host, cm) = Setup();
|
||||||
|
host.SetOrigin(Vector3.Zero);
|
||||||
|
|
||||||
|
cm.ConstrainTo(Anchor(8f), 2f, 10f); // offset 8, 90% of 10 = 9 → 8 < 9
|
||||||
|
Assert.False(cm.IsFullyConstrained());
|
||||||
|
|
||||||
|
cm.ConstrainTo(Anchor(9.5f), 2f, 10f); // offset 9.5 > 9
|
||||||
|
Assert.True(cm.IsFullyConstrained());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsFullyConstrained_FalseWhenNotConstrainedYet()
|
||||||
|
{
|
||||||
|
var (_, cm) = Setup();
|
||||||
|
Assert.False(cm.IsFullyConstrained()); // max 0 → 0 < 0 is false
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdjustOffset_InBand_AppliesLinearTaper()
|
||||||
|
{
|
||||||
|
var (host, cm) = Setup();
|
||||||
|
host.SetOrigin(Vector3.Zero);
|
||||||
|
host.InContact = true;
|
||||||
|
cm.ConstrainTo(Anchor(5f), startDistance: 2f, maxDistance: 10f); // offset 5 in (2,10)
|
||||||
|
|
||||||
|
var frame = new MotionDeltaFrame { Origin = new Vector3(1f, 0f, 0f) };
|
||||||
|
cm.AdjustOffset(frame, 0.1);
|
||||||
|
|
||||||
|
// taper = (10-5)/(10-2) = 5/8 = 0.625.
|
||||||
|
Assert.Equal(0.625f, frame.Origin.X, 3);
|
||||||
|
Assert.Equal(0.625f, cm.ConstraintPosOffset, 3); // recomputed = |offset|
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdjustOffset_PastMax_HardClampsToZero()
|
||||||
|
{
|
||||||
|
var (host, cm) = Setup();
|
||||||
|
host.SetOrigin(Vector3.Zero);
|
||||||
|
host.InContact = true;
|
||||||
|
cm.ConstrainTo(Anchor(20f), 2f, 10f); // offset 20 >= max 10
|
||||||
|
|
||||||
|
var frame = new MotionDeltaFrame { Origin = new Vector3(1f, 0f, 0f) };
|
||||||
|
cm.AdjustOffset(frame, 0.1);
|
||||||
|
|
||||||
|
Assert.Equal(Vector3.Zero, frame.Origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdjustOffset_BelowStart_PassesThrough()
|
||||||
|
{
|
||||||
|
var (host, cm) = Setup();
|
||||||
|
host.SetOrigin(Vector3.Zero);
|
||||||
|
host.InContact = true;
|
||||||
|
cm.ConstrainTo(Anchor(1f), startDistance: 2f, maxDistance: 10f); // offset 1 < start 2
|
||||||
|
|
||||||
|
var frame = new MotionDeltaFrame { Origin = new Vector3(1f, 0f, 0f) };
|
||||||
|
cm.AdjustOffset(frame, 0.1);
|
||||||
|
|
||||||
|
Assert.Equal(1f, frame.Origin.X, 3); // unscaled
|
||||||
|
Assert.Equal(1f, cm.ConstraintPosOffset, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdjustOffset_Airborne_SkipsClampButStillTracksLength()
|
||||||
|
{
|
||||||
|
var (host, cm) = Setup();
|
||||||
|
host.SetOrigin(Vector3.Zero);
|
||||||
|
host.InContact = false; // airborne
|
||||||
|
cm.ConstrainTo(Anchor(20f), 2f, 10f); // would clamp if grounded
|
||||||
|
|
||||||
|
var frame = new MotionDeltaFrame { Origin = new Vector3(3f, 4f, 0f) };
|
||||||
|
cm.AdjustOffset(frame, 0.1);
|
||||||
|
|
||||||
|
Assert.Equal(new Vector3(3f, 4f, 0f), frame.Origin); // untouched while airborne
|
||||||
|
Assert.Equal(5f, cm.ConstraintPosOffset, 3); // |(3,4,0)| = 5, still updated
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdjustOffset_NotConstrained_IsNoOp()
|
||||||
|
{
|
||||||
|
var (host, cm) = Setup();
|
||||||
|
var frame = new MotionDeltaFrame { Origin = new Vector3(7f, 0f, 0f) };
|
||||||
|
cm.AdjustOffset(frame, 0.1);
|
||||||
|
Assert.Equal(new Vector3(7f, 0f, 0f), frame.Origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnConstrain_ClearsFlag()
|
||||||
|
{
|
||||||
|
var (host, cm) = Setup();
|
||||||
|
cm.ConstrainTo(Anchor(5f), 2f, 10f);
|
||||||
|
cm.UnConstrain();
|
||||||
|
Assert.False(cm.IsConstrained);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Physics.Motion;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics.Motion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R5 conformance — <see cref="PositionManager"/> facade (retail
|
||||||
|
/// 0x00555160-0x005553d0). Lazy sub-manager creation + fan-out. (Test class is
|
||||||
|
/// suffixed "Facade" to read distinctly from the renamed
|
||||||
|
/// <c>RemoteMotionCombiner</c> combiner tests.)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PositionManagerFacadeTests
|
||||||
|
{
|
||||||
|
private static (R5Host self, R5Host target, PositionManager pm) Setup()
|
||||||
|
{
|
||||||
|
var world = new Dictionary<uint, R5Host>();
|
||||||
|
var self = new R5Host(10u, world);
|
||||||
|
var target = new R5Host(20u, world);
|
||||||
|
return (self, target, new PositionManager(self));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnStick_BeforeAnyStick_IsNoOp()
|
||||||
|
{
|
||||||
|
var (_, _, pm) = Setup();
|
||||||
|
pm.UnStick(); // no sticky manager yet — must not throw
|
||||||
|
Assert.Null(pm.Sticky);
|
||||||
|
Assert.Equal(0u, pm.GetStickyObjectId());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StickTo_LazilyCreatesSticky_AndForwards()
|
||||||
|
{
|
||||||
|
var (self, target, pm) = Setup();
|
||||||
|
pm.StickTo(target.Id, radius: 0.5f, height: 1.0f);
|
||||||
|
|
||||||
|
Assert.NotNull(pm.Sticky);
|
||||||
|
Assert.Equal(target.Id, pm.GetStickyObjectId());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsFullyConstrained_FalseWhenNoConstraintManager()
|
||||||
|
{
|
||||||
|
var (_, _, pm) = Setup();
|
||||||
|
Assert.False(pm.IsFullyConstrained());
|
||||||
|
Assert.Null(pm.Constraint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConstrainTo_LazilyCreatesConstraint()
|
||||||
|
{
|
||||||
|
var (self, _, pm) = Setup();
|
||||||
|
self.SetOrigin(Vector3.Zero);
|
||||||
|
pm.ConstrainTo(new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity), 2f, 10f);
|
||||||
|
Assert.NotNull(pm.Constraint);
|
||||||
|
Assert.True(pm.Constraint!.IsConstrained);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleUpdateTarget_ForwardsToSticky()
|
||||||
|
{
|
||||||
|
var (self, target, pm) = Setup();
|
||||||
|
pm.StickTo(target.Id, 0.5f, 1.0f);
|
||||||
|
|
||||||
|
var tp = new Position(1u, new Vector3(2f, 0f, 0f), Quaternion.Identity);
|
||||||
|
pm.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
|
||||||
|
|
||||||
|
Assert.True(pm.Sticky!.Initialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdjustOffset_ChainsStickyThenConstraint()
|
||||||
|
{
|
||||||
|
var (self, target, pm) = Setup();
|
||||||
|
self.Radius = 0.5f;
|
||||||
|
self.MinterpMaxSpeed = 1.0f;
|
||||||
|
self.InContact = true;
|
||||||
|
|
||||||
|
// Sticky toward +X: produces an offset the constraint then tapers.
|
||||||
|
pm.StickTo(target.Id, 0.5f, 1.0f);
|
||||||
|
target.SetOrigin(new Vector3(5f, 0f, 0f));
|
||||||
|
var tp = new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity);
|
||||||
|
pm.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
|
||||||
|
|
||||||
|
// Arm a constraint whose band tapers by 0.5.
|
||||||
|
self.SetOrigin(Vector3.Zero);
|
||||||
|
pm.ConstrainTo(new Position(1u, new Vector3(6f, 0f, 0f), Quaternion.Identity),
|
||||||
|
startDistance: 2f, maxDistance: 10f); // offset 6 → taper (10-6)/(10-2)=0.5
|
||||||
|
|
||||||
|
var frame = new MotionDeltaFrame();
|
||||||
|
pm.AdjustOffset(frame, quantum: 0.1);
|
||||||
|
|
||||||
|
// Sticky writes +0.5 X; constraint tapers by 0.5 → 0.25.
|
||||||
|
Assert.Equal(0.25f, frame.Origin.X, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UseTime_DrivesStickyTimeout()
|
||||||
|
{
|
||||||
|
var (self, target, pm) = Setup();
|
||||||
|
self.CurTime = 100.0;
|
||||||
|
pm.StickTo(target.Id, 0.5f, 1.0f); // deadline 101
|
||||||
|
|
||||||
|
self.CurTime = 101.0;
|
||||||
|
pm.UseTime();
|
||||||
|
|
||||||
|
Assert.Equal(0u, pm.GetStickyObjectId()); // unstuck
|
||||||
|
}
|
||||||
|
}
|
||||||
98
tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs
Normal file
98
tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Physics.Motion;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics.Motion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R5 conformance harness — a scriptable <see cref="IPhysicsObjHost"/> fake
|
||||||
|
/// backed by a shared <see cref="World"/> so <see cref="GetObjectA"/> resolves
|
||||||
|
/// OTHER hosts (the cross-entity seam the voyeur round-trip needs). Each host
|
||||||
|
/// lazily owns a <see cref="TargetManager"/> and a <see cref="PositionManager"/>
|
||||||
|
/// (the two R5 managers under test), and records every
|
||||||
|
/// <see cref="HandleUpdateTarget"/> the managers fan out so tests can assert on
|
||||||
|
/// delivery.
|
||||||
|
///
|
||||||
|
/// <para>Position/velocity/radius/contact/max-speed and both clocks are mutable
|
||||||
|
/// fields tests drive directly (retail's <c>CPhysicsObj</c> accessors). The
|
||||||
|
/// <c>set_target</c>/<c>clear_target</c>/<c>add_voyeur</c>/<c>remove_voyeur</c>/
|
||||||
|
/// <c>receive_target_update</c> seams forward to the owned
|
||||||
|
/// <see cref="TargetManager"/> exactly as retail's <c>CPhysicsObj</c> does.</para>
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class R5Host : IPhysicsObjHost
|
||||||
|
{
|
||||||
|
public readonly Dictionary<uint, R5Host> World;
|
||||||
|
|
||||||
|
public R5Host(uint id, Dictionary<uint, R5Host> world)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
World = world;
|
||||||
|
World[id] = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── scriptable CPhysicsObj state ───────────────────────────────────────
|
||||||
|
public uint Id { get; }
|
||||||
|
public Position Position { get; set; } = new(1u, Vector3.Zero, Quaternion.Identity);
|
||||||
|
public Vector3 Velocity { get; set; } = Vector3.Zero;
|
||||||
|
public float Radius { get; set; } = 0.5f;
|
||||||
|
public bool InContact { get; set; } = true;
|
||||||
|
public float? MinterpMaxSpeed { get; set; } = 1.0f;
|
||||||
|
public double CurTime { get; set; }
|
||||||
|
public double PhysicsTimerTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Set to false to simulate a target that isn't currently
|
||||||
|
/// resolvable (out of streaming view) — <see cref="GetObjectA"/> returns
|
||||||
|
/// null for it even while it's in <see cref="World"/>.</summary>
|
||||||
|
public bool Resolvable { get; set; } = true;
|
||||||
|
|
||||||
|
// ── owned R5 managers ──────────────────────────────────────────────────
|
||||||
|
private TargetManager? _targetManager;
|
||||||
|
public TargetManager TargetManager => _targetManager ??= new TargetManager(this);
|
||||||
|
public TargetManager? TargetManagerOrNull => _targetManager;
|
||||||
|
|
||||||
|
private PositionManager? _positionManager;
|
||||||
|
public PositionManager PositionManager => _positionManager ??= new PositionManager(this);
|
||||||
|
|
||||||
|
// ── recorded fan-outs ──────────────────────────────────────────────────
|
||||||
|
public readonly List<TargetInfo> HandleUpdateTargetCalls = new();
|
||||||
|
public int InterruptCurrentMovementCalls;
|
||||||
|
|
||||||
|
// ── IPhysicsObjHost ────────────────────────────────────────────────────
|
||||||
|
public IPhysicsObjHost? GetObjectA(uint id)
|
||||||
|
=> World.TryGetValue(id, out var h) && h.Resolvable ? h : null;
|
||||||
|
|
||||||
|
public void HandleUpdateTarget(TargetInfo info)
|
||||||
|
{
|
||||||
|
HandleUpdateTargetCalls.Add(info);
|
||||||
|
// Retail CPhysicsObj::HandleUpdateTarget also fans to the position
|
||||||
|
// manager's sticky sub-manager (context 0). Mirror that so sticky tests
|
||||||
|
// that rely on the full CPhysicsObj fan-out see the callback.
|
||||||
|
if (info.ContextId == 0)
|
||||||
|
_positionManager?.HandleUpdateTarget(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void InterruptCurrentMovement() => InterruptCurrentMovementCalls++;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// ── test helpers ───────────────────────────────────────────────────────
|
||||||
|
public void SetOrigin(Vector3 origin)
|
||||||
|
=> Position = new Position(Position.ObjCellId, origin, Position.Frame.Orientation);
|
||||||
|
|
||||||
|
public void AdvanceClocks(double seconds)
|
||||||
|
{
|
||||||
|
CurTime += seconds;
|
||||||
|
PhysicsTimerTime += seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
220
tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs
Normal file
220
tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Physics.Motion;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics.Motion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R5 conformance — <see cref="StickyManager"/> (retail 0x00555400-0x00555866).
|
||||||
|
/// Lifecycle (StickTo/UnStick/timeout/HandleUpdateTarget) + the decoded
|
||||||
|
/// <c>adjust_offset</c> steering math (port-plan §2a).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StickyManagerTests
|
||||||
|
{
|
||||||
|
private static (R5Host self, R5Host target, StickyManager sticky) Setup(
|
||||||
|
uint targetId = 20u, float targetRadius = 0.5f)
|
||||||
|
{
|
||||||
|
var world = new Dictionary<uint, R5Host>();
|
||||||
|
var self = new R5Host(10u, world);
|
||||||
|
var target = new R5Host(targetId, world);
|
||||||
|
var sticky = new StickyManager(self);
|
||||||
|
return (self, target, sticky);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StickyManager StuckAndInitialized(
|
||||||
|
R5Host self, R5Host target, Vector3 targetOrigin, float targetRadius = 0.5f)
|
||||||
|
{
|
||||||
|
var sticky = new StickyManager(self);
|
||||||
|
sticky.StickTo(target.Id, targetRadius, targetHeight: 1.0f);
|
||||||
|
target.SetOrigin(targetOrigin);
|
||||||
|
var tp = new Position(1u, targetOrigin, Quaternion.Identity);
|
||||||
|
sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
|
||||||
|
return sticky;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StickTo_SetsTargetAndTimeout_AndSubscribesVoyeurOnTarget()
|
||||||
|
{
|
||||||
|
var (self, target, sticky) = Setup();
|
||||||
|
self.CurTime = 100.0;
|
||||||
|
|
||||||
|
sticky.StickTo(target.Id, targetRadius: 0.5f, targetHeight: 2.0f);
|
||||||
|
|
||||||
|
Assert.Equal(target.Id, sticky.TargetId);
|
||||||
|
Assert.Equal(0.5f, sticky.TargetRadius);
|
||||||
|
Assert.False(sticky.Initialized);
|
||||||
|
Assert.Equal(101.0, sticky.StickyTimeoutTime); // now + StickyTime(1.0)
|
||||||
|
// set_target(0, target, 0.5, 0.5) → watcher subscribes ON the target.
|
||||||
|
Assert.NotNull(target.TargetManagerOrNull);
|
||||||
|
Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleUpdateTarget_Ok_MarksInitializedAndCachesPosition()
|
||||||
|
{
|
||||||
|
var (self, target, sticky) = Setup();
|
||||||
|
sticky.StickTo(target.Id, 0.5f, 1.0f);
|
||||||
|
|
||||||
|
var tp = new Position(1u, new Vector3(3f, 0f, 0f), Quaternion.Identity);
|
||||||
|
sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
|
||||||
|
|
||||||
|
Assert.True(sticky.Initialized);
|
||||||
|
Assert.Equal(new Vector3(3f, 0f, 0f), sticky.TargetPosition.Frame.Origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleUpdateTarget_ForeignObject_Ignored()
|
||||||
|
{
|
||||||
|
var (self, target, sticky) = Setup();
|
||||||
|
sticky.StickTo(target.Id, 0.5f, 1.0f);
|
||||||
|
|
||||||
|
var tp = new Position(1u, Vector3.Zero, Quaternion.Identity);
|
||||||
|
sticky.HandleUpdateTarget(new TargetInfo(999u, TargetStatus.Ok, tp, tp));
|
||||||
|
|
||||||
|
Assert.False(sticky.Initialized);
|
||||||
|
Assert.Equal(target.Id, sticky.TargetId); // still stuck to the real target
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleUpdateTarget_NonOkStatus_TearsDown()
|
||||||
|
{
|
||||||
|
var (self, target, sticky) = Setup();
|
||||||
|
sticky.StickTo(target.Id, 0.5f, 1.0f);
|
||||||
|
int interruptsBefore = self.InterruptCurrentMovementCalls;
|
||||||
|
|
||||||
|
var tp = new Position(1u, Vector3.Zero, Quaternion.Identity);
|
||||||
|
sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.ExitWorld, tp, tp));
|
||||||
|
|
||||||
|
Assert.Equal(0u, sticky.TargetId);
|
||||||
|
Assert.False(sticky.Initialized);
|
||||||
|
Assert.Equal(interruptsBefore + 1, self.InterruptCurrentMovementCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UseTime_BeforeDeadline_KeepsStick()
|
||||||
|
{
|
||||||
|
var (self, target, sticky) = Setup();
|
||||||
|
self.CurTime = 100.0;
|
||||||
|
sticky.StickTo(target.Id, 0.5f, 1.0f); // deadline 101.0
|
||||||
|
|
||||||
|
self.CurTime = 100.9;
|
||||||
|
sticky.UseTime();
|
||||||
|
|
||||||
|
Assert.Equal(target.Id, sticky.TargetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UseTime_AtDeadline_UnsticksAndInterrupts()
|
||||||
|
{
|
||||||
|
var (self, target, sticky) = Setup();
|
||||||
|
self.CurTime = 100.0;
|
||||||
|
sticky.StickTo(target.Id, 0.5f, 1.0f); // deadline 101.0
|
||||||
|
int interruptsBefore = self.InterruptCurrentMovementCalls;
|
||||||
|
|
||||||
|
self.CurTime = 101.0;
|
||||||
|
sticky.UseTime();
|
||||||
|
|
||||||
|
Assert.Equal(0u, sticky.TargetId);
|
||||||
|
Assert.Equal(interruptsBefore + 1, self.InterruptCurrentMovementCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReStick_TearsDownPreviousBeforeSettingNew()
|
||||||
|
{
|
||||||
|
var world = new Dictionary<uint, R5Host>();
|
||||||
|
var self = new R5Host(10u, world);
|
||||||
|
var a = new R5Host(20u, world);
|
||||||
|
var b = new R5Host(21u, world);
|
||||||
|
var sticky = new StickyManager(self);
|
||||||
|
|
||||||
|
sticky.StickTo(a.Id, 0.5f, 1.0f);
|
||||||
|
int interruptsBefore = self.InterruptCurrentMovementCalls;
|
||||||
|
sticky.StickTo(b.Id, 0.5f, 1.0f);
|
||||||
|
|
||||||
|
Assert.Equal(b.Id, sticky.TargetId);
|
||||||
|
Assert.Equal(interruptsBefore + 1, self.InterruptCurrentMovementCalls); // old stick torn down
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdjustOffset_NotInitialized_IsNoOp()
|
||||||
|
{
|
||||||
|
var (self, target, sticky) = Setup();
|
||||||
|
sticky.StickTo(target.Id, 0.5f, 1.0f); // no HandleUpdateTarget → not initialized
|
||||||
|
|
||||||
|
var frame = new MotionDeltaFrame { Origin = new Vector3(9f, 9f, 9f) };
|
||||||
|
sticky.AdjustOffset(frame, 0.1);
|
||||||
|
|
||||||
|
Assert.Equal(new Vector3(9f, 9f, 9f), frame.Origin); // untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdjustOffset_SteersTowardTarget_ClampedToStep()
|
||||||
|
{
|
||||||
|
var world = new Dictionary<uint, R5Host>();
|
||||||
|
var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f };
|
||||||
|
var target = new R5Host(20u, world);
|
||||||
|
var sticky = StuckAndInitialized(self, target, new Vector3(5f, 0f, 0f));
|
||||||
|
|
||||||
|
var frame = new MotionDeltaFrame();
|
||||||
|
sticky.AdjustOffset(frame, quantum: 0.1);
|
||||||
|
|
||||||
|
// dir = +X (east); speed = 1.0 * 5 = 5; delta = 5 * 0.1 = 0.5 (< dist 3.7).
|
||||||
|
Assert.Equal(0.5f, frame.Origin.X, 3);
|
||||||
|
Assert.Equal(0f, frame.Origin.Y, 3);
|
||||||
|
Assert.Equal(0f, frame.Origin.Z, 3); // horizontal-only
|
||||||
|
// heading toward +X (east) = 90° compass.
|
||||||
|
Assert.Equal(90f, frame.GetHeading(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdjustOffset_TooClose_BacksOff_SignedDistance()
|
||||||
|
{
|
||||||
|
var world = new Dictionary<uint, R5Host>();
|
||||||
|
var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f };
|
||||||
|
var target = new R5Host(20u, world);
|
||||||
|
// centerDist 0.9, cyl = 0.9-0.5-0.5 = -0.1, minus 0.3 → dist = -0.4.
|
||||||
|
var sticky = StuckAndInitialized(self, target, new Vector3(0.9f, 0f, 0f));
|
||||||
|
|
||||||
|
var frame = new MotionDeltaFrame();
|
||||||
|
sticky.AdjustOffset(frame, quantum: 0.1);
|
||||||
|
|
||||||
|
// delta = 5*0.1 = 0.5 >= |dist|=0.4 → delta = dist = -0.4; dir +X * -0.4 → back off (-X).
|
||||||
|
Assert.Equal(-0.4f, frame.Origin.X, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdjustOffset_UsesCachedPosition_WhenTargetUnresolvable()
|
||||||
|
{
|
||||||
|
var world = new Dictionary<uint, R5Host>();
|
||||||
|
var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f };
|
||||||
|
var target = new R5Host(20u, world);
|
||||||
|
var sticky = new StickyManager(self);
|
||||||
|
sticky.StickTo(target.Id, 0.5f, 1.0f);
|
||||||
|
// Cache a position via HandleUpdateTarget, then make the target vanish.
|
||||||
|
var tp = new Position(1u, new Vector3(4f, 0f, 0f), Quaternion.Identity);
|
||||||
|
sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
|
||||||
|
target.Resolvable = false; // GetObjectA(target) → null → fall back to cached
|
||||||
|
|
||||||
|
var frame = new MotionDeltaFrame();
|
||||||
|
sticky.AdjustOffset(frame, quantum: 0.1);
|
||||||
|
|
||||||
|
Assert.Equal(0.5f, frame.Origin.X, 3); // still steers toward cached (4,0,0)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdjustOffset_NoMinterp_UsesFallbackSpeed()
|
||||||
|
{
|
||||||
|
var world = new Dictionary<uint, R5Host>();
|
||||||
|
var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = null };
|
||||||
|
var target = new R5Host(20u, world);
|
||||||
|
var sticky = StuckAndInitialized(self, target, new Vector3(50f, 0f, 0f));
|
||||||
|
|
||||||
|
var frame = new MotionDeltaFrame();
|
||||||
|
sticky.AdjustOffset(frame, quantum: 0.1);
|
||||||
|
|
||||||
|
// fallback speed 15 → delta = 15 * 0.1 = 1.5 (dist ≈ 49 → not clamped).
|
||||||
|
Assert.Equal(1.5f, frame.Origin.X, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
255
tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs
Normal file
255
tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Physics.Motion;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics.Motion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R5 conformance — <see cref="TargetManager"/> voyeur system (retail
|
||||||
|
/// 0x0051a370-0x0051ad90). Watcher role (SetTarget/ReceiveUpdate/timeout),
|
||||||
|
/// watched role (AddVoyeur/HandleTargetting/CheckAndUpdateVoyeur), and the full
|
||||||
|
/// cross-entity round-trip (port-plan §2d/§2e). Replaces the AP-79 adapter.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TargetManagerTests
|
||||||
|
{
|
||||||
|
private static (R5Host self, R5Host target, Dictionary<uint, R5Host> world) TwoHosts()
|
||||||
|
{
|
||||||
|
var world = new Dictionary<uint, R5Host>();
|
||||||
|
var self = new R5Host(10u, world);
|
||||||
|
var target = new R5Host(20u, world);
|
||||||
|
return (self, target, world);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── watcher role ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetTarget_SubscribesOnTarget_AndReceivesImmediateSnapshot()
|
||||||
|
{
|
||||||
|
var (self, target, _) = TwoHosts();
|
||||||
|
target.SetOrigin(new Vector3(3f, 0f, 0f));
|
||||||
|
|
||||||
|
self.TargetManager.SetTarget(0, target.Id, radius: 1.0f, quantum: 0.0);
|
||||||
|
|
||||||
|
// Watcher subscribed on the target.
|
||||||
|
Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
|
||||||
|
// Immediate Ok snapshot fanned to the watcher's host.
|
||||||
|
Assert.Single(self.HandleUpdateTargetCalls);
|
||||||
|
Assert.Equal(TargetStatus.Ok, self.HandleUpdateTargetCalls[0].Status);
|
||||||
|
Assert.Equal(target.Id, self.HandleUpdateTargetCalls[0].ObjectId);
|
||||||
|
Assert.Equal(new Vector3(3f, 0f, 0f),
|
||||||
|
self.HandleUpdateTargetCalls[0].InterpolatedPosition.Frame.Origin);
|
||||||
|
Assert.Equal(TargetStatus.Ok, self.TargetManager.TargetInfo!.Value.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetTarget_Zero_SynthesizesTimedOut_LeavesTargetInfoNull()
|
||||||
|
{
|
||||||
|
var (self, _, _) = TwoHosts();
|
||||||
|
self.TargetManager.SetTarget(contextId: 7, objectId: 0, radius: 1.0f, quantum: 0.0);
|
||||||
|
|
||||||
|
Assert.Null(self.TargetManager.TargetInfo);
|
||||||
|
Assert.Single(self.HandleUpdateTargetCalls);
|
||||||
|
Assert.Equal(TargetStatus.TimedOut, self.HandleUpdateTargetCalls[0].Status);
|
||||||
|
Assert.Equal(7u, self.HandleUpdateTargetCalls[0].ContextId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClearTarget_UnsubscribesFromTarget()
|
||||||
|
{
|
||||||
|
var (self, target, _) = TwoHosts();
|
||||||
|
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
|
||||||
|
Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
|
||||||
|
|
||||||
|
self.TargetManager.ClearTarget();
|
||||||
|
|
||||||
|
Assert.Null(self.TargetManager.TargetInfo);
|
||||||
|
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReceiveUpdate_ForWrongObject_Ignored()
|
||||||
|
{
|
||||||
|
var (self, target, _) = TwoHosts();
|
||||||
|
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
|
||||||
|
int before = self.HandleUpdateTargetCalls.Count;
|
||||||
|
|
||||||
|
var p = new Position(1u, Vector3.Zero, Quaternion.Identity);
|
||||||
|
self.TargetManager.ReceiveUpdate(new TargetInfo(999u, TargetStatus.Ok, p, p));
|
||||||
|
|
||||||
|
Assert.Equal(before, self.HandleUpdateTargetCalls.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReceiveUpdate_ExitWorld_ClearsTarget()
|
||||||
|
{
|
||||||
|
var (self, target, _) = TwoHosts();
|
||||||
|
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
|
||||||
|
|
||||||
|
var p = new Position(1u, Vector3.Zero, Quaternion.Identity);
|
||||||
|
self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.ExitWorld, p, p));
|
||||||
|
|
||||||
|
Assert.Null(self.TargetManager.TargetInfo); // cleared
|
||||||
|
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); // unsubscribed
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReceiveUpdate_ComputesInterpolatedHeadingTowardTarget()
|
||||||
|
{
|
||||||
|
var (self, target, _) = TwoHosts();
|
||||||
|
self.SetOrigin(Vector3.Zero);
|
||||||
|
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
|
||||||
|
|
||||||
|
var tp = new Position(1u, new Vector3(0f, 5f, 0f), Quaternion.Identity);
|
||||||
|
self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
|
||||||
|
|
||||||
|
// self→target interp position (0,5,0) normalized = +Y.
|
||||||
|
var heading = self.TargetManager.TargetInfo!.Value.InterpolatedHeading;
|
||||||
|
Assert.Equal(0f, heading.X, 3);
|
||||||
|
Assert.Equal(1f, heading.Y, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleTargetting_UndefinedTarget_TimesOutAfter10Seconds()
|
||||||
|
{
|
||||||
|
var (self, target, _) = TwoHosts();
|
||||||
|
target.Resolvable = false; // no immediate snapshot → status stays Undefined
|
||||||
|
self.CurTime = 0;
|
||||||
|
self.PhysicsTimerTime = 0;
|
||||||
|
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
|
||||||
|
Assert.Equal(TargetStatus.Undefined, self.TargetManager.TargetInfo!.Value.Status);
|
||||||
|
|
||||||
|
self.AdvanceClocks(11.0);
|
||||||
|
self.TargetManager.HandleTargetting();
|
||||||
|
|
||||||
|
Assert.Equal(TargetStatus.TimedOut, self.TargetManager.TargetInfo!.Value.Status);
|
||||||
|
Assert.Contains(self.HandleUpdateTargetCalls, c => c.Status == TargetStatus.TimedOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── watched role + gates ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleTargetting_Throttles_Within500ms()
|
||||||
|
{
|
||||||
|
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.
|
||||||
|
target.TargetManager.AddVoyeur(self.Id, 0.1f, 0.0);
|
||||||
|
target.SetOrigin(new Vector3(10f, 0f, 0f)); // far past radius
|
||||||
|
|
||||||
|
target.PhysicsTimerTime = 0.3; // < 0.5 throttle
|
||||||
|
int before = self.HandleUpdateTargetCalls.Count;
|
||||||
|
target.TargetManager.HandleTargetting();
|
||||||
|
|
||||||
|
Assert.Equal(before, self.HandleUpdateTargetCalls.Count); // throttled, no sweep
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CheckAndUpdateVoyeur_SendsOnlyPastRadius()
|
||||||
|
{
|
||||||
|
var (self, target, _) = TwoHosts();
|
||||||
|
// self must have a matching target_info for ReceiveUpdate to record.
|
||||||
|
self.TargetManager.SetTarget(0, target.Id, radius: 1.0f, quantum: 0.0);
|
||||||
|
int afterSubscribe = self.HandleUpdateTargetCalls.Count; // includes immediate snapshot
|
||||||
|
|
||||||
|
// small move → within radius → no send
|
||||||
|
target.SetOrigin(new Vector3(0.5f, 0f, 0f));
|
||||||
|
target.AdvanceClocks(1.0);
|
||||||
|
target.TargetManager.HandleTargetting();
|
||||||
|
Assert.Equal(afterSubscribe, self.HandleUpdateTargetCalls.Count);
|
||||||
|
|
||||||
|
// large move → past radius → send
|
||||||
|
target.SetOrigin(new Vector3(2f, 0f, 0f));
|
||||||
|
target.AdvanceClocks(1.0);
|
||||||
|
target.TargetManager.HandleTargetting();
|
||||||
|
Assert.Equal(afterSubscribe + 1, self.HandleUpdateTargetCalls.Count);
|
||||||
|
var last = self.HandleUpdateTargetCalls[^1];
|
||||||
|
Assert.Equal(TargetStatus.Ok, last.Status);
|
||||||
|
Assert.Equal(2f, last.InterpolatedPosition.Frame.Origin.X, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetInterpolatedPosition_DeadReckonsWithVelocity()
|
||||||
|
{
|
||||||
|
var (_, target, _) = TwoHosts();
|
||||||
|
target.SetOrigin(new Vector3(1f, 2f, 0f));
|
||||||
|
target.Velocity = new Vector3(10f, 0f, 0f);
|
||||||
|
|
||||||
|
var p = target.TargetManager.GetInterpolatedPosition(quantum: 0.5);
|
||||||
|
|
||||||
|
Assert.Equal(new Vector3(6f, 2f, 0f), p.Frame.Origin); // 1 + 10*0.5 = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddVoyeur_Existing_UpdatesInPlace_NoResend()
|
||||||
|
{
|
||||||
|
var (self, target, _) = TwoHosts();
|
||||||
|
target.SetOrigin(Vector3.Zero);
|
||||||
|
target.TargetManager.AddVoyeur(self.Id, radius: 1.0f, quantum: 0.0);
|
||||||
|
var voyeur = target.TargetManager.VoyeurTable![self.Id];
|
||||||
|
Assert.Equal(Vector3.Zero, voyeur.LastSentPosition.Frame.Origin);
|
||||||
|
|
||||||
|
target.SetOrigin(new Vector3(5f, 0f, 0f));
|
||||||
|
target.TargetManager.AddVoyeur(self.Id, radius: 2.5f, quantum: 0.3); // existing → update only
|
||||||
|
|
||||||
|
Assert.Equal(2.5f, voyeur.Radius);
|
||||||
|
Assert.Equal(0.3, voyeur.Quantum);
|
||||||
|
Assert.Equal(Vector3.Zero, voyeur.LastSentPosition.Frame.Origin); // NOT re-sent
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveVoyeur_RemovesEntry()
|
||||||
|
{
|
||||||
|
var (self, target, _) = TwoHosts();
|
||||||
|
target.TargetManager.AddVoyeur(self.Id, 1.0f, 0.0);
|
||||||
|
Assert.True(target.TargetManager.RemoveVoyeur(self.Id));
|
||||||
|
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
|
||||||
|
Assert.False(target.TargetManager.RemoveVoyeur(self.Id)); // already gone
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NotifyVoyeurOfEvent_BroadcastsToAll_NoDistanceGate()
|
||||||
|
{
|
||||||
|
var world = new Dictionary<uint, R5Host>();
|
||||||
|
var target = new R5Host(20u, world);
|
||||||
|
var w1 = new R5Host(10u, world);
|
||||||
|
var w2 = new R5Host(11u, world);
|
||||||
|
// both watch the target (each gets a target_info via SetTarget)
|
||||||
|
w1.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
|
||||||
|
w2.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
|
||||||
|
int w1Before = w1.HandleUpdateTargetCalls.Count;
|
||||||
|
int w2Before = w2.HandleUpdateTargetCalls.Count;
|
||||||
|
|
||||||
|
target.TargetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
|
||||||
|
|
||||||
|
Assert.Equal(w1Before + 1, w1.HandleUpdateTargetCalls.Count);
|
||||||
|
Assert.Equal(w2Before + 1, w2.HandleUpdateTargetCalls.Count);
|
||||||
|
Assert.Equal(TargetStatus.Teleported, w1.HandleUpdateTargetCalls[^1].Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── full round-trip ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_WatcherTracksMovingTarget()
|
||||||
|
{
|
||||||
|
var (self, target, _) = TwoHosts();
|
||||||
|
target.SetOrigin(Vector3.Zero);
|
||||||
|
|
||||||
|
// Watcher sets target with a 1.0 radius (moveto-style, quantum 0).
|
||||||
|
self.TargetManager.SetTarget(0, target.Id, radius: 1.0f, quantum: 0.0);
|
||||||
|
Assert.Single(self.HandleUpdateTargetCalls); // immediate snapshot at (0,0,0)
|
||||||
|
|
||||||
|
// Target walks to (3,0,0); its per-tick HandleTargetting notices the
|
||||||
|
// drift and pushes an update the watcher receives.
|
||||||
|
target.SetOrigin(new Vector3(3f, 0f, 0f));
|
||||||
|
target.AdvanceClocks(1.0);
|
||||||
|
target.TargetManager.HandleTargetting();
|
||||||
|
|
||||||
|
Assert.Equal(2, self.HandleUpdateTargetCalls.Count);
|
||||||
|
Assert.Equal(new Vector3(3f, 0f, 0f),
|
||||||
|
self.HandleUpdateTargetCalls[^1].InterpolatedPosition.Frame.Origin);
|
||||||
|
// The watcher's cached target position tracks the target.
|
||||||
|
Assert.Equal(3f, self.TargetManager.TargetInfo!.Value.InterpolatedPosition.Frame.Origin.X, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,18 +6,19 @@ using Xunit;
|
||||||
namespace AcDream.Core.Tests.Physics;
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// PositionManagerTests — 6 tests covering ComputeOffset.
|
// RemoteMotionCombinerTests — 6 tests covering ComputeOffset (class renamed R5
|
||||||
|
// from PositionManager; see RemoteMotionCombiner's class doc).
|
||||||
//
|
//
|
||||||
// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730).
|
// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730).
|
||||||
// Pure-function combiner: animation root motion (seqVel × dt, rotated by
|
// Pure-function combiner: animation root motion (seqVel × dt, rotated by
|
||||||
// body orientation) + InterpolationManager.AdjustOffset correction.
|
// body orientation) + InterpolationManager.AdjustOffset correction.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public sealed class PositionManagerTests
|
public sealed class RemoteMotionCombinerTests
|
||||||
{
|
{
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static PositionManager Make() => new();
|
private static RemoteMotionCombiner Make() => new();
|
||||||
|
|
||||||
private static InterpolationManager EmptyInterp() => new();
|
private static InterpolationManager EmptyInterp() => new();
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue