using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Scene;
using AcDream.Core.Lighting;
using AcDream.Core.Meshing;
using AcDream.Core.Rendering;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using AcDream.App.Rendering.Selection;
using DatReaderWriter.Enums;
namespace AcDream.App.Rendering.Wb;
///
/// Draws entities using WB's (a single global
/// vertex/index arena under modern rendering) with acdream's
/// for texture resolution. Exact pass classification
/// travels with each immutable prepared mesh batch.
///
///
/// Atlas-tier entities (ServerGuid == 0): mesh data comes from WB's
/// via .
/// Shared textures reuse each batch's WB atlas handle and layer, returning
/// a device texture-table slot stored in the per-group SSBO.
///
///
///
/// Per-instance-tier entities (ServerGuid != 0): mesh data also from
/// WB. Native surfaces still reuse the WB atlas; only actual indexed-palette
/// and original-texture replacements resolve through owner-scoped
/// composites. is currently
/// unused at draw time — GameWindow's spawn path already bakes AnimPartChanges +
/// GfxObjDegradeResolver (Issue #47 close-detail mesh) into MeshRefs.
///
///
///
/// Draw strategy (Campaign V — Vulkan only): multi-draw-indexed-indirect
/// with SSBOs, recorded through the RHI encoder in the sibling
/// WbDrawDispatcher.Rhi.cs partial. All visible (entity, batch) pairs are
/// bucketed by ; each group becomes one
/// DrawElementsIndirectCommand. Per-frame ring allocations carry instance
/// matrices (binding 0), per-group batch metadata/texture-table slots (binding
/// 1), and the indirect draw commands. Opaque world groups remain MDI-batched.
/// Transparent world instances enter so ordinary
/// GfxObj parts and particles share retail's stable far-to-near stream; sealed
/// off-screen consumers retain the immediate transparent MDI path.
///
///
///
/// Shader: mesh_modern, compiled from committed SPIR-V. Missing a
/// mandatory GPU capability (the device texture table, MDI, or SSBOs) throws at
/// renderer construction — there is no legacy fallback.
///
///
///
/// Modern rendering assumption: WB's modern-rendering path puts every
/// mesh in a single shared vertex/index arena and uses FirstIndex +
/// BaseVertex per batch. The dispatcher honors those offsets inside each
/// DrawElementsIndirectCommand via multi-draw-indexed-indirect.
///
///
public sealed partial class WbDrawDispatcher : IDisposable
{
///
/// Which subset of entities to walk in a single Draw call.
///
/// Phase U.1 (2026-05-30): the indoor/outdoor two-pipe split (IndoorPass /
/// OutdoorScenery / BuildingShells / LiveDynamic) was deleted along with the
/// inside-out render machinery. is the sole remaining
/// member; the unified retail-faithful pass (Phase U) draws every entity in
/// one path. The set: parameter is retained on the Draw overloads so
/// the unified pass can re-introduce partitioning later without re-threading
/// the call sites.
///
public enum EntitySet
{
/// Every entity walked, gated only by the existing
/// ParentCellId ∈ visibleCellIds filter.
All,
}
private readonly TextureCache _textures;
private readonly WbMeshAdapter _meshAdapter;
private readonly EntitySpawnAdapter _entitySpawnAdapter;
private readonly IRetailSelectionRenderSink? _selectionSink;
private readonly IRetailSelectionLightingSource? _selectionLighting;
private readonly RetailAlphaQueue? _alphaQueue;
private readonly AlphaDrawSource _alphaSource;
private readonly RetainedScratchCapacityPolicy _alphaScratchPolicy;
private int _scratchPeakUnits;
private ICurrentRenderDispatcherObserver? _currentRenderSceneObserver;
public readonly record struct DrawStats(
EntitySet Set,
int EntitiesWalked,
int MeshRefs,
int Instances,
int Draws,
int CullRuns,
int OpaqueDraws,
int TransparentDraws,
long Triangles);
public DrawStats LastDrawStats { get; private set; }
public bool CompositeTexturesReady { get; private set; } = true;
internal int LastCompositeWarmupPendingCount { get; private set; }
// Candidate discovery only reads already-published entity facts. Keep it
// independently bounded from the materially more expensive mesh/texture
// preparation below so a large retained Far-tier world cannot consume the
// entire portal reveal window merely proving that most entities are
// outside the destination neighborhood.
internal const int MaximumCompositeWarmupScanEntitiesPerFrame = 4096;
internal const int MaximumCompositeWarmupPrepareEntitiesPerFrame = 128;
private readonly Queue _compositeWarmupQueue = new();
// Membership may change while ACE is streaming the destination object set.
// Retain exact candidate progress and schedule a follow-up pass instead of
// restarting at index zero on every generation edge.
private readonly HashSet _compositeWarmupTracked = [];
private IReadOnlyList? _compositeWarmupSource;
private ulong _compositeWarmupSourceGeneration;
private uint _compositeWarmupDestinationCell;
private int _compositeWarmupRadius;
private int _compositeWarmupScanIndex;
private bool _compositeWarmupScanComplete = true;
private enum CompositeWarmupResult : byte
{
Complete,
Pending,
UploadBudgetBlocked,
}
public void InvalidateCompositeWarmupReadiness()
{
CompositeTexturesReady = false;
LastCompositeWarmupPendingCount = 1;
_compositeWarmupQueue.Clear();
_compositeWarmupTracked.Clear();
_compositeWarmupSource = null;
_compositeWarmupSourceGeneration = 0;
_compositeWarmupDestinationCell = 0;
_compositeWarmupRadius = 0;
_compositeWarmupScanIndex = 0;
_compositeWarmupScanComplete = true;
}
///
/// Resolves live-object palette/original-texture composites before the
/// world viewport becomes visible. The texture cache enforces the upload
/// budget, so repeated calls advance readiness over multiple portal-space
/// frames without one large first-world-frame upload burst.
///
public void PrepareCompositeTextures(
IReadOnlyList entities,
ulong entityGeneration,
uint destinationCell,
int radius)
{
ArgumentNullException.ThrowIfNull(entities);
ArgumentOutOfRangeException.ThrowIfNegative(radius);
if (RequiresCompositeWarmupRebuild(
_compositeWarmupSource,
_compositeWarmupDestinationCell,
_compositeWarmupRadius,
entities,
destinationCell,
radius))
{
RebuildCompositeWarmupQueue(
entities,
entityGeneration,
destinationCell,
radius);
}
else if (ShouldBeginCompositeWarmupRescan(
_compositeWarmupScanComplete,
_compositeWarmupSourceGeneration,
entityGeneration))
{
BeginCompositeWarmupRescan(entities.Count, entityGeneration);
}
if (CompositeTexturesReady)
return;
_compositeWarmupScanIndex =
Math.Min(_compositeWarmupScanIndex, entities.Count);
int scanEnd = CompositeWarmupScanEnd(
_compositeWarmupScanIndex,
entities.Count);
for (; _compositeWarmupScanIndex < scanEnd; _compositeWarmupScanIndex++)
{
WorldEntity entity = entities[_compositeWarmupScanIndex];
if (IsCompositeWarmupCandidate(entity, destinationCell, radius)
&& _compositeWarmupTracked.Add(entity))
{
_compositeWarmupQueue.Enqueue(entity);
}
}
_compositeWarmupScanComplete = _compositeWarmupScanIndex == entities.Count;
if (ShouldBeginCompositeWarmupRescan(
_compositeWarmupScanComplete,
_compositeWarmupSourceGeneration,
entityGeneration))
{
BeginCompositeWarmupRescan(entities.Count, entityGeneration);
}
int candidatesThisPass = Math.Min(
_compositeWarmupQueue.Count,
MaximumCompositeWarmupPrepareEntitiesPerFrame);
for (int i = 0; i < candidatesThisPass; i++)
{
WorldEntity entity = _compositeWarmupQueue.Dequeue();
CompositeWarmupResult result = PrepareCompositeEntity(entity);
if (result != CompositeWarmupResult.Complete)
_compositeWarmupQueue.Enqueue(entity);
if (result == CompositeWarmupResult.UploadBudgetBlocked)
break;
}
LastCompositeWarmupPendingCount = _compositeWarmupQueue.Count
+ (_compositeWarmupScanComplete ? 0 : entities.Count - _compositeWarmupScanIndex);
CompositeTexturesReady = _compositeWarmupScanComplete
&& _compositeWarmupQueue.Count == 0;
if (!CompositeTexturesReady
&& AcDream.Core.Net.NetDiagnostics.ProbeReveal)
{
ProbeRevealWarmupStall();
}
}
// #260 probe: while composite warmup blocks a reveal, name the stall once
// per second. The two permanent-stall shapes — a GfxObj id that never
// resolves (silent load failure, e.g. custom-server content missing from
// the baked pak) versus an upload budget that never reopens — are
// otherwise indistinguishable from the reveal log's composites=False.
private long _probeWarmupLastEmitTs;
private void ProbeRevealWarmupStall()
{
long now = Stopwatch.GetTimestamp();
if (_probeWarmupLastEmitTs != 0
&& now - _probeWarmupLastEmitTs < Stopwatch.Frequency)
{
return;
}
_probeWarmupLastEmitTs = now;
var pendingIds = new System.Text.StringBuilder();
int listed = 0;
foreach (WorldEntity entity in _compositeWarmupQueue)
{
if (listed >= 4)
break;
ulong gfxObjId = entity.MeshRefs.Count > 0
? entity.MeshRefs[0].GfxObjId
: 0;
if (listed > 0)
pendingIds.Append(',');
pendingIds.Append($"0x{gfxObjId:X8}");
listed++;
}
Console.WriteLine(
$"[composite-warmup] STALL pending={LastCompositeWarmupPendingCount}"
+ $" queue={_compositeWarmupQueue.Count}"
+ $" scanComplete={_compositeWarmupScanComplete}"
+ $" uploadOpen={_textures.CanStartCompositeUpload}"
+ $" firstPending=[{pendingIds}]");
}
internal static bool RequiresCompositeWarmupRebuild(
IReadOnlyList? currentSource,
uint currentDestinationCell,
int currentRadius,
IReadOnlyList nextSource,
uint nextDestinationCell,
int nextRadius) =>
!ReferenceEquals(currentSource, nextSource)
|| currentDestinationCell != nextDestinationCell
|| currentRadius != nextRadius;
internal static bool ShouldBeginCompositeWarmupRescan(
bool scanComplete,
ulong scanGeneration,
ulong entityGeneration) =>
scanComplete && scanGeneration != entityGeneration;
internal static int CompositeWarmupScanEnd(
int scanIndex,
int entityCount)
{
ArgumentOutOfRangeException.ThrowIfNegative(scanIndex);
ArgumentOutOfRangeException.ThrowIfNegative(entityCount);
return Math.Min(
entityCount,
scanIndex + MaximumCompositeWarmupScanEntitiesPerFrame);
}
private void RebuildCompositeWarmupQueue(
IReadOnlyList entities,
ulong entityGeneration,
uint destinationCell,
int radius)
{
_compositeWarmupQueue.Clear();
_compositeWarmupTracked.Clear();
_compositeWarmupSource = entities;
_compositeWarmupSourceGeneration = entityGeneration;
_compositeWarmupDestinationCell = destinationCell;
_compositeWarmupRadius = radius;
_compositeWarmupScanIndex = 0;
_compositeWarmupScanComplete = entities.Count == 0;
LastCompositeWarmupPendingCount = entities.Count;
CompositeTexturesReady = _compositeWarmupScanComplete;
}
private void BeginCompositeWarmupRescan(
int entityCount,
ulong entityGeneration)
{
_compositeWarmupSourceGeneration = entityGeneration;
_compositeWarmupScanIndex = 0;
_compositeWarmupScanComplete = entityCount == 0;
CompositeTexturesReady =
_compositeWarmupScanComplete && _compositeWarmupQueue.Count == 0;
}
internal static bool IsCompositeWarmupCandidate(
WorldEntity entity,
uint destinationCell,
int radius)
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentOutOfRangeException.ThrowIfNegative(radius);
if (destinationCell != 0)
{
// A cell-less live object is either outside the published
// destination or still transitioning. It must not load meshes
// or hold this destination's portal readiness.
if (!TryGetEntityCell(entity, out uint entityCell)
|| !IsWithinLandblockRadius(entityCell, destinationCell, radius))
{
return false;
}
}
if (entity.PaletteOverride is not null)
return true;
for (int meshIndex = 0; meshIndex < entity.MeshRefs.Count; meshIndex++)
{
if (entity.MeshRefs[meshIndex].SurfaceOverrides is { Count: > 0 })
return true;
}
return false;
}
private CompositeWarmupResult PrepareCompositeEntity(WorldEntity entity)
{
bool pending = false;
PaletteCompositeIdentity paletteIdentity = entity.PaletteOverride is not null
? TextureCache.GetPaletteIdentity(entity.PaletteOverride)
: default;
for (int meshIndex = 0; meshIndex < entity.MeshRefs.Count; meshIndex++)
{
MeshRef meshRef = entity.MeshRefs[meshIndex];
ObjectRenderData? renderData = _meshAdapter.TryGetRenderData(meshRef.GfxObjId);
if (renderData is null)
{
_meshAdapter.EnsureLoaded(meshRef.GfxObjId);
pending = true;
continue;
}
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
{
for (int partIndex = 0; partIndex < renderData.SetupParts.Count; partIndex++)
{
ulong partId = renderData.SetupParts[partIndex].GfxObjId;
ObjectRenderData? partData = _meshAdapter.TryGetRenderData(partId);
if (partData is null)
{
_meshAdapter.EnsureLoaded(partId);
pending = true;
continue;
}
if (!PrepareCompositeBatches(entity, meshRef, partData, paletteIdentity))
pending = true;
if (!_textures.CanStartCompositeUpload && pending)
return CompositeWarmupResult.UploadBudgetBlocked;
}
}
else
{
if (!PrepareCompositeBatches(entity, meshRef, renderData, paletteIdentity))
pending = true;
if (!_textures.CanStartCompositeUpload && pending)
return CompositeWarmupResult.UploadBudgetBlocked;
}
}
return pending ? CompositeWarmupResult.Pending : CompositeWarmupResult.Complete;
}
private bool PrepareCompositeBatches(
WorldEntity entity,
MeshRef meshRef,
ObjectRenderData renderData,
PaletteCompositeIdentity paletteIdentity)
{
bool complete = true;
for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++)
{
_ = ResolveTexture(
entity,
meshRef,
renderData.Batches[batchIndex],
paletteIdentity,
out bool compositePending);
if (compositePending)
complete = false;
if (compositePending && !_textures.CanStartCompositeUpload)
break;
}
return complete;
}
private static bool TryGetEntityCell(WorldEntity entity, out uint cell)
{
if (entity.ParentCellId is uint parent)
{
cell = parent;
return true;
}
if (entity.EffectCellId is uint effect)
{
cell = effect;
return true;
}
cell = 0;
return false;
}
private static bool IsWithinLandblockRadius(uint cell, uint center, int radius)
{
int x = (int)(cell >> 24);
int y = (int)((cell >> 16) & 0xFFu);
int centerX = (int)(center >> 24);
int centerY = (int)((center >> 16) & 0xFFu);
return Math.Abs(x - centerX) <= radius && Math.Abs(y - centerY) <= radius;
}
// Tier 1 cache (#53): per-entity classification results for static
// entities (those NOT in GameWindow._animatedEntities). Wired here in
// Task 7 for plumbing only — Tasks 9-10 wire the per-entity
// miss-populate / hit-fast-path through the loop.
private readonly EntityClassificationCache _cache;
// #188 — per-(entity, Setup-part) translucency ramp state (fading doors /
// secret-passage walls). ClassifyBatches reads this per part to compute
// the instance's opacity multiplier; never mutated here.
private readonly AcDream.Core.Rendering.TranslucencyFadeManager _translucencyFades;
// ACDREAM_DISABLE_TIER1_CACHE=1 A/B diagnostic — forces every static
// entity through the slow path. Read once in ctor.
private readonly bool _tier1CacheDisabled =
string.Equals(Environment.GetEnvironmentVariable("ACDREAM_DISABLE_TIER1_CACHE"), "1", StringComparison.Ordinal);
///
/// A.5 T22.5: gate for GL_SAMPLE_ALPHA_TO_COVERAGE around the opaque pass.
/// Default true matches T20 behavior. Set false for Low/Medium presets that
/// have MsaaSamples=0 (A2C is a no-op without MSAA, but turning it off
/// avoids the unnecessary GL state thrash and is cleaner diagnostics).
/// Can be toggled mid-session via
/// .
///
public bool AlphaToCoverage { get; set; } = true;
// Phase U.3: per-instance clip-slot data (binding=3 on the RHI ring). One
// uint per instance selecting its CellClip slot. In U.3 this is ALL ZEROS
// (every instance → slot 0 → no-clip), so the render is identical to
// pre-U.3. U.4 populates real slot indices.
private uint[] _clipSlotData = new uint[256];
// Fix B (A7 #3): per-OBJECT light selection (minimize_object_lighting). Two
// ring sections replace the single global nearest-8-to-CAMERA UBO set for
// point/spot lights — see mesh_modern.vert binding=4/5. The global-lights
// section (binding=4) holds the per-frame point-light snapshot
// (LightManager.PointSnapshot); the light-set section (binding=5) holds
// MaxLightsPerObject int indices per instance INTO it (-1 = unused), laid
// out parallel to the instance data.
private int[] _lightSetData = new int[256 * LightManager.MaxLightsPerObject];
private float[] _globalLightData = new float[GlobalLightPacker.FloatsPerLight * 16]; // 16 floats (4 vec4) per GlobalLight
// #142: per-instance "indoor" flag (binding=6), one uint per instance, parallel
// to the instance data. 1 = object parented to an EnvCell (skip the sun in
// the shader's uLightingMode==0 branch); 0 = outdoor object (gets the sun).
// Mechanically a clone of _clipSlotData.
private uint[] _indoorData = new uint[256];
// #188: per-instance opacity multiplier (binding=7), one float per
// instance, parallel to the instance data. 1.0 = unmodified (the dat's own
// material/texture alpha, untouched); < 1.0 multiplies the shader's
// sampled alpha for an entity mid-TransparentPartHook fade. Mechanically
// a clone of _indoorData, one binding higher.
private float[] _alphaData = new float[256];
private bool _dynamicFrameStarted;
// Campaign V slice V11: the raw-GL upload path (and its per-frame triple-
// buffered SSBO pool) is gone — the RHI arm's frame ring
// (WbDrawDispatcher.Rhi.cs) owns the equivalent ring allocations now, so
// there is no dynamic buffer set left to count. Kept for the diagnostic
// consumer (RenderFrameDiagnosticSources) that reads this alongside the
// other renderers'.
internal int DynamicBufferSetCount => 0;
// Retail SmartBox click confirmation: per-instance CMaterial luminosity /
// diffuse replacement (binding=8), parallel to the transform buffer.
private Vector2[] _selectionLightingData = new Vector2[256];
// This frame's point-light snapshot, handed in by GameWindow before Draw via
// SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1).
private IReadOnlyList? _pointSnapshot;
// This entity's selected point/spot light set — computed ONCE per entity at
// the isNewEntity site (constant across the entity's parts/tuples), exactly
// like _currentEntitySlot. -1 = unused slot.
private readonly int[] _currentEntityLightSetScratch = new int[LightManager.MaxLightsPerObject];
private InstanceLightSet _currentEntityLightSet = InstanceLightSet.Disabled;
// #142: per-entity "indoor" flag — set once per entity in ComputeEntityLightSet,
// parallel to _currentEntityLightSet. True when IndoorObjectReceivesTorches fires
// (ParentCellId is an EnvCell). Appended to InstanceGroup.IndoorFlags in
// AppendCurrentLightSet; uploaded as binding=6 instanceIndoor[].
private bool _currentEntityIndoor;
private Vector2 _currentEntitySelectionLighting = new(0f, 1f);
// Phase U.3: the SHARED per-cell clip-region SSBO (binding=2) id, owned by
// the GL-arm ClipFrame and handed in via SetClipRegionSsbo. Campaign V
// slice V11: the raw-GL world path that read this is gone, and the RHI arm
// binds clip regions from IWorldPassScope.Sections instead (see
// WbDrawDispatcher.Rhi.cs), so this is now write-only — kept because
// GlWorldPassSurface still calls the setter unconditionally.
private uint _sharedClipRegionSsbo;
// Phase U.4: per-frame clip-slot routing handed in via SetClipRouting before
// each Draw. When _clipRoutingActive is false (the U.3 path / outdoor root /
// no portal frame), every instance maps to slot 0 (no-clip) and no instance is
// culled — identical to U.3. When active, each instance's slot is resolved by
// ResolveEntitySlot per the U.4 policy (cell-owned entities to their cell slot;
// outdoor-owned entities to OutsideView; non-visible/unresolved indoors culled).
private bool _clipRoutingActive;
private IReadOnlyDictionary? _cellIdToSlot;
private int _outdoorSlot;
private bool _outdoorVisible;
// Phase U.4: the clip slot of the entity currently being classified in Draw's
// per-entity loop. Set once per entity (before ClassifyBatches / ApplyCacheHit),
// read by the two matrix-append sites (AppendInstanceToGroup + ClassifyBatches)
// so every group's Slots[] stays in lockstep with its Matrices[]. Defaults to 0
// (no-clip) on the U.3 / outdoor path.
private uint _currentEntitySlot;
// Phase U.4: true when the current entity resolved to the CULL sentinel
// (cell not visible, or outdoor stab while no outdoors is visible). Persisted
// across the entity's tuples; the per-tuple body skips all instance emission.
private bool _currentEntityCulled;
// Per-frame scratch arrays — Tasks 9-10 fully wire these.
private float[] _instanceData = new float[256 * 16]; // mat4 floats per instance
private BatchData[] _batchData = new BatchData[256];
private DrawElementsIndirectCommand[] _indirectCommands = new DrawElementsIndirectCommand[256];
private CullMode[] _drawCullModes = new CullMode[256];
private BatchDataPublic[] _batchPublicScratch = new BatchDataPublic[256];
private readonly List _groupInputScratch = new(256);
private readonly List _retiredGroupKeys = new();
private long _nextGroupRegistration = 1;
private long _groupFrame;
private int _opaqueDrawCount;
private int _transparentDrawCount;
private int _transparentByteOffset;
// Campaign V slice V2 (2026-07-27): std430 layout: uint TextureIndex at
// offset 0, uint Reserved (pad) at offset 4, uint TextureLayer at offset 8,
// uint Flags at offset 12. Total 16 bytes — unchanged from before V2, so
// every existing CPU writer's offsets are unchanged (see
// GpuBindingModel.GpuBatchDataStrideBytes). TextureIndex used to be a
// 64-bit ulong TextureHandle (an ARB_bindless_texture handle, uvec2 in
// GLSL); it is now a slot into the binding=9 handle table
// (mesh_modern.vert's BatchData.textureIndex / ACDREAM_TEXTURE_HANDLE),
// which is why the struct only needs 4-byte (not 8-byte) packing now.
[StructLayout(LayoutKind.Sequential, Pack = 4)]
private struct BatchData
{
public uint TextureIndex; // slot into the binding=9 handle table
public uint Reserved; // pad — keeps TextureLayer/Flags at offsets 8/12
public uint TextureLayer;
public uint Flags;
}
private readonly record struct DeferredAlphaInstance(
GroupKey Key,
Matrix4x4 Model,
uint ClipSlot,
InstanceLightSet Lights,
uint Indoor,
float Opacity,
Vector2 SelectionLighting);
internal readonly record struct InstanceLightSet(
int L0, int L1, int L2, int L3,
int L4, int L5, int L6, int L7)
{
public static InstanceLightSet Disabled { get; } = new(
-1, -1, -1, -1, -1, -1, -1, -1);
public static InstanceLightSet From(ReadOnlySpan source)
{
if (source.Length < LightManager.MaxLightsPerObject)
throw new ArgumentException("A retail object-light set requires eight entries.", nameof(source));
return new InstanceLightSet(
source[0], source[1], source[2], source[3],
source[4], source[5], source[6], source[7]);
}
public void CopyTo(int[] destination, int offset)
{
destination[offset + 0] = L0;
destination[offset + 1] = L1;
destination[offset + 2] = L2;
destination[offset + 3] = L3;
destination[offset + 4] = L4;
destination[offset + 5] = L5;
destination[offset + 6] = L6;
destination[offset + 7] = L7;
}
public int this[int index] => index switch
{
0 => L0, 1 => L1, 2 => L2, 3 => L3,
4 => L4, 5 => L5, 6 => L6, 7 => L7,
_ => throw new ArgumentOutOfRangeException(nameof(index)),
};
}
private sealed class AlphaDrawSource(WbDrawDispatcher owner) : IRetailAlphaDrawSource
{
public void PrepareAlphaDraws(ReadOnlySpan tokens)
=> owner.PrepareDeferredAlphaDraws(tokens);
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
=> owner.DrawPreparedAlphaBatch(firstPreparedDraw, drawCount);
public void ResetAlphaSubmissions()
=> owner.ResetDeferredAlphaSubmissions();
}
// Per-frame scratch — reused across frames to avoid per-frame allocation.
private readonly Dictionary _groups = new();
private readonly List _opaqueDraws = new();
private readonly List _translucentDraws = new();
private readonly List _alphaFingerprintScratch = [];
private readonly List _deferredAlpha = new(128);
private TranslucencyKind[] _deferredAlphaKinds = new TranslucencyKind[128];
private Matrix4x4 _deferredAlphaViewProjection;
private int _nextInstanceSubmissionOrder;
internal long AlphaScratchBudgetBytes => _alphaScratchPolicy.BudgetBytes;
internal long RetainedAlphaScratchBytes => checked(
(long)_instanceData.Length * sizeof(float)
+ (long)_clipSlotData.Length * sizeof(uint)
+ (long)_lightSetData.Length * sizeof(int)
+ (long)_indoorData.Length * sizeof(uint)
+ (long)_alphaData.Length * sizeof(float)
+ (long)_selectionLightingData.Length * Unsafe.SizeOf()
+ (long)_batchData.Length * Unsafe.SizeOf()
+ (long)_indirectCommands.Length
* Unsafe.SizeOf()
+ (long)_drawCullModes.Length * Unsafe.SizeOf()
+ (long)_batchPublicScratch.Length
* Unsafe.SizeOf()
+ (long)_deferredAlphaKinds.Length
* Unsafe.SizeOf()
+ (long)_deferredAlpha.Capacity
* Unsafe.SizeOf());
// A.5 T26 follow-up (Bug B): WalkEntities populates this scratch list
// instead of allocating a fresh List<(WorldEntity, int)> per frame. At
// ~10K entities × ~3 mesh refs = ~30K tuples × 16 bytes = ~480 KB / frame
// of GC pressure on the render thread under the original T17 shape.
private readonly List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> _walkScratch = new();
// G2: the dispatcher consumes this acdream-owned value boundary, never
// WorldEntity. The current source translates its accepted walk into this
// retained list; G4 will replace only that producer with RenderFrameView.
private readonly List _candidateTupleScratch = new();
// Tier 1 cache (#53) — per-entity classification collector. Reused across
// frames; cleared at flush time when the per-entity loop crosses an entity
// boundary in _walkScratch (and once more at end-of-loop for the last
// entity). _walkScratch is in entity-order, so all MeshRefs of one entity
// are contiguous — accumulate them all before flushing one Populate call.
// Animated entities skip this scratch entirely (collector = null).
private readonly List _populateScratch = new();
private readonly List _populateSelectionScratch = new();
// Per-entity-cull AABB radius. Conservative — covers most entities; large
// outliers (long banners, tall columns) are still landblock-culled.
private const float PerEntityCullRadius = 5.0f;
private RetryableResourceReleaseLedger? _disposeResources;
private bool _disposing;
private bool _disposed;
///
/// Per-cell-entity last-log frame number for rate-limiting the
/// [indoor-walk] / [indoor-lookup] / [indoor-xform] / [indoor-cull]
/// probes. Defaults to 30 frames at 30Hz = 1 sec.
///
private readonly Dictionary _lastIndoorProbeFrame = new();
private int _indoorProbeFrameCounter;
private const int IndoorProbeRateLimitFrames = 30;
///
/// Returns true at most once per
/// frames per cellId. Caller must already have checked that an indoor
/// probe flag is enabled.
///
private bool ShouldEmitIndoorProbe(ulong cellId)
{
if (!_lastIndoorProbeFrame.TryGetValue(cellId, out int last)
|| _indoorProbeFrameCounter - last >= IndoorProbeRateLimitFrames)
{
_lastIndoorProbeFrame[cellId] = _indoorProbeFrameCounter;
return true;
}
return false;
}
// Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1.
private int _entitiesSeen;
private int _entitiesDrawn;
private int _meshesMissing;
private int _drawsIssued;
private int _instancesIssued;
private long _lastLogTick;
// #128 self-heal: per-Draw dedup of point-of-use load re-requests
// (PrepareMeshDataAsync is idempotent while pending — the dedup just
// avoids redundant dictionary probes within one pass) + the once-per-id
// [mesh-miss] diagnostic set (never cleared; diag-gated emission).
private readonly HashSet _missRequested = new();
private readonly HashSet _missLogged = new();
// #119 decisive probe (2026-06-11): ACDREAM_DUMP_ENTITY one-shot entity
// dump. Keyed by entity Id; the stored signature re-emits the header line
// whenever (MeshRefs count, cache batch count, zero-translation count,
// culled) changes — e.g. the Tier-1 populate landing one frame after the
// first slow-path draw. The full per-part listing prints only on first
// sight. Inert (one Count==0 check per new entity) when the env var is
// unset. Render-thread only.
private readonly Dictionary _entityDumpSig = new();
// Rate limiter for [dump-entity] WALK-REJECT lines: a rejected entity
// re-tests every frame; emit the first rejection per entity then every
// 300th (~5 s at 60 fps). Static because WalkEntitiesInto is static;
// render-thread only like the walk itself.
private static readonly Dictionary _walkRejectCounts = new();
// CPU + GPU timing for [WB-DIAG] under ACDREAM_WB_DIAG=1. The GPU samples
// are written by the RHI arm's SampleRhiTimers (WbDrawDispatcher.Rhi.cs)
// from the device's own timer pool; the raw-GL query-object ring that used
// to feed them is gone with the raw-GL draw path.
private readonly System.Diagnostics.Stopwatch _cpuStopwatch = new();
private readonly long[] _cpuSamples = new long[256]; // microseconds
private int _cpuSampleCursor;
private readonly long[] _gpuSamples = new long[256]; // microseconds
private int _gpuSampleCursor;
///
/// Marks the start of a fence-protected frame.
/// is the shared GPU frame-ring slot every renderer in the frame receives
/// (see ) — the RHI arm's own
/// ring allocations (WbDrawDispatcher.Rhi.cs) come from the current
/// IGpuFrame instead, so this dispatcher no longer indexes its own
/// buffer-set pool by it; the parameter is kept so every renderer's
/// BeginFrame call stays uniform.
///
public void BeginFrame(int frameSlot)
{
_ = frameSlot;
if (_groupFrame == long.MaxValue)
throw new InvalidOperationException("Instance-group frame identity was exhausted.");
ApplyScratchRetention(_scratchPeakUnits);
_scratchPeakUnits = 0;
_groupFrame++;
PruneInstanceGroupsUnusedBeforeFrame(
_groups,
_retiredGroupKeys,
_groupFrame - 1);
_dynamicFrameStarted = true;
_currentRenderSceneObserver?.BeginDispatcherFrame();
}
internal void SetCurrentRenderSceneObserver(
ICurrentRenderDispatcherObserver? observer) =>
_currentRenderSceneObserver = observer;
internal void AbortCurrentRenderSceneObserverFrame() =>
_currentRenderSceneObserver?.AbortDispatcherFrame();
///
/// Fix B (A7 #3): hand the dispatcher this frame's GLOBAL point-light snapshot
/// (). Call once per frame BEFORE
/// . The dispatcher uploads it to binding=4 and selects each
/// object's up-to-8 lights from it ()
/// by the object's bounding sphere — camera-independent. Pass null/empty to
/// disable per-object point lights (only ambient + sun render).
///
public void SetSceneLights(IReadOnlyList? pointSnapshot)
=> _pointSnapshot = pointSnapshot;
///
/// Phase U.3: hand the dispatcher the SHARED per-cell clip-region SSBO
/// (binding=2) that created. Campaign
/// V slice V11: the raw-GL draw path that rebound this id is gone —
/// GlWorldPassSurface still calls this setter unconditionally, so it
/// is kept as a harmless store; the RHI arm binds clip regions from
/// IWorldPassScope.Sections instead (see
/// WbDrawDispatcher.Rhi.cs).
///
public void SetClipRegionSsbo(uint sharedClipRegionSsbo)
=> _sharedClipRegionSsbo = sharedClipRegionSsbo;
///
/// Phase U.4: install the per-frame clip-slot routing for an INDOOR root.
/// Call once per frame BEFORE when the camera's root cell is
/// non-null; the next resolves each instance's binding=3
/// clip slot via the U.4 policy (cell-owned entities to their cell slot,
/// outdoor-owned entities to OutsideView, non-visible/unresolved indoors culled).
/// Pair with on outdoor-root frames so the
/// dispatcher reverts to the U.3 no-clip-everything behavior.
///
/// cellId → CellClip slot. A cell absent from the map
/// is NOT visible → its cell-static instances are culled.
/// Slot for outdoor scenery / building shells while
/// indoors (the OutsideView slot, or 0 for no-clip over-include).
/// False ⇒ cull outdoor scenery / shells this frame
/// (the OutsideView is empty).
public void SetClipRouting(IReadOnlyDictionary cellIdToSlot, int outdoorSlot, bool outdoorVisible)
{
ArgumentNullException.ThrowIfNull(cellIdToSlot);
_clipRoutingActive = true;
_cellIdToSlot = cellIdToSlot;
_outdoorSlot = outdoorSlot;
_outdoorVisible = outdoorVisible;
}
///
/// Phase U.4: revert to U.3 behavior — every instance maps to slot 0 (no-clip),
/// nothing is culled by clip routing. Call on outdoor-root frames (camera
/// outdoors) and any frame without a portal-visibility result.
///
public void ClearClipRouting()
{
_clipRoutingActive = false;
_cellIdToSlot = null;
_outdoorSlot = 0;
_outdoorVisible = false;
}
// §4 flap [clip-route-disp] probe state (2026-06-10, throwaway): print-on-change
// signature + monotonic sequence + reusable histogram. See RenderingDiagnostics
// .ProbeClipRouteEnabled for the full probe contract.
private string? _lastClipRouteDispSig;
private long _clipRouteDispSeq;
private readonly SortedDictionary _clipRouteHist = new();
// §4 flap apparatus (2026-06-10): per-slot instance histogram as staged for binding=3.
// grp.Slots is laid out 1:1 with grp.Matrices (binding=0), so this IS the slot content
// the GPU reads per instance — if outdoor instances land on the wrong slot (or vanish
// into cullEnt) when the building flood merges, this line shows it directly.
private void EmitClipRouteDispatchProbe(int culledEntities)
{
_clipRouteHist.Clear();
int total = 0;
foreach (var grp in _groups.Values)
{
var slots = grp.Slots;
for (int i = 0; i < slots.Count; i++)
{
_clipRouteHist.TryGetValue(slots[i], out int c);
_clipRouteHist[slots[i]] = c + 1;
total++;
}
}
var sb = new System.Text.StringBuilder(128);
sb.Append(System.FormattableString.Invariant(
$"outdoorSlot={_outdoorSlot} outdoorVis={(_outdoorVisible ? 'Y' : 'n')} inst={total} cullEnt={culledEntities} slots={{"));
bool first = true;
foreach (var kv in _clipRouteHist)
{
if (!first) sb.Append(',');
first = false;
sb.Append(System.FormattableString.Invariant($"{kv.Key}:{kv.Value}"));
}
sb.Append('}');
string sig = sb.ToString();
_clipRouteDispSeq++;
if (sig == _lastClipRouteDispSig)
return;
_lastClipRouteDispSig = sig;
Console.WriteLine($"[clip-route-disp] n={_clipRouteDispSeq} {sig}");
}
// Phase U.4 CULL sentinel returned by ResolveEntitySlot: the entity's instances
// are dropped entirely (not emitted into the binding=0 instance buffer NOR the
// binding=3 slot buffer), matching the existing frustum / visible-cell cull.
// Internal (not private) so the clip-slot unit tests can assert against it
// directly — see WbDrawDispatcherClipSlotTests.
internal const int ClipSlotCull = -1;
///
/// Phase U.4: resolve the clip slot for one entity per the slot/gate policy.
/// Returns to drop the entity's instances entirely.
///
/// - Indoor ParentCellId: the cell's slot, or CULL when hidden.
/// - Outdoor ParentCellId or ParentCellId == null static scenery: the OutsideView slot
/// when , else CULL.
/// - ServerGuid != 0 with ParentCellId == null: CULL while routing is active.
///
/// Only called when _clipRoutingActive (indoor root). On the U.3 / outdoor
/// path every instance is slot 0 and nothing is culled — see
/// , which gates on that flag.
///
/// INVARIANT: and the keys of
/// MUST live in the same FULL cell-id space
/// (lbMask | OtherCellId, e.g. 0xA9B40164). A bare-low-byte
/// ParentCellId (e.g. 0x64) would never match a full-id key and would
/// silently CULL every indoor stab — cf. the L.2e bare-low-byte finding in
/// CLAUDE.md where player CellId was tracked without its landblock prefix.
///
///
/// internal static + pure (reads no instance state) so the clip-slot
/// unit tests exercise every branch without a GL context. The caller hands in
/// the routing fields it would otherwise read from _cellIdToSlot etc.
///
///
internal static int ResolveEntitySlot(
uint serverGuid,
uint? parentCellId,
IReadOnlyDictionary cellIdToSlot,
int outdoorSlot,
bool outdoorVisible)
{
// Live-dynamic entities are not a global indoor overlay. When they
// have current cell ownership, route them through the same visible
// cell/OutsideView graph as every other object. Parentless live objects
// are unresolved indoors, so cull them while clip routing is active.
if (parentCellId is uint parentCell)
{
if (IsIndoorCellId(parentCell))
{
if (!cellIdToSlot.ContainsKey(parentCell))
return ClipSlotCull;
return cellIdToSlot[parentCell];
}
return outdoorVisible ? outdoorSlot : ClipSlotCull;
}
if (serverGuid != 0)
return ClipSlotCull;
// Outdoor scenery / building shell (no ParentCellId). Indoor root: gate to
// the OutsideView slot, or cull when nothing outdoors is visible.
return outdoorVisible ? outdoorSlot : ClipSlotCull;
}
private static bool IsIndoorCellId(uint cellId)
{
uint low = cellId & 0xFFFFu;
return low >= 0x0100u && low != 0xFFFFu;
}
///
/// Phase U.4: the call-site clip-slot decision for one entity, returning the
/// (Slot, Culled) pair the per-entity loop body consumes. Wraps
/// with the
/// gate: when routing is INACTIVE (outdoor root / no portal frame), every entity
/// is slot 0 and nothing is clip-culled — the bit-identical-to-U.3 property, so
/// the resolver (and ) is bypassed entirely.
/// When active, a CULL sentinel maps to (0, culled=true) — the slot value
/// is never emitted for a culled entity.
/// internal static + pure so the whole policy (including the routing-
/// inactive branch) is unit-testable — see WbDrawDispatcherClipSlotTests.
///
internal static (uint Slot, bool Culled) ResolveSlotForFrame(
bool clipRoutingActive,
uint serverGuid,
uint? parentCellId,
IReadOnlyDictionary? cellIdToSlot,
int outdoorSlot,
bool outdoorVisible)
{
if (!clipRoutingActive)
return (0u, false);
int resolved = ResolveEntitySlot(serverGuid, parentCellId, cellIdToSlot!, outdoorSlot, outdoorVisible);
bool culled = resolved == ClipSlotCull;
return (culled ? 0u : (uint)resolved, culled);
}
public static Matrix4x4 ComposePartWorldMatrix(
Matrix4x4 entityWorld,
Matrix4x4 animOverride,
Matrix4x4 restPose)
=> restPose * animOverride * entityWorld;
///
/// Entry for per-landblock iteration.
/// Mirrors the shape yielded by GpuWorldState.LandblockEntries.
///
public readonly record struct LandblockEntry(
uint LandblockId,
Vector3 AabbMin,
Vector3 AabbMax,
IReadOnlyList Entities,
IReadOnlyDictionary? AnimatedById);
///
/// Result of — the list of (entity, meshRef index)
/// pairs that passed all visibility filters, plus a diagnostic walk count.
///
public struct WalkResult
{
public int EntitiesWalked;
public int BuildingShellAnchorPass;
public int BuildingShellAnchorReject;
public List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> ToDraw;
}
///
/// Pure-CPU visibility filter over .
/// Separated from so tests can exercise it without GL state.
///
///
/// A.5 T17 Change #1: when an LB is frustum-culled AND
/// is non-empty, the OLD path walked
/// every entity in the LB just to find the few animated ones. This helper
/// fixes that: if the LB is invisible, we iterate
/// directly and look each up in
/// entry.AnimatedById (typically <50 animated, up to ~10K total).
///
///
///
/// A.5 T18 Change #2: per-entity AABB cull reads from the cached
/// /
/// (refreshed lazily if ), instead of
/// recomputing Position±5 each frame.
///
///
///
/// Test-friendly overload that allocates a fresh ToDraw list per call.
/// Production code () uses the no-alloc overload below
/// with a caller-provided scratch list.
///
internal static WalkResult WalkEntities(
IEnumerable landblockEntries,
FrustumPlanes? frustum,
uint? neverCullLandblockId,
HashSet? visibleCellIds,
HashSet? animatedEntityIds)
{
var scratch = new List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)>();
var result = new WalkResult { ToDraw = scratch };
WalkEntitiesInto(
landblockEntries, frustum, neverCullLandblockId,
visibleCellIds, animatedEntityIds, scratch, ref result);
return result;
}
///
/// No-alloc overload: clears + populates the caller-provided
/// list. reuses a per-dispatcher scratch field across frames to
/// avoid the 480+ KB / frame GC pressure that the test-friendly overload incurs.
/// Returns walk count via 's EntitiesWalked field.
///
///
/// When is non-null the method emits
/// [indoor-cull] lines for cell entities rejected by the
/// visibleCellIds or frustum filters, and [indoor-walk] lines for
/// cell entities that pass all filters. Rate-limited by
/// . Pass (the default)
/// to disable all probe emission — used by the test-friendly
/// overload.
///
///
internal static void WalkEntitiesInto(
IEnumerable landblockEntries,
FrustumPlanes? frustum,
uint? neverCullLandblockId,
HashSet? visibleCellIds,
HashSet? animatedEntityIds,
List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch,
ref WalkResult result,
IndoorProbeState? indoorProbeState = null,
EntitySet set = EntitySet.All)
{
scratch.Clear();
result.EntitiesWalked = 0;
result.ToDraw = scratch;
foreach (var entry in landblockEntries)
{
bool landblockVisible = frustum is null
|| entry.LandblockId == neverCullLandblockId
|| FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
if (!landblockVisible)
{
// A.5 T17 Change #1: walk only animated entities, not all entities.
// Avoids O(N_entities) scan when only O(N_animated) work is needed.
if (animatedEntityIds is null || animatedEntityIds.Count == 0) continue;
if (entry.AnimatedById is null) continue;
foreach (var animatedId in animatedEntityIds)
{
if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
if (!entity.IsDrawVisible || !entity.IsAncestorDrawVisible) continue;
// Phase A8: EntitySet partition for indoor/outdoor split passes.
if (!EntityMatchesSet(entity, set)) continue;
if (entity.MeshRefs.Count == 0) continue;
bool shellScoped = IsShellScopedSet(set)
&& entity.IsBuildingShell
&& visibleCellIds is not null;
if (!EntityPassesVisibleCellGate(entity, visibleCellIds, set))
{
if (shellScoped) result.BuildingShellAnchorReject++;
continue;
}
if (shellScoped) result.BuildingShellAnchorPass++;
result.EntitiesWalked++;
for (int i = 0; i < entity.MeshRefs.Count; i++)
scratch.Add((entity, i, entry.LandblockId));
}
continue;
}
foreach (var entity in entry.Entities)
{
if (!entity.IsDrawVisible || !entity.IsAncestorDrawVisible) continue;
// Phase A8: EntitySet partition for indoor/outdoor split passes.
if (!EntityMatchesSet(entity, set)) continue;
if (entity.MeshRefs.Count == 0) continue;
// Detect cell entity for indoor probes — first MeshRef.GfxObjId
// is an EnvCell id (low 16 bits ≥ 0x0100). Cheap to compute;
// result reused for all probe checks below.
ulong cellProbeId = (ulong)entity.MeshRefs[0].GfxObjId;
bool isCellEntity = indoorProbeState is not null
&& RenderingDiagnostics.IsEnvCellId(cellProbeId);
bool shellScoped = IsShellScopedSet(set)
&& entity.IsBuildingShell
&& visibleCellIds is not null;
bool cellInVis = EntityPassesVisibleCellGate(entity, visibleCellIds, set);
if (!cellInVis)
{
if (shellScoped) result.BuildingShellAnchorReject++;
MaybeEmitWalkRejectDump(entity, "visibleCellIds-miss");
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
&& indoorProbeState!.ShouldEmit(cellProbeId))
{
Console.WriteLine(
$"[indoor-cull] cellEnt=0x{entity.Id:X8} " +
$"reason=visibleCellIds-miss " +
$"parentCell=0x{entity.ParentCellId!.Value:X8}");
}
continue;
}
if (shellScoped) result.BuildingShellAnchorPass++;
// Per-entity AABB frustum cull (perf #3). Animated entities bypass —
// they're tracked at landblock level + need per-frame work regardless.
// A.5 T18 Change #2: read cached AABB, refresh lazily on AabbDirty.
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
bool aabbVisible = true;
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
{
if (entity.AabbDirty) entity.RefreshAabb();
aabbVisible = FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax);
}
if (!aabbVisible)
{
MaybeEmitWalkRejectDump(entity, "frustum");
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
&& indoorProbeState!.ShouldEmit(cellProbeId))
{
Console.WriteLine(
$"[indoor-cull] cellEnt=0x{entity.Id:X8} " +
$"reason=frustum " +
$"aabbMin=({entity.AabbMin.X:F1},{entity.AabbMin.Y:F1},{entity.AabbMin.Z:F1}) " +
$"aabbMax=({entity.AabbMax.X:F1},{entity.AabbMax.Y:F1},{entity.AabbMax.Z:F1})");
}
continue;
}
// Passed all filters — emit walk probe.
if (isCellEntity && RenderingDiagnostics.ProbeIndoorWalkEnabled
&& indoorProbeState!.ShouldEmit(cellProbeId))
{
Console.WriteLine(
$"[indoor-walk] cellEnt=0x{entity.Id:X8} " +
$"pos=({entity.Position.X:F1},{entity.Position.Y:F1},{entity.Position.Z:F1}) " +
$"parentCell=0x{(entity.ParentCellId ?? 0u):X8} " +
$"meshRef0=0x{cellProbeId:X8} " +
$"meshRefCount={entity.MeshRefs.Count} " +
$"landblockVisible=true aabbVisible=true cellInVis=true");
}
result.EntitiesWalked++;
for (int i = 0; i < entity.MeshRefs.Count; i++)
scratch.Add((entity, i, entry.LandblockId));
}
}
}
///
/// #119 ROOT-CAUSE FIX (2026-06-11): the Tier-1 cache hint must identify the
/// entity's OWNING landblock, not the Draw call's tuple landblock.
/// RetailPViewRenderer.DrawEntityBucket fabricates its tuple with the
/// PLAYER's landblock id, so every bucket entity that frame shared one hint —
/// and colliding entity ids from different landblocks (the pre-fix
/// 0x40YYFF00 interior namespace discarded the landblock X byte) mapped
/// to the SAME cache key and served each other's batches: the AAB3 tower's
/// 43-part staircase drew a 1-part entity's 3 zero-RestPose batches
/// (captured live, tower-dump-launch1.log) — the session-sticky "broken
/// stairs + water barrel". Interior statics carry their owning cell; derive
/// the hint from it, canonicalized to the same 0xXXYYFFFF key format
/// the streaming entries and
/// use — which also makes owner-unload invalidation actually hit these
/// entries (bucket-hinted entries were previously orphaned forever).
/// Entities without a ParentCellId (outdoor stabs / scenery / building
/// shells via GpuWorldState entries) keep the tuple id, which IS their
/// owner on those paths.
///
internal static uint ResolveCacheLandblockHint(WorldEntity entity, uint tupleLandblockId)
=> entity.ParentCellId is uint pc ? ((pc & 0xFFFF0000u) | 0xFFFFu) : tupleLandblockId;
internal static uint ResolveCacheLandblockHint(
in RenderInstanceCandidate entity) =>
entity.CacheLandblockId;
private static void BuildCurrentCandidateTuples(
List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> source,
HashSet? animatedEntityIds,
List destination)
{
destination.Clear();
if (destination.Capacity < source.Count)
destination.Capacity = source.Count;
int index = 0;
while (index < source.Count)
{
(WorldEntity entity, _, uint tupleLandblockId) = source[index];
int end = index + 1;
while (end < source.Count
&& ReferenceEquals(source[end].Entity, entity)
&& source[end].LandblockId == tupleLandblockId)
{
end++;
}
int meshPartCount = end - index;
RenderInstanceCandidate candidate =
RenderInstanceCandidate.FromWorldEntity(
entity,
animatedEntityIds?.Contains(entity.Id) == true,
meshPartCount,
tupleLandblockId);
for (; index < end; index++)
{
int meshRefIndex = source[index].MeshRefIndex;
destination.Add(new RenderInstanceTuple(
candidate,
meshRefIndex,
entity.MeshRefs[meshRefIndex]));
}
}
}
///
/// #119 decisive probe: rate-limited [dump-entity] WALK-REJECT line
/// for an ACDREAM_DUMP_ENTITY-targeted entity that the walk filtered
/// out (visibleCellIds gate / per-entity frustum). Absence of any DRAW dump
/// plus presence of these lines attributes "entity exists but never reaches
/// the draw loop" to the specific gate. Inert when the target set is empty.
///
private static void MaybeEmitWalkRejectDump(WorldEntity entity, string reason)
{
var targets = RenderingDiagnostics.DumpEntitySourceIds;
if (targets.Count == 0 || !targets.Contains(entity.SourceGfxObjOrSetupId)) return;
_walkRejectCounts.TryGetValue(entity.Id, out int n);
_walkRejectCounts[entity.Id] = n + 1;
if (n % 300 != 0) return;
Console.WriteLine(
$"[dump-entity] WALK-REJECT id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} " +
$"reason={reason} parentCell=0x{(entity.ParentCellId ?? 0u):X8} " +
$"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) n={n + 1}");
}
///
/// #119 decisive probe: per-entity state dump at draw time for
/// ACDREAM_DUMP_ENTITY-targeted entities. First sight prints a
/// header + every MeshRef's GfxObj id, part-transform translation, and
/// loaded flag; afterwards a compact header re-emits only when the
/// (meshRefs, cacheBatches, zeroTranslations, culled) signature changes.
/// Discriminates H-A (hydration-time MeshRef corruption: translations
/// collapsed to ~zero / missing parts) from H-B (Tier-1 cache holding a
/// partial or stale batch set) from H-C (both healthy ⇒ draw-side compose).
///
private void MaybeEmitEntityDump(
in RenderInstanceCandidate entity,
uint landblockId,
bool culled,
IReadOnlyList tuples)
{
var targets = RenderingDiagnostics.DumpEntitySourceIds;
if (targets.Count == 0 || !targets.Contains(entity.SourceId))
return;
int zeroT = 0;
int refsCount = 0;
float tzMin = float.MaxValue, tzMax = float.MinValue;
for (int i = 0; i < tuples.Count; i++)
{
RenderInstanceTuple tuple = tuples[i];
if (tuple.Candidate.LocalEntityId != entity.LocalEntityId
|| tuple.Candidate.TupleLandblockId
!= entity.TupleLandblockId)
{
continue;
}
refsCount++;
Vector3 t = tuple.MeshRef.PartTransform.Translation;
if (t.LengthSquared() < 1e-9f) zeroT++;
if (t.Z < tzMin) tzMin = t.Z;
if (t.Z > tzMax) tzMax = t.Z;
}
int cacheBatches = -1;
int restZero = 0;
float rzMin = float.MaxValue, rzMax = float.MinValue;
if (_cache.TryGet(entity.Id, landblockId, out var cacheEntry))
{
cacheBatches = cacheEntry!.Batches.Length;
foreach (var b in cacheEntry.Batches)
{
var t = b.RestPose.Translation;
if (t.LengthSquared() < 1e-9f) restZero++;
if (t.Z < rzMin) rzMin = t.Z;
if (t.Z > rzMax) rzMax = t.Z;
}
}
var sig = (refsCount, cacheBatches, zeroT, culled);
bool first = !_entityDumpSig.TryGetValue(entity.Id, out var prev);
if (!first && prev == sig) return;
_entityDumpSig[entity.Id] = sig;
string cacheStr = cacheBatches < 0
? (_tier1CacheDisabled ? "disabled" : "miss")
: $"hit:{cacheBatches} restZero={restZero} restZ=[{rzMin:F2}..{rzMax:F2}]";
Console.WriteLine(
$"[dump-entity] DRAW{(first ? "" : "-CHANGED")} id=0x{entity.Id:X8} src=0x{entity.SourceId:X8} " +
$"lb=0x{landblockId:X8} cell=0x{entity.ParentCellId:X8} " +
$"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) scale={entity.Scale:F2} " +
$"meshRefs={refsCount} tZero={zeroT} tZ=[{tzMin:F2}..{tzMax:F2}] cache={cacheStr} culled={culled}");
if (first)
{
for (int i = 0; i < tuples.Count; i++)
{
RenderInstanceTuple tuple = tuples[i];
if (tuple.Candidate.LocalEntityId
!= entity.LocalEntityId
|| tuple.Candidate.TupleLandblockId
!= entity.TupleLandblockId)
{
continue;
}
MeshRef mr = tuple.MeshRef;
var t = mr.PartTransform.Translation;
bool loaded = _meshAdapter.TryGetRenderData(mr.GfxObjId) is not null;
Console.WriteLine(
$"[dump-entity] part[{tuple.MeshRefIndex:D2}] gfx=0x{mr.GfxObjId:X8} t=({t.X:F3},{t.Y:F3},{t.Z:F3}) loaded={loaded}");
}
}
}
public void Draw(
ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList Entities,
IReadOnlyDictionary? AnimatedById)> landblockEntries,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null,
HashSet? visibleCellIds = null,
HashSet? animatedEntityIds = null,
EntitySet set = EntitySet.All)
{
bool diag = BeginEntityDispatch(
camera,
out Matrix4x4 vp,
out Vector3 camPos);
// ── Phase 1: clear groups, walk entities, build groups ──────────────
// Draw is invoked several times per frame (landscape slices, late
// dynamics, paperdoll). Per-dispatch payloads reset here, while group
// retirement happens once in BeginFrame from whole-frame liveness.
_nextInstanceSubmissionOrder = 0;
foreach (InstanceGroup group in _groups.Values)
group.ClearPerInstanceData();
// Campaign V slice V11: no longer read for its own sake (the raw-GL
// VAO bind it fed is gone) — kept only because the packed-oracle
// partial (WbDrawDispatcher.PackedOracle.cs) mirrors this exact
// "first non-zero mesh id" computation and shares ExecuteClassifiedGroups'
// signature with it.
uint anyVao = 0;
// Project the 5-tuple enumerable into LandblockEntry records for WalkEntities.
static IEnumerable ToEntries(
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList Entities,
IReadOnlyDictionary? AnimatedById)> src)
{
foreach (var e in src)
yield return new LandblockEntry(e.LandblockId, e.AabbMin, e.AabbMax, e.Entities, e.AnimatedById);
}
// A.5 T26 follow-up (Bug B): use the no-alloc WalkEntitiesInto overload
// that populates _walkScratch (a per-dispatcher field reused across frames)
// instead of allocating a fresh List<(WorldEntity, int)> per frame.
//
// Pass an IndoorProbeState when any indoor probe is active so the static
// WalkEntitiesInto can emit rate-limited [indoor-cull] / [indoor-walk]
// lines without needing access to instance fields. Null = probes off.
IndoorProbeState? probeState = null;
if (RenderingDiagnostics.ProbeIndoorCullEnabled || RenderingDiagnostics.ProbeIndoorWalkEnabled)
{
// _currentFrame is snapped at construction time. Construct
// once per Draw() call only — a second construction within
// the same frame would stamp the dictionary with the
// (already-advanced) counter value, suppressing the second
// pass's emissions for IndoorProbeRateLimitFrames frames.
// Today Draw() is called exactly once per frame; if a
// future refactor adds a shadow / reflection / second pass,
// this assumption needs revisiting.
probeState = new IndoorProbeState(_lastIndoorProbeFrame, _indoorProbeFrameCounter);
}
var walkResult = default(WalkResult);
WalkEntitiesInto(
ToEntries(landblockEntries),
frustum,
neverCullLandblockId,
visibleCellIds,
animatedEntityIds,
_walkScratch,
ref walkResult,
probeState,
set);
_currentRenderSceneObserver?.ObserveDispatcherDraw(
set,
walkResult.EntitiesWalked,
_walkScratch);
BuildCurrentCandidateTuples(
_walkScratch,
animatedEntityIds,
_candidateTupleScratch);
// Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple
// per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of
// a given entity are contiguous. We accumulate ALL of an entity's
// batches into _populateScratch, then flush exactly once per entity:
// either when the iteration crosses to a different entity, or at the
// end of the loop for the last entity. Flushing per-tuple would
// overwrite earlier MeshRefs (the cache is keyed by entity.Id), so
// multi-part Setup-backed entities would only retain their LAST
// MeshRef's batches — bug fixed in commit after 2f489a8.
uint? populateEntityId = null;
uint populateLandblockId = 0;
// §4 flap [clip-route-disp] probe (2026-06-10, throwaway): entities dropped by
// ResolveSlotForFrame's CULL sentinel this Draw. One increment per culled entity —
// cheap enough to count unconditionally; emission below is probe-gated.
int probeCulledEntities = 0;
// Tier 1 cache (#53) — fast-path one-shot tracker. The cache stores a
// FLAT list of batches across all MeshRefs of an entity, so a single
// ApplyCacheHit call already drew every batch. _walkScratch yields
// one tuple per (entity, MeshRefIndex), so without this guard a
// 3-MeshRef static entity on a frame-2 cache hit would call
// ApplyCacheHit 3 times — appending all 6 batches × 3 = 18 instances
// to _groups instead of 6. Result: severe Z-fighting + 3× perf hit
// on every multi-part static entity (buildings, statues, multi-MeshRef
// NPCs). The fast path must fire only on the FIRST tuple of each
// entity; subsequent tuples skip via this tracker.
uint? lastHitEntityId = null;
// Tier 1 cache (#53) — incomplete-entity guard. When any MeshRef of
// the current entity has _meshAdapter.TryGetRenderData return null
// (mesh still async-decoding via ObjectMeshManager.PrepareMeshDataAsync),
// we mark the entity incomplete and DROP the accumulated populate
// scratch at entity boundary instead of writing it to the cache.
// Otherwise the cache would hold a partial classification (some parts
// missing), and frame-2 cache hits would persist that partial render
// even after the missing mesh loads — every subsequent frame sees the
// cache hit and skips re-classification, so the missing parts never
// recover. User-visible symptom: the drudge statue on top of the
// Foundry (multi-part Setup entity with AnimPartChange) renders with
// some parts missing permanently. Reset on entity change.
bool currentEntityIncomplete = false;
// Per-tuple entity tracker used purely for entity-change detection.
// Updated UNCONDITIONALLY at end of every tuple (including tuples that
// skip via null renderData), so the flag-reset block below correctly
// distinguishes "new entity" from "same entity, different tuple."
// populateEntityId can't be used for this because it's only set after
// a successful slow-path classification.
uint? prevTupleEntityId = null;
foreach (RenderInstanceTuple tuple in _candidateTupleScratch)
{
RenderInstanceCandidate entity = tuple.Candidate;
int partIdx = tuple.MeshRefIndex;
uint landblockId = entity.TupleLandblockId;
if (diag) _entitiesSeen++;
// Skip subsequent tuples of an entity that already cache-hit on
// its first tuple. ApplyCacheHit drew the full flat batch list;
// re-firing here would N-multiply the instance count. Diag
// _entitiesDrawn is bumped here to preserve per-tuple parity with
// the previous counting semantics.
if (lastHitEntityId == entity.Id)
{
if (diag) _entitiesDrawn++;
continue;
}
// Reset the hit tracker on entity change so the next entity's
// first tuple re-checks the cache. (When this iteration is the
// FIRST tuple of a new entity after a cache-hit entity, we must
// not retain the previous entity's id.)
if (lastHitEntityId.HasValue && lastHitEntityId.Value != entity.Id)
{
lastHitEntityId = null;
}
// Tier 1 cache (#53) — drop the previous entity's accumulated
// populate scratch BEFORE MaybeFlushOnEntityChange runs. If the
// previous entity ended incomplete (≥1 null renderData), we MUST
// NOT cache its partial classification: clear scratch and null
// the tracker so MaybeFlushOnEntityChange sees the cleaned state
// and no-ops for this entity. Reset the incomplete flag for the
// new entity so each one gets a fresh measurement.
//
// CRITICAL: the flag reset must fire ONLY on entity change, not
// every tuple. Resetting per-tuple within the same entity would
// undo a null-renderData flag set by a previous tuple of the same
// entity → if the missing MeshRef sits in the MIDDLE of the
// entity's MeshRefs list, a later valid tuple's reset would
// re-mark the entity "complete" and let partial data populate
// the cache. Trees with [trunk valid, branches null, leaves
// valid] hit this exactly — branches never recover.
// #119 root-cause fix: cache operations key on the entity's OWNING
// landblock, never the Draw call's tuple landblock (which is the
// PLAYER's landblock on the bucket path). See ResolveCacheLandblockHint.
uint cacheLb = ResolveCacheLandblockHint(in entity);
bool isNewEntity = !prevTupleEntityId.HasValue || prevTupleEntityId.Value != entity.Id;
if (isNewEntity)
{
if (populateEntityId.HasValue && currentEntityIncomplete)
{
_populateScratch.Clear();
_populateSelectionScratch.Clear();
populateEntityId = null;
}
currentEntityIncomplete = false;
// Phase U.4: resolve this entity's clip slot ONCE per entity
// (constant across its tuples). On the U.3 / outdoor path
// (_clipRoutingActive false) every entity is slot 0, never culled.
// The whole decision (including the routing-active gate) lives in
// the pure ResolveSlotForFrame helper so it's unit-testable.
(_currentEntitySlot, _currentEntityCulled) = ResolveSlotForFrame(
_clipRoutingActive, entity.ServerGuid, entity.ParentCell,
_cellIdToSlot, _outdoorSlot, _outdoorVisible);
if (_currentEntityCulled)
probeCulledEntities++;
// Fix B: select this entity's up-to-8 point/spot lights ONCE (the set
// is constant across the entity's parts/tuples), by the entity's
// bounding sphere — camera-INDEPENDENT (minimize_object_lighting).
ComputeEntityLightSet(entity);
_currentEntitySelectionLighting =
_selectionLighting?.TryGetLighting(
entity.ServerGuid,
entity.Id,
out var lighting) == true
? new Vector2(lighting.Luminosity, lighting.Diffuse)
: new Vector2(0f, 1f);
// #119 decisive probe: one-shot dump (+ change re-emission) for
// ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue
// so a routed-out entity still reports its state.
MaybeEmitEntityDump(
in entity,
cacheLb,
_currentEntityCulled,
_candidateTupleScratch);
// #176 seam-draw probe: any entity parented to a target cell reports
// its position + light set (a floor-coincident static/plate would be
// the z-fight's second draw; the player entity is the positive
// control). Before the culled-continue, like the dump above.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled
&& entity.ParentCell is { } seamPc
&& AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells.Contains(seamPc))
MaybeEmitSeamEnt(entity);
}
prevTupleEntityId = entity.Id;
// Flush-on-entity-change: if the previous entity accumulated any
// batches AND this iteration is for a different entity, populate
// its cache entry now and reset the scratch buffer. Runs for ALL
// entities (including this-entity-culled) so the PREVIOUS entity's
// cache always flushes at the boundary.
(populateEntityId, populateLandblockId) = MaybeFlushOnEntityChange(
populateEntityId, populateLandblockId, entity.Id, _cache,
_populateScratch, _populateSelectionScratch);
// Phase U.4: a culled entity (cell not visible, or no outdoors visible
// for an outdoor stab) contributes NO instances. Skip after the
// boundary flush above so the previous entity still committed; the
// next entity's isNewEntity logic is unaffected (prevTupleEntityId is
// already updated). Matches the existing visible-cell / frustum cull:
// nothing enters _groups, so neither binding=0 nor binding=3 sees it.
if (_currentEntityCulled)
continue;
Matrix4x4 entityWorld = entity.RootWorld;
bool isAnimated = entity.Animated;
// Cache-hit fast path (Task 10): static entity with a populated
// cache entry skips classification entirely. Walk the cached
// (GroupKey, RestPose) flat list and append cached.RestPose *
// entityWorld to each matching group's matrices. Animated entities
// bypass the cache (collector is set null below; their entries are
// never populated in the first place).
//
// Placed AFTER the entity-change flush above so that, on a
// hit, this iteration also finishes flushing any pending
// populate state from a previous entity. Animated entities never
// enter this branch — the !isAnimated guard makes that explicit.
//
// Fires ONCE per entity: the first tuple reaches here, runs
// ApplyCacheHit, sets lastHitEntityId, and continues. Subsequent
// tuples of the same entity short-circuit at the top of the loop
// body via the lastHitEntityId == entity.Id check above.
if (!isAnimated && !_tier1CacheDisabled && _cache.TryGet(entity.Id, cacheLb, out var cachedEntry))
{
ApplyCacheHitDirect(cachedEntry!, entityWorld);
// The cache is populated only after every MeshRef rendered
// successfully. Publish the same parts for retail picking now;
// CPhysicsPart::Draw only participates after the visible draw
// path has accepted a real part.
if (_selectionSink is not null)
PublishCachedSelectionParts(cachedEntry!, entity, entityWorld);
// anyVao recovery: when the first visible entity in the frame
// takes the fast path, no slow-path lookup has populated
// anyVao yet. Look up THIS entity's first MeshRef once via
// the mesh adapter — cheap dict lookup, not a re-classify.
if (anyVao == 0)
{
MeshRef firstMeshRef = tuple.MeshRef;
var firstRenderData = _meshAdapter.TryGetRenderData(firstMeshRef.GfxObjId);
if (firstRenderData is not null) anyVao = firstRenderData.VAO;
}
if (diag) _entitiesDrawn++;
lastHitEntityId = entity.Id;
#if DEBUG
// Cross-check guard: assert the membership predicate held at hit time.
// The full re-classification cross-check (spec section 6.5) is a stretch
// goal; this simpler assert catches the prior Tier 1 bug class — a
// static entity that turns out to actually be animated would fire here.
//
// Structurally redundant with the `if (!isAnimated && ...)` branch
// condition, but serves as a TRIPWIRE: a future refactor that
// incorrectly relaxes the branch condition (e.g., removes
// `!isAnimated` from the guard) would silently allow animated
// entities into the fast path; the assert catches that immediately.
System.Diagnostics.Debug.Assert(
!isAnimated,
$"EntityClassificationCache hit on animated entity {entity.Id} — invariant violated");
#endif
continue;
}
// Compute structural palette identity once per entity. Its hash
// accelerates lookup; equality still compares every range.
PaletteCompositeIdentity paletteIdentity = default;
if (entity.PaletteOverride is not null)
paletteIdentity = TextureCache.GetPaletteIdentity(entity.PaletteOverride);
// Note: GameWindow's spawn path already applies
// AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix —
// close-detail mesh swap for humanoids) to MeshRefs. We
// trust MeshRefs as the source of truth here. AnimatedEntityState's
// overrides become relevant only for hot-swap (0xF625
// ObjDescEvent) which today rebuilds MeshRefs anyway.
MeshRef meshRef = tuple.MeshRef;
ulong gfxObjId = meshRef.GfxObjId;
var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
// [indoor-lookup] probe — emit once per cell entity per sec.
// Fires BEFORE the null-renderData early-continue so a miss still
// emits hit=false, distinguishing H2 (empty batches) from H6
// (dispatcher fails to traverse Setup).
ulong lookupCellId = (ulong)gfxObjId;
if (RenderingDiagnostics.IsEnvCellId(lookupCellId)
&& RenderingDiagnostics.ProbeIndoorLookupEnabled
// Rate-limit in a separate namespace from [indoor-walk]/[indoor-cull]
// (which key on the same gfxObjId). Without this, IndoorAll=1 would
// silence the lookup probe whenever the walk probe fired first.
&& ShouldEmitIndoorProbe(lookupCellId | 0x8000_0000_0000_0000UL))
{
bool hit = renderData is not null;
bool isSetup = hit && renderData!.IsSetup;
int partCount = isSetup ? renderData!.SetupParts.Count : 0;
int partsHit = 0, partsMiss = 0;
if (isSetup)
{
foreach (var (partId, _) in renderData!.SetupParts)
{
if (_meshAdapter.TryGetRenderData(partId) is not null) partsHit++;
else partsMiss++;
}
}
bool hasEnvCellGeom = isSetup
&& renderData!.SetupParts.Exists(t => (t.GfxObjId & 0x1_0000_0000UL) != 0);
Console.WriteLine(
$"[indoor-lookup] cellId=0x{lookupCellId:X8} " +
$"hit={hit} isSetup={isSetup} partCount={partCount} " +
$"hasEnvCellGeom={hasEnvCellGeom} partsHit={partsHit} partsMiss={partsMiss}");
}
if (renderData is null)
{
// Tier 1 cache (#53): mesh data is still async-decoding via
// WB's ObjectMeshManager.PrepareMeshDataAsync. Flag the entity
// as incomplete so the entity-boundary check (or end-of-loop
// check) drops the accumulated populate scratch instead of
// caching a partial classification. The slow path retries on
// the next frame; once all this entity's meshes have loaded,
// the populate fires with the complete batch set.
currentEntityIncomplete = true;
if (diag) _meshesMissing++;
// #128 self-heal: a missing-but-referenced mesh re-requests
// its load HERE — the one site that touches it every frame —
// so a preparation lost to landblock churn (cancelled after
// the last registration event) can never stay lost. Deduped
// per Draw; PrepareMeshDataAsync is idempotent while pending.
if (_missRequested.Add(gfxObjId))
{
_meshAdapter.EnsureLoaded(gfxObjId);
if (diag && _missLogged.Add(gfxObjId))
Console.WriteLine($"[mesh-miss] 0x{gfxObjId:X10} re-requested at point of use");
}
continue;
}
if (anyVao == 0) anyVao = renderData.VAO;
// Cache-miss path (animated entities skip cache entirely).
// Static entities accumulate into _populateScratch across ALL
// their MeshRefs; the flush at next-entity-boundary (or
// end-of-loop) commits them as a single Populate call.
var collector = isAnimated ? null : _populateScratch;
var selectionCollector = isAnimated ? null : _populateSelectionScratch;
bool drewAny = false;
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
{
// #188: setupPartIndex is the SAME index space
// TransparentPartHook.PartIndex addresses — retail's CPartArray
// numbers parts by their ordinal position in the Setup's own
// part list (SetupPartTransforms.Compute is the other verified
// consumer of this exact indexing: one rigid pose per
// Setup.Parts[i]). NOT the outer per-MeshRef loop index — a
// MeshRef is acdream's own decomposition of top-level
// attachments (weapon/shield/etc), a different concept.
for (int setupPartIndex = 0; setupPartIndex < renderData.SetupParts.Count; setupPartIndex++)
{
var (partGfxObjId, partTransform) = renderData.SetupParts[setupPartIndex];
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
if (partData is null)
{
// #128 self-heal + #53: a missing Setup PART must mark
// the entity incomplete (else a partial batch set
// caches permanently — the same bug class one level
// deeper) and re-request its load like the MeshRef
// path above.
currentEntityIncomplete = true;
if (diag) _meshesMissing++;
if (_missRequested.Add(partGfxObjId))
{
_meshAdapter.EnsureLoaded(partGfxObjId);
if (diag && _missLogged.Add(partGfxObjId))
Console.WriteLine($"[mesh-miss] 0x{partGfxObjId:X10} (setup part) re-requested at point of use");
}
continue;
}
var model = ComposePartWorldMatrix(
entityWorld, meshRef.PartTransform, partTransform);
// [indoor-xform] probe — only for the cell's synthetic
// geometry part (bit 32 set, per WB's PrepareEnvCellMeshData
// cellGeomId convention). One line per part per sec.
// Disambiguates hypothesis H5 (transform double-apply —
// composedT lands at 2 × cellOrigin).
if ((partGfxObjId & 0x1_0000_0000UL) != 0
&& RenderingDiagnostics.ProbeIndoorXformEnabled
&& ShouldEmitIndoorProbe(partGfxObjId))
{
Console.WriteLine(
$"[indoor-xform] cellGeomId=0x{partGfxObjId:X16} " +
$"entityWorldT=({entityWorld.Translation.X:F2},{entityWorld.Translation.Y:F2},{entityWorld.Translation.Z:F2}) " +
$"meshRefT=({meshRef.PartTransform.Translation.X:F2},{meshRef.PartTransform.Translation.Y:F2},{meshRef.PartTransform.Translation.Z:F2}) " +
$"partT=({partTransform.Translation.X:F2},{partTransform.Translation.Y:F2},{partTransform.Translation.Z:F2}) " +
$"composedT=({model.Translation.X:F2},{model.Translation.Y:F2},{model.Translation.Z:F2})");
}
var restPose = partTransform * meshRef.PartTransform;
// #188 retail CPhysicsPart::Draw (0x0050d7a0) early-out: once a
// part's translucency hits EXACTLY 1.0 (fully invisible), retail
// sets draw_state|=1 and skips the whole part outright — not a
// blend to nothing. TranslucencyFadeManager.AdvanceAll guarantees
// t=1 commits the bitwise-exact value so this check is safe.
float opacityMultiplier = 1.0f;
if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)setupPartIndex, out float translucencyValue))
{
if (translucencyValue >= 1.0f) continue; // skip this part's draw entirely
opacityMultiplier = 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0
}
if (!ClassifyBatches(partData, model, entity, meshRef, paletteIdentity, restPose, opacityMultiplier, collector))
currentEntityIncomplete = true;
_selectionSink?.AddVisiblePart(
entity.ServerGuid,
entity.LocalEntityId,
unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)),
(uint)partGfxObjId,
model);
selectionCollector?.Add(new CachedSelectionPart(
unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)),
(uint)partGfxObjId,
restPose));
drewAny = true;
}
}
else
{
// #188/#32: this MeshRef's ordinal IS the retail CPartArray part
// ordinal TransparentPartHook.PartIndex addresses. A single-part
// object trivially reads index 0; a FLATTENED multi-part live
// entity (SetupMesh.Flatten emits one bare-GfxObj MeshRef per
// Setup.Parts[i], order preserved, AnimPartChanges replace
// in place) keeps the same equality per part. The previous
// constant 0 silently ignored per-part hooks on every flattened
// entity — the Bind Stone's idle cycle hides its four authored
// shard parts (3-6) with TransparentPartHook start=end=1.0
// every loop, and they stayed visible.
float opacityMultiplier = 1.0f;
bool fullyInvisible = false;
if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)partIdx, out float translucencyValue))
{
if (translucencyValue >= 1.0f) fullyInvisible = true;
else opacityMultiplier = 1f - translucencyValue;
}
if (!fullyInvisible)
{
var model = meshRef.PartTransform * entityWorld;
if (!ClassifyBatches(renderData, model, entity, meshRef, paletteIdentity, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector))
currentEntityIncomplete = true;
_selectionSink?.AddVisiblePart(
entity.ServerGuid,
entity.LocalEntityId,
partIdx,
(uint)gfxObjId,
model);
selectionCollector?.Add(new CachedSelectionPart(
partIdx,
(uint)gfxObjId,
meshRef.PartTransform));
drewAny = true;
}
}
// Track THIS entity for the next iteration's flush check. Only
// when collector is non-null (entity is static); animated entities
// leave the tracker null so we don't try to flush them.
// #119: the populate commits under the OWNER-derived hint so the
// entry is found by the same key on the next frame's TryGet and
// swept by InvalidateLandblock when the OWNING landblock unloads.
if (collector is not null)
{
populateEntityId = entity.Id;
populateLandblockId = cacheLb;
}
if (diag && drewAny) _entitiesDrawn++;
}
// Tier 1 cache (#53) — drop the accumulated populate scratch if the
// LAST entity in the loop ended incomplete (had ≥1 null renderData).
// Same reason as the entity-boundary handling above: avoid caching a
// partial classification. The slow path will retry on the next frame
// and populate correctly once all meshes have loaded.
if (currentEntityIncomplete)
{
_populateScratch.Clear();
_populateSelectionScratch.Clear();
populateEntityId = null;
}
// Final flush: the last entity in _walkScratch has no "next iteration"
// to trigger the entity-change flush, so commit its accumulated batches
// here. No-op when the last entity was animated (populateEntityId stays
// null) or when no entities walked at all.
FinalFlushPopulate(
populateEntityId, populateLandblockId, _cache,
_populateScratch, _populateSelectionScratch);
// §4 flap [clip-route-disp] probe (2026-06-10, throwaway): the per-slot instance
// histogram exactly as it will be uploaded to binding=3 (grp.Slots) plus the
// culled-entity count. Routed draws only (the landscape pass under DrawInside) so the
// unrouted per-cell bucket draws don't oscillate the print-on-change signature.
// Emitted BEFORE the MeshSourceReady / totalInstances early-outs so an
// all-culled frame still reports (inst=0).
if (RenderingDiagnostics.ProbeClipRouteEnabled && _clipRoutingActive)
EmitClipRouteDispatchProbe(probeCulledEntities);
ExecuteClassifiedGroups(
vp,
camPos,
anyVao,
_groups.Values,
set,
walkResult.EntitiesWalked,
_walkScratch.Count,
diag,
observeCurrentPath: true);
}
///
/// Whether there is a mesh source to draw from. The encoder arm has no
/// vertex array of its own — the pipeline owns one shaped by
/// GpuVertexLayout.WorldMesh — so this asks the shared mesh arena
/// directly, the backend-neutral question V6i-3 published as
/// HasStores.
///
private bool MeshSourceReady() =>
_meshAdapter.MeshManager?.GlobalBuffer is { HasStores: true };
private bool BeginEntityDispatch(
ICamera camera,
out Matrix4x4 viewProjection,
out Vector3 cameraWorldPosition)
{
_selectionLighting?.TickLighting();
_indoorProbeFrameCounter++;
viewProjection = camera.View * camera.Projection;
_missRequested.Clear();
bool diagnosticsEnabled = string.Equals(
Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"),
"1",
StringComparison.Ordinal);
_cpuStopwatch.Restart();
cameraWorldPosition = Vector3.Zero;
if (Matrix4x4.Invert(camera.View, out Matrix4x4 inverseView))
cameraWorldPosition = inverseView.Translation;
return diagnosticsEnabled;
}
///
/// is no longer read — it survives as a
/// parameter only because WbDrawDispatcher.PackedOracle.cs calls
/// this positionally with its own mirrored classification's
/// PackedRangeClassification.AnyVao and that partial is out of
/// scope for this collapse.
///
private void ExecuteClassifiedGroups(
Matrix4x4 vp,
Vector3 camPos,
uint anyVao,
IEnumerable groups,
EntitySet set,
int entitiesWalked,
int tupleCount,
bool diag,
bool observeCurrentPath)
{
// Nothing visible — skip the pass entirely.
if (!MeshSourceReady())
{
LastDrawStats = new DrawStats(set, entitiesWalked, tupleCount, 0, 0, 0, 0, 0, 0);
ObserveClassifiedDispatcherSubmission(observeCurrentPath,
visibleInstanceCount: 0,
immediateInstanceCount: 0,
deferTransparent: false,
camPos);
_cpuStopwatch.Stop();
if (diag) MaybeFlushDiag();
return;
}
// ── Phase 3: assign FirstInstance per group, lay matrices contiguously, sort opaque ──
bool deferTransparent = _alphaQueue?.IsCollecting == true;
var instanceCounts = PartitionInstanceGroups(
groups,
deferTransparent,
camPos,
_opaqueDraws,
_translucentDraws);
int totalInstances = instanceCounts.VisibleInstances;
int immediateInstances = instanceCounts.ImmediateInstances;
if (totalInstances == 0)
{
LastDrawStats = new DrawStats(set, entitiesWalked, tupleCount, 0, 0, 0, 0, 0, 0);
ObserveClassifiedDispatcherSubmission(observeCurrentPath,
visibleInstanceCount: 0,
immediateInstanceCount: 0,
deferTransparent,
camPos);
_cpuStopwatch.Stop();
if (diag) MaybeFlushDiag();
return;
}
_opaqueDraws.Sort(CompareOpaqueSubmissionOrder);
if (deferTransparent)
DeferTransparentGroups(camPos, vp);
else
_translucentDraws.Sort(CompareTransparentSubmissionOrder);
int needed = immediateInstances * 16;
if (_instanceData.Length < needed)
_instanceData = new float[needed + 256 * 16];
// Phase U.4: size the per-instance clip-slot buffer to match the instance
// count and lay it out in the SAME group order / cursor as _instanceData,
// so instanceClipSlot[i] (binding=3) tracks Instances[i] (binding=0). On
// the U.3 / outdoor path every Slots entry is 0 ⇒ identical to U.3.
if (_clipSlotData.Length < immediateInstances)
_clipSlotData = new uint[immediateInstances + 256];
// Fix B: per-instance light-set buffer, MaxLightsPerObject ints per
// instance, laid out in the SAME group order / cursor as _instanceData
// so instanceLightIdx[instanceIndex*8 + k] (binding=5) tracks
// Instances[instanceIndex] (binding=0).
if (_lightSetData.Length < immediateInstances * LightManager.MaxLightsPerObject)
_lightSetData = new int[(immediateInstances + 256) * LightManager.MaxLightsPerObject];
// #142: per-instance indoor flag buffer, one uint per instance, parallel to
// _clipSlotData / _instanceData. Grown on demand like the others.
if (_indoorData.Length < immediateInstances)
_indoorData = new uint[immediateInstances + 256];
// #188: per-instance opacity buffer, one float per instance, parallel to
// _clipSlotData / _instanceData. Grown on demand like the others.
if (_alphaData.Length < immediateInstances)
_alphaData = new float[immediateInstances + 256];
if (_selectionLightingData.Length < immediateInstances)
_selectionLightingData = new Vector2[immediateInstances + 256];
int cursor = 0;
foreach (InstanceGroup grp in _opaqueDraws)
StageImmediateGroup(grp, ref cursor);
if (!deferTransparent)
{
foreach (InstanceGroup grp in _translucentDraws)
StageImmediateGroup(grp, ref cursor);
}
System.Diagnostics.Debug.Assert(cursor == immediateInstances);
// Front-to-back sort within each cull mode. DrawIndirectRange must
// split MDI calls whenever CullMode changes because GL state is not
// part of an indirect command. Sorting by distance alone can turn a
// stable 1k-draw live scene into hundreds of tiny MDI runs after a
// landblock transition, which shows up as a GPU-command bottleneck
// without a triangle-count spike.
// Retail particles and ordinary object parts share CPartCell's
// CShadowPart list before delayed alpha is flushed. During the world
// alpha frame, preserve each transparent instance as an independent
// submission so the shared queue can interleave it with particles.
// Immediate mode remains for sealed off-screen consumers such as the
// paperdoll and UI Studio render stack.
// ── Phase 4: build IndirectGroupInput list (opaque sorted, then translucent),
// fill via BuildIndirectArrays ──────────────────────────────────
int immediateTransparentCount = deferTransparent ? 0 : _translucentDraws.Count;
int totalDraws = _opaqueDraws.Count + immediateTransparentCount;
TrackScratchDemand(Math.Max(totalInstances, totalDraws));
if (_batchData.Length < totalDraws)
_batchData = new BatchData[totalDraws + 64];
if (_indirectCommands.Length < totalDraws)
_indirectCommands = new DrawElementsIndirectCommand[totalDraws + 64];
if (_drawCullModes.Length < totalDraws)
_drawCullModes = new CullMode[totalDraws + 64];
if (_batchPublicScratch.Length < totalDraws)
_batchPublicScratch = new BatchDataPublic[totalDraws + 64];
_groupInputScratch.Clear();
foreach (var g in _opaqueDraws) _groupInputScratch.Add(ToInput(g));
if (!deferTransparent)
foreach (var g in _translucentDraws) _groupInputScratch.Add(ToInput(g));
// Cast _batchData (private BatchData) to public-mirror BatchDataPublic for BuildIndirectArrays.
// Layout is asserted at test time (BatchDataPublic_LayoutMatchesPrivateBatchData test).
var layout = BuildIndirectArrays(
_groupInputScratch,
_indirectCommands,
_batchPublicScratch,
_drawCullModes);
long totalTriangles = 0;
foreach (var input in _groupInputScratch)
totalTriangles += (long)(input.IndexCount / 3) * input.InstanceCount;
int cullRuns =
CountCullRuns(_drawCullModes, 0, layout.OpaqueCount) +
CountCullRuns(_drawCullModes, layout.OpaqueCount, layout.TransparentCount);
// Copy back into _batchData
for (int i = 0; i < totalDraws; i++)
{
_batchData[i] = new BatchData
{
TextureIndex = _batchPublicScratch[i].TextureIndex,
TextureLayer = _batchPublicScratch[i].TextureLayer,
Flags = _batchPublicScratch[i].Flags,
};
}
_opaqueDrawCount = layout.OpaqueCount;
_transparentDrawCount = layout.TransparentCount;
_transparentByteOffset = layout.TransparentByteOffset;
LastDrawStats = new DrawStats(
set,
entitiesWalked,
tupleCount,
totalInstances,
totalDraws,
cullRuns,
_opaqueDrawCount,
_transparentDrawCount,
totalTriangles);
ObserveClassifiedDispatcherSubmission(observeCurrentPath,
totalInstances,
immediateInstances,
deferTransparent,
camPos);
// Campaign V slice V11: every per-frame upload is a frame ring slice
// bound through the borrowed world pass, which retires the buffer-set
// pool structurally. See WbDrawDispatcher.Rhi.cs.
SubmitRhi(vp, immediateInstances, totalDraws, diag);
_cpuStopwatch.Stop();
if (diag)
{
long cpuUs = _cpuStopwatch.ElapsedTicks * 1_000_000L
/ System.Diagnostics.Stopwatch.Frequency;
_cpuSamples[_cpuSampleCursor] = cpuUs;
_cpuSampleCursor = (_cpuSampleCursor + 1) % _cpuSamples.Length;
_drawsIssued += _opaqueDrawCount + _transparentDrawCount;
_instancesIssued += totalInstances;
MaybeFlushDiag();
}
}
///
/// Phase A8 RR5 (2026-05-26): per-building draw overload. Walks only
/// entities whose ParentCellId is in , plus
/// outdoor-style entities matching the EntitySet partition. Used by
/// the indoor render branch to scope rendering to the camera-buildings'
/// cells.
///
/// Mirrors the existing visibleCellIds-based Draw but with an
/// explicit cell list (not the BFS-derived visibility set). The semantic
/// difference is at the caller: cellIds = the camera-buildings' EnvCellIds,
/// not the portal BFS result. The dispatcher's internal logic is identical
/// — it filters indoor entities by membership in the provided set.
///
public void Draw(
ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList Entities,
IReadOnlyDictionary? AnimatedById)> landblockEntries,
IReadOnlyCollection cellIds,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null,
HashSet? animatedEntityIds = null,
EntitySet set = EntitySet.All)
{
// Adapt IReadOnlyCollection → HashSet for the existing path.
// If the caller already passed a HashSet, avoid re-wrapping.
HashSet cellIdSet = cellIds is HashSet hs ? hs : new HashSet(cellIds);
Draw(camera, landblockEntries,
frustum: frustum,
neverCullLandblockId: neverCullLandblockId,
visibleCellIds: cellIdSet,
animatedEntityIds: animatedEntityIds,
set: set);
}
private void PublishCachedSelectionParts(
EntityCacheEntry cachedEntry,
in RenderInstanceCandidate entity,
Matrix4x4 entityWorld)
{
foreach (CachedSelectionPart part in cachedEntry.SelectionParts)
{
_selectionSink!.AddVisiblePart(
entity.ServerGuid,
entity.LocalEntityId,
part.PartIndex,
part.GfxObjId,
part.RestPose * entityWorld);
}
}
// Campaign V slice V4t: static again — the group already carries the
// device's table slot, so there is no per-renderer interning left to do.
private static IndirectGroupInput ToInput(InstanceGroup g) => new(
IndexCount: g.IndexCount,
FirstIndex: g.FirstIndex,
BaseVertex: g.BaseVertex,
InstanceCount: g.InstanceCount,
FirstInstance: g.FirstInstance,
TextureIndex: g.TextureSlot.Index,
TextureLayer: g.TextureLayer,
Translucency: g.Translucency,
CullMode: g.CullMode);
internal readonly record struct InstanceLayoutCounts(
int VisibleInstances,
int ImmediateInstances);
internal static InstanceLayoutCounts PartitionInstanceGroups(
IEnumerable groups,
bool deferTransparent,
Vector3 cameraWorldPosition,
List opaque,
List transparent)
{
opaque.Clear();
transparent.Clear();
int visibleInstances = 0;
int immediateInstances = 0;
foreach (InstanceGroup group in groups)
{
int count = group.Matrices.Count;
if (count == 0)
continue;
group.InstanceCount = count;
Matrix4x4 first = group.Matrices[0];
var groupPosition = new Vector3(first.M41, first.M42, first.M43);
group.SortDistance = Vector3.DistanceSquared(cameraWorldPosition, groupPosition);
visibleInstances += count;
if (IsOpaque(group.Translucency))
{
opaque.Add(group);
immediateInstances += count;
}
else
{
transparent.Add(group);
if (!deferTransparent)
immediateInstances += count;
}
}
return new InstanceLayoutCounts(visibleInstances, immediateInstances);
}
private void StageImmediateGroup(InstanceGroup group, ref int cursor)
{
group.FirstInstance = cursor;
for (int i = 0; i < group.Matrices.Count; i++)
{
WriteMatrix(_instanceData, cursor * 16, group.Matrices[i]);
_clipSlotData[cursor] = group.Slots[i];
group.LightSets[i].CopyTo(
_lightSetData,
cursor * LightManager.MaxLightsPerObject);
_indoorData[cursor] = group.IndoorFlags[i];
_alphaData[cursor] = group.Opacities[i];
_selectionLightingData[cursor] = group.SelectionLighting[i];
cursor++;
}
}
private static GroupKey ToKey(InstanceGroup g) => new(
g.FirstIndex,
g.BaseVertex,
g.IndexCount,
g.TextureSlot,
g.TextureLayer,
g.Translucency,
g.CullMode);
private void ObserveCurrentDispatcherSubmission(
int visibleInstanceCount,
int immediateInstanceCount,
bool deferTransparent,
Vector3 cameraWorldPosition)
{
ICurrentRenderDispatcherObserver? observer =
_currentRenderSceneObserver;
if (observer is null)
return;
CurrentRenderDispatcherSubmission submission =
CreateDispatcherSubmission(
visibleInstanceCount,
immediateInstanceCount,
deferTransparent,
_opaqueDraws,
_translucentDraws,
cameraWorldPosition,
_alphaFingerprintScratch);
observer.ObserveDispatcherSubmission(in submission);
}
private void ObserveClassifiedDispatcherSubmission(
bool observeCurrentPath,
int visibleInstanceCount,
int immediateInstanceCount,
bool deferTransparent,
Vector3 cameraWorldPosition)
{
if (observeCurrentPath)
{
ObserveCurrentDispatcherSubmission(
visibleInstanceCount,
immediateInstanceCount,
deferTransparent,
cameraWorldPosition);
}
}
internal static CurrentRenderDispatcherSubmission
CreateDispatcherSubmission(
int visibleInstanceCount,
int immediateInstanceCount,
bool deferTransparent,
IReadOnlyList opaque,
IReadOnlyList transparent,
Vector3 cameraWorldPosition,
List alphaScratch)
{
// The no-VAO production early-out deliberately skips group
// partitioning, so its reusable opaque/transparent lists may still
// contain the preceding draw's entries. No instances were accepted;
// those stale scratch entries are not part of this submission and
// must not leak into its diagnostic identity.
IReadOnlyList acceptedOpaque =
visibleInstanceCount == 0
? Array.Empty()
: opaque;
IReadOnlyList acceptedTransparent =
visibleInstanceCount == 0
? Array.Empty()
: transparent;
int opaqueGroupCount = acceptedOpaque.Count;
int transparentGroupCount = acceptedTransparent.Count;
StableRenderHash128 hash = StableRenderHash128.Create();
hash.Add(visibleInstanceCount);
hash.Add(immediateInstanceCount);
hash.Add(opaqueGroupCount);
hash.Add(transparentGroupCount);
hash.Add(deferTransparent);
RenderSceneHash128 opaqueDigest =
BuildOpaqueSubmissionDigest(acceptedOpaque);
RenderSceneHash128 transparentDigest =
BuildTransparentSubmissionDigest(
acceptedTransparent,
cameraWorldPosition,
alphaScratch);
RenderSceneHash128 transparentSetDigest =
BuildOpaqueSubmissionDigest(acceptedTransparent);
hash.Add(opaqueDigest.Low);
hash.Add(opaqueDigest.High);
hash.Add(transparentDigest.Low);
hash.Add(transparentDigest.High);
hash.Add(transparentSetDigest.Low);
hash.Add(transparentSetDigest.High);
return new CurrentRenderDispatcherSubmission(
VisibleInstanceCount: visibleInstanceCount,
ImmediateInstanceCount: immediateInstanceCount,
OpaqueGroupCount: opaqueGroupCount,
TransparentGroupCount: transparentGroupCount,
TransparentDeferred: deferTransparent,
OpaqueDigest: opaqueDigest,
TransparentDigest: transparentDigest,
TransparentSetDigest: transparentSetDigest,
Digest: hash.Finish());
}
private static RenderSceneHash128 BuildOpaqueSubmissionDigest(
IReadOnlyList groups)
{
// Opaque groups are a mathematical set: depth testing makes submission
// order irrelevant, and equal-distance List.Sort ties can reflect the
// persistent dictionary's historical insertion order. Preserve exact
// group and per-instance contents while combining group fingerprints
// commutatively. Transparent groups remain strictly order-sensitive.
ulong xorLow = 0;
ulong xorHigh = 0;
ulong sumLow = 0;
ulong sumHigh = 0;
for (int index = 0; index < groups.Count; index++)
{
StableRenderHash128 groupHash = StableRenderHash128.Create();
AddOpaqueSubmissionGroup(
ref groupHash,
groups[index]);
RenderSceneHash128 digest = groupHash.Finish();
xorLow ^= digest.Low;
xorHigh ^= digest.High;
sumLow = unchecked(sumLow + digest.Low);
sumHigh = unchecked(sumHigh + digest.High);
}
StableRenderHash128 hash = StableRenderHash128.Create();
hash.Add(groups.Count);
hash.Add(xorLow);
hash.Add(xorHigh);
hash.Add(sumLow);
hash.Add(sumHigh);
return hash.Finish();
}
private static RenderSceneHash128 BuildTransparentSubmissionDigest(
IReadOnlyList groups,
Vector3 cameraWorldPosition,
List scratch)
{
scratch.Clear();
for (int groupIndex = 0;
groupIndex < groups.Count;
groupIndex++)
{
InstanceGroup group = groups[groupIndex];
for (int instanceIndex = 0;
instanceIndex < group.Matrices.Count;
instanceIndex++)
{
float distance =
RetailAlphaOrdering.ComputeViewerDistance(
group.LocalSortCenters[instanceIndex],
group.Matrices[instanceIndex],
cameraWorldPosition);
if (!float.IsFinite(distance) || distance <= 0f)
distance = 0f;
scratch.Add(new AlphaFingerprint(
group,
instanceIndex,
distance,
group.SubmissionOrders[instanceIndex]));
}
}
scratch.Sort(AlphaFingerprintComparer.Instance);
StableRenderHash128 hash = StableRenderHash128.Create();
hash.Add(scratch.Count);
for (int index = 0; index < scratch.Count; index++)
{
AlphaFingerprint entry = scratch[index];
GroupKey key = ToKey(entry.Group);
hash.Add(key.FirstIndex);
hash.Add(key.BaseVertex);
hash.Add(key.IndexCount);
hash.Add(key.TextureSlot.Index);
hash.Add(key.TextureLayer);
hash.Add((int)key.Translucency);
hash.Add((int)key.CullMode);
hash.Add(entry.ViewerDistance);
AddSubmissionInstance(
ref hash,
entry.Group,
entry.InstanceIndex);
}
return hash.Finish();
}
internal readonly record struct AlphaFingerprint(
InstanceGroup Group,
int InstanceIndex,
float ViewerDistance,
int SubmissionOrder);
private sealed class AlphaFingerprintComparer :
IComparer
{
public static AlphaFingerprintComparer Instance { get; } =
new();
private AlphaFingerprintComparer()
{
}
public int Compare(
AlphaFingerprint left,
AlphaFingerprint right)
{
int value = right.ViewerDistance.CompareTo(
left.ViewerDistance);
return value != 0
? value
: left.SubmissionOrder.CompareTo(
right.SubmissionOrder);
}
}
private static void AddOpaqueSubmissionGroup(
ref StableRenderHash128 hash,
InstanceGroup group)
{
GroupKey key = ToKey(group);
hash.Add(key.FirstIndex);
hash.Add(key.BaseVertex);
hash.Add(key.IndexCount);
hash.Add(key.TextureSlot.Index);
hash.Add(key.TextureLayer);
hash.Add((int)key.Translucency);
hash.Add((int)key.CullMode);
hash.Add(group.Matrices.Count);
ulong xorLow = 0;
ulong xorHigh = 0;
ulong sumLow = 0;
ulong sumHigh = 0;
for (int index = 0;
index < group.Matrices.Count;
index++)
{
StableRenderHash128 instanceHash =
StableRenderHash128.Create();
AddSubmissionInstance(
ref instanceHash,
group,
index);
RenderSceneHash128 digest = instanceHash.Finish();
xorLow ^= digest.Low;
xorHigh ^= digest.High;
sumLow = unchecked(sumLow + digest.Low);
sumHigh = unchecked(sumHigh + digest.High);
}
hash.Add(xorLow);
hash.Add(xorHigh);
hash.Add(sumLow);
hash.Add(sumHigh);
}
private static void AddSubmissionInstance(
ref StableRenderHash128 hash,
InstanceGroup group,
int index)
{
hash.Add(group.Matrices[index]);
hash.Add(group.LocalSortCenters[index]);
hash.Add(group.Slots[index]);
InstanceLightSet lights = group.LightSets[index];
for (int lightIndex = 0;
lightIndex < LightManager.MaxLightsPerObject;
lightIndex++)
{
hash.Add(lights[lightIndex]);
}
hash.Add(group.IndoorFlags[index]);
hash.Add(group.Opacities[index]);
hash.Add(group.SelectionLighting[index]);
}
private void DeferTransparentGroups(Vector3 cameraWorldPosition, Matrix4x4 viewProjection)
{
RetailAlphaQueue queue = _alphaQueue!;
if (_deferredAlpha.Count == 0)
_deferredAlphaViewProjection = viewProjection;
else if (_deferredAlphaViewProjection != viewProjection)
throw new InvalidOperationException(
"One retail alpha scope cannot combine different view-projection matrices.");
// Retail CShadowPart::insertion_sort (0x006B5130) is stable:
// equal-CYpt parts keep the order in which the cell submitted them.
// Material grouping is an acdream batching detail and must not become
// that tiebreak. Reconstruct the original draw-local instance order
// before handing entries to the queue; its stable CYpt radix then
// preserves this sequence for exact-distance ties.
_alphaFingerprintScratch.Clear();
foreach (InstanceGroup group in _translucentDraws)
{
for (int i = 0; i < group.Matrices.Count; i++)
{
Matrix4x4 model = group.Matrices[i];
Vector3 localSortCenter = group.LocalSortCenters[i];
float viewerDistance = RetailAlphaOrdering.ComputeViewerDistance(
localSortCenter,
model,
cameraWorldPosition);
if (!float.IsFinite(viewerDistance)
|| viewerDistance <= 0f)
{
viewerDistance = 0f;
}
_alphaFingerprintScratch.Add(new AlphaFingerprint(
group,
i,
viewerDistance,
group.SubmissionOrders[i]));
}
}
_alphaFingerprintScratch.Sort(
AlphaSubmissionOrderComparer.Instance);
foreach (AlphaFingerprint entry in _alphaFingerprintScratch)
{
InstanceGroup group = entry.Group;
int i = entry.InstanceIndex;
int token = _deferredAlpha.Count;
_deferredAlpha.Add(new DeferredAlphaInstance(
ToKey(group),
group.Matrices[i],
group.Slots[i],
group.LightSets[i],
group.IndoorFlags[i],
group.Opacities[i],
group.SelectionLighting[i]));
queue.Submit(
_alphaSource,
token,
entry.ViewerDistance);
}
}
private sealed class AlphaSubmissionOrderComparer :
IComparer
{
public static AlphaSubmissionOrderComparer Instance { get; } =
new();
private AlphaSubmissionOrderComparer()
{
}
public int Compare(
AlphaFingerprint left,
AlphaFingerprint right) =>
left.SubmissionOrder.CompareTo(right.SubmissionOrder);
}
private void PrepareDeferredAlphaDraws(ReadOnlySpan tokens)
{
if (tokens.Length == 0)
return;
GlobalMeshBuffer? global = _meshAdapter.MeshManager?.GlobalBuffer;
if (global is null || !MeshSourceReady())
return;
int count = tokens.Length;
EnsureDeferredAlphaCapacity(count);
for (int i = 0; i < count; i++)
{
DeferredAlphaInstance entry = _deferredAlpha[tokens[i]];
WriteMatrix(_instanceData, i * 16, entry.Model);
_clipSlotData[i] = entry.ClipSlot;
_indoorData[i] = entry.Indoor;
_alphaData[i] = entry.Opacity;
_selectionLightingData[i] = entry.SelectionLighting;
int lightOffset = i * LightManager.MaxLightsPerObject;
entry.Lights.CopyTo(_lightSetData, lightOffset);
GroupKey key = entry.Key;
_batchData[i] = new BatchData
{
// Campaign V slice V2: table slot, not the raw handle.
TextureIndex = key.TextureSlot.Index,
TextureLayer = key.TextureLayer,
Flags = 0,
};
_indirectCommands[i] = new DrawElementsIndirectCommand
{
Count = (uint)key.IndexCount,
InstanceCount = 1,
FirstIndex = key.FirstIndex,
BaseVertex = key.BaseVertex,
BaseInstance = (uint)i,
};
_drawCullModes[i] = key.CullMode;
_deferredAlphaKinds[i] = key.Translucency;
}
// One upload per source per sorted alpha scope. RetailAlphaQueue later
// draws contiguous ranges from this immutable prepared payload; it must
// never overwrite these buffers for every short mesh/particle run.
//
// A ring allocation cannot outlive its frame as a ref struct, but its
// buffer, offset and size can be stored — so the payload is written
// once here and bound many times below without recopying.
PrepareRhiAlphaSections(count);
}
private void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
{
if (drawCount <= 0)
return;
if (firstPreparedDraw < 0
|| firstPreparedDraw > _deferredAlpha.Count - drawCount)
throw new ArgumentOutOfRangeException(nameof(firstPreparedDraw));
GlobalMeshBuffer? global = _meshAdapter.MeshManager?.GlobalBuffer;
if (global is null || !MeshSourceReady())
return;
DrawPreparedAlphaBatchRhi(global, firstPreparedDraw, drawCount);
}
private void EnsureDeferredAlphaCapacity(int count)
{
TrackScratchDemand(count);
int neededMatrixFloats = count * 16;
if (_instanceData.Length < neededMatrixFloats)
_instanceData = new float[neededMatrixFloats + 256 * 16];
if (_clipSlotData.Length < count)
_clipSlotData = new uint[count + 256];
if (_indoorData.Length < count)
_indoorData = new uint[count + 256];
if (_alphaData.Length < count)
_alphaData = new float[count + 256];
if (_selectionLightingData.Length < count)
_selectionLightingData = new Vector2[count + 256];
if (_lightSetData.Length < count * LightManager.MaxLightsPerObject)
_lightSetData = new int[(count + 256) * LightManager.MaxLightsPerObject];
if (_batchData.Length < count)
_batchData = new BatchData[count + 64];
if (_indirectCommands.Length < count)
_indirectCommands = new DrawElementsIndirectCommand[count + 64];
if (_drawCullModes.Length < count)
_drawCullModes = new CullMode[count + 64];
if (_deferredAlphaKinds.Length < count)
_deferredAlphaKinds = new TranslucencyKind[count + 64];
}
private void ResetDeferredAlphaSubmissions()
{
_deferredAlpha.Clear();
}
private void TrackScratchDemand(int units)
{
if (units > _scratchPeakUnits)
_scratchPeakUnits = units;
}
private void ApplyScratchRetention(int observedUnits)
{
int currentCapacity = Math.Max(
_instanceData.Length / 16,
Math.Max(
_lightSetData.Length / LightManager.MaxLightsPerObject,
Math.Max(
_deferredAlpha.Capacity,
Math.Max(_batchData.Length, _indirectCommands.Length))));
int bytesPerUnit = checked(
16 * sizeof(float)
+ sizeof(uint)
+ LightManager.MaxLightsPerObject * sizeof(int)
+ sizeof(uint)
+ sizeof(float)
+ Unsafe.SizeOf()
+ Unsafe.SizeOf()
+ Unsafe.SizeOf()
+ Unsafe.SizeOf()
+ Unsafe.SizeOf()
+ Unsafe.SizeOf()
+ Unsafe.SizeOf());
int targetCapacity = _alphaScratchPolicy.ObserveAndSelectCapacity(
currentCapacity,
observedUnits,
bytesPerUnit,
minimumCapacity: 256,
growthQuantum: 256);
if (targetCapacity >= currentCapacity)
return;
_instanceData = new float[checked(targetCapacity * 16)];
_clipSlotData = new uint[targetCapacity];
_lightSetData = new int[
checked(targetCapacity * LightManager.MaxLightsPerObject)];
_indoorData = new uint[targetCapacity];
_alphaData = new float[targetCapacity];
_selectionLightingData = new Vector2[targetCapacity];
_batchData = new BatchData[targetCapacity];
_indirectCommands = new DrawElementsIndirectCommand[targetCapacity];
_drawCullModes = new CullMode[targetCapacity];
_batchPublicScratch = new BatchDataPublic[targetCapacity];
_deferredAlphaKinds = new TranslucencyKind[targetCapacity];
_deferredAlpha.Capacity = targetCapacity;
}
private static int CompareOpaqueSubmissionOrder(InstanceGroup a, InstanceGroup b)
{
int cull = ((int)a.CullMode).CompareTo((int)b.CullMode);
return cull != 0 ? cull : a.SortDistance.CompareTo(b.SortDistance);
}
private static int CompareTransparentSubmissionOrder(InstanceGroup a, InstanceGroup b)
{
int cull = ((int)a.CullMode).CompareTo((int)b.CullMode);
return cull != 0 ? cull : b.SortDistance.CompareTo(a.SortDistance);
}
private static int CountCullRuns(CullMode[] modes, int startCommand, int commandCount)
{
if (commandCount <= 0) return 0;
int end = startCommand + commandCount;
int runs = 1;
var previous = modes[startCommand];
for (int i = startCommand + 1; i < end; i++)
{
var current = modes[i];
if (current == previous) continue;
runs++;
previous = current;
}
return runs;
}
private void MaybeFlushDiag()
{
long now = Environment.TickCount64;
if (now - _lastLogTick > 5000)
{
long cpuMed = MedianMicros(_cpuSamples);
long cpuP95 = Percentile95Micros(_cpuSamples);
long gpuMed = MedianMicros(_gpuSamples);
long gpuP95 = Percentile95Micros(_gpuSamples);
// A.5 T23: flag when entity dispatcher median exceeds 2.0ms budget
// (Phase A.5 spec §2 acceptance criterion 6). Grep-friendly prefix.
const long BudgetUs = 2000;
string budgetFlag = cpuMed > BudgetUs ? " BUDGET_OVER" : "";
Console.WriteLine(
$"[WB-DIAG]{budgetFlag} entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} meshMissing={_meshesMissing} drawsIssued={_drawsIssued} instances={_instancesIssued} groups={_groups.Count} " +
$"cpu_us={cpuMed}m/{cpuP95}p95 gpu_us={gpuMed}m/{gpuP95}p95");
_entitiesSeen = _entitiesDrawn = _meshesMissing = _drawsIssued = _instancesIssued = 0;
_lastLogTick = now;
// Don't reset the sample buffers — they're a moving window of the
// last 256 frames; clearing per 5s flush would lose recent history.
}
}
private static long MedianMicros(long[] samples)
{
var copy = (long[])samples.Clone();
Array.Sort(copy);
int nz = 0;
foreach (var v in copy) if (v > 0) nz++;
if (nz == 0) return 0;
// Sorted ascending: zero-padding front, samples at the back. (nz - 1) / 2
// from the end keeps the offset >= 0 for all nz >= 1 — the original
// nz / 2 form indexed copy[copy.Length] (crash) on the first diag flush
// when exactly 1 sample was recorded. Same fix as GameWindow's
// TerrainDiagMedianMicros twin.
return copy[copy.Length - 1 - (nz - 1) / 2];
}
private static long Percentile95Micros(long[] samples)
{
var copy = (long[])samples.Clone();
Array.Sort(copy);
int nz = 0;
foreach (var v in copy) if (v > 0) nz++;
if (nz == 0) return 0;
int idx = copy.Length - 1 - (int)(nz * 0.05);
return copy[idx];
}
// ── Tier 1 cache (#53) helpers extracted for testability ─────────────────
//
// Three pure-CPU static helpers carved out of Draw's per-entity loop so
// unit tests can exercise the populate/flush algorithm + cache-hit fast
// path without needing a real GL context. Production code (Draw) calls
// these helpers; the dispatcher integration tests in
// WbDrawDispatcherBucketingTests use them to drive the same algorithm
// through deterministic inputs.
///
/// Apply a cache hit's batches into the per-frame group dictionary by
/// composing cached.RestPose * entityWorld per batch and routing
/// the result through . The delegate
/// abstracts over so this helper stays
/// GL-free and unit-testable.
///
///
/// Matrix multiplication is non-commutative: it MUST be
/// RestPose * entityWorld, not the reverse. See
/// for the full part-world product.
///
internal static void ApplyCacheHit(
EntityCacheEntry entry,
Matrix4x4 entityWorld,
Action appendInstance)
{
foreach (var cached in entry.Batches)
{
appendInstance(cached.Key, cached.RestPose * entityWorld, cached.LocalSortCenter);
}
}
internal static bool TryResolveCachedGroup(
CachedBatch cached,
out InstanceGroup? group)
{
group = cached.Group;
return group is not null
&& cached.GroupRegistration != 0
&& cached.GroupRegistration == group.Registration;
}
private void ApplyCacheHitDirect(EntityCacheEntry entry, Matrix4x4 entityWorld)
{
for (int i = 0; i < entry.Batches.Length; i++)
{
CachedBatch cached = entry.Batches[i];
Matrix4x4 model = cached.RestPose * entityWorld;
if (!TryResolveCachedGroup(cached, out InstanceGroup? group))
{
group = GetOrCreateInstanceGroup(cached.Key);
entry.Batches[i] = cached with
{
Group = group,
GroupRegistration = group.Registration,
};
}
AppendInstanceToGroup(group!, model, cached.LocalSortCenter);
}
}
///
/// Retires groups that were absent for the entire preceding frame.
/// Retiring sets the registration to zero before the dictionary reference
/// is removed, invalidating every cached direct handle to that exact group
/// without invalidating unrelated live groups. The retired list storage is
/// released because stale classification entries may retain the small group
/// object until their next cache hit.
///
internal static int PruneInstanceGroupsUnusedBeforeFrame(
Dictionary groups,
List retiredKeys,
long oldestLiveFrame)
{
retiredKeys.Clear();
foreach ((GroupKey key, InstanceGroup group) in groups)
{
if (group.LastUsedFrame < oldestLiveFrame)
{
group.Registration = 0;
group.ReleasePerInstanceStorage();
retiredKeys.Add(key);
}
}
foreach (GroupKey key in retiredKeys)
groups.Remove(key);
int retiredCount = retiredKeys.Count;
retiredKeys.Clear();
return retiredCount;
}
///
/// Per-tuple flush check. If is set
/// AND differs from , the previous
/// entity's accumulated batches are committed to
/// and is cleared. Returns the
/// updated tracker tuple — pass these back into the field locals in the
/// caller's loop.
///
///
/// This is the bug-fix structure from commit 00fa8ae (per-MeshRef
/// Populate would overwrite earlier MeshRefs because the cache is
/// keyed by entity.Id; flushing only on entity boundary preserves all
/// MeshRefs' batches). _walkScratch is in entity-order so all MeshRefs
/// of one entity arrive contiguously.
///
internal static (uint? PopulateEntityId, uint PopulateLandblockId)
MaybeFlushOnEntityChange(
uint? populateEntityId,
uint populateLandblockId,
uint currentEntityId,
EntityClassificationCache cache,
List populateScratch,
List? selectionScratch = null)
{
if (populateEntityId.HasValue && populateEntityId.Value != currentEntityId)
{
if (populateScratch.Count > 0)
{
cache.Populate(
populateEntityId.Value,
populateLandblockId,
populateScratch.ToArray(),
selectionScratch?.ToArray());
}
populateScratch.Clear();
selectionScratch?.Clear();
return (null, 0u);
}
return (populateEntityId, populateLandblockId);
}
///
/// End-of-loop final flush. The last entity in _walkScratch has
/// no next-iteration to trigger ,
/// so commit its accumulated batches here. No-op when no populate is
/// pending (the last entity was animated, or the scratch is empty).
///
/// End-of-loop only — does NOT reset the caller's tracker locals
/// (intentional, since they go out of scope immediately after).
///
///
internal static void FinalFlushPopulate(
uint? populateEntityId,
uint populateLandblockId,
EntityClassificationCache cache,
List populateScratch,
List? selectionScratch = null)
{
if (populateEntityId.HasValue && populateScratch.Count > 0)
{
cache.Populate(
populateEntityId.Value,
populateLandblockId,
populateScratch.ToArray(),
selectionScratch?.ToArray());
populateScratch.Clear();
}
selectionScratch?.Clear();
}
///
/// Instance-side helper used by . Looks up or
/// creates an for the given key in
/// _groups and appends the per-instance world matrix.
///
private void AppendInstanceToGroup(GroupKey key, Matrix4x4 model, Vector3 localSortCenter)
{
InstanceGroup grp = GetOrCreateInstanceGroup(key);
AppendInstanceToGroup(grp, model, localSortCenter);
}
private InstanceGroup GetOrCreateInstanceGroup(GroupKey key)
{
if (_groups.TryGetValue(key, out InstanceGroup? group))
{
group.LastUsedFrame = _groupFrame;
return group;
}
if (_nextGroupRegistration == long.MaxValue)
{
throw new InvalidOperationException(
"Instance-group registration space was exhausted before a safe identity could be assigned.");
}
group = new InstanceGroup
{
FirstIndex = key.FirstIndex,
BaseVertex = key.BaseVertex,
IndexCount = key.IndexCount,
TextureSlot = key.TextureSlot,
TextureLayer = key.TextureLayer,
Translucency = key.Translucency,
CullMode = key.CullMode,
Registration = _nextGroupRegistration++,
LastUsedFrame = _groupFrame,
};
_groups.Add(key, group);
return group;
}
private void AppendInstanceToGroup(
InstanceGroup grp,
Matrix4x4 model,
Vector3 localSortCenter)
{
grp.LastUsedFrame = _groupFrame;
grp.Matrices.Add(model);
grp.LocalSortCenters.Add(localSortCenter);
grp.SubmissionOrders.Add(_nextInstanceSubmissionOrder++);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
// #188: cache-hit entities are always non-animated (the Tier-1 cache
// gates on !isAnimated), and TranslucencyFadeManager only ever holds
// state for entities whose animation hooks fired — so a cached
// instance can never be mid-fade. Always unmodified opacity.
grp.Opacities.Add(1.0f);
grp.SelectionLighting.Add(_currentEntitySelectionLighting);
}
///
/// Fix B: choose the up-to-8 point/spot lights for THIS entity (the result
/// reused by every part/instance of it), by the entity's world bounding
/// sphere. Camera-independent (), so
/// a static building's torches stay constant as the viewer moves. Fills
/// ; unused slots are -1. On the no-lights
/// path (no snapshot handed in) every slot is -1 ⇒ shader adds no point light.
///
///
/// A7 Fix D round 2 (2026-06-19): retail lights OUTDOOR objects with the SUN +
/// ambient ONLY — never the static wall torches. The per-object torch step
/// (minimize_object_lighting, 0x0054d480) runs ONLY in the indoor stage:
/// RenderDeviceD3D::DrawMeshInternal (0x0059f398) calls it under
/// if (Render::useSunlight == 0), and the outdoor landscape stage runs
/// Render::useSunlightSet(1) (PView::DrawCells 0x005a485a, right
/// before LScape::draw which draws buildings/scenery). So a building
/// EXTERIOR shell (,
/// = null) and all outdoor scenery /
/// creatures get the sun, not torches. We mirror that: only objects parented to
/// an EnvCell (indoor) select torches; outdoor objects keep the all-(-1) set so
/// the sun path alone lights them. This is what made the Holtburg meeting-hall
/// facade wash out warm — the dat's intensity-100 wall torches (range
/// Falloff×1.3) were flooding the exterior shell that retail never torch-lights.
/// The indoor "no sun" half is already handled by the global sun kill when the
/// player is inside a cell (UpdateSunFromSky). See the divergence register
/// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
///
///
// #176 seam-draw probe (ACDREAM_PROBE_SEAMDRAW) — throwaway apparatus. One
// [seam-ent] line per target-cell entity, re-emitted on state change: world
// position (F3 z — entities do NOT get the +0.02 shell lift), cull/slot,
// and the SelectForObject light set resolved to identities (owner-cell
// low16 + intensity). Sig dict is bounded by the handful of entities that
// ever live in the target cells.
private readonly Dictionary _seamEntSigs = new();
private void MaybeEmitSeamEnt(in RenderInstanceCandidate entity)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
var snap = _pointSnapshot;
var sb = new System.Text.StringBuilder(200);
sb.AppendFormat(ci,
"guid=0x{0:X8} cell=0x{1:X8} pos=({2:F2},{3:F2},{4:F3}) culled={5} slot={6} indoor={7} L=[",
entity.ServerGuid, entity.ParentCellId,
entity.Position.X, entity.Position.Y, entity.Position.Z,
_currentEntityCulled ? 1 : 0, _currentEntitySlot, _currentEntityIndoor ? 1 : 0);
bool any = false;
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
{
int idx = _currentEntityLightSet[k];
if (idx < 0) continue;
if (any) sb.Append(',');
if (snap is not null && idx < snap.Count)
sb.AppendFormat(ci, "{0:X4}:I{1:F0}", snap[idx].CellId & 0xFFFFu, snap[idx].Intensity);
else
sb.Append('?').Append(idx);
any = true;
}
sb.Append(']');
string sig = sb.ToString();
if (_seamEntSigs.TryGetValue(entity.Id, out var prev) && prev == sig) return;
_seamEntSigs[entity.Id] = sig;
Console.WriteLine($"[seam-ent] t={Environment.TickCount64} {sig}");
}
private void ComputeEntityLightSet(
in RenderInstanceCandidate entity)
{
// #142: set the indoor flag first so it's available even when the early-return
// fires below. Both the torch selection and the sun gate use the same predicate,
// so they can't disagree — one call, one truth.
_currentEntityIndoor =
IndoorObjectReceivesTorches(entity.ParentCell);
_currentEntityLightSet = InstanceLightSet.Disabled;
var snap = _pointSnapshot;
if (snap is null || snap.Count == 0) return;
// Retail useSunlight gate: outdoor objects receive no per-object torches.
if (!_currentEntityIndoor) return; // #142: reuse the cached flag (was: IndoorObjectReceivesTorches(...))
Vector3 center =
(entity.Bounds.Minimum + entity.Bounds.Maximum) * 0.5f;
float radius =
(entity.Bounds.Maximum - entity.Bounds.Minimum).Length() * 0.5f;
Array.Fill(_currentEntityLightSetScratch, -1);
LightManager.SelectForObject(snap, center, radius, _currentEntityLightSetScratch);
_currentEntityLightSet = InstanceLightSet.From(_currentEntityLightSetScratch);
}
///
/// Retail's useSunlight gate for per-object torch lighting, as a pure
/// predicate. An object receives the static wall torches (the indoor
/// minimize_object_lighting pass) ONLY when it is parented to an EnvCell
/// — an interior cell, by the AC convention (cellId & 0xFFFF) >= 0x0100.
/// Outdoor objects (building shells with null ,
/// outdoor scenery in a land sub-cell 0x0001..0x00FF, outdoor creatures)
/// are sun-lit only and return false. Mirrors
/// RenderDeviceD3D::DrawMeshInternal (0x0059f398): torches enabled iff
/// Render::useSunlight == 0, which is true only in the indoor draw stage.
///
internal static bool IndoorObjectReceivesTorches(uint? parentCellId)
=> parentCellId.HasValue
&& (parentCellId.Value & 0xFFFFu) >= 0x0100u
&& (parentCellId.Value & 0xFFFFu) != 0xFFFFu; // 0xFFFF = landblock marker, not an EnvCell → outdoor
///
/// Fix B: append the current entity's 8-slot light set to a group's
/// , parallel to its Matrices (one
/// 8-int block per instance), mirroring grp.Slots.Add.
///
private void AppendCurrentLightSet(InstanceGroup grp)
{
grp.LightSets.Add(_currentEntityLightSet);
grp.IndoorFlags.Add(_currentEntityIndoor ? 1u : 0u); // #142, parallel to the light block
}
private bool ClassifyBatches(
ObjectRenderData renderData,
Matrix4x4 model,
in RenderInstanceCandidate entity,
MeshRef meshRef,
PaletteCompositeIdentity paletteIdentity,
Matrix4x4 restPose,
float opacityMultiplier = 1.0f,
List? collector = null)
{
bool allTexturesReady = true;
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
{
var batch = renderData.Batches[batchIdx];
TranslucencyKind translucency = batch.Translucency;
// #188: a mid-fade instance whose surface is otherwise Opaque/ClipMap
// must route through the alpha-blend pass so mesh_modern.frag's
// (blend-enabled) shader actually composites the reduced alpha —
// the no-blend opaque pass would ignore it.
if (opacityMultiplier < 1.0f && IsOpaque(translucency))
translucency = TranslucencyKind.AlphaBlend;
ResolvedTexture texture = ResolveTexture(
in entity,
meshRef,
batch,
paletteIdentity,
out bool compositePending);
if (compositePending)
allTexturesReady = false;
// Campaign V slice V4t: an unassigned slot is the "no texture yet"
// case a zero handle used to signal. It is a real sentinel
// (GpuTextureSlot.Unassigned == ACDREAM_TEXTURE_NONE), not the
// default value, so nothing here can silently resolve to slot 0.
if (!texture.Slot.IsAssigned) continue;
GpuTextureSlot texSlot = texture.Slot;
uint texLayer = texture.Layer;
var key = new GroupKey(
batch.FirstIndex, (int)batch.BaseVertex,
batch.IndexCount, texSlot, texLayer, translucency, batch.CullMode);
InstanceGroup grp = GetOrCreateInstanceGroup(key);
grp.Matrices.Add(model);
grp.LocalSortCenters.Add(renderData.SortCenter);
grp.SubmissionOrders.Add(_nextInstanceSubmissionOrder++);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
grp.Opacities.Add(opacityMultiplier); // #188 — parallel to Matrices
grp.SelectionLighting.Add(_currentEntitySelectionLighting);
collector?.Add(new CachedBatch(
key,
texSlot,
restPose,
renderData.SortCenter,
grp,
grp.Registration));
}
return allTexturesReady;
}
private readonly record struct ResolvedTexture(GpuTextureSlot Slot, uint Layer);
private ResolvedTexture ResolveTexture(
in RenderInstanceCandidate entity,
MeshRef meshRef,
ObjectRenderBatch batch,
PaletteCompositeIdentity paletteIdentity,
out bool compositePending) =>
ResolveTexture(
entity.LocalEntityId,
entity.PaletteOverride,
meshRef,
batch,
paletteIdentity,
out compositePending);
private ResolvedTexture ResolveTexture(
WorldEntity entity,
MeshRef meshRef,
ObjectRenderBatch batch,
PaletteCompositeIdentity paletteIdentity,
out bool compositePending) =>
ResolveTexture(
entity.Id,
entity.PaletteOverride,
meshRef,
batch,
paletteIdentity,
out compositePending);
private ResolvedTexture ResolveTexture(
uint localEntityId,
PaletteOverride? paletteOverride,
MeshRef meshRef,
ObjectRenderBatch batch,
PaletteCompositeIdentity paletteIdentity,
out bool compositePending)
{
compositePending = false;
uint surfaceId = batch.Key.SurfaceId;
if (surfaceId == 0 || surfaceId == 0xFFFFFFFF)
return default;
uint overrideOrigTex = 0;
bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null
&& meshRef.SurfaceOverrides.TryGetValue(surfaceId, out overrideOrigTex)
&& overrideOrigTex != 0;
uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null;
bool sourceIsPaletteIndexed = paletteOverride is not null
&& _textures.IsPaletteIndexed(surfaceId, origTexOverride);
WbTextureResolutionKind resolution = WbTextureResolutionPolicy.Select(
hasOrigTexOverride,
paletteOverride is not null,
sourceIsPaletteIndexed);
switch (resolution)
{
case WbTextureResolutionKind.PaletteComposite:
{
BindlessTextureLocation texture =
_textures.GetOrUploadWithPaletteOverrideBindless(
localEntityId,
surfaceId,
origTexOverride,
paletteOverride!,
paletteIdentity);
compositePending = !texture.IsResolved;
return new ResolvedTexture(texture.Slot, texture.Layer);
}
case WbTextureResolutionKind.OriginalTextureOverride:
{
BindlessTextureLocation texture =
_textures.GetOrUploadWithOrigTextureOverrideBindless(
localEntityId,
surfaceId,
overrideOrigTex);
compositePending = !texture.IsResolved;
return new ResolvedTexture(texture.Slot, texture.Layer);
}
case WbTextureResolutionKind.SharedAtlas:
return new ResolvedTexture(
batch.TextureSlot,
checked((uint)batch.TextureIndex));
default:
throw new ArgumentOutOfRangeException(nameof(resolution));
}
}
private static void WriteMatrix(float[] buf, int offset, in Matrix4x4 m)
{
buf[offset + 0] = m.M11; buf[offset + 1] = m.M12; buf[offset + 2] = m.M13; buf[offset + 3] = m.M14;
buf[offset + 4] = m.M21; buf[offset + 5] = m.M22; buf[offset + 6] = m.M23; buf[offset + 7] = m.M24;
buf[offset + 8] = m.M31; buf[offset + 9] = m.M32; buf[offset + 10] = m.M33; buf[offset + 11] = m.M34;
buf[offset + 12] = m.M41; buf[offset + 13] = m.M42; buf[offset + 14] = m.M43; buf[offset + 15] = m.M44;
}
///
/// Entity-set membership test. Phase U.1 (2026-05-30): with the
/// two-pipe partition deleted, the sole
/// member matches every entity. Retained as a seam for the unified
/// pass to re-introduce partitioning.
///
private static bool EntityMatchesSet(WorldEntity entity, EntitySet set) => true;
internal static bool EntityPassesVisibleCellGate(
WorldEntity entity,
HashSet? visibleCellIds,
EntitySet set)
{
// No cell filter (outdoor root, or a bucket drawn unfiltered like live-dynamics / outdoor
// scenery) ⇒ every entity passes; clip-slot routing (ResolveEntitySlot) does the gating.
if (visibleCellIds is null)
return true;
// A cell-membership filter is active. An interior static passes iff its cell is visible.
if (entity.ParentCellId.HasValue)
return visibleCellIds.Contains(entity.ParentCellId.Value);
// ParentCellId == null (outdoor scenery / building shell): NOT a member of any interior cell,
// so it does NOT pass a cell-membership filter (R1: the bleed fix — was an unconditional
// `return true`). When such entities must draw (through the doorway), the caller passes
// visibleCellIds: null and relies on ResolveEntitySlot's OutsideView routing instead.
return false;
}
// Phase U.1 (2026-05-30): the shell-scoped sets (IndoorPass / BuildingShells)
// were deleted with the two-pipe machinery. EntitySet.All is never shell-scoped.
private static bool IsShellScopedSet(EntitySet set) => false;
public void Dispose()
{
if (_disposed || _disposing) return;
_disposing = true;
try
{
if (_disposeResources is null)
{
// Campaign V slice V11: the RHI arm is the only arm — its
// pipelines route their physical free through the device's
// retirement queue, so the release ledger is always empty.
var releases = new List<(string Name, Action Release)>();
DisposeRhiResources();
_disposeResources = new RetryableResourceReleaseLedger(releases);
}
ResourceReleaseAttempt attempt = _disposeResources.Advance();
if (!_disposeResources.IsComplete)
{
throw attempt.ToException(
"One or more entity renderer resources could not be released.");
}
CompleteDispose();
_disposeResources = null;
_disposed = true;
if (attempt.HasFailures)
{
throw attempt.ToException(
"Entity renderer resources released with exceptional committed outcomes.");
}
}
finally
{
_disposing = false;
}
}
private void CompleteDispose()
{
_dynamicFrameStarted = false;
}
// ── Public types + helpers for BuildIndirectArrays (Task 9) ─────────────
//
// These are public so the pure-CPU unit tests in AcDream.Core.Tests can
// exercise BuildIndirectArrays without needing a GL context.
///
/// Stride in bytes of DrawElementsIndirectCommand in the indirect buffer.
/// 5 × uint = 20 bytes. Tests and callers reference this symbolically
/// rather than hard-coding 20 so a layout change produces a compile error.
///
public const int DrawCommandStride = 20; // sizeof(DrawElementsIndirectCommand): 5 × uint
///
/// Public view of the per-group inputs to — used in tests.
/// Campaign V slice V2: TextureIndex is a slot into the binding=9
/// handle table (was a raw 64-bit bindless TextureHandle).
///
public readonly record struct IndirectGroupInput(
int IndexCount,
uint FirstIndex,
int BaseVertex,
int InstanceCount,
int FirstInstance,
uint TextureIndex,
uint TextureLayer,
TranslucencyKind Translucency,
CullMode CullMode = CullMode.CounterClockwise);
///
/// Public mirror of the per-group uploaded to the SSBO.
/// Tests verify the layout. Same field shape as the private BatchData.
///
[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct BatchDataPublic
{
public uint TextureIndex;
public uint Reserved;
public uint TextureLayer;
public uint Flags;
}
/// Result of .
public readonly record struct IndirectLayoutResult(
int OpaqueCount,
int TransparentCount,
int TransparentByteOffset);
///
/// Lays out the indirect commands + parallel BatchData array contiguously:
/// opaque section first (caller sorts before calling), transparent section second.
/// Pure CPU, no GL state. Caller passes pre-sized scratch arrays.
///
///
/// Classification: Opaque + ClipMap → opaque pass (ClipMap uses discard, not
/// blending). Everything else (AlphaBlend, Additive, InvAlpha) → transparent pass.
///
public static IndirectLayoutResult BuildIndirectArrays(
IReadOnlyList groups,
DrawElementsIndirectCommand[] indirectScratch,
BatchDataPublic[] batchScratch,
CullMode[]? cullScratch = null)
{
int opaqueCount = 0;
int transparentCount = 0;
foreach (var g in groups)
{
if (IsOpaque(g.Translucency)) opaqueCount++;
else transparentCount++;
}
int oi = 0; // opaque write cursor (fills [0..opaqueCount))
int ti = opaqueCount; // transparent write cursor (fills [opaqueCount..end))
foreach (var g in groups)
{
var dec = new DrawElementsIndirectCommand
{
Count = (uint)g.IndexCount,
InstanceCount = (uint)g.InstanceCount,
FirstIndex = g.FirstIndex,
BaseVertex = g.BaseVertex,
BaseInstance = (uint)g.FirstInstance,
};
var bd = new BatchDataPublic
{
TextureIndex = g.TextureIndex,
Reserved = 0,
TextureLayer = g.TextureLayer,
Flags = 0,
};
if (IsOpaque(g.Translucency))
{
indirectScratch[oi] = dec;
batchScratch[oi] = bd;
if (cullScratch is not null) cullScratch[oi] = g.CullMode;
oi++;
}
else
{
indirectScratch[ti] = dec;
batchScratch[ti] = bd;
if (cullScratch is not null) cullScratch[ti] = g.CullMode;
ti++;
}
}
return new IndirectLayoutResult(opaqueCount, transparentCount, opaqueCount * DrawCommandStride);
}
///
/// Public test shim for . Locks in the N.5 Decision 2
/// translucency partition: Opaque + ClipMap → opaque indirect; AlphaBlend +
/// Additive + InvAlpha → transparent indirect.
///
public static bool IsOpaquePublic(TranslucencyKind t) => IsOpaque(t);
private static bool IsOpaque(TranslucencyKind t)
=> t == TranslucencyKind.Opaque || t == TranslucencyKind.ClipMap;
// ────────────────────────────────────────────────────────────────────────
///
/// Thin wrapper around an instance's rate-limit dictionary + frame
/// counter, passed into the static
/// overload so it can emit rate-limited probe lines without access
/// to instance fields. Null = probes disabled (test-friendly overload).
///
internal sealed class IndoorProbeState
{
private readonly Dictionary _lastFrame;
private readonly int _currentFrame;
private const int RateLimit = IndoorProbeRateLimitFrames;
internal IndoorProbeState(Dictionary lastFrame, int currentFrame)
{
_lastFrame = lastFrame;
_currentFrame = currentFrame;
}
///
/// Returns true at most once per
/// frames per . Side-effect: stamps the frame
/// number into the dictionary on success.
///
internal bool ShouldEmit(ulong cellId)
{
if (!_lastFrame.TryGetValue(cellId, out int last)
|| _currentFrame - last >= RateLimit)
{
_lastFrame[cellId] = _currentFrame;
return true;
}
return false;
}
}
internal sealed class InstanceGroup
{
// Nonzero only while this exact object is registered in _groups.
// CachedBatch stores the value alongside the reference; retirement
// zeros it before removal so stale handles cannot append off-table.
public long Registration;
public long LastUsedFrame;
public uint FirstIndex;
public int BaseVertex;
public int IndexCount;
// Campaign V slice V4t: the device texture-table slot (was a raw 64-bit
// ARB_bindless_texture handle, and a uint TextureHandle in N.4).
public AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot =
AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned;
public uint TextureLayer; // Layer in either the pooled composite array or WB shared atlas.
public TranslucencyKind Translucency;
public CullMode CullMode;
public int FirstInstance; // offset into the shared instance VBO (in instances, not bytes)
public int InstanceCount;
public float SortDistance; // squared distance from camera to first instance, for opaque sort
public readonly List Matrices = new();
// Retail CPhysicsPart::CYpt uses the transformed GfxObj sort center,
// not the entity origin. Parallel to Matrices so delayed-alpha
// submissions retain the exact per-part key after material grouping.
public readonly List LocalSortCenters = new();
// Retail CShadowPart::insertion_sort (0x006B5130) is stable for equal
// CYpt. Material groups erase authored entity/part/batch traversal
// unless that order is retained explicitly. SubmissionOrders[i] is
// the draw-local append ordinal for Matrices[i].
public readonly List SubmissionOrders = new();
// Phase U.4: per-instance clip-slot index, parallel to Matrices (Slots[i]
// is the binding=2 CellClip slot for the instance whose matrix is
// Matrices[i]). At layout time the dispatcher writes Slots[i] into
// _clipSlotData at the same cursor it writes Matrices[i] into _instanceData,
// so the binding=3 instanceClipSlot[] tracks the binding=0 instance.
public readonly List Slots = new();
// Fix B (A7 #3): one packed eight-index light set per instance, parallel
// to Matrices (LightSets[i] belongs to Matrices[i]). At
// layout time the dispatcher copies each block into _lightSetData at the
// same cursor, so the binding=5 instanceLightIdx[] tracks the binding=0
// instance. -1 = unused slot.
public readonly List LightSets = new();
// #142: per-instance "indoor" flag, parallel to Matrices. IndoorFlags[i] is
// 1 when the instance's entity is parented to an EnvCell (skip the sun); 0
// for outdoor objects (gets the sun). Written into _indoorData at the same
// cursor as Matrices, so binding=6 instanceIndoor[] tracks binding=0.
public readonly List IndoorFlags = new();
// #188: per-instance opacity multiplier, parallel to Matrices.
// Opacities[i] is 1.0=unmodified, or <1.0 while a TransparentPartHook
// fade is in flight for the instance whose matrix is Matrices[i]. At
// layout time the dispatcher writes Opacities[i] into _alphaData at
// the same cursor, so the binding=7 instanceAlpha[] tracks binding=0.
public readonly List Opacities = new();
// Retail SmartBox click lighting, parallel to Matrices. Each vec2 is
// (luminosity, diffuse) and is uploaded to binding=8.
public readonly List SelectionLighting = new();
///
/// Resets every per-instance parallel list for a new frame. These lists are
/// appended in lockstep (one entry per drawn instance) during group build, so
/// they MUST all be cleared together each frame. Keeping the reset in one
/// method stops a newly-added parallel list from silently drifting out of the
/// frame lifecycle — which is exactly the #193 OOM: #188 added
/// alongside the others but left it out of the old
/// inline clear loop, so it grew one float per instance per frame forever and
/// leaked ~1 GB/min of LOH float[] as its backing array doubled.
///
public void ClearPerInstanceData()
{
Matrices.Clear();
LocalSortCenters.Clear();
SubmissionOrders.Clear();
Slots.Clear();
LightSets.Clear();
IndoorFlags.Clear();
Opacities.Clear();
SelectionLighting.Clear();
}
public void ReleasePerInstanceStorage()
{
ClearPerInstanceData();
Matrices.TrimExcess();
LocalSortCenters.TrimExcess();
SubmissionOrders.TrimExcess();
Slots.TrimExcess();
LightSets.TrimExcess();
IndoorFlags.TrimExcess();
Opacities.TrimExcess();
SelectionLighting.TrimExcess();
}
}
}