acdream/src/AcDream.App/Rendering/EquippedChildRenderController.cs
Erik ab6d96d113 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.
2026-07-11 13:02:26 +02:00

423 lines
16 KiB
C#

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);
}