using AcDream.Core.Lighting; using AcDream.Core.Plugins; using AcDream.Core.Rendering; using AcDream.Core.World; using AcDream.Plugin.Abstractions; namespace AcDream.App.Streaming; /// /// Retained receipt for the DAT-static presentation stages of one landblock /// publication. The physics publisher calls the light/translucency stage at /// the existing per-object point immediately before ordinary collision. /// public sealed class LandblockStaticPresentationPublication { private readonly Dictionary _entities; private readonly Dictionary _snapshots; private readonly uint[] _orderedPreviouslyActiveIds; private readonly Dictionary _replacementActive; internal LandblockStaticPresentationPublication( object owner, LandblockPhysicsPublication physicsPublication, Dictionary entities, Dictionary snapshots, IReadOnlyDictionary priorActive, HashSet previouslyActiveIds, uint[] orderedPreviouslyActiveIds) { Owner = owner; PhysicsPublication = physicsPublication; _entities = entities; _snapshots = snapshots; PriorActive = priorActive; PreviouslyActiveIds = previouslyActiveIds; _orderedPreviouslyActiveIds = orderedPreviouslyActiveIds; _replacementActive = new Dictionary( physicsPublication.Build.Landblock.Entities.Count); } internal object Owner { get; } internal LandblockPhysicsPublication PhysicsPublication { get; } internal IReadOnlyDictionary PriorActive { get; } internal Dictionary MutableEntities => _entities; internal Dictionary MutableSnapshots => _snapshots; internal HashSet PreviouslyActiveIds { get; } internal IReadOnlyList OrderedPreviouslyActiveIds => _orderedPreviouslyActiveIds; internal IReadOnlyList> OrderedSnapshots { get; set; } = Array.Empty>(); internal Dictionary ReplacementActive => _replacementActive; internal int PreparationCursor { get; set; } internal bool PreparationCommitted { get; set; } internal int PriorCleanupCursor { get; set; } internal int PluginCursor { get; set; } internal bool BeginCommitted { get; set; } internal bool CompletionCommitted { get; set; } public uint LandblockId => PhysicsPublication.LandblockId; public IReadOnlyDictionary Entities => _entities; public IReadOnlyDictionary Snapshots => _snapshots; } public readonly record struct LandblockStaticPresentationDiagnostics( long BeginCount, long CompleteCount, long LightReplacementCount, long PluginSpawnCount, long PluginRefreshCount, long PluginRemovalCount, int ActiveLandblockCount, int ActiveEntityCount); /// /// Update-thread owner of DAT-static lights, translucency lifetime, and plugin /// projection. Setup defaults remain activated exactly once at the existing /// post-spatial render-pin boundary owned by . /// /// /// Retail constructs each static CPhysicsObj from Setup defaults before /// CObjCell::init_objects (0x0052B420) inserts/refloods it. The light /// state follows CPhysicsObj::set_lights (0x0050FCF0); translucency /// starts from the new object's Setup/material state before later FPHooks. /// Plugin projection is an acdream adaptation and therefore emits at most one /// logical spawn per retained static ID, while reapply only refreshes current /// state. /// public sealed class LandblockStaticPresentationPublisher { private readonly object _receiptOwner = new(); private readonly LightingHookSink _lighting; private readonly TranslucencyFadeManager _translucency; private readonly WorldGameState _worldState; private readonly WorldEvents _worldEvents; private readonly Dictionary> _activeByLandblock = new(); private readonly Dictionary _landblockByEntityId = new(); private long _beginCount; private long _completeCount; private long _lightReplacementCount; private long _pluginSpawnCount; private long _pluginRefreshCount; private long _pluginRemovalCount; public LandblockStaticPresentationPublisher( LightingHookSink lighting, TranslucencyFadeManager translucency, WorldGameState worldState, WorldEvents worldEvents) { _lighting = lighting ?? throw new ArgumentNullException(nameof(lighting)); _translucency = translucency ?? throw new ArgumentNullException(nameof(translucency)); _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); _worldEvents = worldEvents ?? throw new ArgumentNullException(nameof(worldEvents)); } public LandblockStaticPresentationDiagnostics Diagnostics => new( _beginCount, _completeCount, _lightReplacementCount, _pluginSpawnCount, _pluginRefreshCount, _pluginRemovalCount, _activeByLandblock.Count, _landblockByEntityId.Count); internal bool MatchesResources( LightingHookSink lighting, TranslucencyFadeManager translucency) => ReferenceEquals(_lighting, lighting) && ReferenceEquals(_translucency, translucency); public LandblockStaticPresentationPublication PreparePublication( LandblockPhysicsPublication physicsPublication) { LandblockStaticPresentationPublication publication = CreatePublication(physicsPublication); while (!AdvancePreparationOne(publication)) { } return publication; } /// /// Captures prior ownership without validating the replacement entity /// list. The concrete pipeline advances replacement validation one entity /// at a time before any render/physics owner mutates external state. /// internal LandblockStaticPresentationPublication CreatePublication( LandblockPhysicsPublication physicsPublication) { ArgumentNullException.ThrowIfNull(physicsPublication); uint canonical = Canonicalize(physicsPublication.LandblockId); _activeByLandblock.TryGetValue( canonical, out Dictionary? active); var entities = new Dictionary(); var snapshots = new Dictionary(); HashSet previous = active is not null ? active.Keys.ToHashSet() : new HashSet(); uint[] orderedPrevious = previous.Order().ToArray(); return new LandblockStaticPresentationPublication( _receiptOwner, physicsPublication, entities, snapshots, active ?? new Dictionary(), previous, orderedPrevious); } /// /// Validates and snapshots one replacement entity. The final stable plugin /// order is materialized only after the cursor reaches the immutable tail. /// internal bool AdvancePreparationOne( LandblockStaticPresentationPublication publication) { ValidateReceipt(publication); if (publication.PreparationCommitted) return true; IReadOnlyList source = publication.PhysicsPublication.Build.Landblock.Entities; uint canonical = Canonicalize(publication.LandblockId); if (publication.PreparationCursor < source.Count) { WorldEntity entity = source[publication.PreparationCursor]; if (entity.ServerGuid != 0) { publication.PreparationCursor++; return false; } if (publication.Entities.ContainsKey(entity.Id)) { throw new InvalidOperationException( $"Landblock 0x{canonical:X8} contains duplicate DAT-static ID " + $"0x{entity.Id:X8}."); } if (_landblockByEntityId.TryGetValue(entity.Id, out uint owner) && owner != canonical) { throw new InvalidOperationException( $"DAT-static ID 0x{entity.Id:X8} is already owned by " + $"landblock 0x{owner:X8}."); } if (publication.PriorActive.TryGetValue( entity.Id, out WorldEntitySnapshot retained) && retained.SourceId != entity.SourceGfxObjOrSetupId) { throw new InvalidOperationException( $"Retained DAT-static ID 0x{entity.Id:X8} changed source " + $"from 0x{retained.SourceId:X8} to " + $"0x{entity.SourceGfxObjOrSetupId:X8}."); } publication.MutableEntities.Add(entity.Id, entity); publication.MutableSnapshots.Add(entity.Id, Snapshot(entity)); publication.PreparationCursor++; return false; } publication.OrderedSnapshots = publication.Snapshots .OrderBy(static pair => pair.Key) .ToArray(); publication.PreparationCommitted = true; return true; } /// /// Retires omitted prior projections and detaches prior concrete lights /// before replacement collision. Retained IDs keep their SetLight latch /// and FPHook translucency state because rehydrate is not a logical create. /// public void BeginPublication(LandblockStaticPresentationPublication publication) { ValidateReceipt(publication); if (!publication.PreparationCommitted) throw new InvalidOperationException( "Static presentation cannot begin before preparation commits."); while (!AdvanceBeginOne(publication)) { } } /// /// Advances exactly one retained prior-owner cleanup. The cursor advances /// only after the complete owner operation succeeds, so a failure retries /// the same ID without rebuilding or reordering the work list. /// internal bool AdvanceBeginOne( LandblockStaticPresentationPublication publication) { ValidateReceipt(publication); if (publication.BeginCommitted) return true; if (publication.PriorCleanupCursor < publication.OrderedPreviouslyActiveIds.Count) { uint id = publication.OrderedPreviouslyActiveIds[ publication.PriorCleanupCursor]; bool retained = publication.Entities.ContainsKey(id); _lighting.UnregisterOwner(id, forgetState: !retained); if (!retained) { _translucency.ClearEntity(id); _worldState.RemoveById(id); _worldEvents.ForgetEntity(id); _landblockByEntityId.Remove(id); _pluginRemovalCount++; } publication.PriorCleanupCursor++; return false; } publication.BeginCommitted = true; _beginCount++; return true; } /// /// Exact replacement called by /// immediately before registering this object's ordinary collision. /// public void PrepareEntityBeforeCollision( LandblockStaticPresentationPublication publication, WorldEntity entity) { ValidateReceipt(publication); ArgumentNullException.ThrowIfNull(entity); if (!publication.BeginCommitted) throw new InvalidOperationException( "Static presentation cannot prepare an entity before begin commits."); if (entity.ServerGuid != 0) return; if (!publication.Entities.TryGetValue(entity.Id, out WorldEntity? expected) || !ReferenceEquals(expected, entity)) { throw new InvalidOperationException( $"DAT-static entity 0x{entity.Id:X8} does not belong to this publication."); } bool retained = publication.PreviouslyActiveIds.Contains(entity.Id); _lighting.UnregisterOwner(entity.Id, forgetState: !retained); if (!retained) _translucency.ClearEntity(entity.Id); PhysicsDatBundle datBundle = publication.PhysicsPublication.Build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty; uint sourceId = entity.SourceGfxObjOrSetupId; if ((sourceId & 0xFF000000u) == 0x02000000u && datBundle.Setups.TryGetValue(sourceId, out var setup)) { IReadOnlyList lights = LightInfoLoader.Load( setup, ownerId: entity.Id, entityPosition: entity.Position, entityRotation: entity.Rotation, cellId: entity.ParentCellId ?? 0u); for (int i = 0; i < lights.Count; i++) _lighting.RegisterOwnedLight(lights[i]); } _lightReplacementCount++; } /// /// Publishes the exact current plugin snapshot after collision/reflood. /// Existing IDs refresh replay state without emitting another logical /// spawn; new IDs emit once. /// public void CompletePublication( LandblockStaticPresentationPublication publication) { ValidateReceipt(publication); if (!publication.BeginCommitted || !publication.PhysicsPublication.CompletionCommitted) { throw new InvalidOperationException( "Static presentation requires completed physics publication."); } while (!AdvanceCompleteOne(publication)) { } } /// /// Advances exactly one plugin projection, then atomically publishes the /// retained replacement dictionary after the cursor reaches its tail. /// internal bool AdvanceCompleteOne( LandblockStaticPresentationPublication publication) { ValidateReceipt(publication); if (!publication.BeginCommitted || !publication.PhysicsPublication.CompletionCommitted) { throw new InvalidOperationException( "Static presentation requires completed physics publication."); } if (publication.CompletionCommitted) return true; if (publication.PluginCursor < publication.OrderedSnapshots.Count) { (uint id, WorldEntitySnapshot snapshot) = publication.OrderedSnapshots[publication.PluginCursor]; _worldState.Add(snapshot); if (publication.PreviouslyActiveIds.Contains(id)) { _worldEvents.UpsertCurrent(snapshot); _pluginRefreshCount++; } else { _worldEvents.FireEntitySpawned(snapshot); _pluginSpawnCount++; } _landblockByEntityId[id] = Canonicalize(publication.LandblockId); publication.ReplacementActive[id] = snapshot; publication.PluginCursor++; return false; } uint canonical = Canonicalize(publication.LandblockId); if (publication.ReplacementActive.Count == 0) _activeByLandblock.Remove(canonical); else _activeByLandblock[canonical] = publication.ReplacementActive; publication.CompletionCommitted = true; _completeCount++; return true; } public void RemoveLighting(WorldEntity entity) { ArgumentNullException.ThrowIfNull(entity); if (entity.ServerGuid == 0) _lighting.UnregisterOwner(entity.Id); } public void RemoveTranslucency(WorldEntity entity) { ArgumentNullException.ThrowIfNull(entity); if (entity.ServerGuid == 0) _translucency.ClearEntity(entity.Id); } public void RemovePluginProjection(WorldEntity entity) { ArgumentNullException.ThrowIfNull(entity); if (entity.ServerGuid != 0) return; _worldState.RemoveById(entity.Id); _worldEvents.ForgetEntity(entity.Id); if (_landblockByEntityId.Remove(entity.Id, out uint landblockId) && _activeByLandblock.TryGetValue( landblockId, out Dictionary? active)) { active.Remove(entity.Id); if (active.Count == 0) _activeByLandblock.Remove(landblockId); } _pluginRemovalCount++; } private void ValidateReceipt(LandblockStaticPresentationPublication publication) { ArgumentNullException.ThrowIfNull(publication); if (!ReferenceEquals(publication.Owner, _receiptOwner)) { throw new ArgumentException( "The static presentation receipt belongs to another publisher.", nameof(publication)); } } private static WorldEntitySnapshot Snapshot(WorldEntity entity) => new( Id: entity.Id, SourceId: entity.SourceGfxObjOrSetupId, Position: entity.Position, Rotation: entity.Rotation); private static uint Canonicalize(uint landblockId) => (landblockId & 0xFFFF0000u) | 0xFFFFu; }