feat(chargen): Campaign CC slice CC6b-PRE — idle loop, rotation, zoom (mount-independent half)
Idle animation loop: decomp re-read of gmCGAppearancePage::Update's trailing StartAnimation/StopAnimation gate (~0x0047EF01-0x0047EF12) plus the ctor evidence that m_bZoomedIn is a decompiler-elided bool (never explicitly set away from its zero default, unlike its two sibling bools) establishes that retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose CC6a shipped as a deliberate simplification (TS-83) — the rest pose only appears once Zoom In fires. New Core primitive RetailAnimationCyclePlayback ports CPhysicsObj::set_sequence_animation's advance-with-wrap + lerp/slerp effect (the same algorithm LiveEntityAnimationPresenter's legacy NPC-idle branch already carries inline; not consolidated this round — out of blast radius for a preview-only feature, noted in the new type's own doc). New ChargenPreviewAnimator drives the per-tick swap; ChargenPreviewEntityBuilder gained TryBuildAnimated alongside the byte-behavior-unchanged TryBuild. Olthoi/OlthoiAcid use the SAME enum key for idle and rest DIDs (decomp-confirmed quirk). TS-83 retired in the register (§4 count 50->49). Rotation controller: ChargenPreviewRotationController ports Rotate/DoRotation (0x0047CB50/0x0047CA80) verbatim — toggle-to-stop, deltaDegrees = ((now-last)/RotationSecondsPerRevolution)*360, single-pass +-360 clamp (not a full modulo, matching retail's own tail), the -1.0 invalidation sentinel. Applies to the entity's heading via the existing MoveToMath.SetHeading port, not the camera, confirming CC6a's own note. Zoom tween: ChargenPreviewZoomController ports ZoomIn/ZoomOut/ DoZoomAnimation (0x0047CF00/0x0047D050/0x0047C960) — a LINEAR 0.6s tween (no easing curve in the decomp) between the already-recorded camera eye profiles, calling into the animator's zoom swap IMMEDIATELY at button-press time, matching retail's call order exactly. m_alternateSetupID (research correction): re-reading the decomp function-by-function found all five m_alternateSetupID write sites — including the two the CC6a review cited — belong to gmBarberUI (the post-creation barber shop), not gmCGAppearancePage, which has no m_pOption1Checkbox-equivalent field and never writes the field. For character creation the field is always INVALID_DID in retail. TryCompose still gained a real, decomp-cited alternateSetupIdOverride parameter (default no-op) implementing gmCG3DView::Update's generic override precedence, for a future non-chargen consumer. RetailHeldPose extraction: shared ResolvePoseDid/ComposePartTransform between RetailPaperdollPoseApplicator and ChargenPreviewEntityBuilder — a clean mechanical extraction, behavior-identical on the paperdoll side. Bookkeeping: CC6a ledger row now cites its real commit SHAs (55bfd9ca,1774d8b2); new CC6b-PRE ledger row records scope done + the page-mount half still owed. Tests: RetailAnimationCyclePlaybackTests (10, Core), ChargenAppearanceFactoryTests (+4), ChargenPreviewRotationControllerTests (9), ChargenPreviewZoomControllerTests (7), ChargenPreviewAnimatorTests (7, hand-built fixtures), ChargenPreviewEntityBuilderTests (+5, installed-DAT). Core.Tests 4786/1 skip, Content.Tests 147/0, App.Tests 5149/6 skips — zero failures, full solution Release build green. One pre-existing, unrelated flake noted: Core.Net.Tests' NakEmissionTests loss soak failed once in the full-suite run, passed 1/1 isolated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1774d8b298
commit
8dfee1118f
18 changed files with 1657 additions and 108 deletions
135
src/AcDream.App/Rendering/ChargenPreviewAnimator.cs
Normal file
135
src/AcDream.App/Rendering/ChargenPreviewAnimator.cs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the chargen preview's per-frame idle-loop ↔ rest-pose playback,
|
||||
/// mirroring <c>gmCG3DView::StartAnimation</c>/<c>StopAnimation</c>'s swap
|
||||
/// (<c>0x004EE600</c>/<c>0x004EE640</c>) and
|
||||
/// <c>gmCGAppearancePage::ZoomIn</c>/<c>ZoomOut</c>'s immediate call into it
|
||||
/// (<c>0x0047D024</c>/<c>0x0047D160</c> — the swap happens the instant the
|
||||
/// button is pressed, NOT once the camera's own 0.6s tween finishes).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Retail default is idle-PLAYING, not frozen</b> — see
|
||||
/// <see cref="ChargenPreviewEntityBuilder"/>'s class doc for the decomp
|
||||
/// citations. This class's own default (<see cref="IsZoomedIn"/> starts
|
||||
/// <c>false</c>) reproduces that: its constructor immediately plays the
|
||||
/// idle animation's frame 0 when one resolved, matching
|
||||
/// <c>gmCGAppearancePage::Update</c>'s own trailing
|
||||
/// <c>if (m_bZoomedIn == 0) StartAnimation()</c> gate
|
||||
/// (~0x0047EF01-0x0047EF12), which re-fires on every heritage/gender/
|
||||
/// appearance change too — <see cref="SetZoomedIn"/> restarts the idle loop
|
||||
/// at frame 0 on every transition INTO the playing state for the same
|
||||
/// reason: <c>set_sequence_animation</c>'s <c>arg3=1</c> clears the sequence
|
||||
/// before appending, so every <c>StartAnimation</c> call restarts the clip.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The page-mount half (CC6b, after CC4 merges) wires the Zoom In/Out
|
||||
/// buttons to <see cref="SetZoomedIn"/> and the render loop to
|
||||
/// <see cref="Tick"/>; nothing in this repository calls either yet.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class ChargenPreviewAnimator
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>gmCG3DView::StartAnimation</c>'s literal framerate argument
|
||||
/// (<c>set_sequence_animation(this->m_pPlayerObject,
|
||||
/// this->m_didAnimation.id, 1, 0, 30f)</c>, pseudo-C ~0x004ee61b).
|
||||
/// </summary>
|
||||
public const float IdleFramerate = 30f;
|
||||
|
||||
private readonly ChargenPreviewAnimatedBuild _build;
|
||||
private float _currFrame;
|
||||
private bool _zoomedIn;
|
||||
|
||||
public ChargenPreviewAnimator(ChargenPreviewAnimatedBuild build)
|
||||
{
|
||||
_build = build ?? throw new ArgumentNullException(nameof(build));
|
||||
_currFrame = build.IdleLowFrame;
|
||||
if (build.IdleAnimation is not null)
|
||||
ApplyIdleFrame(); // retail's true default: idle playing, frame 0.
|
||||
// Else: Entity.MeshRefs already holds RestMeshRefs (set by
|
||||
// TryBuildAnimated) as the best available fallback.
|
||||
}
|
||||
|
||||
/// <summary>The live preview entity — mutated in place by <see cref="Tick"/>
|
||||
/// and <see cref="SetZoomedIn"/>; the renderer never needs to re-call
|
||||
/// <c>SetPreview</c> after the first assignment (<c>WorldEntity.MeshRefs</c>
|
||||
/// is read fresh every draw — see its own doc comment).</summary>
|
||||
public WorldEntity Entity => _build.Entity;
|
||||
|
||||
public bool IsZoomedIn => _zoomedIn;
|
||||
|
||||
/// <summary>
|
||||
/// <c>gmCGAppearancePage::ZoomIn</c>/<c>ZoomOut</c>'s
|
||||
/// <c>StopAnimation</c>/<c>StartAnimation</c> call, applied immediately
|
||||
/// (retail does not wait for the camera tween to finish before swapping
|
||||
/// animation state — see this class's own doc comment). No-op if
|
||||
/// already in the requested state, matching retail's own early-return
|
||||
/// guards (<c>ZoomIn</c>'s <c>if (m_bZoomedIn != 0) return</c>,
|
||||
/// <c>ZoomOut</c>'s mirror).
|
||||
/// </summary>
|
||||
public void SetZoomedIn(bool zoomedIn)
|
||||
{
|
||||
if (_zoomedIn == zoomedIn)
|
||||
return;
|
||||
_zoomedIn = zoomedIn;
|
||||
if (zoomedIn)
|
||||
{
|
||||
_build.Entity.MeshRefs = _build.RestMeshRefs;
|
||||
}
|
||||
else
|
||||
{
|
||||
_currFrame = _build.IdleLowFrame;
|
||||
if (_build.IdleAnimation is not null)
|
||||
ApplyIdleFrame();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances the idle loop by <paramref name="elapsedSeconds"/>. No-op
|
||||
/// while zoomed in (the rest pose is frozen — retail's framerate-0
|
||||
/// <c>set_sequence_animation</c> call never advances) or when no idle
|
||||
/// Animation resolved (heritage/DID gap; the entity keeps whatever pose
|
||||
/// the constructor seeded).
|
||||
/// </summary>
|
||||
public void Tick(float elapsedSeconds)
|
||||
{
|
||||
if (_zoomedIn || _build.IdleAnimation is null || elapsedSeconds <= 0f)
|
||||
return;
|
||||
|
||||
_currFrame = RetailAnimationCyclePlayback.Advance(
|
||||
_currFrame, _build.IdleLowFrame, _build.IdleHighFrame, IdleFramerate, elapsedSeconds);
|
||||
ApplyIdleFrame();
|
||||
}
|
||||
|
||||
private void ApplyIdleFrame()
|
||||
{
|
||||
DatReaderWriter.DBObjs.Animation animation = _build.IdleAnimation!;
|
||||
IReadOnlyList<ChargenPreviewDrawablePart> parts = _build.DrawableParts;
|
||||
var meshRefs = new List<MeshRef>(parts.Count);
|
||||
foreach (ChargenPreviewDrawablePart part in parts)
|
||||
{
|
||||
bool resolved = RetailAnimationCyclePlayback.TryInterpolatePart(
|
||||
animation, _currFrame, _build.IdleLowFrame, _build.IdleHighFrame,
|
||||
part.SetupPartIndex, out Vector3 origin, out Quaternion orientation);
|
||||
// Same defensive default as ApplyHeldPoseTransforms: a part
|
||||
// index the bracketing frame doesn't cover (a Setup/Animation
|
||||
// part-count mismatch, never expected in practice) keeps
|
||||
// identity rather than a degenerate zero quaternion.
|
||||
if (!resolved)
|
||||
{
|
||||
origin = Vector3.Zero;
|
||||
orientation = Quaternion.Identity;
|
||||
}
|
||||
Matrix4x4 transform = RetailHeldPose.ComposePartTransform(part.DefaultScale, origin, orientation);
|
||||
meshRefs.Add(new MeshRef(part.GfxObjId, transform) { SurfaceOverrides = part.SurfaceOverrides });
|
||||
}
|
||||
_build.Entity.MeshRefs = meshRefs;
|
||||
}
|
||||
}
|
||||
|
|
@ -25,10 +25,15 @@ namespace AcDream.App.Rendering;
|
|||
/// button (<c>gmCGAppearancePage::DoRotation @ 0x0047CA80</c>) advances a
|
||||
/// HEADING applied to the preview CHARACTER (<c>CPhysicsObj::set_heading</c>
|
||||
/// inside <c>gmCG3DView::Update</c>, pseudo-C ~242088) — the camera's own
|
||||
/// position/direction never change during a rotation. CC6b's heading
|
||||
/// parameter therefore belongs on the entity builder
|
||||
/// (<see cref="ChargenPreviewEntityBuilder"/>), not here; this class stays a
|
||||
/// fixed-per-heritage eye, exactly like retail's own camera.
|
||||
/// position/direction never change during a rotation. The heading itself
|
||||
/// lives on <see cref="ChargenPreviewRotationController"/> (CC6b: the
|
||||
/// <c>DoRotation</c>/<c>Rotate</c> port) and is applied to the entity via
|
||||
/// <c>ChargenPreviewEntityBuilder.TryBuild</c>/<c>TryBuildAnimated</c>'s
|
||||
/// <c>heading</c> parameter, not here; this class stays a fixed-per-heritage
|
||||
/// eye, exactly like retail's own camera. <see cref="ChargenPreviewZoomController"/>
|
||||
/// (CC6b: the <c>ZoomIn</c>/<c>ZoomOut</c>/<c>DoZoomAnimation</c> port) DOES
|
||||
/// mutate this class's <see cref="Eye"/> — zoom is a camera concern, unlike
|
||||
/// rotation.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ChargenPreviewCamera : ICamera
|
||||
|
|
|
|||
|
|
@ -10,7 +10,50 @@ using DatReaderWriter.DBObjs;
|
|||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the static-pose chargen preview <see cref="WorldEntity"/> from a
|
||||
/// 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
|
||||
|
|
@ -20,7 +63,30 @@ namespace AcDream.App.Rendering;
|
|||
/// 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-animated, non-collision preview scene needs.
|
||||
/// 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 field is never
|
||||
/// explicitly initialized away from its zero-initialized default in the
|
||||
/// ctor (<c>gmCGAppearancePage::gmCGAppearancePage</c>, pseudo-C
|
||||
/// ~0x0047CD58-0x0047CD64 — <c>m_bShouldZoomAnimate</c>/<c>m_bRotating</c>/
|
||||
/// <c>m_bZoomedIn</c> are three consecutive bool bytes the decompiler shows
|
||||
/// only the first two of, a known decompiler-elision class per
|
||||
/// <c>claude-memory/feedback_bn_decomp_field_names.md</c>). 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
|
||||
{
|
||||
|
|
@ -37,8 +103,8 @@ internal static class ChargenPreviewEntityBuilder
|
|||
public const uint PreviewRenderId = 0xDA11_D032u;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's held-pose animation DID enum key, resolved through master
|
||||
/// map slot 7 exactly like <c>RetailPaperdollPoseApplicator.ResolvePoseDid</c>
|
||||
/// 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
|
||||
|
|
@ -55,10 +121,35 @@ internal static class ChargenPreviewEntityBuilder
|
|||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the 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").
|
||||
/// 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 — a thin wrapper over
|
||||
/// <see cref="TryBuildAnimated"/> that keeps this method's existing
|
||||
/// callers' behavior byte-identical. 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.
|
||||
|
|
@ -79,21 +170,48 @@ internal static class ChargenPreviewEntityBuilder
|
|||
uint heritageId,
|
||||
Quaternion heading,
|
||||
object datLock)
|
||||
{
|
||||
ChargenPreviewAnimatedBuild? build = TryBuildAnimated(
|
||||
dats, animations, appearance, heritageId, heading, datLock);
|
||||
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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
ArgumentNullException.ThrowIfNull(animations);
|
||||
ArgumentNullException.ThrowIfNull(appearance);
|
||||
ArgumentNullException.ThrowIfNull(datLock);
|
||||
|
||||
List<MeshRef> meshRefs;
|
||||
uint setupId = appearance.SetupId;
|
||||
PaletteOverride? paletteOverride;
|
||||
PartOverride[] partOverrides;
|
||||
List<ChargenPreviewDrawablePart> drawableParts;
|
||||
List<MeshRef> restMeshRefs;
|
||||
Animation? idleAnimation;
|
||||
int idleLowFrame = 0, idleHighFrame = -1;
|
||||
|
||||
// Every dat read this method performs — the Setup fetch, the held-
|
||||
// pose animation resolution, the per-part GfxObj drawable checks,
|
||||
// and the texture-change surface resolution — happens inside this
|
||||
// one lock, mirroring RetailPaperdollPoseApplicator.Apply's "resolve
|
||||
// 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)
|
||||
{
|
||||
|
|
@ -109,12 +227,16 @@ internal static class ChargenPreviewEntityBuilder
|
|||
flattened[change.PartIndex] = new MeshRef(change.PartId, flattened[change.PartIndex].PartTransform);
|
||||
}
|
||||
|
||||
ApplyHeldPose(dats, animations, setup, heritageId, flattened);
|
||||
// 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);
|
||||
|
||||
meshRefs = new List<MeshRef>(flattened.Count);
|
||||
drawableParts = new List<ChargenPreviewDrawablePart>(flattened.Count);
|
||||
restMeshRefs = new List<MeshRef>(flattened.Count);
|
||||
for (int partIndex = 0; partIndex < flattened.Count; partIndex++)
|
||||
{
|
||||
MeshRef part = flattened[partIndex];
|
||||
|
|
@ -125,27 +247,52 @@ internal static class ChargenPreviewEntityBuilder
|
|||
if (surfaceOverrides is not null && surfaceOverrides.TryGetValue(partIndex, out var perPart))
|
||||
overrides = perPart;
|
||||
|
||||
meshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides });
|
||||
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 (meshRefs.Count == 0)
|
||||
if (drawableParts.Count == 0)
|
||||
return null;
|
||||
|
||||
paletteOverride = BuildPaletteOverride(appearance);
|
||||
partOverrides = BuildPartOverrides(appearance);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
return new WorldEntity
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = PreviewRenderId,
|
||||
ServerGuid = PreviewServerGuid,
|
||||
SourceGfxObjOrSetupId = setupId,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = heading,
|
||||
MeshRefs = meshRefs,
|
||||
PaletteOverride = paletteOverride,
|
||||
PartOverrides = partOverrides,
|
||||
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>No dat access — pure projection of the already-composed
|
||||
|
|
@ -178,8 +325,8 @@ internal static class ChargenPreviewEntityBuilder
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overwrites every part's transform from the resolved rest pose's
|
||||
/// FINAL frame — same "hold the settled last frame at zero frame rate"
|
||||
/// 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
|
||||
|
|
@ -187,14 +334,14 @@ internal static class ChargenPreviewEntityBuilder
|
|||
/// GfxObj. No-ops (keeps the default placement frame) when the pose
|
||||
/// DID or its animation can't be resolved.
|
||||
/// </summary>
|
||||
private static void ApplyHeldPose(
|
||||
private static void ApplyHeldPoseTransforms(
|
||||
IDatReaderWriter dats,
|
||||
IAnimationLoader animations,
|
||||
Setup setup,
|
||||
uint heritageId,
|
||||
uint poseEnum,
|
||||
List<MeshRef> flattened)
|
||||
{
|
||||
uint poseDid = ResolvePoseDid(dats, ResolveRestPoseEnum(heritageId));
|
||||
uint poseDid = RetailHeldPose.ResolvePoseDid(dats, poseEnum);
|
||||
if ((poseDid >> 24) != 0x03u)
|
||||
return;
|
||||
|
||||
|
|
@ -214,32 +361,12 @@ internal static class ChargenPreviewEntityBuilder
|
|||
orientation = frame.Frames[index].Orientation;
|
||||
}
|
||||
|
||||
Matrix4x4 transform = Matrix4x4.CreateScale(scale)
|
||||
* Matrix4x4.CreateFromQuaternion(orientation)
|
||||
* Matrix4x4.CreateTranslation(origin);
|
||||
flattened[index] = new MeshRef(flattened[index].GfxObjId, transform);
|
||||
flattened[index] = new MeshRef(
|
||||
flattened[index].GfxObjId,
|
||||
RetailHeldPose.ComposePartTransform(scale, origin, orientation));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>DBCache::GetDIDFromEnumStatic(poseEnum, 7)</c> equivalent — verbatim
|
||||
/// port of <c>RetailPaperdollPoseApplicator.ResolvePoseDid</c>,
|
||||
/// parameterized by the target enum key.
|
||||
/// </summary>
|
||||
private static uint ResolvePoseDid(IDatReaderWriter dats, uint poseEnum)
|
||||
{
|
||||
uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId;
|
||||
if (masterDid == 0
|
||||
|| !dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(masterDid, out var master)
|
||||
|| !master.ClientEnumToID.TryGetValue(7u, out uint subDid)
|
||||
|| !dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(subDid, out var sub))
|
||||
{
|
||||
return 0u;
|
||||
}
|
||||
|
||||
return sub.ClientEnumToID.TryGetValue(poseEnum, out uint did) ? did : 0u;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Part-index → (old texture id → new texture id) resolution, verbatim
|
||||
/// port of <c>DatLiveEntityProjectionMaterializer.ResolveSurfaceOverrides</c>'s
|
||||
|
|
|
|||
|
|
@ -14,22 +14,32 @@ namespace AcDream.App.Rendering;
|
|||
/// paperdoll's fixed one.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>NOT wired here (CC6b, after CC4 merges per the campaign's parallelism
|
||||
/// contract):</b> mounting into the authored Appearance/Summary viewport ids
|
||||
/// (<c>0x100003bb</c> / <c>0x10000406</c>), spin/color-wheel controls, and
|
||||
/// the rotate/zoom buttons. This class is a standalone, composition-root-
|
||||
/// agnostic renderer — nothing in <c>AcDream.App/UI/Layout/</c> or
|
||||
/// <c>RetailUiRuntime.cs</c> references it yet.
|
||||
/// <b>NOT wired here (CC6b page-mount half, after CC4 merges per the
|
||||
/// campaign's parallelism contract):</b> mounting into the authored
|
||||
/// Appearance/Summary viewport ids (<c>0x100003bb</c> / <c>0x10000406</c>)
|
||||
/// and binding the spin/color-wheel/rotate/zoom widgets to
|
||||
/// <see cref="ChargenPreviewAnimator"/>/<see cref="ChargenPreviewRotationController"/>/
|
||||
/// <see cref="ChargenPreviewZoomController"/>. This class is a standalone,
|
||||
/// composition-root-agnostic renderer — nothing in
|
||||
/// <c>AcDream.App/UI/Layout/</c> or <c>RetailUiRuntime.cs</c> references it
|
||||
/// yet.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Register row (staged deviation, retired by CC6b):</b> retail plays a
|
||||
/// live 30fps idle loop in the preview
|
||||
/// (<c>gmCG3DView</c>'s <c>m_didAnimation</c>/<c>m_didAnimArray</c>,
|
||||
/// <c>set_sequence_animation</c>, distinct from the STATIC
|
||||
/// <c>m_didAnimationRest</c> this class's entity builder uses). CC6a holds
|
||||
/// the static rest-pose final frame only — see
|
||||
/// <c>docs/architecture/retail-divergence-register.md</c>.
|
||||
/// <b>CC6b (pre-mount half):</b> the preview now HAS a real live idle loop
|
||||
/// (<see cref="ChargenPreviewAnimator"/>, retail's <c>m_didAnimation</c> DID
|
||||
/// at 30fps via <c>set_sequence_animation</c>) instead of the CC6a-only held
|
||||
/// rest pose — TS-83 is retired. <see cref="SetPreview"/> still accepts a
|
||||
/// static <c>WorldEntity</c> for callers that only want
|
||||
/// <c>ChargenPreviewEntityBuilder.TryBuild</c>'s unchanged rest-pose
|
||||
/// snapshot; a caller that wants the animated preview constructs a
|
||||
/// <see cref="ChargenPreviewAnimator"/> from
|
||||
/// <c>ChargenPreviewEntityBuilder.TryBuildAnimated</c> and passes its
|
||||
/// <c>Entity</c> here once — the animator mutates that SAME entity's
|
||||
/// <c>MeshRefs</c> in place every <c>Tick</c>, and <c>Render</c> reads it
|
||||
/// fresh (no re-<c>SetPreview</c> needed per frame; see
|
||||
/// <c>WorldEntity.MeshRefs</c>'s own "mutable so the animation tick can
|
||||
/// replace it each frame" doc comment).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class ChargenPreviewRenderer :
|
||||
|
|
|
|||
114
src/AcDream.App/Rendering/ChargenPreviewRotationController.cs
Normal file
114
src/AcDream.App/Rendering/ChargenPreviewRotationController.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's toggle direction enum
|
||||
/// (<c>gmBarberUI::ERotateDirection</c>/<c>gmCGAppearancePage::ERotateDirection</c>
|
||||
/// typedef alias, <c>acclient.h:6848-6852,6960</c>): <c>Invalid=0</c>,
|
||||
/// <c>Clockwise=1</c>, <c>CounterClockwise=2</c>.
|
||||
/// </summary>
|
||||
internal enum ChargenRotateDirection
|
||||
{
|
||||
Invalid = 0,
|
||||
Clockwise = 1,
|
||||
CounterClockwise = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Presentation-free port of <c>gmCGAppearancePage::Rotate</c>
|
||||
/// (<c>0x0047CB50</c>) + <c>DoRotation</c> (<c>0x0047CA80</c>) — the
|
||||
/// button-toggled continuous rotation retail applies to the preview
|
||||
/// CHARACTER's heading (<c>CPhysicsObj::set_heading</c> inside
|
||||
/// <c>gmCG3DView::Update</c>, pseudo-C ~0x0047eecf1), not the camera (see
|
||||
/// <see cref="ChargenPreviewCamera"/>'s own doc comment on why rotation
|
||||
/// lives here instead). Retail drives <see cref="Tick"/> once per frame from
|
||||
/// a global-message-3 tick while <see cref="IsRotating"/> is set
|
||||
/// (<c>gmCGAppearancePage::ListenToGlobalMessage @ 0x0047CED0</c>); the
|
||||
/// CC6b page-mount half will bind the Rotate Clockwise/Counter-Clockwise
|
||||
/// buttons to <see cref="Toggle"/> and the render loop to <see cref="Tick"/>.
|
||||
/// </summary>
|
||||
internal sealed class ChargenPreviewRotationController
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>Rotate</c>'s explicit sentinel write
|
||||
/// (<c>this->m_dLastRotateTime = -1.0</c>, pseudo-C ~0x0047cba7/0x0047cbb1
|
||||
/// — the high dword <c>0xbff00000</c> paired with a zero low dword is the
|
||||
/// exact IEEE-754 bit pattern for <c>-1.0</c>) — invalidates the
|
||||
/// timestamp so the very next <see cref="Tick"/> resets it to "now"
|
||||
/// (a zero-length first delta) instead of computing a huge jump from a
|
||||
/// stale or never-set value.
|
||||
/// </summary>
|
||||
private const double InvalidTimeSentinel = -1.0;
|
||||
|
||||
private double _lastRotateTime = InvalidTimeSentinel;
|
||||
private ChargenRotateDirection _direction = ChargenRotateDirection.Invalid;
|
||||
private bool _rotating;
|
||||
|
||||
public bool IsRotating => _rotating;
|
||||
public ChargenRotateDirection Direction => _direction;
|
||||
|
||||
/// <summary>Retail's <c>m_fCurHeading</c>, degrees, ctor default 0 —
|
||||
/// applied to the preview entity via <c>MoveToMath.SetHeading</c>
|
||||
/// (<c>CPhysicsObj::set_heading</c>'s exact port).</summary>
|
||||
public float HeadingDegrees { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>gmCGAppearancePage::Rotate @ 0x0047CB50</c>: pressing the SAME
|
||||
/// direction a second time while already rotating STOPS rotation
|
||||
/// (retail's button-toggle UX); any other press (opposite direction, or
|
||||
/// starting from stopped) sets that direction and (re)starts,
|
||||
/// invalidating <c>m_dLastRotateTime</c> per this class's own sentinel
|
||||
/// doc.
|
||||
/// </summary>
|
||||
public void Toggle(ChargenRotateDirection direction)
|
||||
{
|
||||
if (_rotating && direction == _direction)
|
||||
{
|
||||
_rotating = false;
|
||||
return;
|
||||
}
|
||||
_direction = direction;
|
||||
_lastRotateTime = InvalidTimeSentinel;
|
||||
_rotating = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>gmCGAppearancePage::DoRotation @ 0x0047CA80</c>: per-tick
|
||||
/// <c>deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution)
|
||||
/// * 360</c>, added for <see cref="ChargenRotateDirection.Clockwise"/>
|
||||
/// and subtracted for every other direction (pseudo-C ~0x0047cacd:
|
||||
/// <c>if (m_eRotateDir != ECG_ROTATE_CLOCKWISE) heading -= delta; else
|
||||
/// heading += delta;</c>), then a SINGLE-PASS clamp back into
|
||||
/// <c>[0, 360)</c> — not a full modulo loop; retail's own tail only
|
||||
/// adds/subtracts 360 once (pseudo-C ~0x0047caf3-0x0047cb31), which is
|
||||
/// exactly enough for any realistic per-frame delta and is reproduced
|
||||
/// here verbatim rather than "improved" into a `%=`.
|
||||
/// </summary>
|
||||
public void Tick(double now)
|
||||
{
|
||||
if (!_rotating)
|
||||
return;
|
||||
if (_lastRotateTime <= 0d)
|
||||
_lastRotateTime = now;
|
||||
|
||||
double deltaDegrees = ((now - _lastRotateTime) / ChargenPreviewCamera.RotationSecondsPerRevolution) * 360.0;
|
||||
HeadingDegrees = _direction == ChargenRotateDirection.Clockwise
|
||||
? HeadingDegrees + (float)deltaDegrees
|
||||
: HeadingDegrees - (float)deltaDegrees;
|
||||
|
||||
if (HeadingDegrees < 0f)
|
||||
HeadingDegrees += 360f;
|
||||
if (HeadingDegrees > 360f)
|
||||
HeadingDegrees -= 360f;
|
||||
|
||||
_lastRotateTime = now;
|
||||
}
|
||||
|
||||
/// <summary><c>CPhysicsObj::set_heading</c>'s exact quaternion
|
||||
/// construction — the SAME shared Core primitive retail movement already
|
||||
/// ports (<see cref="MoveToMath.SetHeading"/>).</summary>
|
||||
public Quaternion ToOrientation() =>
|
||||
MoveToMath.SetHeading(Quaternion.Identity, HeadingDegrees);
|
||||
}
|
||||
135
src/AcDream.App/Rendering/ChargenPreviewZoomController.cs
Normal file
135
src/AcDream.App/Rendering/ChargenPreviewZoomController.cs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Presentation-free port of <c>gmCGAppearancePage::ZoomIn</c>/<c>ZoomOut</c>
|
||||
/// (<c>0x0047CF00</c>/<c>0x0047D050</c>) and <c>DoZoomAnimation</c>
|
||||
/// (<c>0x0047C960</c>): a linear 0.6s tween of the preview camera's eye
|
||||
/// between <see cref="ChargenPreviewCamera.ResolveDefaultEye"/> (zoomed IN)
|
||||
/// and <see cref="ChargenPreviewCamera.ResolveZoomedOutEye"/> (zoomed OUT),
|
||||
/// driving the SAME <see cref="ChargenPreviewAnimator"/> zoom-state swap the
|
||||
/// button presses trigger in retail — immediately, not once the tween
|
||||
/// finishes (see <see cref="ChargenPreviewAnimator"/>'s own doc comment).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail drives <see cref="Tick"/> once per frame from a global-message-3
|
||||
/// tick while <c>m_bShouldZoomAnimate</c> is set
|
||||
/// (<c>gmCGAppearancePage::ListenToGlobalMessage @ 0x0047CED0</c>); the
|
||||
/// CC6b page-mount half will bind the Zoom In/Out buttons to
|
||||
/// <see cref="ZoomIn"/>/<see cref="ZoomOut"/> and the render loop to
|
||||
/// <see cref="Tick"/>. Direction is always <c>(0,0,0)</c> for this camera
|
||||
/// (see <see cref="ChargenPreviewCamera"/>'s own remarks), so only the eye
|
||||
/// position tweens — retail's own <c>m_vectCurDirection</c> lerp is a no-op
|
||||
/// here and is not reproduced.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class ChargenPreviewZoomController
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>ZoomIn</c>/<c>ZoomOut</c>'s explicit invalidation write
|
||||
/// (<c>this->m_dAnimDuration = -0.1</c>, pseudo-C ~0x0047cff1/0x0047cffb
|
||||
/// and ~0x0047d12c/0x0047d136 — the exact IEEE-754 bit pattern for
|
||||
/// <c>-0.1</c>) so the very next <see cref="Tick"/> resets the duration
|
||||
/// to <see cref="ChargenPreviewCamera.ZoomTweenDurationSeconds"/> and the
|
||||
/// start time to "now", matching <c>DoZoomAnimation</c>'s own
|
||||
/// reset-if-invalid guard exactly.
|
||||
/// </summary>
|
||||
private const double InvalidDurationSentinel = -0.1;
|
||||
|
||||
private readonly uint _heritageId;
|
||||
private Vector3 _startEye;
|
||||
private Vector3 _targetEye;
|
||||
private double _animStartTime;
|
||||
private double _animDuration;
|
||||
private bool _shouldAnimate;
|
||||
private bool _zoomedIn;
|
||||
|
||||
public ChargenPreviewZoomController(uint heritageId, ChargenPreviewCamera camera)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(camera);
|
||||
_heritageId = heritageId;
|
||||
Camera = camera;
|
||||
}
|
||||
|
||||
public ChargenPreviewCamera Camera { get; }
|
||||
|
||||
/// <summary>Mirrors retail's <c>m_bZoomedIn</c> — false (not zoomed in)
|
||||
/// is the ctor-implicit default, matching <see cref="ChargenPreviewAnimator"/>'s
|
||||
/// own default (see that class's doc comment for the shared citation).</summary>
|
||||
public bool IsZoomedIn => _zoomedIn;
|
||||
|
||||
/// <summary>
|
||||
/// <c>gmCGAppearancePage::ZoomIn @ 0x0047CF00</c>: no-op if already
|
||||
/// zoomed in (retail's own early-return guard). Otherwise starts a tween
|
||||
/// from the camera's CURRENT eye to the default (zoomed-IN) per-heritage
|
||||
/// profile and swaps <paramref name="animator"/> to the frozen rest
|
||||
/// pose IMMEDIATELY (<c>gmCG3DView::StopAnimation</c>'s call site,
|
||||
/// pseudo-C ~0x0047d024, precedes the tween's own completion by
|
||||
/// definition — it runs once, synchronously, inside <c>ZoomIn</c>
|
||||
/// itself).
|
||||
/// </summary>
|
||||
public void ZoomIn(ChargenPreviewAnimator? animator)
|
||||
{
|
||||
if (_zoomedIn)
|
||||
return;
|
||||
StartTween(ChargenPreviewCamera.ResolveDefaultEye(_heritageId));
|
||||
_zoomedIn = true;
|
||||
animator?.SetZoomedIn(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>gmCGAppearancePage::ZoomOut @ 0x0047D050</c>: no-op if not
|
||||
/// currently zoomed in. Otherwise starts a tween toward the zoomed-OUT
|
||||
/// per-heritage profile and swaps <paramref name="animator"/> back to
|
||||
/// the playing idle loop immediately, mirroring <see cref="ZoomIn"/>.
|
||||
/// </summary>
|
||||
public void ZoomOut(ChargenPreviewAnimator? animator)
|
||||
{
|
||||
if (!_zoomedIn)
|
||||
return;
|
||||
StartTween(ChargenPreviewCamera.ResolveZoomedOutEye(_heritageId));
|
||||
_zoomedIn = false;
|
||||
animator?.SetZoomedIn(false);
|
||||
}
|
||||
|
||||
private void StartTween(Vector3 targetEye)
|
||||
{
|
||||
_startEye = Camera.Eye;
|
||||
_targetEye = targetEye;
|
||||
_shouldAnimate = true;
|
||||
_animDuration = InvalidDurationSentinel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>gmCGAppearancePage::DoZoomAnimation @ 0x0047C960</c>: a LINEAR
|
||||
/// (not eased) lerp of the eye position from <c>m_vectStartPosition</c>
|
||||
/// to <c>m_vectTargPosition</c> over
|
||||
/// <see cref="ChargenPreviewCamera.ZoomTweenDurationSeconds"/>, clamping
|
||||
/// <c>t</c> to exactly 1.0 (and clearing <c>m_bShouldZoomAnimate</c>) the
|
||||
/// tick that reaches or passes the duration — the decomp shows a
|
||||
/// straight <c>(targ - start) * t + start</c> per axis with no easing
|
||||
/// curve applied anywhere in this function.
|
||||
/// </summary>
|
||||
public void Tick(double now)
|
||||
{
|
||||
if (!_shouldAnimate)
|
||||
return;
|
||||
|
||||
if (_animDuration <= 0d)
|
||||
{
|
||||
_animDuration = ChargenPreviewCamera.ZoomTweenDurationSeconds;
|
||||
_animStartTime = now;
|
||||
}
|
||||
|
||||
double elapsed = now - _animStartTime;
|
||||
if (elapsed >= _animDuration)
|
||||
{
|
||||
_shouldAnimate = false;
|
||||
elapsed = _animDuration;
|
||||
}
|
||||
|
||||
float t = (float)(elapsed / _animDuration);
|
||||
Camera.Eye = Vector3.Lerp(_startEye, _targetEye, t);
|
||||
}
|
||||
}
|
||||
|
|
@ -335,29 +335,11 @@ internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator
|
|||
|
||||
/// <summary>
|
||||
/// Retail <c>gmPaperDollUI</c> resolves its held pose with
|
||||
/// <c>DBCache::GetDIDFromEnumStatic(0x10000005, 7)</c>. The master map
|
||||
/// therefore resolves key 7 to a sub-map, then key 0x10000005 to the
|
||||
/// Animation DID.
|
||||
/// <c>DBCache::GetDIDFromEnumStatic(0x10000005, 7)</c> —
|
||||
/// <see cref="RetailHeldPose.ResolvePoseDid"/> parameterized by the
|
||||
/// paperdoll's own fixed enum key.
|
||||
/// </summary>
|
||||
private uint ResolvePoseDid()
|
||||
{
|
||||
uint masterDid = (uint)_dats.Portal.Db.Header.MasterMapId;
|
||||
if (masterDid == 0
|
||||
|| !_dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(
|
||||
masterDid,
|
||||
out var master)
|
||||
|| !master.ClientEnumToID.TryGetValue(7u, out uint subDid)
|
||||
|| !_dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(
|
||||
subDid,
|
||||
out var sub))
|
||||
{
|
||||
return 0u;
|
||||
}
|
||||
|
||||
return sub.ClientEnumToID.TryGetValue(0x10000005u, out uint did)
|
||||
? did
|
||||
: 0u;
|
||||
}
|
||||
private uint ResolvePoseDid() => RetailHeldPose.ResolvePoseDid(_dats, 0x10000005u);
|
||||
|
||||
public void Apply(WorldEntity doll, uint setupId)
|
||||
{
|
||||
|
|
@ -392,9 +374,7 @@ internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator
|
|||
orientation = frame.Frames[index].Orientation;
|
||||
}
|
||||
|
||||
Matrix4x4 transform = Matrix4x4.CreateScale(scale)
|
||||
* Matrix4x4.CreateFromQuaternion(orientation)
|
||||
* Matrix4x4.CreateTranslation(origin);
|
||||
Matrix4x4 transform = RetailHeldPose.ComposePartTransform(scale, origin, orientation);
|
||||
MeshRef source = doll.MeshRefs[index];
|
||||
reposed.Add(new MeshRef(source.GfxObjId, transform)
|
||||
{
|
||||
|
|
|
|||
61
src/AcDream.App/Rendering/RetailHeldPose.cs
Normal file
61
src/AcDream.App/Rendering/RetailHeldPose.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Shared primitives behind retail's "resolve a rest-pose DID via master-map
|
||||
/// slot 7, load its Animation, hold the final frame" algorithm — the
|
||||
/// mechanism <see cref="RetailPaperdollPoseApplicator"/> (paperdoll,
|
||||
/// <c>gmPaperDollUI::RedressCreature @ 0x004A3C22</c>) and
|
||||
/// <see cref="ChargenPreviewEntityBuilder"/> (chargen preview,
|
||||
/// <c>gmCG3DView::StopAnimation @ 0x004EE640</c>) both implement. Extracted
|
||||
/// per the CC6a review's F11/F12 note ("before adding a FOURTH consumer... a
|
||||
/// shared <c>RetailHeldPose</c> helper is worth extracting before a fourth
|
||||
/// held-pose consumer exists") — CC6b's own idle-loop work makes chargen's
|
||||
/// implementation grow enough that mechanically sharing the two primitives
|
||||
/// BOTH sites already had byte-identical (DID resolution, final-frame
|
||||
/// transform composition) is a clean win without forcing the two sites'
|
||||
/// slightly different per-index LOOP shapes (paperdoll walks an
|
||||
/// already-built, already-filtered <c>WorldEntity.MeshRefs</c>; chargen
|
||||
/// walks the pre-filter, Setup-part-indexed scratch list) into one method
|
||||
/// they don't actually share.
|
||||
/// </summary>
|
||||
internal static class RetailHeldPose
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>DBCache::GetDIDFromEnumStatic(poseEnum, 7)</c> equivalent: master
|
||||
/// map → slot 7's sub-map → <paramref name="poseEnum"/>'s Animation DID.
|
||||
/// Returns 0 if any link in the chain is missing. MUST be called under
|
||||
/// the caller's dat lock (see <see cref="ChargenPreviewEntityBuilder.TryBuild"/>'s
|
||||
/// <c>datLock</c> doc — <c>DatCollection</c> is not thread-safe).
|
||||
/// </summary>
|
||||
public static uint ResolvePoseDid(IDatReaderWriter dats, uint poseEnum)
|
||||
{
|
||||
uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId;
|
||||
if (masterDid == 0
|
||||
|| !dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)
|
||||
|| !master.ClientEnumToID.TryGetValue(7u, out uint subDid)
|
||||
|| !dats.Portal.TryGet<EnumIDMap>(subDid, out var sub))
|
||||
{
|
||||
return 0u;
|
||||
}
|
||||
|
||||
return sub.ClientEnumToID.TryGetValue(poseEnum, out uint did) ? did : 0u;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's per-part pose transform: <c>Scale(defaultScale) *
|
||||
/// Rotate(orientation) * Translate(origin)</c> — the SAME composition
|
||||
/// both <c>RetailPaperdollPoseApplicator.Apply</c> and
|
||||
/// <see cref="ChargenPreviewEntityBuilder"/>'s pose steps use, whether
|
||||
/// the (origin, orientation) pair comes from a held final frame or an
|
||||
/// interpolated idle-cycle frame.
|
||||
/// </summary>
|
||||
public static Matrix4x4 ComposePartTransform(Vector3 defaultScale, Vector3 origin, Quaternion orientation) =>
|
||||
Matrix4x4.CreateScale(defaultScale)
|
||||
* Matrix4x4.CreateFromQuaternion(orientation)
|
||||
* Matrix4x4.CreateTranslation(origin);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue