feat(combat): Phase L.1c wire live attack input

This commit is contained in:
Erik 2026-04-28 11:58:57 +02:00
parent d1fb68f419
commit 4874d8595a
6 changed files with 367 additions and 11 deletions

View file

@ -522,6 +522,11 @@ public sealed class GameWindow : IDisposable
/// keys the render list; this parallel dictionary keys by server guid. /// keys the render list; this parallel dictionary keys by server guid.
/// </summary> /// </summary>
private readonly Dictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new(); private readonly Dictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new();
private readonly Dictionary<uint, LiveEntityInfo> _liveEntityInfoByGuid = new();
private uint? _selectedTargetGuid;
private readonly record struct LiveEntityInfo(
string? Name,
AcDream.Core.Items.ItemType ItemType);
private int _liveSpawnReceived; // diagnostics private int _liveSpawnReceived; // diagnostics
private int _liveSpawnHydrated; private int _liveSpawnHydrated;
private int _liveDropReasonNoPos; private int _liveDropReasonNoPos;
@ -1662,6 +1667,9 @@ public sealed class GameWindow : IDisposable
// UpdatePosition look like a 2m-residual soft-snap. // UpdatePosition look like a 2m-residual soft-snap.
_remoteDeadReckon.Remove(spawn.Guid); _remoteDeadReckon.Remove(spawn.Guid);
_remoteLastMove.Remove(spawn.Guid); _remoteLastMove.Remove(spawn.Guid);
_liveEntityInfoByGuid.Remove(spawn.Guid);
if (_selectedTargetGuid == spawn.Guid)
_selectedTargetGuid = null;
} }
// Log every spawn that arrives so we can inventory what the server // Log every spawn that arrives so we can inventory what the server
@ -1674,12 +1682,19 @@ public sealed class GameWindow : IDisposable
: "no-pos"; : "no-pos";
string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup"; string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup";
string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name"; string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name";
string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype";
int animPartCount = spawn.AnimPartChanges?.Count ?? 0; int animPartCount = spawn.AnimPartChanges?.Count ?? 0;
int texChangeCount = spawn.TextureChanges?.Count ?? 0; int texChangeCount = spawn.TextureChanges?.Count ?? 0;
int subPalCount = spawn.SubPalettes?.Count ?? 0; int subPalCount = spawn.SubPalettes?.Count ?? 0;
Console.WriteLine( Console.WriteLine(
$"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " + $"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " +
$"animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}"); $"itemType={itemTypeStr} animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
_liveEntityInfoByGuid[spawn.Guid] = new LiveEntityInfo(
spawn.Name,
spawn.ItemType is { } rawItemType
? (AcDream.Core.Items.ItemType)rawItemType
: AcDream.Core.Items.ItemType.None);
// Target the statue specifically for full diagnostic dump: Name match // Target the statue specifically for full diagnostic dump: Name match
// is cheap and gives us exactly one entity's worth of log regardless // is cheap and gives us exactly one entity's worth of log regardless
@ -5915,6 +5930,26 @@ public sealed class GameWindow : IDisposable
_settingsPanel.IsVisible = !_settingsPanel.IsVisible; _settingsPanel.IsVisible = !_settingsPanel.IsVisible;
break; break;
case AcDream.UI.Abstractions.Input.InputAction.SelectionClosestMonster:
SelectClosestCombatTarget(showToast: true);
break;
case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
ToggleLiveCombatMode();
break;
case AcDream.UI.Abstractions.Input.InputAction.CombatLowAttack:
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Low);
break;
case AcDream.UI.Abstractions.Input.InputAction.CombatMediumAttack:
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Medium);
break;
case AcDream.UI.Abstractions.Input.InputAction.CombatHighAttack:
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.High);
break;
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey: case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
if (_cameraController?.IsFlyMode == true) if (_cameraController?.IsFlyMode == true)
_cameraController.ToggleFly(); // exit fly, release cursor _cameraController.ToggleFly(); // exit fly, release cursor
@ -5932,6 +5967,123 @@ public sealed class GameWindow : IDisposable
} }
} }
private void ToggleLiveCombatMode()
{
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
return;
var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(Combat.CurrentMode);
_liveSession.SendChangeCombatMode(nextMode);
Combat.SetCombatMode(nextMode);
string text = $"Combat mode {nextMode}";
Console.WriteLine($"combat: {text}");
_debugVm?.AddToast(text);
}
private void SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction action)
{
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
return;
if (!AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode))
{
_debugVm?.AddToast("Enter melee or missile combat first");
Console.WriteLine("combat: attack ignored; not in melee/missile combat mode");
return;
}
uint? target = GetSelectedOrClosestCombatTarget();
if (target is null)
{
_debugVm?.AddToast("No monster target");
Console.WriteLine("combat: attack ignored; no creature target found");
return;
}
var height = AcDream.Core.Combat.CombatInputPlanner.HeightFor(action);
const float FullBar = 1.0f;
if (Combat.CurrentMode == AcDream.Core.Combat.CombatMode.Missile)
{
_liveSession.SendMissileAttack(target.Value, height, FullBar);
Console.WriteLine($"combat: missile attack target=0x{target.Value:X8} height={height} accuracy={FullBar:F2}");
}
else
{
_liveSession.SendMeleeAttack(target.Value, height, FullBar);
Console.WriteLine($"combat: melee attack target=0x{target.Value:X8} height={height} power={FullBar:F2}");
}
}
private uint? GetSelectedOrClosestCombatTarget()
{
if (_selectedTargetGuid is { } selected && IsLiveCreatureTarget(selected))
return selected;
return SelectClosestCombatTarget(showToast: false);
}
private uint? SelectClosestCombatTarget(bool showToast)
{
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
return null;
uint? bestGuid = null;
float bestDistanceSq = float.PositiveInfinity;
foreach (var (guid, entity) in _entitiesByServerGuid)
{
if (!IsLiveCreatureTarget(guid))
continue;
float distanceSq = System.Numerics.Vector3.DistanceSquared(
entity.Position,
playerEntity.Position);
if (distanceSq >= bestDistanceSq)
continue;
bestDistanceSq = distanceSq;
bestGuid = guid;
}
_selectedTargetGuid = bestGuid;
if (bestGuid is { } selected)
{
string label = DescribeLiveEntity(selected);
float distance = MathF.Sqrt(bestDistanceSq);
Console.WriteLine($"combat: selected target 0x{selected:X8} {label} dist={distance:F1}");
if (showToast)
_debugVm?.AddToast($"Target {label}");
}
else if (showToast)
{
_debugVm?.AddToast("No monster target");
Console.WriteLine("combat: no creature target found");
}
return bestGuid;
}
private bool IsLiveCreatureTarget(uint guid)
{
if (guid == _playerServerGuid)
return false;
if (!_entitiesByServerGuid.ContainsKey(guid))
return false;
if (!_liveEntityInfoByGuid.TryGetValue(guid, out var info))
return false;
return (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0;
}
private string DescribeLiveEntity(uint guid)
{
if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)
&& !string.IsNullOrWhiteSpace(info.Name))
return info.Name!;
return $"0x{guid:X8}";
}
/// <summary> /// <summary>
/// K.1b: Tab handler extracted into a method so the dispatcher /// K.1b: Tab handler extracted into a method so the dispatcher
/// subscriber can call it. Same body as the previous Tab branch in /// subscriber can call it. Same body as the previous Tab branch in

