diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 1f552307..d6037b6d 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,58 @@ Copy this block when adding a new issue: --- +## #207 — Repeat attack continues after movement begins + +**Status:** IN-PROGRESS — fix shipped 2026-07-12, pending live gate +**Severity:** HIGH +**Component:** combat / input + +**Description:** With Repeat Attacks enabled, starting to move did not stop the +attack loop, making combat difficult to disengage from. + +**Root cause:** The attack controller had no port of +`ClientCombatSystem::AbortAutomaticAttack`, and semantic movement input was +never forwarded to combat as retail's `ACCmdInterp::HandleNewForwardMovement` +does. + +**Resolution:** Forward, backward, autorun, and jump press transitions now send +the retail cancel-attack event, clear repeat mode, and hide an active combat +power build. Turns and sidesteps remain independent, matching the retail +command-list split. Controller tests prove movement sends one cancel and that a +later AttackDone cannot launch another repeat. + +**Research:** `docs/research/2026-07-12-login-placement-and-repeat-cancel-pseudocode.md` + +**Acceptance:** Enable Repeat Attacks, start attacking, then press forward. The +attack loop stops immediately and movement proceeds normally. + +## #206 — Relogging into a monster leaves the player stuck + +**Status:** IN-PROGRESS — fix shipped 2026-07-12, pending live gate +**Severity:** HIGH +**Component:** physics / login lifecycle + +**Description:** If a monster moved onto the character's saved position while +the user was logged out, logging back in placed both bodies together and the +character could not escape. + +**Root cause:** Login used the cell/floor-only `Resolve` snap and omitted retail +`CTransition::find_placement_pos`, the radius-aware nearest-clear-position +search. The local player was also omitted from the shadow registry, so remote +creatures could not collide/de-overlap against it as retail physics objects do. + +**Resolution:** The retail four-metre concentric compass-ring placement search +is ported into Core and runs after login's existing AdjustPosition/floor snap. +The local player now registers a normal multipart collision shadow, skips that +shadow in its own transition, and republishes the resolved body position through +the same shared synchronizer as remotes. A flat-world conformance test begins +inside a monster and proves the selected position clears both bodies. + +**Research:** `docs/research/2026-07-12-login-placement-and-repeat-cancel-pseudocode.md` + +**Acceptance:** Log out beside a monster, let it occupy the saved position, and +log back in. The character is seated at the nearest clear point and can move. + ## #205 — Auto Target clears the toolbar and includes friendly creatures **Status:** DONE — 2026-07-12, user confirmed the replacement target appears correctly diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 6ac74f66..3ff4be06 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 36 rows (AD-39/40/41 added 2026-07-07 — the #182 verbatim player-physics rebuild; AD-25 narrowed to the remote-DR sweep, its player half retired by the rebuild) +## 2. Adaptation (AD) — 37 rows (AD-42 added 2026-07-12 — enter-world placement is split across the existing snap plus the ported object-aware ring search) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -103,6 +103,7 @@ accepted-divergence entries (#96, #49, #50). | AD-39 | The `frames_stationary_fall` ladder + fsf≥3 UP-contact-plane manufacture runs AFTER acdream's fused LKCP-restore/contact-marking block, deriving retail's `_redo` as `cleanAdvance \|\| OnWalkable`; retail (ACE Transition.cs:1029-1061) interleaves the fsf block BETWEEN the LKCP-restore (sets `_redo`) and the contact-marking (reads the manufactured plane) (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ValidateTransition` fsf tail) | acdream deliberately fused ACE's separate LKCP-restore + contact-mark blocks (the L.2.3c/L.2.4/A6.P3 contact-retention divergences); running the ladder after them and re-marking grounding inside the manufacture branch is semantically equal (a grounded wall-slide is not a stuck-fall in either arrangement) without disturbing those hard-won fixes | If a future contact-retention change alters when OnWalkable is set relative to the ladder, `_redo` could misclassify a frame (grounded-jam mistaken for stuck-fall → spurious velocity zero, or vice-versa) — the fsf conformance tests pin the current arrangement | `CTransition::validate_transition` 0x0050aa70 pc:272625-656; ACE Transition.cs:1029-1061 | | AD-40 | The fsf `Stationary*` transient-bit encode (fsf→0x10/0x20/0x40) lives in the Core resolve writeback (`PhysicsEngine.ResolveWithTransition`), co-located with the fsf computation; retail encodes it in `handle_all_collisions` (pc:282737-758). Also: `PhysicsBody.CachedVelocity` is computed at the player chokepoint but not yet consumed — outbound wire velocity still uses the existing `get_state_velocity` path, not retail's cached_velocity source (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/PhysicsEngine.cs` (writeback); `src/AcDream.App/Input/PlayerMovementController.cs` (`CachedVelocity`) | Encoding in the writeback keeps the seed→ladder→writeback→seed round-trip self-contained in Core (testable without the App loop); the bit values + timing are identical to retail's (set after fsf is final, before the next resolve). CachedVelocity is faithful to carry now; routing the wire through it is a separate, unmeasured change | If a future consumer reads the Stationary* bits expecting retail's handle_all_collisions to have set them (it doesn't run in Core), the Core writeback is the source of truth; a wire-reporting change that assumes CachedVelocity is live would send the wrong velocity until it's wired | `handle_all_collisions` bit encode pc:282737-758; `get_velocity` 0x005113c0 (cached_velocity reader) | | AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses ONLY `handle_all_collisions` + `cached_velocity` on a no-move frame; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.App/Input/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) | +| AD-42 | Enter-world placement is split across two Core calls: legacy `Resolve` performs retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` runs the verbatim object-aware `find_placement_pos` ring search. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.App/Rendering/GameWindow.cs` (`EnterPlayerModeNow`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. Keeping the split preserves the proven indoor-login snap while adding the missing occupied-position behavior | A spawn that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 | --- diff --git a/docs/research/2026-07-12-login-placement-and-repeat-cancel-pseudocode.md b/docs/research/2026-07-12-login-placement-and-repeat-cancel-pseudocode.md new file mode 100644 index 00000000..537add73 --- /dev/null +++ b/docs/research/2026-07-12-login-placement-and-repeat-cancel-pseudocode.md @@ -0,0 +1,119 @@ +# Login placement and repeat-attack cancellation — retail pseudocode + +## Sources + +- `CTransition::find_placement_pos` @ `0x0050BA50` +- `CTransition::find_placement_position` @ `0x0050C170` +- `CObjCell::find_obj_collisions` @ `0x0052B750` +- `CPhysicsObj::add_shadows_to_cells` @ `0x00514AE0` +- `CPhysicsObj::enter_world` @ `0x00516170` +- `ClientCombatSystem::AbortAutomaticAttack` @ `0x0056AE90` +- `ClientCombatSystem::CommenceJump` @ `0x0056AF90` +- `ACCmdInterp::HandleNewForwardMovement` @ `0x0058B1F0` +- Named pseudo-C: `docs/research/named-retail/acclient_2013_pseudo_c.txt` +- Interpretation cross-check: ACE `Transition.cs`, `CommandInterpreter.cs`, and + `ACCmdInterp.cs` on the `ACEmulator/ACE` `master` branch. + +## Enter-world placement + +Retail `enter_world(position)` stores the server position and calls +`enter_world(true)`. That constructs `SetPosition` flags `0x11`, so placement +may slide away from an obstruction. + +```text +find_placement_position(): + check = current + insert_type = INITIAL_PLACEMENT + test environment/cells at check + (CObjCell skips shadow-object collisions for INITIAL_PLACEMENT) + validate initial placement + + insert_type = PLACEMENT + if !find_placement_pos(): fail + + optionally step down to a walkable surface + validate final placement +``` + +`find_placement_pos` is the object-aware nearest-clear-point search: + +```text +find_placement_pos(): + check = current + clear sliding/contact state + if placement_test(check) succeeds: + accept check and return true + + if placement sliding is disabled: + return false + + search_distance = 4 metres + search_radius = 4 metres + radius = mover foot-sphere radius + + if radius < 0.125 metres: + fake_sphere = true + search_radius = 2 metres + else if radius < 0.48 metres: + radius = 0.48 metres + + exact_step_count = 4 / radius + if fake_sphere: exact_step_count *= 0.5 + if exact_step_count <= 1: return false + + step_count = ceil(exact_step_count) + distance_step = search_radius / step_count + radians_step = PI * distance_step / radius + total_distance = 0 + total_radians = 0 + + for each radial step: + total_distance += distance_step + total_radians += radians_step + compass_samples = ceil(total_radians) * 2 + heading_step_degrees = 360 / compass_samples + + for sample = 0 .. compass_samples-1: + check = current + heading = heading_step_degrees * sample + offset = forward_vector(heading) * total_distance + offset = adjust_offset(offset) + if length(offset) < epsilon: continue + + check += offset + clear sliding/contact state + if placement_test(check) succeeds: + accept check and return true + + return false +``` + +The local player must also own a normal shadow entry. Retail registers every +placed `CPhysicsObj`; collision queries exclude only the moving object's own +pointer. Therefore remote monsters can collide/de-overlap against the local +player, while the player's placement search skips its own shadow by entity id. + +## Movement cancels repeat attack + +```text +ACCmdInterp.HandleNewForwardMovement(): + if combat_system exists: + combat_system.AbortAutomaticAttack() + base.HandleNewForwardMovement() // also stops auto-run + +ClientCombatSystem.AbortAutomaticAttack(): + if server_response_pending + or attack_request_in_progress + or attack_in_progress + or repeat_attacking: + send Event_CancelAttack + repeat_attacking = false + if combat power build is active: + HidePowerBar() +``` + +`CommandInterpreter::AddCommand` calls `HandleNewForwardMovement` for the +forward-substate command list, which includes forward/backward/autorun rather +than the independent turn and sidestep lists. Jump has the same cancellation +at `ClientCombatSystem::CommenceJump` before its jump power bar begins. + diff --git a/src/AcDream.App/Combat/CombatAttackController.cs b/src/AcDream.App/Combat/CombatAttackController.cs index eeb382d2..6e5893fa 100644 --- a/src/AcDream.App/Combat/CombatAttackController.cs +++ b/src/AcDream.App/Combat/CombatAttackController.cs @@ -15,7 +15,8 @@ namespace AcDream.App.Combat; /// (0x0056ADE0), StartAttackRequest (0x0056C040), /// EndAttackRequest (0x0056C0E0), UseTime (0x0056C1F0), /// HandleAttackDoneEvent (0x0056C500), and -/// SetRequestedAttackHeight (0x0056C8F0). +/// SetRequestedAttackHeight (0x0056C8F0), and +/// AbortAutomaticAttack (0x0056AE90). /// public sealed class CombatAttackController : IDisposable { @@ -27,6 +28,7 @@ public sealed class CombatAttackController : IDisposable private readonly CombatState _combat; private readonly Func _canStartAttack; private readonly Func _sendAttack; + private readonly Action _sendCancelAttack; private readonly Func _isDualWield; private readonly Func _playerReadyForAttack; private readonly Func _autoRepeatAttack; @@ -48,6 +50,7 @@ public sealed class CombatAttackController : IDisposable CombatState combat, Func canStartAttack, Func sendAttack, + Action? sendCancelAttack = null, Func? isDualWield = null, Func? playerReadyForAttack = null, Func? autoRepeatAttack = null, @@ -56,6 +59,7 @@ public sealed class CombatAttackController : IDisposable _combat = combat ?? throw new ArgumentNullException(nameof(combat)); _canStartAttack = canStartAttack ?? throw new ArgumentNullException(nameof(canStartAttack)); _sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack)); + _sendCancelAttack = sendCancelAttack ?? (() => { }); _isDualWield = isDualWield ?? (() => false); _playerReadyForAttack = playerReadyForAttack ?? (() => true); _autoRepeatAttack = autoRepeatAttack ?? (() => false); @@ -113,6 +117,26 @@ public sealed class CombatAttackController : IDisposable return false; } + /// + /// Routes the semantic inputs that retail turns into a new forward-motion + /// command. Turns and sidesteps live in separate command lists and do not + /// call HandleNewForwardMovement. Jump has its own equivalent cancel + /// in ClientCombatSystem::CommenceJump (0x0056AF90). + /// + public void HandleMovementInput(InputAction action, ActivationType activation) + { + if (activation != ActivationType.Press) + return; + + if (action is InputAction.MovementForward + or InputAction.MovementBackup + or InputAction.MovementRunLock + or InputAction.MovementJump) + { + AbortAutomaticAttack(); + } + } + public void SetDesiredPower(float power) { float value = Math.Clamp(power, 0f, 1f); @@ -153,6 +177,30 @@ public sealed class CombatAttackController : IDisposable StateChanged?.Invoke(); } + /// + /// Retail ClientCombatSystem::AbortAutomaticAttack (0x0056AE90). + /// A new forward-movement command enters here through + /// ACCmdInterp::HandleNewForwardMovement (0x0058B1F0): notify the + /// server, end repeat mode, and hide an in-progress combat power build. + /// Request/pending flags are deliberately left for the normal server + /// response, matching retail. + /// + public void AbortAutomaticAttack() + { + if (!_attackServerResponsePending + && !_attackRequestInProgress + && !_repeatAttacking) + return; + + _sendCancelAttack(); + _repeatAttacking = false; + + if (_buildInProgress) + ResetPowerBar(); + + StateChanged?.Invoke(); + } + /// /// Retail UseTime: publishes the live build and commits a released /// request once the ticking level reaches the power captured on release. diff --git a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs index ef0d2b13..2aa57a71 100644 --- a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs +++ b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs @@ -607,13 +607,14 @@ internal sealed class RemotePhysicsUpdater /// public void SyncRemoteShadowToBody(uint entityId, RemoteMotion rm, int liveCenterX, int liveCenterY) { - int shLbX = (int)((rm.CellId >> 24) & 0xFFu); - int shLbY = (int)((rm.CellId >> 16) & 0xFFu); - float shOffX = (shLbX - liveCenterX) * 192f; - float shOffY = (shLbY - liveCenterY) * 192f; - _physicsEngine.ShadowObjects.UpdatePosition( - entityId, rm.Body.Position, rm.Body.Orientation, - shOffX, shOffY, rm.CellId, seedCellId: rm.CellId); + ShadowPositionSynchronizer.Sync( + _physicsEngine.ShadowObjects, + entityId, + rm.Body.Position, + rm.Body.Orientation, + rm.CellId, + liveCenterX, + liveCenterY); rm.LastShadowSyncPos = rm.Body.Position; } } diff --git a/src/AcDream.App/Physics/ShadowPositionSynchronizer.cs b/src/AcDream.App/Physics/ShadowPositionSynchronizer.cs new file mode 100644 index 00000000..e6122393 --- /dev/null +++ b/src/AcDream.App/Physics/ShadowPositionSynchronizer.cs @@ -0,0 +1,39 @@ +using System.Numerics; +using AcDream.Core.Physics; + +namespace AcDream.App.Physics; + +/// +/// Shared host adapter for retail's moved-object shadow re-registration +/// (CPhysicsObj::SetPositionInternal 0x00515330). Both the local body +/// and remote dead-reckoning bodies publish their resolved position through +/// this one cell-offset calculation. +/// +internal static class ShadowPositionSynchronizer +{ + public static void Sync( + ShadowObjectRegistry registry, + uint entityId, + Vector3 position, + Quaternion orientation, + uint cellId, + int liveCenterX, + int liveCenterY) + { + if (cellId == 0) + return; + + int landblockX = (int)((cellId >> 24) & 0xFFu); + int landblockY = (int)((cellId >> 16) & 0xFFu); + float worldOffsetX = (landblockX - liveCenterX) * 192f; + float worldOffsetY = (landblockY - liveCenterY) * 192f; + registry.UpdatePosition( + entityId, + position, + orientation, + worldOffsetX, + worldOffsetY, + cellId, + seedCellId: cellId); + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3f3a2ae3..07ed89e0 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -858,6 +858,9 @@ public sealed class GameWindow : IDisposable private uint _playerServerGuid; private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character private MovementTruthOutbound? _lastMovementTruthOutbound; + private (System.Numerics.Vector3 Position, + System.Numerics.Quaternion Orientation, + uint CellId)? _lastLocalPlayerShadow; private readonly record struct MovementTruthOutbound( string Kind, @@ -1988,6 +1991,7 @@ public sealed class GameWindow : IDisposable Combat, CanStartLiveCombatAttack, SendLiveCombatAttack, + sendCancelAttack: () => _liveSession?.SendCancelAttack(), isDualWield: () => _playerController?.Motion.InterpretedState.CurrentStyle == AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle, playerReadyForAttack: IsPlayerReadyForCombatAttack, @@ -3594,17 +3598,16 @@ public sealed class GameWindow : IDisposable _lastSpawnByGuid[spawn.Guid] = spawn; _equippedChildRenderer?.OnSpawn(spawn); - // Commit B 2026-04-29 — live-entity collision registration. The - // local player is the simulator (its PhysicsBody is the source of - // truth for our own movement); only remotes register as targets. + // Commit B 2026-04-29 — live-entity collision registration. + // The local player is the simulator (its PhysicsBody is the source of + // truth for our own movement), but retail still registers its resolved + // CPhysicsObj as a collision target for remote creatures. The player's + // own transition skips this entry by LocalEntityId / OBJECTINFO::object. // Phantom-Setup entities (no CylSpheres / no Spheres / no Radius) // are deliberately skipped — retail FUN's `FindObjCollisions` // falls through to OK_TS for any object with no collision // geometry (acclient_2013_pseudo_c.txt:276917,276987). - if (spawn.Guid != _playerServerGuid) - { - RegisterLiveEntityCollision(entity, setup, spawn, origin); - } + RegisterLiveEntityCollision(entity, setup, spawn, origin); // Phase B.2: capture the server-sent MotionTableId for our own // character so UpdatePlayerAnimation can pass it to GetIdleCycle. @@ -4162,6 +4165,36 @@ public sealed class GameWindow : IDisposable } } + private void SyncLocalPlayerShadow( + AcDream.Core.World.WorldEntity playerEntity, + uint cellId, + bool force = false) + { + if (!force + && _lastLocalPlayerShadow is { } last + && last.CellId == cellId + && System.Numerics.Vector3.DistanceSquared( + last.Position, playerEntity.Position) <= 1e-4f + && MathF.Abs(System.Numerics.Quaternion.Dot( + last.Orientation, playerEntity.Rotation)) >= 0.99999f) + { + return; + } + + AcDream.App.Physics.ShadowPositionSynchronizer.Sync( + _physicsEngine.ShadowObjects, + playerEntity.Id, + playerEntity.Position, + playerEntity.Rotation, + cellId, + _liveCenterX, + _liveCenterY); + _lastLocalPlayerShadow = ( + playerEntity.Position, + playerEntity.Rotation, + cellId); + } + /// /// #175: the motion table's default-state pose (the closed pose for /// doors) — the derivation lives in @@ -4504,6 +4537,8 @@ public sealed class GameWindow : IDisposable _animatedEntities.Remove(existingEntity.Id); _classificationCache.InvalidateEntity(existingEntity.Id); _physicsEngine.ShadowObjects.Deregister(existingEntity.Id); + if (serverGuid == _playerServerGuid) + _lastLocalPlayerShadow = null; // Dead-reckon state is keyed by SERVER guid (not local id) so we // clear using the same guid the next spawn/update would use. @@ -8307,6 +8342,7 @@ public sealed class GameWindow : IDisposable pe.ParentCellId = result.CellId; pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle( System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f); + SyncLocalPlayerShadow(pe, result.CellId); // Move the player entity to its current landblock in GpuWorldState // so it doesn't get frustum-culled when the player walks away from @@ -11713,6 +11749,12 @@ public sealed class GameWindow : IDisposable return; } + // ACCmdInterp::HandleNewForwardMovement (0x0058B1F0) aborts automatic + // combat before the movement command enters CommandInterpreter. This + // notification does not consume the input; the movement owner below + // still receives it normally. + _combatAttackController?.HandleMovementInput(action, activation); + // Retail attack actions consume their full transition stream: key-down // starts the power build and key-up commits it. Handle them before the // generic Press-only gate below. @@ -13278,6 +13320,33 @@ public sealed class GameWindow : IDisposable var initResult = _physicsEngine.Resolve( playerEntity.Position, pinitCellId, System.Numerics.Vector3.Zero, 100f); + + // Retail enter_world -> SetPosition(flags 0x11) runs + // CTransition::find_placement_pos after the initial cell/environment + // placement. That second phase is what seats a relogging character + // beside a monster that moved onto the saved logout position. The + // legacy Resolve above supplies the already-ported AdjustPosition + + // floor snap; this call supplies the missing object-aware ring search. + var (placementRadius, placementHeight) = + GetSetupCylinder(_playerServerGuid, playerEntity); + if (placementRadius < 0.05f) + { + placementRadius = 0.48f; + placementHeight = 1.835f; + } + var placementResult = _physicsEngine.ResolvePlacement( + initResult.Position, + initResult.CellId, + placementRadius, + placementHeight, + _playerController.StepUpHeight, + _playerController.StepDownHeight, + AcDream.Core.Physics.ObjectInfoState.IsPlayer + | AcDream.Core.Physics.ObjectInfoState.EdgeSlide, + playerEntity.Id); + if (placementResult.Ok) + initResult = placementResult; + _playerController.SetPosition(initResult.Position, initResult.CellId, CellLocalForSeed(initResult.Position, initResult.CellId)); // #111 (2026-06-10): snap the ENTITY too — parity with the @@ -13288,6 +13357,7 @@ public sealed class GameWindow : IDisposable // 2 m up against the window while physics stood on the floor). playerEntity.SetPosition(initResult.Position); playerEntity.ParentCellId = initResult.CellId; + SyncLocalPlayerShadow(playerEntity, initResult.CellId, force: true); var q = playerEntity.Rotation; float rawYaw = MathF.Atan2( diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 7b072fc5..49a226e9 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -1325,4 +1325,47 @@ public sealed class PhysicsEngine return resolveResult; } + + /// + /// Runs retail's radius-aware placement-ring search after the host has + /// validated the server's cell and grounded the initial position. This is + /// the CTransition::find_placement_pos half of enter-world + /// SetPosition; unlike an ordinary zero-distance transition it tests + /// object occupancy and can seat a relogging player beside a creature that + /// now occupies the saved location. + /// + public ResolveResult ResolvePlacement( + Vector3 position, + uint cellId, + float sphereRadius, + float sphereHeight, + float stepUpHeight, + float stepDownHeight, + ObjectInfoState moverFlags = ObjectInfoState.None, + uint movingEntityId = 0) + { + var transition = new Transition(); + transition.ObjectInfo.StepUpHeight = stepUpHeight; + transition.ObjectInfo.StepDownHeight = stepDownHeight; + transition.ObjectInfo.StepDown = true; + transition.ObjectInfo.SelfEntityId = movingEntityId; + transition.ObjectInfo.State = moverFlags; + transition.SpherePath.InitPath( + position, position, cellId, sphereRadius, sphereHeight); + transition.SpherePath.InsertType = InsertType.Placement; + + bool ok = transition.FindPlacementPos(this); + var sp = transition.SpherePath; + var ci = transition.CollisionInfo; + bool onGround = ci.ContactPlaneValid + || transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable); + + return new ResolveResult( + sp.CurPos, + sp.CurCellId != 0 ? sp.CurCellId : cellId, + onGround, + ci.CollisionNormalValid, + ci.CollisionNormal, + ok); + } } diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index e7b67b11..3d6b7e1a 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -391,6 +391,7 @@ public sealed class SpherePath public Vector3 NegCollisionNormal; public bool CheckWalkable; public InsertType InsertType = InsertType.Transition; + public bool PlacementAllowsSliding = true; /// /// Retail SPHEREPATH.obstruction_ethereal: set to 1 per-target @@ -987,6 +988,122 @@ public sealed class Transition return transitionState == TransitionState.OK; } + /// + /// Retail CTransition::find_placement_pos (0x0050BA50). + /// Tests the requested position first, then searches concentric rings up + /// to four metres away for the nearest valid placement. The ring spacing + /// and angular sampling are radius-dependent exactly as in retail. + /// + public bool FindPlacementPos(PhysicsEngine engine) + { + var sp = SpherePath; + + sp.SetCheckPos(sp.CurPos, sp.CurCellId); + CollisionInfo.SlidingNormalValid = false; + CollisionInfo.ContactPlaneValid = false; + CollisionInfo.ContactPlaneIsWater = false; + + var transitionState = ValidatePlacementTransition( + TransitionalInsert(3, engine)); + if (transitionState == TransitionState.OK) + return true; + + if (!sp.PlacementAllowsSliding) + return false; + + float adjustDistance = 4f; + float adjustRadius = adjustDistance; + float sphereRadius = sp.LocalSphere[0].Radius; + bool fakeSphere = false; + + if (sphereRadius < 0.125f) + { + fakeSphere = true; + adjustRadius = 2f; + } + else if (sphereRadius < 0.48f) + { + sphereRadius = 0.48f; + } + + float stepCountExact = 4f / sphereRadius; + if (fakeSphere) + stepCountExact *= 0.5f; + if (stepCountExact <= 1f) + return false; + + int stepCount = (int)MathF.Ceiling(stepCountExact); + float distancePerStep = adjustRadius / stepCount; + float radiansPerStep = MathF.PI * distancePerStep / sphereRadius; + float totalDistance = 0f; + float totalRadians = 0f; + + for (int ring = 0; ring < stepCount; ring++) + { + totalDistance += distancePerStep; + totalRadians += radiansPerStep; + + int sampleCount = (int)MathF.Ceiling(totalRadians) * 2; + float headingStep = 360f / sampleCount; + + for (int sample = 0; sample < sampleCount; sample++) + { + sp.SetCheckPos(sp.CurPos, sp.CurCellId); + + // Frame::set_heading/get_vector_heading: 0 degrees is +Y, + // 90 degrees is +X in AC's compass convention. + float headingRadians = headingStep * sample * (MathF.PI / 180f); + var offset = new Vector3( + MathF.Sin(headingRadians) * totalDistance, + MathF.Cos(headingRadians) * totalDistance, + 0f); + sp.GlobalOffset = AdjustOffset(offset); + + if (sp.GlobalOffset.Length() < PhysicsGlobals.EPSILON) + continue; + + sp.AddOffsetToCheckPos(sp.GlobalOffset); + CollisionInfo.SlidingNormalValid = false; + CollisionInfo.ContactPlaneValid = false; + CollisionInfo.ContactPlaneIsWater = false; + + transitionState = ValidatePlacementTransition( + TransitionalInsert(3, engine)); + if (transitionState == TransitionState.OK) + return true; + } + } + + return false; + } + + private TransitionState ValidatePlacementTransition(TransitionState transitionState) + { + var sp = SpherePath; + if (sp.CheckCellId == 0) + return TransitionState.Collided; + + if (transitionState == TransitionState.OK) + { + sp.CurPos = sp.CheckPos; + sp.CurCellId = sp.CheckCellId; + sp.CurOrientation = sp.CheckOrientation; + for (int i = 0; i < sp.NumSphere; i++) + { + sp.GlobalCurrCenter[i].Origin = sp.LocalSphere[i].Origin + sp.CurPos; + sp.GlobalCurrCenter[i].Radius = sp.LocalSphere[i].Radius; + } + } + else if (sp.PlacementAllowsSliding) + { + // COLLISIONINFO::init at retail 0x0050B052. Placement probes are + // independent; a failed compass sample must not bias the next one. + CollisionInfo = new CollisionInfo(); + } + + return transitionState; + } + // ----------------------------------------------------------------------- // Per-step collision check // ----------------------------------------------------------------------- diff --git a/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs b/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs index 18cc4da2..0a798048 100644 --- a/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs +++ b/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs @@ -128,6 +128,57 @@ public sealed class CombatAttackControllerTests Assert.Equal(0f, controller.PowerBarLevel); } + [Fact] + public void MovementAbort_DuringRepeat_SendsCancelAndPreventsNextAttack() + { + double now = 20d; + int cancels = 0; + var sent = new List<(AttackHeight Height, float Power)>(); + var combat = new CombatState(); + using var controller = new CombatAttackController( + combat, + canStartAttack: () => true, + sendAttack: (height, power) => + { + sent.Add((height, power)); + return true; + }, + sendCancelAttack: () => cancels++, + autoRepeatAttack: () => true, + now: () => now); + combat.SetCombatMode(CombatMode.Melee); + + controller.PressAttack(AttackHeight.Medium); + now += 0.5d; + controller.ReleaseAttack(); + Assert.Single(sent); + + controller.HandleMovementInput( + InputAction.MovementForward, ActivationType.Press); + combat.OnAttackDone(1u, 0u); + + Assert.Equal(1, cancels); + Assert.Single(sent); + Assert.False(controller.BuildInProgress); + Assert.Equal(0f, controller.PowerBarLevel); + } + + [Fact] + public void MovementAbort_WhileIdle_DoesNotSendCancel() + { + int cancels = 0; + var combat = new CombatState(); + using var controller = new CombatAttackController( + combat, + canStartAttack: () => true, + sendAttack: (_, _) => true, + sendCancelAttack: () => cancels++); + + controller.AbortAutomaticAttack(); + + Assert.Equal(0, cancels); + } + private static CombatAttackController Create( CombatState combat, Func now, diff --git a/tests/AcDream.Core.Tests/Physics/InitialPlacementOverlapTests.cs b/tests/AcDream.Core.Tests/Physics/InitialPlacementOverlapTests.cs new file mode 100644 index 00000000..fb243f39 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/InitialPlacementOverlapTests.cs @@ -0,0 +1,86 @@ +using System.Numerics; +using AcDream.Core.Physics; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Enter-world overlap conformance for retail +/// CTransition::find_placement_pos (0x0050BA50). +/// +public sealed class InitialPlacementOverlapTests +{ + private const uint Landblock = 0xA9B40000u; + private const uint Cell = Landblock | 0x0001u; + private const uint PlayerId = 0x50000001u; + private const uint MonsterId = 0x50000002u; + private const float Radius = 0.48f; + + [Fact] + public void PlayerReloggingInsideMonster_SearchesOutToNearestClearRing() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + + var savedFeet = new Vector3(10f, 10f, 0f); + var playerCenter = savedFeet + new Vector3(0f, 0f, Radius); + var monsterCenter = playerCenter + new Vector3(0.10f, 0f, 0f); + + // Retail registers every placed CPhysicsObj, including the local + // player. The mover's OBJECTINFO::object self pointer excludes only + // its own shadow while the monster remains an obstruction. + RegisterSphere(engine, PlayerId, playerCenter, + EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsCreature); + RegisterSphere(engine, MonsterId, monsterCenter, + EntityCollisionFlags.IsCreature); + + ResolveResult result = engine.ResolvePlacement( + savedFeet, + Cell, + sphereRadius: Radius, + sphereHeight: 1.835f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: PlayerId); + + Assert.True(result.Ok); + Assert.NotEqual(savedFeet, result.Position); + float centerDistance = Vector3.Distance( + result.Position + new Vector3(0f, 0f, Radius), + monsterCenter); + Assert.True(centerDistance >= Radius * 2f - PhysicsGlobals.EPSILON, + $"placement must clear the monster; centers remain {centerDistance:F3} m apart"); + Assert.True(Vector3.Distance(savedFeet, result.Position) <= 4f, + "retail placement search is bounded to four metres"); + } + + private static void RegisterSphere( + PhysicsEngine engine, + uint id, + Vector3 center, + EntityCollisionFlags flags) + { + engine.ShadowObjects.Register( + id, + gfxObjId: 0u, + worldPos: center, + rotation: Quaternion.Identity, + radius: Radius, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: Landblock, + collisionType: ShadowCollisionType.Sphere, + cylHeight: 0f, + scale: 1f, + state: 0u, + flags: flags, + seedCellId: Cell, + isStatic: false); + } +}