using System.Collections.Generic; using AcDream.Core.World; namespace AcDream.App.Rendering.Wb; /// /// Bridges landblock streaming events to 's /// reference-count lifecycle. Tier-aware by design: only atlas-tier /// entities (procedural / dat-hydrated, identified by /// ServerGuid == 0) drive ref counts. Server-spawned entities /// (per-instance tier) are skipped — those go through /// EntitySpawnAdapter and the owner-scoped texture path /// (see Phase N.4 spec, Architecture → Two-tier rendering split). /// /// /// On load: walks the landblock's atlas-tier entities, collects unique /// GfxObj ids from their MeshRefs, calls /// IncrementRefCount per id, and pins each specialized EnvCell geometry /// id without starting generic GfxObj decode. Each reference has an explicit /// desired/held marker so a throwing backend cannot make the logical snapshot /// disagree with the physical reference count. /// /// /// /// On unload: releases only references whose held marker is still set. A /// before-commit failure remains retryable; an after-commit /// advances the marker before the /// exception is propagated. The registration is dropped only after every held /// reference has been released. Unknown / never-loaded landblocks no-op. /// /// /// /// Idempotency: repeated notifications for the same landblock only register /// newly-seen ids. This matters for two-tier streaming: a far-tier terrain /// load first snapshots an empty entity set, then a later Far-to-Near promotion /// supplies the actual stabs/buildings. Treating the second notification as a /// blanket no-op leaves the world-state entity list populated while the WB /// mesh cache never pins the promoted GfxObj ids. /// /// /// /// Thread safety: the internal snapshots are intentionally not synchronized. /// invokes every load, unload, and readiness query /// on the owning render/update thread. /// /// public sealed class LandblockSpawnAdapter { private sealed class ReferenceRegistration { public bool Desired; public bool Held; } private sealed class LandblockRegistration { public bool WantsLoaded; public Dictionary Ordinary { get; } = new(); public Dictionary Prepared { get; } = new(); } private readonly IWbMeshAdapter _adapter; private readonly Dictionary _registrations = new(); public LandblockSpawnAdapter(IWbMeshAdapter adapter) { System.ArgumentNullException.ThrowIfNull(adapter); _adapter = adapter; } /// /// Called when a landblock finishes streaming in or receives promoted /// atlas-tier entities. Registers a ref-count increment with WB for each /// unique atlas-tier GfxObj id that has not already been registered for /// this landblock. /// public void OnLandblockLoaded( LoadedLandblock landblock, IEnumerable? additionalReadinessIds = null) { System.ArgumentNullException.ThrowIfNull(landblock); var unique = new HashSet(); foreach (var entity in landblock.Entities) { // Atlas-tier filter: server-spawned entities (ServerGuid != 0) // belong to the per-instance path and are NOT registered with WB. if (entity.ServerGuid != 0) continue; foreach (var meshRef in entity.MeshRefs) unique.Add((ulong)meshRef.GfxObjId); } HashSet? preparedIds = additionalReadinessIds is null ? null : new HashSet(additionalReadinessIds); if (!_registrations.TryGetValue(landblock.LandblockId, out var registration)) { registration = new LandblockRegistration { WantsLoaded = true }; _registrations.Add(landblock.LandblockId, registration); } else if (!registration.WantsLoaded) { // This is a new load edge that arrived while a preceding unload // still had unfinished releases. The new snapshot replaces the old // desired set. Any still-held overlap remains acquired; obsolete // residual references are released by Reconcile below. MarkAllUndesired(registration.Ordinary); MarkAllUndesired(registration.Prepared); registration.WantsLoaded = true; } MarkDesired(registration.Ordinary, unique); if (preparedIds is not null) MarkDesired(registration.Prepared, preparedIds); List? failures = null; ReleaseUndesired(registration.Ordinary, ref failures); ReleaseUndesired(registration.Prepared, ref failures); PruneReleasedUndesired(registration.Ordinary); PruneReleasedUndesired(registration.Prepared); AcquireDesired(registration.Ordinary, prepared: false, ref failures); AcquireDesired(registration.Prepared, prepared: true, ref failures); ThrowFailures( failures, $"Landblock 0x{landblock.LandblockId:X8} mesh-reference acquisition did not fully converge."); } /// /// True only after every render mesh required by this published landblock /// is drawable. This includes both ref-counted static GfxObjs and EnvCell /// shell geometry prepared by the independent indoor pipeline. /// public bool IsLandblockRenderReady(uint landblockId) { if (!_registrations.TryGetValue(landblockId, out var registration) || !registration.WantsLoaded) return false; foreach (var pair in registration.Ordinary) if (!pair.Value.Desired || !pair.Value.Held || !_adapter.IsRenderDataReady(pair.Key)) return false; foreach (var pair in registration.Prepared) if (!pair.Value.Desired || !pair.Value.Held || !_adapter.IsRenderDataReady(pair.Key)) return false; return true; } /// /// Called when a landblock is unloaded from the streaming window. /// Releases the ref-count for every GfxObj id that was registered on load. /// Unknown landblock ids (never loaded, or already unloaded) are no-ops. /// public void OnLandblockUnloaded(uint landblockId) { if (!_registrations.TryGetValue(landblockId, out var registration)) return; registration.WantsLoaded = false; MarkAllUndesired(registration.Ordinary); MarkAllUndesired(registration.Prepared); List? failures = null; ReleaseUndesired(registration.Ordinary, ref failures); ReleaseUndesired(registration.Prepared, ref failures); PruneReleasedUndesired(registration.Ordinary); PruneReleasedUndesired(registration.Prepared); // Even an after-commit backend exception may report failure after the // final physical release. Drop the registration before propagating in // that case so a caller retry cannot decrement the same reference. if (registration.Ordinary.Count == 0 && registration.Prepared.Count == 0) _registrations.Remove(landblockId); ThrowFailures( failures, $"Landblock 0x{landblockId:X8} mesh-reference release did not fully converge."); } private static void MarkDesired( Dictionary registrations, IEnumerable ids) { foreach (ulong id in ids) { if (!registrations.TryGetValue(id, out var reference)) { reference = new ReferenceRegistration(); registrations.Add(id, reference); } reference.Desired = true; } } private static void MarkAllUndesired( Dictionary registrations) { foreach (var reference in registrations.Values) reference.Desired = false; } private void AcquireDesired( Dictionary registrations, bool prepared, ref List? failures) { foreach (var pair in registrations) { ReferenceRegistration reference = pair.Value; if (!reference.Desired || reference.Held) continue; try { if (prepared) _adapter.PinPreparedRenderData(pair.Key); else _adapter.IncrementRefCount(pair.Key); reference.Held = true; } catch (Exception error) { if (error is MeshReferenceMutationException { MutationCommitted: true }) reference.Held = true; (failures ??= new List()).Add(error); } } } private void ReleaseUndesired( Dictionary registrations, ref List? failures) { foreach (var pair in registrations) { ReferenceRegistration reference = pair.Value; if (reference.Desired || !reference.Held) continue; try { _adapter.DecrementRefCount(pair.Key); reference.Held = false; } catch (Exception error) { if (error is MeshReferenceMutationException { MutationCommitted: true }) reference.Held = false; (failures ??= new List()).Add(error); } } } private static void PruneReleasedUndesired( Dictionary registrations) { List? released = null; foreach (var pair in registrations) { if (!pair.Value.Desired && !pair.Value.Held) (released ??= new List()).Add(pair.Key); } if (released is null) return; foreach (ulong id in released) registrations.Remove(id); } private static void ThrowFailures(List? failures, string message) { if (failures is null) return; if (failures.Count == 1) throw failures[0]; throw new AggregateException(message, failures); } }