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.ReleaseOwnerCompositeTextureArrayCache.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; } }