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:
Erik 2026-07-11 13:02:26 +02:00
parent 564d39dfea
commit ab6d96d113
18 changed files with 1152 additions and 17 deletions

View file

@ -0,0 +1,423 @@
using System.Numerics;
using AcDream.App.Streaming;
using AcDream.Core.Items;
using AcDream.Core.Meshing;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
namespace AcDream.App.Rendering;
/// <summary>
/// Render-side owner of retail parented physics objects (held weapons,
/// shields, and visible ammunition). Network/state parsing remains in Core;
/// this controller projects an accepted parent relation into a dynamic child
/// <see cref="WorldEntity"/> and recomposes it after the parent's animation
/// advances each frame.
/// </summary>
public sealed class EquippedChildRenderController : IDisposable
{
private readonly DatCollection _dats;
private readonly object _datLock;
private readonly ClientObjectTable _objects;
private readonly GpuWorldState _worldState;
private readonly Func<uint, WorldEntity?> _resolveEntity;
private readonly Func<uint, WorldSession.EntitySpawn?> _resolveSpawn;
private readonly Func<uint> _nextEntityId;
private readonly Dictionary<uint, PendingAttachment> _pendingByChild = new();
private readonly Dictionary<uint, PendingAttachment> _lastRelationByChild = new();
private readonly Dictionary<uint, AttachedChild> _attachedByChild = new();
private readonly Dictionary<uint, ushort> _positionSequenceByChild = new();
public IEnumerable<uint> AttachedEntityIds
{
get
{
foreach (AttachedChild child in _attachedByChild.Values)
yield return child.Entity.Id;
}
}
public EquippedChildRenderController(
DatCollection dats,
object datLock,
ClientObjectTable objects,
GpuWorldState worldState,
Func<uint, WorldEntity?> resolveEntity,
Func<uint, WorldSession.EntitySpawn?> resolveSpawn,
Func<uint> nextEntityId)
{
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
_resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity));
_resolveSpawn = resolveSpawn ?? throw new ArgumentNullException(nameof(resolveSpawn));
_nextEntityId = nextEntityId ?? throw new ArgumentNullException(nameof(nextEntityId));
_objects.ObjectMoved += OnObjectMoved;
_objects.MoveRolledBack += OnMoveRolledBack;
_objects.ObjectRemoved += OnObjectRemoved;
}
/// <summary>
/// Seed/refresh an object from CreateObject. Equipped child CreateObjects
/// carry Placement + Parent directly; this is retail's login/first-observe
/// bootstrap and does not wait for a separate ParentEvent.
/// </summary>
public void OnSpawn(WorldSession.EntitySpawn spawn)
{
_positionSequenceByChild[spawn.Guid] = spawn.PositionSequence;
if (spawn.ParentGuid is { } parentGuid and not 0
&& spawn.ParentLocation is { } parentLocation
&& spawn.PlacementId is { } placementId)
{
var relation = new PendingAttachment(
parentGuid,
spawn.Guid,
parentLocation,
placementId,
spawn.InstanceSequence,
spawn.PositionSequence,
FromCreateObject: true);
_pendingByChild[spawn.Guid] = relation;
_lastRelationByChild[spawn.Guid] = relation;
}
lock (_datLock)
{
TryRealize(spawn.Guid);
// ParentEvent can precede the parent's CreateObject. Revisit every
// child waiting specifically on the object that just arrived.
uint[] waiting = _pendingByChild.Values
.Where(p => p.ParentGuid == spawn.Guid)
.Select(p => p.ChildGuid)
.ToArray();
for (int i = 0; i < waiting.Length; i++)
TryRealize(waiting[i]);
}
}
/// <summary>
/// Apply/queue a live ParentEvent using retail's two sequence gates:
/// parent instance must match, child position must advance strictly.
/// </summary>
public void OnParentEvent(ParentEvent.Parsed update)
{
WorldSession.EntitySpawn? parentSpawn = _resolveSpawn(update.ParentGuid);
if (parentSpawn is { } knownParent
&& knownParent.InstanceSequence != update.ParentInstanceSequence)
{
// Known parent newer than the event: stale. Event newer than the
// known parent: retain until its CreateObject arrives.
if (MotionSequenceGate.IsNewer(
update.ParentInstanceSequence,
knownParent.InstanceSequence))
return;
}
if (_positionSequenceByChild.TryGetValue(update.ChildGuid, out ushort current)
&& !MotionSequenceGate.IsNewer(current, update.ChildPositionSequence))
return;
var relation = new PendingAttachment(
update.ParentGuid,
update.ChildGuid,
update.ParentLocation,
update.PlacementId,
update.ParentInstanceSequence,
update.ChildPositionSequence,
FromCreateObject: false);
_pendingByChild[update.ChildGuid] = relation;
_lastRelationByChild[update.ChildGuid] = relation;
lock (_datLock)
TryRealize(update.ChildGuid);
}
public void OnObjectDeleted(uint guid)
{
Remove(guid);
uint[] children = _attachedByChild.Values
.Where(c => c.ParentGuid == guid)
.Select(c => c.ChildGuid)
.ToArray();
for (int i = 0; i < children.Length; i++)
Remove(children[i]);
uint[] pendingChildren = _lastRelationByChild.Values
.Where(c => c.ParentGuid == guid)
.Select(c => c.ChildGuid)
.ToArray();
for (int i = 0; i < pendingChildren.Length; i++)
{
_pendingByChild.Remove(pendingChildren[i]);
_lastRelationByChild.Remove(pendingChildren[i]);
}
_pendingByChild.Remove(guid);
_lastRelationByChild.Remove(guid);
_positionSequenceByChild.Remove(guid);
}
/// <summary>Recompose every child after the parent's animation tick.</summary>
public void Tick()
{
foreach (AttachedChild child in _attachedByChild.Values)
{
WorldEntity? parent = _resolveEntity(child.ParentGuid);
if (parent is null)
continue;
if (!EquippedChildAttachment.TryCompose(
child.ParentSetup,
parent.MeshRefs,
child.ChildSetup,
child.ParentLocation,
child.Placement,
child.PartTemplate,
child.Scale,
out IReadOnlyList<MeshRef> parts))
continue;
child.Entity.MeshRefs = parts;
child.Entity.SetPosition(parent.Position);
child.Entity.Rotation = parent.Rotation;
child.Entity.ParentCellId = parent.ParentCellId;
}
}
private void TryRealize(uint childGuid)
{
if (!_pendingByChild.TryGetValue(childGuid, out PendingAttachment pending))
return;
// A ParentEvent queued before the child existed is replayed only if
// its position stamp still advances the child's CreateObject seed.
// Retail reaches the same check inside DoParentEvent after its blob
// queue resolves both objects.
if (!pending.FromCreateObject
&& _positionSequenceByChild.TryGetValue(childGuid, out ushort childPosition)
&& !MotionSequenceGate.IsNewer(childPosition, pending.ChildPositionSequence))
{
_pendingByChild.Remove(childGuid);
return;
}
WorldEntity? parentEntity = _resolveEntity(pending.ParentGuid);
WorldSession.EntitySpawn? parentSpawn = _resolveSpawn(pending.ParentGuid);
WorldSession.EntitySpawn? childSpawn = _resolveSpawn(childGuid);
if (parentEntity is null || parentSpawn is null || childSpawn is null)
return;
if (!pending.FromCreateObject
&& parentSpawn.Value.InstanceSequence != pending.ParentInstanceSequence)
{
if (MotionSequenceGate.IsNewer(
pending.ParentInstanceSequence,
parentSpawn.Value.InstanceSequence))
_pendingByChild.Remove(childGuid);
return;
}
if (parentSpawn.Value.SetupTableId is not { } parentSetupId
|| childSpawn.Value.SetupTableId is not { } childSetupId
|| parentEntity.ParentCellId is not { } parentCellId)
return;
Setup? parentSetup = _dats.Get<Setup>(parentSetupId);
Setup? childSetup = _dats.Get<Setup>(childSetupId);
if (parentSetup is null || childSetup is null)
return;
var parentLocation = (ParentLocation)pending.ParentLocation;
var placement = (Placement)pending.PlacementId;
IReadOnlyList<MeshRef> template = BuildPartTemplate(childSetup, childSpawn.Value);
float scale = childSpawn.Value.ObjScale is { } objScale && objScale > 0f
? objScale
: 1.0f;
if (!EquippedChildAttachment.TryCompose(
parentSetup,
parentEntity.MeshRefs,
childSetup,
parentLocation,
placement,
template,
scale,
out IReadOnlyList<MeshRef> parts))
return;
Remove(childGuid);
var entity = new WorldEntity
{
Id = _nextEntityId(),
ServerGuid = childGuid,
SourceGfxObjOrSetupId = childSetupId,
Position = parentEntity.Position,
Rotation = parentEntity.Rotation,
MeshRefs = parts,
PaletteOverride = BuildPaletteOverride(childSpawn.Value),
ParentCellId = parentCellId,
};
_worldState.AppendLiveEntity(parentCellId, entity);
_attachedByChild[childGuid] = new AttachedChild(
pending.ParentGuid,
childGuid,
parentLocation,
placement,
parentSetup,
childSetup,
template,
scale,
entity);
Console.WriteLine(
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
$"location={parentLocation} placement={placement}");
_positionSequenceByChild[childGuid] = pending.ChildPositionSequence;
_pendingByChild.Remove(childGuid);
}
private IReadOnlyList<MeshRef> BuildPartTemplate(
Setup setup,
WorldSession.EntitySpawn spawn)
{
var result = new MeshRef[setup.Parts.Count];
for (int i = 0; i < result.Length; i++)
result[i] = new MeshRef((uint)setup.Parts[i], Matrix4x4.Identity);
IReadOnlyList<CreateObject.AnimPartChange> partChanges =
spawn.AnimPartChanges ?? Array.Empty<CreateObject.AnimPartChange>();
for (int i = 0; i < partChanges.Count; i++)
{
CreateObject.AnimPartChange change = partChanges[i];
if (change.PartIndex < result.Length)
result[change.PartIndex] = new MeshRef(change.NewModelId, Matrix4x4.Identity);
}
IReadOnlyList<CreateObject.TextureChange> textureChanges =
spawn.TextureChanges ?? Array.Empty<CreateObject.TextureChange>();
for (int partIndex = 0; partIndex < result.Length; partIndex++)
{
Dictionary<uint, uint>? oldToNew = null;
for (int t = 0; t < textureChanges.Count; t++)
{
CreateObject.TextureChange change = textureChanges[t];
if (change.PartIndex != partIndex) continue;
oldToNew ??= new Dictionary<uint, uint>();
oldToNew[change.OldTexture] = change.NewTexture;
}
if (oldToNew is null) continue;
GfxObj? gfx = _dats.Get<GfxObj>(result[partIndex].GfxObjId);
if (gfx is null) continue;
Dictionary<uint, uint>? surfaceOverrides = null;
foreach (var surfaceQid in gfx.Surfaces)
{
uint surfaceId = (uint)surfaceQid;
Surface? surface = _dats.Get<Surface>(surfaceId);
if (surface is null) continue;
uint oldTexture = (uint)surface.OrigTextureId;
if (!oldToNew.TryGetValue(oldTexture, out uint replacement)) continue;
surfaceOverrides ??= new Dictionary<uint, uint>();
surfaceOverrides[surfaceId] = replacement;
}
if (surfaceOverrides is not null)
{
result[partIndex] = new MeshRef(
result[partIndex].GfxObjId,
Matrix4x4.Identity)
{
SurfaceOverrides = surfaceOverrides,
};
}
}
return result;
}
private static PaletteOverride? BuildPaletteOverride(WorldSession.EntitySpawn spawn)
{
if (spawn.SubPalettes is not { Count: > 0 } subPalettes)
return null;
var ranges = new PaletteOverride.SubPaletteRange[subPalettes.Count];
for (int i = 0; i < subPalettes.Count; i++)
{
CreateObject.SubPaletteSwap swap = subPalettes[i];
ranges[i] = new PaletteOverride.SubPaletteRange(
swap.SubPaletteId,
swap.Offset,
swap.Length);
}
return new PaletteOverride(spawn.BasePaletteId ?? 0, ranges);
}
private void OnObjectMoved(ClientObject item, uint _, uint __)
{
if (item.CurrentlyEquippedLocation == EquipMask.None)
Remove(item.ObjectId);
}
private void OnMoveRolledBack(ClientObject item)
{
if (item.CurrentlyEquippedLocation == EquipMask.None)
return;
// A rejected unwield restores the equipped location without a fresh
// wire ParentEvent; reinstall the last accepted relationship only for
// this explicit rollback signal.
if (_lastRelationByChild.TryGetValue(item.ObjectId, out PendingAttachment relation))
{
_pendingByChild[item.ObjectId] = relation;
lock (_datLock)
TryRealize(item.ObjectId);
}
}
private void OnObjectRemoved(ClientObject item) => OnObjectDeleted(item.ObjectId);
private void Remove(uint childGuid)
{
if (!_attachedByChild.Remove(childGuid))
return;
_worldState.RemoveEntityByServerGuid(childGuid);
}
public void Dispose()
{
_objects.ObjectMoved -= OnObjectMoved;
_objects.MoveRolledBack -= OnMoveRolledBack;
_objects.ObjectRemoved -= OnObjectRemoved;
}
private readonly record struct PendingAttachment(
uint ParentGuid,
uint ChildGuid,
uint ParentLocation,
uint PlacementId,
ushort ParentInstanceSequence,
ushort ChildPositionSequence,
bool FromCreateObject);
private sealed record AttachedChild(
uint ParentGuid,
uint ChildGuid,
ParentLocation ParentLocation,
Placement Placement,
Setup ParentSetup,
Setup ChildSetup,
IReadOnlyList<MeshRef> PartTemplate,
float Scale,
WorldEntity Entity);
}