View file

@ -25,13 +25,13 @@ namespace AcDream.Core.Net.Messages;
/// </list> /// </list>
/// ///
/// <para> /// <para>
/// All other fields (weenie header, object description, motion tables, /// Most other fields (extended weenie header, object description, motion tables,
/// palettes, texture overrides, animation frames, velocity, ...) are /// palettes, texture overrides, animation frames, velocity, ...) are
/// consumed-but-ignored so the parse position ends up wherever the /// consumed-but-ignored so the parse position ends up wherever the
/// client-side caller wanted — a <c>Parse</c> call doesn't need to reach /// client-side caller wanted — a <c>Parse</c> call doesn't need to reach
/// the end of the body to return useful output. We stop after PhysicsData /// the end of the body to return useful output. We read through the fixed
/// since that's the last segment containing fields acdream cares about /// WeenieHeader prefix for Name/ItemType, then stop before optional header
/// in this phase. /// tails.
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -51,6 +51,8 @@ public static class CreateObject
public const uint PaletteTypePrefix = 0x04000000u; public const uint PaletteTypePrefix = 0x04000000u;
/// <summary>SurfaceTexture dat id type prefix.</summary> /// <summary>SurfaceTexture dat id type prefix.</summary>
public const uint SurfaceTextureTypePrefix = 0x05000000u; public const uint SurfaceTextureTypePrefix = 0x05000000u;
/// <summary>Icon dat id type prefix.</summary>
public const uint IconTypePrefix = 0x06000000u;
[Flags] [Flags]
public enum PhysicsDescriptionFlag : uint public enum PhysicsDescriptionFlag : uint
@ -78,9 +80,9 @@ public static class CreateObject
} }
/// <summary> /// <summary>
/// The three fields acdream cares about. Position and SetupTableId are /// The spawn fields acdream currently cares about. Position and
/// nullable because their corresponding physics-description-flag bits /// SetupTableId are nullable because their corresponding
/// may not be set on every CreateObject. /// physics-description-flag bits may not be set on every CreateObject.
/// </summary> /// </summary>
public readonly record struct Parsed( public readonly record struct Parsed(
uint Guid, uint Guid,
@ -92,6 +94,7 @@ public static class CreateObject
uint? BasePaletteId, uint? BasePaletteId,
float? ObjScale, float? ObjScale,
string? Name, string? Name,
uint? ItemType,
ServerMotionState? MotionState, ServerMotionState? MotionState,
uint? MotionTableId, uint? MotionTableId,
ushort InstanceSequence = 0, ushort InstanceSequence = 0,
@ -390,27 +393,39 @@ public static class CreateObject
pos += 9 * 2; pos += 9 * 2;
AlignTo4(ref pos); AlignTo4(ref pos);
// --- WeenieHeader: read just the Name field (second after flags). --- // --- WeenieHeader: read the fixed prefix fields we need. ---
// ACE WorldObject_Networking.SerializeCreateObject writes:
// weenieFlags, Name, WeenieClassId(PackedDword),
// IconId(PackedDwordOfKnownType 0x06000000), ItemType,
// ObjectDescriptionFlags, align.
string? name = null; string? name = null;
uint? itemType = null;
if (body.Length - pos >= 4) if (body.Length - pos >= 4)
{ {
pos += 4; // skip weenieFlags u32 pos += 4; // skip weenieFlags u32
try try
{ {
name = ReadString16L(body, ref pos); name = ReadString16L(body, ref pos);
_ = ReadPackedDword(body, ref pos); // WeenieClassId
_ = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
if (body.Length - pos >= 4)
itemType = ReadU32(body, ref pos);
if (body.Length - pos >= 4)
_ = ReadU32(body, ref pos); // ObjectDescriptionFlags
AlignTo4(ref pos);
} }
catch { /* truncated name — partial result is still useful */ } catch { /* truncated name — partial result is still useful */ }
} }
return new Parsed(guid, position, setupTableId, animParts, return new Parsed(guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, name, motionState, motionTableId, textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq); instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq);
// Local helper: if we ran out of fields past PhysicsData, still // Local helper: if we ran out of fields past PhysicsData, still
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion). // return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
Parsed PartialResult() => new( Parsed PartialResult() => new(
guid, position, setupTableId, animParts, guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, null, motionState, motionTableId); textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId);
} }
catch catch
{ {

View file

@ -54,6 +54,7 @@ public sealed class WorldSession : IDisposable
uint? BasePaletteId, uint? BasePaletteId,
float? ObjScale, float? ObjScale,
string? Name, string? Name,
uint? ItemType,
CreateObject.ServerMotionState? MotionState, CreateObject.ServerMotionState? MotionState,
uint? MotionTableId); uint? MotionTableId);
@ -635,6 +636,7 @@ public sealed class WorldSession : IDisposable
parsed.Value.BasePaletteId, parsed.Value.BasePaletteId,
parsed.Value.ObjScale, parsed.Value.ObjScale,
parsed.Value.Name, parsed.Value.Name,
parsed.Value.ItemType,
parsed.Value.MotionState, parsed.Value.MotionState,
parsed.Value.MotionTableId)); parsed.Value.MotionTableId));
} }

