fix(combat): escape occupied login positions

Port retail's radius-aware placement ring so a relogging player is seated beside creatures occupying the saved location, and register the local body in the shared resolved-shadow pipeline. Route new forward movement and jump through AbortAutomaticAttack so repeat combat cancels immediately on movement.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-12 22:20:17 +02:00
parent e671b8a5c4
commit 00fe993f6f
11 changed files with 643 additions and 16 deletions

View file

@ -15,7 +15,8 @@ namespace AcDream.App.Combat;
/// (0x0056ADE0), <c>StartAttackRequest</c> (0x0056C040),
/// <c>EndAttackRequest</c> (0x0056C0E0), <c>UseTime</c> (0x0056C1F0),
/// <c>HandleAttackDoneEvent</c> (0x0056C500), and
/// <c>SetRequestedAttackHeight</c> (0x0056C8F0).
/// <c>SetRequestedAttackHeight</c> (0x0056C8F0), and
/// <c>AbortAutomaticAttack</c> (0x0056AE90).
/// </remarks>
public sealed class CombatAttackController : IDisposable
{
@ -27,6 +28,7 @@ public sealed class CombatAttackController : IDisposable
private readonly CombatState _combat;
private readonly Func<bool> _canStartAttack;
private readonly Func<AttackHeight, float, bool> _sendAttack;
private readonly Action _sendCancelAttack;
private readonly Func<bool> _isDualWield;
private readonly Func<bool> _playerReadyForAttack;
private readonly Func<bool> _autoRepeatAttack;
@ -48,6 +50,7 @@ public sealed class CombatAttackController : IDisposable
CombatState combat,
Func<bool> canStartAttack,
Func<AttackHeight, float, bool> sendAttack,
Action? sendCancelAttack = null,
Func<bool>? isDualWield = null,
Func<bool>? playerReadyForAttack = null,
Func<bool>? 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;
}
/// <summary>
/// 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 <c>HandleNewForwardMovement</c>. Jump has its own equivalent cancel
/// in <c>ClientCombatSystem::CommenceJump</c> (0x0056AF90).
/// </summary>
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();
}
/// <summary>
/// Retail <c>ClientCombatSystem::AbortAutomaticAttack</c> (0x0056AE90).
/// A new forward-movement command enters here through
/// <c>ACCmdInterp::HandleNewForwardMovement</c> (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.
/// </summary>
public void AbortAutomaticAttack()
{
if (!_attackServerResponsePending
&& !_attackRequestInProgress
&& !_repeatAttacking)
return;
_sendCancelAttack();
_repeatAttacking = false;
if (_buildInProgress)
ResetPowerBar();
StateChanged?.Invoke();
}
/// <summary>
/// Retail <c>UseTime</c>: publishes the live build and commits a released
/// request once the ticking level reaches the power captured on release.

View file

@ -607,13 +607,14 @@ internal sealed class RemotePhysicsUpdater
/// </summary>
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;
}
}

View file

@ -0,0 +1,39 @@
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.App.Physics;
/// <summary>
/// Shared host adapter for retail's moved-object shadow re-registration
/// (<c>CPhysicsObj::SetPositionInternal</c> 0x00515330). Both the local body
/// and remote dead-reckoning bodies publish their resolved position through
/// this one cell-offset calculation.
/// </summary>
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);
}
}

View file

@ -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);
}
/// <summary>
/// #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(