View file

@ -131,6 +131,7 @@ public sealed class GameWindow : IDisposable
// Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer;
private AcDream.App.Streaming.GpuWorldState _worldState = new();
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
private AcDream.App.Streaming.StreamingController? _streamingController;
private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback)
private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9)
@ -1030,8 +1031,10 @@ public sealed class GameWindow : IDisposable
private readonly Dictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new();
/// <summary>
/// Latest <see cref="AcDream.Core.Net.WorldSession.EntitySpawn"/> for each
/// guid. Captured at the end of <see cref="OnLiveEntitySpawnedLocked"/> so
/// <see cref="OnLiveAppearanceUpdated"/> can reuse the position/setup/motion
/// guid. Captured before the renderability gate so no-position inventory /
/// parented children retain Setup and parent metadata; hydrated world objects
/// refresh it again at the end of <see cref="OnLiveEntitySpawnedLocked"/>.
/// <see cref="OnLiveAppearanceUpdated"/> reuses the cached position/setup/motion
/// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
/// </summary>
private readonly Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> _lastSpawnByGuid = new();
@ -2269,6 +2272,17 @@ public sealed class GameWindow : IDisposable
onLandblockUnloaded: _classificationCache.InvalidateLandblock,
entityScriptActivator: entityScriptActivator);
_equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
_dats!,
_datLock,
Objects,
_worldState,
guid => _entitiesByServerGuid.TryGetValue(guid, out var entity) ? entity : null,
guid => _lastSpawnByGuid.TryGetValue(guid, out var spawn)
? spawn
: (AcDream.Core.Net.WorldSession.EntitySpawn?)null,
() => _liveEntityIdCounter++);
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
_classificationCache, _translucencyFades);
@ -2494,6 +2508,7 @@ public sealed class GameWindow : IDisposable
_liveSession.PositionUpdated += OnLivePositionUpdated;
_liveSession.VectorUpdated += OnLiveVectorUpdated;
_liveSession.StateUpdated += OnLiveStateUpdated;
_liveSession.ParentUpdated += update => _equippedChildRenderer?.OnParentEvent(update);
_liveSession.TeleportStarted += OnTeleportStarted;
_liveSession.AppearanceUpdated += OnLiveAppearanceUpdated;
@ -2908,6 +2923,14 @@ public sealed class GameWindow : IDisposable
if (appearanceUpdate is null)
RemoveLiveEntityByServerGuid(spawn.Guid);
// Retail's weenie-object table retains CreateObject data for inventory
// and parented children even when they have no world Position. Held
// weapon CreateObjects are intentionally no-position and carry their
// Parent + Placement in PhysicsData, so cache before the renderability
// gate and offer the relationship to the focused child controller.
_lastSpawnByGuid[spawn.Guid] = spawn;
_equippedChildRenderer?.OnSpawn(spawn);
// When requested, log every spawn that arrives so we can inventory what the server
// sends (including the ones we can't render yet). The Name field
// is the critical one — we can grep the log for "Nullified Statue
@ -3549,6 +3572,7 @@ public sealed class GameWindow : IDisposable
// Cache the spawn so OnLiveAppearanceUpdated can replay it with new
// appearance fields when a later 0xF625 ObjDescEvent arrives.
_lastSpawnByGuid[spawn.Guid] = spawn;
_equippedChildRenderer?.OnSpawn(spawn);
// Commit B 2026-04-29 — live-entity collision registration. The
// local player is the simulator (its PhysicsBody is the source of
@ -3796,8 +3820,23 @@ public sealed class GameWindow : IDisposable
// re-create adopts fresh stamps from its CreateObject (retail's
// update_times die with the CPhysicsObj).
_motionSequenceGates.Remove(delete.Guid);
_equippedChildRenderer?.OnObjectDeleted(delete.Guid);
if (RemoveLiveEntityByServerGuid(delete.Guid)
// Snapshot before RemoveLiveEntityByServerGuid clears the render-side
// cache. Pickup removes only the 3-D projection; the weenie persists
// in inventory and may later become a parented weapon.
AcDream.Core.Net.WorldSession.EntitySpawn? pickedUp =
delete.FromPickup && _lastSpawnByGuid.TryGetValue(delete.Guid, out var cached)
? cached with { Position = null }
: null;
bool removed = RemoveLiveEntityByServerGuid(delete.Guid);
if (pickedUp is { } retained)
_lastSpawnByGuid[delete.Guid] = retained;
else if (!delete.FromPickup)
_lastSpawnByGuid.Remove(delete.Guid);
if (removed
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
{
Console.WriteLine(
@ -8889,6 +8928,7 @@ public sealed class GameWindow : IDisposable
// so the renderer always sees the up-to-date per-part transforms.
if (_animatedEntities.Count > 0)
TickAnimations((float)deltaSeconds);
_equippedChildRenderer?.Tick();
// #188 — advance translucency fades UNCONDITIONALLY (not gated on
// _animatedEntities.Count): a one-shot open-cycle animation can
@ -9186,6 +9226,11 @@ public sealed class GameWindow : IDisposable
_animatedIdsScratch.Clear();
foreach (var k in _animatedEntities.Keys)
_animatedIdsScratch.Add(k);
if (_equippedChildRenderer is not null)
{
foreach (uint id in _equippedChildRenderer.AttachedEntityIds)
_animatedIdsScratch.Add(id);
}
HashSet<uint>? animatedIds = _animatedIdsScratch;
// Phase G.1: sky renderer — draws the far-plane-infinity
@ -11971,7 +12016,18 @@ public sealed class GameWindow : IDisposable
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
return;
var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(Combat.CurrentMode);
var orderedEquipmentIds = Objects.GetContents(_playerServerGuid);
var orderedEquipment = new List<AcDream.Core.Items.ClientObject>(orderedEquipmentIds.Count);
for (int i = 0; i < orderedEquipmentIds.Count; i++)
{
if (Objects.Get(orderedEquipmentIds[i]) is { } item)
orderedEquipment.Add(item);
}
var defaultMode = AcDream.Core.Combat.CombatInputPlanner
.GetDefaultCombatMode(orderedEquipment);
var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(
Combat.CurrentMode,
defaultMode);
_liveSession.SendChangeCombatMode(nextMode);
Combat.SetCombatMode(nextMode);
string text = $"Combat mode {nextMode}";
@ -13618,6 +13674,7 @@ public sealed class GameWindow : IDisposable
// Phase I.7: unsubscribe combat → chat translator before the
// session it depends on goes away.
_combatChatTranslator?.Dispose();
_equippedChildRenderer?.Dispose();
_liveSessionController?.Dispose();
_liveSession = null;
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context

View file

@ -122,6 +122,12 @@ public static class CreateObject
// L.2g S1 (DEV-6): ObjectMovement stamp (timestamp block index 1)
// seeds MotionSequenceGate's MOVEMENT_TS at spawn.
ushort MovementSequence = 0,
// Parent/placement bootstrap for equipped child objects. These are
// the CreateObject equivalents of ParentEvent 0xF749.
ushort PositionSequence = 0,
uint? ParentGuid = null,
uint? ParentLocation = null,
uint? PlacementId = null,
uint? PhysicsState = null,
uint? ObjectDescriptionFlags = null,
// L.3b (2026-04-30): per-object friction + elasticity from the
@ -522,6 +528,10 @@ public static class CreateObject
physicsState = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
uint? placementId = null;
uint? parentGuid = null;
uint? parentLocation = null;
if ((physicsFlags & PhysicsDescriptionFlag.Movement) != 0)
{
// u32 length, length bytes of serialized MovementData (no header
@ -543,6 +553,7 @@ public static class CreateObject
else if ((physicsFlags & PhysicsDescriptionFlag.AnimationFrame) != 0)
{
if (body.Length - pos < 4) return null;
placementId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
}
@ -593,7 +604,9 @@ public static class CreateObject
if ((physicsFlags & PhysicsDescriptionFlag.Parent) != 0)
{
if (body.Length - pos < 8) return null;
pos += 8; // wielderId u32 + parentLocation u32
parentGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
parentLocation = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos + 4));
pos += 8;
}
if ((physicsFlags & PhysicsDescriptionFlag.Children) != 0)
{
@ -643,6 +656,7 @@ public static class CreateObject
if (body.Length - pos < 9 * 2) return PartialResult();
var seqSpan = body.Slice(pos, 9 * 2);
ushort instanceSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(8 * 2));
ushort positionSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(0 * 2));
ushort movementSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(1 * 2));
ushort teleportSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(4 * 2));
ushort serverControlSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(5 * 2));
@ -984,8 +998,14 @@ public static class CreateObject
textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq,
movementSeq,
physicsState, objectDescriptionFlags,
friction, elasticity,
PositionSequence: positionSeq,
ParentGuid: parentGuid,
ParentLocation: parentLocation,
PlacementId: placementId,
PhysicsState: physicsState,
ObjectDescriptionFlags: objectDescriptionFlags,
Friction: friction,
Elasticity: elasticity,
IconId: iconId,
Useability: useability, UseRadius: useRadius, TargetType: targetType,
IconOverlayId: iconOverlayId, IconUnderlayId: iconUnderlayId,

