From dae5b1ea68f734b19dc2a41f436d342a4adf7b07 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Jul 2026 09:05:44 +0200 Subject: [PATCH] fix(physics): TS-46 - seed the sweep from the Setup's own sphere list Campaign P Slice P3 item 1. Retail CPhysicsObj::transition (0x00512dc0) seeds the collision sweep from CPartArray::GetSphere (the Setup's own <=2-sphere list, each origin+radius scaled by m_scale) via SPHEREPATH::init_sphere (0x0050c670) -- not from a symmetric two-scalar (radius, height) capsule reconstruction. The human Setup 0x02000001's authored spheres are (0,0,0.475) r=.48 and (0,0,1.350) r=.48; the old reconstruction from (0.48, 1.835) produced (0,0,0.48) + (0,0,1.355), a 5 mm head-center offset the TS-46 register row documented as a residual. Port: - SpherePath.InitPath gains a sphere-list overload (ImmutableArray< FlatCollisionSphere>, scale) sharing a new InitPathCore with the existing (radius, height) overload, which is now the degenerate 2-scalar case of the same code -- byte-for-byte unchanged, so every captured-fixture replay (CellarUpTrajectoryReplayTests, DoorBugTrajectoryReplayTests, CellarLipWedgeTests) keeps passing unmodified. - PhysicsEngine.ResolveWithTransition gains optional sphereList/ sphereScale parameters; empty/default preserves the legacy scalar path for every pre-existing caller. - LiveEntityMotionRuntimeController.GetSetupMoverShape is a new sibling of GetSetupCylinder (left untouched) that resolves the Setup's own sphere list plus Setup-derived step-up/step-down (CPartArray::GetStepUpHeight/GetStepDownHeight, 0x005180d0/0x005180f0, x ObjScale, 0.4 m fallback matching the pre-existing literal). - Threaded through PlayerMovementController (both resolve call sites, new SphereList property set by PlayerModeController.ApplyStepHeights and the Headless world projection), RuntimeRemotePhysicsUpdater (Tick + TickHidden), and RuntimeOrdinaryPhysicsUpdater.TryBegin. Remote/ordinary step heights are now Setup-derived instead of a hardcoded 0.4f literal. Projectile and camera-probe sweeps are untouched (already single-sphere-exact). - PlayerModeController.ApplyStepHeights also now applies the x ObjScale multiply to the player's own step heights (previously only the remote/ordinary paths did), closing an adjacent gap the P3 research flagged. Ts46SphereListConformanceTests proves the sphere-list overload sees the exact dat spheres (not the reconstruction), that the scalar overload is unchanged, and that ResolveWithTransition's sphereList parameter actually drives the sweep (a decoy-scalar control pair using a head-height obstacle sphere). Register: TS-46 retired (both residuals it named are closed); header count corrected to 40 active TS rows. dotnet build + dotnet test (Core.Tests 3991/2 skip, Runtime.Tests 425/0, App.Tests 3968/3 skip, complete solution build) all green. Co-Authored-By: Claude Opus 5 --- .../retail-divergence-register.md | 3 +- .../LivePresentationComposition.cs | 3 +- src/AcDream.App/Input/PlayerModeController.cs | 39 ++- ...DeferredLiveEntityMotionRuntimeBindings.cs | 11 + .../LiveEntityMotionRuntimeController.cs | 40 +++ .../LiveEntityOrdinaryPhysicsUpdater.cs | 18 +- .../Physics/RemotePhysicsUpdater.cs | 23 +- src/AcDream.App/Rendering/GameWindow.cs | 1 + src/AcDream.Core/Physics/PhysicsEngine.cs | 49 +++- src/AcDream.Core/Physics/TransitionTypes.cs | 120 +++++++- .../Hosting/HeadlessSessionWorldProjection.cs | 6 + .../Gameplay/PlayerMovementController.cs | 31 +- .../Physics/RuntimeOrdinaryPhysicsUpdater.cs | 20 +- .../Physics/RuntimeRemotePhysicsUpdater.cs | 37 ++- .../LivePresentationCompositionTests.cs | 4 + ...redLiveEntityMotionRuntimeBindingsTests.cs | 3 + .../LiveEntityOrdinaryPhysicsUpdaterTests.cs | 6 +- .../Physics/ProjectileControllerTests.cs | 1 + .../Physics/RemotePhysicsUpdaterTests.cs | 3 + .../LiveEntityAnimationSchedulerTests.cs | 4 +- .../Physics/Ts46SphereListConformanceTests.cs | 276 ++++++++++++++++++ 21 files changed, 648 insertions(+), 50 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/Ts46SphereListConformanceTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1c604fa6..91a8da42 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -234,7 +234,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-126 | One monotonic Stopwatch-backed clock (`TransportClock`) drives every transport gate (2.0 s ack, 0.6 s NAK, 0.333 s handshake retry, 0.5 s interval, 5 s assembler sweep); retail splits gates between `Timer::cur_time` (server-adjusted) and `Timer::local_time`. | `src/AcDream.Core.Net/Transport/TransportClock.cs` | The cur/local split only matters for gates that must track server clock adjustments; none of the ported gates semantically depend on server time — they are local cadences. A single injectable source also gives the virtual-clock test seam every conformance suite relies on. | A future port of a genuinely server-clock-relative gate could silently use the wrong clock if it reuses TransportClock without checking this row. | `SharedNet::EnqueuePak @ 0x00543B10` (cur_time); `ClientNet::ProcessConnection @ 0x00545450` (local_time for the 140 s check) | | AP-127 | Campaign P Slice P1's run/jump base-skill chain omits two minor retail additive/multiplier terms feeding `CACQualities::InqRunRate`/`InqJumpVelocity` BEFORE `EnchantSkill` runs (property `0x146` "> 0 → +5" bonus; property `0x158` "specialized skill" doubling of a PP-derived term), and reads the raw wire current-stamina value for the zero-skill gate rather than the retail-adjusted local copy (`EnchantAttribute2nd(ATTR2ND_STAMINA)` can apply a Stamina-buff to that check's own copy without changing the displayed vital) | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`ApplySkillEnchantments`); `src/AcDream.Core/Physics/PlayerWeenie.cs` (`InqRunRate`/`InqJumpVelocity` stamina==0 gate) | Bounded per the P1 plan's explicit scope ("port only what the run/jump query path needs... not a general effective-skill engine"); both terms are rare/small relative to the dominant formulaBonus+init+ranks+vitae chain, which IS fully ported | A character with the specific rare property set (0x146/0x158) or an active Stamina-buff at exactly 0 raw stamina predicts a slightly different run/jump skill than retail; low practical impact | `CACQualities::InqRunRate` 0x00592800 pc 413824 (0x146/0x158 reads); `CEnchantmentRegistry::EnchantAttribute2nd` 0x00594670 pc 416169 | -## 4. Temporary stopgap (TS) — 41 active rows (TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port, see the AD-53/AD-54 rows for the two compensating branches it left registered; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 40 active rows (TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port, see the AD-53/AD-54 rows for the two compensating branches it left registered; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -267,7 +267,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly | | ~~TS-41~~ | **RETIRED 2026-07-07 (remote-creature de-overlap #184)** — the SERVERVEL synth-velocity body-drive (`Body.Velocity = ServerVelocity` / `get_state_velocity()` leg) is DELETED. Grounded NPC remotes now translate by the retail interp CATCH-UP (`RemoteMotionCombiner.ComputeOffset` → `InterpolationManager::adjust_offset` toward the MoveOrTeleport-queued server waypoint) and `MovementManager::UseTime` (`TickRemoteMoveTo`) runs UNCONDITIONALLY per tick — the retail `UpdateObjectInternal` shape (no wire-velocity leg-driver). The de-overlap sweep resolves the catch-up movement; the resolved position is written back into the SHADOW (AP-86) so it persists. Residual: the non-retail anim-cycle stale-stop heuristic (`ApplyServerControlledVelocityCycle(Zero)` on a >0.6 s velocity-staleness timer) is kept as ANIM-only and stays covered by **AP-80**; it no longer drives the body. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (grounded NPC branch) | — | — | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` @0x00515998, unconditional); `MoveOrTeleport` 0x00516330; `InterpolationManager::adjust_offset` 0x00555d30 | | TS-44 | NPC UpdatePosition **enqueue is suppressed while StickyManager is armed** (`PositionManager.GetStickyObjectId() != 0`). Position and complete orientation otherwise share the ported `InterpolateTo → Position::subtract2 → PositionManager::adjust_offset` Frame, so the former orientation hard-snap residual is retired. Retail would still enqueue the server Position and let Sticky overwrite that Frame each tick; acdream retains the gate so no queued waypoint survives the stick | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (NPC `snapSuppressedByStick` gate) | Avoids replaying an old ACE waypoint immediately after a stick lease ends; all live during-stick pose ownership is now otherwise retail-shaped | After unstick the body waits for the next UP instead of consuming the latest waypoint already in the queue; at low packet cadence this can pause correction for one update interval | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330; retire by allowing enqueue while Sticky overwrites the shared complete Frame | -| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`GetSetupCylinder`); `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (remote sweep fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` | | ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-91` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) | | TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` | | TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` | diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index a89a786c..a9219c86 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -481,7 +481,8 @@ internal sealed class LivePresentationCompositionPhase static value => value.Dispose()); var ordinaryPhysicsUpdater = new LiveEntityOrdinaryPhysicsUpdater( d.EntityObjects.Physics, - d.MotionBindings.GetSetupCylinder); + d.MotionBindings.GetSetupCylinder, + d.MotionBindings.GetSetupMoverShape); var animationScheduler = new LiveEntityAnimationScheduler( liveEntities, d.PlayerIdentity, diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs index 1b76538c..6a7385be 100644 --- a/src/AcDream.App/Input/PlayerModeController.cs +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; +using System.Linq; using System.Numerics; using AcDream.App.Interaction; using AcDream.App.Net; @@ -370,7 +372,7 @@ internal sealed class PlayerModeController : + $"run={_skills.RunSkill} jump={_skills.JumpSkill}"); } - ApplyStepHeights(controller, playerEntity); + ApplyStepHeights(controller, playerEntity, playerGuid); uint initialCellId = ResolveInitialCell(playerGuid, playerEntity); Action? drainPriorAnimationQueue = null; @@ -532,7 +534,8 @@ internal sealed class PlayerModeController : private void ApplyStepHeights( PlayerMovementController controller, - WorldEntity playerEntity) + WorldEntity playerEntity, + uint playerGuid) { if ((playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) { @@ -544,22 +547,48 @@ internal sealed class PlayerModeController : _collisionAssets.CacheSetup( playerEntity.SourceGfxObjOrSetupId, setup); + // TS-46 (2026-07-30): CPartArray::GetStepUpHeight/GetStepDownHeight + // (0x005180d0/0x005180f0) return setup->step_up_height * this->scale + // — apply the same ObjScale multiply the remote/ordinary paths now + // use (LiveEntityMotionRuntimeController.GetSetupMoverShape), for + // parity on a non-1.0-scale player (a rare but real case — e.g. a + // disguise/size-changing effect). Human ObjScale is 1.0 in the + // overwhelming common case, so this is a no-op there. + float scale = + _liveEntities.Snapshots.TryGetValue(playerGuid, out var sp) + && sp.ObjScale is { } objScale && objScale > 0f + ? objScale + : (playerEntity.Scale > 0f ? playerEntity.Scale : 1f); controller.StepUpHeight = setup is { StepUpHeight: > 0f } - ? setup.StepUpHeight + ? setup.StepUpHeight * scale : 0.4f; controller.StepDownHeight = setup is { StepDownHeight: > 0f } - ? setup.StepDownHeight + ? setup.StepDownHeight * scale : 0.4f; + // TS-46 (2026-07-30): the Setup's own ≤2-sphere list, verbatim — + // retail CPhysicsObj::transition (0x00512dc0) seeds the sweep + // from CPartArray::GetSphere, not a (radius, height) capsule + // reconstruction. Empty (no Setup, or a Setup with no sphere + // rows) leaves SphereList at its default empty value, which + // ResolveWithTransition treats as "use the legacy scalar + // reconstruction." + controller.SphereList = setup?.Spheres is { Count: > 0 } spheres + ? spheres + .Select(s => new FlatCollisionSphere(s.Origin, s.Radius)) + .ToImmutableArray() + : ImmutableArray.Empty; Console.WriteLine( $"physics: player step heights — StepUp={controller.StepUpHeight:F3} m " + $"(Setup.StepUpHeight={(setup?.StepUpHeight ?? 0f):F3}), " + $"StepDown={controller.StepDownHeight:F3} m " - + $"(Setup.StepDownHeight={(setup?.StepDownHeight ?? 0f):F3})"); + + $"(Setup.StepDownHeight={(setup?.StepDownHeight ?? 0f):F3}), " + + $"Spheres={controller.SphereList.Length}"); return; } controller.StepUpHeight = 0.4f; controller.StepDownHeight = 0.4f; + controller.SphereList = ImmutableArray.Empty; Console.WriteLine( "physics: player step heights — defaulting to 0.4 m (no setup dat)"); } diff --git a/src/AcDream.App/Physics/DeferredLiveEntityMotionRuntimeBindings.cs b/src/AcDream.App/Physics/DeferredLiveEntityMotionRuntimeBindings.cs index 6fbc456c..e8df1022 100644 --- a/src/AcDream.App/Physics/DeferredLiveEntityMotionRuntimeBindings.cs +++ b/src/AcDream.App/Physics/DeferredLiveEntityMotionRuntimeBindings.cs @@ -1,6 +1,8 @@ +using System.Collections.Immutable; using AcDream.App.Physics; using AcDream.App.Rendering; using AcDream.Core.Net; +using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using AcDream.Core.World; @@ -14,6 +16,11 @@ namespace AcDream.App.Physics; internal interface ILiveEntityMotionRuntimeBindings { (float Radius, float Height) GetSetupCylinder(uint serverGuid, WorldEntity entity); + + /// TS-46 sibling of — see + /// LiveEntityMotionRuntimeController.GetSetupMoverShape. + (ImmutableArray Spheres, float Scale, float StepUpHeight, float StepDownHeight) + GetSetupMoverShape(uint serverGuid, WorldEntity entity); bool RouteServerMoveTo( MovementManager movement, uint cellId, @@ -59,6 +66,10 @@ internal sealed class DeferredLiveEntityMotionRuntimeBindings uint serverGuid, WorldEntity entity) => Target.GetSetupCylinder(serverGuid, entity); + public (ImmutableArray Spheres, float Scale, float StepUpHeight, float StepDownHeight) + GetSetupMoverShape(uint serverGuid, WorldEntity entity) => + Target.GetSetupMoverShape(serverGuid, entity); + public bool RouteServerMoveTo( MovementManager movement, uint cellId, diff --git a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs index b4d0bafe..0f0ba09b 100644 --- a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs +++ b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using AcDream.App.Interaction; using AcDream.App.Rendering; using AcDream.App.World; @@ -283,6 +284,45 @@ internal sealed class LiveEntityMotionRuntimeController return (setup.Radius * scale, setup.Height * scale); } + /// + /// TS-46 (2026-07-30) sibling of : the + /// Setup's own ≤2-sphere list (retail CPhysicsObj::transition + /// 0x00512dc0 → SPHEREPATH::init_sphere 0x0050c670) plus + /// Setup-derived step-up/step-down (CPartArray::GetStepUpHeight/ + /// GetStepDownHeight, 0x005180d0/0x005180f0 — both scaled by the + /// object's own ObjScale, matching the existing 0.4 m literal fallback + /// the remote/ordinary callers already carried). + /// is deliberately UNTOUCHED — its callers want the single-radius/height + /// CYLINDER for sticky/moveto math, not the collision sweep shape. + /// Returns an empty sphere list (and the 0.4 m fallbacks) when the + /// entity has no resolvable/prepared Setup, or when the Setup carries no + /// sphere rows — 's + /// sphereList parameter treats empty as "use the legacy + /// two-scalar reconstruction", so this degrades gracefully rather than + /// degenerating the sweep. + /// + public (ImmutableArray Spheres, float Scale, float StepUpHeight, float StepDownHeight) + GetSetupMoverShape(uint serverGuid, AcDream.Core.World.WorldEntity entity) + { + FlatSetupCollision? setup = + _physicsDataCache.GetFlatSetup(entity.SourceGfxObjOrSetupId); + if (setup is null) + return (ImmutableArray.Empty, 1f, 0.4f, 0.4f); + + // Same scale resolution as GetSetupCylinder (see its own comment): + // the spawn record's ObjScale is authoritative for live spawns; a + // non-spawn entity (scenery) falls back to WorldEntity.Scale. + float scale = + _liveEntities.Snapshots.TryGetValue(serverGuid, out var sp) + && sp.ObjScale is { } objScale && objScale > 0f + ? objScale + : (entity.Scale > 0f ? entity.Scale : 1f); + + float stepUp = setup.StepUpHeight > 0f ? setup.StepUpHeight * scale : 0.4f; + float stepDown = setup.StepDownHeight > 0f ? setup.StepDownHeight * scale : 0.4f; + return (setup.Spheres, scale, stepUp, stepDown); + } + // #184 Slice 2a: ApplyPositionManagerDelta + SyncRemoteShadowToBody moved to // AcDream.App.Physics.RemotePhysicsUpdater. ApplyPositionManagerDelta had no // caller outside the DR tick; SyncRemoteShadowToBody is now called back via diff --git a/src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs b/src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs index aad3ff57..7797faaf 100644 --- a/src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs +++ b/src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using AcDream.App.World; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; @@ -17,15 +18,23 @@ internal sealed class LiveEntityOrdinaryPhysicsUpdater private readonly RuntimeOrdinaryPhysicsUpdater _runtime; private readonly Func _getSetupCylinder; + private readonly Func Spheres, float Scale, float StepUpHeight, float StepDownHeight)> + _getSetupMoverShape; public LiveEntityOrdinaryPhysicsUpdater( RuntimePhysicsState physics, - Func getSetupCylinder) + Func getSetupCylinder, + Func Spheres, float Scale, float StepUpHeight, float StepDownHeight)> + getSetupMoverShape) { _runtime = new RuntimeOrdinaryPhysicsUpdater( physics ?? throw new ArgumentNullException(nameof(physics))); _getSetupCylinder = getSetupCylinder ?? throw new ArgumentNullException(nameof(getSetupCylinder)); + _getSetupMoverShape = getSetupMoverShape + ?? throw new ArgumentNullException(nameof(getSetupMoverShape)); } public bool Tick( @@ -49,6 +58,7 @@ internal sealed class LiveEntityOrdinaryPhysicsUpdater if (record.PhysicsBody is not { } body) return false; var (radius, height) = _getSetupCylinder(record.ServerGuid, entity); + var shape = _getSetupMoverShape(record.ServerGuid, entity); bool ExternalOwnerValid() => IsCurrent( runtime, @@ -68,7 +78,11 @@ internal sealed class LiveEntityOrdinaryPhysicsUpdater sequencer, captureAnimationHooks, ExternalOwnerValid, - out RuntimeOrdinaryPhysicsCommit commit)) + out RuntimeOrdinaryPhysicsCommit commit, + sphereList: shape.Spheres, + sphereScale: shape.Scale, + stepUpHeight: shape.StepUpHeight, + stepDownHeight: shape.StepDownHeight)) { return false; } diff --git a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs index 3a171c42..a9b8ee74 100644 --- a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs +++ b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Numerics; using AcDream.App.Rendering; using AcDream.App.World; @@ -18,6 +19,9 @@ internal sealed class RemotePhysicsUpdater private readonly RuntimeRemotePhysicsUpdater _runtime; private readonly Func _getSetupCylinder; + private readonly Func Spheres, float Scale, float StepUpHeight, float StepDownHeight)> + _getSetupMoverShape; private readonly Action _applyServerControlledVelocityCycle; private readonly List _spatialRemoteSnapshot = new(); @@ -25,6 +29,9 @@ internal sealed class RemotePhysicsUpdater internal RemotePhysicsUpdater( RuntimePhysicsState physics, Func getSetupCylinder, + Func Spheres, float Scale, float StepUpHeight, float StepDownHeight)> + getSetupMoverShape, Action applyServerControlledVelocityCycle) { @@ -32,6 +39,8 @@ internal sealed class RemotePhysicsUpdater physics ?? throw new ArgumentNullException(nameof(physics))); _getSetupCylinder = getSetupCylinder ?? throw new ArgumentNullException(nameof(getSetupCylinder)); + _getSetupMoverShape = getSetupMoverShape + ?? throw new ArgumentNullException(nameof(getSetupMoverShape)); _applyServerControlledVelocityCycle = applyServerControlledVelocityCycle ?? throw new ArgumentNullException( @@ -189,6 +198,7 @@ internal sealed class RemotePhysicsUpdater ownerClockEpoch); var (radius, height) = _getSetupCylinder(ownerRecord.ServerGuid, entity); + var shape = _getSetupMoverShape(ownerRecord.ServerGuid, entity); Action? staleCycle = animationForVelocityCycle is null ? null @@ -221,7 +231,11 @@ internal sealed class RemotePhysicsUpdater entity.Rotation = snapshot.Orientation; return OwnerValid(); }, - OwnerValid); + OwnerValid, + sphereList: shape.Spheres, + sphereScale: shape.Scale, + stepUpHeight: shape.StepUpHeight, + stepDownHeight: shape.StepDownHeight); } public bool TickHidden( @@ -251,6 +265,7 @@ internal sealed class RemotePhysicsUpdater ownerClockEpoch); var (radius, height) = _getSetupCylinder(ownerRecord.ServerGuid, entity); + var shape = _getSetupMoverShape(ownerRecord.ServerGuid, entity); return _runtime.TickHidden( ownerRecord.Canonical, remote, @@ -270,7 +285,11 @@ internal sealed class RemotePhysicsUpdater entity.Rotation = snapshot.Orientation; return OwnerValid(); }, - OwnerValid); + OwnerValid, + sphereList: shape.Spheres, + sphereScale: shape.Scale, + stepUpHeight: shape.StepUpHeight, + stepDownHeight: shape.StepDownHeight); } public void SyncRemoteShadowToBody( diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index f60e9e24..255a037c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -652,6 +652,7 @@ public sealed class GameWindow : _remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater( _runtimeEntityObjects.Physics, _liveEntityMotionBindings.GetSetupCylinder, + _liveEntityMotionBindings.GetSetupMoverShape, AcDream.App.Physics.RemoteServerControlledVelocityCycle.Apply); _remoteInboundMotion = new AcDream.App.Physics.RemoteInboundMotionDispatcher( (movement, cellId, update) => diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 5b4dbf6e..90d3977f 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Numerics; namespace AcDream.Core.Physics; @@ -1093,7 +1094,17 @@ public sealed class PhysicsEngine Vector3? localSphereOrigin = null, Quaternion? beginOrientation = null, Quaternion? endOrientation = null, - uint designatedTargetId = 0) + uint designatedTargetId = 0, + // TS-46 (2026-07-30): the mover's own Setup ≤2-sphere list (retail + // CPhysicsObj::transition 0x00512dc0 → SPHEREPATH::init_sphere + // 0x0050c670), scaled by sphereScale (the object's own m_scale / + // wire ObjScale) exactly as init_sphere applies it per-sphere. + // Default/empty preserves the legacy sphereRadius/sphereHeight + // two-scalar reconstruction below — every pre-existing caller + // (camera probe, projectiles, captured-fixture replays) that omits + // this parameter is byte-for-byte unaffected. + ImmutableArray sphereList = default, + float sphereScale = 1f) { // A6.P3 #98 (2026-05-23) live capture. Filtered to IsPlayer so NPC / // remote ResolveWithTransition calls don't pollute the capture. Snapshot @@ -1185,15 +1196,33 @@ public sealed class PhysicsEngine transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal); } - transition.SpherePath.InitPath( - currentPos, - targetPos, - cellId, - sphereRadius, - sphereHeight, - localSphereOrigin, - beginOrientation, - endOrientation); + if (!sphereList.IsDefaultOrEmpty) + { + // TS-46: the Setup's verbatim sphere list, not the two-scalar + // capsule reconstruction. localSphereOrigin has no meaning + // here — every sphere already carries its own dat-authored + // origin. + transition.SpherePath.InitPath( + currentPos, + targetPos, + cellId, + sphereList, + sphereScale, + beginOrientation, + endOrientation); + } + else + { + transition.SpherePath.InitPath( + currentPos, + targetPos, + cellId, + sphereRadius, + sphereHeight, + localSphereOrigin, + beginOrientation, + endOrientation); + } // #145: supply the carried cell-relative frame anchor to the outdoor // membership pick. body.Position - body.CellPosition.Frame.Origin is the TRUE diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 0d3624ea..0dd9030e 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Collections.Immutable; using System.Numerics; using DatReaderWriter.Types; @@ -970,7 +971,16 @@ public sealed class SpherePath } /// - /// Initialize the path for a simple point-to-point movement. + /// Initialize the path for a simple point-to-point movement from a + /// (radius, height) capsule reconstruction. TS-46 (2026-07-30): this is + /// now the degenerate 2-scalar case of — + /// retail's own CPhysicsObj::transition (0x00512dc0) seeds the + /// sweep from the Setup's OWN sphere list (verbatim origin+radius per + /// sphere), not from a symmetric two-scalar capsule. Callers with a + /// resolved Setup should prefer the sphere-list overload; this scalar + /// overload remains for callers without one (camera probe, projectiles, + /// captured-fixture replays) and is unchanged byte-for-byte from the + /// pre-TS-46 behavior. /// public void InitPath( Vector3 begin, @@ -981,6 +991,97 @@ public sealed class SpherePath Vector3? localSphereOrigin = null, Quaternion? beginOrientation = null, Quaternion? endOrientation = null) + { + Vector3 origin0 = localSphereOrigin ?? new Vector3(0, 0, sphereRadius); + if (sphereHeight > 0) + { + InitPathCore( + begin, end, cellId, 2, + origin0, sphereRadius, + new Vector3(0, 0, sphereHeight - sphereRadius), sphereRadius, + beginOrientation, endOrientation); + } + else + { + InitPathCore( + begin, end, cellId, 1, + origin0, sphereRadius, + Vector3.Zero, 0f, + beginOrientation, endOrientation); + } + } + + /// + /// TS-46 (2026-07-30): initialize the path from the Setup's OWN authored + /// sphere list — retail CPhysicsObj::transition (0x00512dc0) → + /// SPHEREPATH::init_sphere (0x0050c670): up to 2 spheres, each + /// origin AND radius independently scaled by the object's own + /// m_scale (wire ObjScale), matching init_sphere(count, src, + /// scale)'s exact signature. longer than 2 + /// is capped, matching retail's hard num_sphere = min(count, 2). + /// An empty list falls back to retail's own numSphere == 0 arm + /// (transition() passes a single dummy sphere, scale 1.0) rather than + /// throwing, so a shapeless Setup degrades gracefully instead of + /// degenerating the sweep with a zero radius. + /// + public void InitPath( + Vector3 begin, + Vector3 end, + uint cellId, + ImmutableArray spheres, + float scale = 1f, + Quaternion? beginOrientation = null, + Quaternion? endOrientation = null) + { + if (spheres.IsDefaultOrEmpty) + { + InitPathCore( + begin, end, cellId, 1, + new Vector3(0, 0, PhysicsGlobals.DummySphereRadius), PhysicsGlobals.DummySphereRadius, + Vector3.Zero, 0f, + beginOrientation, endOrientation); + return; + } + + int count = spheres.Length <= 2 ? spheres.Length : 2; + FlatCollisionSphere s0 = spheres[0]; + if (count > 1) + { + FlatCollisionSphere s1 = spheres[1]; + InitPathCore( + begin, end, cellId, 2, + s0.Origin * scale, s0.Radius * scale, + s1.Origin * scale, s1.Radius * scale, + beginOrientation, endOrientation); + } + else + { + InitPathCore( + begin, end, cellId, 1, + s0.Origin * scale, s0.Radius * scale, + Vector3.Zero, 0f, + beginOrientation, endOrientation); + } + } + + /// + /// Shared tail of both InitPath overloads: seeds begin/end/cell, + /// orientation, the (≤2) local sphere slots, and the globalized + /// array. Extracted so the sphere-list + /// overload (TS-46) and the legacy scalar overload can never drift from + /// each other on anything but sphere-source geometry. + /// + private void InitPathCore( + Vector3 begin, + Vector3 end, + uint cellId, + int numSphere, + Vector3 origin0, + float radius0, + Vector3 origin1, + float radius1, + Quaternion? beginOrientation, + Quaternion? endOrientation) { BeginPos = begin; EndPos = end; @@ -992,18 +1093,13 @@ public sealed class SpherePath CurOrientation = BeginOrientation; CheckOrientation = BeginOrientation; - LocalSphere[0].Origin = localSphereOrigin ?? new Vector3(0, 0, sphereRadius); - LocalSphere[0].Radius = sphereRadius; - - if (sphereHeight > 0) + NumSphere = numSphere; + LocalSphere[0].Origin = origin0; + LocalSphere[0].Radius = radius0; + if (numSphere > 1) { - NumSphere = 2; - LocalSphere[1].Origin = new Vector3(0, 0, sphereHeight - sphereRadius); - LocalSphere[1].Radius = sphereRadius; - } - else - { - NumSphere = 1; + LocalSphere[1].Origin = origin1; + LocalSphere[1].Radius = radius1; } SetCheckPos(begin, cellId); diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs index 373a4d3d..8f364dec 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs @@ -461,5 +461,11 @@ internal sealed class HeadlessSessionWorldProjection controller.StepDownHeight = setup.StepDownHeight > 0f ? setup.StepDownHeight : 0.4f; + // TS-46 (2026-07-30): the prepared package already carries the + // Setup's verbatim sphere list — no raw-DAT read needed here (unlike + // the graphical PlayerModeController.ApplyStepHeights, which reads + // DatReaderWriter.DBObjs.Setup directly). Empty falls back to + // ResolveWithTransition's legacy scalar reconstruction. + controller.SphereList = setup.Spheres; } } diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index 86ce190e..ff3cea8d 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -163,6 +163,21 @@ public sealed class PlayerMovementController /// public float StepDownHeight { get; set; } = 0.4f; + /// + /// TS-46 (2026-07-30): the player's own Setup ≤2-sphere list (dat + /// CSphere Origin+Radius), verbatim per retail + /// CPhysicsObj::transition (0x00512dc0) → + /// SPHEREPATH::init_sphere (0x0050c670). Set at world-entry by + /// PlayerModeController.ApplyStepHeights alongside + /// /. Default + /// (empty) falls back to ResolveWithTransition's legacy + /// (0.48, 1.835) two-scalar capsule reconstruction — the human Setup + /// 0x02000001's authored spheres are (0,0,0.475) r=.48 and + /// (0,0,1.350) r=.48, a 5 mm improvement over the reconstruction's + /// (0,0,0.48) + (0,0,1.355). + /// + public System.Collections.Immutable.ImmutableArray SphereList { get; set; } + /// /// Retail CPhysicsObj::m_scale. Grounded CSequence root /// displacement is multiplied by this value before PositionManager @@ -1404,7 +1419,12 @@ public sealed class PlayerMovementController isOnGround: previousOnWalkable, body: _body, moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, - movingEntityId: LocalEntityId); + movingEntityId: LocalEntityId, + // TS-46: the player's own Setup sphere list, scaled by + // ObjectScale. Empty falls back to the 0.48/1.835 + // reconstruction above. + sphereList: SphereList, + sphereScale: ObjectScale); _body.CommitTransitionPosition(resolved.CellId, resolved.Position); PhysicsObjUpdate.CommitSetPositionTransition( _body, @@ -1849,7 +1869,14 @@ public sealed class PlayerMovementController // when the local player entity spawns (or stays 0 in tests, in // which case there's no registered ShadowEntry to collide with // anyway). - movingEntityId: LocalEntityId); + movingEntityId: LocalEntityId, + // TS-46 (2026-07-30): the player's own Setup sphere list + // (0x02000001: (0,0,0.475) r=.48 + (0,0,1.350) r=.48), + // scaled by ObjectScale. Empty (unset/no Setup resolved yet) + // falls back to the sphereRadius/sphereHeight reconstruction + // above. + sphereList: SphereList, + sphereScale: ObjectScale); // L.4-diag (2026-04-30): trace position transitions so we can see // whether the body is actually moving frame-to-frame on the steep diff --git a/src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.cs b/src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.cs index 874fcba7..df9403cb 100644 --- a/src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.cs +++ b/src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.cs @@ -49,7 +49,15 @@ internal sealed class RuntimeOrdinaryPhysicsUpdater AnimationSequencer? sequencer, Action captureAnimationHooks, Func? externalOwnerValid, - out RuntimeOrdinaryPhysicsCommit commit) + out RuntimeOrdinaryPhysicsCommit commit, + // TS-46 (2026-07-30): the Setup's own ≤2-sphere list + Setup-derived + // step heights (LiveEntityMotionRuntimeController.GetSetupMoverShape). + // Default/empty preserves the pre-TS-46 0.4 m literal fallback below. + System.Collections.Immutable.ImmutableArray + sphereList = default, + float sphereScale = 1f, + float stepUpHeight = 0.4f, + float stepDownHeight = 0.4f) { ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(rootFrame); @@ -130,14 +138,18 @@ internal sealed class RuntimeOrdinaryPhysicsUpdater sourceCellId, radius, height, - stepUpHeight: 0.4f, - stepDownHeight: 0.4f, + stepUpHeight: stepUpHeight, // TS-46: Setup-derived, was a 0.4f literal + stepDownHeight: stepDownHeight, // TS-46: Setup-derived, was a 0.4f literal isOnGround: previousOnWalkable, body: body, moverFlags: IsPlayerGuid(record.ServerGuid) ? ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide : ObjectInfoState.EdgeSlide, - movingEntityId: movingEntityId); + movingEntityId: movingEntityId, + // TS-46: the Setup's own sphere list, scaled by ObjScale. + // Empty falls back to the radius/height reconstruction above. + sphereList: sphereList, + sphereScale: sphereScale); if (resolved.Ok) { diff --git a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs index 6401c918..ba91fefd 100644 --- a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs +++ b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs @@ -75,7 +75,15 @@ internal sealed class RuntimeRemotePhysicsUpdater System.Action? applyStaleVelocityCycle = null, System.Func? acknowledgeProjection = null, - System.Func? externalOwnerValid = null) + System.Func? externalOwnerValid = null, + // TS-46 (2026-07-30): the Setup's own ≤2-sphere list + Setup-derived + // step heights (LiveEntityMotionRuntimeController.GetSetupMoverShape). + // Default/empty preserves the pre-TS-46 human-capsule fallback below. + System.Collections.Immutable.ImmutableArray + sphereList = default, + float sphereScale = 1f, + float stepUpHeight = 0.4f, + float stepDownHeight = 0.4f) { ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(rm); @@ -344,8 +352,13 @@ internal sealed class RuntimeRemotePhysicsUpdater preIntegratePos, postIntegratePos, rm.CellId, sphereRadius: deR, sphereHeight: deH, - stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f - stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f + stepUpHeight: stepUpHeight, // TS-46: Setup-derived, was a 0.4f literal + stepDownHeight: stepDownHeight, // TS-46: Setup-derived, was a 0.4f literal + // TS-46: the Setup's own sphere list, scaled by the + // creature's own ObjScale. Empty falls back to the + // deR/deH two-scalar reconstruction above. + sphereList: sphereList, + sphereScale: sphereScale, // K-fix9 (2026-04-26): mirror the K-fix7 gate — // airborne remotes must NOT pre-seed the // ContactPlane, otherwise AdjustOffset's snap-to-plane @@ -629,7 +642,13 @@ internal sealed class RuntimeRemotePhysicsUpdater AcDream.Core.Physics.AnimationSequencer? sequencer = null, System.Func? acknowledgeProjection = null, - System.Func? externalOwnerValid = null) + System.Func? externalOwnerValid = null, + // TS-46 (2026-07-30): see the visible Tick's identical parameters. + System.Collections.Immutable.ImmutableArray + sphereList = default, + float sphereScale = 1f, + float stepUpHeight = 0.4f, + float stepDownHeight = 0.4f) { ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(rm); @@ -700,15 +719,19 @@ internal sealed class RuntimeRemotePhysicsUpdater rm.CellId, radius, height, - stepUpHeight: 0.4f, - stepDownHeight: 0.4f, + stepUpHeight: stepUpHeight, // TS-46: Setup-derived, was a 0.4f literal + stepDownHeight: stepDownHeight, // TS-46: Setup-derived, was a 0.4f literal isOnGround: previousOnWalkable, body: rm.Body, moverFlags: IsPlayerGuid(record.ServerGuid) ? AcDream.Core.Physics.ObjectInfoState.IsPlayer | AcDream.Core.Physics.ObjectInfoState.EdgeSlide : AcDream.Core.Physics.ObjectInfoState.EdgeSlide, - movingEntityId: localEntityId); + movingEntityId: localEntityId, + // TS-46: the Setup's own sphere list, scaled by ObjScale. + // Empty falls back to the radius/height reconstruction above. + sphereList: sphereList, + sphereScale: sphereScale); rm.Body.Position = resolved.Position; if (resolved.CellId != 0) committedCellId = resolved.CellId; diff --git a/tests/AcDream.App.Tests/Composition/LivePresentationCompositionTests.cs b/tests/AcDream.App.Tests/Composition/LivePresentationCompositionTests.cs index eb79ccd3..ef8e90b8 100644 --- a/tests/AcDream.App.Tests/Composition/LivePresentationCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/LivePresentationCompositionTests.cs @@ -203,6 +203,10 @@ public sealed class LivePresentationCompositionTests uint serverGuid, WorldEntity entity) => (radius, 1f); + public (System.Collections.Immutable.ImmutableArray Spheres, float Scale, float StepUpHeight, float StepDownHeight) + GetSetupMoverShape(uint serverGuid, WorldEntity entity) => + (System.Collections.Immutable.ImmutableArray.Empty, 1f, 0.4f, 0.4f); + public bool RouteServerMoveTo( MovementManager movement, uint cellId, diff --git a/tests/AcDream.App.Tests/Physics/DeferredLiveEntityMotionRuntimeBindingsTests.cs b/tests/AcDream.App.Tests/Physics/DeferredLiveEntityMotionRuntimeBindingsTests.cs index b66385f2..2ba83c66 100644 --- a/tests/AcDream.App.Tests/Physics/DeferredLiveEntityMotionRuntimeBindingsTests.cs +++ b/tests/AcDream.App.Tests/Physics/DeferredLiveEntityMotionRuntimeBindingsTests.cs @@ -65,6 +65,9 @@ public sealed class DeferredLiveEntityMotionRuntimeBindingsTests Calls.Add($"cylinder:{guid}"); return (2f, 3f); } + public (System.Collections.Immutable.ImmutableArray Spheres, float Scale, float StepUpHeight, float StepDownHeight) + GetSetupMoverShape(uint guid, WorldEntity entity) => + (System.Collections.Immutable.ImmutableArray.Empty, 1f, 0.4f, 0.4f); public bool RouteServerMoveTo( MovementManager movement, uint cellId, diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs index e7e61920..e71dc71c 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs @@ -57,7 +57,8 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests var updater = new LiveEntityOrdinaryPhysicsUpdater( live.Physics, - (_, _) => (0.48f, 1.835f)); + (_, _) => (0.48f, 1.835f), + (_, _) => (System.Collections.Immutable.ImmutableArray.Empty, 1f, 0.4f, 0.4f)); var rootFrame = new Frame { Origin = new Vector3(2f, 0f, 0f), @@ -124,7 +125,8 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests Vector3 retainedEntityPosition = entity.Position; var updater = new LiveEntityOrdinaryPhysicsUpdater( live.Physics, - (_, _) => (0.48f, 1.835f)); + (_, _) => (0.48f, 1.835f), + (_, _) => (System.Collections.Immutable.ImmutableArray.Empty, 1f, 0.4f, 0.4f)); Assert.False(updater.Tick( live, diff --git a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs index a3baee81..9d3041e9 100644 --- a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs @@ -125,6 +125,7 @@ public sealed class ProjectileControllerTests var updater = new RemotePhysicsUpdater( fixture.Live.Physics, (_, _) => (0.48f, 1.835f), + (_, _) => (System.Collections.Immutable.ImmutableArray.Empty, 1f, 0.4f, 0.4f), (_, _, _, _) => { }); int publishedRoots = 0; diff --git a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs index 034dea73..f17b57af 100644 --- a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs @@ -722,6 +722,7 @@ public sealed class RemotePhysicsUpdaterTests var updater = new RemotePhysicsUpdater( live.Physics, (_, _) => (0.48f, 1.835f), + (_, _) => (System.Collections.Immutable.ImmutableArray.Empty, 1f, 0.4f, 0.4f), (_, _, _, _) => { }); var published = new List(); var partPoseDirty = new List(); @@ -804,6 +805,7 @@ public sealed class RemotePhysicsUpdaterTests new RemotePhysicsUpdater( live.Physics, (_, _) => (0.48f, 1.835f), + (_, _) => (System.Collections.Immutable.ImmutableArray.Empty, 1f, 0.4f, 0.4f), (_, _, _, _) => { })); } @@ -995,6 +997,7 @@ public sealed class RemotePhysicsUpdaterTests Updater = new RemotePhysicsUpdater( Live.Physics, (_, _) => (0.48f, 1.835f), + (_, _) => (System.Collections.Immutable.ImmutableArray.Empty, 1f, 0.4f, 0.4f), (_, _, _, _) => { }); } diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs index 94fd25f5..0bbadff2 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs @@ -629,10 +629,12 @@ public sealed class LiveEntityAnimationSchedulerTests var remotePhysics = new RemotePhysicsUpdater( live.Physics, (_, _) => (0.48f, 1.835f), + (_, _) => (System.Collections.Immutable.ImmutableArray.Empty, 1f, 0.4f, 0.4f), (_, _, _, _) => { }); var ordinaryPhysics = new LiveEntityOrdinaryPhysicsUpdater( live.Physics, - (_, _) => (0.48f, 1.835f)); + (_, _) => (0.48f, 1.835f), + (_, _) => (System.Collections.Immutable.ImmutableArray.Empty, 1f, 0.4f, 0.4f)); var identity = new LocalPlayerIdentityState { ServerGuid = localPlayerGuid }; var poses = new EntityEffectPoseRegistry(); foreach (LiveEntityRecord record in live.MaterializedRecords) diff --git a/tests/AcDream.Core.Tests/Physics/Ts46SphereListConformanceTests.cs b/tests/AcDream.Core.Tests/Physics/Ts46SphereListConformanceTests.cs new file mode 100644 index 00000000..5e8bfde0 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Ts46SphereListConformanceTests.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Conformance tests for Campaign P Slice P3's TS-46 port: retail +/// CPhysicsObj::transition (0x00512dc0) seeds the sweep from the +/// Setup's OWN sphere list via SPHEREPATH::init_sphere (0x0050c670), +/// not from a symmetric two-scalar (radius, height) capsule reconstruction. +/// The dat human Setup 0x02000001's authored spheres are (0,0,0.475) +/// r=.48 and (0,0,1.350) r=.48 (Height = 1.835); the pre-TS-46 +/// reconstruction from (0.48, 1.835) produced a foot sphere at +/// (0,0,0.48) and a head sphere at (0,0,1.355) — a 5 mm +/// head-center offset that TS-46's own register row (retail-divergence- +/// register.md) documented as a residual. These tests prove the sphere-list +/// overload now sees the EXACT dat spheres, that the pre-existing scalar +/// overload is byte-for-byte unchanged (so captured-fixture replays that +/// use it stay green), and that +/// actually routes the sphere list into the sweep rather than silently +/// preferring the legacy scalars when both are supplied. +/// +public class Ts46SphereListConformanceTests +{ + private readonly ITestOutputHelper _out; + public Ts46SphereListConformanceTests(ITestOutputHelper output) => _out = output; + + private const uint TestLandblockId = 0xA9C50000u; + private const uint TestCellId = TestLandblockId | 0x0001u; + + // Human Setup 0x02000001's verbatim authored sphere list. + private static readonly ImmutableArray HumanSetupSpheres = + ImmutableArray.Create( + new FlatCollisionSphere(new Vector3(0f, 0f, 0.475f), 0.48f), + new FlatCollisionSphere(new Vector3(0f, 0f, 1.350f), 0.48f)); + + [Fact] + public void SphereListInitPath_MatchesDatSpheresExactly_NotTheReconstruction() + { + var sp = new SpherePath(); + + sp.InitPath( + begin: Vector3.Zero, + end: new Vector3(1f, 0f, 0f), + cellId: TestCellId, + spheres: HumanSetupSpheres, + scale: 1f); + + Assert.Equal(2, sp.NumSphere); + + // Foot sphere: dat-exact (0,0,0.475) r=.48 — NOT the reconstruction's + // (0,0,0.48) (the reconstruction and the dat happen to agree on the + // RADIUS-as-Z-origin coincidence for the foot sphere, but the true + // origin is 0.475, not 0.48 — assert the dat value, not the coincidence). + Assert.Equal(new Vector3(0f, 0f, 0.475f), sp.LocalSphere[0].Origin); + Assert.Equal(0.48f, sp.LocalSphere[0].Radius); + + // Head sphere: dat-exact (0,0,1.350) — NOT the reconstruction's + // (0,0,1.355). This is the load-bearing assertion: TS-46's register + // row documents this exact 5 mm gap as the residual being retired. + Assert.Equal(new Vector3(0f, 0f, 1.350f), sp.LocalSphere[1].Origin); + Assert.Equal(0.48f, sp.LocalSphere[1].Radius); + Assert.NotEqual(1.355f, sp.LocalSphere[1].Origin.Z); + } + + [Fact] + public void SphereListInitPath_AppliesScaleToOriginAndRadius() + { + var sp = new SpherePath(); + sp.InitPath( + begin: Vector3.Zero, + end: Vector3.Zero, + cellId: TestCellId, + spheres: HumanSetupSpheres, + scale: 2f); + + // Retail SPHEREPATH::init_sphere multiplies EACH sphere's origin AND + // radius by the object's own m_scale (wire ObjScale) — not just the + // radius. + Assert.Equal(new Vector3(0f, 0f, 0.95f), sp.LocalSphere[0].Origin); + Assert.Equal(0.96f, sp.LocalSphere[0].Radius); + Assert.Equal(new Vector3(0f, 0f, 2.70f), sp.LocalSphere[1].Origin); + Assert.Equal(0.96f, sp.LocalSphere[1].Radius); + } + + [Fact] + public void SphereListInitPath_CapsAtTwoSpheres_MatchingRetailHardCap() + { + // SPHEREPATH::init_sphere (0x0050c670): num_sphere = min(count, 2). + var threeSpheres = ImmutableArray.Create( + new FlatCollisionSphere(new Vector3(0f, 0f, 0.475f), 0.48f), + new FlatCollisionSphere(new Vector3(0f, 0f, 1.350f), 0.48f), + new FlatCollisionSphere(new Vector3(0f, 0f, 2.0f), 0.20f)); + + var sp = new SpherePath(); + sp.InitPath(Vector3.Zero, Vector3.Zero, TestCellId, threeSpheres, scale: 1f); + + Assert.Equal(2, sp.NumSphere); + Assert.Equal(1.350f, sp.LocalSphere[1].Origin.Z); + } + + [Fact] + public void SphereListInitPath_EmptyList_FallsBackToDummySphere() + { + // Retail transition()'s numSphere==0 arm: a single dummy sphere, + // scale 1.0 — not a degenerate zero-radius sweep. + var sp = new SpherePath(); + sp.InitPath(Vector3.Zero, Vector3.Zero, TestCellId, ImmutableArray.Empty); + + Assert.Equal(1, sp.NumSphere); + Assert.Equal(PhysicsGlobals.DummySphereRadius, sp.LocalSphere[0].Radius); + } + + [Fact] + public void ScalarInitPath_IsByteForByteUnchanged_PreservingCapturedFixtureReplays() + { + // The pre-TS-46 scalar overload is now the degenerate 2-scalar case + // of the sphere-list overload — its OUTPUT must be identical to the + // pre-port behavior (the reconstructed 5 mm-off pair), because every + // captured-fixture replay test (CellarUpTrajectoryReplayTests, + // DoorBugTrajectoryReplayTests, CellarLipWedgeTests) calls this exact + // overload with recorded scalar values and must keep passing + // unmodified. + var sp = new SpherePath(); + sp.InitPath( + begin: Vector3.Zero, + end: Vector3.Zero, + cellId: TestCellId, + sphereRadius: 0.48f, + sphereHeight: 1.835f); + + Assert.Equal(2, sp.NumSphere); + Assert.Equal(new Vector3(0f, 0f, 0.48f), sp.LocalSphere[0].Origin); + Assert.Equal(0.48f, sp.LocalSphere[0].Radius); + // The reconstruction's own arithmetic: height − radius = 1.835 − 0.48 = 1.355. + Assert.Equal(new Vector3(0f, 0f, 1.355f), sp.LocalSphere[1].Origin); + Assert.Equal(0.48f, sp.LocalSphere[1].Radius); + + // And this is genuinely DIFFERENT from the dat-exact sphere list — + // documents the 5 mm residual the TS-46 register row described. + Assert.NotEqual(1.350f, sp.LocalSphere[1].Origin.Z); + } + + /// + /// Integration-level proof that 's + /// sphereList parameter actually drives the sweep, not just + /// in isolation. A tiny DECOY + /// (sphereRadius, sphereHeight) capsule that could never reach a + /// head-height obstacle is passed ALONGSIDE the real human sphere list; + /// if the engine silently preferred the decoy scalars over the supplied + /// list, the mover would sail through untouched. + /// + [Fact] + public void ResolveWithTransition_HonorsSphereListOverScalarDecoyWhenBothSupplied() + { + var engine = BuildEngine(); + const float HeadZ = 1.350f; // human Setup 0x02000001 Spheres[1].Origin.Z + RegisterObstacleSphere(engine, 0xD0D0u, x: 12f, y: 11.0f, z: HeadZ, radius: 0.30f); + + Vector3 pos = new(12f, 10f, 0f); + uint cellId = TestCellId; + bool grounded = true; + var perTick = new Vector3(0f, 0.08f, 0f); + + for (int tick = 0; tick < 30; tick++) + { + var result = engine.ResolveWithTransition( + pos, pos + perTick, cellId, + // DECOY scalars: a 5 cm radius / 10 cm capsule cannot reach + // Z=1.35 no matter what — if the engine used THESE, the + // mover would pass straight through the obstacle. + sphereRadius: 0.05f, + sphereHeight: 0.10f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f, + isOnGround: grounded, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + sphereList: HumanSetupSpheres, + sphereScale: 1f); + + pos = result.Position; + cellId = result.CellId; + grounded = result.IsOnGround; + } + + _out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})"); + + // Surface contact at Y = 11.0 − (0.48 + 0.30) = 10.22. + Assert.True(pos.Y < 10.6f, + "Supplying the real human sphere list must block the mover's head " + + $"sphere at the head-height obstacle, proving sphereList (not the " + + $"decoy scalar) drove the sweep; got Y={pos.Y:F3}"); + Assert.True(pos.Y > 9.9f, + $"The mover must actually reach the obstacle, not stop early; got Y={pos.Y:F3}"); + } + + /// + /// Control for the test above: the SAME decoy scalars, but with + /// sphereList omitted (empty/default). The decoy capsule can + /// never reach the head-height obstacle, so the mover must sail through + /// untouched — proving the block above genuinely comes from the + /// supplied sphere list, not some unrelated artifact of the obstacle + /// registration. + /// + [Fact] + public void ResolveWithTransition_EmptySphereList_FallsBackToScalarReconstruction() + { + var engine = BuildEngine(); + const float HeadZ = 1.350f; + RegisterObstacleSphere(engine, 0xD0D1u, x: 12f, y: 11.0f, z: HeadZ, radius: 0.30f); + + Vector3 pos = new(12f, 10f, 0f); + uint cellId = TestCellId; + bool grounded = true; + var perTick = new Vector3(0f, 0.08f, 0f); + + for (int tick = 0; tick < 30; tick++) + { + var result = engine.ResolveWithTransition( + pos, pos + perTick, cellId, + sphereRadius: 0.05f, + sphereHeight: 0.10f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f, + isOnGround: grounded, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide); + // sphereList omitted — default empty, legacy scalar reconstruction. + + pos = result.Position; + cellId = result.CellId; + grounded = result.IsOnGround; + } + + _out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})"); + + Assert.True(pos.Y > 11.9f, + "With no sphereList supplied, the decoy capsule (too short to reach " + + $"the head-height obstacle) must sail through untouched; got Y={pos.Y:F3}"); + } + + private static PhysicsEngine BuildEngine() + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + + var heights = new byte[81]; + var heightTable = new float[256]; // all zero → terrain Z = 0 + engine.AddLandblock( + landblockId: TestLandblockId, + terrain: new TerrainSurface(heights, heightTable), + cells: Array.Empty(), + portals: Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + return engine; + } + + private static void RegisterObstacleSphere( + PhysicsEngine engine, uint entityId, float x, float y, float z, float radius) + { + engine.ShadowObjects.Register( + entityId, gfxObjId: 0u, + new Vector3(x, y, z), Quaternion.Identity, radius, + worldOffsetX: 0f, worldOffsetY: 0f, landblockId: TestLandblockId, + collisionType: ShadowCollisionType.Sphere, + cylHeight: 0f, scale: 1f, + state: 0u, + flags: EntityCollisionFlags.IsCreature, + isStatic: false); + } +}