From 4874d8595a2d3e436ee50457c74937b1cd4958d3 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 11:58:57 +0200 Subject: [PATCH] feat(combat): Phase L.1c wire live attack input --- src/AcDream.App/Rendering/GameWindow.cs | 154 +++++++++++++++++- src/AcDream.Core.Net/Messages/CreateObject.cs | 35 ++-- src/AcDream.Core.Net/WorldSession.cs | 2 + src/AcDream.Core/Combat/CombatModel.cs | 45 +++++ .../Messages/CreateObjectTests.cs | 99 +++++++++++ .../Combat/CombatInputPlannerTests.cs | 43 +++++ 6 files changed, 367 insertions(+), 11 deletions(-) create mode 100644 tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs create mode 100644 tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d252c9b..1f03458 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -522,6 +522,11 @@ public sealed class GameWindow : IDisposable /// keys the render list; this parallel dictionary keys by server guid. /// private readonly Dictionary _entitiesByServerGuid = new(); + private readonly Dictionary _liveEntityInfoByGuid = new(); + private uint? _selectedTargetGuid; + private readonly record struct LiveEntityInfo( + string? Name, + AcDream.Core.Items.ItemType ItemType); private int _liveSpawnReceived; // diagnostics private int _liveSpawnHydrated; private int _liveDropReasonNoPos; @@ -1662,6 +1667,9 @@ public sealed class GameWindow : IDisposable // UpdatePosition look like a 2m-residual soft-snap. _remoteDeadReckon.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 @@ -1674,12 +1682,19 @@ public sealed class GameWindow : IDisposable : "no-pos"; string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup"; 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 texChangeCount = spawn.TextureChanges?.Count ?? 0; int subPalCount = spawn.SubPalettes?.Count ?? 0; Console.WriteLine( $"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 // 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; 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: if (_cameraController?.IsFlyMode == true) _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}"; + } + /// /// K.1b: Tab handler extracted into a method so the dispatcher /// subscriber can call it. Same body as the previous Tab branch in diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 1541e07..3b4e90a 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -25,13 +25,13 @@ namespace AcDream.Core.Net.Messages; /// /// /// -/// 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 /// consumed-but-ignored so the parse position ends up wherever the /// client-side caller wanted โ€” a Parse call doesn't need to reach -/// the end of the body to return useful output. We stop after PhysicsData -/// since that's the last segment containing fields acdream cares about -/// in this phase. +/// the end of the body to return useful output. We read through the fixed +/// WeenieHeader prefix for Name/ItemType, then stop before optional header +/// tails. /// /// /// @@ -51,6 +51,8 @@ public static class CreateObject public const uint PaletteTypePrefix = 0x04000000u; /// SurfaceTexture dat id type prefix. public const uint SurfaceTextureTypePrefix = 0x05000000u; + /// Icon dat id type prefix. + public const uint IconTypePrefix = 0x06000000u; [Flags] public enum PhysicsDescriptionFlag : uint @@ -78,9 +80,9 @@ public static class CreateObject } /// - /// The three fields acdream cares about. Position and SetupTableId are - /// nullable because their corresponding physics-description-flag bits - /// may not be set on every CreateObject. + /// The spawn fields acdream currently cares about. Position and + /// SetupTableId are nullable because their corresponding + /// physics-description-flag bits may not be set on every CreateObject. /// public readonly record struct Parsed( uint Guid, @@ -92,6 +94,7 @@ public static class CreateObject uint? BasePaletteId, float? ObjScale, string? Name, + uint? ItemType, ServerMotionState? MotionState, uint? MotionTableId, ushort InstanceSequence = 0, @@ -390,27 +393,39 @@ public static class CreateObject pos += 9 * 2; 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; + uint? itemType = null; if (body.Length - pos >= 4) { pos += 4; // skip weenieFlags u32 try { 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 */ } } 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); // Local helper: if we ran out of fields past PhysicsData, still // return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion). Parsed PartialResult() => new( guid, position, setupTableId, animParts, - textureChanges, subPalettes, basePaletteId, objScale, null, motionState, motionTableId); + textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId); } catch { diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 5c2bf20..580b1b9 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -54,6 +54,7 @@ public sealed class WorldSession : IDisposable uint? BasePaletteId, float? ObjScale, string? Name, + uint? ItemType, CreateObject.ServerMotionState? MotionState, uint? MotionTableId); @@ -635,6 +636,7 @@ public sealed class WorldSession : IDisposable parsed.Value.BasePaletteId, parsed.Value.ObjScale, parsed.Value.Name, + parsed.Value.ItemType, parsed.Value.MotionState, parsed.Value.MotionTableId)); } diff --git a/src/AcDream.Core/Combat/CombatModel.cs b/src/AcDream.Core/Combat/CombatModel.cs index 246ddab..a57d37d 100644 --- a/src/AcDream.Core/Combat/CombatModel.cs +++ b/src/AcDream.Core/Combat/CombatModel.cs @@ -27,6 +27,51 @@ public enum AttackHeight Low = 3, } +public enum CombatAttackAction +{ + Low, + Medium, + High, +} + +/// +/// 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. +/// +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, + }; +} + /// /// Retail uses a 15-bit flags enum for attack types โ€” weapon categories. /// See r02 ยง2 + ACE.Entity.Enum.AttackType. diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs new file mode 100644 index 0000000..a7dea33 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs @@ -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(); + 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 bytes, uint value) + { + Span tmp = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(tmp, value); + bytes.AddRange(tmp.ToArray()); + } + + private static void WriteU16(List bytes, ushort value) + { + Span tmp = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16LittleEndian(tmp, value); + bytes.AddRange(tmp.ToArray()); + } + + private static void WritePackedDword(List 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 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 bytes) + { + while ((bytes.Count & 3) != 0) + bytes.Add(0); + } +} diff --git a/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs b/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs new file mode 100644 index 0000000..c970d45 --- /dev/null +++ b/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs @@ -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)); + } +}