View file

@ -27,6 +27,51 @@ public enum AttackHeight
Low = 3, Low = 3,
} }
public enum CombatAttackAction
{
Low,
Medium,
High,
}
/// <summary>
/// Retail input-facing combat decisions. The heavyweight parts of the combat
/// system remain server authoritative; this helper only maps UI intent to the
/// mode / attack-height values sent on the wire.
///
/// References:
/// named-retail ClientCombatSystem::ToggleCombatMode (0x0056C8C0),
/// ClientCombatSystem::SetCombatMode (0x0056BE30), and
/// ClientCombatSystem::ExecuteAttack (0x0056BB70).
/// Cross-check: holtburger DesiredAttackProfile::to_attack_request only emits
/// targeted attacks for Melee and Missile modes.
/// </summary>
public static class CombatInputPlanner
{
public static CombatMode ToggleMode(
CombatMode currentMode,
CombatMode defaultCombatMode = CombatMode.Melee)
{
if ((currentMode & CombatMode.CombatCombat) != 0)
return CombatMode.NonCombat;
return (defaultCombatMode & CombatMode.CombatCombat) != 0
? defaultCombatMode
: CombatMode.Melee;
}
public static bool SupportsTargetedAttack(CombatMode mode) =>
mode == CombatMode.Melee || mode == CombatMode.Missile;
public static AttackHeight HeightFor(CombatAttackAction action) => action switch
{
CombatAttackAction.Low => AttackHeight.Low,
CombatAttackAction.Medium => AttackHeight.Medium,
CombatAttackAction.High => AttackHeight.High,
_ => AttackHeight.Medium,
};
}
/// <summary> /// <summary>
/// Retail uses a 15-bit flags enum for attack types — weapon categories. /// Retail uses a 15-bit flags enum for attack types — weapon categories.
/// See r02 §2 + <c>ACE.Entity.Enum.AttackType</c>. /// See r02 §2 + <c>ACE.Entity.Enum.AttackType</c>.

