acdream/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs
Erik bd359d5181 fix(chargen): Campaign CC gate round 1 closeout — Group 3: round review fixes (F4-F11, F14, F16)
The remaining code-bearing findings from the round review, F4-F16 minus
the doc-only items (batched separately):

- F4: three client-wide UiButton corpus sweeps (LabelBox path — exactly
  the 4 Town buttons, confined to chargen; conflicting custom-selection-
  pair + standard Normal/Highlight media — zero found, no gate
  tightening needed; per-state label-color map — 209 matches beyond
  chargen, confirming AP-222's mechanism has always been broadly active
  since it shipped generically in DatWidgetFactory).
- F5/F6: LayoutImporter's Batch C un-consumed-children carve-out now
  honors a child's own AuthoredInvisible flag (a narrow honor scoped to
  exactly that carve-out, not the general #408 client-wide one) — the
  chat transcript's new-text indicator (0x1000048C) was building as a
  visible phantom element retail never shows; verified both directions
  against the gold-frame pieces, which do not author Invisible.
- F7: BoundedProcessOutputCapture.AppendLine combines the line text and
  its trailing newline into one buffer and one file open/write/close
  instead of two.
- F9: corrected a stale comment in RuntimeSettingsTargets — #407 split
  DisplayModeCatalog's Resolutions/WindowedResolutions in two, so the
  fullscreen validator's own narrower list is now DELIBERATELY different
  from the Config dropdown's fuller offering, not the "must match" bug
  the comment described.
- F10: documented (not changed) why the LabelBox path's default 3px
  inset and the face-relative +4px gap in DatWidgetFactory.BuildButton
  are deliberately different numbers — neither carries a retail
  citation, and moving either to match the other would be an unfounded
  guess on a button that currently works correctly.
- F11: Heritage/Profession/Summary/Town description pages now compose
  DatRichText.Compose's result ONCE inside their already revision-gated
  Refresh, caching the built line list instead of re-wrapping on every
  draw call.
- F14: documented (not changed) why PrivateEntityViewportRenderer's
  _animatedIds set carrying a reserved-but-never-drawn backdrop id is
  harmless — BuildDrawEntities already excludes a null/empty backdrop
  from the actual draw list, so the id is never looked up.
- F16: the Summary preview now uses its own render-id pair
  (SummaryPreviewRenderId/SummaryPreviewBackdropRenderId, 0xDA11D035/
  0xDA11D036) instead of sharing the Appearance page's
  (0xDA11D032/0xDA11D034) — confirmed by tracing
  FixedEntityTextureOwnerLease through TextureCache to
  CompositeTextureArrayCache's shared owner tracker that both pages'
  previews share ONE process-wide TextureCache, so sharing render ids
  was a real cross-page texture-release collision (either page's own
  re-dress or disposal could release the OTHER page's still-active
  textures), not a theoretical one.

F3's own register bookkeeping (AP-229 addendum) and F12's register/AD
header-count corrections land in the docs-only commit alongside F15.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 15:34:07 +02:00

