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:
parent
f1a7912160
commit
7eaa68a5f4
26 changed files with 2780 additions and 237 deletions
|
|
@ -76,7 +76,7 @@ Copy this block when adding a new issue:
|
|||
|
||||
## #234 — Monster assessment held busy and examination used the shared panel
|
||||
|
||||
**Status:** DONE — 2026-07-23, corrective connected gate pending
|
||||
**Status:** DONE — 2026-07-23; creature presentation visual gate pending
|
||||
**Severity:** HIGH
|
||||
**Filed:** 2026-07-23
|
||||
**Component:** appraisal protocol / retained examination UI / item writing
|
||||
|
|
@ -95,6 +95,9 @@ had also been incorrectly registered as synthetic main-panel id
|
|||
bits/order are now pinned by literal packet tests, the floaty is registered as
|
||||
an independent top-level window, and the imported multiline field sends
|
||||
retail's CP-1252 `SetInscription (0x00BF)` under its ownership/authorship rules.
|
||||
The follow-up creature page now uses the authored row template and exact retail
|
||||
stat order/formatting, creature-type EnumMapper, level property, fixed-heading
|
||||
animated clone, bounding-box camera, and private-viewport light.
|
||||
|
||||
**Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`;
|
||||
`src/AcDream.App/UI/RetailUiRuntime.cs`;
|
||||
|
|
@ -108,7 +111,9 @@ retail's CP-1252 `SetInscription (0x00BF)` under its ownership/authorship rules.
|
|||
**Acceptance:** Monster/NPC/player assessment opens and clears busy; the
|
||||
examination window coexists with the shared primary panel; an owned
|
||||
inscribable weapon can be edited/cleared and reassessed; foreign/nonowned/
|
||||
noninscribable cases show retail's exact permission messages.
|
||||
noninscribable cases show retail's exact permission messages. Monster pages
|
||||
show the animated target, mapped creature type, level, and nine ordered
|
||||
retail stat rows.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
|
||||
| AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 − translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 − translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 − translucency`, applied to all 4 D3D9 material alpha channels) |
|
||||
| AP-90 | **Radar fellowship/allegiance relationship state is modeled but not yet delivered at runtime.** `RetailRadar.GetBlipShape` and `RadarBlipColors.For` implement retail's leader/member/allegiance precedence, and `RadarSnapshotProvider` exposes a `relationshipFor(guid)` seam, but acdream does not yet maintain live fellowship membership and its `AllegianceTree` is not wired into GameWindow. PK/PKLite relationship shapes do work from PWD flags. | `src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs`; `src/AcDream.Core/Ui/RetailRadar.cs`; `src/AcDream.Core/Ui/RadarBlipColors.cs` | Preserve the exact model/seam now and avoid inventing membership from names or chat; connect it when the social game-event state is ported | Fellowship members render their ordinary player color/shape instead of bright-green leader/member triangles; allegiance members render an ordinary plus instead of a hollow box | `gmRadarUI::GetBlipColor` 0x004D76F0; `gmRadarUI::GetBlipShape` 0x004D7B60 |
|
||||
| AP-92 | Paperdoll renders its creature through a private FBO/RTT and blits it into `UiViewport`; retail renders `CreatureMode` directly in the UI viewport/in-cell presentation path | `src/AcDream.App/Rendering/PaperdollFramePresenter.cs`; `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent and the accepted pixels/camera/pose match retail; state is sealed with `GLStateScope` | FBO origin, alpha, lighting, or state isolation can diverge from direct viewport rendering | `gmPaperDollUI::PostInit @ 0x004A5360`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` |
|
||||
| AP-92 | Private creature viewports (paperdoll and examination) render through isolated FBO/RTTs and blit into `UiViewport`; retail renders each `CreatureMode` directly and advances a cloned `CPhysicsObj`, while examination currently refreshes its clone from the live target's animated mesh pose | `src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs`; `src/AcDream.App/Rendering/PaperdollFramePresenter.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent; shared live pose data provides animation without registering a second gameplay entity or duplicating the world sequencer | FBO origin, alpha, lighting, state isolation, or an assessment-time cloned motion diverging later from the live target can differ from direct retail CreatureMode presentation | `CPhysicsObj::makeObject(CPhysicsObj const*) @ 0x005144B0`; `gmPaperDollUI::PostInit @ 0x004A5360`; `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` |
|
||||
| AP-93 | Paperdoll does not port `UpdateForRace`; all characters use the cdb-confirmed default held pose `0x030003C0` and default presentation | `src/AcDream.App/Rendering/DollEntityBuilder.cs`; `RetailPaperdollPoseApplicator` in `src/AcDream.App/Rendering/PaperdollFramePresenter.cs` | Exact for the tested Horan character; unresolved for other heritage/gender/body combinations | Non-default races can use the wrong pose, heading, camera framing, or presentation asset | `gmPaperDollUI::UpdateForRace @ 0x004A3ED0` |
|
||||
| AP-94 | Imported Type-12 text defaults to interactive/selectable behavior; display labels can focus/capture/drag, and vitals synthesize duplicate runtime labels instead of binding `0x100000EB/ED/EF` | `src/AcDream.App/UI/UiText.cs`; `src/AcDream.App/UI/Layout/VitalsController.cs` | Historical widget-generalization default; Wave 1 ports explicit Display/Selectable/Editable roles | Invisible/static text steals input and duplicate labels drift from DAT geometry | `UIElement_Text` property handlers; `gmVitalsUI::PostInit @ 0x004BFCE0` |
|
||||
| ~~AP-95~~ | **RETIRED 2026-07-11** — `UiButton` owns retail hover/pressed/released-outside, disabled, selected/toggle, missing-state fallback, hot-click, and distinct press/release controller callbacks. | `src/AcDream.App/UI/UiButton.cs`; `UiButtonStateMachine.cs` | — | — | `UIElement_Button::UpdateState_ @ 0x00471CF0`; mouse handlers `0x00471FF0..0x004721F0` |
|
||||
|
|
@ -205,7 +205,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` |
|
||||
| AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` |
|
||||
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
|
||||
| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination now has its independent authored floaty layout, item/creature/character reports, and conditional inscription-write transaction, but still lacks the exact 3D preview, exhaustive specialized detail regions, and selection-follow preference. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription loop cover the active loops; the remaining examination mechanisms require the retail preview render owner and additional field projections rather than fabricated content | Specialized assessments omit some retail fields/3D preview; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog |
|
||||
| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, item/creature/character reports, inscription transaction, and retail creature stat/animated-preview presentation, but still lacks item-object preview, exhaustive specialized detail/extra-rating regions, exact appraisal FontInfo-list selection, and selection-follow preference. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription/creature-preview loop cover the active loops; the remaining examination mechanisms require additional retail field/font/object-preview projections rather than fabricated content | Specialized assessments omit some retail fields and item-object presentation; enchanted/incomplete appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog |
|
||||
| ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
|
||||
| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
|
||||
| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ zero-useability, carried direct-use, AutoWear correction, and the requested
|
|||
shared item-cooldown follow-up passed their connected gates. Slice 3's first
|
||||
connected gate exposed an incorrect IdentifyResponse flag table, a non-retail
|
||||
shared-panel mount, and its missing inscription transaction. All three are
|
||||
corrected; the independent floaty examination window awaits its corrective
|
||||
connected gate.
|
||||
corrected and live-confirmed. The creature-page follow-up now has retail's
|
||||
ordered stat rows and animated private preview; its visual gate is pending.
|
||||
**Milestone:** M4 prerequisite/preamble.
|
||||
**Architecture:** retained gameplay UI over shared selection, object, and
|
||||
interaction state. `GameWindow` remains a composition/callback shell.
|
||||
|
|
@ -132,14 +132,25 @@ geometry. The imported multiline inscription field now ports
|
|||
`ItemExamineUI`'s public/hook inscribability, inventory ownership and
|
||||
same-scribe permission rules, placeholder/signature presentation, exact
|
||||
failure notices, focus commit behavior, and CP-1252 `SetInscription (0x00BF)`
|
||||
transaction. Basic item, creature, and player reports are live; exact 3D
|
||||
preview/specialized detail regions and selection-follow remain the narrowed
|
||||
AP-110 residual. Research:
|
||||
transaction. Basic item and player reports are live. The creature page now
|
||||
resolves its type through retail EnumMapper `0x2200000E`, preserves the
|
||||
authored Character/Level header, creates the exact nine stat rows from
|
||||
template `0x10000166`, and renders a fixed-heading animated clone through a
|
||||
private viewport using retail's bounding-box camera and distant light.
|
||||
Item-object preview, specialized detail regions, exact appraisal font-state
|
||||
selection, and selection-follow remain the narrowed AP-110 residual. Research:
|
||||
[`../research/2026-07-23-retail-appraisal-ui-pseudocode.md`](../research/2026-07-23-retail-appraisal-ui-pseudocode.md).
|
||||
The focused parser/router/request/controller/fixture tests pass, as do the
|
||||
Release solution build with 17 pre-existing tracked test warnings, 3,496 App
|
||||
tests / 3 skips, and 7,913 complete-solution tests / 5 skips.
|
||||
|
||||
The creature presentation follow-up adds real-DAT row-template and EnumMapper
|
||||
fixtures plus pure conformance coverage for row order/formatting, enchantment
|
||||
semantics, failed assessments, stable clone identity, live animated mesh
|
||||
updates, hydrated mesh bounds, and retail camera fitting. The warning-free App code,
|
||||
Release solution build, 3,506 App tests / 3 skips, and 7,923 complete-solution
|
||||
tests / 5 skips pass. The connected visual result remains the closeout gate.
|
||||
|
||||
## Slice 1 — spell-bar overflow arrows
|
||||
|
||||
### Retail oracle
|
||||
|
|
@ -253,6 +264,10 @@ Named retail references and executable pseudocode are recorded in
|
|||
inscription/signature, retail chrome, and working scrollbars.
|
||||
- Assess a monster, NPC, and player: the correct creature/character page
|
||||
should appear and the busy cursor should clear.
|
||||
- On a monster, verify the animated creature is centered behind the exact
|
||||
Strength/Endurance/Coordination/Quickness/Focus/Self/Health/Stamina/Mana
|
||||
order, with its creature type and level in the authored header. Assess
|
||||
differently sized monsters to verify the retail bounding-box fit.
|
||||
- In combat, leave a creature assessment open long enough to observe a health
|
||||
refresh; closing the examination window must stop refreshes.
|
||||
- Keep Examination open while opening/moving Inventory or Skills and confirm
|
||||
|
|
|
|||
|
|
@ -13,9 +13,15 @@ retained examination window.
|
|||
- `gmExaminationUI::SetActiveExamineUI @ 0x004AB420`
|
||||
- `gmExaminationUI::UseTime @ 0x004AB530`
|
||||
- `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`
|
||||
- `BasicCreatureExamineUI::Init @ 0x004AB9C0`
|
||||
- `BasicCreatureExamineUI::BasicCreatureExamineUI @ 0x004ACD30`
|
||||
- `BasicCreatureExamineUI::SetLevelValueText @ 0x004B3E70`
|
||||
- `BasicCreatureExamineUI::SetAppraiseInfo @ 0x004B3F70`
|
||||
- `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`
|
||||
- `CharExamineUI::SetAppraiseInfo @ 0x004B45F0`
|
||||
- `AttributeInfoRegion::Update @ 0x004F1D90`
|
||||
- `Attribute2ndInfoRegion::Update @ 0x004F1E80`
|
||||
- `AppraisalSystem::InqCreatureDisplayName @ 0x005B59E0`
|
||||
- `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`
|
||||
- `ItemExamineUI::HandleInscriptionGainingFocus @ 0x004AC0E0`
|
||||
- `ItemExamineUI::HandleInscriptionMousePresses @ 0x004AC230`
|
||||
|
|
@ -189,6 +195,79 @@ When the examination window's selection-follow option is active, a selection
|
|||
change appraises the new selected GUID; clearing selection hides the window.
|
||||
That optional retail preference remains outside this slice.
|
||||
|
||||
## Creature presentation
|
||||
|
||||
`BasicCreatureExamineUI` binds viewport `0x10000148`, list box `0x10000149`,
|
||||
level value `0x1000014C`, and creature display name `0x1000014E`. Its stat
|
||||
rows are instances of LayoutDesc `0x2100006B` template `0x10000166`, whose
|
||||
label and value fields are `0x1000012A` and `0x1000012B`.
|
||||
|
||||
```text
|
||||
on creature appraisal:
|
||||
level = int property 25
|
||||
level text = decimal level when positive, otherwise "???"
|
||||
|
||||
creatureType = int property 2
|
||||
display name = EnumMapper 0x2200000E[creatureType]
|
||||
replace underscores in display name with spaces
|
||||
|
||||
clear stat list
|
||||
append rows in this exact order:
|
||||
Strength primary attribute 1
|
||||
Endurance primary attribute 2
|
||||
Coordination primary attribute 4
|
||||
Quickness primary attribute 3
|
||||
Focus primary attribute 5
|
||||
Self primary attribute 6
|
||||
Health current secondary 2 / maximum secondary 1, show percent
|
||||
Stamina current secondary 4 / maximum secondary 3
|
||||
Mana current secondary 6 / maximum secondary 5
|
||||
```
|
||||
|
||||
Successful primary values are decimal integers. Successful Health is
|
||||
`current/max (percent %)`; Stamina and Mana are `current/max`. On a failed
|
||||
assessment, Health still shows the percentage when both values are known,
|
||||
while every other row is `???`. The profile highlight/color bitfields select
|
||||
one of retail's four authored FontInfo entries (normal, beneficial,
|
||||
detrimental, or incomplete); exact FontInfo-list binding remains AP-110.
|
||||
|
||||
The animated preview is a private `CreatureMode`, not the live world object:
|
||||
|
||||
```text
|
||||
clear the previous private creature
|
||||
clone selected CPhysicsObj with makeObject
|
||||
set clone heading to 191.367905 degrees
|
||||
query the clone bounding box
|
||||
|
||||
width = bbox.max.x - bbox.min.x
|
||||
depth = bbox.max.y - bbox.min.y
|
||||
height = bbox.max.z - bbox.min.z
|
||||
viewportHeightOverWidth = viewport.height / viewport.width
|
||||
fittedVerticalSpan = max(height, width * viewportHeightOverWidth)
|
||||
center = (bbox.min + bbox.max) / 2
|
||||
camera.position = (
|
||||
center.x,
|
||||
center.y - (fittedVerticalSpan * 1.20710683 + depth / 2),
|
||||
center.z)
|
||||
camera.direction = identity (+Y)
|
||||
|
||||
viewport light:
|
||||
type = DISTANT_LIGHT
|
||||
intensity = 2
|
||||
direction = (0.3, 1.9, 0.65)
|
||||
|
||||
insert clone into the private CreatureMode
|
||||
```
|
||||
|
||||
ACDream's modern rendering equivalent uses the same isolated render-to-texture
|
||||
owner as the inventory paperdoll. The examination clone has its own stable
|
||||
render identity, fixed heading and retail camera/light, while its mesh-part
|
||||
transforms are refreshed from the live target each frame so its current
|
||||
animation is visible without registering a second gameplay entity. Retail's
|
||||
clone instead advances the motion state copied by
|
||||
`CPhysicsObj::MorphToExistingObject`; that narrow sequencer-ownership
|
||||
adaptation is recorded under AP-92.
|
||||
|
||||
## Item presentation order
|
||||
|
||||
`ItemExamineUI::SetAppraiseInfo` clears and rebuilds the authored item text in
|
||||
|
|
@ -333,7 +412,9 @@ implements the same behavior.
|
|||
|
||||
The request lifetime, exact packet flags, independent floaty window,
|
||||
inscription permission/edit/write transaction, subview choice, authored outer
|
||||
layout, basic report content, scrollbar ownership, and combat refresh are in
|
||||
scope. Retail's exact 3D object/creature preview, exhaustive specialized
|
||||
`ItemExamineUI` detail branches, selection-follow preference, and PSR-only
|
||||
scribe-account/override behavior remain explicit AP-110 work.
|
||||
layout, ordered creature stats, animated creature preview, scrollbar
|
||||
ownership, and combat refresh are in scope. Retail's item-object preview,
|
||||
exhaustive specialized `ItemExamineUI` detail branches, creature
|
||||
extra-rating regions, exact appraisal FontInfo-list selection,
|
||||
selection-follow preference, and PSR-only scribe-account/override behavior
|
||||
remain explicit AP-110 work.
|
||||
|
|
|
|||
|
|
@ -441,7 +441,9 @@ internal sealed class FrameRootCompositionPhase
|
|||
session.LocalTeleport,
|
||||
host.CameraController),
|
||||
renderFrameResources,
|
||||
live.PaperdollPresenter,
|
||||
new PrivateEntityViewportFrameGroup(
|
||||
live.PaperdollPresenter,
|
||||
live.CreatureAppraisalPresenter),
|
||||
retainedGameplayUi,
|
||||
settings.DevTools?.Presenter,
|
||||
privateScreenshot);
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ internal sealed record LivePresentationResult(
|
|||
RetainedUiGameplayBinding? RetainedGameplay,
|
||||
PaperdollViewportRenderer? PaperdollRenderer,
|
||||
PaperdollFramePresenter? PaperdollPresenter,
|
||||
CreatureAppraisalViewportRenderer? CreatureAppraisalRenderer,
|
||||
CreatureAppraisalFramePresenter? CreatureAppraisalPresenter,
|
||||
WbFrustum EnvCellFrustum,
|
||||
EnvCellRenderer EnvCellRenderer,
|
||||
LandblockPresentationPipeline LandblockPipeline,
|
||||
|
|
@ -120,7 +122,7 @@ internal enum LivePresentationCompositionPoint
|
|||
EffectRoutingBound,
|
||||
SelectionAndRadarBound,
|
||||
RetainedGameplayBound,
|
||||
PaperdollCreated,
|
||||
PrivateCreatureViewportsCreated,
|
||||
EnvironmentCellsCreated,
|
||||
LandblockPipelineCreated,
|
||||
PortalResourcesCreated,
|
||||
|
|
@ -694,7 +696,48 @@ internal sealed class LivePresentationCompositionPhase
|
|||
content.AnimationLoader,
|
||||
d.DatLock)));
|
||||
}
|
||||
Fault(LivePresentationCompositionPoint.PaperdollCreated);
|
||||
|
||||
CompositionAcquisitionScope.CompositionAcquisitionLease<
|
||||
CreatureAppraisalViewportRenderer>? creatureAppraisalLease = null;
|
||||
CreatureAppraisalFramePresenter? creatureAppraisalPresenter = null;
|
||||
if (interaction.RetainedUi?.Runtime.CreatureAppraisalViewportWidget
|
||||
is { } creatureViewport
|
||||
&& interaction.RetainedUi.Runtime.ExaminationFrame
|
||||
is { } examinationFrame
|
||||
&& interaction.RetainedUi.Runtime.AppraisalController
|
||||
is { } appraisalController)
|
||||
{
|
||||
creatureAppraisalLease = scope.Acquire(
|
||||
"creature appraisal viewport",
|
||||
() => new CreatureAppraisalViewportRenderer(
|
||||
d.Gl,
|
||||
dispatcherLease.Resource,
|
||||
foundation.SceneLighting,
|
||||
foundation.TextureCache),
|
||||
static value => value.Dispose());
|
||||
IUiViewportRenderer? previousRenderer = creatureViewport.Renderer;
|
||||
creatureViewport.Renderer = creatureAppraisalLease.Resource;
|
||||
bindings.AdoptRelease(
|
||||
"creature appraisal viewport target",
|
||||
() =>
|
||||
{
|
||||
if (ReferenceEquals(
|
||||
creatureViewport.Renderer,
|
||||
creatureAppraisalLease.Resource))
|
||||
{
|
||||
creatureViewport.Renderer = previousRenderer;
|
||||
}
|
||||
});
|
||||
creatureAppraisalPresenter = new CreatureAppraisalFramePresenter(
|
||||
creatureAppraisalLease.Resource,
|
||||
new RetailCreatureAppraisalFrameView(
|
||||
creatureViewport,
|
||||
examinationFrame,
|
||||
appraisalController),
|
||||
new RetailCreatureAppraisalCloneFactory(
|
||||
new LiveCreatureAppraisalEntityLookup(liveEntities)));
|
||||
}
|
||||
Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated);
|
||||
|
||||
var envCellFrustum = new WbFrustum();
|
||||
var envCellLease = scope.Acquire(
|
||||
|
|
@ -890,6 +933,8 @@ internal sealed class LivePresentationCompositionPhase
|
|||
retainedGameplayLease?.Resource,
|
||||
paperdollLease?.Resource,
|
||||
paperdollPresenter,
|
||||
creatureAppraisalLease?.Resource,
|
||||
creatureAppraisalPresenter,
|
||||
envCellFrustum,
|
||||
envCellLease.Resource,
|
||||
landblockPipeline,
|
||||
|
|
@ -910,6 +955,7 @@ internal sealed class LivePresentationCompositionPhase
|
|||
dispatcherLease.Transfer();
|
||||
retainedGameplayLease?.Transfer();
|
||||
paperdollLease?.Transfer();
|
||||
creatureAppraisalLease?.Transfer();
|
||||
envCellLease.Transfer();
|
||||
clipFrameLease.Transfer();
|
||||
portalDepthLease.Transfer();
|
||||
|
|
|
|||
407
src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs
Normal file
407
src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs
Normal 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();
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ namespace AcDream.App.Rendering;
|
|||
public sealed class DollCamera : ICamera
|
||||
{
|
||||
// Retail paperdoll camera origin (decomp 0x004a5a51–0x004a5a61).
|
||||
private static readonly Vector3 Eye = new(0.12f, -2.4f, 0.88f);
|
||||
internal static readonly Vector3 RetailEye = new(0.12f, -2.4f, 0.88f);
|
||||
// Identity view orientation ⇒ look straight down +Y (no yaw/pitch). Target = Eye + (0,1,0).
|
||||
private static readonly Vector3 Target = new(0.12f, -1.4f, 0.88f);
|
||||
private static readonly Vector3 Up = Vector3.UnitZ; // AC up-axis = +Z, same as ChaseCamera
|
||||
|
|
@ -45,9 +45,42 @@ public sealed class DollCamera : ICamera
|
|||
|
||||
/// <inheritdoc/>
|
||||
public Matrix4x4 View =>
|
||||
Matrix4x4.CreateLookAt(Eye, Target, Up);
|
||||
Matrix4x4.CreateLookAt(RetailEye, Target, Up);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Matrix4x4 Projection =>
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -378,6 +378,10 @@ public sealed class GameWindow :
|
|||
// widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625).
|
||||
private AcDream.App.Rendering.PaperdollViewportRenderer? _paperdollViewportRenderer;
|
||||
private AcDream.App.Rendering.PaperdollFramePresenter? _paperdollFramePresenter;
|
||||
private AcDream.App.Rendering.CreatureAppraisalViewportRenderer?
|
||||
_creatureAppraisalViewportRenderer;
|
||||
private AcDream.App.Rendering.CreatureAppraisalFramePresenter?
|
||||
_creatureAppraisalFramePresenter;
|
||||
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
|
||||
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
|
||||
// Phase I.2: ImGui debug panel ViewModel. The devtools presenter owns
|
||||
|
|
@ -948,6 +952,8 @@ public sealed class GameWindow :
|
|||
|| _animationPresenter is not null
|
||||
|| _paperdollViewportRenderer is not null
|
||||
|| _paperdollFramePresenter is not null
|
||||
|| _creatureAppraisalViewportRenderer is not null
|
||||
|| _creatureAppraisalFramePresenter is not null
|
||||
|| _envCellRenderer is not null
|
||||
|| _envCellFrustum is not null
|
||||
|| _landblockPresentationPipeline is not null
|
||||
|
|
@ -983,6 +989,8 @@ public sealed class GameWindow :
|
|||
_retainedUiGameplayBinding = result.RetainedGameplay;
|
||||
_paperdollViewportRenderer = result.PaperdollRenderer;
|
||||
_paperdollFramePresenter = result.PaperdollPresenter;
|
||||
_creatureAppraisalViewportRenderer = result.CreatureAppraisalRenderer;
|
||||
_creatureAppraisalFramePresenter = result.CreatureAppraisalPresenter;
|
||||
_envCellFrustum = result.EnvCellFrustum;
|
||||
_envCellRenderer = result.EnvCellRenderer;
|
||||
_landblockPresentationPipeline = result.LandblockPipeline;
|
||||
|
|
@ -1581,6 +1589,7 @@ public sealed class GameWindow :
|
|||
_localPlayerTeleport,
|
||||
_portalTunnelFallback,
|
||||
_paperdollViewportRenderer,
|
||||
_creatureAppraisalViewportRenderer,
|
||||
_wbDrawDispatcher,
|
||||
_envCellRenderer,
|
||||
_portalDepthMask,
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ internal sealed record RenderShutdownRoots(
|
|||
LocalPlayerTeleportController? LocalTeleport,
|
||||
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
|
||||
PaperdollViewportRenderer? Paperdoll,
|
||||
CreatureAppraisalViewportRenderer? CreatureAppraisal,
|
||||
WbDrawDispatcher? DrawDispatcher,
|
||||
EnvCellRenderer? EnvironmentCells,
|
||||
PortalDepthMaskRenderer? PortalDepthMask,
|
||||
|
|
@ -413,6 +414,9 @@ internal static class GameWindowShutdownManifest
|
|||
render.PortalTunnelFallback.ReleaseFallback();
|
||||
}),
|
||||
Hard("paperdoll viewport", () => render.Paperdoll?.Dispose()),
|
||||
Hard(
|
||||
"creature appraisal viewport",
|
||||
() => render.CreatureAppraisal?.Dispose()),
|
||||
Hard("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()),
|
||||
Hard("environment cells", () => render.EnvironmentCells?.Dispose()),
|
||||
Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()),
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ internal interface IPaperdollPoseApplicator
|
|||
/// The GL renderer remains a borrowed resource disposed by the existing window
|
||||
/// shutdown transaction.
|
||||
/// </summary>
|
||||
internal sealed class PaperdollFramePresenter : IPrivatePaperdollFrame
|
||||
internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame
|
||||
{
|
||||
private readonly IPaperdollDollRenderer _renderer;
|
||||
private readonly IPaperdollFrameView _view;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Lighting;
|
||||
|
|
@ -10,52 +7,15 @@ using Silk.NET.OpenGL;
|
|||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Render-to-texture renderer for the paperdoll 3-D doll (the C# analog of retail
|
||||
/// <c>CreatureMode::Render</c>). Draws ONE re-dressed player clone (a <see cref="WorldEntity"/>,
|
||||
/// 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>
|
||||
/// Paperdoll-specific facade over the shared private creature viewport. The
|
||||
/// fixed camera remains the verbatim retail <c>gmPaperDollUI</c> camera.
|
||||
/// </summary>
|
||||
public sealed unsafe class PaperdollViewportRenderer :
|
||||
public sealed class PaperdollViewportRenderer :
|
||||
IUiViewportRenderer,
|
||||
IPaperdollDollRenderer,
|
||||
IDisposable
|
||||
{
|
||||
private readonly GL _gl;
|
||||
private readonly WbDrawDispatcher _dispatcher;
|
||||
private readonly SceneLightingUboBinding _lightUbo;
|
||||
private readonly FixedEntityTextureOwnerLease _textureOwnerLease;
|
||||
private readonly DollCamera _camera = new();
|
||||
|
||||
// Off-screen target (lazily (re)created on size change).
|
||||
private uint _fbo;
|
||||
private uint _colorTex;
|
||||
private uint _depthRbo;
|
||||
private int _fbW;
|
||||
private int _fbH;
|
||||
|
||||
// The static held-pose doll. null = nothing to draw.
|
||||
private WorldEntity? _doll;
|
||||
|
||||
// Synthetic landblock for the doll's one-entry Draw tuple. With frustum:null + this as
|
||||
// neverCullLandblockId the entity is never culled. 0 is unused by live streaming on the doll path.
|
||||
private const uint DollLandblockId = 0u;
|
||||
|
||||
// The doll's render id, passed as "animated" so the dispatcher bypasses the Tier-1 classification
|
||||
// cache (WbDrawDispatcher.cs:1142) and re-classifies from the current MeshRefs every frame. This is
|
||||
// what makes a re-dress (a new WorldEntity built with the same DollRenderId) actually take effect,
|
||||
// AND what lets the idle animation (later slice, TickAnimations rebuilds MeshRefs) show.
|
||||
private static readonly HashSet<uint> _dollAnimatedIds = new() { DollEntityBuilder.DollRenderId };
|
||||
private readonly PrivateEntityViewportRenderer _renderer;
|
||||
|
||||
public PaperdollViewportRenderer(
|
||||
GL gl,
|
||||
|
|
@ -63,151 +23,20 @@ public sealed unsafe class PaperdollViewportRenderer :
|
|||
SceneLightingUboBinding lightUbo,
|
||||
IEntityTextureLifetime textureLifetime)
|
||||
{
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
|
||||
_textureOwnerLease = new FixedEntityTextureOwnerLease(
|
||||
_renderer = new PrivateEntityViewportRenderer(
|
||||
gl,
|
||||
dispatcher,
|
||||
lightUbo,
|
||||
textureLifetime,
|
||||
DollEntityBuilder.DollRenderId);
|
||||
DollEntityBuilder.DollRenderId,
|
||||
new DollViewportCamera(),
|
||||
"paperdoll");
|
||||
}
|
||||
|
||||
/// <summary>Set (or clear) the doll entity built from the live player projection.</summary>
|
||||
public void SetDoll(WorldEntity? doll)
|
||||
{
|
||||
if (ReferenceEquals(_doll, doll))
|
||||
return;
|
||||
_textureOwnerLease.Replace(doll is not null);
|
||||
_doll = doll;
|
||||
}
|
||||
public void SetDoll(WorldEntity? doll) => _renderer.SetEntity(doll);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public uint Render(int width, int height)
|
||||
{
|
||||
var doll = _doll;
|
||||
if (doll is null || doll.MeshRefs.Count == 0 || width <= 0 || height <= 0)
|
||||
return 0u;
|
||||
public uint Render(int width, int height) =>
|
||||
_renderer.Render(width, height);
|
||||
|
||||
EnsureFramebuffer(width, height);
|
||||
if (_fbo == 0) return 0u;
|
||||
_camera.Aspect = width / (float)height;
|
||||
|
||||
// Seal the entire 3-D pass — GLStateScope restores viewport/scissor/depth/cull/blend/program/
|
||||
// FBO bindings on dispose, so the surrounding world + 2-D UI passes are untouched.
|
||||
using var scope = new GLStateScope(_gl);
|
||||
|
||||
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
|
||||
_gl.Viewport(0, 0, (uint)width, (uint)height);
|
||||
_gl.Disable(EnableCap.ScissorTest);
|
||||
_gl.ClearColor(0f, 0f, 0f, 0f); // transparent — the window backdrop shows through behind the doll
|
||||
_gl.ClearDepth(1.0);
|
||||
_gl.DepthMask(true); // depth clears honor glDepthMask
|
||||
_gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
||||
|
||||
_gl.Enable(EnableCap.DepthTest);
|
||||
_gl.DepthFunc(DepthFunction.Less);
|
||||
_gl.Enable(EnableCap.CullFace);
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
_gl.FrontFace(FrontFaceDirection.Ccw); // matches the world object pass; if the doll renders inside-out at the gate, flip to Cw
|
||||
_gl.Disable(EnableCap.Blend);
|
||||
|
||||
UploadDollLight();
|
||||
|
||||
// One synthetic landblock entry holding just the doll. frustum:null ⇒ always "visible" ⇒
|
||||
// walked from entry.Entities and drawn from doll.MeshRefs (WbDrawDispatcher.cs:692,1190).
|
||||
var entities = new WorldEntity[] { doll };
|
||||
var entries = new (uint, Vector3, Vector3, IReadOnlyList<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();
|
||||
}
|
||||
public void Dispose() => _renderer.Dispose();
|
||||
}
|
||||
|
|
|
|||
252
src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
Normal file
252
src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ internal interface IPrivatePortalViewport
|
|||
void Draw(int width, int height);
|
||||
}
|
||||
|
||||
internal interface IPrivatePaperdollFrame
|
||||
internal interface IPrivateEntityViewportFrame
|
||||
{
|
||||
void Render();
|
||||
}
|
||||
|
|
@ -35,7 +35,7 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha
|
|||
{
|
||||
private readonly IPrivatePortalViewport _portal;
|
||||
private readonly IRenderFrameFoundationSource _foundation;
|
||||
private readonly IPrivatePaperdollFrame? _paperdoll;
|
||||
private readonly IPrivateEntityViewportFrame? _entityViewports;
|
||||
private readonly IRetainedGameplayUiFrame? _gameplayUi;
|
||||
private readonly IDevToolsFrameLifecycle? _devTools;
|
||||
private readonly IPrivateFrameScreenshot? _screenshots;
|
||||
|
|
@ -43,7 +43,7 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha
|
|||
public PrivatePresentationRenderer(
|
||||
IPrivatePortalViewport portal,
|
||||
IRenderFrameFoundationSource foundation,
|
||||
IPrivatePaperdollFrame? paperdoll,
|
||||
IPrivateEntityViewportFrame? entityViewports,
|
||||
IRetainedGameplayUiFrame? gameplayUi,
|
||||
IDevToolsFrameLifecycle? devTools,
|
||||
IPrivateFrameScreenshot? screenshots)
|
||||
|
|
@ -51,7 +51,7 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha
|
|||
_portal = portal ?? throw new ArgumentNullException(nameof(portal));
|
||||
_foundation = foundation
|
||||
?? throw new ArgumentNullException(nameof(foundation));
|
||||
_paperdoll = paperdoll;
|
||||
_entityViewports = entityViewports;
|
||||
_gameplayUi = gameplayUi;
|
||||
_devTools = devTools;
|
||||
_screenshots = screenshots;
|
||||
|
|
@ -68,7 +68,7 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha
|
|||
bool portalViewportVisible =
|
||||
_foundation.Foundation.PortalViewportVisible;
|
||||
_portal.Draw(input.ViewportWidth, input.ViewportHeight);
|
||||
_paperdoll?.Render();
|
||||
_entityViewports?.Render();
|
||||
_gameplayUi?.Render(
|
||||
input.DeltaSeconds,
|
||||
input.ViewportWidth,
|
||||
|
|
@ -86,6 +86,30 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha
|
|||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
internal sealed class LocalPlayerPortalViewport : IPrivatePortalViewport
|
||||
{
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
public const uint SignatureTextId = 0x1000013Fu;
|
||||
public const uint InscriptionScrollbarId = 0x1000046Eu;
|
||||
public const uint CreaturePanelId = 0x10000140u;
|
||||
public const uint CreatureViewportId = 0x10000148u;
|
||||
public const uint CreatureStatsListId = 0x10000149u;
|
||||
public const uint CreatureDisplayNameId = 0x1000014Eu;
|
||||
public const uint CreatureLevelValueId = 0x1000014Cu;
|
||||
public const uint SpellPanelId = 0x10000153u;
|
||||
|
||||
private const uint TemplateStringProperty = 5u;
|
||||
|
|
@ -53,6 +57,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
private readonly UiText? _signature;
|
||||
private readonly UiDatElement? _inscriptionBackground;
|
||||
private readonly UiButton? _close;
|
||||
private readonly UiItemList? _creatureStats;
|
||||
private readonly CreatureAppraisalRowTemplateFactory? _creatureRowTemplates;
|
||||
private readonly CreatureDisplayNameResolver _creatureNames;
|
||||
private AppraisalView _activeView;
|
||||
private uint _itemObjectId;
|
||||
private uint _creatureObjectId;
|
||||
|
|
@ -81,7 +88,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
UiElement itemPanel,
|
||||
UiElement creaturePanel,
|
||||
UiText title,
|
||||
UiText itemText)
|
||||
UiText itemText,
|
||||
CreatureAppraisalRowTemplateFactory? creatureRowTemplates,
|
||||
CreatureDisplayNameResolver? creatureNames)
|
||||
{
|
||||
_layout = layout;
|
||||
_objects = objects;
|
||||
|
|
@ -97,6 +106,10 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
_spellPanel = layout.FindElement(SpellPanelId);
|
||||
_title = title;
|
||||
_itemText = itemText;
|
||||
_creatureRowTemplates = creatureRowTemplates;
|
||||
_creatureNames = creatureNames
|
||||
?? new CreatureDisplayNameResolver(
|
||||
new Dictionary<uint, string>());
|
||||
_inscriptionText = layout.FindElement(InscriptionTextId) as UiText;
|
||||
_inscriptionField = layout.FindElement(InscriptionTextId) as UiField;
|
||||
_signature = layout.FindElement(SignatureTextId) as UiText;
|
||||
|
|
@ -136,6 +149,25 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
_inscriptionBackground.ClickThrough = false;
|
||||
}
|
||||
|
||||
if (creatureRowTemplates is not null
|
||||
&& layout.FindElement(CreatureStatsListId) is { } statsHost)
|
||||
{
|
||||
_creatureStats = statsHost as UiItemList
|
||||
?? new UiItemList
|
||||
{
|
||||
Width = statsHost.Width,
|
||||
Height = statsHost.Height,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top
|
||||
| AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
};
|
||||
if (!ReferenceEquals(_creatureStats, statsHost))
|
||||
statsHost.AddChild(_creatureStats);
|
||||
_creatureStats.Flush();
|
||||
_creatureStats.Columns = 1;
|
||||
_creatureStats.CellWidth = creatureRowTemplates.Width;
|
||||
_creatureStats.CellHeight = creatureRowTemplates.Height;
|
||||
}
|
||||
|
||||
SetActiveView(AppraisalView.Item);
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +184,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
Action<uint, string> sendSetInscription,
|
||||
Action<string> systemMessage,
|
||||
Action show,
|
||||
Action close)
|
||||
Action close,
|
||||
CreatureAppraisalRowTemplateFactory? creatureRowTemplates = null,
|
||||
CreatureDisplayNameResolver? creatureNames = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(objects);
|
||||
|
|
@ -185,7 +219,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
itemPanel,
|
||||
creaturePanel,
|
||||
title,
|
||||
itemText);
|
||||
itemText,
|
||||
creatureRowTemplates,
|
||||
creatureNames);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -452,17 +488,12 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
{
|
||||
ClearCreatureText();
|
||||
PropertyBundle p = appraisal.Properties;
|
||||
SetText(0x1000014Au, character
|
||||
? GetString(p, 4u)
|
||||
: AppraisalTextFormatter.CreatureTypeName(GetInt(p, 2u)));
|
||||
SetText(0x1000014Bu, GetString(p, 2u));
|
||||
SetText(0x1000014Cu, GetString(p, 14u));
|
||||
|
||||
string report = AppraisalTextFormatter.BuildCreature(
|
||||
obj,
|
||||
appraisal,
|
||||
character);
|
||||
SetText(0x1000014Eu, report, scrollable: true);
|
||||
int level = GetInt(p, 25u);
|
||||
SetText(
|
||||
CreatureLevelValueId,
|
||||
level > 0
|
||||
? level.ToString(CultureInfo.CurrentCulture)
|
||||
: "???");
|
||||
|
||||
if (character)
|
||||
{
|
||||
|
|
@ -470,6 +501,14 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
SetText(0x10000151u, GetString(p, 47u));
|
||||
SetText(0x10000152u, GetString(p, 11u));
|
||||
}
|
||||
else
|
||||
{
|
||||
SetText(
|
||||
CreatureDisplayNameId,
|
||||
_creatureNames.Resolve(GetInt(p, 2u)));
|
||||
}
|
||||
|
||||
RebuildCreatureStats(appraisal);
|
||||
|
||||
SetText(0x1000053Au, appraisal.Success
|
||||
? string.Empty
|
||||
|
|
@ -478,6 +517,25 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
ResetCreatureScroll();
|
||||
}
|
||||
|
||||
private void RebuildCreatureStats(AppraiseInfoParser.Parsed appraisal)
|
||||
{
|
||||
if (_creatureStats is null || _creatureRowTemplates is null)
|
||||
return;
|
||||
|
||||
using (_creatureStats.DeferLayout())
|
||||
{
|
||||
_creatureStats.Flush();
|
||||
if (appraisal.CreatureProfile is not { } profile)
|
||||
return;
|
||||
foreach (CreatureAppraisalRow row in CreatureAppraisalRows.Build(
|
||||
profile,
|
||||
appraisal.Success))
|
||||
{
|
||||
_creatureStats.AddItem(_creatureRowTemplates.Create(row));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureScrollableText(
|
||||
UiText text,
|
||||
uint scrollbarId,
|
||||
|
|
@ -513,15 +571,17 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
{
|
||||
foreach (uint id in new uint[]
|
||||
{
|
||||
0x1000014Au, 0x1000014Bu, 0x1000014Cu, 0x1000014Eu,
|
||||
CreatureLevelValueId, CreatureDisplayNameId,
|
||||
0x10000150u, 0x10000151u, 0x10000152u, 0x1000053Au,
|
||||
})
|
||||
if (_layout.FindElement(id) is UiText text)
|
||||
text.LinesProvider = static () => Array.Empty<UiText.Line>();
|
||||
_creatureStats?.Flush();
|
||||
}
|
||||
|
||||
private void ResetCreatureScroll()
|
||||
{
|
||||
_creatureStats?.Scroll.SetScrollY(0);
|
||||
foreach (UiText text in Descendants(_creaturePanel).OfType<UiText>())
|
||||
text.Scroll.SetScrollY(0);
|
||||
}
|
||||
|
|
|
|||
304
src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs
Normal file
304
src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -301,6 +301,8 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public SpellcastingUiController? SpellcastingUiController { get; private set; }
|
||||
public SpellbookWindowController? SpellbookWindowController { get; private set; }
|
||||
public AppraisalUiController? AppraisalController { get; private set; }
|
||||
public UiViewport? CreatureAppraisalViewportWidget { get; private set; }
|
||||
public UiElement? ExaminationFrame { get; private set; }
|
||||
public EffectsUiController? PositiveEffectsController { get; private set; }
|
||||
public EffectsUiController? NegativeEffectsController { get; private set; }
|
||||
public LinkStatusUiController? LinkStatusUiController { get; private set; }
|
||||
|
|
@ -961,9 +963,26 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
private void MountAppraisal()
|
||||
{
|
||||
ImportedLayout? layout = Import(
|
||||
AppraisalUiController.LayoutId,
|
||||
AppraisalUiController.RootId);
|
||||
ImportedLayout? layout;
|
||||
CreatureAppraisalRowTemplateFactory? creatureRows;
|
||||
CreatureDisplayNameResolver? creatureNames;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
layout = LayoutImporter.Import(
|
||||
_bindings.Assets.Dats,
|
||||
AppraisalUiController.LayoutId,
|
||||
AppraisalUiController.RootId,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
creatureRows = CreatureAppraisalRowTemplateFactory.TryLoad(
|
||||
_bindings.Assets.Dats,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
creatureNames = CreatureDisplayNameResolver.Load(
|
||||
_bindings.Assets.Dats);
|
||||
}
|
||||
if (layout is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
|
|
@ -981,7 +1000,9 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Appraisal.SendSetInscription,
|
||||
_bindings.Appraisal.DisplaySystemMessage,
|
||||
show: () => Host.ShowWindow(WindowNames.Examination),
|
||||
close: () => CloseWindow(WindowNames.Examination));
|
||||
close: () => CloseWindow(WindowNames.Examination),
|
||||
creatureRowTemplates: creatureRows,
|
||||
creatureNames: creatureNames);
|
||||
if (controller is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
|
|
@ -991,7 +1012,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
AppraisalController = controller;
|
||||
UiElement root = layout.Root;
|
||||
_ = RetailWindowFrame.Mount(
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
|
|
@ -1013,6 +1034,10 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
ContentClickThrough = false,
|
||||
Controller = controller,
|
||||
});
|
||||
CreatureAppraisalViewportWidget =
|
||||
layout.FindElement(AppraisalUiController.CreatureViewportId)
|
||||
as UiViewport;
|
||||
ExaminationFrame = handle.OuterFrame;
|
||||
Console.WriteLine(
|
||||
"[M4] retail examination window from LayoutDesc 0x2100006B.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
AssertAppearsInOrder(
|
||||
presentation,
|
||||
"_portal.Draw(",
|
||||
"_paperdoll?.Render();",
|
||||
"_entityViewports?.Render();",
|
||||
"_gameplayUi?.Render(",
|
||||
"_devTools?.Render(",
|
||||
"_screenshots?.CapturePending(");
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ public sealed class PrivatePresentationRendererTests
|
|||
var presentation = new PrivatePresentationRenderer(
|
||||
new Portal(calls),
|
||||
new FoundationSource(portalVisible: true),
|
||||
paperdoll: null,
|
||||
entityViewports: null,
|
||||
gameplayUi: null,
|
||||
devTools: null,
|
||||
screenshots: null);
|
||||
|
|
@ -64,6 +64,20 @@ public sealed class PrivatePresentationRendererTests
|
|||
Assert.False(outcome.ScreenshotCaptured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrivateEntityViewportGroup_RendersEveryViewportInOrder()
|
||||
{
|
||||
List<string> calls = [];
|
||||
var group = new PrivateEntityViewportFrameGroup(
|
||||
new NamedViewport(calls, "paperdoll"),
|
||||
null,
|
||||
new NamedViewport(calls, "examination"));
|
||||
|
||||
group.Render();
|
||||
|
||||
Assert.Equal(["paperdoll", "examination"], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ReportsThePreparedPortalSnapshotWhenDrawMutatesTheSource()
|
||||
{
|
||||
|
|
@ -72,7 +86,7 @@ public sealed class PrivatePresentationRendererTests
|
|||
var presentation = new PrivatePresentationRenderer(
|
||||
new MutatingPortal(calls, () => foundation.PortalVisible = false),
|
||||
foundation,
|
||||
paperdoll: null,
|
||||
entityViewports: null,
|
||||
gameplayUi: null,
|
||||
devTools: null,
|
||||
screenshots: null);
|
||||
|
|
@ -209,11 +223,17 @@ public sealed class PrivatePresentationRendererTests
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class Paperdoll(List<string> calls) : IPrivatePaperdollFrame
|
||||
private sealed class Paperdoll(List<string> calls) : IPrivateEntityViewportFrame
|
||||
{
|
||||
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
|
||||
{
|
||||
public RenderFrameInput Input { get; private set; }
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ public sealed class RenderFrameRecoveryIntegrationTests
|
|||
public void Draw(int width, int height) => failure.ThrowIf("portal");
|
||||
}
|
||||
|
||||
private sealed class Paperdoll(FailureSwitch failure) : IPrivatePaperdollFrame
|
||||
private sealed class Paperdoll(FailureSwitch failure) : IPrivateEntityViewportFrame
|
||||
{
|
||||
public void Render() => failure.ThrowIf("paperdoll");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ namespace AcDream.App.Tests.UI.Layout;
|
|||
public sealed class AppraisalUiControllerTests
|
||||
{
|
||||
private const uint ObjectId = 0x50000001u;
|
||||
private static (uint, int, int) NoTexture(uint _) => (0u, 0, 0);
|
||||
|
||||
[Fact]
|
||||
public void ItemResponse_UsesAuthoredItemSubviewTitleAndScrollbars()
|
||||
|
|
@ -146,6 +147,114 @@ public sealed class AppraisalUiControllerTests
|
|||
Assert.Equal(1, shown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreatureResponse_UsesRetailHeaderAndNineOrderedTemplateRows()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = ObjectId,
|
||||
Name = "Specter",
|
||||
Type = ItemType.Creature,
|
||||
});
|
||||
using var interaction = NewInteraction(objects, []);
|
||||
UiText characterLabel = Assert.IsType<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]
|
||||
public void ResponseForNeitherPendingNorCurrent_IsIgnored()
|
||||
{
|
||||
|
|
@ -429,7 +538,9 @@ public sealed class AppraisalUiControllerTests
|
|||
List<(uint ObjectId, string Text)> inscriptions,
|
||||
List<string> messages,
|
||||
Action show,
|
||||
Action close)
|
||||
Action close,
|
||||
CreatureAppraisalRowTemplateFactory? creatureRows = null,
|
||||
CreatureDisplayNameResolver? creatureNames = null)
|
||||
=> AppraisalUiController.Bind(
|
||||
layout,
|
||||
objects,
|
||||
|
|
@ -440,7 +551,9 @@ public sealed class AppraisalUiControllerTests
|
|||
(objectId, text) => inscriptions.Add((objectId, text)),
|
||||
messages.Add,
|
||||
show,
|
||||
close);
|
||||
close,
|
||||
creatureRows,
|
||||
creatureNames);
|
||||
|
||||
private static ItemInteractionController NewInteraction(
|
||||
ClientObjectTable objects,
|
||||
|
|
|
|||
100
tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs
Normal file
100
tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -113,6 +113,9 @@ public static class FixtureLoader
|
|||
public static ElementInfo LoadExaminationInfos()
|
||||
=> LoadInfos("examine_2100006B_100005F2.json");
|
||||
|
||||
public static ElementInfo LoadExaminationRowTemplateInfos()
|
||||
=> LoadInfos("examine_row_2100006B_10000166.json");
|
||||
|
||||
public static ImportedLayout LoadPowerbar()
|
||||
=> LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
(CharacterController.LayoutId, CharacterController.RootId,
|
||||
"character_info_2100006E_10000183.json"),
|
||||
(0x2100006Bu, 0x100005F2u, "examine_2100006B_100005F2.json"),
|
||||
(AppraisalUiController.LayoutId, CreatureAppraisalRowTemplateFactory.TemplateId,
|
||||
"examine_row_2100006B_10000166.json"),
|
||||
})
|
||||
{
|
||||
ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId);
|
||||
|
|
@ -145,6 +147,10 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
}));
|
||||
}
|
||||
|
||||
CreatureDisplayNameResolver creatureNames =
|
||||
CreatureDisplayNameResolver.Load(dats);
|
||||
Assert.Equal("Ghost", creatureNames.Resolve(77));
|
||||
|
||||
ElementInfo? miniGame = LayoutImporter.ImportInfos(
|
||||
dats, MiniGameUiController.LayoutId, MiniGameUiController.RootId);
|
||||
Assert.NotNull(miniGame);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue