using System.Numerics;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Walk;
using AcDream.Core.Lighting;
using AcDream.Core.Meshing;
using AcDream.Core.World;
using DatReaderWriter.Enums;
namespace AcDream.App.Rendering.Wb;
///
/// Campaign FW stage FW3.2a: the walk-order population layer. This partial
/// holds two things.
///
/// The shared per-batch classify core ():
/// extracted from ClassifyBatches (this file's sibling
/// WbDrawDispatcher.cs) and ClassifyPackedBatches
/// (WbDrawDispatcher.PackedOracle.cs), which carried byte-identical
/// per-batch logic (the #426 untextured-subset gate, the #188 opacity
/// promotion, texture resolution, Campaign VM foliage classification, the
/// construction) with only their SURROUNDING
/// bookkeeping differing (classic appends to an
/// via caller-scoped instance fields; packed appends via explicit
/// parameters). Both call sites now call this one method per batch and keep
/// their own append logic — the classic and packed paths are unchanged in
/// every observable way (same hermetic + Lane=InstalledDat suites
/// stay the referee); this stage's OWN new walk classifier
/// () is the third caller.
///
/// The per-entity walk classify seam
/// (): given ONE
/// — the same shape ClassifyPackedEntity consumes, but read straight
/// off the record's own via
/// RenderInstanceCandidate.FromProjection rather than the packed
/// route's frame-arena mesh-part flattening — yields one
/// per surviving (part, batch) pair WITHOUT
/// touching any , plus the per-part selection
/// data AddPackedSelectionPart would have published, for
/// WalkStaticStreamPopulator to publish itself (deliverable
/// 1 leaves the publish decision — timing, stage/cell provenance — to the
/// caller; see , the thin internal seam
/// that keeps _selectionSink encapsulated).
///
/// Scope: this stage classifies static content only (no production
/// frame wiring — see plan §FW3.2a). Per-part translucency-fade
/// (TranslucencyFadeManager/EntityOpacity) is NOT threaded
/// through: that mechanic is TransparentPartHook, a retail LIVE-entity
/// behavior keyed by ServerGuid, not something world statics undergo — every
/// classified batch here carries Alpha = 1f. Likewise the async
/// mesh-miss self-heal request (_missRequested/EnsureLoaded,
/// frame-scoped state cleared by BeginEntityDispatch) is not fired
/// here — this seam has no production frame to be scoped to yet; a missing
/// mesh is simply skipped, matching every other unwired FW2/FW3.2a path.
///
public sealed partial class WbDrawDispatcher
{
///
/// One walk-classified (entity, part, batch) draw candidate — exactly
/// OrderedDrawCommand's per-instance field set (minus
/// the walk-provenance Stage/CellId fields, which the
/// populator stamps on since the classifier has no notion of either)
/// plus two fields an opaque command has no use for but a translucent
/// one needs to reach the alpha queue: (so the
/// populator can route without re-deriving it from
/// ) and
/// (the authored GfxObj sort center RetailAlphaOrdering.ComputeViewerDistance
/// transforms through — the same value
/// InstanceGroup.LocalSortCenters carries per instance today).
///
internal readonly record struct WalkClassifiedBatch(
GroupKey Key,
Matrix4x4 Transform,
uint ClipSlot,
InstanceLightSet Lights,
uint IndoorFlag,
float Alpha,
Vector2 SelectionLighting,
uint DetailCategory,
bool IsOpaque,
Vector3 LocalSortCenter);
///
/// One retail-picking part surfaced by
/// — the exact argument tuple AddPackedSelectionPart's
/// publishSelection: true branch passes to
/// IRetailSelectionRenderSink.AddVisiblePart. The classify seam
/// surfaces this; is the caller's
/// publish call.
///
internal readonly record struct WalkClassifiedSelectionPart(
uint ServerGuid,
uint LocalEntityId,
int PartIndex,
uint GfxObjId,
Matrix4x4 LocalToWorld);
///
/// The shared per-batch classify core. Given one already-resolved
/// and the batch at ,
/// applies — in this exact order, matching both ClassifyBatches and
/// ClassifyPackedBatches before this extraction — the #426
/// untextured-subset-on-a-shell-only gate, the #188 mid-fade-forces-
/// AlphaBlend promotion, texture resolution, and Campaign VM foliage
/// classification, then builds the surviving batch's .
///
/// Returns false when the batch does not survive — either the
/// untextured-subset gate rejected it (in which case
/// is always false: ResolveTexture
/// is never called) or its resolved texture slot is unassigned (in which
/// case still reflects whatever
/// ResolveTexture reported — a caller must apply it to its own
/// reusability/readiness tracking regardless of the false return, exactly
/// as both existing callers did before this extraction).
///
private bool TryClassifyBatch(
ObjectRenderData renderData,
int batchIndex,
in RenderInstanceCandidate entity,
MeshRef meshRef,
PaletteCompositeIdentity paletteIdentity,
float opacityMultiplier,
bool entityHasCutoutSubset,
out GroupKey key,
out bool compositePending)
{
key = default;
compositePending = false;
ObjectRenderBatch batch = renderData.Batches[batchIndex];
// #426 — see RetailUntexturedSubsetPolicy for the retail citation.
// ONE shared predicate for every classifier so they cannot drift.
if (!RetailUntexturedSubsetPolicy.Draws(entity.IsBuildingShell, batch.Key.IsSolid))
return false;
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 compositePending);
if (!texture.Slot.IsAssigned)
return false;
// Classify from the RAW (pre-#188-promotion) batch.Translucency — a
// mid-fade trunk is still a trunk, it just landed in the alpha-blend
// group instead of opaque (Campaign VM VM6 review fix round).
uint foliageFlags = FoliageWindClassification.Classify(
entity.LocalEntityId,
FoliageWindExclusions.Contains(meshRef.GfxObjId),
batch.Translucency,
entityHasCutoutSubset);
key = new GroupKey(
batch.FirstIndex, (int)batch.BaseVertex,
batch.IndexCount, texture.Slot, texture.Layer, translucency,
FoliageFlags: foliageFlags,
CullMode: batch.CullMode);
return true;
}
///
/// Classifies one static entity for the walk populator: resolves its clip
/// slot / light set / selection lighting exactly as
/// ClassifyPackedEntity does (via the shared
/// / ResolveWalkLightSet
/// helpers), walks its Setup parts or single mesh (mirroring
/// ClassifyPackedEntity's own shape), and appends one
/// per surviving batch to
/// plus one
/// per part to — WITHOUT touching
/// _groups/_packedGroups or publishing selection itself (see
/// this file's type doc comment).
///
/// A culled entity ( returns
/// Culled: true — not visible through the active clip route)
/// contributes nothing, matching every other classifier's cull gate.
///
internal void ClassifyEntityForWalk(
in RenderProjectionRecord projection,
uint tupleLandblockId,
List batches,
List selectionParts,
bool liveDynamic = false,
IWalkLookInViewSource? lookInViews = null,
int lookInRouteIndex = -1,
uint lookInCellId = 0)
{
ArgumentNullException.ThrowIfNull(batches);
ArgumentNullException.ThrowIfNull(selectionParts);
RenderInstanceCandidate entity =
RenderInstanceCandidate.FromProjection(
in projection,
tupleLandblockId,
animated: liveDynamic);
(uint slot, bool culled) = ResolveSlotForFrame(
_clipRoutingActive, entity.ServerGuid, entity.ParentCell,
_cellIdToSlot, _outdoorSlot, _outdoorVisible);
if (culled)
return;
ResolveWalkLightSet(in entity, out InstanceLightSet lights, out bool indoor);
Vector2 selectionLighting =
_selectionLighting?.TryGetLighting(
entity.ServerGuid, entity.LocalEntityId, out RetailSelectionLighting lighting) == true
? new Vector2(lighting.Luminosity, lighting.Diffuse)
: new Vector2(0f, 1f);
uint detailCategory = entity.IsBuildingShell ? 1u : 0u;
PaletteCompositeIdentity paletteIdentity = default;
if (entity.PaletteOverride is not null)
paletteIdentity = TextureCache.GetPaletteIdentity(entity.PaletteOverride);
IReadOnlyList? meshRefs = projection.EntityPayload.MeshRefs;
if (meshRefs is null)
return;
for (int partIndex = 0; partIndex < meshRefs.Count; partIndex++)
{
MeshRef meshRef = meshRefs[partIndex];
if (_meshAdapter.IsRuntimeHiddenMarker(meshRef.GfxObjId))
continue;
ObjectRenderData? renderData = _meshAdapter.TryGetRenderData(meshRef.GfxObjId);
if (renderData is null)
{
if (_missRequested.Add(meshRef.GfxObjId))
_meshAdapter.EnsureLoaded(meshRef.GfxObjId);
continue;
}
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
{
// Same entity-scoped OR the classic/packed classifiers compute
// — a Setup composite's parts are separate GfxObjs with their
// own independently cached HasCutoutSubset (Campaign VM VM6
// review fix round A4/F1).
bool entityHasCutoutSubset = FoliageWindClassification.ComputeEntityHasCutoutSubset(
renderData.SetupParts,
_meshAdapter,
static (adapter, part) => adapter.TryGetRenderData(part.GfxObjId)
is { HasCutoutSubset: true });
for (int setupPartIndex = 0; setupPartIndex < renderData.SetupParts.Count; setupPartIndex++)
{
(ulong gfxObjId, Matrix4x4 partTransform) = renderData.SetupParts[setupPartIndex];
if (_meshAdapter.IsRuntimeHiddenMarker((uint)gfxObjId))
continue;
ObjectRenderData? partData = _meshAdapter.TryGetRenderData(gfxObjId);
if (partData is null)
{
if (_missRequested.Add(gfxObjId))
_meshAdapter.EnsureLoaded(gfxObjId);
continue;
}
float opacity = liveDynamic
? WalkPartOpacity(
entity.ServerGuid,
entity.LocalEntityId,
(uint)setupPartIndex)
: 1f;
if (opacity <= 0f)
continue;
Matrix4x4 restPose = partTransform * meshRef.PartTransform;
Matrix4x4 model = restPose * entity.RootWorld;
int selectionPartIndex = unchecked((partIndex << 16) | (setupPartIndex & 0xFFFF));
if (!PartVisibleInLookInTurn(
lookInViews,
lookInRouteIndex,
lookInCellId,
in entity,
selectionPartIndex,
(uint)gfxObjId,
partData,
model))
{
continue;
}
EmitClassifiedBatches(
partData, model, in entity, meshRef, paletteIdentity,
entityHasCutoutSubset, slot, lights, indoor, selectionLighting,
detailCategory, opacity, batches);
selectionParts.Add(new WalkClassifiedSelectionPart(
entity.ServerGuid, entity.LocalEntityId, selectionPartIndex,
(uint)gfxObjId, model));
}
}
else
{
float opacity = liveDynamic
? WalkPartOpacity(
entity.ServerGuid,
entity.LocalEntityId,
(uint)partIndex)
: 1f;
if (opacity <= 0f)
continue;
Matrix4x4 model = meshRef.PartTransform * entity.RootWorld;
if (!PartVisibleInLookInTurn(
lookInViews,
lookInRouteIndex,
lookInCellId,
in entity,
partIndex,
(uint)meshRef.GfxObjId,
renderData,
model))
{
continue;
}
EmitClassifiedBatches(
renderData, model, in entity, meshRef, paletteIdentity,
entityHasCutoutSubsetOverride: null, slot, lights, indoor,
selectionLighting, detailCategory, opacity, batches);
selectionParts.Add(new WalkClassifiedSelectionPart(
entity.ServerGuid, entity.LocalEntityId, partIndex,
(uint)meshRef.GfxObjId, model));
}
}
}
private float WalkPartOpacity(
uint serverGuid,
uint localEntityId,
uint setupPartIndex)
{
float opacity = EntityOpacity(serverGuid);
if (opacity <= 0f)
return 0f;
if (!_translucencyFades.TryGetCurrentValue(
localEntityId,
setupPartIndex,
out float translucency))
{
return opacity;
}
return translucency >= 1f
? 0f
: opacity * (1f - translucency);
}
private void ResolveWalkLightSet(
in RenderInstanceCandidate entity,
out InstanceLightSet lights,
out bool indoor)
{
indoor = IndoorObjectReceivesTorches(entity.ParentCell);
lights = InstanceLightSet.Disabled;
IReadOnlyList? snapshot = _pointSnapshot;
if (!indoor || snapshot is null || snapshot.Count == 0)
return;
Vector3 center =
(entity.Bounds.Minimum + entity.Bounds.Maximum) * 0.5f;
float radius =
(entity.Bounds.Maximum - entity.Bounds.Minimum)
.Length() * 0.5f;
Span selected =
stackalloc int[LightManager.MaxLightsPerObject];
selected.Fill(-1);
LightManager.SelectForObject(
snapshot,
center,
radius,
selected);
lights = InstanceLightSet.From(selected);
}
private static bool PartVisibleInLookInTurn(
IWalkLookInViewSource? lookInViews,
int routeIndex,
uint cellId,
in RenderInstanceCandidate entity,
int partIndex,
uint gfxObjId,
ObjectRenderData renderData,
Matrix4x4 localToWorld)
{
if (lookInViews is null)
return true;
if (renderData.SelectionSphere is not { Radius: > 0f } sphere)
return true;
return LookInDrawingSphereVisible(
lookInViews,
routeIndex,
sphere,
localToWorld,
out _,
out _);
}
internal static bool LookInDrawingSphereVisible(
IWalkLookInViewSource lookInViews,
int routeIndex,
DatReaderWriter.Types.Sphere sphere,
Matrix4x4 localToWorld,
out Vector3 center,
out float radius)
{
ArgumentNullException.ThrowIfNull(lookInViews);
ArgumentNullException.ThrowIfNull(sphere);
center = Vector3.Transform(sphere.Origin, localToWorld);
float scaleX = new Vector3(
localToWorld.M11,
localToWorld.M12,
localToWorld.M13).Length();
float scaleY = new Vector3(
localToWorld.M21,
localToWorld.M22,
localToWorld.M23).Length();
float scaleZ = new Vector3(
localToWorld.M31,
localToWorld.M32,
localToWorld.M33).Length();
radius = sphere.Radius
* MathF.Max(scaleX, MathF.Max(scaleY, scaleZ));
return lookInViews.SphereVisibleInLookInTurn(
routeIndex,
in center,
radius);
}
///
/// Walks one resolved mesh's batches through
/// and appends every surviving one to at
/// Alpha = 1f (see this file's type doc comment for why statics
/// never carry a per-part opacity multiplier here).
///
private void EmitClassifiedBatches(
ObjectRenderData renderData,
Matrix4x4 model,
in RenderInstanceCandidate entity,
MeshRef meshRef,
PaletteCompositeIdentity paletteIdentity,
bool? entityHasCutoutSubsetOverride,
uint slot,
InstanceLightSet lights,
bool indoor,
Vector2 selectionLighting,
uint detailCategory,
float opacity,
List sink)
{
bool entityHasCutoutSubset = entityHasCutoutSubsetOverride ?? renderData.HasCutoutSubset;
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
{
bool survives = TryClassifyBatch(
renderData, batchIdx, in entity, meshRef, paletteIdentity,
opacityMultiplier: opacity, entityHasCutoutSubset,
out GroupKey key, out _);
if (!survives)
continue;
sink.Add(new WalkClassifiedBatch(
key, model, slot, lights, indoor ? 1u : 0u, Alpha: opacity,
selectionLighting, detailCategory, IsOpaque: IsOpaque(key.Translucency),
LocalSortCenter: renderData.SortCenter));
}
}
///
/// The thin internal publish seam WalkStaticStreamPopulator
/// calls for every
/// surfaced — same call shape as
/// AddPackedSelectionPart's publishSelection: true branch.
/// Keeps _selectionSink encapsulated: the populator lives outside
/// the dispatcher and must not reach the field directly.
///
internal void PublishWalkSelectionPart(in WalkClassifiedSelectionPart part) =>
_selectionSink?.AddVisiblePart(
part.ServerGuid, part.LocalEntityId, part.PartIndex, part.GfxObjId, part.LocalToWorld);
///
/// The walk populator's per-instance sibling of DeferTransparentGroups
/// (see that method for the retail citations this mirrors): submits ONE
/// translucent into the same
/// _deferredAlpha/ machinery the
/// classic material-grouped path uses, so scenery, particles, and walk
/// content share retail's one stable far-to-near stream. Same
/// view-projection consistency check, same
/// call, same
/// queue.Submit contract — the walk path denormalizes to one
/// instance per call instead of flattening a material group.
///
internal void SubmitWalkAlphaInstance(
in WalkClassifiedBatch batch,
Vector3 cameraWorldPosition,
Matrix4x4 viewProjection)
{
RetailAlphaQueue queue = _alphaQueue
?? throw new InvalidOperationException(
"SubmitWalkAlphaInstance requires an active RetailAlphaQueue.");
if (_deferredAlpha.Count == 0)
_deferredAlphaViewProjection = viewProjection;
else if (_deferredAlphaViewProjection != viewProjection)
throw new InvalidOperationException(
"One retail alpha scope cannot combine different view-projection matrices.");
float viewerDistance = RetailAlphaOrdering.ComputeViewerDistance(
batch.LocalSortCenter, batch.Transform, cameraWorldPosition);
if (!float.IsFinite(viewerDistance) || viewerDistance <= 0f)
viewerDistance = 0f;
int token = _deferredAlpha.Count;
_deferredAlpha.Add(new DeferredAlphaInstance(
batch.Key, batch.Transform, batch.ClipSlot, batch.Lights,
batch.IndoorFlag, batch.DetailCategory, batch.Alpha, batch.SelectionLighting));
queue.Submit(_alphaSource, token, viewerDistance);
}
}