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>
572 lines
27 KiB
C#
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;
|
|
}
|
|
}
|