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>
387 lines
13 KiB
C#
387 lines
13 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Input;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.World;
|
|
using AcDream.Content;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
internal interface IPaperdollDollRenderer
|
|
{
|
|
void SetDoll(WorldEntity? doll);
|
|
|
|
uint Render(int width, int height);
|
|
}
|
|
|
|
internal interface IPaperdollFrameView
|
|
{
|
|
bool TryGetVisibleSize(out int width, out int height);
|
|
|
|
void SetTextureHandle(uint textureHandle);
|
|
}
|
|
|
|
internal interface IPaperdollInventoryVisibility
|
|
{
|
|
bool IsVisible { get; }
|
|
}
|
|
|
|
internal interface IPaperdollDollFactory
|
|
{
|
|
bool TryBuild(out WorldEntity? doll);
|
|
}
|
|
|
|
internal interface IPaperdollEntityLookup
|
|
{
|
|
bool TryGet(uint serverGuid, out WorldEntity player);
|
|
}
|
|
|
|
internal interface IPaperdollPoseApplicator
|
|
{
|
|
void Apply(WorldEntity doll, uint setupId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Owns paperdoll dirty/rebuild state and the private render-target
|
|
/// presentation edge. The renderer remains a borrowed resource disposed by
|
|
/// the existing window shutdown transaction.
|
|
/// </summary>
|
|
internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
|
|
{
|
|
private readonly IPaperdollDollRenderer _renderer;
|
|
private readonly IPaperdollFrameView _view;
|
|
private readonly IPaperdollDollFactory _factory;
|
|
private WorldEntity? _doll;
|
|
private bool _dirty = true;
|
|
|
|
public PaperdollFramePresenter(
|
|
IPaperdollDollRenderer renderer,
|
|
IPaperdollFrameView view,
|
|
IPaperdollDollFactory factory)
|
|
{
|
|
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
|
|
_view = view ?? throw new ArgumentNullException(nameof(view));
|
|
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
|
}
|
|
|
|
internal bool IsDirty => _dirty;
|
|
|
|
public void MarkDirty() => _dirty = true;
|
|
|
|
public void Render()
|
|
{
|
|
if (!_view.TryGetVisibleSize(out int width, out int height))
|
|
return;
|
|
|
|
if (_dirty)
|
|
{
|
|
if (_factory.TryBuild(out WorldEntity? doll))
|
|
{
|
|
// Same-generation CreateObject refreshes can repeat the exact
|
|
// player ObjDesc at a portal boundary. Retail redresses its
|
|
// private inventory object in place; releasing and reacquiring
|
|
// an identical synthetic owner briefly blanks the viewport and
|
|
// churns its texture composites.
|
|
if (!HasEquivalentAppearance(_doll, doll))
|
|
{
|
|
_renderer.SetDoll(doll);
|
|
_doll = doll;
|
|
}
|
|
_dirty = false;
|
|
}
|
|
else
|
|
{
|
|
// gmPaperDollUI::RedressCreature @ 0x004A3BC0 leaves its
|
|
// private m_pInventoryObject intact when the SmartBox player
|
|
// is temporarily unavailable. Keep the successful doll and
|
|
// retry this dirty redress on the next visible frame.
|
|
}
|
|
}
|
|
|
|
_view.SetTextureHandle(_renderer.Render(width, height));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears the private object only at the owning character-session
|
|
/// boundary, matching gmPaperDollUI's private-object lifetime.
|
|
/// </summary>
|
|
public void ResetSession()
|
|
{
|
|
_renderer.SetDoll(null);
|
|
_doll = null;
|
|
_dirty = true;
|
|
}
|
|
|
|
private static bool HasEquivalentAppearance(
|
|
WorldEntity? current,
|
|
WorldEntity? candidate)
|
|
{
|
|
if (current is null || candidate is null)
|
|
return ReferenceEquals(current, candidate);
|
|
if (current.SourceGfxObjOrSetupId != candidate.SourceGfxObjOrSetupId
|
|
|| current.Scale != candidate.Scale
|
|
|| current.HiddenPartsMask != candidate.HiddenPartsMask
|
|
|| current.MeshRefs.Count != candidate.MeshRefs.Count
|
|
|| current.PartOverrides.Count != candidate.PartOverrides.Count)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
for (int i = 0; i < current.MeshRefs.Count; i++)
|
|
{
|
|
MeshRef left = current.MeshRefs[i];
|
|
MeshRef right = candidate.MeshRefs[i];
|
|
if (left.GfxObjId != right.GfxObjId
|
|
|| left.PartTransform != right.PartTransform
|
|
|| !DictionaryEquals(
|
|
left.SurfaceOverrides,
|
|
right.SurfaceOverrides))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < current.PartOverrides.Count; i++)
|
|
{
|
|
if (current.PartOverrides[i] != candidate.PartOverrides[i])
|
|
return false;
|
|
}
|
|
|
|
PaletteOverride? leftPalette = current.PaletteOverride;
|
|
PaletteOverride? rightPalette = candidate.PaletteOverride;
|
|
if (leftPalette is null || rightPalette is null)
|
|
return leftPalette is null && rightPalette is null;
|
|
if (leftPalette.BasePaletteId != rightPalette.BasePaletteId
|
|
|| leftPalette.SubPalettes.Count != rightPalette.SubPalettes.Count)
|
|
{
|
|
return false;
|
|
}
|
|
for (int i = 0; i < leftPalette.SubPalettes.Count; i++)
|
|
{
|
|
if (leftPalette.SubPalettes[i] != rightPalette.SubPalettes[i])
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static bool DictionaryEquals(
|
|
IReadOnlyDictionary<uint, uint>? left,
|
|
IReadOnlyDictionary<uint, uint>? right)
|
|
{
|
|
if (left is null || right is null)
|
|
return left is null && right is null;
|
|
if (left.Count != right.Count)
|
|
return false;
|
|
foreach ((uint key, uint value) in left)
|
|
{
|
|
if (!right.TryGetValue(key, out uint rightValue)
|
|
|| rightValue != value)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>Retained-UI visibility and texture publication for the doll view.</summary>
|
|
internal sealed class RetailPaperdollFrameView : IPaperdollFrameView
|
|
{
|
|
private readonly UiViewport _viewport;
|
|
private readonly IPaperdollInventoryVisibility _inventory;
|
|
|
|
public RetailPaperdollFrameView(
|
|
UiViewport viewport,
|
|
IPaperdollInventoryVisibility inventory)
|
|
{
|
|
_viewport = viewport ?? throw new ArgumentNullException(nameof(viewport));
|
|
_inventory = inventory ?? throw new ArgumentNullException(nameof(inventory));
|
|
}
|
|
|
|
public bool TryGetVisibleSize(out int width, out int height)
|
|
{
|
|
width = 0;
|
|
height = 0;
|
|
if (!_viewport.Visible || !_inventory.IsVisible)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
width = (int)_viewport.Width;
|
|
height = (int)_viewport.Height;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6k: the renderer already hands out a
|
|
/// <see cref="UiTextureTableHandle"/>, so this decodes rather than registers.
|
|
/// The §7.1 external-texture seam it used to call is deleted with V4g.
|
|
/// </summary>
|
|
public void SetTextureHandle(uint textureHandle) =>
|
|
_viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle);
|
|
}
|
|
|
|
/// <summary>Narrow visibility adapter for the paperdoll's inventory host.</summary>
|
|
internal sealed class PaperdollInventoryVisibility : IPaperdollInventoryVisibility
|
|
{
|
|
private readonly UiElement _inventoryFrame;
|
|
|
|
public PaperdollInventoryVisibility(UiElement inventoryFrame)
|
|
{
|
|
_inventoryFrame = inventoryFrame
|
|
?? throw new ArgumentNullException(nameof(inventoryFrame));
|
|
}
|
|
|
|
public bool IsVisible => _inventoryFrame.Visible;
|
|
}
|
|
|
|
/// <summary>Canonical live-entity lookup used by the paperdoll factory.</summary>
|
|
internal sealed class LivePaperdollEntityLookup : IPaperdollEntityLookup
|
|
{
|
|
private readonly LiveEntityRuntime _liveEntities;
|
|
|
|
public LivePaperdollEntityLookup(LiveEntityRuntime liveEntities)
|
|
{
|
|
_liveEntities = liveEntities
|
|
?? throw new ArgumentNullException(nameof(liveEntities));
|
|
}
|
|
|
|
public bool TryGet(uint serverGuid, out WorldEntity player) =>
|
|
_liveEntities.TryGetWorldEntity(serverGuid, out player);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the static retail paperdoll clone from the canonical live player
|
|
/// projection and applies the DAT-defined held pose.
|
|
/// </summary>
|
|
internal sealed class RetailPaperdollDollFactory : IPaperdollDollFactory
|
|
{
|
|
private readonly IPaperdollEntityLookup _entities;
|
|
private readonly ILocalPlayerIdentitySource _identity;
|
|
private readonly IPaperdollPoseApplicator _pose;
|
|
|
|
public RetailPaperdollDollFactory(
|
|
IPaperdollEntityLookup entities,
|
|
ILocalPlayerIdentitySource identity,
|
|
IPaperdollPoseApplicator pose)
|
|
{
|
|
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
|
|
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
|
_pose = pose ?? throw new ArgumentNullException(nameof(pose));
|
|
}
|
|
|
|
public bool TryBuild(out WorldEntity? doll)
|
|
{
|
|
doll = null;
|
|
if (!_entities.TryGet(_identity.ServerGuid, out WorldEntity player)
|
|
|| player.MeshRefs.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
uint? basePalette = null;
|
|
List<(uint, byte, byte)>? subPalettes = null;
|
|
if (player.PaletteOverride is { } palette)
|
|
{
|
|
basePalette = palette.BasePaletteId;
|
|
subPalettes = new List<(uint, byte, byte)>();
|
|
foreach (var range in palette.SubPalettes)
|
|
{
|
|
subPalettes.Add((
|
|
range.SubPaletteId,
|
|
range.Offset,
|
|
range.Length));
|
|
}
|
|
}
|
|
|
|
List<(byte, uint)>? partOverrides = null;
|
|
if (player.PartOverrides.Count > 0)
|
|
{
|
|
partOverrides = new List<(byte, uint)>(player.PartOverrides.Count);
|
|
foreach (var part in player.PartOverrides)
|
|
partOverrides.Add((part.PartIndex, part.GfxObjId));
|
|
}
|
|
|
|
doll = DollEntityBuilder.Build(
|
|
player.SourceGfxObjOrSetupId,
|
|
new List<MeshRef>(player.MeshRefs),
|
|
basePalette,
|
|
subPalettes,
|
|
partOverrides);
|
|
_pose.Apply(doll, player.SourceGfxObjOrSetupId);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>Applies retail's DAT-defined settled paperdoll stance.</summary>
|
|
internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator
|
|
{
|
|
private readonly IDatReaderWriter _dats;
|
|
private readonly IAnimationLoader _animations;
|
|
private readonly object _datLock;
|
|
|
|
public RetailPaperdollPoseApplicator(
|
|
IDatReaderWriter dats,
|
|
IAnimationLoader animations,
|
|
object datLock)
|
|
{
|
|
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
|
_animations = animations ?? throw new ArgumentNullException(nameof(animations));
|
|
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>gmPaperDollUI</c> resolves its held pose with
|
|
/// <c>DBCache::GetDIDFromEnumStatic(0x10000005, 7)</c> —
|
|
/// <see cref="RetailHeldPose.ResolvePoseDid"/> parameterized by the
|
|
/// paperdoll's own fixed enum key.
|
|
/// </summary>
|
|
private uint ResolvePoseDid() => RetailHeldPose.ResolvePoseDid(_dats, 0x10000005u);
|
|
|
|
public void Apply(WorldEntity doll, uint setupId)
|
|
{
|
|
DatReaderWriter.DBObjs.Animation? animation;
|
|
DatReaderWriter.DBObjs.Setup? setup;
|
|
lock (_datLock)
|
|
{
|
|
uint poseDid = ResolvePoseDid();
|
|
if ((poseDid >> 24) != 0x03u)
|
|
return;
|
|
|
|
animation = _animations.LoadAnimation(poseDid);
|
|
setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(setupId);
|
|
}
|
|
if (animation is null || setup is null || animation.PartFrames.Count == 0)
|
|
return;
|
|
|
|
// RedressCreature @ 0x004A3C22 installs the pose with zero frame rate
|
|
// and holds the settled final frame.
|
|
var frame = animation.PartFrames[^1];
|
|
var reposed = new List<MeshRef>(doll.MeshRefs.Count);
|
|
for (int index = 0; index < doll.MeshRefs.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;
|
|
}
|
|
|
|
Matrix4x4 transform = RetailHeldPose.ComposePartTransform(scale, origin, orientation);
|
|
MeshRef source = doll.MeshRefs[index];
|
|
reposed.Add(new MeshRef(source.GfxObjId, transform)
|
|
{
|
|
SurfaceOverrides = source.SurfaceOverrides,
|
|
});
|
|
}
|
|
|
|
doll.MeshRefs = reposed;
|
|
}
|
|
}
|