feat(ui): port retail creature appraisal presentation

Render assessed creatures through the shared private viewport with retail heading, bounding-box camera, and light. Build the exact authored nine-row stat list and resolve creature names from the retail EnumMapper while keeping remaining font/sequencer adaptations explicit.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 12:55:24 +02:00
parent f1a7912160
commit 7eaa68a5f4
26 changed files with 2780 additions and 237 deletions

View file

@ -76,7 +76,7 @@ Copy this block when adding a new issue:
## #234 — Monster assessment held busy and examination used the shared panel ## #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 **Severity:** HIGH
**Filed:** 2026-07-23 **Filed:** 2026-07-23
**Component:** appraisal protocol / retained examination UI / item writing **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 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 an independent top-level window, and the imported multiline field sends
retail's CP-1252 `SetInscription (0x00BF)` under its ownership/authorship rules. 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`; **Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`;
`src/AcDream.App/UI/RetailUiRuntime.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 **Acceptance:** Monster/NPC/player assessment opens and clears busy; the
examination window coexists with the shared primary panel; an owned examination window coexists with the shared primary panel; an owned
inscribable weapon can be edited/cleared and reassessed; foreign/nonowned/ 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.
--- ---

View file

@ -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-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-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-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-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-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` | | ~~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-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-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-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-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-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` | | 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` |

View file

