feat(combat): port retail held weapon parenting
Select the default combat mode from ordered equipped objects so bows request missile stance. Parse CreateObject parent metadata and ParentEvent, then render held objects as separate children composed from setup holding locations and placement frames each animation tick.
This commit is contained in:
parent
564d39dfea
commit
ab6d96d113
18 changed files with 1152 additions and 17 deletions
|
|
@ -188,6 +188,29 @@ public sealed class CreateObjectTests
|
|||
Assert.Equal((byte)2, parsed.Value.CombatUse);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_ParentedChild_CapturesPlacementParentAndPositionSequence()
|
||||
{
|
||||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||
guid: 0x60000002u,
|
||||
name: "Bow",
|
||||
itemType: (uint)ItemType.MissileWeapon,
|
||||
placementId: (uint)DatReaderWriter.Enums.Placement.LeftHand,
|
||||
parentGuid: 0x50000001u,
|
||||
parentLocation: (uint)DatReaderWriter.Enums.ParentLocation.LeftHand,
|
||||
positionSeq: 0x3456,
|
||||
instanceSeq: 0x789A);
|
||||
|
||||
var parsed = CreateObject.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal((uint)DatReaderWriter.Enums.Placement.LeftHand, parsed.Value.PlacementId);
|
||||
Assert.Equal(0x50000001u, parsed.Value.ParentGuid);
|
||||
Assert.Equal((uint)DatReaderWriter.Enums.ParentLocation.LeftHand, parsed.Value.ParentLocation);
|
||||
Assert.Equal((ushort)0x3456, parsed.Value.PositionSequence);
|
||||
Assert.Equal((ushort)0x789A, parsed.Value.InstanceSequence);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Retail radar: PublicWeenieDesc carries two independently gated bytes.
|
||||
// Absence is distinct from an explicitly transmitted zero because zero is
|
||||
|
|
@ -625,6 +648,11 @@ public sealed class CreateObjectTests
|
|||
byte? radarBehavior = null,
|
||||
byte? combatUse = null,
|
||||
ushort movementSeq = 0,
|
||||
uint? placementId = null,
|
||||
uint? parentGuid = null,
|
||||
uint? parentLocation = null,
|
||||
ushort positionSeq = 0,
|
||||
ushort instanceSeq = 0,
|
||||
uint materialType = 0,
|
||||
uint cooldownId = 0,
|
||||
double cooldownDuration = 0,
|
||||
|
|
@ -640,12 +668,27 @@ public sealed class CreateObjectTests
|
|||
bytes.Add(0);
|
||||
bytes.Add(0);
|
||||
|
||||
// PhysicsData: physics flags = 0, then PhysicsState u32, then 9 seq stamps
|
||||
// PhysicsData: optional placement + parent bootstrap, then 9 seq stamps
|
||||
// (PhysicsTimeStamp enum order; index 1 = ObjectMovement).
|
||||
WriteU32(bytes, 0);
|
||||
uint physicsFlags = 0;
|
||||
if (placementId.HasValue) physicsFlags |= (uint)CreateObject.PhysicsDescriptionFlag.AnimationFrame;
|
||||
if (parentGuid.HasValue) physicsFlags |= (uint)CreateObject.PhysicsDescriptionFlag.Parent;
|
||||
WriteU32(bytes, physicsFlags);
|
||||
WriteU32(bytes, physicsState);
|
||||
if (placementId.HasValue) WriteU32(bytes, placementId.Value);
|
||||
if (parentGuid.HasValue)
|
||||
{
|
||||
WriteU32(bytes, parentGuid.Value);
|
||||
WriteU32(bytes, parentLocation ?? 0u);
|
||||
}
|
||||
for (int i = 0; i < 9; i++)
|
||||
WriteU16(bytes, i == 1 ? movementSeq : (ushort)0);
|
||||
WriteU16(bytes, i switch
|
||||
{
|
||||
0 => positionSeq,
|
||||
1 => movementSeq,
|
||||
8 => instanceSeq,
|
||||
_ => 0,
|
||||
});
|
||||
Align4(bytes);
|
||||
|
||||
// Fixed WeenieHeader prefix per ACE SerializeCreateObject.
|
||||
|
|
|
|||
40
tests/AcDream.Core.Net.Tests/Messages/ParentEventTests.cs
Normal file
40
tests/AcDream.Core.Net.Tests/Messages/ParentEventTests.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public sealed class ParentEventTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryParse_RetailWireOrder()
|
||||
{
|
||||
var body = new byte[24];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(0, 4), ParentEvent.Opcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4, 4), 0x50000001u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8, 4), 0x60000002u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12, 4), 2u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16, 4), 3u);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20, 2), 0x1234);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22, 2), 0x5678);
|
||||
|
||||
var parsed = ParentEvent.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(0x50000001u, parsed.Value.ParentGuid);
|
||||
Assert.Equal(0x60000002u, parsed.Value.ChildGuid);
|
||||
Assert.Equal(2u, parsed.Value.ParentLocation);
|
||||
Assert.Equal(3u, parsed.Value.PlacementId);
|
||||
Assert.Equal((ushort)0x1234, parsed.Value.ParentInstanceSequence);
|
||||
Assert.Equal((ushort)0x5678, parsed.Value.ChildPositionSequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_RejectsTruncationAndWrongOpcode()
|
||||
{
|
||||
Assert.Null(ParentEvent.TryParse(new byte[23]));
|
||||
|
||||
var body = new byte[24];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, 0xF748u);
|
||||
Assert.Null(ParentEvent.TryParse(body));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,62 @@
|
|||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.Core.Tests.Combat;
|
||||
|
||||
public sealed class CombatInputPlannerTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetDefaultCombatMode_MissileCombatUseSelectsMissile()
|
||||
{
|
||||
var bow = Equipped(EquipMask.MissileWeapon, ItemType.MissileWeapon, combatUse: 2);
|
||||
|
||||
Assert.Equal(
|
||||
CombatMode.Missile,
|
||||
CombatInputPlanner.GetDefaultCombatMode([bow]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDefaultCombatMode_PrimaryWeaponOrderMatchesRetailInventoryPlacement()
|
||||
{
|
||||
var bow = Equipped(EquipMask.MissileWeapon, ItemType.MissileWeapon, combatUse: 2);
|
||||
var sword = Equipped(EquipMask.MeleeWeapon, ItemType.MeleeWeapon, combatUse: 1);
|
||||
|
||||
Assert.Equal(
|
||||
CombatMode.Missile,
|
||||
CombatInputPlanner.GetDefaultCombatMode([bow, sword]));
|
||||
Assert.Equal(
|
||||
CombatMode.Melee,
|
||||
CombatInputPlanner.GetDefaultCombatMode([sword, bow]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDefaultCombatMode_HeldCasterSelectsMagic()
|
||||
{
|
||||
var wand = Equipped(EquipMask.Held, ItemType.Caster, combatUse: 0);
|
||||
|
||||
Assert.Equal(
|
||||
CombatMode.Magic,
|
||||
CombatInputPlanner.GetDefaultCombatMode([wand]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDefaultCombatMode_HeldNonCasterCannotEnterCombat()
|
||||
{
|
||||
var held = Equipped(EquipMask.Held, ItemType.Misc, combatUse: 0);
|
||||
|
||||
Assert.Equal(
|
||||
CombatMode.NonCombat,
|
||||
CombatInputPlanner.GetDefaultCombatMode([held]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDefaultCombatMode_NoWeaponDefaultsToUnarmedMelee()
|
||||
{
|
||||
Assert.Equal(
|
||||
CombatMode.Melee,
|
||||
CombatInputPlanner.GetDefaultCombatMode([]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleMode_FromNonCombat_UsesDefaultCombatMode()
|
||||
{
|
||||
|
|
@ -40,4 +93,15 @@ public sealed class CombatInputPlannerTests
|
|||
{
|
||||
Assert.Equal(expected, CombatInputPlanner.SupportsTargetedAttack(mode));
|
||||
}
|
||||
|
||||
private static ClientObject Equipped(
|
||||
EquipMask location,
|
||||
ItemType type,
|
||||
byte combatUse) => new()
|
||||
{
|
||||
ObjectId = 1,
|
||||
CurrentlyEquippedLocation = location,
|
||||
Type = type,
|
||||
CombatUse = combatUse,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -678,12 +678,15 @@ public sealed class ClientObjectTableTests
|
|||
{
|
||||
var table = new ClientObjectTable();
|
||||
const uint player = 0x50000001u;
|
||||
ClientObject? rolledBack = null;
|
||||
table.MoveRolledBack += item => rolledBack = item;
|
||||
table.AddOrUpdate(new ClientObject { ObjectId = 0x942u });
|
||||
table.MoveItem(0x942u, player, newSlot: -1, newEquipLocation: EquipMask.Shield); // equipped
|
||||
table.MoveItemOptimistic(0x942u, player, 0); // optimistic UNWIELD into the pack (clears equip)
|
||||
Assert.Equal(EquipMask.None, table.Get(0x942u)!.CurrentlyEquippedLocation);
|
||||
Assert.True(table.RollbackMove(0x942u)); // server rejected the unwield
|
||||
Assert.Equal(EquipMask.Shield, table.Get(0x942u)!.CurrentlyEquippedLocation); // restored to equipped
|
||||
Assert.Same(table.Get(0x942u), rolledBack);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
108
tests/AcDream.Core.Tests/Meshing/EquippedChildAttachmentTests.cs
Normal file
108
tests/AcDream.Core.Tests/Meshing/EquippedChildAttachmentTests.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Tests.Meshing;
|
||||
|
||||
public sealed class EquippedChildAttachmentTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryCompose_CombinesChildPlacementHoldingFrameAndAnimatedHandPart()
|
||||
{
|
||||
var parent = new Setup
|
||||
{
|
||||
HoldingLocations =
|
||||
{
|
||||
[ParentLocation.LeftHand] = new LocationType
|
||||
{
|
||||
PartId = 1,
|
||||
Frame = FrameAt(2, 0, 0),
|
||||
},
|
||||
},
|
||||
};
|
||||
var parentPose = new[]
|
||||
{
|
||||
new MeshRef(1, Matrix4x4.Identity),
|
||||
new MeshRef(2, Matrix4x4.CreateTranslation(10, 0, 0)),
|
||||
};
|
||||
var child = new Setup
|
||||
{
|
||||
Parts = { 0x01000001u },
|
||||
DefaultScale = { Vector3.One },
|
||||
PlacementFrames =
|
||||
{
|
||||
[Placement.LeftHand] = new AnimationFrame(1)
|
||||
{
|
||||
Frames = { FrameAt(3, 0, 0) },
|
||||
},
|
||||
},
|
||||
};
|
||||
var template = new[] { new MeshRef(0x01000001u, Matrix4x4.Identity) };
|
||||
|
||||
bool ok = EquippedChildAttachment.TryCompose(
|
||||
parent, parentPose, child, ParentLocation.LeftHand,
|
||||
Placement.LeftHand, template, 1.0f, out var result);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(15f, Assert.Single(result).PartTransform.Translation.X);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompose_MissingRequestedPlacementFallsBackToDefault()
|
||||
{
|
||||
var parent = ParentWithRightHand(partId: -1, FrameAt(4, 0, 0));
|
||||
var child = new Setup
|
||||
{
|
||||
Parts = { 0x01000001u },
|
||||
PlacementFrames =
|
||||
{
|
||||
[Placement.Default] = new AnimationFrame(1)
|
||||
{
|
||||
Frames = { FrameAt(6, 0, 0) },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
bool ok = EquippedChildAttachment.TryCompose(
|
||||
parent, [], child, ParentLocation.RightHand,
|
||||
Placement.RightHandCombat,
|
||||
[new MeshRef(0x01000001u, Matrix4x4.Identity)],
|
||||
1.0f,
|
||||
out var result);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(10f, Assert.Single(result).PartTransform.Translation.X);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompose_MissingHoldingLocationRejectsAttachment()
|
||||
{
|
||||
bool ok = EquippedChildAttachment.TryCompose(
|
||||
new Setup(), [], new Setup(), ParentLocation.RightHand,
|
||||
Placement.RightHandCombat, [], 1.0f, out var result);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
private static Setup ParentWithRightHand(int partId, Frame frame) => new()
|
||||
{
|
||||
HoldingLocations =
|
||||
{
|
||||
[ParentLocation.RightHand] = new LocationType
|
||||
{
|
||||
PartId = partId,
|
||||
Frame = frame,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
private static Frame FrameAt(float x, float y, float z) => new()
|
||||
{
|
||||
Origin = new Vector3(x, y, z),
|
||||
Orientation = Quaternion.Identity,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue