From 7eaa68a5f40b7e1610b54985fa0ac490c4fd1756 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 23 Jul 2026 12:55:24 +0200 Subject: [PATCH] feat(ui): port retail creature appraisal presentation Render assessed creatures through the shared private viewport with retail heading, bounding-box camera, and light. Build the exact authored nine-row stat list and resolve creature names from the retail EnumMapper while keeping remaining font/sequencer adaptations explicit. Co-authored-by: Codex --- docs/ISSUES.md | 9 +- .../retail-divergence-register.md | 4 +- ...2026-07-23-world-interaction-completion.md | 25 +- ...26-07-23-retail-appraisal-ui-pseudocode.md | 89 +- .../Composition/FrameRootComposition.cs | 4 +- .../LivePresentationComposition.cs | 50 +- .../CreatureAppraisalPresentation.cs | 407 +++++++ src/AcDream.App/Rendering/DollCamera.cs | 37 +- src/AcDream.App/Rendering/GameWindow.cs | 9 + .../Rendering/GameWindowLifetime.cs | 4 + .../Rendering/PaperdollFramePresenter.cs | 2 +- .../Rendering/PaperdollViewportRenderer.cs | 201 +--- .../PrivateEntityViewportRenderer.cs | 252 ++++ .../Rendering/PrivatePresentationRenderer.cs | 34 +- .../UI/Layout/AppraisalUiController.cs | 90 +- .../UI/Layout/CreatureAppraisalRows.cs | 304 +++++ src/AcDream.App/UI/RetailUiRuntime.cs | 35 +- .../CreatureAppraisalPresentationTests.cs | 196 ++++ .../GameWindowRenderLeafCompositionTests.cs | 2 +- .../PrivatePresentationRendererTests.cs | 26 +- .../RenderFrameRecoveryIntegrationTests.cs | 2 +- .../UI/Layout/AppraisalUiControllerTests.cs | 117 +- .../UI/Layout/CreatureAppraisalRowsTests.cs | 100 ++ .../UI/Layout/FixtureLoader.cs | 3 + .../UI/Layout/RetailLayoutFixtureGenerator.cs | 6 + .../examine_row_2100006B_10000166.json | 1009 +++++++++++++++++ 26 files changed, 2780 insertions(+), 237 deletions(-) create mode 100644 src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs create mode 100644 src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs create mode 100644 src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs create mode 100644 tests/AcDream.App.Tests/Rendering/CreatureAppraisalPresentationTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/fixtures/examine_row_2100006B_10000166.json diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 4a951a1c..a60c5f49 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -76,7 +76,7 @@ Copy this block when adding a new issue: ## #234 — Monster assessment held busy and examination used the shared panel -**Status:** DONE — 2026-07-23, corrective connected gate pending +**Status:** DONE — 2026-07-23; creature presentation visual gate pending **Severity:** HIGH **Filed:** 2026-07-23 **Component:** appraisal protocol / retained examination UI / item writing @@ -95,6 +95,9 @@ had also been incorrectly registered as synthetic main-panel id bits/order are now pinned by literal packet tests, the floaty is registered as an independent top-level window, and the imported multiline field sends retail's CP-1252 `SetInscription (0x00BF)` under its ownership/authorship rules. +The follow-up creature page now uses the authored row template and exact retail +stat order/formatting, creature-type EnumMapper, level property, fixed-heading +animated clone, bounding-box camera, and private-viewport light. **Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs`; @@ -108,7 +111,9 @@ retail's CP-1252 `SetInscription (0x00BF)` under its ownership/authorship rules. **Acceptance:** Monster/NPC/player assessment opens and clears busy; the examination window coexists with the shared primary panel; an owned inscribable weapon can be edited/cleared and reassessed; foreign/nonowned/ -noninscribable cases show retail's exact permission messages. +noninscribable cases show retail's exact permission messages. Monster pages +show the animated target, mapped creature type, level, and nine ordered +retail stat rows. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 042a3a16..bda5e686 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -191,7 +191,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) | | AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 − translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 − translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 − translucency`, applied to all 4 D3D9 material alpha channels) | | AP-90 | **Radar fellowship/allegiance relationship state is modeled but not yet delivered at runtime.** `RetailRadar.GetBlipShape` and `RadarBlipColors.For` implement retail's leader/member/allegiance precedence, and `RadarSnapshotProvider` exposes a `relationshipFor(guid)` seam, but acdream does not yet maintain live fellowship membership and its `AllegianceTree` is not wired into GameWindow. PK/PKLite relationship shapes do work from PWD flags. | `src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs`; `src/AcDream.Core/Ui/RetailRadar.cs`; `src/AcDream.Core/Ui/RadarBlipColors.cs` | Preserve the exact model/seam now and avoid inventing membership from names or chat; connect it when the social game-event state is ported | Fellowship members render their ordinary player color/shape instead of bright-green leader/member triangles; allegiance members render an ordinary plus instead of a hollow box | `gmRadarUI::GetBlipColor` 0x004D76F0; `gmRadarUI::GetBlipShape` 0x004D7B60 | -| AP-92 | Paperdoll renders its creature through a private FBO/RTT and blits it into `UiViewport`; retail renders `CreatureMode` directly in the UI viewport/in-cell presentation path | `src/AcDream.App/Rendering/PaperdollFramePresenter.cs`; `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent and the accepted pixels/camera/pose match retail; state is sealed with `GLStateScope` | FBO origin, alpha, lighting, or state isolation can diverge from direct viewport rendering | `gmPaperDollUI::PostInit @ 0x004A5360`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` | +| AP-92 | Private creature viewports (paperdoll and examination) render through isolated FBO/RTTs and blit into `UiViewport`; retail renders each `CreatureMode` directly and advances a cloned `CPhysicsObj`, while examination currently refreshes its clone from the live target's animated mesh pose | `src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs`; `src/AcDream.App/Rendering/PaperdollFramePresenter.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent; shared live pose data provides animation without registering a second gameplay entity or duplicating the world sequencer | FBO origin, alpha, lighting, state isolation, or an assessment-time cloned motion diverging later from the live target can differ from direct retail CreatureMode presentation | `CPhysicsObj::makeObject(CPhysicsObj const*) @ 0x005144B0`; `gmPaperDollUI::PostInit @ 0x004A5360`; `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` | | AP-93 | Paperdoll does not port `UpdateForRace`; all characters use the cdb-confirmed default held pose `0x030003C0` and default presentation | `src/AcDream.App/Rendering/DollEntityBuilder.cs`; `RetailPaperdollPoseApplicator` in `src/AcDream.App/Rendering/PaperdollFramePresenter.cs` | Exact for the tested Horan character; unresolved for other heritage/gender/body combinations | Non-default races can use the wrong pose, heading, camera framing, or presentation asset | `gmPaperDollUI::UpdateForRace @ 0x004A3ED0` | | AP-94 | Imported Type-12 text defaults to interactive/selectable behavior; display labels can focus/capture/drag, and vitals synthesize duplicate runtime labels instead of binding `0x100000EB/ED/EF` | `src/AcDream.App/UI/UiText.cs`; `src/AcDream.App/UI/Layout/VitalsController.cs` | Historical widget-generalization default; Wave 1 ports explicit Display/Selectable/Editable roles | Invisible/static text steals input and duplicate labels drift from DAT geometry | `UIElement_Text` property handlers; `gmVitalsUI::PostInit @ 0x004BFCE0` | | ~~AP-95~~ | **RETIRED 2026-07-11** — `UiButton` owns retail hover/pressed/released-outside, disabled, selected/toggle, missing-state fallback, hot-click, and distinct press/release controller callbacks. | `src/AcDream.App/UI/UiButton.cs`; `UiButtonStateMachine.cs` | — | — | `UIElement_Button::UpdateState_ @ 0x00471CF0`; mouse handlers `0x00471FF0..0x004721F0` | @@ -205,7 +205,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | | AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | -| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination now has its independent authored floaty layout, item/creature/character reports, and conditional inscription-write transaction, but still lacks the exact 3D preview, exhaustive specialized detail regions, and selection-follow preference. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription loop cover the active loops; the remaining examination mechanisms require the retail preview render owner and additional field projections rather than fabricated content | Specialized assessments omit some retail fields/3D preview; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | +| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, item/creature/character reports, inscription transaction, and retail creature stat/animated-preview presentation, but still lacks item-object preview, exhaustive specialized detail/extra-rating regions, exact appraisal FontInfo-list selection, and selection-follow preference. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription/creature-preview loop cover the active loops; the remaining examination mechanisms require additional retail field/font/object-preview projections rather than fabricated content | Specialized assessments omit some retail fields and item-object presentation; enchanted/incomplete appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | | ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` | | AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` | | AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` | diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index 85bd58ca..48200bc2 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -5,8 +5,8 @@ zero-useability, carried direct-use, AutoWear correction, and the requested shared item-cooldown follow-up passed their connected gates. Slice 3's first connected gate exposed an incorrect IdentifyResponse flag table, a non-retail shared-panel mount, and its missing inscription transaction. All three are -corrected; the independent floaty examination window awaits its corrective -connected gate. +corrected and live-confirmed. The creature-page follow-up now has retail's +ordered stat rows and animated private preview; its visual gate is pending. **Milestone:** M4 prerequisite/preamble. **Architecture:** retained gameplay UI over shared selection, object, and interaction state. `GameWindow` remains a composition/callback shell. @@ -132,14 +132,25 @@ geometry. The imported multiline inscription field now ports `ItemExamineUI`'s public/hook inscribability, inventory ownership and same-scribe permission rules, placeholder/signature presentation, exact failure notices, focus commit behavior, and CP-1252 `SetInscription (0x00BF)` -transaction. Basic item, creature, and player reports are live; exact 3D -preview/specialized detail regions and selection-follow remain the narrowed -AP-110 residual. Research: +transaction. Basic item and player reports are live. The creature page now +resolves its type through retail EnumMapper `0x2200000E`, preserves the +authored Character/Level header, creates the exact nine stat rows from +template `0x10000166`, and renders a fixed-heading animated clone through a +private viewport using retail's bounding-box camera and distant light. +Item-object preview, specialized detail regions, exact appraisal font-state +selection, and selection-follow remain the narrowed AP-110 residual. Research: [`../research/2026-07-23-retail-appraisal-ui-pseudocode.md`](../research/2026-07-23-retail-appraisal-ui-pseudocode.md). The focused parser/router/request/controller/fixture tests pass, as do the Release solution build with 17 pre-existing tracked test warnings, 3,496 App tests / 3 skips, and 7,913 complete-solution tests / 5 skips. +The creature presentation follow-up adds real-DAT row-template and EnumMapper +fixtures plus pure conformance coverage for row order/formatting, enchantment +semantics, failed assessments, stable clone identity, live animated mesh +updates, hydrated mesh bounds, and retail camera fitting. The warning-free App code, +Release solution build, 3,506 App tests / 3 skips, and 7,923 complete-solution +tests / 5 skips pass. The connected visual result remains the closeout gate. + ## Slice 1 — spell-bar overflow arrows ### Retail oracle @@ -253,6 +264,10 @@ Named retail references and executable pseudocode are recorded in inscription/signature, retail chrome, and working scrollbars. - Assess a monster, NPC, and player: the correct creature/character page should appear and the busy cursor should clear. +- On a monster, verify the animated creature is centered behind the exact + Strength/Endurance/Coordination/Quickness/Focus/Self/Health/Stamina/Mana + order, with its creature type and level in the authored header. Assess + differently sized monsters to verify the retail bounding-box fit. - In combat, leave a creature assessment open long enough to observe a health refresh; closing the examination window must stop refreshes. - Keep Examination open while opening/moving Inventory or Skills and confirm diff --git a/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md b/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md index 626e8e87..a748fca9 100644 --- a/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md +++ b/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md @@ -13,9 +13,15 @@ retained examination window. - `gmExaminationUI::SetActiveExamineUI @ 0x004AB420` - `gmExaminationUI::UseTime @ 0x004AB530` - `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0` + - `BasicCreatureExamineUI::Init @ 0x004AB9C0` + - `BasicCreatureExamineUI::BasicCreatureExamineUI @ 0x004ACD30` + - `BasicCreatureExamineUI::SetLevelValueText @ 0x004B3E70` - `BasicCreatureExamineUI::SetAppraiseInfo @ 0x004B3F70` - `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0` - `CharExamineUI::SetAppraiseInfo @ 0x004B45F0` + - `AttributeInfoRegion::Update @ 0x004F1D90` + - `Attribute2ndInfoRegion::Update @ 0x004F1E80` + - `AppraisalSystem::InqCreatureDisplayName @ 0x005B59E0` - `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0` - `ItemExamineUI::HandleInscriptionGainingFocus @ 0x004AC0E0` - `ItemExamineUI::HandleInscriptionMousePresses @ 0x004AC230` @@ -189,6 +195,79 @@ When the examination window's selection-follow option is active, a selection change appraises the new selected GUID; clearing selection hides the window. That optional retail preference remains outside this slice. +## Creature presentation + +`BasicCreatureExamineUI` binds viewport `0x10000148`, list box `0x10000149`, +level value `0x1000014C`, and creature display name `0x1000014E`. Its stat +rows are instances of LayoutDesc `0x2100006B` template `0x10000166`, whose +label and value fields are `0x1000012A` and `0x1000012B`. + +```text +on creature appraisal: + level = int property 25 + level text = decimal level when positive, otherwise "???" + + creatureType = int property 2 + display name = EnumMapper 0x2200000E[creatureType] + replace underscores in display name with spaces + + clear stat list + append rows in this exact order: + Strength primary attribute 1 + Endurance primary attribute 2 + Coordination primary attribute 4 + Quickness primary attribute 3 + Focus primary attribute 5 + Self primary attribute 6 + Health current secondary 2 / maximum secondary 1, show percent + Stamina current secondary 4 / maximum secondary 3 + Mana current secondary 6 / maximum secondary 5 +``` + +Successful primary values are decimal integers. Successful Health is +`current/max (percent %)`; Stamina and Mana are `current/max`. On a failed +assessment, Health still shows the percentage when both values are known, +while every other row is `???`. The profile highlight/color bitfields select +one of retail's four authored FontInfo entries (normal, beneficial, +detrimental, or incomplete); exact FontInfo-list binding remains AP-110. + +The animated preview is a private `CreatureMode`, not the live world object: + +```text +clear the previous private creature +clone selected CPhysicsObj with makeObject +set clone heading to 191.367905 degrees +query the clone bounding box + +width = bbox.max.x - bbox.min.x +depth = bbox.max.y - bbox.min.y +height = bbox.max.z - bbox.min.z +viewportHeightOverWidth = viewport.height / viewport.width +fittedVerticalSpan = max(height, width * viewportHeightOverWidth) +center = (bbox.min + bbox.max) / 2 +camera.position = ( + center.x, + center.y - (fittedVerticalSpan * 1.20710683 + depth / 2), + center.z) +camera.direction = identity (+Y) + +viewport light: + type = DISTANT_LIGHT + intensity = 2 + direction = (0.3, 1.9, 0.65) + +insert clone into the private CreatureMode +``` + +ACDream's modern rendering equivalent uses the same isolated render-to-texture +owner as the inventory paperdoll. The examination clone has its own stable +render identity, fixed heading and retail camera/light, while its mesh-part +transforms are refreshed from the live target each frame so its current +animation is visible without registering a second gameplay entity. Retail's +clone instead advances the motion state copied by +`CPhysicsObj::MorphToExistingObject`; that narrow sequencer-ownership +adaptation is recorded under AP-92. + ## Item presentation order `ItemExamineUI::SetAppraiseInfo` clears and rebuilds the authored item text in @@ -333,7 +412,9 @@ implements the same behavior. The request lifetime, exact packet flags, independent floaty window, inscription permission/edit/write transaction, subview choice, authored outer -layout, basic report content, scrollbar ownership, and combat refresh are in -scope. Retail's exact 3D object/creature preview, exhaustive specialized -`ItemExamineUI` detail branches, selection-follow preference, and PSR-only -scribe-account/override behavior remain explicit AP-110 work. +layout, ordered creature stats, animated creature preview, scrollbar +ownership, and combat refresh are in scope. Retail's item-object preview, +exhaustive specialized `ItemExamineUI` detail branches, creature +extra-rating regions, exact appraisal FontInfo-list selection, +selection-follow preference, and PSR-only scribe-account/override behavior +remain explicit AP-110 work. diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 58851721..a8009c69 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -441,7 +441,9 @@ internal sealed class FrameRootCompositionPhase session.LocalTeleport, host.CameraController), renderFrameResources, - live.PaperdollPresenter, + new PrivateEntityViewportFrameGroup( + live.PaperdollPresenter, + live.CreatureAppraisalPresenter), retainedGameplayUi, settings.DevTools?.Presenter, privateScreenshot); diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 6e1be6ea..e52bc858 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -94,6 +94,8 @@ internal sealed record LivePresentationResult( RetainedUiGameplayBinding? RetainedGameplay, PaperdollViewportRenderer? PaperdollRenderer, PaperdollFramePresenter? PaperdollPresenter, + CreatureAppraisalViewportRenderer? CreatureAppraisalRenderer, + CreatureAppraisalFramePresenter? CreatureAppraisalPresenter, WbFrustum EnvCellFrustum, EnvCellRenderer EnvCellRenderer, LandblockPresentationPipeline LandblockPipeline, @@ -120,7 +122,7 @@ internal enum LivePresentationCompositionPoint EffectRoutingBound, SelectionAndRadarBound, RetainedGameplayBound, - PaperdollCreated, + PrivateCreatureViewportsCreated, EnvironmentCellsCreated, LandblockPipelineCreated, PortalResourcesCreated, @@ -694,7 +696,48 @@ internal sealed class LivePresentationCompositionPhase content.AnimationLoader, d.DatLock))); } - Fault(LivePresentationCompositionPoint.PaperdollCreated); + + CompositionAcquisitionScope.CompositionAcquisitionLease< + CreatureAppraisalViewportRenderer>? creatureAppraisalLease = null; + CreatureAppraisalFramePresenter? creatureAppraisalPresenter = null; + if (interaction.RetainedUi?.Runtime.CreatureAppraisalViewportWidget + is { } creatureViewport + && interaction.RetainedUi.Runtime.ExaminationFrame + is { } examinationFrame + && interaction.RetainedUi.Runtime.AppraisalController + is { } appraisalController) + { + creatureAppraisalLease = scope.Acquire( + "creature appraisal viewport", + () => new CreatureAppraisalViewportRenderer( + d.Gl, + dispatcherLease.Resource, + foundation.SceneLighting, + foundation.TextureCache), + static value => value.Dispose()); + IUiViewportRenderer? previousRenderer = creatureViewport.Renderer; + creatureViewport.Renderer = creatureAppraisalLease.Resource; + bindings.AdoptRelease( + "creature appraisal viewport target", + () => + { + if (ReferenceEquals( + creatureViewport.Renderer, + creatureAppraisalLease.Resource)) + { + creatureViewport.Renderer = previousRenderer; + } + }); + creatureAppraisalPresenter = new CreatureAppraisalFramePresenter( + creatureAppraisalLease.Resource, + new RetailCreatureAppraisalFrameView( + creatureViewport, + examinationFrame, + appraisalController), + new RetailCreatureAppraisalCloneFactory( + new LiveCreatureAppraisalEntityLookup(liveEntities))); + } + Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated); var envCellFrustum = new WbFrustum(); var envCellLease = scope.Acquire( @@ -890,6 +933,8 @@ internal sealed class LivePresentationCompositionPhase retainedGameplayLease?.Resource, paperdollLease?.Resource, paperdollPresenter, + creatureAppraisalLease?.Resource, + creatureAppraisalPresenter, envCellFrustum, envCellLease.Resource, landblockPipeline, @@ -910,6 +955,7 @@ internal sealed class LivePresentationCompositionPhase dispatcherLease.Transfer(); retainedGameplayLease?.Transfer(); paperdollLease?.Transfer(); + creatureAppraisalLease?.Transfer(); envCellLease.Transfer(); clipFrameLease.Transfer(); portalDepthLease.Transfer(); diff --git a/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs b/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs new file mode 100644 index 00000000..5b80672c --- /dev/null +++ b/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs @@ -0,0 +1,407 @@ +using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.App.World; +using AcDream.Core.Lighting; +using AcDream.Core.World; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +internal interface ICreatureAppraisalRenderer +{ + void SetCreature( + WorldEntity? creature, + Vector3 boundsMin, + Vector3 boundsMax); + + uint Render(int width, int height); +} + +internal interface ICreatureAppraisalFrameView +{ + bool TryGetVisibleTarget( + out uint serverGuid, + out int width, + out int height); + + void SetTextureHandle(uint textureHandle); +} + +internal interface ICreatureAppraisalEntityLookup +{ + bool TryGet(uint serverGuid, out WorldEntity entity); +} + +internal interface ICreatureAppraisalCloneFactory +{ + bool TrySynchronize( + uint serverGuid, + WorldEntity? currentClone, + out WorldEntity? synchronizedClone, + out Vector3 boundsMin, + out Vector3 boundsMax); +} + +/// +/// Per-frame owner for retail's examination CreatureMode. Unlike the +/// inventory doll's held pose, the examination clone follows the live target's +/// current animated part transforms every frame. +/// +internal sealed class CreatureAppraisalFramePresenter : + IPrivateEntityViewportFrame +{ + private readonly ICreatureAppraisalRenderer _renderer; + private readonly ICreatureAppraisalFrameView _view; + private readonly ICreatureAppraisalCloneFactory _factory; + private WorldEntity? _clone; + private uint _serverGuid; + + public CreatureAppraisalFramePresenter( + ICreatureAppraisalRenderer renderer, + ICreatureAppraisalFrameView view, + ICreatureAppraisalCloneFactory factory) + { + _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); + _view = view ?? throw new ArgumentNullException(nameof(view)); + _factory = factory ?? throw new ArgumentNullException(nameof(factory)); + } + + public void Render() + { + if (!_view.TryGetVisibleTarget( + out uint serverGuid, + out int width, + out int height)) + { + return; + } + + if (_serverGuid != serverGuid) + { + _serverGuid = serverGuid; + _clone = null; + } + + if (!_factory.TrySynchronize( + serverGuid, + _clone, + out WorldEntity? synchronized, + out Vector3 boundsMin, + out Vector3 boundsMax)) + { + _clone = null; + _renderer.SetCreature(null, Vector3.Zero, Vector3.Zero); + _view.SetTextureHandle(0u); + return; + } + + _clone = synchronized; + _renderer.SetCreature(_clone, boundsMin, boundsMax); + _view.SetTextureHandle(_renderer.Render(width, height)); + } +} + +/// +/// Retained-view adapter for LayoutDesc 0x2100006B element 0x10000148. +/// Parent visibility is checked explicitly because the viewport remains locally +/// visible while retail switches the examination subview to an item. +/// +internal sealed class RetailCreatureAppraisalFrameView : + ICreatureAppraisalFrameView +{ + private readonly UiViewport _viewport; + private readonly UiElement _windowFrame; + private readonly AppraisalUiController _controller; + + public RetailCreatureAppraisalFrameView( + UiViewport viewport, + UiElement windowFrame, + AppraisalUiController controller) + { + _viewport = viewport ?? throw new ArgumentNullException(nameof(viewport)); + _windowFrame = windowFrame ?? throw new ArgumentNullException(nameof(windowFrame)); + _controller = controller ?? throw new ArgumentNullException(nameof(controller)); + } + + public bool TryGetVisibleTarget( + out uint serverGuid, + out int width, + out int height) + { + serverGuid = 0u; + width = 0; + height = 0; + if (_controller.ActiveView is not ( + AppraisalView.Creature or AppraisalView.Character) + || !IsEffectivelyVisible(_windowFrame) + || !IsEffectivelyVisible(_viewport) + || _controller.CurrentObjectId == 0u) + { + return false; + } + + serverGuid = _controller.CurrentObjectId; + width = (int)_viewport.Width; + height = (int)_viewport.Height; + return width > 0 && height > 0; + } + + public void SetTextureHandle(uint textureHandle) => + _viewport.TextureHandle = textureHandle; + + private static bool IsEffectivelyVisible(UiElement element) + { + for (UiElement? current = element; current is not null; current = current.Parent) + if (!current.Visible) + return false; + return true; + } +} + +internal sealed class LiveCreatureAppraisalEntityLookup : + ICreatureAppraisalEntityLookup +{ + private readonly LiveEntityRuntime _liveEntities; + + public LiveCreatureAppraisalEntityLookup(LiveEntityRuntime liveEntities) + { + _liveEntities = liveEntities + ?? throw new ArgumentNullException(nameof(liveEntities)); + } + + public bool TryGet(uint serverGuid, out WorldEntity entity) => + _liveEntities.TryGetWorldEntity(serverGuid, out entity); +} + +/// +/// Retail BasicCreatureExamineUI::Init @ 0x004AB9C0 clones the selected +/// physics object, fixes its heading at 191.367905 degrees, and lets the private +/// CreatureMode animate that clone. ACDream shares the already-resolved live +/// MeshRefs and updates that reference every frame, giving the private clone the +/// same current animated pose without registering a second gameplay entity. +/// +internal sealed class RetailCreatureAppraisalCloneFactory : + ICreatureAppraisalCloneFactory +{ + private readonly ICreatureAppraisalEntityLookup _entities; + + public RetailCreatureAppraisalCloneFactory( + ICreatureAppraisalEntityLookup entities) + { + _entities = entities ?? throw new ArgumentNullException(nameof(entities)); + } + + public bool TrySynchronize( + uint serverGuid, + WorldEntity? currentClone, + out WorldEntity? synchronizedClone, + out Vector3 boundsMin, + out Vector3 boundsMax) + { + synchronizedClone = null; + boundsMin = Vector3.Zero; + boundsMax = Vector3.Zero; + if (!_entities.TryGet(serverGuid, out WorldEntity source) + || source.MeshRefs.Count == 0) + { + return false; + } + + WorldEntity clone = currentClone is not null + && currentClone.SourceGfxObjOrSetupId == source.SourceGfxObjOrSetupId + ? currentClone + : CreatureAppraisalEntityBuilder.Build(source); + + clone.ApplyAppearance( + source.MeshRefs, + source.PaletteOverride, + source.PartOverrides); + clone.IsDrawVisible = source.IsDrawVisible; + clone.IsAncestorDrawVisible = source.IsAncestorDrawVisible; + if (source.HasLocalBounds) + clone.SetLocalBounds(source.LocalBoundMin, source.LocalBoundMax); + + (boundsMin, boundsMax) = + CreatureAppraisalEntityBuilder.RotatedBounds(source); + synchronizedClone = clone; + return true; + } +} + +internal static class CreatureAppraisalEntityBuilder +{ + public const uint RenderId = 0xDA11_D022u; + public const uint ServerGuid = 0xDA11_D021u; + private const float HeadingDegrees = 191.367905f; + private static readonly Quaternion Heading = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, + -HeadingDegrees * (MathF.PI / 180f)); + + public static WorldEntity Build(WorldEntity source) + { + ArgumentNullException.ThrowIfNull(source); + var clone = new WorldEntity + { + Id = RenderId, + ServerGuid = ServerGuid, + SourceGfxObjOrSetupId = source.SourceGfxObjOrSetupId, + Position = Vector3.Zero, + Rotation = Heading, + MeshRefs = source.MeshRefs, + PaletteOverride = source.PaletteOverride, + PartOverrides = source.PartOverrides, + HiddenPartsMask = source.HiddenPartsMask, + Scale = source.Scale, + ParentCellId = null, + EffectCellId = null, + }; + if (source.HasLocalBounds) + clone.SetLocalBounds(source.LocalBoundMin, source.LocalBoundMax); + return clone; + } + + public static (Vector3 Min, Vector3 Max) RotatedBounds( + WorldEntity source) + { + Vector3 min; + Vector3 max; + if (source.HasLocalBounds) + { + min = source.LocalBoundMin; + max = source.LocalBoundMax; + } + else + { + min = new Vector3(-0.5f, -0.5f, 0f); + max = new Vector3(0.5f, 0.5f, 2f); + foreach (MeshRef mesh in source.MeshRefs) + { + Vector3 p = mesh.PartTransform.Translation; + min = Vector3.Min(min, p - new Vector3(0.5f)); + max = Vector3.Max(max, p + new Vector3(0.5f)); + } + } + + Vector3 rotatedMin = default; + Vector3 rotatedMax = default; + for (int corner = 0; corner < 8; corner++) + { + Vector3 point = new( + (corner & 1) == 0 ? min.X : max.X, + (corner & 2) == 0 ? min.Y : max.Y, + (corner & 4) == 0 ? min.Z : max.Z); + point = Vector3.Transform(point, Heading); + if (corner == 0) + rotatedMin = rotatedMax = point; + else + { + rotatedMin = Vector3.Min(rotatedMin, point); + rotatedMax = Vector3.Max(rotatedMax, point); + } + } + return (rotatedMin, rotatedMax); + } +} + +/// +/// Bounding-box camera from BasicCreatureExamineUI::Init @ 0x004AB9C0. +/// The camera keeps retail's identity direction (+Y) and 45-degree vertical +/// field of view. Its distance is the fitted vertical span times 1.20710683, +/// plus half the creature's front-to-back depth. +/// +internal sealed class CreatureAppraisalCamera : + IPrivateEntityViewportCamera +{ + private const float FitDistanceFactor = 1.20710683f; + private Vector3 _boundsMin = new(-0.5f, -0.5f, 0f); + private Vector3 _boundsMax = new(0.5f, 0.5f, 2f); + private float _aspect = 1f; + private Vector3 _eye; + + public CreatureAppraisalCamera() => Recalculate(); + + public float Aspect + { + get => _aspect; + set + { + _aspect = float.IsFinite(value) && value > 0f ? value : 1f; + Recalculate(); + } + } + + public Vector3 Eye => _eye; + public float FovRadians { get; set; } = MathF.PI / 4f; + public float Near { get; set; } = 0.1f; + public float Far { get; set; } = 2048f; + + public Matrix4x4 View => + Matrix4x4.CreateLookAt(_eye, _eye + Vector3.UnitY, Vector3.UnitZ); + + public Matrix4x4 Projection => Matrix4x4.CreatePerspectiveFieldOfView( + FovRadians, + _aspect, + Near, + Far); + + public void Fit(Vector3 min, Vector3 max) + { + _boundsMin = Vector3.Min(min, max); + _boundsMax = Vector3.Max(min, max); + Recalculate(); + } + + private void Recalculate() + { + Vector3 span = Vector3.Max( + _boundsMax - _boundsMin, + new Vector3(0.001f)); + Vector3 center = (_boundsMin + _boundsMax) * 0.5f; + float fittedVertical = MathF.Max(span.Z, span.X / _aspect); + float distance = fittedVertical * FitDistanceFactor + span.Y * 0.5f; + _eye = new Vector3(center.X, center.Y - distance, center.Z); + } +} + +/// Examination-specific facade over the shared private renderer. +internal sealed class CreatureAppraisalViewportRenderer : + IUiViewportRenderer, + ICreatureAppraisalRenderer, + IDisposable +{ + private readonly CreatureAppraisalCamera _camera = new(); + private readonly PrivateEntityViewportRenderer _renderer; + + public CreatureAppraisalViewportRenderer( + GL gl, + WbDrawDispatcher dispatcher, + SceneLightingUboBinding lightUbo, + IEntityTextureLifetime textureLifetime) + { + _renderer = new PrivateEntityViewportRenderer( + gl, + dispatcher, + lightUbo, + textureLifetime, + CreatureAppraisalEntityBuilder.RenderId, + _camera, + "creature examination"); + } + + public void SetCreature( + WorldEntity? creature, + Vector3 boundsMin, + Vector3 boundsMax) + { + if (creature is not null) + _camera.Fit(boundsMin, boundsMax); + _renderer.SetEntity(creature); + } + + public uint Render(int width, int height) => + _renderer.Render(width, height); + + public void Dispose() => _renderer.Dispose(); +} diff --git a/src/AcDream.App/Rendering/DollCamera.cs b/src/AcDream.App/Rendering/DollCamera.cs index 53a164ea..3a37cdb1 100644 --- a/src/AcDream.App/Rendering/DollCamera.cs +++ b/src/AcDream.App/Rendering/DollCamera.cs @@ -31,7 +31,7 @@ namespace AcDream.App.Rendering; public sealed class DollCamera : ICamera { // Retail paperdoll camera origin (decomp 0x004a5a51–0x004a5a61). - private static readonly Vector3 Eye = new(0.12f, -2.4f, 0.88f); + internal static readonly Vector3 RetailEye = new(0.12f, -2.4f, 0.88f); // Identity view orientation ⇒ look straight down +Y (no yaw/pitch). Target = Eye + (0,1,0). private static readonly Vector3 Target = new(0.12f, -1.4f, 0.88f); private static readonly Vector3 Up = Vector3.UnitZ; // AC up-axis = +Z, same as ChaseCamera @@ -45,9 +45,42 @@ public sealed class DollCamera : ICamera /// public Matrix4x4 View => - Matrix4x4.CreateLookAt(Eye, Target, Up); + Matrix4x4.CreateLookAt(RetailEye, Target, Up); /// public Matrix4x4 Projection => Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0f ? 1f : Aspect, Near, Far); } + +/// +/// Internal private-viewport adapter that preserves 's +/// existing public camera contract. +/// +internal sealed class DollViewportCamera : IPrivateEntityViewportCamera +{ + private readonly DollCamera _camera = new(); + + public Vector3 Eye => DollCamera.RetailEye; + public float FovRadians + { + get => _camera.FovRadians; + set => _camera.FovRadians = value; + } + public float Near + { + get => _camera.Near; + set => _camera.Near = value; + } + public float Far + { + get => _camera.Far; + set => _camera.Far = value; + } + public float Aspect + { + get => _camera.Aspect; + set => _camera.Aspect = value; + } + public Matrix4x4 View => _camera.View; + public Matrix4x4 Projection => _camera.Projection; +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a12abe6e..8c598e60 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -378,6 +378,10 @@ public sealed class GameWindow : // widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625). private AcDream.App.Rendering.PaperdollViewportRenderer? _paperdollViewportRenderer; private AcDream.App.Rendering.PaperdollFramePresenter? _paperdollFramePresenter; + private AcDream.App.Rendering.CreatureAppraisalViewportRenderer? + _creatureAppraisalViewportRenderer; + private AcDream.App.Rendering.CreatureAppraisalFramePresenter? + _creatureAppraisalFramePresenter; // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad. private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; // Phase I.2: ImGui debug panel ViewModel. The devtools presenter owns @@ -948,6 +952,8 @@ public sealed class GameWindow : || _animationPresenter is not null || _paperdollViewportRenderer is not null || _paperdollFramePresenter is not null + || _creatureAppraisalViewportRenderer is not null + || _creatureAppraisalFramePresenter is not null || _envCellRenderer is not null || _envCellFrustum is not null || _landblockPresentationPipeline is not null @@ -983,6 +989,8 @@ public sealed class GameWindow : _retainedUiGameplayBinding = result.RetainedGameplay; _paperdollViewportRenderer = result.PaperdollRenderer; _paperdollFramePresenter = result.PaperdollPresenter; + _creatureAppraisalViewportRenderer = result.CreatureAppraisalRenderer; + _creatureAppraisalFramePresenter = result.CreatureAppraisalPresenter; _envCellFrustum = result.EnvCellFrustum; _envCellRenderer = result.EnvCellRenderer; _landblockPresentationPipeline = result.LandblockPipeline; @@ -1581,6 +1589,7 @@ public sealed class GameWindow : _localPlayerTeleport, _portalTunnelFallback, _paperdollViewportRenderer, + _creatureAppraisalViewportRenderer, _wbDrawDispatcher, _envCellRenderer, _portalDepthMask, diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index f32696a0..a39fc60a 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -96,6 +96,7 @@ internal sealed record RenderShutdownRoots( LocalPlayerTeleportController? LocalTeleport, TransferableResourceSlot PortalTunnelFallback, PaperdollViewportRenderer? Paperdoll, + CreatureAppraisalViewportRenderer? CreatureAppraisal, WbDrawDispatcher? DrawDispatcher, EnvCellRenderer? EnvironmentCells, PortalDepthMaskRenderer? PortalDepthMask, @@ -413,6 +414,9 @@ internal static class GameWindowShutdownManifest render.PortalTunnelFallback.ReleaseFallback(); }), Hard("paperdoll viewport", () => render.Paperdoll?.Dispose()), + Hard( + "creature appraisal viewport", + () => render.CreatureAppraisal?.Dispose()), Hard("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()), Hard("environment cells", () => render.EnvironmentCells?.Dispose()), Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()), diff --git a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs index a3149e6f..8ee3167e 100644 --- a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs +++ b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs @@ -48,7 +48,7 @@ internal interface IPaperdollPoseApplicator /// The GL renderer remains a borrowed resource disposed by the existing window /// shutdown transaction. /// -internal sealed class PaperdollFramePresenter : IPrivatePaperdollFrame +internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame { private readonly IPaperdollDollRenderer _renderer; private readonly IPaperdollFrameView _view; diff --git a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs index 258d79bb..303e008e 100644 --- a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs +++ b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Numerics; using AcDream.App.Rendering.Wb; using AcDream.App.UI; using AcDream.Core.Lighting; @@ -10,52 +7,15 @@ using Silk.NET.OpenGL; namespace AcDream.App.Rendering; /// -/// Render-to-texture renderer for the paperdoll 3-D doll (the C# analog of retail -/// CreatureMode::Render). Draws ONE re-dressed player clone (a , -/// built by + wired by ) into a private off-screen -/// framebuffer with a fixed + one distant light, then hands the color -/// texture to the widget which blits it as a normal 2-D sprite. -/// -/// The whole 3-D pass is sealed in a so it can't disturb the -/// surrounding world / UI GL state ([[feedback_render_self_contained_gl_state]]). It runs in the -/// per-frame pre-UI hook, gated on inventory-open ∧ doll-view — NOT from the widget's -/// 2-D OnDraw. -/// -/// Reuses the existing + the player's already-resolved, -/// already-GPU-resident MeshRefs (the doll clones them), so no separate mesh/texture upload is -/// needed. With frustum: null the doll's synthetic landblock is always "visible" and the -/// entity is walked from entry.Entities and drawn from its current MeshRefs — a static held pose. +/// Paperdoll-specific facade over the shared private creature viewport. The +/// fixed camera remains the verbatim retail gmPaperDollUI camera. /// -public sealed unsafe class PaperdollViewportRenderer : +public sealed class PaperdollViewportRenderer : IUiViewportRenderer, IPaperdollDollRenderer, IDisposable { - private readonly GL _gl; - private readonly WbDrawDispatcher _dispatcher; - private readonly SceneLightingUboBinding _lightUbo; - private readonly FixedEntityTextureOwnerLease _textureOwnerLease; - private readonly DollCamera _camera = new(); - - // Off-screen target (lazily (re)created on size change). - private uint _fbo; - private uint _colorTex; - private uint _depthRbo; - private int _fbW; - private int _fbH; - - // The static held-pose doll. null = nothing to draw. - private WorldEntity? _doll; - - // Synthetic landblock for the doll's one-entry Draw tuple. With frustum:null + this as - // neverCullLandblockId the entity is never culled. 0 is unused by live streaming on the doll path. - private const uint DollLandblockId = 0u; - - // The doll's render id, passed as "animated" so the dispatcher bypasses the Tier-1 classification - // cache (WbDrawDispatcher.cs:1142) and re-classifies from the current MeshRefs every frame. This is - // what makes a re-dress (a new WorldEntity built with the same DollRenderId) actually take effect, - // AND what lets the idle animation (later slice, TickAnimations rebuilds MeshRefs) show. - private static readonly HashSet _dollAnimatedIds = new() { DollEntityBuilder.DollRenderId }; + private readonly PrivateEntityViewportRenderer _renderer; public PaperdollViewportRenderer( GL gl, @@ -63,151 +23,20 @@ public sealed unsafe class PaperdollViewportRenderer : SceneLightingUboBinding lightUbo, IEntityTextureLifetime textureLifetime) { - _gl = gl ?? throw new ArgumentNullException(nameof(gl)); - _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); - _lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo)); - _textureOwnerLease = new FixedEntityTextureOwnerLease( + _renderer = new PrivateEntityViewportRenderer( + gl, + dispatcher, + lightUbo, textureLifetime, - DollEntityBuilder.DollRenderId); + DollEntityBuilder.DollRenderId, + new DollViewportCamera(), + "paperdoll"); } - /// Set (or clear) the doll entity built from the live player projection. - public void SetDoll(WorldEntity? doll) - { - if (ReferenceEquals(_doll, doll)) - return; - _textureOwnerLease.Replace(doll is not null); - _doll = doll; - } + public void SetDoll(WorldEntity? doll) => _renderer.SetEntity(doll); - /// - public uint Render(int width, int height) - { - var doll = _doll; - if (doll is null || doll.MeshRefs.Count == 0 || width <= 0 || height <= 0) - return 0u; + public uint Render(int width, int height) => + _renderer.Render(width, height); - EnsureFramebuffer(width, height); - if (_fbo == 0) return 0u; - _camera.Aspect = width / (float)height; - - // Seal the entire 3-D pass — GLStateScope restores viewport/scissor/depth/cull/blend/program/ - // FBO bindings on dispose, so the surrounding world + 2-D UI passes are untouched. - using var scope = new GLStateScope(_gl); - - _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo); - _gl.Viewport(0, 0, (uint)width, (uint)height); - _gl.Disable(EnableCap.ScissorTest); - _gl.ClearColor(0f, 0f, 0f, 0f); // transparent — the window backdrop shows through behind the doll - _gl.ClearDepth(1.0); - _gl.DepthMask(true); // depth clears honor glDepthMask - _gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); - - _gl.Enable(EnableCap.DepthTest); - _gl.DepthFunc(DepthFunction.Less); - _gl.Enable(EnableCap.CullFace); - _gl.CullFace(TriangleFace.Back); - _gl.FrontFace(FrontFaceDirection.Ccw); // matches the world object pass; if the doll renders inside-out at the gate, flip to Cw - _gl.Disable(EnableCap.Blend); - - UploadDollLight(); - - // One synthetic landblock entry holding just the doll. frustum:null ⇒ always "visible" ⇒ - // walked from entry.Entities and drawn from doll.MeshRefs (WbDrawDispatcher.cs:692,1190). - var entities = new WorldEntity[] { doll }; - var entries = new (uint, Vector3, Vector3, IReadOnlyList, IReadOnlyDictionary?)[] - { - (DollLandblockId, new Vector3(-4f, -4f, -4f), new Vector3(4f, 4f, 4f), entities, null), - }; - - _dispatcher.Draw( - _camera, - entries, - frustum: null, - neverCullLandblockId: DollLandblockId, - visibleCellIds: null, - animatedEntityIds: _dollAnimatedIds); // doll treated as animated ⇒ cache-bypass (re-dress correctness) - - return _colorTex; - } - - /// Overwrite the shared scene-lighting UBO (binding=1) with the doll's single distant - /// light. Safe: nothing else draws 3-D after the doll this frame, and GameWindow rebuilds the - /// world UBO at the start of the next frame. Retail values: DISTANT_LIGHT dir (0.3,1.9,0.65) - /// intensity 2.0 (gmPaperDollUI::PostInit decomp 175529-175533). - private void UploadDollLight() - { - var dir = Vector3.Normalize(new Vector3(0.3f, 1.9f, 0.65f)); - var ubo = new SceneLightingUbo - { - Light0 = new UboLight - { - PosAndKind = new Vector4(0f, 0f, 0f, 0f), // kind 0 = directional - DirAndRange = new Vector4(dir, 1e9f), // huge range = no distance cutoff - ColorAndIntensity = new Vector4(1f, 1f, 1f, 2.0f), // white @ retail intensity 2.0 - ConeAngleEtc = Vector4.Zero, - }, - CellAmbient = new Vector4(0.30f, 0.30f, 0.30f, 1f), // retail CreatureMode default ambient (ctor 0x004543cf = 0.3,0.3,0.3) - FogParams = new Vector4(1e9f, 1e9f, 0f, 0f), // fog pushed to infinity = no fog on the doll - FogColor = Vector4.Zero, - CameraAndTime = new Vector4(0.12f, -2.4f, 0.88f, 0f), // camera world pos (matches DollCamera eye) - }; - _lightUbo.Upload(ubo); - } - - /// (Re)create the FBO + color texture + depth-stencil renderbuffer when the requested - /// size changes. Color is RGBA8 with linear filtering + clamp; depth is Depth24Stencil8. - private void EnsureFramebuffer(int width, int height) - { - if (_fbo != 0 && width == _fbW && height == _fbH) return; - DeleteFramebuffer(); - - _fbW = width; - _fbH = height; - - _colorTex = _gl.GenTexture(); - _gl.BindTexture(TextureTarget.Texture2D, _colorTex); - _gl.TexImage2D(TextureTarget.Texture2D, 0, InternalFormat.Rgba8, (uint)width, (uint)height, 0, - PixelFormat.Rgba, PixelType.UnsignedByte, (void*)0); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Linear); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge); - _gl.BindTexture(TextureTarget.Texture2D, 0); - - _depthRbo = _gl.GenRenderbuffer(); - _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, _depthRbo); - _gl.RenderbufferStorage(RenderbufferTarget.Renderbuffer, InternalFormat.Depth24Stencil8, (uint)width, (uint)height); - _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0); - - _fbo = _gl.GenFramebuffer(); - _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo); - _gl.FramebufferTexture2D(FramebufferTarget.Framebuffer, FramebufferAttachment.ColorAttachment0, - TextureTarget.Texture2D, _colorTex, 0); - _gl.FramebufferRenderbuffer(FramebufferTarget.Framebuffer, FramebufferAttachment.DepthStencilAttachment, - RenderbufferTarget.Renderbuffer, _depthRbo); - - var status = _gl.CheckFramebufferStatus(FramebufferTarget.Framebuffer); - _gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0); - if (status != GLEnum.FramebufferComplete) - { - Console.WriteLine($"[paperdoll] framebuffer incomplete: {status} ({width}x{height})"); - DeleteFramebuffer(); - } - } - - private void DeleteFramebuffer() - { - if (_fbo != 0) { _gl.DeleteFramebuffer(_fbo); _fbo = 0; } - if (_colorTex != 0) { _gl.DeleteTexture(_colorTex); _colorTex = 0; } - if (_depthRbo != 0) { _gl.DeleteRenderbuffer(_depthRbo); _depthRbo = 0; } - _fbW = _fbH = 0; - } - - public void Dispose() - { - _doll = null; - _textureOwnerLease.Dispose(); - DeleteFramebuffer(); - } + public void Dispose() => _renderer.Dispose(); } diff --git a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs new file mode 100644 index 00000000..02686955 --- /dev/null +++ b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs @@ -0,0 +1,252 @@ +using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.App.UI; +using AcDream.Core.Lighting; +using AcDream.Core.World; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// Camera contract for retail CreatureMode-style UI viewports. The +/// renderer supplies the authored viewport aspect before each draw and uses +/// when it publishes the private scene lighting UBO. +/// +internal interface IPrivateEntityViewportCamera : ICamera +{ + Vector3 Eye { get; } +} + +/// +/// Shared render-to-texture implementation for the private 3-D creature +/// viewports used by paperdoll and examination UI. Each instance owns one FBO, +/// one synthetic render identity, and one balanced texture-owner lease. +/// +internal sealed unsafe class PrivateEntityViewportRenderer : + IUiViewportRenderer, + IDisposable +{ + private const uint PrivateLandblockId = 0u; + + private readonly GL _gl; + private readonly WbDrawDispatcher _dispatcher; + private readonly SceneLightingUboBinding _lightUbo; + private readonly FixedEntityTextureOwnerLease _textureOwnerLease; + private readonly IPrivateEntityViewportCamera _camera; + private readonly HashSet _animatedIds; + private readonly string _diagnosticName; + + private uint _fbo; + private uint _colorTex; + private uint _depthRbo; + private int _fbW; + private int _fbH; + private WorldEntity? _entity; + + public PrivateEntityViewportRenderer( + GL gl, + WbDrawDispatcher dispatcher, + SceneLightingUboBinding lightUbo, + IEntityTextureLifetime textureLifetime, + uint renderId, + IPrivateEntityViewportCamera camera, + string diagnosticName) + { + if (renderId == 0u) + throw new ArgumentOutOfRangeException(nameof(renderId)); + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _diagnosticName = string.IsNullOrWhiteSpace(diagnosticName) + ? "creature viewport" + : diagnosticName; + _animatedIds = [renderId]; + _textureOwnerLease = new FixedEntityTextureOwnerLease( + textureLifetime ?? throw new ArgumentNullException(nameof(textureLifetime)), + renderId); + } + + public void SetEntity(WorldEntity? entity) + { + if (ReferenceEquals(_entity, entity)) + return; + _textureOwnerLease.Replace(entity is not null); + _entity = entity; + } + + public uint Render(int width, int height) + { + WorldEntity? entity = _entity; + if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0) + return 0u; + + EnsureFramebuffer(width, height); + if (_fbo == 0u) + return 0u; + _camera.Aspect = width / (float)height; + + using var scope = new GLStateScope(_gl); + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo); + _gl.Viewport(0, 0, (uint)width, (uint)height); + _gl.Disable(EnableCap.ScissorTest); + _gl.ClearColor(0f, 0f, 0f, 0f); + _gl.ClearDepth(1.0); + _gl.DepthMask(true); + _gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); + + _gl.Enable(EnableCap.DepthTest); + _gl.DepthFunc(DepthFunction.Less); + _gl.Enable(EnableCap.CullFace); + _gl.CullFace(TriangleFace.Back); + _gl.FrontFace(FrontFaceDirection.Ccw); + _gl.Disable(EnableCap.Blend); + + UploadCreatureLight(); + + WorldEntity[] entities = [entity]; + var entries = + new (uint, Vector3, Vector3, IReadOnlyList, + IReadOnlyDictionary?)[] + { + ( + PrivateLandblockId, + new Vector3(-1024f), + new Vector3(1024f), + entities, + null), + }; + + _dispatcher.Draw( + _camera, + entries, + frustum: null, + neverCullLandblockId: PrivateLandblockId, + visibleCellIds: null, + animatedEntityIds: _animatedIds); + return _colorTex; + } + + /// + /// Both retail paperdoll and creature examination call + /// UIElement_Viewport::SetLight(DISTANT_LIGHT, 2, (0.3,1.9,0.65)). + /// + private void UploadCreatureLight() + { + Vector3 direction = Vector3.Normalize(new Vector3(0.3f, 1.9f, 0.65f)); + _lightUbo.Upload(new SceneLightingUbo + { + Light0 = new UboLight + { + PosAndKind = Vector4.Zero, + DirAndRange = new Vector4(direction, 1e9f), + ColorAndIntensity = new Vector4(1f, 1f, 1f, 2f), + ConeAngleEtc = Vector4.Zero, + }, + CellAmbient = new Vector4(0.3f, 0.3f, 0.3f, 1f), + FogParams = new Vector4(1e9f, 1e9f, 0f, 0f), + FogColor = Vector4.Zero, + CameraAndTime = new Vector4(_camera.Eye, 0f), + }); + } + + private void EnsureFramebuffer(int width, int height) + { + if (_fbo != 0u && width == _fbW && height == _fbH) + return; + DeleteFramebuffer(); + + _fbW = width; + _fbH = height; + _colorTex = _gl.GenTexture(); + _gl.BindTexture(TextureTarget.Texture2D, _colorTex); + _gl.TexImage2D( + TextureTarget.Texture2D, + 0, + InternalFormat.Rgba8, + (uint)width, + (uint)height, + 0, + PixelFormat.Rgba, + PixelType.UnsignedByte, + (void*)0); + _gl.TexParameter( + TextureTarget.Texture2D, + TextureParameterName.TextureMinFilter, + (int)TextureMinFilter.Linear); + _gl.TexParameter( + TextureTarget.Texture2D, + TextureParameterName.TextureMagFilter, + (int)TextureMinFilter.Linear); + _gl.TexParameter( + TextureTarget.Texture2D, + TextureParameterName.TextureWrapS, + (int)TextureWrapMode.ClampToEdge); + _gl.TexParameter( + TextureTarget.Texture2D, + TextureParameterName.TextureWrapT, + (int)TextureWrapMode.ClampToEdge); + _gl.BindTexture(TextureTarget.Texture2D, 0u); + + _depthRbo = _gl.GenRenderbuffer(); + _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, _depthRbo); + _gl.RenderbufferStorage( + RenderbufferTarget.Renderbuffer, + InternalFormat.Depth24Stencil8, + (uint)width, + (uint)height); + _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0u); + + _fbo = _gl.GenFramebuffer(); + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo); + _gl.FramebufferTexture2D( + FramebufferTarget.Framebuffer, + FramebufferAttachment.ColorAttachment0, + TextureTarget.Texture2D, + _colorTex, + 0); + _gl.FramebufferRenderbuffer( + FramebufferTarget.Framebuffer, + FramebufferAttachment.DepthStencilAttachment, + RenderbufferTarget.Renderbuffer, + _depthRbo); + + GLEnum status = _gl.CheckFramebufferStatus( + FramebufferTarget.Framebuffer); + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0u); + if (status != GLEnum.FramebufferComplete) + { + Console.WriteLine( + $"[{_diagnosticName}] framebuffer incomplete: {status} ({width}x{height})"); + DeleteFramebuffer(); + } + } + + private void DeleteFramebuffer() + { + if (_fbo != 0u) + { + _gl.DeleteFramebuffer(_fbo); + _fbo = 0u; + } + if (_colorTex != 0u) + { + _gl.DeleteTexture(_colorTex); + _colorTex = 0u; + } + if (_depthRbo != 0u) + { + _gl.DeleteRenderbuffer(_depthRbo); + _depthRbo = 0u; + } + _fbW = 0; + _fbH = 0; + } + + public void Dispose() + { + _entity = null; + _textureOwnerLease.Dispose(); + DeleteFramebuffer(); + } +} diff --git a/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs b/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs index b82aa5c3..1a3d5db8 100644 --- a/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs +++ b/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs @@ -11,7 +11,7 @@ internal interface IPrivatePortalViewport void Draw(int width, int height); } -internal interface IPrivatePaperdollFrame +internal interface IPrivateEntityViewportFrame { void Render(); } @@ -35,7 +35,7 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha { private readonly IPrivatePortalViewport _portal; private readonly IRenderFrameFoundationSource _foundation; - private readonly IPrivatePaperdollFrame? _paperdoll; + private readonly IPrivateEntityViewportFrame? _entityViewports; private readonly IRetainedGameplayUiFrame? _gameplayUi; private readonly IDevToolsFrameLifecycle? _devTools; private readonly IPrivateFrameScreenshot? _screenshots; @@ -43,7 +43,7 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha public PrivatePresentationRenderer( IPrivatePortalViewport portal, IRenderFrameFoundationSource foundation, - IPrivatePaperdollFrame? paperdoll, + IPrivateEntityViewportFrame? entityViewports, IRetainedGameplayUiFrame? gameplayUi, IDevToolsFrameLifecycle? devTools, IPrivateFrameScreenshot? screenshots) @@ -51,7 +51,7 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha _portal = portal ?? throw new ArgumentNullException(nameof(portal)); _foundation = foundation ?? throw new ArgumentNullException(nameof(foundation)); - _paperdoll = paperdoll; + _entityViewports = entityViewports; _gameplayUi = gameplayUi; _devTools = devTools; _screenshots = screenshots; @@ -68,7 +68,7 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha bool portalViewportVisible = _foundation.Foundation.PortalViewportVisible; _portal.Draw(input.ViewportWidth, input.ViewportHeight); - _paperdoll?.Render(); + _entityViewports?.Render(); _gameplayUi?.Render( input.DeltaSeconds, input.ViewportWidth, @@ -86,6 +86,30 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha } } +/// +/// Ordered private creature-view presentation. Retail renders each visible +/// CreatureMode before the retained 2-D tree that displays its texture. +/// +internal sealed class PrivateEntityViewportFrameGroup : + IPrivateEntityViewportFrame +{ + private readonly IPrivateEntityViewportFrame[] _frames; + + public PrivateEntityViewportFrameGroup( + params IPrivateEntityViewportFrame?[] frames) + { + _frames = frames.Where(static frame => frame is not null) + .Cast() + .ToArray(); + } + + public void Render() + { + foreach (IPrivateEntityViewportFrame frame in _frames) + frame.Render(); + } +} + /// Typed portal viewport adapter over the canonical teleport owner. internal sealed class LocalPlayerPortalViewport : IPrivatePortalViewport { diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index 8c062bb3..7cd0d955 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -27,6 +27,10 @@ public sealed class AppraisalUiController : IRetainedPanelController public const uint SignatureTextId = 0x1000013Fu; public const uint InscriptionScrollbarId = 0x1000046Eu; public const uint CreaturePanelId = 0x10000140u; + public const uint CreatureViewportId = 0x10000148u; + public const uint CreatureStatsListId = 0x10000149u; + public const uint CreatureDisplayNameId = 0x1000014Eu; + public const uint CreatureLevelValueId = 0x1000014Cu; public const uint SpellPanelId = 0x10000153u; private const uint TemplateStringProperty = 5u; @@ -53,6 +57,9 @@ public sealed class AppraisalUiController : IRetainedPanelController private readonly UiText? _signature; private readonly UiDatElement? _inscriptionBackground; private readonly UiButton? _close; + private readonly UiItemList? _creatureStats; + private readonly CreatureAppraisalRowTemplateFactory? _creatureRowTemplates; + private readonly CreatureDisplayNameResolver _creatureNames; private AppraisalView _activeView; private uint _itemObjectId; private uint _creatureObjectId; @@ -81,7 +88,9 @@ public sealed class AppraisalUiController : IRetainedPanelController UiElement itemPanel, UiElement creaturePanel, UiText title, - UiText itemText) + UiText itemText, + CreatureAppraisalRowTemplateFactory? creatureRowTemplates, + CreatureDisplayNameResolver? creatureNames) { _layout = layout; _objects = objects; @@ -97,6 +106,10 @@ public sealed class AppraisalUiController : IRetainedPanelController _spellPanel = layout.FindElement(SpellPanelId); _title = title; _itemText = itemText; + _creatureRowTemplates = creatureRowTemplates; + _creatureNames = creatureNames + ?? new CreatureDisplayNameResolver( + new Dictionary()); _inscriptionText = layout.FindElement(InscriptionTextId) as UiText; _inscriptionField = layout.FindElement(InscriptionTextId) as UiField; _signature = layout.FindElement(SignatureTextId) as UiText; @@ -136,6 +149,25 @@ public sealed class AppraisalUiController : IRetainedPanelController _inscriptionBackground.ClickThrough = false; } + if (creatureRowTemplates is not null + && layout.FindElement(CreatureStatsListId) is { } statsHost) + { + _creatureStats = statsHost as UiItemList + ?? new UiItemList + { + Width = statsHost.Width, + Height = statsHost.Height, + Anchors = AnchorEdges.Left | AnchorEdges.Top + | AnchorEdges.Right | AnchorEdges.Bottom, + }; + if (!ReferenceEquals(_creatureStats, statsHost)) + statsHost.AddChild(_creatureStats); + _creatureStats.Flush(); + _creatureStats.Columns = 1; + _creatureStats.CellWidth = creatureRowTemplates.Width; + _creatureStats.CellHeight = creatureRowTemplates.Height; + } + SetActiveView(AppraisalView.Item); } @@ -152,7 +184,9 @@ public sealed class AppraisalUiController : IRetainedPanelController Action sendSetInscription, Action systemMessage, Action show, - Action close) + Action close, + CreatureAppraisalRowTemplateFactory? creatureRowTemplates = null, + CreatureDisplayNameResolver? creatureNames = null) { ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(objects); @@ -185,7 +219,9 @@ public sealed class AppraisalUiController : IRetainedPanelController itemPanel, creaturePanel, title, - itemText); + itemText, + creatureRowTemplates, + creatureNames); } /// @@ -452,17 +488,12 @@ public sealed class AppraisalUiController : IRetainedPanelController { ClearCreatureText(); PropertyBundle p = appraisal.Properties; - SetText(0x1000014Au, character - ? GetString(p, 4u) - : AppraisalTextFormatter.CreatureTypeName(GetInt(p, 2u))); - SetText(0x1000014Bu, GetString(p, 2u)); - SetText(0x1000014Cu, GetString(p, 14u)); - - string report = AppraisalTextFormatter.BuildCreature( - obj, - appraisal, - character); - SetText(0x1000014Eu, report, scrollable: true); + int level = GetInt(p, 25u); + SetText( + CreatureLevelValueId, + level > 0 + ? level.ToString(CultureInfo.CurrentCulture) + : "???"); if (character) { @@ -470,6 +501,14 @@ public sealed class AppraisalUiController : IRetainedPanelController SetText(0x10000151u, GetString(p, 47u)); SetText(0x10000152u, GetString(p, 11u)); } + else + { + SetText( + CreatureDisplayNameId, + _creatureNames.Resolve(GetInt(p, 2u))); + } + + RebuildCreatureStats(appraisal); SetText(0x1000053Au, appraisal.Success ? string.Empty @@ -478,6 +517,25 @@ public sealed class AppraisalUiController : IRetainedPanelController ResetCreatureScroll(); } + private void RebuildCreatureStats(AppraiseInfoParser.Parsed appraisal) + { + if (_creatureStats is null || _creatureRowTemplates is null) + return; + + using (_creatureStats.DeferLayout()) + { + _creatureStats.Flush(); + if (appraisal.CreatureProfile is not { } profile) + return; + foreach (CreatureAppraisalRow row in CreatureAppraisalRows.Build( + profile, + appraisal.Success)) + { + _creatureStats.AddItem(_creatureRowTemplates.Create(row)); + } + } + } + private void ConfigureScrollableText( UiText text, uint scrollbarId, @@ -513,15 +571,17 @@ public sealed class AppraisalUiController : IRetainedPanelController { foreach (uint id in new uint[] { - 0x1000014Au, 0x1000014Bu, 0x1000014Cu, 0x1000014Eu, + CreatureLevelValueId, CreatureDisplayNameId, 0x10000150u, 0x10000151u, 0x10000152u, 0x1000053Au, }) if (_layout.FindElement(id) is UiText text) text.LinesProvider = static () => Array.Empty(); + _creatureStats?.Flush(); } private void ResetCreatureScroll() { + _creatureStats?.Scroll.SetScrollY(0); foreach (UiText text in Descendants(_creaturePanel).OfType()) text.Scroll.SetScrollY(0); } diff --git a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs new file mode 100644 index 00000000..ef0139c4 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs @@ -0,0 +1,304 @@ +using System.Globalization; +using System.Numerics; +using AcDream.Content; +using AcDream.Core.Net.Messages; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.UI.Layout; + +public enum CreatureAppraisalValueStyle +{ + Normal, + Positive, + Negative, + Incomplete, +} + +public readonly record struct CreatureAppraisalRow( + string Label, + string Value, + CreatureAppraisalValueStyle Style); + +/// +/// Pure projection of retail's six AttributeInfoRegion and three +/// Attribute2ndInfoRegion tokens. Ordering and formatting come from +/// BasicCreatureExamineUI and the two Update overloads at +/// 0x004F1D90/0x004F1E80. +/// +public static class CreatureAppraisalRows +{ + private const string Unknown = "???"; + + public static IReadOnlyList Build( + AppraiseInfoParser.CreatureProfile profile, + bool success) + { + return + [ + Primary("Strength", profile.Strength, 0, profile, success), + Primary("Endurance", profile.Endurance, 1, profile, success), + Primary("Coordination", profile.Coordination, 3, profile, success), + Primary("Quickness", profile.Quickness, 2, profile, success), + Primary("Focus", profile.Focus, 4, profile, success), + Primary("Self", profile.Self, 5, profile, success), + Secondary( + "Health", + profile.Health, + profile.HealthMax, + showPercent: true, + enchantmentBit: 6, + profile, + success), + Secondary( + "Stamina", + profile.Stamina, + profile.StaminaMax, + showPercent: false, + enchantmentBit: 7, + profile, + success), + Secondary( + "Mana", + profile.Mana, + profile.ManaMax, + showPercent: false, + enchantmentBit: 8, + profile, + success), + ]; + } + + private static CreatureAppraisalRow Primary( + string label, + uint? value, + int enchantmentBit, + AppraiseInfoParser.CreatureProfile profile, + bool success) + => new( + label, + value is > 0 + ? value.Value.ToString(CultureInfo.CurrentCulture) + : Unknown, + Style(enchantmentBit, profile, success)); + + private static CreatureAppraisalRow Secondary( + string label, + uint? current, + uint? maximum, + bool showPercent, + int enchantmentBit, + AppraiseInfoParser.CreatureProfile profile, + bool success) + { + string value = Unknown; + if (current is > 0 && maximum is > 0) + { + int percent = RoundedPercent(current.Value, maximum.Value); + if (success) + { + value = showPercent + ? $"{current.Value.ToString(CultureInfo.CurrentCulture)}/{maximum.Value.ToString(CultureInfo.CurrentCulture)} ({percent.ToString(CultureInfo.CurrentCulture)} %)" + : $"{current.Value.ToString(CultureInfo.CurrentCulture)}/{maximum.Value.ToString(CultureInfo.CurrentCulture)}"; + } + else if (showPercent) + { + value = $"{percent.ToString(CultureInfo.CurrentCulture)} %"; + } + } + + return new CreatureAppraisalRow( + label, + value, + Style(enchantmentBit, profile, success)); + } + + private static int RoundedPercent(uint numerator, uint denominator) + => denominator == 0u + ? 0 + : (int)Math.Min( + int.MaxValue, + ((100L * numerator) + denominator / 2L) / denominator); + + private static CreatureAppraisalValueStyle Style( + int bit, + AppraiseInfoParser.CreatureProfile profile, + bool success) + { + if (!success) + return CreatureAppraisalValueStyle.Incomplete; + + ushort highlight = profile.AttributeHighlights ?? 0; + if ((highlight & (1 << bit)) == 0) + return CreatureAppraisalValueStyle.Normal; + + ushort color = profile.AttributeColors ?? 0; + return (color & (1 << bit)) != 0 + ? CreatureAppraisalValueStyle.Positive + : CreatureAppraisalValueStyle.Negative; + } +} + +/// +/// Instantiates LayoutDesc 0x2100006B's InfoRegion token template +/// 0x10000166, matching UIElement_ListBox::AddItemFromTemplateList. +/// +public sealed class CreatureAppraisalRowTemplateFactory +{ + public const uint TemplateId = 0x10000166u; + public const uint LabelId = 0x1000012Au; + public const uint ValueId = 0x1000012Bu; + + private readonly ElementInfo _template; + private readonly Func _resolveSprite; + private readonly UiDatFont? _defaultFont; + private readonly IReadOnlyDictionary _fonts; + + public CreatureAppraisalRowTemplateFactory( + ElementInfo template, + Func resolveSprite, + UiDatFont? defaultFont, + IReadOnlyDictionary? fonts = null) + { + _template = template ?? throw new ArgumentNullException(nameof(template)); + _resolveSprite = resolveSprite ?? throw new ArgumentNullException(nameof(resolveSprite)); + _defaultFont = defaultFont; + _fonts = fonts ?? new Dictionary(); + } + + public float Width => _template.Width; + public float Height => _template.Height; + + public static CreatureAppraisalRowTemplateFactory? TryLoad( + IDatReaderWriter dats, + Func resolveSprite, + UiDatFont? defaultFont, + Func? resolveFont) + { + ElementInfo? template = LayoutImporter.ImportInfos( + dats, + AppraisalUiController.LayoutId, + TemplateId); + if (template is null + || Find(template, LabelId) is null + || Find(template, ValueId) is null + || template.Width <= 0f + || template.Height <= 0f) + { + return null; + } + + var fonts = new Dictionary(); + CaptureFonts(template, resolveFont, fonts); + return new CreatureAppraisalRowTemplateFactory( + template, + resolveSprite, + defaultFont, + fonts); + } + + public UiTemplateListSlot Create(CreatureAppraisalRow row) + { + ImportedLayout content = LayoutImporter.Build( + _template, + _resolveSprite, + _defaultFont, + did => _fonts.TryGetValue(did, out UiDatFont? font) + ? font + : _defaultFont); + UiText label = Required(content, LabelId); + UiText value = Required(content, ValueId); + label.LinesProvider = () => + [new UiText.Line(row.Label, label.DefaultColor)]; + value.LinesProvider = () => + [new UiText.Line(row.Value, ResolveColor(row.Style, value.DefaultColor))]; + + return new UiTemplateListSlot( + content, + entryId: 0u, + content.Root is IUiDatStateful stateful + ? stateful.ActiveRetailStateId + : UiStateInfo.DirectStateId, + selectedState: null); + } + + private static Vector4 ResolveColor( + CreatureAppraisalValueStyle style, + Vector4 normal) + { + // Retail selects one of four authored FontInfo entries here. Keep the + // semantic state in the row model, but do not invent substitute RGB + // values while that FontInfo-list binding remains unported (AP-110). + _ = style; + return normal; + } + + private static T Required(ImportedLayout content, uint id) + where T : UiElement + => content.FindElement(id) as T + ?? throw new InvalidOperationException( + $"Retail creature appraisal template element 0x{id:X8} did not resolve to {typeof(T).Name}."); + + private static ElementInfo? Find(ElementInfo root, uint id) + { + if (root.Id == id) + return root; + foreach (ElementInfo child in root.Children) + if (Find(child, id) is { } found) + return found; + return null; + } + + private static void CaptureFonts( + ElementInfo info, + Func? resolveFont, + Dictionary fonts) + { + if (info.FontDid != 0u && !fonts.ContainsKey(info.FontDid)) + fonts[info.FontDid] = resolveFont?.Invoke(info.FontDid); + foreach (ElementInfo child in info.Children) + CaptureFonts(child, resolveFont, fonts); + } +} + +/// +/// Retail AppraisalSystem::InqCreatureDisplayName @ 0x005B59E0 resolves +/// creature enum 0x10000005 through portal EnumMapper 0x2200000E and replaces +/// underscores with spaces. +/// +public sealed class CreatureDisplayNameResolver +{ + public const uint MapperDid = 0x2200000Eu; + private readonly IReadOnlyDictionary _names; + + public CreatureDisplayNameResolver( + IReadOnlyDictionary names) + { + _names = names ?? throw new ArgumentNullException(nameof(names)); + } + + public static CreatureDisplayNameResolver Load(IDatReaderWriter dats) + { + ArgumentNullException.ThrowIfNull(dats); + var names = new Dictionary(); + var visited = new HashSet(); + uint did = MapperDid; + while (did != 0u && visited.Add(did)) + { + EnumMapper? mapper = dats.Get(did); + if (mapper is null) + break; + foreach ((uint id, PStringBase text) in mapper.IdToStringMap) + names.TryAdd(id, text.Value.Replace('_', ' ')); + did = mapper.BaseEnumMap; + } + return new CreatureDisplayNameResolver(names); + } + + public string Resolve(int creatureType) + => creatureType > 0 + && _names.TryGetValue((uint)creatureType, out string? name) + ? name + : string.Empty; +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 7c361ea1..49e744dd 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -301,6 +301,8 @@ public sealed class RetailUiRuntime : IDisposable public SpellcastingUiController? SpellcastingUiController { get; private set; } public SpellbookWindowController? SpellbookWindowController { get; private set; } public AppraisalUiController? AppraisalController { get; private set; } + public UiViewport? CreatureAppraisalViewportWidget { get; private set; } + public UiElement? ExaminationFrame { get; private set; } public EffectsUiController? PositiveEffectsController { get; private set; } public EffectsUiController? NegativeEffectsController { get; private set; } public LinkStatusUiController? LinkStatusUiController { get; private set; } @@ -961,9 +963,26 @@ public sealed class RetailUiRuntime : IDisposable private void MountAppraisal() { - ImportedLayout? layout = Import( - AppraisalUiController.LayoutId, - AppraisalUiController.RootId); + ImportedLayout? layout; + CreatureAppraisalRowTemplateFactory? creatureRows; + CreatureDisplayNameResolver? creatureNames; + lock (_bindings.Assets.DatLock) + { + layout = LayoutImporter.Import( + _bindings.Assets.Dats, + AppraisalUiController.LayoutId, + AppraisalUiController.RootId, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont); + creatureRows = CreatureAppraisalRowTemplateFactory.TryLoad( + _bindings.Assets.Dats, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont); + creatureNames = CreatureDisplayNameResolver.Load( + _bindings.Assets.Dats); + } if (layout is null) { Console.WriteLine( @@ -981,7 +1000,9 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Appraisal.SendSetInscription, _bindings.Appraisal.DisplaySystemMessage, show: () => Host.ShowWindow(WindowNames.Examination), - close: () => CloseWindow(WindowNames.Examination)); + close: () => CloseWindow(WindowNames.Examination), + creatureRowTemplates: creatureRows, + creatureNames: creatureNames); if (controller is null) { Console.WriteLine( @@ -991,7 +1012,7 @@ public sealed class RetailUiRuntime : IDisposable AppraisalController = controller; UiElement root = layout.Root; - _ = RetailWindowFrame.Mount( + RetailWindowHandle handle = RetailWindowFrame.Mount( Host.Root, root, _bindings.Assets.ResolveSprite, @@ -1013,6 +1034,10 @@ public sealed class RetailUiRuntime : IDisposable ContentClickThrough = false, Controller = controller, }); + CreatureAppraisalViewportWidget = + layout.FindElement(AppraisalUiController.CreatureViewportId) + as UiViewport; + ExaminationFrame = handle.OuterFrame; Console.WriteLine( "[M4] retail examination window from LayoutDesc 0x2100006B."); } diff --git a/tests/AcDream.App.Tests/Rendering/CreatureAppraisalPresentationTests.cs b/tests/AcDream.App.Tests/Rendering/CreatureAppraisalPresentationTests.cs new file mode 100644 index 00000000..3ae4efc8 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/CreatureAppraisalPresentationTests.cs @@ -0,0 +1,196 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering; + +public sealed class CreatureAppraisalPresentationTests +{ + [Fact] + public void CameraFitsRetailVerticalSpanAndKeepsIdentityLookDirection() + { + var camera = new CreatureAppraisalCamera { Aspect = 2f }; + + camera.Fit( + new Vector3(-1f, -0.5f, 0f), + new Vector3(1f, 0.5f, 4f)); + + Assert.Equal(0f, camera.Eye.X, 5); + Assert.Equal(-(4f * 1.20710683f + 0.5f), camera.Eye.Y, 5); + Assert.Equal(2f, camera.Eye.Z, 5); + Vector3 forward = -new Vector3( + camera.View.M13, + camera.View.M23, + camera.View.M33); + Assert.Equal(Vector3.UnitY, forward); + } + + [Fact] + public void CameraUsesViewportAspectWhenCreatureIsWiderThanTall() + { + var camera = new CreatureAppraisalCamera { Aspect = 1f }; + + camera.Fit( + new Vector3(-4f, -0.5f, 0f), + new Vector3(4f, 0.5f, 2f)); + + Assert.Equal(-(8f * 1.20710683f + 0.5f), camera.Eye.Y, 5); + } + + [Fact] + public void CloneFactoryReusesIdentityAndFollowsCurrentAnimatedMeshRefs() + { + var firstMeshes = new[] + { + new MeshRef(0x01000001u, Matrix4x4.Identity), + }; + var source = Entity( + id: 17u, + setup: 0x02000001u, + firstMeshes); + source.SetLocalBounds( + new Vector3(-1f, -0.5f, 0f), + new Vector3(1f, 0.5f, 2f)); + var lookup = new Lookup { Entity = source }; + var factory = new RetailCreatureAppraisalCloneFactory(lookup); + + Assert.True(factory.TrySynchronize( + 17u, + currentClone: null, + out WorldEntity? firstClone, + out Vector3 firstMin, + out Vector3 firstMax)); + var nextMeshes = new[] + { + new MeshRef( + 0x01000001u, + Matrix4x4.CreateTranslation(0f, 0f, 0.25f)), + }; + source.MeshRefs = nextMeshes; + Assert.True(factory.TrySynchronize( + 17u, + firstClone, + out WorldEntity? nextClone, + out Vector3 nextMin, + out Vector3 nextMax)); + + Assert.Same(firstClone, nextClone); + Assert.Same(nextMeshes, nextClone!.MeshRefs); + Assert.Equal(CreatureAppraisalEntityBuilder.RenderId, nextClone.Id); + Assert.Equal(firstMin, nextMin); + Assert.Equal(firstMax, nextMax); + Assert.Equal([17u, 17u], lookup.Requests); + } + + [Fact] + public void PresenterRendersVisibleTargetAndClearsWhenProjectionDisappears() + { + WorldEntity clone = Entity( + CreatureAppraisalEntityBuilder.RenderId, + 0x02000001u, + [new MeshRef(0x01000001u, Matrix4x4.Identity)]); + var renderer = new Renderer { Texture = 73u }; + var view = new View(); + var factory = new Factory { Clone = clone }; + var presenter = new CreatureAppraisalFramePresenter( + renderer, + view, + factory); + + presenter.Render(); + factory.Available = false; + presenter.Render(); + + Assert.Equal([17u, 17u], factory.Requests); + Assert.Same(clone, renderer.Creatures[0]); + Assert.Null(renderer.Creatures[1]); + Assert.Equal([(300, 265)], renderer.RenderSizes); + Assert.Equal([73u, 0u], view.Textures); + } + + private static WorldEntity Entity( + uint id, + uint setup, + IReadOnlyList meshes) + => new() + { + Id = id, + ServerGuid = id, + SourceGfxObjOrSetupId = setup, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = meshes, + }; + + private sealed class Lookup : ICreatureAppraisalEntityLookup + { + public WorldEntity? Entity { get; init; } + public List Requests { get; } = []; + + public bool TryGet(uint serverGuid, out WorldEntity entity) + { + Requests.Add(serverGuid); + entity = Entity!; + return Entity is not null; + } + } + + private sealed class Renderer : ICreatureAppraisalRenderer + { + public uint Texture { get; init; } + public List Creatures { get; } = []; + public List<(int Width, int Height)> RenderSizes { get; } = []; + + public void SetCreature( + WorldEntity? creature, + Vector3 boundsMin, + Vector3 boundsMax) + => Creatures.Add(creature); + + public uint Render(int width, int height) + { + RenderSizes.Add((width, height)); + return Texture; + } + } + + private sealed class View : ICreatureAppraisalFrameView + { + public List Textures { get; } = []; + + public bool TryGetVisibleTarget( + out uint serverGuid, + out int width, + out int height) + { + serverGuid = 17u; + width = 300; + height = 265; + return true; + } + + public void SetTextureHandle(uint textureHandle) => + Textures.Add(textureHandle); + } + + private sealed class Factory : ICreatureAppraisalCloneFactory + { + public bool Available { get; set; } = true; + public WorldEntity? Clone { get; init; } + public List Requests { get; } = []; + + public bool TrySynchronize( + uint serverGuid, + WorldEntity? currentClone, + out WorldEntity? synchronizedClone, + out Vector3 boundsMin, + out Vector3 boundsMax) + { + Requests.Add(serverGuid); + synchronizedClone = Available ? Clone : null; + boundsMin = new Vector3(-1f); + boundsMax = new Vector3(1f); + return Available; + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs index 68e1ac81..6bf45442 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -13,7 +13,7 @@ public sealed class GameWindowRenderLeafCompositionTests AssertAppearsInOrder( presentation, "_portal.Draw(", - "_paperdoll?.Render();", + "_entityViewports?.Render();", "_gameplayUi?.Render(", "_devTools?.Render(", "_screenshots?.CapturePending("); diff --git a/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs b/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs index 318ee615..802e858f 100644 --- a/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs @@ -52,7 +52,7 @@ public sealed class PrivatePresentationRendererTests var presentation = new PrivatePresentationRenderer( new Portal(calls), new FoundationSource(portalVisible: true), - paperdoll: null, + entityViewports: null, gameplayUi: null, devTools: null, screenshots: null); @@ -64,6 +64,20 @@ public sealed class PrivatePresentationRendererTests Assert.False(outcome.ScreenshotCaptured); } + [Fact] + public void PrivateEntityViewportGroup_RendersEveryViewportInOrder() + { + List calls = []; + var group = new PrivateEntityViewportFrameGroup( + new NamedViewport(calls, "paperdoll"), + null, + new NamedViewport(calls, "examination")); + + group.Render(); + + Assert.Equal(["paperdoll", "examination"], calls); + } + [Fact] public void Render_ReportsThePreparedPortalSnapshotWhenDrawMutatesTheSource() { @@ -72,7 +86,7 @@ public sealed class PrivatePresentationRendererTests var presentation = new PrivatePresentationRenderer( new MutatingPortal(calls, () => foundation.PortalVisible = false), foundation, - paperdoll: null, + entityViewports: null, gameplayUi: null, devTools: null, screenshots: null); @@ -209,11 +223,17 @@ public sealed class PrivatePresentationRendererTests } } - private sealed class Paperdoll(List calls) : IPrivatePaperdollFrame + private sealed class Paperdoll(List calls) : IPrivateEntityViewportFrame { public void Render() => calls.Add("paperdoll"); } + private sealed class NamedViewport(List calls, string name) : + IPrivateEntityViewportFrame + { + public void Render() => calls.Add(name); + } + private sealed class GameplayUi(List calls) : IRetainedGameplayUiFrame { public RenderFrameInput Input { get; private set; } diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameRecoveryIntegrationTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameRecoveryIntegrationTests.cs index 9d27e27d..b0c2b6fc 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderFrameRecoveryIntegrationTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameRecoveryIntegrationTests.cs @@ -146,7 +146,7 @@ public sealed class RenderFrameRecoveryIntegrationTests public void Draw(int width, int height) => failure.ThrowIf("portal"); } - private sealed class Paperdoll(FailureSwitch failure) : IPrivatePaperdollFrame + private sealed class Paperdoll(FailureSwitch failure) : IPrivateEntityViewportFrame { public void Render() => failure.ThrowIf("paperdoll"); } diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index 85b5e2f2..b7f536a2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -10,6 +10,7 @@ namespace AcDream.App.Tests.UI.Layout; public sealed class AppraisalUiControllerTests { private const uint ObjectId = 0x50000001u; + private static (uint, int, int) NoTexture(uint _) => (0u, 0, 0); [Fact] public void ItemResponse_UsesAuthoredItemSubviewTitleAndScrollbars() @@ -146,6 +147,114 @@ public sealed class AppraisalUiControllerTests Assert.Equal(1, shown); } + [Fact] + public void CreatureResponse_UsesRetailHeaderAndNineOrderedTemplateRows() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Specter", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + UiText characterLabel = Assert.IsType( + layout.FindElement(0x1000014Au)); + UiText levelLabel = Assert.IsType( + layout.FindElement(0x1000014Bu)); + characterLabel.LinesProvider = () => + [new UiText.Line("Character", characterLabel.DefaultColor)]; + levelLabel.LinesProvider = () => + [new UiText.Line("Level", levelLabel.DefaultColor)]; + var templates = new CreatureAppraisalRowTemplateFactory( + FixtureLoader.LoadExaminationRowTemplateInfos(), + NoTexture, + defaultFont: null); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { }, + templates, + new CreatureDisplayNameResolver( + new Dictionary { [77u] = "Ghost" }))!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Ints[2u] = 77; + properties.Ints[25u] = 80; + var profile = new AppraiseInfoParser.CreatureProfile( + Flags: 0x08u, + Health: 295u, + HealthMax: 295u, + Strength: 120u, + Endurance: 190u, + Quickness: 190u, + Coordination: 190u, + Focus: 330u, + Self: 350u, + Stamina: 190u, + Mana: 550u, + StaminaMax: 190u, + ManaMax: 550u, + AttributeHighlights: (ushort)0, + AttributeColors: (ushort)0); + + Assert.True(controller.Apply(Parsed(properties, profile))); + + Assert.Equal( + "Character", + Assert.Single(characterLabel.LinesProvider()).Text); + Assert.Equal("Level", Assert.Single(levelLabel.LinesProvider()).Text); + Assert.Equal( + "80", + Assert.Single(((UiText)layout.FindElement( + AppraisalUiController.CreatureLevelValueId)!) + .LinesProvider()).Text); + Assert.Equal( + "Ghost", + Assert.Single(((UiText)layout.FindElement( + AppraisalUiController.CreatureDisplayNameId)!) + .LinesProvider()).Text); + + UiElement host = layout.FindElement( + AppraisalUiController.CreatureStatsListId)!; + UiItemList list = Assert.Single(host.Children.OfType()); + Assert.Equal(9, list.GetNumUIItems()); + string[] labels = new string[9]; + string[] values = new string[9]; + for (int index = 0; index < 9; index++) + { + UiTemplateListSlot row = + Assert.IsType(list.GetItem(index)); + labels[index] = Assert.Single( + ((UiText)row.Content.FindElement( + CreatureAppraisalRowTemplateFactory.LabelId)!) + .LinesProvider()).Text; + values[index] = Assert.Single( + ((UiText)row.Content.FindElement( + CreatureAppraisalRowTemplateFactory.ValueId)!) + .LinesProvider()).Text; + } + Assert.Equal( + [ + "Strength", "Endurance", "Coordination", "Quickness", + "Focus", "Self", "Health", "Stamina", "Mana", + ], + labels); + Assert.Equal( + [ + "120", "190", "190", "190", "330", "350", + "295/295 (100 %)", "190/190", "550/550", + ], + values); + } + [Fact] public void ResponseForNeitherPendingNorCurrent_IsIgnored() { @@ -429,7 +538,9 @@ public sealed class AppraisalUiControllerTests List<(uint ObjectId, string Text)> inscriptions, List messages, Action show, - Action close) + Action close, + CreatureAppraisalRowTemplateFactory? creatureRows = null, + CreatureDisplayNameResolver? creatureNames = null) => AppraisalUiController.Bind( layout, objects, @@ -440,7 +551,9 @@ public sealed class AppraisalUiControllerTests (objectId, text) => inscriptions.Add((objectId, text)), messages.Add, show, - close); + close, + creatureRows, + creatureNames); private static ItemInteractionController NewInteraction( ClientObjectTable objects, diff --git a/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs b/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs new file mode 100644 index 00000000..4cee8d62 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs @@ -0,0 +1,100 @@ +using AcDream.App.UI.Layout; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.Tests.UI.Layout; + +public sealed class CreatureAppraisalRowsTests +{ + [Fact] + public void FailedAssessmentShowsOnlyHealthPercentAndUnknownOtherValues() + { + var profile = Profile( + health: 25u, + healthMax: 100u, + highlights: (ushort)0, + colors: (ushort)0); + + IReadOnlyList rows = + CreatureAppraisalRows.Build(profile, success: false); + + Assert.All( + rows, + row => Assert.Equal( + CreatureAppraisalValueStyle.Incomplete, + row.Style)); + Assert.Equal("25 %", rows[6].Value); + Assert.Equal("???", rows[0].Value); + Assert.Equal("???", rows[7].Value); + Assert.Equal("???", rows[8].Value); + } + + [Fact] + public void EnchantmentBitsSelectRetailPositiveAndNegativeStyles() + { + var profile = Profile( + health: 100u, + healthMax: 100u, + highlights: (ushort)((1 << 0) | (1 << 6)), + colors: (ushort)(1 << 0)); + + IReadOnlyList rows = + CreatureAppraisalRows.Build(profile, success: true); + + Assert.Equal(CreatureAppraisalValueStyle.Positive, rows[0].Style); + Assert.Equal(CreatureAppraisalValueStyle.Negative, rows[6].Style); + Assert.Equal(CreatureAppraisalValueStyle.Normal, rows[1].Style); + } + + [Fact] + public void AuthoredRowTemplateCarriesRetailOverlappingLabelValueGeometry() + { + ElementInfo template = + FixtureLoader.LoadExaminationRowTemplateInfos(); + + Assert.Equal(CreatureAppraisalRowTemplateFactory.TemplateId, template.Id); + Assert.Equal((292f, 20f), (template.Width, template.Height)); + ElementInfo label = Assert.Single( + template.Children, + child => child.Id == CreatureAppraisalRowTemplateFactory.LabelId); + ElementInfo value = Assert.Single( + template.Children, + child => child.Id == CreatureAppraisalRowTemplateFactory.ValueId); + Assert.Equal((0f, 128f), (label.X, label.Width)); + Assert.Equal((27f, 256f), (value.X, value.Width)); + Assert.Equal(0x40000001u, label.FontDid); + Assert.Equal(0x40000001u, value.FontDid); + } + + [Fact] + public void CreatureNameResolverUsesLoadedRetailMappingAndSafeFallback() + { + var resolver = new CreatureDisplayNameResolver( + new Dictionary { [77u] = "Ghost" }); + + Assert.Equal("Ghost", resolver.Resolve(77)); + Assert.Equal(string.Empty, resolver.Resolve(0)); + Assert.Equal(string.Empty, resolver.Resolve(999)); + } + + private static AppraiseInfoParser.CreatureProfile Profile( + uint health, + uint healthMax, + ushort highlights, + ushort colors) + => new( + Flags: 0x09u, + Health: health, + HealthMax: healthMax, + Strength: null, + Endurance: null, + Quickness: null, + Coordination: null, + Focus: null, + Self: null, + Stamina: 50u, + Mana: 75u, + StaminaMax: 100u, + ManaMax: 100u, + AttributeHighlights: highlights, + AttributeColors: colors); +} diff --git a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs index 9f1e50f0..2ac0a94c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs +++ b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs @@ -113,6 +113,9 @@ public static class FixtureLoader public static ElementInfo LoadExaminationInfos() => LoadInfos("examine_2100006B_100005F2.json"); + public static ElementInfo LoadExaminationRowTemplateInfos() + => LoadInfos("examine_row_2100006B_10000166.json"); + public static ImportedLayout LoadPowerbar() => LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null); diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs index 80b1afbe..2477194f 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs @@ -132,6 +132,8 @@ public sealed class RetailLayoutFixtureGenerator (CharacterController.LayoutId, CharacterController.RootId, "character_info_2100006E_10000183.json"), (0x2100006Bu, 0x100005F2u, "examine_2100006B_100005F2.json"), + (AppraisalUiController.LayoutId, CreatureAppraisalRowTemplateFactory.TemplateId, + "examine_row_2100006B_10000166.json"), }) { ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId); @@ -145,6 +147,10 @@ public sealed class RetailLayoutFixtureGenerator })); } + CreatureDisplayNameResolver creatureNames = + CreatureDisplayNameResolver.Load(dats); + Assert.Equal("Ghost", creatureNames.Resolve(77)); + ElementInfo? miniGame = LayoutImporter.ImportInfos( dats, MiniGameUiController.LayoutId, MiniGameUiController.RootId); Assert.NotNull(miniGame); diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/examine_row_2100006B_10000166.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/examine_row_2100006B_10000166.json new file mode 100644 index 00000000..6a2e96dc --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/examine_row_2100006B_10000166.json @@ -0,0 +1,1009 @@ +{ + "Id": 268435814, + "Type": 3, + "X": 0, + "Y": 0, + "Width": 292, + "Height": 20, + "OriginalParentWidth": 0, + "OriginalParentHeight": 0, + "HasOriginalParentSize": false, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 2, + "ReadOrder": 2, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": { + "File": 100682946, + "DrawMode": 3 + }, + "Cursor": null, + "Properties": { + "Values": {} + } + } + }, + "DefaultStateId": 0, + "FontDid": 0, + "HJustify": 1, + "VJustify": 1, + "FontColor": null, + "StateMedia": { + "": { + "Item1": 100682946, + "Item2": 3 + } + }, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [ + { + "Id": 268435754, + "Type": 12, + "X": 0, + "Y": 0, + "Width": 128, + "Height": 20, + "OriginalParentWidth": 292, + "OriginalParentHeight": 20, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 1, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "35": { + "Kind": 4, + "MasterPropertyId": 35, + "UnsignedValue": 0, + "IntegerValue": 9, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "26": { + "Kind": 7, + "MasterPropertyId": 26, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 2, + "MasterPropertyId": 24, + "UnsignedValue": 1073741825, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "37": { + "Kind": 4, + "MasterPropertyId": 37, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 255, + "Green": 255, + "Red": 255, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "20": { + "Kind": 0, + "MasterPropertyId": 20, + "UnsignedValue": 2, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "21": { + "Kind": 0, + "MasterPropertyId": 21, + "UnsignedValue": 1, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "33": { + "Kind": 1, + "MasterPropertyId": 33, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "81": { + "Kind": 1, + "MasterPropertyId": 81, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "205": { + "Kind": 0, + "MasterPropertyId": 205, + "UnsignedValue": 3, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + }, + "13": { + "Id": 13, + "Name": "Ghosted", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 76, + "Green": 76, + "Red": 76, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + } + } + } + } + }, + "DefaultStateId": 0, + "FontDid": 1073741825, + "HJustify": 0, + "VJustify": 1, + "FontColor": { + "X": 1, + "Y": 1, + "Z": 1, + "W": 1 + }, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [] + }, + { + "Id": 268435755, + "Type": 12, + "X": 27, + "Y": 0, + "Width": 256, + "Height": 20, + "OriginalParentWidth": 292, + "OriginalParentHeight": 20, + "HasOriginalParentSize": true, + "Left": 1, + "Top": 1, + "Right": 1, + "Bottom": 1, + "ReadOrder": 2, + "ZLevel": 0, + "States": { + "4294967295": { + "Id": 4294967295, + "Name": "", + "PassToChildren": false, + "IncorporationFlags": 30, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "35": { + "Kind": 4, + "MasterPropertyId": 35, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "26": { + "Kind": 7, + "MasterPropertyId": 26, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 2, + "MasterPropertyId": 24, + "UnsignedValue": 1073741825, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "37": { + "Kind": 4, + "MasterPropertyId": 37, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 255, + "Green": 255, + "Red": 255, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 255, + "Red": 0, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 255, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 255, + "Red": 255, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + }, + "20": { + "Kind": 0, + "MasterPropertyId": 20, + "UnsignedValue": 3, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "21": { + "Kind": 0, + "MasterPropertyId": 21, + "UnsignedValue": 1, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "33": { + "Kind": 1, + "MasterPropertyId": 33, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "81": { + "Kind": 1, + "MasterPropertyId": 81, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": true, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + }, + "205": { + "Kind": 0, + "MasterPropertyId": 205, + "UnsignedValue": 3, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + } + } + }, + "13": { + "Id": 13, + "Name": "Ghosted", + "PassToChildren": false, + "IncorporationFlags": 0, + "Image": null, + "Cursor": null, + "Properties": { + "Values": { + "27": { + "Kind": 7, + "MasterPropertyId": 27, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 0, + "Green": 0, + "Red": 0, + "Alpha": 0 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [ + { + "Kind": 6, + "MasterPropertyId": 25, + "UnsignedValue": 0, + "IntegerValue": 0, + "FloatValue": 0, + "BoolValue": false, + "StringInfoValue": { + "Token": 0, + "StringId": 0, + "TableId": 0, + "Override": 0, + "English": 0, + "Comment": 0 + }, + "ColorValue": { + "Blue": 76, + "Green": 76, + "Red": 76, + "Alpha": 255 + }, + "VectorValue": { + "X": 0, + "Y": 0, + "Z": 0 + }, + "ArrayValue": [], + "StructValue": {} + } + ], + "StructValue": {} + } + } + } + } + }, + "DefaultStateId": 0, + "FontDid": 1073741825, + "HJustify": 2, + "VJustify": 1, + "FontColor": { + "X": 1, + "Y": 1, + "Z": 1, + "W": 1 + }, + "StateMedia": {}, + "StateCursors": {}, + "DefaultStateName": "", + "Children": [] + } + ] +} \ No newline at end of file