View file

@ -0,0 +1,42 @@
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound retail <c>ParentEvent</c> (<c>0xF749</c>): attach a child physics
/// object (weapon, shield, ammunition) to a named holding location on a
/// parent creature and apply the child's placement frame.
///
/// Wire oracle: <c>CM_Physics::DispatchSB_ParentEvent @ 0x006ACAF0</c>.
/// Runtime oracle: <c>SmartBox::HandleParentEvent @ 0x004535D0</c> and
/// <c>SmartBox::DoParentEvent @ 0x00452290</c>.
/// </summary>
public static class ParentEvent
{
public const uint Opcode = 0xF749u;
public readonly record struct Parsed(
uint ParentGuid,
uint ChildGuid,
uint ParentLocation,
uint PlacementId,
ushort ParentInstanceSequence,
ushort ChildPositionSequence);
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
if (body.Length < 24)
return null;
if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode)
return null;
return new Parsed(
BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(8, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(12, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(16, 4)),
BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(20, 2)),
BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(22, 2)));
}
}

View file

@ -118,6 +118,10 @@ public sealed class WorldSession : IDisposable
ushort InstanceSequence = 0,
ushort MovementSequence = 0,
ushort ServerControlSequence = 0,
ushort PositionSequence = 0,
uint? ParentGuid = null,
uint? ParentLocation = null,
uint? PlacementId = null,
// PublicWeenieDesc optional-tail bytes. null means the corresponding
// flag was absent; zero means the server explicitly sent the enum's
// undefined/default value.
@ -175,6 +179,10 @@ public sealed class WorldSession : IDisposable
InstanceSequence: parsed.InstanceSequence,
MovementSequence: parsed.MovementSequence,
ServerControlSequence: parsed.ServerControlSequence,
PositionSequence: parsed.PositionSequence,
ParentGuid: parsed.ParentGuid,
ParentLocation: parsed.ParentLocation,
PlacementId: parsed.PlacementId,
RadarBlipColor: parsed.RadarBlipColor,
RadarBehavior: parsed.RadarBehavior,
CombatUse: parsed.CombatUse,
@ -243,6 +251,12 @@ public sealed class WorldSession : IDisposable
/// </summary>
public event Action<VectorUpdate.Parsed>? VectorUpdated;
/// <summary>
/// Fires for retail <c>ParentEvent (0xF749)</c>, which attaches a separate
/// child object to a creature holding location (weapons, shields, ammo).
/// </summary>
public event Action<ParentEvent.Parsed>? ParentUpdated;
/// <summary>
/// Fires when the server broadcasts a <c>SetState (0xF74B)</c> game
/// message — a previously-spawned entity's <c>PhysicsState</c>
@ -886,6 +900,12 @@ public sealed class WorldSession : IDisposable
new DeleteObject.Parsed(
parsed.Value.Guid, parsed.Value.InstanceSequence, FromPickup: true));
}
else if (op == ParentEvent.Opcode)
{
var parsed = ParentEvent.TryParse(body);
if (parsed is not null)
ParentUpdated?.Invoke(parsed.Value);
}
else if (op == UpdateMotion.Opcode)
{
// Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an

View file

@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Items;
namespace AcDream.Core.Combat;
@ -48,6 +50,56 @@ public enum CombatAttackAction
/// </summary>
public static class CombatInputPlanner
{
private const EquipMask PrimaryWeaponLocations =
EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.TwoHanded;
/// <summary>
/// Port of retail <c>ClientCombatSystem::GetDefaultCombatMode</c>
/// (0x0056B310). <paramref name="orderedPlayerContents"/> must be in the
/// player's inventory-placement order; retail returns the first equipped
/// object intersecting the requested location mask.
/// </summary>
public static CombatMode GetDefaultCombatMode(
IReadOnlyList<ClientObject> orderedPlayerContents)
{
ArgumentNullException.ThrowIfNull(orderedPlayerContents);
ClientObject? weapon = GetObjectAtLocation(
orderedPlayerContents, PrimaryWeaponLocations);
if (weapon is not null)
{
// Retail COMBAT_USE_MISSILE = 2. Every other combat-use value in
// this primary weapon slot selects melee.
return weapon.CombatUse == 2
? CombatMode.Missile
: CombatMode.Melee;
}
ClientObject? held = GetObjectAtLocation(
orderedPlayerContents, EquipMask.Held);
if (held is null)
return CombatMode.Melee;
// The decomp's byte-1 sign test is ITEM_TYPE bit 15 (Caster).
return (held.Type & ItemType.Caster) != 0
? CombatMode.Magic
: CombatMode.NonCombat;
}
private static ClientObject? GetObjectAtLocation(
IReadOnlyList<ClientObject> orderedPlayerContents,
EquipMask locationMask)
{
for (int i = 0; i < orderedPlayerContents.Count; i++)
{
ClientObject candidate = orderedPlayerContents[i];
if ((candidate.CurrentlyEquippedLocation & locationMask) != 0)
return candidate;
}
return null;
}
public static CombatMode ToggleMode(
CombatMode currentMode,
CombatMode defaultCombatMode = CombatMode.Melee)

View file

@ -65,6 +65,15 @@ public sealed class ClientObjectTable
/// </summary>
public event Action<ClientObject, uint, uint>? ObjectMoved;
/// <summary>
/// Fires after an optimistic inventory/equipment move is rejected and
/// <see cref="RollbackMove"/> has restored the exact pre-move state.
/// Consumers with projections outside the item grid (for example a
/// parented 3-D weapon) can restore their prior projection without
/// treating every ordinary equip as a rollback.
/// </summary>
public event Action<ClientObject>? MoveRolledBack;
/// <summary>Fires when an object is removed from the session.</summary>
public event Action<ClientObject>? ObjectRemoved;
@ -239,7 +248,10 @@ public sealed class ClientObjectTable
{
if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false;
_pendingMoves.Remove(itemId);
return MoveItem(itemId, pre.container, pre.slot, pre.equip);
if (!MoveItem(itemId, pre.container, pre.slot, pre.equip))
return false;
MoveRolledBack?.Invoke(_objects[itemId]);
return true;
}
/// <summary>

View file

@ -0,0 +1,85 @@
using System.Numerics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Meshing;
/// <summary>
/// Retail held-object transform composition. A weapon is a separate child
/// physics object: the parent's <see cref="Setup.HoldingLocations"/> selects
/// a hand part and a frame relative to it, while the child's placement frame
/// poses the weapon's own setup parts.
///
/// Sources: <c>CPhysicsObj::add_child @ 0x0050F870</c>,
/// <c>CPhysicsObj::UpdateChild @ 0x00512D50</c>,
/// <c>CPartArray::SetPlacementFrame @ 0x005193D0</c>, and
/// <c>Frame::combine @ 0x005122E0</c>.
/// </summary>
public static class EquippedChildAttachment
{
public static bool TryCompose(
Setup parentSetup,
IReadOnlyList<MeshRef> currentParentPose,
Setup childSetup,
ParentLocation parentLocation,
Placement placement,
IReadOnlyList<MeshRef> childPartTemplate,
float childScale,
out IReadOnlyList<MeshRef> attachedParts)
{
ArgumentNullException.ThrowIfNull(parentSetup);
ArgumentNullException.ThrowIfNull(currentParentPose);
ArgumentNullException.ThrowIfNull(childSetup);
ArgumentNullException.ThrowIfNull(childPartTemplate);
if (!parentSetup.HoldingLocations.TryGetValue(parentLocation, out LocationType? holding))
{
attachedParts = Array.Empty<MeshRef>();
return false;
}
Matrix4x4 parentPart = holding.PartId >= 0
&& holding.PartId < currentParentPose.Count
? currentParentPose[holding.PartId].PartTransform
: Matrix4x4.Identity;
Matrix4x4 holdingFrame = ToMatrix(holding.Frame);
Matrix4x4 childRoot = holdingFrame * parentPart;
// Retail CPartArray::SetPlacementFrame falls back specifically to
// placement 0 (Default), then installs null if Default is absent.
AnimationFrame? placementFrame = null;
if (!childSetup.PlacementFrames.TryGetValue(placement, out placementFrame))
childSetup.PlacementFrames.TryGetValue(Placement.Default, out placementFrame);
int partCount = Math.Min(childSetup.Parts.Count, childPartTemplate.Count);
var result = new MeshRef[partCount];
for (int i = 0; i < partCount; i++)
{
Frame partFrame = placementFrame is not null && i < placementFrame.Frames.Count
? placementFrame.Frames[i]
: new Frame { Orientation = Quaternion.Identity };
Vector3 scale = i < childSetup.DefaultScale.Count
? childSetup.DefaultScale[i]
: Vector3.One;
Matrix4x4 childPart = Matrix4x4.CreateScale(scale)
* ToMatrix(partFrame);
if (childScale != 1.0f)
childPart *= Matrix4x4.CreateScale(childScale);
MeshRef template = childPartTemplate[i];
result[i] = new MeshRef(template.GfxObjId, childPart * childRoot)
{
SurfaceOverrides = template.SurfaceOverrides,
};
}
attachedParts = result;
return true;
}
private static Matrix4x4 ToMatrix(Frame frame) =>
Matrix4x4.CreateFromQuaternion(frame.Orientation)
* Matrix4x4.CreateTranslation(frame.Origin);
}