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