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);
+ }
+}