View file

@ -0,0 +1,99 @@
using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class CreateObjectTests
{
[Fact]
public void TryParse_WeenieHeaderPrefix_ReturnsNameAndItemType()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000002u,
name: "Drudge",
itemType: (uint)ItemType.Creature);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x50000002u, parsed.Value.Guid);
Assert.Equal("Drudge", parsed.Value.Name);
Assert.Equal((uint)ItemType.Creature, parsed.Value.ItemType);
}
private static byte[] BuildMinimalCreateObjectWithWeenieHeader(
uint guid,
string name,
uint itemType)
{
var bytes = new List<byte>();
WriteU32(bytes, CreateObject.Opcode);
WriteU32(bytes, guid);
// ModelData header: marker, subpalette count, texture count, animpart count.
bytes.Add(0x11);
bytes.Add(0);
bytes.Add(0);
bytes.Add(0);
// PhysicsData: no flags, empty physics state, then 9 sequence stamps.
WriteU32(bytes, 0);
WriteU32(bytes, 0);
for (int i = 0; i < 9; i++)
WriteU16(bytes, 0);
Align4(bytes);
// Fixed WeenieHeader prefix per ACE SerializeCreateObject.
WriteU32(bytes, 0); // weenieFlags
WriteString16L(bytes, name);
WritePackedDword(bytes, 0x1234); // WeenieClassId
WritePackedDword(bytes, 0); // IconId via known-type writer
WriteU32(bytes, itemType);
WriteU32(bytes, 0); // ObjectDescriptionFlags
Align4(bytes);
return bytes.ToArray();
}
private static void WriteU32(List<byte> bytes, uint value)
{
Span<byte> tmp = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(tmp, value);
bytes.AddRange(tmp.ToArray());
}
private static void WriteU16(List<byte> bytes, ushort value)
{
Span<byte> tmp = stackalloc byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(tmp, value);
bytes.AddRange(tmp.ToArray());
}
private static void WritePackedDword(List<byte> bytes, uint value)
{
if (value <= 0x7FFF)
{
WriteU16(bytes, (ushort)value);
return;
}
WriteU16(bytes, (ushort)(((value >> 16) & 0x7FFF) | 0x8000));
WriteU16(bytes, (ushort)(value & 0xFFFF));
}
private static void WriteString16L(List<byte> bytes, string value)
{
byte[] encoded = Encoding.GetEncoding(1252).GetBytes(value);
WriteU16(bytes, checked((ushort)encoded.Length));
bytes.AddRange(encoded);
Align4(bytes);
}
private static void Align4(List<byte> bytes)
{
while ((bytes.Count & 3) != 0)
bytes.Add(0);
}
}

View file

@ -0,0 +1,43 @@
using AcDream.Core.Combat;
namespace AcDream.Core.Tests.Combat;
public sealed class CombatInputPlannerTests
{
[Fact]
public void ToggleMode_FromNonCombat_UsesDefaultCombatMode()
{
Assert.Equal(CombatMode.Melee, CombatInputPlanner.ToggleMode(CombatMode.NonCombat));
Assert.Equal(
CombatMode.Missile,
CombatInputPlanner.ToggleMode(CombatMode.NonCombat, CombatMode.Missile));
}
[Fact]
public void ToggleMode_FromCombat_ReturnsNonCombat()
{
Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Melee));
Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Magic));
}
[Theory]
[InlineData(CombatAttackAction.Low, AttackHeight.Low)]
[InlineData(CombatAttackAction.Medium, AttackHeight.Medium)]
[InlineData(CombatAttackAction.High, AttackHeight.High)]
public void HeightFor_MapsRetailAttackKeys(CombatAttackAction action, AttackHeight expected)
{
Assert.Equal(expected, CombatInputPlanner.HeightFor(action));
}
[Theory]
[InlineData(CombatMode.Melee, true)]
[InlineData(CombatMode.Missile, true)]
[InlineData(CombatMode.NonCombat, false)]
[InlineData(CombatMode.Magic, false)]
public void SupportsTargetedAttack_MatchesRetailExecuteAttackModes(
CombatMode mode,
bool expected)
{
Assert.Equal(expected, CombatInputPlanner.SupportsTargetedAttack(mode));
}
}