acdream/docs/research/2026-07-30-constraint-leash-constants.md
Erik 9e17554ed4 fix(physics): P5 commit 3 - retire TS-35, close #167 (ConstraintManager leash)
PhysicsBody.IsFullyConstrained now reflects real ConstraintManager state
(pushed every tick by the same per-tick pumps commit 2 wired), so
jump_is_allowed's already-ported gate (WeenieError 0x47) actually fires
while an object is rubber-banding hard against a server position
correction, closing the last piece of #167.

Housekeeping:
- Delete register row TS-35 (retired: the write side is no longer stubbed).
- Rewrite the stale doc comments on PhysicsBody.IsFullyConstrained,
  ConstraintManager (class + IsFullyConstrained), PositionManager.ConstrainTo,
  EntityPhysicsHost.PositionManager, and PlayerMovementController.PositionManager
  that described the leash as permanently unarmed/stubbed.
- Close #167 in ISSUES.md citing the research doc and commits e0629145 /
  7719d25b.
- Add an "as-ported" addendum to
  docs/research/2026-07-30-constraint-leash-constants.md naming the actual
  current seam owners (the doc's own open question flagged this as
  implementer-verify-required post-J-slices).
- Update docs/plans/2026-07-29-physics-parity-campaign.md's P5 status and
  CLAUDE.md's Campaign P summary to reflect #167's closure (items #153/#72
  remain open in P5).

Verification: complete solution suite green - 9,978 tests, 5 skips, 0
failures across all 9 test projects (Core.Tests, Runtime.Tests, App.Tests,
Headless.Tests, Core.Net.Tests, Content.Tests, UI.Abstractions.Tests,
Bake.Tests, Cli.Tests).
2026-07-30 12:12:29 +02:00

7.6 KiB

#167 ConstraintManager leash — constants recovered + arming flow (Campaign P P5)

2026-07-30. Both #167 blockers are now research-solved; only the port remains. No cdb session was needed: the two "unknown x87 constants" were recovered by decoding the raw machine code of the matching binary (C:\Users\erikn\Downloads\acclient.exe, v11.4186, PDB-paired — verified GUID match per the retail debugger toolchain doc).

1. The getters, byte-decoded (FACT)

CPhysicsObj::GetStartConstraintDistance @ 0x0050ebc0 and GetMaxConstraintDistance @ 0x0050ec10 are FPU-return getters whose fld operands Binary Ninja elided (the pseudo-C shows a bare this->m_position;). Raw bytes (file offset 0x10ebc0/0x10ec10):

3b 0d 58 3d 84 00   cmp  ecx, [0x00843d58]      ; this == player_object?
75 1d               jnz  non_player
8b 41 4c            mov  eax, [ecx+0x4c]        ; m_position.objcell_id
25 ff ff 00 00      and  eax, 0xFFFF
3d 00 01 00 00      cmp  eax, 0x100
73 07               jae  indoor                 ; low16 >= 0x100 = EnvCell
d9 05 <rdata>       fld  dword [outdoor_const]
c3                  ret
indoor: d9 05 <..>  fld  dword [indoor_const]
c3                  ret
non_player:         ; identical cell test, second constant pair

Constant values read from .rdata:

player outdoor player indoor remote outdoor remote indoor
Start (0x007c6abc..c8) 10.0 5.0 10.0 5.0
Max (0x007c6acc..d8) 50.0 20.0 50.0 20.0

Two consequences (FACT):

  1. The player-vs-remote branch is vestigial — both sides load identical values. Effective semantics: start = outdoor 10 m / indoor 5 m, max = outdoor 50 m / indoor 20 m (indoor = cell low16 ≥ 0x100).
  2. ACE's GetStartConstraintDistance is INVERTED (ACE PhysicsObj.cs:620: outdoor 5 / indoor 10). ACE's max mapping (outdoor 50 / indoor 20) matches the binary. Do NOT copy ACE's start mapping. (feedback_acme_oracle / binary-wins rule.)

2. The arming flow — SmartBox::HandleReceivedPosition @ 0x00453fd0 (FACT)

Pseudo-C lines ~92940-93060. After the update-time staleness gates and unset_parent/SetPlacementFrame handling:

  • Remote object (arg2 != this->player): call MoveOrTeleport(obj, &recvPos, ts, arg5, arg6); only if it returns nonzero (the position was NOT hard-teleport-applied), arm the leash anchored to the object's own current position: ConstrainTo(obj, &obj->m_position, start, max) (0x00454254-72).
  • Player, teleport-newer (newer_event(TELEPORT_TS, ts)): SmartBox::TeleportPlayer(&recvPos), then ConstrainTo(player, &recvPos, start, max) — anchored to the received position — then set_velocity(player, {0,0,0}, 1) (0x0045415f-c0).
  • Player, normal: ConstrainTo(player, &recvPos, start, max) anchored to the received position; then, if cmdinterp->UsePositionFromServer() && arg5, InterpolateTo(&recvPos, -GetAutonomyLevel()) (0x004541c9-422c).

