From fa8d5232cce4cd7885740ae2389a89f4a2e4a6da Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 07:24:26 +0200 Subject: [PATCH] refactor(interaction): add selection interaction owner Introduce a tested controller for selection mutations, target-mode interception, outbound ordering, Use/PickUp lifetime, and live-incarnation cleanup, plus narrow movement and WorldSession transport adapters. --- .../PlayerInteractionMovementSink.cs | 57 ++++ .../SelectionInteractionController.cs | 296 ++++++++++++++++ .../SelectionInteractionTransport.cs | 62 ++++ .../Interaction/WorldSelectionQuery.cs | 17 +- .../SelectionInteractionControllerTests.cs | 323 ++++++++++++++++++ 5 files changed, 754 insertions(+), 1 deletion(-) create mode 100644 src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs create mode 100644 src/AcDream.App/Interaction/SelectionInteractionController.cs create mode 100644 src/AcDream.App/Interaction/SelectionInteractionTransport.cs create mode 100644 tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs diff --git a/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs new file mode 100644 index 00000000..2890aedb --- /dev/null +++ b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs @@ -0,0 +1,57 @@ +using AcDream.App.Input; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; + +namespace AcDream.App.Interaction; + +internal interface IPlayerInteractionMovementSink +{ + bool BeginApproach(InteractionApproach approach); +} + +/// +/// Installs retail's client-side TurnToObject/MoveToObject prediction through +/// the same MovementManager used by authoritative movement packets. +/// +internal sealed class PlayerInteractionMovementSink( + Func player) + : IPlayerInteractionMovementSink +{ + private readonly Func _player = player + ?? throw new ArgumentNullException(nameof(player)); + + public bool BeginApproach(InteractionApproach approach) + { + PlayerMovementController? controller = _player(); + if (controller?.MoveTo is null) + return false; + + var parameters = new MovementParameters + { + DistanceToObject = approach.UseRadius, + CanCharge = approach.CanCharge, + }; + var movement = new MovementStruct + { + ObjectId = approach.Target.ServerGuid, + TopLevelId = approach.Target.ServerGuid, + Pos = new Position( + approach.Player.CellId, + approach.Target.Entity.Position, + System.Numerics.Quaternion.Identity), + Params = parameters, + Type = approach.IsCloseRange + ? MovementType.TurnToObject + : MovementType.MoveToObject, + Radius = approach.TargetRadius, + Height = approach.TargetHeight, + }; + + // P1's wire MoveToObject store marks the command non-autonomous. + // The speculative local install must do the same or the next raw-input + // pump overwrites it before ACE's authoritative movement arrives. + controller.SetLastMoveWasAutonomous(false); + controller.Movement.PerformMovement(movement); + return true; + } +} diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs new file mode 100644 index 00000000..67e37dde --- /dev/null +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -0,0 +1,296 @@ +using AcDream.App.Input; +using AcDream.App.UI; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using AcDream.Core.Ui; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Interaction; + +/// +/// Owns world selection mutations and one-shot Use/PickUp interaction state. +/// Read-only classification stays in ; +/// retained item policy stays in . +/// +internal sealed class SelectionInteractionController +{ + private readonly SelectionState _selection; + private readonly IWorldSelectionQuery _query; + private readonly ItemInteractionController _items; + private readonly ISelectionInteractionTransport _transport; + private readonly IPlayerInteractionMovementSink _movement; + private readonly OutboundInteractionQueue _outbound = new(); + private readonly Action? _toast; + private PendingPostArrivalAction? _pendingPostArrival; + + private readonly record struct PendingPostArrivalAction( + uint ServerGuid, + uint LocalEntityId, + bool IsPickup, + uint DestinationContainerId, + int Placement); + + public SelectionInteractionController( + SelectionState selection, + IWorldSelectionQuery query, + ItemInteractionController items, + ISelectionInteractionTransport transport, + IPlayerInteractionMovementSink movement, + Action? toast = null) + { + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); + _query = query ?? throw new ArgumentNullException(nameof(query)); + _items = items ?? throw new ArgumentNullException(nameof(items)); + _transport = transport ?? throw new ArgumentNullException(nameof(transport)); + _movement = movement ?? throw new ArgumentNullException(nameof(movement)); + _toast = toast; + } + + public bool HandleInputAction(InputAction action) + { + switch (action) + { + case InputAction.SelectionClosestMonster: + SelectClosestCombatTarget(showToast: true); + return true; + case InputAction.SelectionPreviousSelection: + _selection.SelectPrevious(); + return true; + case InputAction.SelectLeft: + PickAndStoreSelection(useImmediately: false); + return true; + case InputAction.SelectDblLeft: + PickAndStoreSelection(useImmediately: true); + return true; + case InputAction.UseSelected: + UseCurrentSelection(); + return true; + case InputAction.SelectionPickUp: + if (_selection.SelectedObjectId is uint pickupTarget) + { + _outbound.Enqueue(() => + _items.PlaceWorldItemInBackpack(pickupTarget)); + } + else + { + _toast?.Invoke("Nothing selected"); + } + return true; + case InputAction.EscapeKey when _items.IsAnyTargetModeActive: + _items.CancelTargetMode(); + return true; + default: + return false; + } + } + + public uint? PickAt(float mouseX, float mouseY, bool includeSelf) + => _query.PickAt(mouseX, mouseY, includeSelf); + + public uint? PickAtCursor(bool includeSelf) + => _query.PickAtCursor(includeSelf); + + public uint? GetSelectedOrClosestCombatTarget(bool autoTarget) + { + if (_selection.SelectedObjectId is { } selected + && _query.IsHostileMonster(selected)) + { + return selected; + } + return autoTarget ? SelectClosestCombatTarget(showToast: false) : null; + } + + public uint? SelectClosestCombatTarget(bool showToast) + { + ClosestCombatTarget? closest = _query.FindClosestHostileMonster(); + uint? bestGuid = closest?.ServerGuid; + if (bestGuid is { } selected) + _selection.Select(selected, SelectionChangeSource.Keyboard); + else + _selection.Clear(SelectionChangeSource.Keyboard); + + if (bestGuid is { } guid) + { + string label = _query.Describe(guid); + float distance = MathF.Sqrt(closest!.Value.DistanceSquared); + Console.WriteLine($"combat: selected target 0x{guid:X8} {label} dist={distance:F1}"); + if (showToast) + _toast?.Invoke($"Target {label}"); + } + else if (showToast) + { + _toast?.Invoke("No monster target"); + Console.WriteLine("combat: no creature target found"); + } + return bestGuid; + } + + public void PickAndStoreSelection(bool useImmediately) + { + uint? picked = _query.PickAtCursor(_items.IsAnyTargetModeActive); + if (picked is not uint guid) + { + if (!_items.IsAnyTargetModeActive) + _toast?.Invoke("Nothing to select"); + return; + } + + // UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound + // @ 0x004E5AD0 pulses before select/use/target-mode branching. + _query.BeginLightingPulse(guid); + if (_items.OfferPrimaryClick(guid) is not ItemPrimaryClickResult.NotActive) + return; + + _selection.Select(guid, SelectionChangeSource.World); + string label = _query.Describe(guid); + Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}"); + _toast?.Invoke($"Selected: {label}"); + if (useImmediately) + _outbound.Enqueue(() => _items.ActivateItem(guid)); + } + + public void UseCurrentSelection() + { + if (_selection.SelectedObjectId is not uint selected) + { + _toast?.Invoke("Nothing selected"); + return; + } + _outbound.Enqueue(() => _items.UseSelectedOrEnterMode(selected)); + } + + public void SendUse(uint serverGuid) + { + if (!_transport.IsInWorld) + { + _toast?.Invoke("Not in world"); + return; + } + if (!_query.IsUseable(serverGuid) + || !_query.TryGetApproach(serverGuid, out InteractionApproach approach)) + { + return; + } + + _movement.BeginApproach(approach); + if (approach.IsCloseRange) + { + _pendingPostArrival = new PendingPostArrivalAction( + serverGuid, + approach.Target.LocalEntityId, + IsPickup: false, + DestinationContainerId: 0u, + Placement: 0); + return; + } + + if (_transport.TrySendUse(serverGuid, out uint sequence)) + Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}"); + } + + public void SendPickup(uint itemGuid, uint destinationContainerId, int placement) + { + if (!_transport.IsInWorld) + { + _toast?.Invoke("Not in world"); + return; + } + if (_query.IsCreature(itemGuid)) + { + _toast?.Invoke(RetailMessages.CannotPickUpCreatures); + return; + } + if (!_query.IsPickupable(itemGuid)) + { + _toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(itemGuid))); + return; + } + if (!_query.TryGetApproach(itemGuid, out InteractionApproach approach)) + return; + + _movement.BeginApproach(approach); + if (approach.IsCloseRange) + { + _pendingPostArrival = new PendingPostArrivalAction( + itemGuid, + approach.Target.LocalEntityId, + IsPickup: true, + destinationContainerId, + placement); + return; + } + + if (_transport.TrySendPickup( + itemGuid, + destinationContainerId, + placement, + out uint sequence)) + { + Console.WriteLine( + $"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}"); + } + } + + /// Fires only after natural MoveToComplete(None), never cancellation. + public void OnNaturalMoveToComplete() + { + if (_pendingPostArrival is not { } pending) + return; + _pendingPostArrival = null; + if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId)) + return; + + if (pending.IsPickup) + { + _transport.TrySendPickup( + pending.ServerGuid, + pending.DestinationContainerId, + pending.Placement, + out _); + } + else + { + _transport.TrySendUse(pending.ServerGuid, out _); + } + } + + public void DrainOutbound() => _outbound.Drain(); + + public void OnEntityHidden(uint serverGuid) + { + if (_pendingPostArrival?.ServerGuid == serverGuid) + _pendingPostArrival = null; + if (_selection.SelectedObjectId == serverGuid) + { + _selection.Clear( + SelectionChangeSource.System, + SelectionChangeReason.Cleared); + } + } + + public void OnEntityRemoved(LiveEntityRecord record, bool replacementExists) + { + ArgumentNullException.ThrowIfNull(record); + if (_pendingPostArrival is { } pending + && pending.ServerGuid == record.ServerGuid + && pending.LocalEntityId == record.LocalEntityId) + { + _pendingPostArrival = null; + } + if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid) + { + _selection.Clear( + SelectionChangeSource.System, + SelectionChangeReason.SelectedObjectRemoved); + } + } + + public void ResetSession() + { + _items.ResetSession(); + _selection.Reset(); + _outbound.Clear(); + _pendingPostArrival = null; + } +} diff --git a/src/AcDream.App/Interaction/SelectionInteractionTransport.cs b/src/AcDream.App/Interaction/SelectionInteractionTransport.cs new file mode 100644 index 00000000..7af08489 --- /dev/null +++ b/src/AcDream.App/Interaction/SelectionInteractionTransport.cs @@ -0,0 +1,62 @@ +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.Interaction; + +internal interface ISelectionInteractionTransport +{ + bool IsInWorld { get; } + bool TrySendUse(uint serverGuid, out uint sequence); + bool TrySendPickup( + uint itemGuid, + uint destinationContainerId, + int placement, + out uint sequence); +} + +internal sealed class WorldSessionSelectionInteractionTransport( + Func session) + : ISelectionInteractionTransport +{ + private readonly Func _session = session + ?? throw new ArgumentNullException(nameof(session)); + + public bool IsInWorld => + _session()?.CurrentState == WorldSession.State.InWorld; + + public bool TrySendUse(uint serverGuid, out uint sequence) + { + WorldSession? live = _session(); + if (live?.CurrentState != WorldSession.State.InWorld) + { + sequence = 0u; + return false; + } + + sequence = live.NextGameActionSequence(); + live.SendGameAction(InteractRequests.BuildUse(sequence, serverGuid)); + return true; + } + + public bool TrySendPickup( + uint itemGuid, + uint destinationContainerId, + int placement, + out uint sequence) + { + WorldSession? live = _session(); + if (live?.CurrentState != WorldSession.State.InWorld) + { + sequence = 0u; + return false; + } + + sequence = live.NextGameActionSequence(); + live.SendGameAction(InteractRequests.BuildPickUp( + sequence, + itemGuid, + destinationContainerId, + placement)); + return true; + } +} diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs index f6a5cae1..f5548a81 100644 --- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -34,12 +34,27 @@ internal readonly record struct InteractionApproach( float TargetRadius, float TargetHeight); +internal interface IWorldSelectionQuery +{ + uint? PickAtCursor(bool includeSelf); + uint? PickAt(float mouseX, float mouseY, bool includeSelf); + void BeginLightingPulse(uint serverGuid); + bool IsCurrent(uint serverGuid, uint localEntityId); + string Describe(uint serverGuid); + bool IsCreature(uint serverGuid); + bool IsHostileMonster(uint serverGuid); + ClosestCombatTarget? FindClosestHostileMonster(); + bool IsUseable(uint serverGuid); + bool IsPickupable(uint serverGuid); + bool TryGetApproach(uint serverGuid, out InteractionApproach approach); +} + /// /// Read-only owner for retail world selection and interaction classification. /// It consumes the canonical live-entity runtime but never mutates selection, /// movement, inventory, or network state. /// -internal sealed class WorldSelectionQuery +internal sealed class WorldSelectionQuery : IWorldSelectionQuery { private const uint LargeUseObjectFlags = 0x1000u | 0x4000u | 0x40000u | 0x2000u; private const uint StuckObjectFlag = 0x0004u; diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs new file mode 100644 index 00000000..c9fa956f --- /dev/null +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -0,0 +1,323 @@ +using System.Numerics; +using AcDream.App.Interaction; +using AcDream.App.UI; +using AcDream.App.World; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using AcDream.Core.World; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Tests.Interaction; + +public sealed class SelectionInteractionControllerTests +{ + private const uint Player = 0x5000_0001u; + private const uint Target = 0x7000_0001u; + + private sealed class Query : IWorldSelectionQuery + { + public uint? Picked { get; set; } + public bool LastIncludeSelf { get; private set; } + public bool Current { get; set; } = true; + public bool Creature { get; set; } + public bool Hostile { get; set; } + public bool Useable { get; set; } = true; + public bool Pickupable { get; set; } = true; + public ClosestCombatTarget? Closest { get; set; } + public InteractionApproach? Approach { get; set; } + public List Events { get; } = new(); + + public uint? PickAtCursor(bool includeSelf) + { + LastIncludeSelf = includeSelf; + Events.Add("pick"); + return Picked; + } + + public uint? PickAt(float mouseX, float mouseY, bool includeSelf) + => PickAtCursor(includeSelf); + + public void BeginLightingPulse(uint serverGuid) => Events.Add("pulse"); + public bool IsCurrent(uint serverGuid, uint localEntityId) => Current; + public string Describe(uint serverGuid) => $"Target {serverGuid:X8}"; + public bool IsCreature(uint serverGuid) => Creature; + public bool IsHostileMonster(uint serverGuid) => Hostile; + public ClosestCombatTarget? FindClosestHostileMonster() => Closest; + public bool IsUseable(uint serverGuid) => Useable; + public bool IsPickupable(uint serverGuid) => Pickupable; + + public bool TryGetApproach(uint serverGuid, out InteractionApproach approach) + { + if (Approach is { } found) + { + approach = found; + return true; + } + approach = default; + return false; + } + } + + private sealed class Transport : ISelectionInteractionTransport + { + private uint _sequence; + public bool IsInWorld { get; set; } = true; + public List Uses { get; } = new(); + public List<(uint Item, uint Container, int Placement)> Pickups { get; } = new(); + + public bool TrySendUse(uint serverGuid, out uint sequence) + { + if (!IsInWorld) + { + sequence = 0u; + return false; + } + sequence = ++_sequence; + Uses.Add(serverGuid); + return true; + } + + public bool TrySendPickup( + uint itemGuid, + uint destinationContainerId, + int placement, + out uint sequence) + { + if (!IsInWorld) + { + sequence = 0u; + return false; + } + sequence = ++_sequence; + Pickups.Add((itemGuid, destinationContainerId, placement)); + return true; + } + } + + private sealed class Movement : IPlayerInteractionMovementSink + { + public List Approaches { get; } = new(); + public bool BeginApproach(InteractionApproach approach) + { + Approaches.Add(approach); + return true; + } + } + + private sealed class Harness + { + public readonly Query Query = new(); + public readonly Transport Transport = new(); + public readonly Movement Movement = new(); + public readonly SelectionState Selection = new(); + public readonly ClientObjectTable Objects = new(); + public readonly List Toasts = new(); + public readonly List Examines = new(); + public readonly ItemInteractionController Items; + public readonly SelectionInteractionController Controller; + + public Harness() + { + SelectionInteractionController? controller = null; + Objects.AddOrUpdate(new ClientObject + { + ObjectId = Player, + Type = ItemType.Creature, + }); + Items = new ItemInteractionController( + Objects, + () => Player, + sendUse: guid => controller!.SendUse(guid), + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + sendExamine: guid => + { + Query.Events.Add("examine"); + Examines.Add(guid); + }, + placeInBackpack: (item, container, placement) => + controller!.SendPickup(item, container, placement)); + Controller = controller = new SelectionInteractionController( + Selection, + Query, + Items, + Transport, + Movement, + Toasts.Add); + } + + public void SetApproach(bool closeRange, uint localEntityId = 101u) + { + var entity = new WorldEntity + { + Id = localEntityId, + ServerGuid = Target, + SourceGfxObjOrSetupId = 0x0200_0001u, + Position = new Vector3(5f, 0f, 0f), + Rotation = Quaternion.Identity, + MeshRefs = [], + }; + Query.Approach = new InteractionApproach( + new WorldInteractionTarget(Target, localEntityId, entity), + new PlayerInteractionPose(0x0101_0001u, Vector3.Zero), + 0.6f, + closeRange, + CanCharge: !closeRange, + TargetRadius: 0.5f, + TargetHeight: 2f); + } + } + + [Fact] + public void TargetModeClickPulsesBeforeItIsConsumedAndIncludesSelf() + { + var h = new Harness(); + h.Query.Picked = Target; + h.Items.InteractionState.EnterExamine(); + + Assert.True(h.Controller.HandleInputAction(InputAction.SelectLeft)); + + Assert.True(h.Query.LastIncludeSelf); + Assert.Equal(new[] { "pick", "pulse", "examine" }, h.Query.Events); + Assert.Equal(new[] { Target }, h.Examines); + Assert.Null(h.Selection.SelectedObjectId); + } + + [Fact] + public void ClosestTargetInputMutatesSelectionOnce() + { + var h = new Harness(); + h.Query.Closest = new ClosestCombatTarget(Target, 25f); + + Assert.True(h.Controller.HandleInputAction(InputAction.SelectionClosestMonster)); + + Assert.Equal(Target, h.Selection.SelectedObjectId); + Assert.Contains(h.Toasts, text => text.Contains("Target 70000001")); + } + + [Fact] + public void CloseUseTurnsFirstThenSendsExactlyOnceOnNaturalCompletion() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + + h.Controller.SendUse(Target); + + Assert.Single(h.Movement.Approaches); + Assert.Empty(h.Transport.Uses); + + h.Controller.OnNaturalMoveToComplete(); + h.Controller.OnNaturalMoveToComplete(); + + Assert.Equal(new[] { Target }, h.Transport.Uses); + } + + [Fact] + public void FarUseSendsImmediatelyAndNaturalCompletionDoesNotRetry() + { + var h = new Harness(); + h.SetApproach(closeRange: false); + + h.Controller.SendUse(Target); + h.Controller.OnNaturalMoveToComplete(); + + Assert.Single(h.Movement.Approaches); + Assert.Equal(new[] { Target }, h.Transport.Uses); + } + + [Fact] + public void HiddenOrStaleIncarnationCancelsDeferredAction() + { + var hidden = new Harness(); + hidden.SetApproach(closeRange: true); + hidden.Controller.SendUse(Target); + hidden.Controller.OnEntityHidden(Target); + hidden.Controller.OnNaturalMoveToComplete(); + Assert.Empty(hidden.Transport.Uses); + + var stale = new Harness(); + stale.SetApproach(closeRange: true); + stale.Controller.SendUse(Target); + stale.Query.Current = false; + stale.Controller.OnNaturalMoveToComplete(); + Assert.Empty(stale.Transport.Uses); + } + + [Fact] + public void PickupInputWaitsForFrameDrainThenUsesPendingDestination() + { + var h = new Harness(); + h.SetApproach(closeRange: false); + h.Selection.Select(Target, SelectionChangeSource.World); + + Assert.True(h.Controller.HandleInputAction(InputAction.SelectionPickUp)); + Assert.Empty(h.Transport.Pickups); + + h.Controller.DrainOutbound(); + + Assert.Equal(new[] { (Target, Player, 0) }, h.Transport.Pickups); + } + + [Fact] + public void SessionResetClearsQueuedAndDeferredWork() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Selection.Select(Target, SelectionChangeSource.World); + h.Controller.HandleInputAction(InputAction.SelectionPickUp); + h.Controller.SendUse(Target); + + h.Controller.ResetSession(); + h.Controller.DrainOutbound(); + h.Controller.OnNaturalMoveToComplete(); + + Assert.Null(h.Selection.SelectedObjectId); + Assert.Empty(h.Transport.Pickups); + Assert.Empty(h.Transport.Uses); + } + + [Fact] + public void OldRemovalClearsCapturedActionButDoesNotClearReplacementSelection() + { + var h = new Harness(); + h.SetApproach(closeRange: true, localEntityId: 101u); + h.Selection.Select(Target, SelectionChangeSource.World); + h.Controller.SendUse(Target); + var oldRecord = new LiveEntityRecord(Spawn(Target, instance: 1)); + oldRecord.WorldEntity = new WorldEntity + { + Id = 101u, + ServerGuid = Target, + SourceGfxObjOrSetupId = 0x0200_0001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = [], + }; + + h.Controller.OnEntityRemoved(oldRecord, replacementExists: true); + h.Controller.OnNaturalMoveToComplete(); + + Assert.Equal(Target, h.Selection.SelectedObjectId); + Assert.Empty(h.Transport.Uses); + } + + private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) + => new( + guid, + Position: null, + SetupTableId: null, + AnimPartChanges: [], + TextureChanges: [], + SubPalettes: [], + BasePaletteId: null, + ObjScale: null, + Name: null, + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: instance); +}