572 lines
27 KiB
C#

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;
/// <summary>
/// One resolved drawable part of the chargen preview body — a Setup part
/// index (needed to sample <c>Animation.PartFrames[frame].Frames[index]</c>
/// and <c>Setup.DefaultScale[index]</c>) paired with its resolved GfxObj id,
/// default scale (captured once at build time — scale never changes across
/// an idle cycle), and surface overrides. <see cref="ChargenPreviewAnimator"/>
/// walks this list every tick without touching the dat source again.
/// </summary>
internal readonly record struct ChargenPreviewDrawablePart(
int SetupPartIndex,
uint GfxObjId,
Vector3 DefaultScale,
IReadOnlyDictionary<uint, uint>? SurfaceOverrides);
/// <summary>
/// The richer sibling of <see cref="ChargenPreviewEntityBuilder.TryBuild"/>'s
/// result: the built <see cref="WorldEntity"/> (seeded with retail's true
/// default pose — see <see cref="ChargenPreviewAnimator"/>) 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.
/// </summary>
internal sealed class ChargenPreviewAnimatedBuild
{
public required WorldEntity Entity { get; init; }
public required IReadOnlyList<ChargenPreviewDrawablePart> DrawableParts { get; init; }
/// <summary>
/// The held final-frame rest pose, precomputed once (retail:
/// <c>gmCG3DView::StopAnimation</c>'s framerate-0
/// <c>set_sequence_animation</c> 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 <c>ApplyHeldPose</c> no-op behavior.
/// </summary>
public required IReadOnlyList<MeshRef> RestMeshRefs { get; init; }
/// <summary>Retail's live idle DID (<c>m_didAnimation</c>), or null if unresolved.</summary>
public Animation? IdleAnimation { get; init; }
public int IdleLowFrame { get; init; }
public int IdleHighFrame { get; init; }
}
/// <summary>
/// Builds the chargen preview <see cref="WorldEntity"/> from a
/// <see cref="ChargenAppearanceResult"/> — the App-layer counterpart to
/// <see cref="DollEntityBuilder"/>, 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
/// <see cref="DollEntityBuilder"/>'s pure index-agnostic builder — the
/// closest existing precedent for the actual mesh-flatten/apply-changes/
/// resolve-surface-overrides steps is
/// <c>DatLiveEntityProjectionMaterializer.TryMaterialize</c>, trimmed to
/// what a private, non-collision preview scene needs.
///
/// <para>
/// <b>CC6b:</b> retail's chargen preview does NOT default to a frozen pose —
/// <c>gmCGAppearancePage::Update</c>'s own trailing gate
/// (~0x0047EF01-0x0047EF12) calls <c>gmCG3DView::StartAnimation</c> (idle
/// loop playing) whenever <c>m_bZoomedIn == 0</c>, and that default is
/// DIRECTLY ASSIGNED, not inherited:
/// <c>gmCGAppearancePage::InitializePage @0x0047FDD0</c> writes an
/// explicit <c>m_bZoomedIn = 0</c> at <c>0x004802C3</c> (right after
/// setting the camera to the zoomed-IN per-heritage eye at
/// <c>0x00480286-0x0048029E</c> — 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 (<c>m_didAnimation</c>, 30fps) from
/// the very first frame; the REST pose (<c>m_didAnimationRest</c>, held
/// final frame, this class's pre-CC6b-only behavior) only appears once the
/// user presses Zoom In (<c>gmCGAppearancePage::ZoomIn</c> calls
/// <c>gmCG3DView::StopAnimation</c> immediately, before its camera tween
/// even starts). <see cref="TryBuild"/> keeps its ORIGINAL (rest-only)
/// behavior unchanged for its existing callers; <see cref="TryBuildAnimated"/>
/// plus <see cref="ChargenPreviewAnimator"/> are the new, retail-accurate
/// entry point a live preview (idle-playing by default, freezing on zoom-in)
/// should use.
/// </para>
/// </summary>
internal static class ChargenPreviewEntityBuilder
{
/// <summary>Reserved synthetic guid for the chargen preview clone —
/// same reserved family as <see cref="DollEntityBuilder.DollServerGuid"/>
/// (0xDA11D0xx) and <c>CreatureAppraisalEntityBuilder</c> (0xDA11D02x).</summary>
public const uint PreviewServerGuid = 0xDA11_D031u;
/// <summary>Reserved render-local entity id — passed in
/// <c>animatedEntityIds</c> by the renderer so a re-dress (a new
/// selection) bypasses <c>WbDrawDispatcher</c>'s Tier-1 classification
/// cache, mirroring <see cref="DollEntityBuilder.DollRenderId"/>'s own
/// doc comment.</summary>
public const uint PreviewRenderId = 0xDA11_D032u;
/// <summary>Reserved synthetic guid for the chargen preview's ENVIRONMENT
/// backdrop (GF-7/GF-14 fix) — next slot in the same 0xDA11D03x chargen
/// family as <see cref="PreviewServerGuid"/>.</summary>
public const uint PreviewBackdropServerGuid = 0xDA11_D033u;
/// <summary>Reserved render-local entity id for the backdrop object,
/// passed in <c>animatedEntityIds</c> alongside <see cref="PreviewRenderId"/>
/// so a heritage switch's new environment Setup also bypasses the
/// classification cache — same reasoning as <see cref="PreviewRenderId"/>'s
/// own doc comment, applied to retail's SECOND <c>creature_mode_objects</c>
/// member (<c>gmCG3DView::m_pbgObject</c>).</summary>
public const uint PreviewBackdropRenderId = 0xDA11_D034u;
/// <summary>
/// F16 (Campaign CC gate round 1 closeout, 2026-08-16): the Summary
/// page's OWN preview render-local id — DISTINCT from
/// <see cref="PreviewRenderId"/>. Both the Appearance and Summary pages
/// construct their own <c>ChargenPreviewRenderer</c>, but they share
/// ONE process-wide <c>TextureCache</c> (<c>Wb.IEntityTextureLifetime</c>)
/// via <c>LivePresentationComposition</c>'s <c>foundation.TextureCache</c>
/// — confirmed by tracing <c>FixedEntityTextureOwnerLease.Replace</c> →
/// <c>TextureCache.ReleaseOwner</c> → <c>CompositeTextureArrayCache.ReleaseOwner</c>
/// → its own <c>_owners</c> tracker, keyed ONLY by the raw
/// <c>ownerLocalId</c> uint with no per-renderer namespace. Both pages
/// are mounted as PERMANENT siblings (register AP-229) and can be
/// simultaneously live, so two <c>PrivateEntityViewportRenderer</c>
/// instances sharing <see cref="PreviewRenderId"/> would share this
/// SAME owner bucket: either page re-dressing its own entity (a
/// <c>FixedEntityTextureOwnerLease.Replace</c> call) or being disposed
/// would call <c>ReleaseOwner(PreviewRenderId)</c> 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.
/// </summary>
public const uint SummaryPreviewRenderId = 0xDA11_D035u;
/// <summary>F16: the Summary page's own backdrop render-local id,
/// paired with <see cref="SummaryPreviewRenderId"/> exactly as
/// <see cref="PreviewBackdropRenderId"/> pairs with
/// <see cref="PreviewRenderId"/> — see that constant's own doc for why a
/// distinct id is required, not merely tidy.</summary>
public const uint SummaryPreviewBackdropRenderId = 0xDA11_D036u;
/// <summary>
/// Retail's held-pose (REST) animation DID enum key, resolved through
/// master map slot 7 exactly like <c>RetailPaperdollPoseApplicator.ResolvePoseDid</c>
/// — 0x10000005 for every standard heritage (the SAME enum id the
/// paperdoll's own held pose reads), matching
/// <c>gmCG3DView</c>'s ctor / <c>::Update</c> per-heritage
/// <c>m_didAnimationRest</c> 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.
/// </summary>
private static uint ResolveRestPoseEnum(uint heritageId) => heritageId switch
{
(uint)ChargenHeritageGroup.Olthoi => 0x10000011u,
(uint)ChargenHeritageGroup.OlthoiAcid => 0x10000013u,
_ => 0x10000005u,
};
/// <summary>
/// Retail's LIVE idle-loop animation DID enum key (<c>m_didAnimation</c>,
/// the one <c>gmCG3DView::StartAnimation</c> plays at 30fps) — 0x10000006
/// for every standard heritage, matching <c>gmCG3DView</c>'s ctor /
/// <c>::Update</c> per-heritage assignment (pseudo-C ~0x004ee6cc,
/// ~0x004eec2d). <b>Olthoi and OlthoiAcid use the SAME did for BOTH idle
/// and rest</b> (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.
/// </summary>
private static uint ResolveIdleAnimEnum(uint heritageId) => heritageId switch
{
(uint)ChargenHeritageGroup.Olthoi => 0x10000011u,
(uint)ChargenHeritageGroup.OlthoiAcid => 0x10000013u,
_ => 0x10000006u,
};
/// <summary>
/// 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
/// <see cref="DatLiveEntityProjectionMaterializer"/> treats as "drop this
/// spawn"). Unchanged since CC6a for its RESULT — a thin wrapper over
/// <see cref="TryBuildAnimated"/> that returns exactly the same
/// <c>WorldEntity</c> (rest-posed) this method's existing callers already
/// expect; ALL 3 of those callers' tests still pass unmodified. Not
/// byte-identical internally any more — <see cref="TryBuildAnimated"/>
/// 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
/// <see cref="TryBuildAnimated"/> and wrap the result in a
/// <see cref="ChargenPreviewAnimator"/> instead.
/// </summary>
/// <param name="datLock">
/// Shared exclusion object for every dat read this method performs.
/// <c>DatCollection</c> is NOT thread-safe (see
/// <c>claude-memory/feedback_phase_a1_hotfix_saga.md</c>) — every other
/// dat-touching renderer/resolver in this layer
/// (<c>RetailPaperdollPoseApplicator</c>, <c>PlayerModeController</c>,
/// <c>DatProjectileSetupResolver</c>, <c>EquippedChildRenderController</c>)
/// takes the SAME <c>object datLock</c> the composition root threads
/// through as <c>RuntimeOptions</c>/<c>d.DatLock</c>; callers MUST pass
/// that same shared instance, not a private lock, or this method's reads
/// race every other consumer's.
/// </param>
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;
}
/// <summary>
/// Builds the preview entity PLUS everything a <see cref="ChargenPreviewAnimator"/>
/// needs to drive retail's idle-loop ↔ rest-pose swap without re-touching
/// the dat source. The returned <see cref="ChargenPreviewAnimatedBuild.Entity"/>
/// is initially posed with <see cref="ChargenPreviewAnimatedBuild.RestMeshRefs"/>
/// (cheap, always available) — <see cref="ChargenPreviewAnimator"/>'s
/// constructor immediately reposes it to the true retail default (idle
/// frame 0) when an idle Animation resolved.
/// </summary>
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<ChargenPreviewDrawablePart> drawableParts;
List<MeshRef> 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<Setup>(setupId);
if (setup is null)
return null;
var flattened = new List<MeshRef>(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<int, Dictionary<uint, uint>>? surfaceOverrides =
ResolveSurfaceOverrides(dats, flattened, appearance.ObjDesc.TextureChanges);
drawableParts = new List<ChargenPreviewDrawablePart>(flattened.Count);
restMeshRefs = new List<MeshRef>(flattened.Count);
for (int partIndex = 0; partIndex < flattened.Count; partIndex++)
{
MeshRef part = flattened[partIndex];
if (dats.Get<GfxObj>(part.GfxObjId) is null)
continue; // matches DatLiveEntityProjectionMaterializer's drawable filter.
IReadOnlyDictionary<uint, uint>? 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,
};
}
/// <summary>
/// Builds the chargen preview's ENVIRONMENT BACKDROP entity — the fix for
/// GF-7/GF-14 (preview backdrop black on Appearance and Summary).
///
/// <para>
/// Decomp-cited: <c>gmCG3DView::Update @0x004EE9D0</c>
/// (~0x004eecd3-0x004eed44) constructs a SECOND <c>CPhysicsObj</c> from
/// <c>m_bgSetupID</c> and adds it to the SAME viewport's
/// <c>creature_mode_objects</c> the player object lives in — <b>BEFORE</b>
/// the player is re-added (the player's own re-<c>AddObject</c> happens
/// much later, at ~0x004ef199, after the full clothing ObjDesc is
/// composed), so retail's own draw-list order is backdrop first, player
/// second. <c>m_bgSetupID</c> is compared against a freshly-read value the
/// decompiler elides (<c>var_b8</c>/<c>eax_32</c>, an unresolved-call
/// artifact — see <c>claude-memory/feedback_bn_decomp_field_names.md</c>)
/// immediately after <c>ACCharGenData::GetHG(charGenData, mHeritageGroup)</c>
/// (0x004eea1a) resolves the current heritage's <c>HeritageGroup_CG</c>;
/// <c>acclient.h</c>'s verbatim struct layout
/// (<c>HeritageGroup_CG.environmentSetupID</c>, right after
/// <c>setupID</c>) confirms the elided value IS that field — i.e. THE
/// SAME id this codebase already parses as
/// <see cref="AcDream.Core.CharGen.ChargenHeritageOptions.EnvironmentSetupId"/>
/// (<c>ChargenTableReader.cs</c>) but never consumed. The backdrop object
/// gets NO explicit position/orientation/scale anywhere in the function —
/// <c>CPhysicsObj::makeObject(eax_32, 0, 1)</c> (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.
/// </para>
///
/// <para>
/// Both the Appearance page (<c>gmCGAppearancePage</c>) and the Summary
/// page (<c>gmCGSummaryPage</c>) call this SAME <c>gmCG3DView::Update</c>
/// function on their own <c>gmCG3DView</c> 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.
/// </para>
/// </summary>
/// <param name="environmentSetupId">
/// <see cref="AcDream.Core.CharGen.ChargenHeritageOptions.EnvironmentSetupId"/>.
/// Zero (unset/no environment authored for this heritage) returns null —
/// matches retail's own <c>if (eax_32 != INVALID_DID.id)</c> gate at
/// 0x004eed29, which skips <c>makeObject</c>/<c>AddObject</c> entirely
/// when the heritage has no environment Setup.
/// </param>
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<Setup>(environmentSetupId);
if (setup is null)
return null;
var flattened = SetupMesh.Flatten(setup);
var drawable = new List<MeshRef>(flattened.Count);
foreach (MeshRef part in flattened)
{
if (dats.Get<GfxObj>(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,
};
}
}
/// <summary>No dat access — pure projection of the already-composed
/// ObjDesc's subpalettes, safe to call outside <c>datLock</c>.</summary>
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);
}
/// <summary>No dat access — pure projection, safe to call outside
/// <c>datLock</c>.</summary>
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;
}
/// <summary>
/// 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 <c>RetailPaperdollPoseApplicator.Apply</c>
/// (<c>RedressCreature @ 0x004A3C22</c>), 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.
/// </summary>
private static void ApplyHeldPoseTransforms(
IDatReaderWriter dats,
IAnimationLoader animations,
Setup setup,
uint poseEnum,
List<MeshRef> 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));
}
}
/// <summary>
/// Part-index → (old texture id → new texture id) resolution, verbatim
/// port of <c>DatLiveEntityProjectionMaterializer.ResolveSurfaceOverrides</c>'s
/// algorithm against <see cref="ChargenTextureChange"/> instead of the
/// wire's <c>CreateObject.TextureChange</c>.
/// </summary>
private static Dictionary<int, Dictionary<uint, uint>>? ResolveSurfaceOverrides(
IDatReaderWriter dats,
IReadOnlyList<MeshRef> parts,
IReadOnlyList<ChargenTextureChange> textureChanges)
{
if (textureChanges.Count == 0)
return null;
var oldToNewByPart = new Dictionary<int, Dictionary<uint, uint>>();
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<int, Dictionary<uint, uint>>();
for (int partIndex = 0; partIndex < parts.Count; partIndex++)
{
if (!oldToNewByPart.TryGetValue(partIndex, out var oldToNew))
continue;
GfxObj? gfx = dats.Get<GfxObj>(parts[partIndex].GfxObjId);
if (gfx is null)
continue;
Dictionary<uint, uint>? resolved = null;
foreach (var surfaceQid in gfx.Surfaces)
{
uint surfaceId = (uint)surfaceQid;
Surface? surface = dats.Get<Surface>(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;
}
}