The taper/enforcement side (ConstraintManager::UseTime feeding adjust_offset, IsFullyConstrained = ConstraintDistanceMax * 0.9 < offset) is already ported in src/AcDream.Core/Physics/Motion/ConstraintManager.cs (R5-V1, docs/research/2026-07-03-r5-managers/); it has simply never been armed.

3. Port shape for P5 (INFERENCE — implementation guidance)

  1. Add the four-constant getters (outdoor/indoor by full cell id low16) at the body/host layer; keep the vestigial player/remote split OUT (note it in a code comment with this doc as the cite).
  2. Arm at acdream's inbound-position equivalents of the three branches: the remote UpdatePosition acceptance tail (post-MoveOrTeleport routing in the live-entity network update path) and the local player's accepted-Position path (normal + teleport). Anchor per §2.
  3. PhysicsBody.IsFullyConstrained (register TS-35 stub) becomes a read through PositionManager/ConstraintManager, so jump_is_allowed's ported gate fires (WeenieError 0x47) while rubber-banding. TS-35 and #167 retire together, same commit.
  4. Conformance tests: constant table incl. the ACE-inversion pin (outdoor start MUST be 10, not 5); leash-armed jump refusal; remote-vs-player anchor difference; teleport-branch velocity zero.

Open questions

None for the constants/flow. Remaining implementation risk is only where acdream's position-acceptance seams sit today (J6.3 moved teleport correlation into Runtime — the implementer must find the current owner rather than trusting older file cites).

As-ported (Campaign P Slice P5, 2026-07-30)

The implementation risk flagged above resolved to these CURRENT seam owners (post-J-slices) — recorded here so the next reader doesn't have to re-derive them:

  • Constantssrc/AcDream.Core/Physics/Motion/ConstraintDistance.cs. Keyed purely on the object's own full cell id's low 16 bits (>= 0x0100 = indoor); the vestigial player/remote branch from §1 is deliberately not represented as an API parameter.
  • Remote armsrc/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs, the inbound UpdatePosition handler's remote branch (update.Guid != _playerServerGuid), immediately after the remotePlacementRequired hard-teleport block returns (that block already covers retail's MoveOrTeleport Branch A / hard-place case; everything reached past it is "did not hard-place"). One call site covers BOTH player-remote and NPC remotes — retail's SmartBox::HandleReceivedPosition doesn't distinguish them either, only GetStart/MaxConstraintDistance's now-omitted vestigial branch did. Anchored to the live IPhysicsObjHost.Position (which reads RemoteMotion.Body.Position + the tracked cell id), matching retail's "anchored to the object's own current position" — since the anchor and _host.Position read are the same value at call time, ConstraintManager.ConstrainTo's initial offset is always 0 regardless of whether the routing above just far-snapped or left a near-correction queued.
  • Remote per-tick taper + IsFullyConstrained push — already wired pre-P5 for the taper (RuntimeRemotePhysicsUpdater.Tick/TickHidden call PositionManager.AdjustOffset every tick via the pre-existing R5-V3 sticky/constraint chain); P5 added the PhysicsBody.IsFullyConstrained = host.PositionManager.IsFullyConstrained() push at the same two call sites, since MotionInterpreter only holds a PhysicsBody (no host reference) and needs a live value to read.
  • Local player armsrc/AcDream.Runtime/Gameplay/PlayerMovementController.cs: SetPositionCore (teleport: UnConstrain then re-ConstrainTo after the existing StopCompletelyAtPhysicsObjectBoundary velocity zero — composed, not duplicated) and CommitPreparedPosition (mirrors the same pair for the deferred player-mode-entry commit path); BlipPosition (ForcePosition: ConstrainTo only, no teardown — matches SmartBox::BlipPlayer surviving motion/velocity/stick). Anchored to _body.CellPosition (the just-applied received position).
  • Local player per-tick taper + pushPlayerMovementController.Update already called PositionManager.AdjustOffset every physics tick pre-P5; P5 added the _body.IsFullyConstrained = PositionManager?.IsFullyConstrained() ?? false push immediately after, at the same chokepoint.
  • TS-35 retirementPhysicsBody.IsFullyConstrained stayed a plain settable bool (not a computed property) so the ~40 pre-existing direct-set unit tests keep working; the per-tick pumps above are its single writers now, matching the project's per-entity single-owner-write pattern.