feat(headless): complete deterministic bot command parity

This commit is contained in:
Erik 2026-07-27 08:23:36 +02:00
parent 7e8acb74dd
commit 38e83640d9
37 changed files with 2805 additions and 295 deletions

View file

@ -411,6 +411,7 @@ public sealed class PlayerMovementController
_animationRootMotionScratch = new();
private readonly AcDream.Core.Physics.Motion.MotionDeltaFrame
_positionManagerDeltaScratch = new();
private bool _externalMovementEventPending;
// ── R4-V5: the verbatim retail MoveToManager replaces B.6 auto-walk ──
// The B.6 DriveServerAutoWalk overlay (synthesized turn-first phase,
@ -856,6 +857,35 @@ public sealed class PlayerMovementController
return true;
}
/// <summary>
/// Applies a command-originated retail posture through the same
/// CPhysicsObj movement boundary used by keyboard input. The next admitted
/// object turn emits the resulting raw movement state exactly once.
/// </summary>
public bool RequestPosture(uint motion)
{
if (motion is not (
MotionCommand.Ready
or MotionCommand.Crouch
or MotionCommand.Sitting
or MotionCommand.Sleeping))
{
return false;
}
TakeControlFromServer();
var parameters =
new AcDream.Core.Physics.Motion.MovementParameters();
if (DoMotionAtPhysicsObjectBoundary(motion, parameters)
!= WeenieError.None)
{
return false;
}
_externalMovementEventPending = true;
return true;
}
public void SetCharacterSkills(int runSkill, int jumpSkill)
{
_weenie.SetSkills(runSkill, jumpSkill);
@ -1455,8 +1485,12 @@ public sealed class PlayerMovementController
_motion.set_hold_run(input.Run, interrupt: false);
}
bool externallyRequestedMovementEvent =
_externalMovementEventPending;
_externalMovementEventPending = false;
bool motionEdgeFired = false;
bool movementEventRequested = false;
bool movementEventRequested =
externallyRequestedMovementEvent;
{
bool userInputEdge = input.Run != _prevRunHeld
|| input.Forward != _prevForwardHeld
@ -1511,7 +1545,7 @@ public sealed class PlayerMovementController
// an immediate SendMovementEvent. Mouse-origin turn updates below
// deliberately do not: CameraSet reports them on its separate
// half-second cadence.
movementEventRequested = motionEdgeFired;
movementEventRequested |= motionEdgeFired;
// Turn channel. Retail CameraSet::Rotate (0x00458310) feeds mouse
// input through CommandInterpreter::MovePlayer as TurnLeft /
@ -1937,6 +1971,14 @@ public sealed class PlayerMovementController
outForwardCmd = MotionCommand.WalkBackward;
outForwardSpeed = 1.0f;
}
else if (_motion.RawState.ForwardCommand is (
MotionCommand.Crouch
or MotionCommand.Sitting
or MotionCommand.Sleeping))
{
outForwardCmd = _motion.RawState.ForwardCommand;
outForwardSpeed = _motion.RawState.ForwardSpeed;
}
// Source the outbound axis from the canonical input-owned raw channel.
// CameraInstantMouseLook maps keyboard TurnLeft/TurnRight into this
@ -1979,7 +2021,8 @@ public sealed class PlayerMovementController
|| outTurnCmd != _prevTurnCmd
|| !FloatsEqual(outForwardSpeed, _prevForwardSpeed)
|| runHold != _prevRunHold
|| motionEdgeFired;
|| motionEdgeFired
|| externallyRequestedMovementEvent;
bool mouseMovementEventDue = _mouseMovementEventPending
|| (_mouseMovementEventCandidate

View file

@ -111,6 +111,8 @@ public sealed class RuntimeActionState : IDisposable
public IRuntimeActionView View { get; }
public bool IsDisposed => _disposed;
internal event Action? CombatChanged;
public RuntimeActionOwnershipSnapshot CaptureOwnership() => new(
_disposed,
_internalSubscriptionsAttached,
@ -193,17 +195,26 @@ public sealed class RuntimeActionState : IDisposable
private void OnSelectionChanged(SelectionTransition _) =>
Interlocked.Increment(ref _selectionRevision);
private void OnCombatModeChanged(CombatMode _) =>
private void OnCombatModeChanged(CombatMode _)
{
Interlocked.Increment(ref _combatRevision);
CombatChanged?.Invoke();
}
private void OnHealthChanged(uint _, float __) =>
private void OnHealthChanged(uint _, float __)
{
Interlocked.Increment(ref _combatRevision);
CombatChanged?.Invoke();
}
private void OnInteractionChanged(InteractionModeTransition _) =>
Interlocked.Increment(ref _interactionRevision);
private void OnCombatAttackChanged() =>
private void OnCombatAttackChanged()
{
Interlocked.Increment(ref _combatIntentRevision);
CombatChanged?.Invoke();
}
private void OnSpellCastChanged() =>
Interlocked.Increment(ref _magicIntentRevision);

View file

@ -0,0 +1,115 @@
using System.Numerics;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Presentation-independent hostile-target query over the canonical Runtime
/// directory and object table. Graphical hosts may retain their render-aware
/// query, while direct hosts use this exact live state without constructing a
/// second world model.
/// </summary>
public static class RuntimeHostileTargetQuery
{
public static uint? FindClosest(GameRuntime runtime)
{
ArgumentNullException.ThrowIfNull(runtime);
uint playerGuid = runtime.PlayerIdentity.ServerGuid;
if (playerGuid == 0u
|| !runtime.EntityObjects.Entities.TryGetActive(
playerGuid,
out RuntimeEntityRecord playerRecord)
|| playerRecord.Snapshot.Position is not { } playerPosition)
{
return null;
}
Vector3 playerWorld = AbsolutePosition(playerPosition);
ClientObjectTable objects = runtime.InventoryOwner.Objects;
ClientObject? player = objects.Get(playerGuid);
uint? closest = null;
float closestDistanceSquared = float.PositiveInfinity;
foreach (RuntimeEntityRecord record
in runtime.EntityObjects.Entities.ActiveRecords)
{
if (record.ServerGuid == playerGuid
|| record.Snapshot.Position is not { } position
|| (record.FinalPhysicsState
& (PhysicsStateFlags.Hidden
| PhysicsStateFlags.NoDraw)) != 0)
{
continue;
}
ClientObject? candidate = objects.Get(record.ServerGuid);
if (!CombatTargetPolicy.IsHostileMonster(
playerGuid,
player,
candidate))
{
continue;
}
if (runtime.ActionOwner.Combat.HasHealth(record.ServerGuid)
&& runtime.ActionOwner.Combat.GetHealthPercent(
record.ServerGuid) <= 0f)
{
continue;
}
float distanceSquared = Vector3.DistanceSquared(
playerWorld,
AbsolutePosition(position));
if (distanceSquared >= closestDistanceSquared)
continue;
closestDistanceSquared = distanceSquared;
closest = record.ServerGuid;
}
return closest;
}
public static bool IsHostile(
GameRuntime runtime,
uint objectId)
{
ArgumentNullException.ThrowIfNull(runtime);
uint playerGuid = runtime.PlayerIdentity.ServerGuid;
if (objectId == 0u
|| playerGuid == 0u
|| !runtime.EntityObjects.Entities.TryGetActive(
objectId,
out RuntimeEntityRecord record)
|| (record.FinalPhysicsState
& (PhysicsStateFlags.Hidden
| PhysicsStateFlags.NoDraw)) != 0)
{
return false;
}
if (runtime.ActionOwner.Combat.HasHealth(objectId)
&& runtime.ActionOwner.Combat.GetHealthPercent(objectId) <= 0f)
{
return false;
}
ClientObjectTable objects = runtime.InventoryOwner.Objects;
return CombatTargetPolicy.IsHostileMonster(
playerGuid,
objects.Get(playerGuid),
objects.Get(objectId));
}
private static Vector3 AbsolutePosition(
CreateObject.ServerPosition position)
{
int landblockX =
(int)((position.LandblockId >> 24) & 0xFFu);
int landblockY =
(int)((position.LandblockId >> 16) & 0xFFu);
return new Vector3(
position.PositionX + landblockX * 192f,
position.PositionY + landblockY * 192f,
position.PositionZ);
}
}

View file

@ -0,0 +1,203 @@
using AcDream.Core.Physics;
namespace AcDream.Runtime.Gameplay;
public interface IRuntimeMovementInputSource
{
MovementInput Capture();
}
public interface IRuntimeLocalPlayerFrameHost
{
bool CanAdvancePlayer { get; }
PlayerMovementController? Controller { get; }
uint ResolveLocalEntityId();
void HandleTargeting();
bool IsHidden { get; }
RetailObjectClockDisposition ObjectClockDisposition { get; }
void Project(
PlayerMovementController controller,
MovementResult movement,
bool hidden);
void SendPreNetwork(
PlayerMovementController controller,
MovementResult movement,
bool hidden);
void SendPostNetwork(
PlayerMovementController controller,
bool hidden);
}
public readonly record struct RuntimeLocalPlayerPresentationFrame(
MovementResult Movement,
bool Hidden,
bool AdvancedBeforeNetwork);
/// <summary>
/// Presentation-independent owner of retail's local-player object phase,
/// inbound-network barrier, and post-network position phase. Graphical and
/// direct hosts supply projections and input but execute this exact ordering.
/// </summary>
public sealed class RuntimeLocalPlayerFrameController
{
private readonly IRuntimeLocalPlayerFrameHost _host;
private readonly IRuntimeMovementInputSource _input;
private readonly Action? _publishMovement;
private AdvancedFrame? _advancedFrame;
private readonly record struct AdvancedFrame(
PlayerMovementController Controller,
MovementResult Movement,
bool ObjectAdvanced,
bool Hidden,
bool ObjectQuantumAdvanced);
public RuntimeLocalPlayerFrameController(
IRuntimeLocalPlayerFrameHost host,
IRuntimeMovementInputSource input,
Action? publishMovement = null)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_input = input ?? throw new ArgumentNullException(nameof(input));
_publishMovement = publishMovement;
}
public bool HiddenPartPoseDirty => _advancedFrame is
{
Hidden: true,
ObjectQuantumAdvanced: true,
};
public void AdvanceBeforeNetwork(float deltaSeconds)
{
_advancedFrame = null;
PlayerMovementController? controller = _host.Controller;
if (!_host.CanAdvancePlayer || controller is null)
return;
if (!float.IsFinite(deltaSeconds) || deltaSeconds <= 0f)
{
bool rejectedHidden = _host.IsHidden;
MovementResult rejected =
controller.CapturePresentationResult();
_host.Project(controller, rejected, rejectedHidden);
_advancedFrame = new AdvancedFrame(
controller,
rejected,
ObjectAdvanced: false,
Hidden: rejectedHidden,
ObjectQuantumAdvanced: false);
return;
}
controller.LocalEntityId = _host.ResolveLocalEntityId();
bool hidden = _host.IsHidden;
if (_host.ObjectClockDisposition
is RetailObjectClockDisposition.Suspend)
{
controller.SuspendObjectUpdate(deltaSeconds);
MovementResult paused =
controller.CapturePresentationResult();
_host.Project(controller, paused, hidden);
_advancedFrame = new AdvancedFrame(
controller,
paused,
ObjectAdvanced: false,
Hidden: hidden,
ObjectQuantumAdvanced: false);
return;
}
MovementResult movement = hidden
? controller.TickHidden(
deltaSeconds,
_host.HandleTargeting)
: controller.Update(
deltaSeconds,
_input.Capture(),
_host.HandleTargeting);
_host.Project(controller, movement, hidden);
_host.SendPreNetwork(controller, movement, hidden);
_advancedFrame = new AdvancedFrame(
controller,
movement,
ObjectAdvanced: true,
Hidden: hidden,
ObjectQuantumAdvanced:
controller.AdvancedObjectQuantumLastTick);
}
public void RunPostNetworkCommandPhase()
{
PlayerMovementController? controller = _host.Controller;
if (!_host.CanAdvancePlayer || controller is null)
return;
bool hidden = _host.IsHidden;
if (_advancedFrame is { } advanced
&& ReferenceEquals(advanced.Controller, controller))
{
MovementResult movement = RefreshSpatialFields(
advanced.Movement,
controller);
_host.Project(controller, movement, hidden);
_advancedFrame = new AdvancedFrame(
controller,
movement,
advanced.ObjectAdvanced,
advanced.Hidden,
advanced.ObjectQuantumAdvanced);
if (!advanced.ObjectAdvanced)
return;
}
_host.SendPostNetwork(controller, hidden);
_publishMovement?.Invoke();
}
public bool TryGetPresentationAfterNetwork(
out RuntimeLocalPlayerPresentationFrame frame)
{
frame = default;
PlayerMovementController? controller = _host.Controller;
if (!_host.CanAdvancePlayer || controller is null)
return false;
bool hidden = _host.IsHidden;
if (_advancedFrame is { } advanced
&& ReferenceEquals(advanced.Controller, controller))
{
MovementResult movement = RefreshSpatialFields(
advanced.Movement,
controller);
frame = new RuntimeLocalPlayerPresentationFrame(
movement,
hidden,
AdvancedBeforeNetwork: advanced.ObjectAdvanced);
return true;
}
MovementResult initial =
controller.CapturePresentationResult();
_host.Project(controller, initial, hidden);
frame = new RuntimeLocalPlayerPresentationFrame(
initial,
hidden,
AdvancedBeforeNetwork: false);
return true;
}
private static MovementResult RefreshSpatialFields(
MovementResult movement,
PlayerMovementController controller) =>
movement with
{
Position = controller.Position,
RenderPosition = controller.RenderPosition,
CellId = controller.CellId,
IsOnGround = controller.CanSendPositionEvent,
};
}

View file

@ -1,3 +1,5 @@
using System.Diagnostics;
using AcDream.Core.Combat;
using AcDream.Core.Physics;
namespace AcDream.Runtime.Gameplay;
@ -26,6 +28,8 @@ public sealed class RuntimeLocalPlayerMovementState
private PlayerMovementController? _controller;
private PlayerMovementController? _preparingMotionOwner;
private bool _autoRunActive;
private bool _hasCommandInput;
private MovementInput _commandInput;
private bool _disposed;
private long _revision;
@ -43,6 +47,8 @@ public sealed class RuntimeLocalPlayerMovementState
}
public bool AutoRunActive => _autoRunActive;
public bool HasCommandInput => _hasCommandInput;
public MovementInput CommandInput => _commandInput;
public long Revision => Interlocked.Read(ref _revision);
public IRuntimeMovementView View => this;
@ -63,7 +69,9 @@ public sealed class RuntimeLocalPlayerMovementState
false,
0d,
Revision,
_autoRunActive)
_autoRunActive,
_hasCommandInput,
_commandInput)
: new RuntimeMovementSnapshot(
true,
controller.LocalEntityId,
@ -72,7 +80,9 @@ public sealed class RuntimeLocalPlayerMovementState
controller.IsAirborne,
controller.SimTimeSeconds,
Revision,
_autoRunActive);
_autoRunActive,
_hasCommandInput,
_commandInput);
}
}
@ -109,7 +119,27 @@ public sealed class RuntimeLocalPlayerMovementState
return true;
case RuntimeMovementCommand.Stop:
CancelAutoRun();
ClearCommandInput();
return true;
case RuntimeMovementCommand.Ready:
case RuntimeMovementCommand.Sit:
case RuntimeMovementCommand.Crouch:
case RuntimeMovementCommand.Sleep:
CancelAutoRun();
ClearCommandInput();
uint motion = command switch
{
RuntimeMovementCommand.Ready =>
MotionCommand.Ready,
RuntimeMovementCommand.Sit =>
MotionCommand.Sitting,
RuntimeMovementCommand.Crouch =>
MotionCommand.Crouch,
RuntimeMovementCommand.Sleep =>
MotionCommand.Sleeping,
_ => throw new UnreachableException(),
};
return _controller?.RequestPosture(motion) == true;
default:
return false;
}
@ -125,12 +155,75 @@ public sealed class RuntimeLocalPlayerMovementState
return true;
}
/// <summary>
/// Installs the persistent semantic movement level sampled by either host.
/// The graphical input adapter and direct host both consume this exact
/// owner; no bot-only movement latch exists.
/// </summary>
public void SetCommandInput(in MovementInput input)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_hasCommandInput && _commandInput == input)
return;
_commandInput = input;
_hasCommandInput = true;
Interlocked.Increment(ref _revision);
}
public bool ClearCommandInput()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_hasCommandInput)
return false;
_commandInput = default;
_hasCommandInput = false;
Interlocked.Increment(ref _revision);
return true;
}
/// <summary>
/// Direct-host projection of the same combat readiness query used by the
/// graphical attack adapter. A host without a constructed local movement
/// owner is not ready; Slice K's content host must hydrate that owner
/// before combat packets can leave.
/// </summary>
public bool IsReadyForAttack(CombatMode mode)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_controller is not { } controller)
return false;
var motion = controller.Motion.InterpretedState;
return CombatInputPlanner.PlayerInReadyPositionForAttack(
mode,
motion.CurrentStyle,
motion.ForwardCommand);
}
public bool IsDualWield
{
get
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _controller?.Motion.InterpretedState.CurrentStyle
== CombatInputPlanner.DualWieldCombatStyle;
}
}
public bool PrepareForAttackRequest()
{
ObjectDisposedException.ThrowIf(_disposed, this);
CancelAutoRun();
return _controller?.PrepareForAttackRequest() == true;
}
public void ResetInputIntent()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_autoRunActive)
if (!_autoRunActive && !_hasCommandInput)
return;
_autoRunActive = false;
_hasCommandInput = false;
_commandInput = default;
Interlocked.Increment(ref _revision);
}
@ -145,9 +238,12 @@ public sealed class RuntimeLocalPlayerMovementState
ObjectDisposedException.ThrowIf(_disposed, this);
bool changed =
_autoRunActive
|| _hasCommandInput
|| _controller is not null
|| _preparingMotionOwner is not null;
_autoRunActive = false;
_hasCommandInput = false;
_commandInput = default;
_controller = null;
_preparingMotionOwner = null;
if (changed)
@ -160,6 +256,7 @@ public sealed class RuntimeLocalPlayerMovementState
_controller is not null,
_preparingMotionOwner is not null,
_autoRunActive,
_hasCommandInput,
Revision);
public void Dispose()
@ -167,6 +264,8 @@ public sealed class RuntimeLocalPlayerMovementState
if (_disposed)
return;
_autoRunActive = false;
_hasCommandInput = false;
_commandInput = default;
_controller = null;
_preparingMotionOwner = null;
Interlocked.Increment(ref _revision);
@ -210,11 +309,13 @@ public readonly record struct RuntimeLocalMovementOwnershipSnapshot(
bool HasController,
bool HasPreparingMotionOwner,
bool AutoRunActive,
bool HasCommandInput,
long Revision)
{
public bool IsConverged =>
IsDisposed
&& !HasController
&& !HasPreparingMotionOwner
&& !AutoRunActive;
&& !AutoRunActive
&& !HasCommandInput;
}