@ -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 shared item-cooldown follow-up passed their connected gates. Slice 3's first
connected gate exposed an incorrect IdentifyResponse flag table, a non-retail connected gate exposed an incorrect IdentifyResponse flag table, a non-retail
shared-panel mount, and its missing inscription transaction. All three are shared-panel mount, and its missing inscription transaction. All three are
corrected; the independent floaty examination window awaits its corrective corrected and live-confirmed. The creature-page follow-up now has retail's
connected gate. ordered stat rows and animated private preview; its visual gate is pending.
**Milestone:** M4 prerequisite/preamble. **Milestone:** M4 prerequisite/preamble.
**Architecture:** retained gameplay UI over shared selection, object, and **Architecture:** retained gameplay UI over shared selection, object, and
interaction state. `GameWindow` remains a composition/callback shell. 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 `ItemExamineUI`'s public/hook inscribability, inventory ownership and
same-scribe permission rules, placeholder/signature presentation, exact same-scribe permission rules, placeholder/signature presentation, exact
failure notices, focus commit behavior, and CP-1252 `SetInscription (0x00BF)` failure notices, focus commit behavior, and CP-1252 `SetInscription (0x00BF)`
transaction. Basic item, creature, and player reports are live; exact 3D transaction. Basic item and player reports are live. The creature page now
preview/specialized detail regions and selection-follow remain the narrowed resolves its type through retail EnumMapper `0x2200000E`, preserves the
AP-110 residual. Research: 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). [`../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 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 Release solution build with 17 pre-existing tracked test warnings, 3,496 App
tests / 3 skips, and 7,913 complete-solution tests / 5 skips. 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 ## Slice 1 — spell-bar overflow arrows
### Retail oracle ### Retail oracle
@ -253,6 +264,10 @@ Named retail references and executable pseudocode are recorded in
inscription/signature, retail chrome, and working scrollbars. inscription/signature, retail chrome, and working scrollbars.
- Assess a monster, NPC, and player: the correct creature/character page - Assess a monster, NPC, and player: the correct creature/character page
should appear and the busy cursor should clear. 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 - In combat, leave a creature assessment open long enough to observe a health
refresh; closing the examination window must stop refreshes. refresh; closing the examination window must stop refreshes.
- Keep Examination open while opening/moving Inventory or Skills and confirm - Keep Examination open while opening/moving Inventory or Skills and confirm

View file

@ -13,9 +13,15 @@ retained examination window.
- `gmExaminationUI::SetActiveExamineUI @ 0x004AB420` - `gmExaminationUI::SetActiveExamineUI @ 0x004AB420`
- `gmExaminationUI::UseTime @ 0x004AB530` - `gmExaminationUI::UseTime @ 0x004AB530`
- `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0` - `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`
- `BasicCreatureExamineUI::Init @ 0x004AB9C0`
- `BasicCreatureExamineUI::BasicCreatureExamineUI @ 0x004ACD30`
- `BasicCreatureExamineUI::SetLevelValueText @ 0x004B3E70`
- `BasicCreatureExamineUI::SetAppraiseInfo @ 0x004B3F70` - `BasicCreatureExamineUI::SetAppraiseInfo @ 0x004B3F70`
- `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0` - `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`
- `CharExamineUI::SetAppraiseInfo @ 0x004B45F0` - `CharExamineUI::SetAppraiseInfo @ 0x004B45F0`
- `AttributeInfoRegion::Update @ 0x004F1D90`
- `Attribute2ndInfoRegion::Update @ 0x004F1E80`
- `AppraisalSystem::InqCreatureDisplayName @ 0x005B59E0`
- `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0` - `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`
- `ItemExamineUI::HandleInscriptionGainingFocus @ 0x004AC0E0` - `ItemExamineUI::HandleInscriptionGainingFocus @ 0x004AC0E0`
- `ItemExamineUI::HandleInscriptionMousePresses @ 0x004AC230` - `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. change appraises the new selected GUID; clearing selection hides the window.
That optional retail preference remains outside this slice. 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 ## Item presentation order
`ItemExamineUI::SetAppraiseInfo` clears and rebuilds the authored item text in `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, The request lifetime, exact packet flags, independent floaty window,
inscription permission/edit/write transaction, subview choice, authored outer inscription permission/edit/write transaction, subview choice, authored outer
layout, basic report content, scrollbar ownership, and combat refresh are in layout, ordered creature stats, animated creature preview, scrollbar
scope. Retail's exact 3D object/creature preview, exhaustive specialized ownership, and combat refresh are in scope. Retail's item-object preview,
`ItemExamineUI` detail branches, selection-follow preference, and PSR-only exhaustive specialized `ItemExamineUI` detail branches, creature
scribe-account/override behavior remain explicit AP-110 work. extra-rating regions, exact appraisal FontInfo-list selection,
selection-follow preference, and PSR-only scribe-account/override behavior
remain explicit AP-110 work.

View file

@ -441,7 +441,9 @@ internal sealed class FrameRootCompositionPhase
session.LocalTeleport, session.LocalTeleport,
host.CameraController), host.CameraController),
renderFrameResources, renderFrameResources,
live.PaperdollPresenter, new PrivateEntityViewportFrameGroup(
live.PaperdollPresenter,
live.CreatureAppraisalPresenter),
retainedGameplayUi, retainedGameplayUi,
settings.DevTools?.Presenter, settings.DevTools?.Presenter,
privateScreenshot); privateScreenshot);

View file

@ -94,6 +94,8 @@ internal sealed record LivePresentationResult(
RetainedUiGameplayBinding? RetainedGameplay, RetainedUiGameplayBinding? RetainedGameplay,
PaperdollViewportRenderer? PaperdollRenderer, PaperdollViewportRenderer? PaperdollRenderer,
PaperdollFramePresenter? PaperdollPresenter, PaperdollFramePresenter? PaperdollPresenter,
CreatureAppraisalViewportRenderer? CreatureAppraisalRenderer,
CreatureAppraisalFramePresenter? CreatureAppraisalPresenter,
WbFrustum EnvCellFrustum, WbFrustum EnvCellFrustum,
EnvCellRenderer EnvCellRenderer, EnvCellRenderer EnvCellRenderer,
LandblockPresentationPipeline LandblockPipeline, LandblockPresentationPipeline LandblockPipeline,
@ -120,7 +122,7 @@ internal enum LivePresentationCompositionPoint
EffectRoutingBound, EffectRoutingBound,
SelectionAndRadarBound, SelectionAndRadarBound,
RetainedGameplayBound, RetainedGameplayBound,
PaperdollCreated, PrivateCreatureViewportsCreated,
EnvironmentCellsCreated, EnvironmentCellsCreated,
LandblockPipelineCreated, LandblockPipelineCreated,
PortalResourcesCreated, PortalResourcesCreated,
@ -694,7 +696,48 @@ internal sealed class LivePresentationCompositionPhase
content.AnimationLoader, content.AnimationLoader,
d.DatLock))); 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 envCellFrustum = new WbFrustum();
var envCellLease = scope.Acquire( var envCellLease = scope.Acquire(
@ -890,6 +933,8 @@ internal sealed class LivePresentationCompositionPhase
retainedGameplayLease?.Resource, retainedGameplayLease?.Resource,
paperdollLease?.Resource, paperdollLease?.Resource,
paperdollPresenter, paperdollPresenter,
creatureAppraisalLease?.Resource,
creatureAppraisalPresenter,
envCellFrustum, envCellFrustum,
envCellLease.Resource, envCellLease.Resource,
landblockPipeline, landblockPipeline,
@ -910,6 +955,7 @@ internal sealed class LivePresentationCompositionPhase
dispatcherLease.Transfer(); dispatcherLease.Transfer();
retainedGameplayLease?.Transfer(); retainedGameplayLease?.Transfer();
paperdollLease?.Transfer(); paperdollLease?.Transfer();
creatureAppraisalLease?.Transfer();
envCellLease.Transfer(); envCellLease.Transfer();
clipFrameLease.Transfer(); clipFrameLease.Transfer();
portalDepthLease.Transfer(); portalDepthLease.Transfer();

View file

@ -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);
}
/// <summary>
/// Per-frame owner for retail's examination <c>CreatureMode</c>. Unlike the
/// inventory doll's held pose, the examination clone follows the live target's
/// current animated part transforms every frame.
/// </summary>
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));
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Retail <c>BasicCreatureExamineUI::Init @ 0x004AB9C0</c> 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.
/// </summary>
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);
}
}
/// <summary>
/// Bounding-box camera from <c>BasicCreatureExamineUI::Init @ 0x004AB9C0</c>.
/// 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.
/// </summary>
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);
}
}
/// <summary>Examination-specific facade over the shared private renderer.</summary>
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();
}

View file

@ -31,7 +31,7 @@ namespace AcDream.App.Rendering;
public sealed class DollCamera : ICamera public sealed class DollCamera : ICamera
{ {
// Retail paperdoll camera origin (decomp 0x004a5a510x004a5a61). // Retail paperdoll camera origin (decomp 0x004a5a510x004a5a61).
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). // 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 Target = new(0.12f, -1.4f, 0.88f);
private static readonly Vector3 Up = Vector3.UnitZ; // AC up-axis = +Z, same as ChaseCamera private static readonly Vector3 Up = Vector3.UnitZ; // AC up-axis = +Z, same as ChaseCamera
@ -45,9 +45,42 @@ public sealed class DollCamera : ICamera
/// <inheritdoc/> /// <inheritdoc/>
public Matrix4x4 View => public Matrix4x4 View =>
Matrix4x4.CreateLookAt(Eye, Target, Up); Matrix4x4.CreateLookAt(RetailEye, Target, Up);
/// <inheritdoc/> /// <inheritdoc/>
public Matrix4x4 Projection => public Matrix4x4 Projection =>
Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0f ? 1f : Aspect, Near, Far); Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0f ? 1f : Aspect, Near, Far);
} }
/// <summary>
/// Internal private-viewport adapter that preserves <see cref="DollCamera"/>'s
/// existing public camera contract.
/// </summary>
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;
}

View file

@ -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). // 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.PaperdollViewportRenderer? _paperdollViewportRenderer;
private AcDream.App.Rendering.PaperdollFramePresenter? _paperdollFramePresenter; 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. // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
// Phase I.2: ImGui debug panel ViewModel. The devtools presenter owns // Phase I.2: ImGui debug panel ViewModel. The devtools presenter owns
@ -948,6 +952,8 @@ public sealed class GameWindow :
|| _animationPresenter is not null || _animationPresenter is not null
|| _paperdollViewportRenderer is not null || _paperdollViewportRenderer is not null
|| _paperdollFramePresenter is not null || _paperdollFramePresenter is not null
|| _creatureAppraisalViewportRenderer is not null
|| _creatureAppraisalFramePresenter is not null
|| _envCellRenderer is not null || _envCellRenderer is not null
|| _envCellFrustum is not null || _envCellFrustum is not null
|| _landblockPresentationPipeline is not null || _landblockPresentationPipeline is not null
@ -983,6 +989,8 @@ public sealed class GameWindow :
_retainedUiGameplayBinding = result.RetainedGameplay; _retainedUiGameplayBinding = result.RetainedGameplay;
_paperdollViewportRenderer = result.PaperdollRenderer; _paperdollViewportRenderer = result.PaperdollRenderer;
_paperdollFramePresenter = result.PaperdollPresenter; _paperdollFramePresenter = result.PaperdollPresenter;
_creatureAppraisalViewportRenderer = result.CreatureAppraisalRenderer;
_creatureAppraisalFramePresenter = result.CreatureAppraisalPresenter;
_envCellFrustum = result.EnvCellFrustum; _envCellFrustum = result.EnvCellFrustum;
_envCellRenderer = result.EnvCellRenderer; _envCellRenderer = result.EnvCellRenderer;
_landblockPresentationPipeline = result.LandblockPipeline; _landblockPresentationPipeline = result.LandblockPipeline;
@ -1581,6 +1589,7 @@ public sealed class GameWindow :
_localPlayerTeleport, _localPlayerTeleport,
_portalTunnelFallback, _portalTunnelFallback,
_paperdollViewportRenderer, _paperdollViewportRenderer,
_creatureAppraisalViewportRenderer,
_wbDrawDispatcher, _wbDrawDispatcher,
_envCellRenderer, _envCellRenderer,
_portalDepthMask, _portalDepthMask,

View file

@ -96,6 +96,7 @@ internal sealed record RenderShutdownRoots(
LocalPlayerTeleportController? LocalTeleport, LocalPlayerTeleportController? LocalTeleport,
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback, TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
PaperdollViewportRenderer? Paperdoll, PaperdollViewportRenderer? Paperdoll,
CreatureAppraisalViewportRenderer? CreatureAppraisal,
WbDrawDispatcher? DrawDispatcher, WbDrawDispatcher? DrawDispatcher,
EnvCellRenderer? EnvironmentCells, EnvCellRenderer? EnvironmentCells,
PortalDepthMaskRenderer? PortalDepthMask, PortalDepthMaskRenderer? PortalDepthMask,
@ -413,6 +414,9 @@ internal static class GameWindowShutdownManifest
render.PortalTunnelFallback.ReleaseFallback(); render.PortalTunnelFallback.ReleaseFallback();
}), }),
Hard("paperdoll viewport", () => render.Paperdoll?.Dispose()), Hard("paperdoll viewport", () => render.Paperdoll?.Dispose()),
Hard(
"creature appraisal viewport",
() => render.CreatureAppraisal?.Dispose()),
Hard("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()), Hard("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()),
Hard("environment cells", () => render.EnvironmentCells?.Dispose()), Hard("environment cells", () => render.EnvironmentCells?.Dispose()),
Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()), Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()),

View file

@ -48,7 +48,7 @@ internal interface IPaperdollPoseApplicator
/// The GL renderer remains a borrowed resource disposed by the existing window /// The GL renderer remains a borrowed resource disposed by the existing window
/// shutdown transaction. /// shutdown transaction.
/// </summary> /// </summary>
internal sealed class PaperdollFramePresenter : IPrivatePaperdollFrame internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
{ {
private readonly IPaperdollDollRenderer _renderer; private readonly IPaperdollDollRenderer _renderer;
private readonly IPaperdollFrameView _view; private readonly IPaperdollFrameView _view;

View file

@ -1,6 +1,3 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Core.Lighting; using AcDream.Core.Lighting;
@ -10,52 +7,15 @@ using Silk.NET.OpenGL;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
/// <summary> /// <summary>
/// Render-to-texture renderer for the paperdoll 3-D doll (the C# analog of retail /// Paperdoll-specific facade over the shared private creature viewport. The
/// <c>CreatureMode::Render</c>). Draws ONE re-dressed player clone (a <see cref="WorldEntity"/>, /// fixed camera remains the verbatim retail <c>gmPaperDollUI</c> camera.
/// built by <see cref="DollEntityBuilder"/> + wired by <see cref="PaperdollFramePresenter"/>) into a private off-screen
/// framebuffer with a fixed <see cref="DollCamera"/> + one distant light, then hands the color
/// texture to the <see cref="UiViewport"/> widget which blits it as a normal 2-D sprite.
///
/// <para>The whole 3-D pass is sealed in a <see cref="GLStateScope"/> 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.</para>
///
/// <para>Reuses the existing <see cref="WbDrawDispatcher"/> + the player's already-resolved,
/// already-GPU-resident MeshRefs (the doll clones them), so no separate mesh/texture upload is
/// needed. With <c>frustum: null</c> the doll's synthetic landblock is always "visible" and the
/// entity is walked from <c>entry.Entities</c> and drawn from its current MeshRefs — a static held pose.</para>
/// </summary> /// </summary>
public sealed unsafe class PaperdollViewportRenderer : public sealed class PaperdollViewportRenderer :
IUiViewportRenderer, IUiViewportRenderer,
IPaperdollDollRenderer, IPaperdollDollRenderer,
IDisposable IDisposable
{ {
private readonly GL _gl; private readonly PrivateEntityViewportRenderer _renderer;
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<uint> _dollAnimatedIds = new() { DollEntityBuilder.DollRenderId };
public PaperdollViewportRenderer( public PaperdollViewportRenderer(
GL gl, GL gl,
@ -63,151 +23,20 @@ public sealed unsafe class PaperdollViewportRenderer :
SceneLightingUboBinding lightUbo, SceneLightingUboBinding lightUbo,
IEntityTextureLifetime textureLifetime) IEntityTextureLifetime textureLifetime)
{ {
_gl = gl ?? throw new ArgumentNullException(nameof(gl)); _renderer = new PrivateEntityViewportRenderer(
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); gl,
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo)); dispatcher,
_textureOwnerLease = new FixedEntityTextureOwnerLease( lightUbo,
textureLifetime, textureLifetime,
DollEntityBuilder.DollRenderId); DollEntityBuilder.DollRenderId,
new DollViewportCamera(),
"paperdoll");
} }
/// <summary>Set (or clear) the doll entity built from the live player projection.</summary> public void SetDoll(WorldEntity? doll) => _renderer.SetEntity(doll);
public void SetDoll(WorldEntity? doll)
{
if (ReferenceEquals(_doll, doll))
return;
_textureOwnerLease.Replace(doll is not null);
_doll = doll;
}
/// <inheritdoc/> public uint Render(int width, int height) =>
public uint Render(int width, int height) _renderer.Render(width, height);
{
var doll = _doll;
if (doll is null || doll.MeshRefs.Count == 0 || width <= 0 || height <= 0)
return 0u;
EnsureFramebuffer(width, height); public void Dispose() => _renderer.Dispose();
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<WorldEntity>, IReadOnlyDictionary<uint, WorldEntity>?)[]
{
(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;
}
/// <summary>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).</summary>
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);
}
/// <summary>(Re)create the FBO + color texture + depth-stencil renderbuffer when the requested
/// size changes. Color is RGBA8 with linear filtering + clamp; depth is Depth24Stencil8.</summary>
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();
}
} }

View file

@ -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;
/// <summary>
/// Camera contract for retail <c>CreatureMode</c>-style UI viewports. The
/// renderer supplies the authored viewport aspect before each draw and uses
/// <see cref="Eye"/> when it publishes the private scene lighting UBO.
/// </summary>
internal interface IPrivateEntityViewportCamera : ICamera
{
Vector3 Eye { get; }
}
/// <summary>
/// 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.
/// </summary>
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<uint> _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<WorldEntity>,
IReadOnlyDictionary<uint, WorldEntity>?)[]
{
(
PrivateLandblockId,
new Vector3(-1024f),
new Vector3(1024f),
entities,
null),
};
_dispatcher.Draw(
_camera,
entries,
frustum: null,
neverCullLandblockId: PrivateLandblockId,
visibleCellIds: null,
animatedEntityIds: _animatedIds);
return _colorTex;
}
/// <summary>
/// Both retail paperdoll and creature examination call
/// <c>UIElement_Viewport::SetLight(DISTANT_LIGHT, 2, (0.3,1.9,0.65))</c>.
/// </summary>
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();
}
}

View file

@ -11,7 +11,7 @@ internal interface IPrivatePortalViewport
void Draw(int width, int height); void Draw(int width, int height);
} }
internal interface IPrivatePaperdollFrame internal interface IPrivateEntityViewportFrame
{ {
void Render(); void Render();
} }
@ -35,7 +35,7 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha
{ {
private readonly IPrivatePortalViewport _portal; private readonly IPrivatePortalViewport _portal;
private readonly IRenderFrameFoundationSource _foundation; private readonly IRenderFrameFoundationSource _foundation;
private readonly IPrivatePaperdollFrame? _paperdoll; private readonly IPrivateEntityViewportFrame? _entityViewports;
private readonly IRetainedGameplayUiFrame? _gameplayUi; private readonly IRetainedGameplayUiFrame? _gameplayUi;
private readonly IDevToolsFrameLifecycle? _devTools; private readonly IDevToolsFrameLifecycle? _devTools;
private readonly IPrivateFrameScreenshot? _screenshots; private readonly IPrivateFrameScreenshot? _screenshots;
@ -43,7 +43,7 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha
public PrivatePresentationRenderer( public PrivatePresentationRenderer(
IPrivatePortalViewport portal, IPrivatePortalViewport portal,
IRenderFrameFoundationSource foundation, IRenderFrameFoundationSource foundation,
IPrivatePaperdollFrame? paperdoll, IPrivateEntityViewportFrame? entityViewports,
IRetainedGameplayUiFrame? gameplayUi, IRetainedGameplayUiFrame? gameplayUi,
IDevToolsFrameLifecycle? devTools, IDevToolsFrameLifecycle? devTools,
IPrivateFrameScreenshot? screenshots) IPrivateFrameScreenshot? screenshots)
@ -51,7 +51,7 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha
_portal = portal ?? throw new ArgumentNullException(nameof(portal)); _portal = portal ?? throw new ArgumentNullException(nameof(portal));
_foundation = foundation _foundation = foundation
?? throw new ArgumentNullException(nameof(foundation)); ?? throw new ArgumentNullException(nameof(foundation));
_paperdoll = paperdoll; _entityViewports = entityViewports;
_gameplayUi = gameplayUi; _gameplayUi = gameplayUi;
_devTools = devTools; _devTools = devTools;
_screenshots = screenshots; _screenshots = screenshots;
@ -68,7 +68,7 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha
bool portalViewportVisible = bool portalViewportVisible =
_foundation.Foundation.PortalViewportVisible; _foundation.Foundation.PortalViewportVisible;
_portal.Draw(input.ViewportWidth, input.ViewportHeight); _portal.Draw(input.ViewportWidth, input.ViewportHeight);
_paperdoll?.Render(); _entityViewports?.Render();
_gameplayUi?.Render( _gameplayUi?.Render(
input.DeltaSeconds, input.DeltaSeconds,
input.ViewportWidth, input.ViewportWidth,
@ -86,6 +86,30 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha
} }
} }
/// <summary>
/// Ordered private creature-view presentation. Retail renders each visible
/// <c>CreatureMode</c> before the retained 2-D tree that displays its texture.
/// </summary>
internal sealed class PrivateEntityViewportFrameGroup :
IPrivateEntityViewportFrame
{
private readonly IPrivateEntityViewportFrame[] _frames;
public PrivateEntityViewportFrameGroup(
params IPrivateEntityViewportFrame?[] frames)
{
_frames = frames.Where(static frame => frame is not null)
.Cast<IPrivateEntityViewportFrame>()
.ToArray();
}
public void Render()
{
foreach (IPrivateEntityViewportFrame frame in _frames)
frame.Render();
}
}
/// <summary>Typed portal viewport adapter over the canonical teleport owner.</summary> /// <summary>Typed portal viewport adapter over the canonical teleport owner.</summary>
internal sealed class LocalPlayerPortalViewport : IPrivatePortalViewport internal sealed class LocalPlayerPortalViewport : IPrivatePortalViewport
{ {

View file

@ -27,6 +27,10 @@ public sealed class AppraisalUiController : IRetainedPanelController
public const uint SignatureTextId = 0x1000013Fu; public const uint SignatureTextId = 0x1000013Fu;
public const uint InscriptionScrollbarId = 0x1000046Eu; public const uint InscriptionScrollbarId = 0x1000046Eu;
public const uint CreaturePanelId = 0x10000140u; 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; public const uint SpellPanelId = 0x10000153u;
private const uint TemplateStringProperty = 5u; private const uint TemplateStringProperty = 5u;
@ -53,6 +57,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
private readonly UiText? _signature; private readonly UiText? _signature;
private readonly UiDatElement? _inscriptionBackground; private readonly UiDatElement? _inscriptionBackground;
private readonly UiButton? _close; private readonly UiButton? _close;
private readonly UiItemList? _creatureStats;
private readonly CreatureAppraisalRowTemplateFactory? _creatureRowTemplates;
private readonly CreatureDisplayNameResolver _creatureNames;
private AppraisalView _activeView; private AppraisalView _activeView;
private uint _itemObjectId; private uint _itemObjectId;
private uint _creatureObjectId; private uint _creatureObjectId;
@ -81,7 +88,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
UiElement itemPanel, UiElement itemPanel,
UiElement creaturePanel, UiElement creaturePanel,
UiText title, UiText title,
UiText itemText) UiText itemText,
CreatureAppraisalRowTemplateFactory? creatureRowTemplates,
CreatureDisplayNameResolver? creatureNames)
{ {
_layout = layout; _layout = layout;
_objects = objects; _objects = objects;
@ -97,6 +106,10 @@ public sealed class AppraisalUiController : IRetainedPanelController
_spellPanel = layout.FindElement(SpellPanelId); _spellPanel = layout.FindElement(SpellPanelId);
_title = title; _title = title;
_itemText = itemText; _itemText = itemText;
_creatureRowTemplates = creatureRowTemplates;
_creatureNames = creatureNames
?? new CreatureDisplayNameResolver(
new Dictionary<uint, string>());
_inscriptionText = layout.FindElement(InscriptionTextId) as UiText; _inscriptionText = layout.FindElement(InscriptionTextId) as UiText;
_inscriptionField = layout.FindElement(InscriptionTextId) as UiField; _inscriptionField = layout.FindElement(InscriptionTextId) as UiField;
_signature = layout.FindElement(SignatureTextId) as UiText; _signature = layout.FindElement(SignatureTextId) as UiText;
@ -136,6 +149,25 @@ public sealed class AppraisalUiController : IRetainedPanelController
_inscriptionBackground.ClickThrough = false; _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); SetActiveView(AppraisalView.Item);
} }
@ -152,7 +184,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
Action<uint, string> sendSetInscription, Action<uint, string> sendSetInscription,
Action<string> systemMessage, Action<string> systemMessage,
Action show, Action show,
Action close) Action close,
CreatureAppraisalRowTemplateFactory? creatureRowTemplates = null,
CreatureDisplayNameResolver? creatureNames = null)
{ {
ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(objects); ArgumentNullException.ThrowIfNull(objects);
@ -185,7 +219,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
itemPanel, itemPanel,
creaturePanel, creaturePanel,
title, title,
itemText); itemText,
creatureRowTemplates,
creatureNames);
} }
/// <summary> /// <summary>
@ -452,17 +488,12 @@ public sealed class AppraisalUiController : IRetainedPanelController
{ {
ClearCreatureText(); ClearCreatureText();
PropertyBundle p = appraisal.Properties; PropertyBundle p = appraisal.Properties;
SetText(0x1000014Au, character int level = GetInt(p, 25u);
? GetString(p, 4u) SetText(
: AppraisalTextFormatter.CreatureTypeName(GetInt(p, 2u))); CreatureLevelValueId,
SetText(0x1000014Bu, GetString(p, 2u)); level > 0
SetText(0x1000014Cu, GetString(p, 14u)); ? level.ToString(CultureInfo.CurrentCulture)
: "???");
string report = AppraisalTextFormatter.BuildCreature(
obj,
appraisal,
character);
SetText(0x1000014Eu, report, scrollable: true);
if (character) if (character)
{ {
@ -470,6 +501,14 @@ public sealed class AppraisalUiController : IRetainedPanelController
SetText(0x10000151u, GetString(p, 47u)); SetText(0x10000151u, GetString(p, 47u));
SetText(0x10000152u, GetString(p, 11u)); SetText(0x10000152u, GetString(p, 11u));
} }
else
{
SetText(
CreatureDisplayNameId,
_creatureNames.Resolve(GetInt(p, 2u)));
}
RebuildCreatureStats(appraisal);
SetText(0x1000053Au, appraisal.Success SetText(0x1000053Au, appraisal.Success
? string.Empty ? string.Empty
@ -478,6 +517,25 @@ public sealed class AppraisalUiController : IRetainedPanelController
ResetCreatureScroll(); 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( private void ConfigureScrollableText(
UiText text, UiText text,
uint scrollbarId, uint scrollbarId,
@ -513,15 +571,17 @@ public sealed class AppraisalUiController : IRetainedPanelController
{ {
foreach (uint id in new uint[] foreach (uint id in new uint[]
{ {
0x1000014Au, 0x1000014Bu, 0x1000014Cu, 0x1000014Eu, CreatureLevelValueId, CreatureDisplayNameId,
0x10000150u, 0x10000151u, 0x10000152u, 0x1000053Au, 0x10000150u, 0x10000151u, 0x10000152u, 0x1000053Au,
}) })
if (_layout.FindElement(id) is UiText text) if (_layout.FindElement(id) is UiText text)
text.LinesProvider = static () => Array.Empty<UiText.Line>(); text.LinesProvider = static () => Array.Empty<UiText.Line>();
_creatureStats?.Flush();
} }
private void ResetCreatureScroll() private void ResetCreatureScroll()
{ {
_creatureStats?.Scroll.SetScrollY(0);
foreach (UiText text in Descendants(_creaturePanel).OfType<UiText>()) foreach (UiText text in Descendants(_creaturePanel).OfType<UiText>())
text.Scroll.SetScrollY(0); text.Scroll.SetScrollY(0);
} }

View file

@ -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);
/// <summary>
/// Pure projection of retail's six <c>AttributeInfoRegion</c> and three
/// <c>Attribute2ndInfoRegion</c> tokens. Ordering and formatting come from
/// <c>BasicCreatureExamineUI</c> and the two Update overloads at
/// 0x004F1D90/0x004F1E80.
/// </summary>
public static class CreatureAppraisalRows
{
private const string Unknown = "???";
public static IReadOnlyList<CreatureAppraisalRow> 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;
}
}
/// <summary>
/// Instantiates LayoutDesc 0x2100006B's InfoRegion token template
/// 0x10000166, matching <c>UIElement_ListBox::AddItemFromTemplateList</c>.
/// </summary>
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<uint, (uint Texture, int Width, int Height)> _resolveSprite;
private readonly UiDatFont? _defaultFont;
private readonly IReadOnlyDictionary<uint, UiDatFont?> _fonts;
public CreatureAppraisalRowTemplateFactory(
ElementInfo template,
Func<uint, (uint Texture, int Width, int Height)> resolveSprite,
UiDatFont? defaultFont,
IReadOnlyDictionary<uint, UiDatFont?>? fonts = null)
{
_template = template ?? throw new ArgumentNullException(nameof(template));
_resolveSprite = resolveSprite ?? throw new ArgumentNullException(nameof(resolveSprite));
_defaultFont = defaultFont;
_fonts = fonts ?? new Dictionary<uint, UiDatFont?>();
}
public float Width => _template.Width;
public float Height => _template.Height;
public static CreatureAppraisalRowTemplateFactory? TryLoad(
IDatReaderWriter dats,
Func<uint, (uint Texture, int Width, int Height)> resolveSprite,
UiDatFont? defaultFont,
Func<uint, UiDatFont?>? 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<uint, UiDatFont?>();
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<UiText>(content, LabelId);
UiText value = Required<UiText>(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<T>(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<uint, UiDatFont?>? resolveFont,
Dictionary<uint, UiDatFont?> 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);
}
}
/// <summary>
/// Retail <c>AppraisalSystem::InqCreatureDisplayName @ 0x005B59E0</c> resolves
/// creature enum 0x10000005 through portal EnumMapper 0x2200000E and replaces
/// underscores with spaces.
/// </summary>
public sealed class CreatureDisplayNameResolver
{
public const uint MapperDid = 0x2200000Eu;
private readonly IReadOnlyDictionary<uint, string> _names;
public CreatureDisplayNameResolver(
IReadOnlyDictionary<uint, string> names)
{
_names = names ?? throw new ArgumentNullException(nameof(names));
}
public static CreatureDisplayNameResolver Load(IDatReaderWriter dats)
{
ArgumentNullException.ThrowIfNull(dats);
var names = new Dictionary<uint, string>();
var visited = new HashSet<uint>();
uint did = MapperDid;
while (did != 0u && visited.Add(did))
{
EnumMapper? mapper = dats.Get<EnumMapper>(did);
if (mapper is null)
break;
foreach ((uint id, PStringBase<byte> 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;
}

View file

@ -301,6 +301,8 @@ public sealed class RetailUiRuntime : IDisposable
public SpellcastingUiController? SpellcastingUiController { get; private set; } public SpellcastingUiController? SpellcastingUiController { get; private set; }
public SpellbookWindowController? SpellbookWindowController { get; private set; } public SpellbookWindowController? SpellbookWindowController { get; private set; }
public AppraisalUiController? AppraisalController { 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? PositiveEffectsController { get; private set; }
public EffectsUiController? NegativeEffectsController { get; private set; } public EffectsUiController? NegativeEffectsController { get; private set; }
public LinkStatusUiController? LinkStatusUiController { get; private set; } public LinkStatusUiController? LinkStatusUiController { get; private set; }
@ -961,9 +963,26 @@ public sealed class RetailUiRuntime : IDisposable
private void MountAppraisal() private void MountAppraisal()
{ {
ImportedLayout? layout = Import( ImportedLayout? layout;
AppraisalUiController.LayoutId, CreatureAppraisalRowTemplateFactory? creatureRows;
AppraisalUiController.RootId); 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) if (layout is null)
{ {
Console.WriteLine( Console.WriteLine(
@ -981,7 +1000,9 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Appraisal.SendSetInscription, _bindings.Appraisal.SendSetInscription,
_bindings.Appraisal.DisplaySystemMessage, _bindings.Appraisal.DisplaySystemMessage,
show: () => Host.ShowWindow(WindowNames.Examination), show: () => Host.ShowWindow(WindowNames.Examination),
close: () => CloseWindow(WindowNames.Examination)); close: () => CloseWindow(WindowNames.Examination),
creatureRowTemplates: creatureRows,
creatureNames: creatureNames);
if (controller is null) if (controller is null)
{ {
Console.WriteLine( Console.WriteLine(
@ -991,7 +1012,7 @@ public sealed class RetailUiRuntime : IDisposable
AppraisalController = controller; AppraisalController = controller;
UiElement root = layout.Root; UiElement root = layout.Root;
_ = RetailWindowFrame.Mount( RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root, Host.Root,
root, root,
_bindings.Assets.ResolveSprite, _bindings.Assets.ResolveSprite,
@ -1013,6 +1034,10 @@ public sealed class RetailUiRuntime : IDisposable
ContentClickThrough = false, ContentClickThrough = false,
Controller = controller, Controller = controller,
}); });
CreatureAppraisalViewportWidget =
layout.FindElement(AppraisalUiController.CreatureViewportId)
as UiViewport;
ExaminationFrame = handle.OuterFrame;
Console.WriteLine( Console.WriteLine(
"[M4] retail examination window from LayoutDesc 0x2100006B."); "[M4] retail examination window from LayoutDesc 0x2100006B.");
} }

View file

@ -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<MeshRef> 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<uint> 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<WorldEntity?> 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<uint> 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<uint> 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;
}
}
}

View file

@ -13,7 +13,7 @@ public sealed class GameWindowRenderLeafCompositionTests
AssertAppearsInOrder( AssertAppearsInOrder(
presentation, presentation,
"_portal.Draw(", "_portal.Draw(",
"_paperdoll?.Render();", "_entityViewports?.Render();",
"_gameplayUi?.Render(", "_gameplayUi?.Render(",
"_devTools?.Render(", "_devTools?.Render(",
"_screenshots?.CapturePending("); "_screenshots?.CapturePending(");

View file

@ -52,7 +52,7 @@ public sealed class PrivatePresentationRendererTests
var presentation = new PrivatePresentationRenderer( var presentation = new PrivatePresentationRenderer(
new Portal(calls), new Portal(calls),
new FoundationSource(portalVisible: true), new FoundationSource(portalVisible: true),
paperdoll: null, entityViewports: null,
gameplayUi: null, gameplayUi: null,
devTools: null, devTools: null,
screenshots: null); screenshots: null);
@ -64,6 +64,20 @@ public sealed class PrivatePresentationRendererTests
Assert.False(outcome.ScreenshotCaptured); Assert.False(outcome.ScreenshotCaptured);
} }
[Fact]
public void PrivateEntityViewportGroup_RendersEveryViewportInOrder()
{
List<string> calls = [];
var group = new PrivateEntityViewportFrameGroup(
new NamedViewport(calls, "paperdoll"),
null,
new NamedViewport(calls, "examination"));
group.Render();
Assert.Equal(["paperdoll", "examination"], calls);
}
[Fact] [Fact]
public void Render_ReportsThePreparedPortalSnapshotWhenDrawMutatesTheSource() public void Render_ReportsThePreparedPortalSnapshotWhenDrawMutatesTheSource()
{ {
@ -72,7 +86,7 @@ public sealed class PrivatePresentationRendererTests
var presentation = new PrivatePresentationRenderer( var presentation = new PrivatePresentationRenderer(
new MutatingPortal(calls, () => foundation.PortalVisible = false), new MutatingPortal(calls, () => foundation.PortalVisible = false),
foundation, foundation,
paperdoll: null, entityViewports: null,
gameplayUi: null, gameplayUi: null,
devTools: null, devTools: null,
screenshots: null); screenshots: null);
@ -209,11 +223,17 @@ public sealed class PrivatePresentationRendererTests
} }
} }
private sealed class Paperdoll(List<string> calls) : IPrivatePaperdollFrame private sealed class Paperdoll(List<string> calls) : IPrivateEntityViewportFrame
{ {
public void Render() => calls.Add("paperdoll"); public void Render() => calls.Add("paperdoll");
} }
private sealed class NamedViewport(List<string> calls, string name) :
IPrivateEntityViewportFrame
{
public void Render() => calls.Add(name);
}
private sealed class GameplayUi(List<string> calls) : IRetainedGameplayUiFrame private sealed class GameplayUi(List<string> calls) : IRetainedGameplayUiFrame
{ {
public RenderFrameInput Input { get; private set; } public RenderFrameInput Input { get; private set; }

View file

@ -146,7 +146,7 @@ public sealed class RenderFrameRecoveryIntegrationTests
public void Draw(int width, int height) => failure.ThrowIf("portal"); 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"); public void Render() => failure.ThrowIf("paperdoll");
} }

View file

@ -10,6 +10,7 @@ namespace AcDream.App.Tests.UI.Layout;
public sealed class AppraisalUiControllerTests public sealed class AppraisalUiControllerTests
{ {
private const uint ObjectId = 0x50000001u; private const uint ObjectId = 0x50000001u;
private static (uint, int, int) NoTexture(uint _) => (0u, 0, 0);
[Fact] [Fact]
public void ItemResponse_UsesAuthoredItemSubviewTitleAndScrollbars() public void ItemResponse_UsesAuthoredItemSubviewTitleAndScrollbars()
@ -146,6 +147,114 @@ public sealed class AppraisalUiControllerTests
Assert.Equal(1, shown); 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<UiText>(
layout.FindElement(0x1000014Au));
UiText levelLabel = Assert.IsType<UiText>(
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<uint, string> { [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<UiItemList>());
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<UiTemplateListSlot>(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] [Fact]
public void ResponseForNeitherPendingNorCurrent_IsIgnored() public void ResponseForNeitherPendingNorCurrent_IsIgnored()
{ {
@ -429,7 +538,9 @@ public sealed class AppraisalUiControllerTests
List<(uint ObjectId, string Text)> inscriptions, List<(uint ObjectId, string Text)> inscriptions,
List<string> messages, List<string> messages,
Action show, Action show,
Action close) Action close,
CreatureAppraisalRowTemplateFactory? creatureRows = null,
CreatureDisplayNameResolver? creatureNames = null)
=> AppraisalUiController.Bind( => AppraisalUiController.Bind(
layout, layout,
objects, objects,
@ -440,7 +551,9 @@ public sealed class AppraisalUiControllerTests
(objectId, text) => inscriptions.Add((objectId, text)), (objectId, text) => inscriptions.Add((objectId, text)),
messages.Add, messages.Add,
show, show,
close); close,
creatureRows,
creatureNames);
private static ItemInteractionController NewInteraction( private static ItemInteractionController NewInteraction(
ClientObjectTable objects, ClientObjectTable objects,

View file

@ -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<CreatureAppraisalRow> 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<CreatureAppraisalRow> 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<uint, string> { [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);
}

View file

@ -113,6 +113,9 @@ public static class FixtureLoader
public static ElementInfo LoadExaminationInfos() public static ElementInfo LoadExaminationInfos()
=> LoadInfos("examine_2100006B_100005F2.json"); => LoadInfos("examine_2100006B_100005F2.json");
public static ElementInfo LoadExaminationRowTemplateInfos()
=> LoadInfos("examine_row_2100006B_10000166.json");
public static ImportedLayout LoadPowerbar() public static ImportedLayout LoadPowerbar()
=> LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null); => LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null);

View file

@ -132,6 +132,8 @@ public sealed class RetailLayoutFixtureGenerator
(CharacterController.LayoutId, CharacterController.RootId, (CharacterController.LayoutId, CharacterController.RootId,
"character_info_2100006E_10000183.json"), "character_info_2100006E_10000183.json"),
(0x2100006Bu, 0x100005F2u, "examine_2100006B_100005F2.json"), (0x2100006Bu, 0x100005F2u, "examine_2100006B_100005F2.json"),
(AppraisalUiController.LayoutId, CreatureAppraisalRowTemplateFactory.TemplateId,
"examine_row_2100006B_10000166.json"),
}) })
{ {
ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId); 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( ElementInfo? miniGame = LayoutImporter.ImportInfos(
dats, MiniGameUiController.LayoutId, MiniGameUiController.RootId); dats, MiniGameUiController.LayoutId, MiniGameUiController.RootId);
Assert.NotNull(miniGame); Assert.NotNull(miniGame);

File diff suppressed because it is too large Load diff