acdream/src/AcDream.App/Rendering/ChargenPreviewController.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

416 lines
19 KiB
C#

using System.Diagnostics;
using System.Numerics;
using AcDream.App.UI;
using AcDream.Content;
using AcDream.Core.CharGen;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
using DatReaderWriter;
namespace AcDream.App.Rendering;
/// <summary>
/// Campaign CC slice CC6b-MOUNT: the page-mount half's control surface over
/// the CC6a/CC6b-PRE preview foundation. <see cref="CharacterCreationAppearancePage"/>
/// is constructed BEFORE the graphical presentation pipeline exists (early
/// retained-UI composition — see <see cref="AcDream.App.UI.Layout.CharacterCreationRuntimeBindings"/>'s
/// own late-bound-Func doc comment), so its zoom/rotate buttons bind against
/// this interface's default no-op-until-assigned shape rather than a
/// concrete renderer reference. <see cref="AcDream.App.Composition.LivePresentationComposition"/>
/// constructs the real <see cref="ChargenPreviewController"/> once the
/// graphics backend exists and assigns it onto the page — mirroring exactly
/// how the paperdoll's <c>viewport.Renderer = paperdollLease.Resource</c>
/// late-assignment already works for a DIFFERENT screen's viewport.
/// </summary>
internal interface IChargenPreviewControl
{
/// <summary>
/// Recomposes and rebuilds the preview entity when the heritage/gender/
/// appearance selection actually changed since the last call (a cheap
/// no-op otherwise). Returns false when the selection cannot be
/// resolved/built (heritage or gender not yet chosen, or a missing dat
/// resource) — the caller (the page) simply leaves the previous frame on
/// screen, matching <c>PaperdollFramePresenter</c>'s own
/// "keep the successful doll, retry next visible frame" precedent.
/// </summary>
bool Rebuild(
ChargenOptions options,
uint heritageId,
int genderKey,
ChargenAppearanceSelection selection);
void ZoomIn();
void ZoomOut();
void RotateClockwise();
void RotateCounterClockwise();
}
/// <summary>Gates the preview's per-frame work on whether the Appearance
/// PAGE (not just the leaf viewport widget) is the currently visible page —
/// mirrors <c>IPaperdollInventoryVisibility</c>'s outer-frame gate.</summary>
internal interface IChargenPreviewPageVisibility
{
bool IsVisible { get; }
}
/// <summary>CC6b-MOUNT: narrow seam mirroring <c>IPaperdollFrameView</c> so
/// <see cref="ChargenPreviewController"/> can be exercised with a fake view
/// in tests.</summary>
internal interface IChargenPreviewFrameView
{
bool TryGetVisibleSize(out int width, out int height);
void SetTextureHandle(uint textureHandle);
}
/// <summary>Thin adapter over <c>RetailUiRuntime.IsChargenPreviewPageVisible</c>
/// — narrowed to <see cref="IChargenPreviewPageVisibility"/> so this
/// Rendering-namespace class doesn't need a direct dependency on the
/// UI/Layout-namespace <c>RetailUiRuntime</c> type beyond the one property
/// read.</summary>
internal sealed class RetailChargenPreviewPageVisibility : IChargenPreviewPageVisibility
{
private readonly AcDream.App.UI.RetailUiRuntime _runtime;
public RetailChargenPreviewPageVisibility(AcDream.App.UI.RetailUiRuntime runtime) =>
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
public bool IsVisible => _runtime.IsChargenPreviewPageVisible;
}
/// <summary>Campaign CC slice CC5: the Summary page's own visibility gate —
/// same shape as <see cref="RetailChargenPreviewPageVisibility"/>, reading
/// <c>RetailUiRuntime.IsSummaryPreviewPageVisible</c> instead.</summary>
internal sealed class RetailSummaryPreviewPageVisibility : IChargenPreviewPageVisibility
{
private readonly AcDream.App.UI.RetailUiRuntime _runtime;
public RetailSummaryPreviewPageVisibility(AcDream.App.UI.RetailUiRuntime runtime) =>
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
public bool IsVisible => _runtime.IsSummaryPreviewPageVisible;
}
/// <summary>Retained-UI visibility + texture publication, mirroring
/// <c>RetailPaperdollFrameView</c>.</summary>
internal sealed class RetailChargenPreviewFrameView : IChargenPreviewFrameView
{
private readonly UiViewport _viewport;
private readonly IChargenPreviewPageVisibility _page;
public RetailChargenPreviewFrameView(
UiViewport viewport,
IChargenPreviewPageVisibility page)
{
_viewport = viewport ?? throw new ArgumentNullException(nameof(viewport));
_page = page ?? throw new ArgumentNullException(nameof(page));
}
public bool TryGetVisibleSize(out int width, out int height)
{
width = 0;
height = 0;
if (!_viewport.Visible || !_page.IsVisible)
return false;
width = (int)_viewport.Width;
height = (int)_viewport.Height;
return true;
}
public void SetTextureHandle(uint textureHandle) =>
_viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle);
}
/// <summary>
/// The real, dat-touching implementation of <see cref="IChargenPreviewControl"/>
/// plus the per-frame <see cref="IPrivateEntityViewportFrame"/> owner —
/// constructed once in <see cref="AcDream.App.Composition.LivePresentationComposition"/>
/// (same composition scope <c>RetailPaperdollPoseApplicator</c> is built in,
/// which has the real <c>content.Dats</c>/<c>content.AnimationLoader</c>/
/// <c>d.DatLock</c>) and assigned onto the already-mounted Appearance page.
///
/// <para>
/// <b>Camera/zoom/rotation ownership (CC6b-MOUNT bridges a CC6a/CC6b-PRE gap):</b>
/// <see cref="ChargenPreviewRenderer"/> only ever built its OWN private
/// <see cref="ChargenPreviewCamera"/> with no injection seam, but
/// <see cref="ChargenPreviewZoomController"/> needs a SETTABLE camera to
/// tween. This class owns the ONE <see cref="ChargenPreviewCamera"/>
/// instance and hands it to the renderer via the new
/// <see cref="ChargenPreviewViewportCamera(ChargenPreviewCamera)"/> overload,
/// so both the renderer's draw and the zoom controller's tween read/write
/// the exact same eye position.
/// </para>
///
/// <para>
/// <b>Rebuild vs per-frame ownership split, decomp-cited (retail
/// <c>gmCGAppearancePage::Update @ 0x0047E8F0</c>):</b> the camera SNAPS to
/// the heritage's default (zoomed-in) eye only on a HERITAGE or GENDER
/// change (the two confirmed direct call sites of the outer <c>Update</c> —
/// <c>InitializePage</c> and the two gender-button handlers,
/// <c>ListenToElementMessage</c> cases <c>0x9d</c>/<c>0x9e</c>) — spin/color/
/// shade changes call the narrower <c>SetSelection</c>/<c>SetColor</c>/
/// <c>SetShade</c> instead, none of which touch <c>m_vectCurPosition</c>.
/// <see cref="Rebuild"/> reproduces that split: it always recomposes the
/// ObjDesc/mesh (every appearance field feeds <c>gmCG3DView::Update</c>'s
/// rebuild eventually), but only resets the camera when heritage or gender
/// actually changed. <c>m_fCurHeading</c> (this class's
/// <see cref="ChargenPreviewRotationController"/>) and <c>m_bZoomedIn</c>
/// (read through <see cref="ChargenPreviewAnimator.IsZoomedIn"/>) both live
/// on the PAGE in retail and are NEVER reset by <c>Update</c> — so a fresh
/// <see cref="ChargenPreviewAnimator"/> (unavoidable: it owns the resolved
/// drawable-part list, which changes with the mesh) is immediately restored
/// to the PREVIOUS zoom state, and the current accumulated heading is passed
/// into the rebuild rather than resetting to the retail default.
/// </para>
/// </summary>
internal sealed class ChargenPreviewController :
IChargenPreviewControl,
IPrivateEntityViewportFrame,
IDisposable
{
private readonly IChargenPreviewRenderer _renderer;
private readonly IChargenPreviewFrameView _view;
private readonly ChargenPreviewCamera _camera;
private readonly ChargenPreviewRotationController _rotation;
private readonly IDatReaderWriter _dats;
private readonly IAnimationLoader _animations;
private readonly IChargenPalSetSource _palSets;
private readonly IChargenClothingTableSource _clothingTables;
private readonly object _datLock;
private readonly bool _useZoomedOutEye;
private readonly uint _renderId;
private readonly uint _backdropRenderId;
private readonly Stopwatch _clock = Stopwatch.StartNew();
private ChargenPreviewAnimator? _animator;
private ChargenPreviewZoomController? _zoom;
private double _lastElapsedSeconds;
private bool _hasComposed;
private uint _lastHeritageId;
private int _lastGenderKey = -1;
private ChargenAppearanceSelection _lastSelection;
private bool _disposed;
/// <param name="camera">The SAME instance passed to the
/// <see cref="ChargenPreviewRenderer"/>'s own <c>camera</c> constructor
/// parameter — see this class's own doc comment on why the renderer and
/// the zoom controller must share one mutable camera.</param>
/// <param name="useZoomedOutEye">Review fix round F5 (2026-08-16):
/// <see langword="false"/> (the default) reproduces the Appearance
/// page's own zoomed-IN default eye
/// (<c>gmCGAppearancePage::InitializePage @ 0x0047FDD0</c>,
/// <see cref="ChargenPreviewCamera.ResolveDefaultEye"/>).
/// <see langword="true"/> reproduces the Summary page's own eye
/// (<c>gmCGSummaryPage::InitializePage @ 0x0047bbf0</c>, byte-decoded
/// eye literal <c>(0, -2.5, 0.95)</c> at <c>~0x0047bd14-0x0047bd44</c> —
/// exactly <see cref="ChargenPreviewCamera.ResolveZoomedOutEye"/>'s
/// default-heritage value, NOT the zoomed-in one this controller used
/// before the fix). CC5 re-review residual round, nit 1 (2026-08-16):
/// <c>InitializePage</c> alone only justifies the ONE-TIME seed below —
/// the STRONGER citation for why <see cref="Rebuild"/> re-derives this
/// same eye PER HERITAGE on every heritage/gender change (not just
/// once) is <c>gmCGSummaryPage::Update @ 0x0047baa0</c>, which re-sets
/// the camera on every update using the identical per-heritage mapping
/// <see cref="ChargenPreviewCamera.ResolveZoomedOutEye"/> already
/// implements (<c>0xc</c> Olthoi → <c>(0, -3.8, 1.15)</c>, <c>0xd</c>
/// OlthoiAcid → <c>(0, -5.7, 1.65)</c>, else → <c>(0, -2.5, 0.95)</c>) —
/// confirming the per-heritage re-derive below is retail-correct, not
/// an acdream-only elaboration on a one-shot init value. Summary has no
/// zoom buttons at all (retail's own viewport there is fixed-framing),
/// so this is a permanent camera profile for the controller's whole
/// lifetime, not a toggle.</param>
public ChargenPreviewController(
IChargenPreviewRenderer renderer,
ChargenPreviewCamera camera,
IChargenPreviewFrameView view,
IDatReaderWriter dats,
IAnimationLoader animations,
IChargenPalSetSource palSets,
IChargenClothingTableSource clothingTables,
object datLock,
bool useZoomedOutEye = false,
// F16 (Campaign CC gate round 1 closeout): the render-id pair this
// controller stamps on the entities it builds — MUST match the
// pair the sibling ChargenPreviewRenderer was constructed with (see
// that class's own renderId/backdropRenderId parameters), since
// both feed the SAME shared TextureCache owner-tracking key.
// Defaults to the Appearance page's pair; the composition root
// passes the Summary pair explicitly for its own instance — see
// ChargenPreviewEntityBuilder.SummaryPreviewRenderId's own doc for
// why sharing the default here would be a real collision, not
// merely untidy.
uint renderId = ChargenPreviewEntityBuilder.PreviewRenderId,
uint backdropRenderId = ChargenPreviewEntityBuilder.PreviewBackdropRenderId)
{
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_view = view ?? throw new ArgumentNullException(nameof(view));
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_animations = animations ?? throw new ArgumentNullException(nameof(animations));
_palSets = palSets ?? throw new ArgumentNullException(nameof(palSets));
_clothingTables = clothingTables ?? throw new ArgumentNullException(nameof(clothingTables));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
_useZoomedOutEye = useZoomedOutEye;
_renderId = renderId;
_backdropRenderId = backdropRenderId;
_rotation = new ChargenPreviewRotationController();
// Seed the eye NOW, matching whatever the first Rebuild's own
// heritageOrGenderChanged branch below would otherwise defer until
// the first successful compose — avoids one frame of the wrong
// (Appearance-profile) eye if this controller ever renders before
// Rebuild's first call succeeds.
if (_useZoomedOutEye)
_camera.Eye = ChargenPreviewCamera.ResolveZoomedOutEye(0u);
}
/// <summary>Test-observability seam only — production callers use
/// <see cref="ZoomIn"/>/<see cref="ZoomOut"/>.</summary>
internal bool IsZoomedIn => _zoom?.IsZoomedIn ?? false;
/// <summary>Test-observability seam only.</summary>
internal Vector3 CameraEye => _camera.Eye;
public bool Rebuild(
ChargenOptions options,
uint heritageId,
int genderKey,
ChargenAppearanceSelection selection)
{
if (_disposed)
return false;
if (_hasComposed
&& heritageId == _lastHeritageId
&& genderKey == _lastGenderKey
&& selection.Equals(_lastSelection))
{
return true;
}
// Fix round F7 (BLOCKER, CC6a's own F4 re-introduced at a new site):
// TryCompose reaches ChargenAppearanceCatalog.TryGetPalSet/
// TryGetClothingTable (_palSets/_clothingTables), which do lazy raw
// DatCollection.Get<T>() reads on first use — DatCollection is NOT
// thread-safe (feedback_phase_a1_hotfix_saga.md), and this UI-thread
// Rebuild call is the catalog's first production call site. Every
// sibling DAT read in this same method already guards with
// _datLock (see the TryBuildAnimated call just below) — this one
// must too.
bool composed;
ChargenAppearanceResult result;
lock (_datLock)
{
composed = ChargenAppearanceFactory.TryCompose(
options, heritageId, genderKey, selection,
_palSets, _clothingTables, out result);
}
if (!composed)
{
return false;
}
Quaternion heading = MoveToMath.SetHeading(
Quaternion.Identity, _rotation.HeadingDegrees);
ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated(
_dats, _animations, result, heritageId, heading, _datLock, _renderId);
if (build is null)
return false;
bool wasZoomedIn = _animator?.IsZoomedIn ?? false;
_animator = new ChargenPreviewAnimator(build);
if (wasZoomedIn)
_animator.SetZoomedIn(true);
bool heritageOrGenderChanged =
!_hasComposed || heritageId != _lastHeritageId || genderKey != _lastGenderKey;
if (heritageOrGenderChanged)
{
// F5: the Summary controller (_useZoomedOutEye) re-derives the
// FIXED zoomed-out eye per heritage instead of SetHeritage's
// zoomed-in default — see the ctor param's own doc comment.
_camera.Eye = _useZoomedOutEye
? ChargenPreviewCamera.ResolveZoomedOutEye(heritageId)
: ChargenPreviewCamera.ResolveDefaultEye(heritageId);
}
// Batch D (GF-7/GF-14): retail's own backdrop-rebuild gate
// (gmCG3DView::Update's `m_bgSetupID.id != eax_32` check) fires
// whenever the HERITAGE's own environmentSetupID differs from the
// one currently shown — and that value is a pure function of
// heritage (ACCharGenData::GetHG(mHeritageGroup).environmentSetupID),
// never gender. Narrower than heritageOrGenderChanged on purpose: a
// gender-only change (or an appearance-only change, which never
// reaches this branch at all) would otherwise pay a redundant Setup
// dat fetch + mesh-reference acquire/release for a backdrop that
// cannot have changed.
bool heritageChanged = !_hasComposed || heritageId != _lastHeritageId;
if (heritageChanged)
{
WorldEntity? backdrop =
options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)
? ChargenPreviewEntityBuilder.TryBuildBackdrop(
_dats, heritage!.EnvironmentSetupId, _datLock, _backdropRenderId)
: null;
_renderer.SetBackdrop(backdrop);
}
// ChargenPreviewZoomController's animator dependency is required at
// construction (fix round F2) — a fresh animator means a fresh
// controller, but it reads IsZoomedIn straight through the animator
// we just restored above, so zoom state itself survives the swap.
_zoom = new ChargenPreviewZoomController(heritageId, _camera, _animator);
_renderer.SetPreview(_animator.Entity);
_hasComposed = true;
_lastHeritageId = heritageId;
_lastGenderKey = genderKey;
_lastSelection = selection;
return true;
}
public void ZoomIn() => _zoom?.ZoomIn();
public void ZoomOut() => _zoom?.ZoomOut();
public void RotateClockwise() => _rotation.Toggle(ChargenRotateDirection.Clockwise);
public void RotateCounterClockwise() => _rotation.Toggle(ChargenRotateDirection.CounterClockwise);
public void Render()
{
if (_disposed || !_view.TryGetVisibleSize(out int width, out int height))
return;
double now = _clock.Elapsed.TotalSeconds;
float deltaSeconds = (float)Math.Max(0.0, now - _lastElapsedSeconds);
_lastElapsedSeconds = now;
_animator?.Tick(deltaSeconds);
_rotation.Tick(now);
_zoom?.Tick(now);
if (_animator is not null)
_animator.Entity.Rotation = _rotation.ToOrientation();
_view.SetTextureHandle(_renderer.Render(width, height));
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
// Fix round F9: release the preview entity NOW rather than leaving
// the leased renderer holding it until the renderer's OWN disposal
// (a separate manifest entry, one step later) — this class built
// the entity via Rebuild, so it releases it on its own teardown
// instead of relying on a downstream owner to notice. Batch D: the
// backdrop entity is the SAME kind of controller-built resource, so
// it releases on the same teardown for the same reason.
_renderer.SetPreview(null);
_renderer.SetBackdrop(null);
_animator = null;
_zoom = null;
// The renderer itself is a leased composition resource disposed by
// the composition root (mirrors PaperdollViewportRenderer — this
// class does not own its lifetime, only its per-frame drive).
}
}