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; /// /// 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 /// and recomposes it after the parent's animation /// advances each frame. /// public sealed class EquippedChildRenderController : IDisposable { private readonly DatCollection _dats; private readonly object _datLock; private readonly ClientObjectTable _objects; private readonly GpuWorldState _worldState; private readonly Func _resolveEntity; private readonly Func _resolveSpawn; private readonly Func _nextEntityId; private readonly Dictionary _pendingByChild = new(); private readonly Dictionary _lastRelationByChild = new(); private readonly Dictionary _attachedByChild = new(); private readonly Dictionary _positionSequenceByChild = new(); public IEnumerable AttachedEntityIds { get { foreach (AttachedChild child in _attachedByChild.Values) yield return child.Entity.Id; } } public EquippedChildRenderController( DatCollection dats, object datLock, ClientObjectTable objects, GpuWorldState worldState, Func resolveEntity, Func resolveSpawn, Func 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; } /// /// 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. /// 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]); } } /// /// Apply/queue a live ParentEvent using retail's two sequence gates: /// parent instance must match, child position must advance strictly. /// 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); } /// Recompose every child after the parent's animation tick. 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 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(parentSetupId); Setup? childSetup = _dats.Get(childSetupId); if (parentSetup is null || childSetup is null) return; var parentLocation = (ParentLocation)pending.ParentLocation; var placement = (Placement)pending.PlacementId; IReadOnlyList 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 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 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 partChanges = spawn.AnimPartChanges ?? Array.Empty(); 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 textureChanges = spawn.TextureChanges ?? Array.Empty(); for (int partIndex = 0; partIndex < result.Length; partIndex++) { Dictionary? oldToNew = null; for (int t = 0; t < textureChanges.Count; t++) { CreateObject.TextureChange change = textureChanges[t]; if (change.PartIndex != partIndex) continue; oldToNew ??= new Dictionary(); oldToNew[change.OldTexture] = change.NewTexture; } if (oldToNew is null) continue; GfxObj? gfx = _dats.Get(result[partIndex].GfxObjId); if (gfx is null) continue; Dictionary? surfaceOverrides = null; foreach (var surfaceQid in gfx.Surfaces) { uint surfaceId = (uint)surfaceQid; Surface? surface = _dats.Get(surfaceId); if (surface is null) continue; uint oldTexture = (uint)surface.OrigTextureId; if (!oldToNew.TryGetValue(oldTexture, out uint replacement)) continue; surfaceOverrides ??= new Dictionary(); 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 PartTemplate, float Scale, WorldEntity Entity); }