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; /// /// Campaign CC slice CC6b-MOUNT: the page-mount half's control surface over /// the CC6a/CC6b-PRE preview foundation. /// is constructed BEFORE the graphical presentation pipeline exists (early /// retained-UI composition — see '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. /// constructs the real once the /// graphics backend exists and assigns it onto the page — mirroring exactly /// how the paperdoll's viewport.Renderer = paperdollLease.Resource /// late-assignment already works for a DIFFERENT screen's viewport. /// internal interface IChargenPreviewControl { /// /// 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 PaperdollFramePresenter's own /// "keep the successful doll, retry next visible frame" precedent. /// bool Rebuild( ChargenOptions options, uint heritageId, int genderKey, ChargenAppearanceSelection selection); void ZoomIn(); void ZoomOut(); void RotateClockwise(); void RotateCounterClockwise(); } /// Gates the preview's per-frame work on whether the Appearance /// PAGE (not just the leaf viewport widget) is the currently visible page — /// mirrors IPaperdollInventoryVisibility's outer-frame gate. internal interface IChargenPreviewPageVisibility { bool IsVisible { get; } } /// CC6b-MOUNT: narrow seam mirroring IPaperdollFrameView so /// can be exercised with a fake view /// in tests. internal interface IChargenPreviewFrameView { bool TryGetVisibleSize(out int width, out int height); void SetTextureHandle(uint textureHandle); } /// Thin adapter over RetailUiRuntime.IsChargenPreviewPageVisible /// — narrowed to so this /// Rendering-namespace class doesn't need a direct dependency on the /// UI/Layout-namespace RetailUiRuntime type beyond the one property /// read. 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; } /// Campaign CC slice CC5: the Summary page's own visibility gate — /// same shape as , reading /// RetailUiRuntime.IsSummaryPreviewPageVisible instead. 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; } /// Retained-UI visibility + texture publication, mirroring /// RetailPaperdollFrameView. 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); } /// /// The real, dat-touching implementation of /// plus the per-frame owner — /// constructed once in /// (same composition scope RetailPaperdollPoseApplicator is built in, /// which has the real content.Dats/content.AnimationLoader/ /// d.DatLock) and assigned onto the already-mounted Appearance page. /// /// /// Camera/zoom/rotation ownership (CC6b-MOUNT bridges a CC6a/CC6b-PRE gap): /// only ever built its OWN private /// with no injection seam, but /// needs a SETTABLE camera to /// tween. This class owns the ONE /// instance and hands it to the renderer via the new /// overload, /// so both the renderer's draw and the zoom controller's tween read/write /// the exact same eye position. /// /// /// /// Rebuild vs per-frame ownership split, decomp-cited (retail /// gmCGAppearancePage::Update @ 0x0047E8F0): 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 Update — /// InitializePage and the two gender-button handlers, /// ListenToElementMessage cases 0x9d/0x9e) — spin/color/ /// shade changes call the narrower SetSelection/SetColor/ /// SetShade instead, none of which touch m_vectCurPosition. /// reproduces that split: it always recomposes the /// ObjDesc/mesh (every appearance field feeds gmCG3DView::Update's /// rebuild eventually), but only resets the camera when heritage or gender /// actually changed. m_fCurHeading (this class's /// ) and m_bZoomedIn /// (read through ) both live /// on the PAGE in retail and are NEVER reset by Update — so a fresh /// (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. /// /// 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; /// The SAME instance passed to the /// 's own camera constructor /// parameter — see this class's own doc comment on why the renderer and /// the zoom controller must share one mutable camera. /// Review fix round F5 (2026-08-16): /// (the default) reproduces the Appearance /// page's own zoomed-IN default eye /// (gmCGAppearancePage::InitializePage @ 0x0047FDD0, /// ). /// reproduces the Summary page's own eye /// (gmCGSummaryPage::InitializePage @ 0x0047bbf0, byte-decoded /// eye literal (0, -2.5, 0.95) at ~0x0047bd14-0x0047bd44 — /// exactly '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): /// InitializePage alone only justifies the ONE-TIME seed below — /// the STRONGER citation for why re-derives this /// same eye PER HERITAGE on every heritage/gender change (not just /// once) is gmCGSummaryPage::Update @ 0x0047baa0, which re-sets /// the camera on every update using the identical per-heritage mapping /// already /// implements (0xc Olthoi → (0, -3.8, 1.15), 0xd /// OlthoiAcid → (0, -5.7, 1.65), else → (0, -2.5, 0.95)) — /// 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. 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); } /// Test-observability seam only — production callers use /// /. internal bool IsZoomedIn => _zoom?.IsZoomedIn ?? false; /// Test-observability seam only. 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() 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). } }