using System.Collections.Generic;
using System.Numerics;
using AcDream.Content;
using AcDream.Core.CharGen;
using AcDream.Core.Meshing;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering;
///
/// One resolved drawable part of the chargen preview body — a Setup part
/// index (needed to sample Animation.PartFrames[frame].Frames[index]
/// and Setup.DefaultScale[index]) paired with its resolved GfxObj id,
/// default scale (captured once at build time — scale never changes across
/// an idle cycle), and surface overrides.
/// walks this list every tick without touching the dat source again.
///
internal readonly record struct ChargenPreviewDrawablePart(
int SetupPartIndex,
uint GfxObjId,
Vector3 DefaultScale,
IReadOnlyDictionary? SurfaceOverrides);
///
/// The richer sibling of 's
/// result: the built (seeded with retail's true
/// default pose — see ) plus everything
/// needed to drive it frame-by-frame without re-touching the dat source —
/// the resolved drawable parts, the precomputed frozen rest pose, and the
/// resolved idle Animation + its frame range.
///
internal sealed class ChargenPreviewAnimatedBuild
{
public required WorldEntity Entity { get; init; }
public required IReadOnlyList DrawableParts { get; init; }
///
/// The held final-frame rest pose, precomputed once (retail:
/// gmCG3DView::StopAnimation's framerate-0
/// set_sequence_animation call never advances, so there is
/// nothing to recompute per tick while zoomed in). Falls back to each
/// part's raw Setup-default transform (no-op) when the rest DID doesn't
/// resolve, matching the pre-CC6b ApplyHeldPose no-op behavior.
///
public required IReadOnlyList RestMeshRefs { get; init; }
/// Retail's live idle DID (m_didAnimation), or null if unresolved.
public Animation? IdleAnimation { get; init; }
public int IdleLowFrame { get; init; }
public int IdleHighFrame { get; init; }
}
///
/// Builds the chargen preview from a
/// — the App-layer counterpart to
/// , except this one resolves its OWN
/// MeshRefs from a Setup + the composed ObjDesc rather than receiving
/// already-resolved refs from a live entity (there is no live entity yet;
/// character creation hasn't happened). DAT-touching, unlike
/// 's pure index-agnostic builder — the
/// closest existing precedent for the actual mesh-flatten/apply-changes/
/// resolve-surface-overrides steps is
/// DatLiveEntityProjectionMaterializer.TryMaterialize, trimmed to
/// what a private, non-collision preview scene needs.
///
///
/// CC6b: retail's chargen preview does NOT default to a frozen pose —
/// gmCGAppearancePage::Update's own trailing gate
/// (~0x0047EF01-0x0047EF12) calls gmCG3DView::StartAnimation (idle
/// loop playing) whenever m_bZoomedIn == 0, and that default is
/// DIRECTLY ASSIGNED, not inherited:
/// gmCGAppearancePage::InitializePage @0x0047FDD0 writes an
/// explicit m_bZoomedIn = 0 at 0x004802C3 (right after
/// setting the camera to the zoomed-IN per-heritage eye at
/// 0x00480286-0x0048029E — the null-tween quirk the zoom
/// controller's doc records). The earlier elided-ctor-byte argument was
/// unsound (heap-new members are indeterminate, not zero) and was
/// replaced by this citation at the CC6b-PRE re-review. So retail's
/// chargen preview plays its idle loop (m_didAnimation, 30fps) from
/// the very first frame; the REST pose (m_didAnimationRest, held
/// final frame, this class's pre-CC6b-only behavior) only appears once the
/// user presses Zoom In (gmCGAppearancePage::ZoomIn calls
/// gmCG3DView::StopAnimation immediately, before its camera tween
/// even starts). keeps its ORIGINAL (rest-only)
/// behavior unchanged for its existing callers;
/// plus are the new, retail-accurate
/// entry point a live preview (idle-playing by default, freezing on zoom-in)
/// should use.
///
///
internal static class ChargenPreviewEntityBuilder
{
/// Reserved synthetic guid for the chargen preview clone —
/// same reserved family as
/// (0xDA11D0xx) and CreatureAppraisalEntityBuilder (0xDA11D02x).
public const uint PreviewServerGuid = 0xDA11_D031u;
/// Reserved render-local entity id — passed in
/// animatedEntityIds by the renderer so a re-dress (a new
/// selection) bypasses WbDrawDispatcher's Tier-1 classification
/// cache, mirroring 's own
/// doc comment.
public const uint PreviewRenderId = 0xDA11_D032u;
/// Reserved synthetic guid for the chargen preview's ENVIRONMENT
/// backdrop (GF-7/GF-14 fix) — next slot in the same 0xDA11D03x chargen
/// family as .
public const uint PreviewBackdropServerGuid = 0xDA11_D033u;
/// Reserved render-local entity id for the backdrop object,
/// passed in animatedEntityIds alongside
/// so a heritage switch's new environment Setup also bypasses the
/// classification cache — same reasoning as 's
/// own doc comment, applied to retail's SECOND creature_mode_objects
/// member (gmCG3DView::m_pbgObject).
public const uint PreviewBackdropRenderId = 0xDA11_D034u;
///
/// F16 (Campaign CC gate round 1 closeout, 2026-08-16): the Summary
/// page's OWN preview render-local id — DISTINCT from
/// . Both the Appearance and Summary pages
/// construct their own ChargenPreviewRenderer, but they share
/// ONE process-wide TextureCache (Wb.IEntityTextureLifetime)
/// via LivePresentationComposition's foundation.TextureCache
/// — confirmed by tracing FixedEntityTextureOwnerLease.Replace →
/// TextureCache.ReleaseOwner → CompositeTextureArrayCache.ReleaseOwner
/// → its own _owners tracker, keyed ONLY by the raw
/// ownerLocalId uint with no per-renderer namespace. Both pages
/// are mounted as PERMANENT siblings (register AP-229) and can be
/// simultaneously live, so two PrivateEntityViewportRenderer
/// instances sharing would share this
/// SAME owner bucket: either page re-dressing its own entity (a
/// FixedEntityTextureOwnerLease.Replace call) or being disposed
/// would call ReleaseOwner(PreviewRenderId) and release textures
/// the OTHER page's preview is still actively drawing with — a real
/// cross-page texture-corruption path, not a theoretical one. Reserved
/// in the SAME 0xDA11D0xx synthetic family, next free slot after the
/// Appearance page's own pair.
///
public const uint SummaryPreviewRenderId = 0xDA11_D035u;
/// F16: the Summary page's own backdrop render-local id,
/// paired with exactly as
/// pairs with
/// — see that constant's own doc for why a
/// distinct id is required, not merely tidy.
public const uint SummaryPreviewBackdropRenderId = 0xDA11_D036u;
///
/// Retail's held-pose (REST) animation DID enum key, resolved through
/// master map slot 7 exactly like RetailPaperdollPoseApplicator.ResolvePoseDid
/// — 0x10000005 for every standard heritage (the SAME enum id the
/// paperdoll's own held pose reads), matching
/// gmCG3DView's ctor / ::Update per-heritage
/// m_didAnimationRest assignment (pseudo-C ~0x004EE948,
/// ~0x004EEC43). Olthoi and OlthoiAcid each get their OWN distinct rest
/// DID — the one divergence from the paperdoll, which never needs an
/// Olthoi branch because a live player can't be one.
///
private static uint ResolveRestPoseEnum(uint heritageId) => heritageId switch
{
(uint)ChargenHeritageGroup.Olthoi => 0x10000011u,
(uint)ChargenHeritageGroup.OlthoiAcid => 0x10000013u,
_ => 0x10000005u,
};
///
/// Retail's LIVE idle-loop animation DID enum key (m_didAnimation,
/// the one gmCG3DView::StartAnimation plays at 30fps) — 0x10000006
/// for every standard heritage, matching gmCG3DView's ctor /
/// ::Update per-heritage assignment (pseudo-C ~0x004ee6cc,
/// ~0x004eec2d). Olthoi and OlthoiAcid use the SAME did for BOTH idle
/// and rest (0x10000011 / 0x10000013 respectively, pseudo-C
/// ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8) — a genuine retail
/// quirk, not a porting shortcut: those two heritages show no visible
/// difference between "idle playing" and "zoomed in and frozen" in the
/// chargen preview.
///
private static uint ResolveIdleAnimEnum(uint heritageId) => heritageId switch
{
(uint)ChargenHeritageGroup.Olthoi => 0x10000011u,
(uint)ChargenHeritageGroup.OlthoiAcid => 0x10000013u,
_ => 0x10000006u,
};
///
/// Builds the STATIC (held rest-pose) preview entity, or null when the
/// resolved body Setup isn't in the dat source (a corrupted/incomplete
/// install — the same failure shape
/// treats as "drop this
/// spawn"). Unchanged since CC6a for its RESULT — a thin wrapper over
/// that returns exactly the same
/// WorldEntity (rest-posed) this method's existing callers already
/// expect; ALL 3 of those callers' tests still pass unmodified. Not
/// byte-identical internally any more —
/// also resolves the idle DID and loads the idle Animation before this
/// wrapper discards them, extra dat work the pre-CC6b method never did.
/// New code that wants retail's true default (idle loop playing) should call
/// and wrap the result in a
/// instead.
///
///
/// Shared exclusion object for every dat read this method performs.
/// DatCollection is NOT thread-safe (see
/// claude-memory/feedback_phase_a1_hotfix_saga.md) — every other
/// dat-touching renderer/resolver in this layer
/// (RetailPaperdollPoseApplicator, PlayerModeController,
/// DatProjectileSetupResolver, EquippedChildRenderController)
/// takes the SAME object datLock the composition root threads
/// through as RuntimeOptions/d.DatLock; callers MUST pass
/// that same shared instance, not a private lock, or this method's reads
/// race every other consumer's.
///
public static WorldEntity? TryBuild(
IDatReaderWriter dats,
IAnimationLoader animations,
ChargenAppearanceResult appearance,
uint heritageId,
Quaternion heading,
object datLock,
uint renderId = PreviewRenderId)
{
ChargenPreviewAnimatedBuild? build = TryBuildAnimated(
dats, animations, appearance, heritageId, heading, datLock, renderId);
if (build is null)
return null;
build.Entity.MeshRefs = build.RestMeshRefs;
return build.Entity;
}
///
/// Builds the preview entity PLUS everything a
/// needs to drive retail's idle-loop ↔ rest-pose swap without re-touching
/// the dat source. The returned
/// is initially posed with
/// (cheap, always available) — 's
/// constructor immediately reposes it to the true retail default (idle
/// frame 0) when an idle Animation resolved.
///
public static ChargenPreviewAnimatedBuild? TryBuildAnimated(
IDatReaderWriter dats,
IAnimationLoader animations,
ChargenAppearanceResult appearance,
uint heritageId,
Quaternion heading,
object datLock,
// F16 (Campaign CC gate round 1 closeout): the Appearance and
// Summary pages both call this method through their own
// ChargenPreviewController, but must NOT stamp the same Id on
// both entities — see SummaryPreviewRenderId's own doc for the
// full TextureCache collision trace this id also feeds.
uint renderId = PreviewRenderId)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(animations);
ArgumentNullException.ThrowIfNull(appearance);
ArgumentNullException.ThrowIfNull(datLock);
uint setupId = appearance.SetupId;
List drawableParts;
List restMeshRefs;
Animation? idleAnimation;
int idleLowFrame = 0, idleHighFrame = -1;
// Every dat read this method performs — the Setup fetch, both pose
// DID resolutions, the per-part GfxObj drawable checks, and the
// texture-change surface resolution — happens inside this one lock,
// mirroring RetailPaperdollPoseApplicator.Apply's "resolve
// everything under lock, then do pure processing" shape.
lock (datLock)
{
Setup? setup = dats.Get(setupId);
if (setup is null)
return null;
var flattened = new List(SetupMesh.Flatten(setup));
foreach (ChargenAnimPartChange change in appearance.ObjDesc.AnimPartChanges)
{
if (change.PartIndex < flattened.Count)
flattened[change.PartIndex] = new MeshRef(change.PartId, flattened[change.PartIndex].PartTransform);
}
// Rest pose: overwrite flattened's transforms with the held
// final frame (no-op — keeps Setup-default transforms — if the
// rest DID or its Animation don't resolve).
ApplyHeldPoseTransforms(dats, animations, setup, ResolveRestPoseEnum(heritageId), flattened);
Dictionary>? surfaceOverrides =
ResolveSurfaceOverrides(dats, flattened, appearance.ObjDesc.TextureChanges);
drawableParts = new List(flattened.Count);
restMeshRefs = new List(flattened.Count);
for (int partIndex = 0; partIndex < flattened.Count; partIndex++)
{
MeshRef part = flattened[partIndex];
if (dats.Get(part.GfxObjId) is null)
continue; // matches DatLiveEntityProjectionMaterializer's drawable filter.
IReadOnlyDictionary? overrides = null;
if (surfaceOverrides is not null && surfaceOverrides.TryGetValue(partIndex, out var perPart))
overrides = perPart;
restMeshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides });
Vector3 defaultScale = partIndex < setup.DefaultScale.Count
? setup.DefaultScale[partIndex]
: Vector3.One;
drawableParts.Add(new ChargenPreviewDrawablePart(partIndex, part.GfxObjId, defaultScale, overrides));
}
if (drawableParts.Count == 0)
return null;
// Idle DID: independent lookup, no mutation of flattened.
uint idleDid = RetailHeldPose.ResolvePoseDid(dats, ResolveIdleAnimEnum(heritageId));
idleAnimation = (idleDid >> 24) == 0x03u ? animations.LoadAnimation(idleDid) : null;
if (idleAnimation is not null && idleAnimation.PartFrames.Count > 0)
{
idleLowFrame = 0;
idleHighFrame = idleAnimation.PartFrames.Count - 1;
}
else
{
idleAnimation = null;
}
}
var entity = new WorldEntity
{
Id = renderId,
ServerGuid = PreviewServerGuid,
SourceGfxObjOrSetupId = setupId,
Position = Vector3.Zero,
Rotation = heading,
MeshRefs = restMeshRefs,
PaletteOverride = BuildPaletteOverride(appearance),
PartOverrides = BuildPartOverrides(appearance),
ParentCellId = null,
};
return new ChargenPreviewAnimatedBuild
{
Entity = entity,
DrawableParts = drawableParts,
RestMeshRefs = restMeshRefs,
IdleAnimation = idleAnimation,
IdleLowFrame = idleLowFrame,
IdleHighFrame = idleHighFrame,
};
}
///
/// Builds the chargen preview's ENVIRONMENT BACKDROP entity — the fix for
/// GF-7/GF-14 (preview backdrop black on Appearance and Summary).
///
///
/// Decomp-cited: gmCG3DView::Update @0x004EE9D0
/// (~0x004eecd3-0x004eed44) constructs a SECOND CPhysicsObj from
/// m_bgSetupID and adds it to the SAME viewport's
/// creature_mode_objects the player object lives in — BEFORE
/// the player is re-added (the player's own re-AddObject happens
/// much later, at ~0x004ef199, after the full clothing ObjDesc is
/// composed), so retail's own draw-list order is backdrop first, player
/// second. m_bgSetupID is compared against a freshly-read value the
/// decompiler elides (var_b8/eax_32, an unresolved-call
/// artifact — see claude-memory/feedback_bn_decomp_field_names.md)
/// immediately after ACCharGenData::GetHG(charGenData, mHeritageGroup)
/// (0x004eea1a) resolves the current heritage's HeritageGroup_CG;
/// acclient.h's verbatim struct layout
/// (HeritageGroup_CG.environmentSetupID, right after
/// setupID) confirms the elided value IS that field — i.e. THE
/// SAME id this codebase already parses as
///
/// (ChargenTableReader.cs) but never consumed. The backdrop object
/// gets NO explicit position/orientation/scale anywhere in the function —
/// CPhysicsObj::makeObject(eax_32, 0, 1) (0x004eed2f) leaves it at
/// its physics-object default (origin, identity), exactly like the player
/// object's own placement in this same private scene. This method mirrors
/// that: a plain, unposed, unpalette-overridden Setup mesh at the origin.
///
///
///
/// Both the Appearance page (gmCGAppearancePage) and the Summary
/// page (gmCGSummaryPage) call this SAME gmCG3DView::Update
/// function on their own gmCG3DView instance (confirmed at
/// pseudo-C ~0x0047bbf0/~0x0047c92c for Summary and ~0x0047c840/
/// ~0x0047eee1 for Appearance) — so the backdrop mechanism is identical
/// for both viewports, not page-specific.
///
///
///
/// .
/// Zero (unset/no environment authored for this heritage) returns null —
/// matches retail's own if (eax_32 != INVALID_DID.id) gate at
/// 0x004eed29, which skips makeObject/AddObject entirely
/// when the heritage has no environment Setup.
///
public static WorldEntity? TryBuildBackdrop(
IDatReaderWriter dats,
uint environmentSetupId,
object datLock,
// F16 (Campaign CC gate round 1 closeout): see TryBuildAnimated's
// own renderId parameter doc — same Appearance-vs-Summary
// distinction, applied to the backdrop entity.
uint renderId = PreviewBackdropRenderId)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(datLock);
if (environmentSetupId == 0u)
return null;
lock (datLock)
{
Setup? setup = dats.Get(environmentSetupId);
if (setup is null)
return null;
var flattened = SetupMesh.Flatten(setup);
var drawable = new List(flattened.Count);
foreach (MeshRef part in flattened)
{
if (dats.Get(part.GfxObjId) is not null)
drawable.Add(part);
}
if (drawable.Count == 0)
return null;
return new WorldEntity
{
Id = renderId,
ServerGuid = PreviewBackdropServerGuid,
SourceGfxObjOrSetupId = environmentSetupId,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = drawable,
ParentCellId = null,
};
}
}
/// No dat access — pure projection of the already-composed
/// ObjDesc's subpalettes, safe to call outside datLock.
private static PaletteOverride? BuildPaletteOverride(ChargenAppearanceResult appearance)
{
if (appearance.ObjDesc.SubPalettes.Count == 0)
return null;
var ranges = new PaletteOverride.SubPaletteRange[appearance.ObjDesc.SubPalettes.Count];
for (int i = 0; i < appearance.ObjDesc.SubPalettes.Count; i++)
{
ChargenSubPalette sub = appearance.ObjDesc.SubPalettes[i];
ranges[i] = new PaletteOverride.SubPaletteRange(sub.SubPaletteId, sub.Offset, sub.NumColors);
}
return new PaletteOverride(appearance.BasePaletteId, ranges);
}
/// No dat access — pure projection, safe to call outside
/// datLock.
private static PartOverride[] BuildPartOverrides(ChargenAppearanceResult appearance)
{
var partOverrides = new PartOverride[appearance.ObjDesc.AnimPartChanges.Count];
for (int i = 0; i < appearance.ObjDesc.AnimPartChanges.Count; i++)
{
ChargenAnimPartChange change = appearance.ObjDesc.AnimPartChanges[i];
partOverrides[i] = new PartOverride(change.PartIndex, change.PartId);
}
return partOverrides;
}
///
/// Overwrites every part's transform from the resolved pose DID's FINAL
/// frame — same "hold the settled last frame at zero frame rate"
/// approach as RetailPaperdollPoseApplicator.Apply
/// (RedressCreature @ 0x004A3C22), applied to the FULL
/// setup-part-indexed array (before drawable filtering) so the index
/// alignment holds even if a later part turns out to have a missing
/// GfxObj. No-ops (keeps the default placement frame) when the pose
/// DID or its animation can't be resolved.
///
private static void ApplyHeldPoseTransforms(
IDatReaderWriter dats,
IAnimationLoader animations,
Setup setup,
uint poseEnum,
List flattened)
{
uint poseDid = RetailHeldPose.ResolvePoseDid(dats, poseEnum);
if ((poseDid >> 24) != 0x03u)
return;
Animation? animation = animations.LoadAnimation(poseDid);
if (animation is null || animation.PartFrames.Count == 0)
return;
var frame = animation.PartFrames[^1];
for (int index = 0; index < flattened.Count; index++)
{
Vector3 scale = index < setup.DefaultScale.Count ? setup.DefaultScale[index] : Vector3.One;
Vector3 origin = Vector3.Zero;
Quaternion orientation = Quaternion.Identity;
if (index < frame.Frames.Count)
{
origin = frame.Frames[index].Origin;
orientation = frame.Frames[index].Orientation;
}
flattened[index] = new MeshRef(
flattened[index].GfxObjId,
RetailHeldPose.ComposePartTransform(scale, origin, orientation));
}
}
///
/// Part-index → (old texture id → new texture id) resolution, verbatim
/// port of DatLiveEntityProjectionMaterializer.ResolveSurfaceOverrides's
/// algorithm against instead of the
/// wire's CreateObject.TextureChange.
///
private static Dictionary>? ResolveSurfaceOverrides(
IDatReaderWriter dats,
IReadOnlyList parts,
IReadOnlyList textureChanges)
{
if (textureChanges.Count == 0)
return null;
var oldToNewByPart = new Dictionary>();
foreach (ChargenTextureChange change in textureChanges)
{
if (!oldToNewByPart.TryGetValue(change.PartIndex, out var oldToNew))
{
oldToNew = [];
oldToNewByPart.Add(change.PartIndex, oldToNew);
}
oldToNew[change.OldTextureId] = change.NewTextureId;
}
var result = new Dictionary>();
for (int partIndex = 0; partIndex < parts.Count; partIndex++)
{
if (!oldToNewByPart.TryGetValue(partIndex, out var oldToNew))
continue;
GfxObj? gfx = dats.Get(parts[partIndex].GfxObjId);
if (gfx is null)
continue;
Dictionary? resolved = null;
foreach (var surfaceQid in gfx.Surfaces)
{
uint surfaceId = (uint)surfaceQid;
Surface? surface = dats.Get(surfaceId);
if (surface is null)
continue;
uint originalTexture = (uint)surface.OrigTextureId;
if (originalTexture == 0 || !oldToNew.TryGetValue(originalTexture, out uint newTexture))
continue;
(resolved ??= [])[surfaceId] = newTexture;
}
if (resolved is not null)
result[partIndex] = resolved;
}
return result.Count == 0 ? null : result;
}
}