feat(ui): port retail radar and compass
This commit is contained in:
parent
c4af181b92
commit
3cbe4b00a1
43 changed files with 2882 additions and 262 deletions
|
|
@ -117,8 +117,8 @@ user-gated; #138 closed by the round-trip gate; #133/#137/#95/#79/#93/#80 CLOSED
|
|||
first ports = `CombatMath.ComputeDamage` (F.3) + inventory panel (F.2) + combat anim
|
||||
(L.1c) — see the M2 section in the milestones doc + `docs/research/2026-06-04-combat-math-deep-dive.md`.
|
||||
**Carried post-M1.5, NOT blockers:** #145-residual (far-town teleport-OUT cascade —
|
||||
capture-harness-first), #116 slide-response. **D.2b retail UI** parity track still
|
||||
interleaves at the issue level (next: container-switching — `claude-memory/project_d2b_retail_ui.md`);
|
||||
capture-harness-first), #116 slide-response. **D.6 retained retail radar/compass**
|
||||
implemented 2026-07-10 (live-world user visual gate pending; `claude-memory/project_d2b_retail_ui.md`);
|
||||
**R5 movement-manager arc DONE** (2026-07-05; carried #167, R6/TS-42); **Track MP** perf
|
||||
side track at MP0. Keep this paragraph ≤6 lines + pointers — detail in the docs below, NOT here.
|
||||
|
||||
|
|
@ -1155,4 +1155,3 @@ or client-behavior task:
|
|||
→ LoginComplete, DddInterrogation → response, PlayerTeleport).
|
||||
- **`spatial/physics.rs`** — dead-reckoning solver (how the client
|
||||
advances position between server updates).
|
||||
|
||||
|
|
|
|||
|
|
@ -115,8 +115,8 @@ user-gated; #138 closed by the round-trip gate; #133/#137/#95/#79/#93/#80 CLOSED
|
|||
first ports = `CombatMath.ComputeDamage` (F.3) + inventory panel (F.2) + combat anim
|
||||
(L.1c) — see the M2 section in the milestones doc + `docs/research/2026-06-04-combat-math-deep-dive.md`.
|
||||
**Carried post-M1.5, NOT blockers:** #145-residual (far-town teleport-OUT cascade —
|
||||
capture-harness-first), #116 slide-response. **D.2b retail UI** parity track still
|
||||
interleaves at the issue level (next: container-switching — `claude-memory/project_d2b_retail_ui.md`);
|
||||
capture-harness-first), #116 slide-response. **D.6 retained retail radar/compass**
|
||||
implemented 2026-07-10 (live-world user visual gate pending; `claude-memory/project_d2b_retail_ui.md`);
|
||||
**R5 movement-manager arc DONE** (2026-07-05; carried #167, R6/TS-42); **Track MP** perf
|
||||
side track at MP0. Keep this paragraph ≤6 lines + pointers — detail in the docs below, NOT here.
|
||||
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs |
|
||||
| AP-16 | Point/spot lights selected per-object / per-cell as the **8 nearest reaching lights** (sphere-overlap, nearest-first) via `LightManager.SelectForObject`, capped at `MaxLightsPerObject=8`; called from `WbDrawDispatcher.ComputeEntityLightSet` (objects) and `EnvCellRenderer.GetCellLightSet` (cell shells). Retail's bake (`SetStaticLightingVertexColors`) sums ALL reaching static lights per vertex with no count cap. Retail's *hardware* path (`minimize_object_lighting` 0x0054d480) DOES cap at 8 per object, so the cap is faithful to retail's hardware path — not to its bake path. The `LightManager.Tick` UBO path survives for DIRECTIONAL (sun) lights only; `mesh_modern.vert`'s UBO loop skips point/spot entries (`posAndKind.w != 0 → continue`) — point lights reach the shader exclusively via the per-object SSBO (binding 5) | `src/AcDream.Core/Lighting/LightManager.cs:234` (`SelectForObject`); `MaxLightsPerObject` ~line 174; call sites `WbDrawDispatcher.ComputeEntityLightSet` + `EnvCellRenderer.GetCellLightSet` | Matches retail's hardware constraint (8 lights per object/cell); selection is nearest-sphere-overlap which faithfully allocates lights to the surfaces that actually see them | Surfaces reached by >8 point lights are dimmer than retail's uncapped bake — rare (a dungeon room has a handful of torches), but real; see AP-35 for the bake-vs-GPU-evaluate architecture difference | `minimize_object_lighting` 0x0054d480 (retail's 8-light hardware cap); `SetStaticLightingVertexColors` 0x0059cfe0 (retail's bake, no count cap) |
|
||||
| AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E |
|
||||
| AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) |
|
||||
| ~~AP-18~~ | **RETIRED 2026-07-10 — faithful retail radar port.** Exact `RGBAColor_Radar*` floats were recovered from named static data and `gmRadarUI::GetBlipColor` was re-ported with `_blipColor` overrides plus portal/vendor/attackable-creature/admin/PK/PKLite/free-PK/fellowship precedence. The old implementation was not merely hue-tuned: it also had wrong portal/vendor colors and an incomplete dispatch matrix. | `src/AcDream.Core/Ui/RadarBlipColors.cs` + radar classification tests | — | — | `gmRadarUI::GetBlipColor` 0x004d76f0; static RGBA initializers at named decomp pc:1089736-1089804 |
|
||||
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
|
||||
| AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |
|
||||
| AP-21 | Entity translucency: two-pass alpha-test (N.5 Decision 2, invented 0.95/0.05 thresholds); AlphaBlend + Additive + InvAlpha all composite under (SrcAlpha, 1−SrcAlpha) — retail applies per-surface D3D blend incl. true additive. EnvCellRenderer + ParticleBatcher DO switch to additive; divergence confined to GfxObj/Setup entities via WbDrawDispatcher | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1563` (+ `Shaders/mesh_modern.frag:10`; #52 amendment removed the α≥0.95 discard) | Matches original WB's model; keeps the bindless MDI pipeline at two indirect draws; spec §6 documents the falsifiable fallback — a third indirect call with `glBlendFunc(SrcAlpha, One)` (~30 min) on a magic-content regression | Additive glow/magic entity surfaces composite darker / occlude instead of brightening — the predicted regression once spell VFX density increases; α<0.05 discard drops faint fringes retail blends | SurfaceType.Additive → D3DBLEND_ONE per-surface routing |
|
||||
|
|
@ -193,6 +193,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| 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/Rendering/GameWindow.cs` (`OnLivePositionUpdated` 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-88 | **Remote omega is reconstructed with a player/NPC fork retail does not have** (remote-creature de-overlap #184 Slice 2b, 2026-07-08): retail's `UpdateObjectInternal` applies ONE angular-velocity integration to every object; acdream reconstructs remote omega from the wire and keeps a player/NPC split inherited from the two former DR paths — a grounded PLAYER remote applies `ObservedOmega ∥ seqOmega` (falls back to the sequencer's synthesised cycle omega when the wire-TurnCommand-derived `ObservedOmega` is 0 — the "circling player sends RunForward+TurnLeft on ONE UM" case) in the WORLD frame (pre-multiply, `Quaternion.Concatenate`); an NPC or an AIRBORNE body applies `ObservedOmega`-only in the BODY frame (post-multiply, `Quaternion.Multiply`). Both feed the same downstream integrate; `calc_acceleration` zeroes `Body.Omega` for grounded bodies so `UpdatePhysicsInternal` never double-integrates | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick`, Step 2 omega fork) | For an UPRIGHT body (the only remote pose — creatures/players never pitch or roll; the wire orientation is yaw-only) rotating about world-Z (the only turn axis) the pre- and post-multiply orders COMMUTE and both branches reduce to the same yaw increment; the seqOmega fallback only adds rotation a circling player genuinely has, and applying it to NPCs would spuriously add their baked cycle omega — so the fork is behaviourally faithful for every reachable pose (it is what the pre-2b Path A / Path B already did, now explicit in one method). ALSO: the merge applies omega BEFORE `ComputeOffset` for everyone (Path B's order), whereas pre-2b Path A applied a grounded PLAYER's omega AFTER its compose — so the `ori` fed to the anim-root-motion fallback (`Transform(seqVel·dt, ori)`, used only when the interp queue is empty/head-reached) is yaw-advanced by one tick (~ω·dt ≈ 2° at 2.24 rad/s, non-accumulating, zero while the catch-up is active). Keeping Path B's order leaves the shipped/gate-passed NPC omega untouched and only nudges a turning grounded player's fallback direction ~2° in rare queue-empty frames — cosmetically negligible; adopting Path A's order instead would have perturbed NPCs by the same amount | If a remote ever acquires a non-yaw orientation (pitch/roll — a future flying mount / ragdoll) the two multiplication orders diverge and a player would rotate differently from an NPC at the same omega; collapse to one order + a shared fallback policy then | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (single apply_physics omega integration, no object-class fork); `apply_interpreted_movement` 0x00528600 (retail's server-driven omega source acdream reconstructs) |
|
||||
| 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 |
|
||||
|
||||
## 4. Temporary stopgap (TS) — 38 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation)
|
||||
|
||||
|
|
|
|||
|
|
@ -488,7 +488,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
|||
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
|
||||
- **☐ D.5.3 — Toolbar selected-object display (issue #140) + spell shortcuts.** Wire the B.4 `WorldPicker`/selection state → the two hidden meters (`0x100001A1` health / `0x100001A2` mana) + the stack slider (`0x100001A4`) + the object-name line, so the bar shows the player's currently-selected world object. Plus **spell shortcuts** — pinned *spells* (vs items) don't render their glyphs yet (`ToolbarController.Populate` skips `ObjectGuid==0`). Together these finish "the bar." (Click-to-use + the peace/war stance indicator landed in D.5.1.)
|
||||
- **☐ D.5.5+ — Core panels.** Inventory (`gmInventoryUI`/`gmBackpackUI`), equipment/paperdoll (`gmPaperDollUI`/`gm3DItemsUI` + the `UiViewport` 3D doll), vendor, trade, spellbook. Research drop done (`docs/research/2026-06-16-*`). Depends on **D.5.4** (data model) + the item-slot/list/icon spine (D.5.1/D.5.2) + the **window manager** (Plan 2: open/close/z-order/persist + faithful grip/dragbar drag-resize) + the drag-drop spine wired (`UiRoot` has the chain; the per-cell accept/drop hooks are still stubs in `UiField`). Also deferred from D.5.1: drag/reorder + the `AddShortcut`/`RemoveShortcut` mutate wire.
|
||||
- **D.6 — HUD.** Vital orbs (scissor-rect partial fill, dat sprites `0x060013B2`), radar (`0x06001388` / `0x06004CC1`, 1.18× range factor), compass strip (scrolling U), target name plate, damage floaters, selection indicator. See slice 06. **(Targets `AcDream.UI.Abstractions` — ships with D.2a; reskinned by D.2b.)** Phase I.2 retired the StbTrueTypeSharp `DebugOverlay` but kept `TextRenderer` + `BitmapFont` alive specifically for D.6's world-space HUD elements (damage floaters, name plates) — they need raw GL text drawing that ImGui can't reach into the 3D scene.
|
||||
- **D.6 — HUD.** **Radar/compass IMPLEMENTED 2026-07-10; live-world visual gate pending.** It is the retained retail `gmRadarUI` (`LayoutDesc 0x21000074`): a 120×140 circular radar (`0x06004CC1`) with N/E/S/W token sprites orbiting the face, a fixed bright-green player marker, and the coordinate footer (`0x06004CC0`). Projection is `radarPixelRadius / radarRange` with 75 m outdoor / 25 m indoor ranges — there is no 1.18 factor or scrolling compass strip. It lives in `AcDream.App/UI` beside the other imported retail panels. Remaining D.6: target name plate, damage floaters, and other world-space HUD elements through the raw `TextRenderer` path. See slice 06 §A.2, `docs/research/2026-07-10-retail-radar-pseudocode.md`, and the named `gmRadarUI` decomp.
|
||||
- **D.7 — Cursor manager.** OS + dat-sourced custom cursors (`FUN_0043c1c0` GDI HCURSOR builder pattern from slice 03). **(D.2b dependency.)**
|
||||
- ~~**D.8 — Sound.**~~ **Superseded — shipped as Phase E.2** (`SoundTable`/`Sound` dat decode, OpenAL 16-voice engine, per-entity 3D positional audio via `AudioHookSink`). Entry kept here for history; see the shipped table.
|
||||
|
||||
|
|
|
|||
|
|
@ -534,7 +534,9 @@ Open the inventory panel — retail-skinned with the right font, icons, and
|
|||
- **C.4** — Double-sided translucent polys.
|
||||
- **D.2b** — Custom retail-look UI backend. **In progress:** vitals + chat + toolbar + inventory (window manager, B-Grid, B-Controller, B-Wire, drag-drop, container-switching) + paperdoll (equip slots, 3-D doll UiViewport, Slots toggle) + **Character window** (Attributes tab, visually confirmed 2026-06-26) + **UI Studio** (standalone panel previewer + 26-window retail dump + interactive click-routing + headless screenshot) + **importer dat-fidelity** (Fix A/B/C: justification, FontColor, per-element FontDid resolver [studio-only; #157 tracks live-game path]) are shipped. Next: D.5.3 selected-object bar + spell shortcuts; #158 Character window polish deferred.
|
||||
- **D.3–D.7** — AcFont + dat sprites + core panels reskinned + HUD orbs +
|
||||
cursor manager.
|
||||
cursor manager. **D.6 radar/compass shipped 2026-07-10** through the retained
|
||||
UI architecture using the retail DAT layout and recovered runtime behavior;
|
||||
live-game visual confirmation remains the user gate.
|
||||
- **L.1f** — NPC/monster + item-use animation coverage.
|
||||
|
||||
**Freeze on landing:**
|
||||
|
|
|
|||
103
docs/research/2026-07-10-retail-radar-pseudocode.md
Normal file
103
docs/research/2026-07-10-retail-radar-pseudocode.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# Retail radar/compass pseudocode — 2026-07-10
|
||||
|
||||
## Oracle and scope
|
||||
|
||||
This note records the primary retail behavior used by acdream's retained
|
||||
radar port. The oracle is the September 2013 named client:
|
||||
|
||||
- `gmRadarUI` fields: `named-retail/acclient.h:54506-54548`
|
||||
- color/shape: `gmRadarUI::GetBlipColor` `0x004D76F0`,
|
||||
`gmRadarUI::GetBlipShape` `0x004D7B60`
|
||||
- coordinates/compass/draw: `UpdateCoordinates` `0x004D8C80`,
|
||||
`UpdateCompassTokens` `0x004D9060`, `DrawObjects` `0x004D9380`,
|
||||
`DrawChildren` `0x004D9720`
|
||||
- cadence/list maintenance: `UseTime` `0x004D98A0`, `AddObject`
|
||||
`0x004D9A00`, `Init` `0x004D9B90`, `PostInit` `0x004D9D30`
|
||||
- production DAT layout: `LayoutDesc 0x21000074` in
|
||||
`2026-06-25-retail-ui-layout-dump.json`
|
||||
|
||||
The older AC2D-derived `1.18 × range`, rotating player arrow, and horizontal
|
||||
scrolling compass strip are rejected by the named retail implementation.
|
||||
|
||||
## Retained UI ownership
|
||||
|
||||
The production root is a 120×140 `gmRadarUI` element of class
|
||||
`0x10000010`. Custom properties set center `(60,60)` (`0x1000002E`,
|
||||
nested `0x1000002F/30`) and pixel radius `50` (`0x1000002D`).
|
||||
`LayoutImporter` owns the static DAT children and media.
|
||||
A dedicated `UiRadar` owns the dynamic draw/tick/input behavior. Core owns
|
||||
the pure projection, classification, marker geometry, compass, and coordinate
|
||||
algorithms. `GameWindow` supplies live snapshots and selection/settings
|
||||
actions only.
|
||||
|
||||
This is a screen-space retained panel. It is not an ImGui panel and does not
|
||||
use the raw world-space TextRenderer overlay path.
|
||||
|
||||
## Per-tick pseudocode
|
||||
|
||||
```text
|
||||
every 0.025 seconds:
|
||||
range = player is outdoors ? 75 : 25
|
||||
heading = physical player heading in degrees
|
||||
move N/E/S/W child tokens around radar center
|
||||
if CoordinatesOnRadar and LandDefs.gid_to_lcoord(playerCell):
|
||||
ns = (lcoordY - 1024) * 0.1 + 0.5
|
||||
ew = (lcoordX - 1024) * 0.1 + 0.5
|
||||
coordinateText = abs(ns) + N/S + "," + abs(ew) + E/W
|
||||
show footer/text
|
||||
else:
|
||||
hide footer/text
|
||||
```
|
||||
|
||||
```text
|
||||
for each maintained object except the player:
|
||||
require a physics/world entity
|
||||
require RadarBehavior in { ShowMovement=2, ShowAttacking=3, ShowAlways=4 }
|
||||
|
||||
relative = convert object position into player's oriented frame
|
||||
if relative.X² + relative.Y² >= (range - 1)²:
|
||||
continue
|
||||
|
||||
scale = radarPixelRadius / range
|
||||
x = trunc(centerX + relative.X * scale)
|
||||
y = trunc(centerY - relative.Y * scale)
|
||||
rgbBrightness = abs(relative.Z) < 5 ? 1.0 : 0.65
|
||||
|
||||
color = exact _blipColor override 1..10, otherwise retail classification
|
||||
shape = fellowship/allegiance/PK relationship shape, otherwise Plus
|
||||
draw marker pixels
|
||||
if selected: draw the open selection outline at offsets ±3
|
||||
```
|
||||
|
||||
After all object blips, draw the player's fixed bright-green center plus.
|
||||
Hover selects the nearest projected blip within squared distance 36; its
|
||||
name is the tooltip. A left click selects that object.
|
||||
|
||||
The lock button drives the existing retained-window drag gate; the drag
|
||||
affordance is hidden while locked. Completed moves are clamped inside the UI
|
||||
root and persisted per character through `SettingsStore`.
|
||||
|
||||
## Production assets
|
||||
|
||||
| Role | Element | Rect | RenderSurface |
|
||||
|---|---:|---:|---:|
|
||||
| Face | `0x1000003F` | `(0,0) 120×120` | `0x06004CC1` |
|
||||
| Coordinate footer | `0x1000003E` | `(0,120) 120×18` | `0x06004CC0` |
|
||||
| N / E / S / W | `0x10000040..43` | four 10×9 tokens | `0x060011FB`, `0x06001938`, `0x0600193A`, `0x0600193C` |
|
||||
| Lock | `0x10000619` | `(6,6) 27×27` | `0x060074B7` / `0x060074B8` |
|
||||
| Drag | `0x100006A3` | `(87,6) 27×27` | `0x060074C9` |
|
||||
|
||||
The child token orbit uses `center + (sin(angle), cos(angle)) * magnitude`
|
||||
with angles N=`heading+π`, E=`heading+π/2`, S=`heading`, W=`heading+3π/2`.
|
||||
|
||||
## Wire contract
|
||||
|
||||
PublicWeenieDesc flag `0x00100000` carries nullable byte
|
||||
`RadarBlipColor`; flag `0x00800000` carries nullable byte `RadarBehavior`.
|
||||
Absent is distinct from an explicitly transmitted zero. Both values flow
|
||||
through `CreateObject.Parsed`, `WorldSession.EntitySpawn`, and
|
||||
`ClientObjectTable`.
|
||||
|
||||
Do not infer ShowMovement/ShowAttacking from local motion. Retail's
|
||||
`InqShowableOnRadar` accepts values 2, 3, and 4 directly; server/quality
|
||||
changes are authoritative.
|
||||
|
|
@ -172,103 +172,88 @@ public sealed class VitalOrb
|
|||
|
||||
### A.2 Radar / compass
|
||||
|
||||
The radar is the canonical "small circular polar plot of nearby creatures
|
||||
with the player at the center". The compass is a thin bar across the top of
|
||||
the screen showing the 16 cardinal directions as the camera rotates.
|
||||
Retail implements both pieces as one `gmRadarUI` element, not as a radar
|
||||
plus a separate scrolling strip. Primary sources are the named September
|
||||
2013 decomp (`gmRadarUI::UpdateCompassTokens`, `DrawObjects`,
|
||||
`UpdateCoordinates`, and `DrawChildren`) and production `LayoutDesc
|
||||
0x21000074` in `2026-06-25-retail-ui-layout-dump.json`. The earlier AC2D
|
||||
comparison in this section was useful secondary evidence but its `1.18`
|
||||
projection, rotating player arrow, and horizontal compass strip are not
|
||||
retail behavior.
|
||||
|
||||
**Radar — data sources**
|
||||
**Production layout (`0x21000074`, root type `0x10000010`):**
|
||||
|
||||
- Player world position (`Position` packet, 24-byte LandCell + XYZ).
|
||||
- Every nearby object's `CreateObject` / `UpdatePosition` with heading.
|
||||
- Per-object `RadarColor` override (hostile = red, green = friendly NPC,
|
||||
etc.) + `ObjectFlags2` bits (`0x08` = item, `0x10` = blue-book NPC).
|
||||
- 120×140 root (`gmRadarUI`).
|
||||
- Root custom properties: center `(60,60)` and radar pixel radius `50`.
|
||||
- Circular face `0x06004CC1` at `(0,0)`, 120×120.
|
||||
- Coordinate footer `0x06004CC0` at `(0,120)`, 120×18.
|
||||
- N/E/S/W token sprites `0x060011FB`, `0x06001938`, `0x0600193A`,
|
||||
`0x0600193C`, each 10×9 and initially centered at the four cardinal
|
||||
points.
|
||||
- Lock/unlock sprites `0x060074B7` / `0x060074B8`; drag sprite
|
||||
`0x060074C9`.
|
||||
|
||||
**Radar — retail dat IDs** (AC2D, `cInterface.cpp:139-144`):
|
||||
|
||||
- `0x06001388` — radar window titlebar/toolbar icon.
|
||||
- `0x06004CC1` — radar background art (the circular bezel).
|
||||
|
||||
**Radar — the player arrow + blip placement**
|
||||
|
||||
AC2D `cRadar::OnRender` (`cCustomWindows.h:1004-1070`) is the clearest
|
||||
retail-equivalent. The math:
|
||||
**Projection (`gmRadarUI::DrawObjects` `0x004D9380`):**
|
||||
|
||||
```text
|
||||
for each nearby object obj:
|
||||
delta = obj.pos - player.pos
|
||||
delta = delta.RotateAround(Z, -player.heading) // align to radar-up = camera-forward
|
||||
screen.x = radar.left + radar.w/2 + (delta.x / (1.18 * range)) * (radar.w/2)
|
||||
screen.y = radar.top + radar.h/2 - (delta.y / (1.18 * range)) * (radar.h/2)
|
||||
color = PickRadarColor(obj.radar_override, obj.flags2)
|
||||
DrawQuad2x2(screen, color)
|
||||
relative = object position converted into the physical player's oriented frame
|
||||
range = outdoors ? 75 : 25
|
||||
|
||||
if relative.X² + relative.Y² >= (range - 1)²:
|
||||
skip
|
||||
|
||||
scale = radarPixelRadius / range
|
||||
pixelX = trunc(centerX + relative.X * scale)
|
||||
pixelY = trunc(centerY - relative.Y * scale)
|
||||
rgbMod = abs(relative.Z) < 5 ? 1.0 : 0.65
|
||||
```
|
||||
|
||||
The `1.18` factor is retail-observed — it shrinks the effective range
|
||||
slightly so blips near the edge stay visible before the bezel clips them.
|
||||
Player's own arrow is NOT in the blip loop; it is drawn as a fixed
|
||||
centered sprite (the "player dot") rotated by `player.heading`.
|
||||
The player's own object is excluded. `DrawChildren` paints a fixed
|
||||
bright-green plus at the center after the object blips; it is not a rotating
|
||||
arrow. Hover chooses the nearest blip within 6 pixels, exposes its object
|
||||
name as the tooltip, and clicking selects that object.
|
||||
|
||||
**Compass — data sources**
|
||||
|
||||
- `player.heading` in radians.
|
||||
|
||||
**Compass — how the rose is drawn**
|
||||
|
||||
The rose is a **seamless horizontal strip texture** where 360° is tiled
|
||||
across some multiple of the screen width. The U-offset is
|
||||
`heading_normalized * strip_u_period`, with the visible portion cropped to
|
||||
a narrow strip at the top of the screen. This is the classic "scrolling
|
||||
texture" approach used by most 3D clients; retail AC follows it.
|
||||
|
||||
Holtburger's TUI compass (`hud/status.rs:11-18`) enumerates the 16
|
||||
cardinal-direction labels that retail paints onto the strip:
|
||||
**Compass tokens (`gmRadarUI::UpdateCompassTokens` `0x004D9060`):**
|
||||
|
||||
```text
|
||||
["W", "WNW", "NW", "NNW", "N", "NNE", "NE", "ENE",
|
||||
"E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW"]
|
||||
tokenX = centerX + sin(angle) * tokenMagnitude - tokenWidth/2
|
||||
tokenY = centerY + cos(angle) * tokenMagnitude - tokenHeight/2
|
||||
|
||||
N angle = heading + π
|
||||
E angle = heading + π/2
|
||||
S angle = heading
|
||||
W angle = heading + 3π/2
|
||||
```
|
||||
|
||||
22.5° per segment, first label centered on 11.25°.
|
||||
Heading comes from the physical player in degrees, not from the camera.
|
||||
There is no numeric heading label in `gmRadarUI`.
|
||||
|
||||
**Render-path pseudocode:**
|
||||
**Visibility, color, and shapes:**
|
||||
|
||||
```text
|
||||
function DrawCompassStrip(heading_rad, bar_x, bar_y, bar_w, bar_h):
|
||||
heading_deg = (heading_rad * 180 / PI) mod 360
|
||||
// Texture is 360° wide in world-space; we crop to bar_w centered on heading
|
||||
u_center = heading_deg / 360 // 0..1
|
||||
u_half = bar_w / strip_texture_w / 2
|
||||
u_left = u_center - u_half
|
||||
u_right = u_center + u_half
|
||||
DrawSpriteUV(compass_strip_tex, bar_x, bar_y, bar_w, bar_h,
|
||||
u0=u_left, u1=u_right, v0=0, v1=1)
|
||||
```
|
||||
- `RadarEnum`: undefined 0, never 1, movement 2, attacking 3, always 4.
|
||||
Retail accepts 2/3/4 as showable and does not invent a client-side
|
||||
movement/attack filter.
|
||||
- The optional PublicWeenieDesc bytes are `RadarBlipColor` (header flag
|
||||
`0x00100000`) and `RadarBehavior` (`0x00800000`).
|
||||
- `_blipColor` overrides 1..10 select the exact blue, gold, white, purple,
|
||||
red, pink, green, yellow, cyan, and bright-green retail constants.
|
||||
- Default classification covers portals, vendors, attackable creatures,
|
||||
visible admins, PK/PKLite/free-PK players, and fellowship overrides.
|
||||
- Marker shapes are point, hollow box, X, plus, triangle, inverted triangle,
|
||||
and X-box. Fellowship/allegiance/PK relationships select the special
|
||||
shapes; the ordinary default is a five-pixel plus.
|
||||
|
||||
**C# port sketch:**
|
||||
**Coordinates (`gmRadarUI::UpdateCoordinates` `0x004D8C80`):**
|
||||
|
||||
```csharp
|
||||
public void DrawRadar(IUiRenderer r, Rect bounds, float playerHeading,
|
||||
Vector3 playerPos, IEnumerable<WorldEntity> nearby, float range)
|
||||
{
|
||||
r.DrawSprite(/*0x06004CC1*/ _radarBgId, bounds);
|
||||
For an outdoor cell, `LandDefs::gid_to_lcoord` supplies integer landscape
|
||||
cell X/Y. Each axis becomes `(lcoord - 1024) * 0.1 + 0.5`, then formats as
|
||||
`{abs(Y):0.0}{N|S},{abs(X):0.0}{E|W}` (no space). The footer is hidden for
|
||||
indoor cells or when `CoordinatesOnRadar` (`0x00400000`) is disabled.
|
||||
|
||||
var cx = bounds.X + bounds.Width * 0.5f;
|
||||
var cy = bounds.Y + bounds.Height * 0.5f;
|
||||
|
||||
foreach (var e in nearby)
|
||||
{
|
||||
var d = e.Position - playerPos;
|
||||
d = Vector3.Transform(d, Matrix4x4.CreateRotationZ(-playerHeading));
|
||||
var sx = cx + (d.X / (1.18f * range)) * (bounds.Width * 0.5f);
|
||||
var sy = cy - (d.Y / (1.18f * range)) * (bounds.Height * 0.5f);
|
||||
var col = PickRadarColor(e);
|
||||
r.DrawFilledQuad(sx - 1, sy - 1, 2, 2, col);
|
||||
}
|
||||
|
||||
// Player arrow - always on top
|
||||
r.DrawSprite(_playerArrowId, cx - 5, cy - 5, 10, 10, rotation: playerHeading);
|
||||
}
|
||||
```
|
||||
`gmRadarUI::UseTime` refreshes range, tokens, coordinates, and dirty state
|
||||
every 25 ms (40 Hz). Static art remains data-driven through the retained
|
||||
LayoutDesc importer; dynamic behavior belongs in a dedicated retained
|
||||
radar widget/controller, with `GameWindow` limited to state providers.
|
||||
|
||||
### A.3 Quickbar / hotbar
|
||||
|
||||
|
|
|
|||
|
|
@ -79,3 +79,17 @@
|
|||
2026-04-24 callout block).
|
||||
- Custom-backend research (applies to D.2b, NOT D.2a):
|
||||
`docs/research/retail-ui/00-master-synthesis.md` + slices 01-06.
|
||||
|
||||
## 2026-07-10 retained radar update
|
||||
|
||||
The older "D.6 ships through IPanelRenderer/ImGui first" rule above is superseded
|
||||
for screen-space retail HUD windows. The production retained toolkit now lives in
|
||||
`src/AcDream.App/UI`; imported `LayoutDesc` windows bind through dedicated `gm*UI`
|
||||
controllers and App-layer snapshot providers. ImGui remains devtools only.
|
||||
|
||||
`gmRadarUI` is the reference pattern: Core owns exact colors/shapes/projection/
|
||||
compass/coordinates, `RadarSnapshotProvider` joins the existing `WorldEntity` +
|
||||
`ClientObjectTable` two-table model, `RadarController` binds LayoutDesc 0x21000074,
|
||||
and `UiRadar` only draws/handles retained interaction. `GameWindow` contains mount
|
||||
and delegate wiring, not the feature body. See
|
||||
`docs/research/2026-07-10-retail-radar-pseudocode.md`.
|
||||
|
|
|
|||
|
|
@ -760,6 +760,11 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
|
||||
// Phase D.5.3a — selected-object strip controller (name, overlay state, health meter).
|
||||
private AcDream.App.UI.Layout.SelectedObjectController? _selectedObjectController;
|
||||
// Phase D.6 — retail gmRadarUI (LayoutDesc 0x21000074). Behavior lives in
|
||||
// Core + the retained controller; GameWindow supplies live state only.
|
||||
private AcDream.App.UI.Layout.RadarController? _radarController;
|
||||
private AcDream.App.UI.Layout.RadarSnapshotProvider? _radarSnapshotProvider;
|
||||
private AcDream.App.UI.UiRadar? _radarRoot;
|
||||
// Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter).
|
||||
private AcDream.App.UI.Layout.InventoryController? _inventoryController;
|
||||
// Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler).
|
||||
|
|
@ -1738,6 +1743,11 @@ public sealed class GameWindow : IDisposable
|
|||
try
|
||||
{
|
||||
settingsStore.SaveGameplay(gameplay);
|
||||
// Runtime consumers (including retained gmRadarUI) read
|
||||
// the live persisted snapshot, not SettingsVM's draft.
|
||||
_persistedGameplay = gameplay;
|
||||
if (_uiHost is not null)
|
||||
_uiHost.Root.UiLocked = gameplay.LockUI;
|
||||
Console.WriteLine(
|
||||
"settings: gameplay saved to "
|
||||
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
|
||||
|
|
@ -1998,6 +2008,7 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
||||
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
|
||||
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
|
||||
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
|
||||
Objects,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
|
|
@ -2025,6 +2036,7 @@ public sealed class GameWindow : IDisposable
|
|||
sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost),
|
||||
sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits));
|
||||
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
|
||||
_uiHost.Root.WindowMoved += OnRetailWindowMoved;
|
||||
|
||||
// Feed Silk input to the UiRoot tree so windows drag / close / select.
|
||||
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the
|
||||
|
|
@ -2110,6 +2122,52 @@ public sealed class GameWindow : IDisposable
|
|||
Console.WriteLine("[D.2b] vitals: LayoutDesc 0x2100006C not found — vitals unavailable.");
|
||||
}
|
||||
|
||||
// Phase D.6 — retail gmRadarUI. The importer owns the exact static
|
||||
// LayoutDesc 0x21000074 shell; RadarController owns retained behavior;
|
||||
// RadarSnapshotProvider is the thin live-world adapter.
|
||||
AcDream.App.UI.Layout.ImportedLayout? radarLayout;
|
||||
lock (_datLock)
|
||||
radarLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
|
||||
_dats!, AcDream.App.UI.Layout.RadarController.LayoutId,
|
||||
ResolveChrome, vitalsDatFont, ResolveDatFont);
|
||||
if (radarLayout is not null && radarLayout.Root is AcDream.App.UI.UiRadar radarRoot)
|
||||
{
|
||||
_radarRoot = radarRoot;
|
||||
_radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider(
|
||||
Objects,
|
||||
_entitiesByServerGuid,
|
||||
_lastSpawnByGuid,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
playerYawRadians: () => _playerController?.Yaw ?? 0f,
|
||||
playerCellId: () => _playerController?.CellId ?? 0u,
|
||||
selectedGuid: () => _selectedGuid,
|
||||
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
|
||||
uiLocked: () => _persistedGameplay.LockUI);
|
||||
_radarController = AcDream.App.UI.Layout.RadarController.Bind(
|
||||
radarLayout,
|
||||
snapshotProvider: _radarSnapshotProvider.BuildSnapshot,
|
||||
selectObject: guid => SelectedGuid = guid,
|
||||
setUiLocked: SetRetailUiLocked,
|
||||
datFont: vitalsDatFont);
|
||||
|
||||
radarRoot.Left = System.Math.Max(0f, _window!.Size.X - radarRoot.Width - 10f);
|
||||
radarRoot.Top = 10f;
|
||||
radarRoot.ClickThrough = false;
|
||||
// User-positioned top-level window: Anchors.None prevents the
|
||||
// per-frame anchor pass from undoing drag-handle movement.
|
||||
radarRoot.Anchors = AcDream.App.UI.AnchorEdges.None;
|
||||
radarRoot.Resizable = false;
|
||||
radarRoot.ConstrainDragToParent = true;
|
||||
_uiHost.Root.AddChild(radarRoot);
|
||||
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Radar, radarRoot);
|
||||
ApplySavedRadarPosition();
|
||||
Console.WriteLine("[D.6] retail radar/compass from LayoutDesc 0x21000074.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[D.6] radar: LayoutDesc 0x21000074 not found or root class mismatch.");
|
||||
}
|
||||
|
||||
// Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI),
|
||||
// the same importer path as vitals. ChatWindowController binds the transcript,
|
||||
// input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter.
|
||||
|
|
@ -2866,6 +2924,7 @@ public sealed class GameWindow : IDisposable
|
|||
_liveSession.EnterWorld(user, characterIndex: 0);
|
||||
|
||||
_activeToonKey = chosen.Name;
|
||||
ApplySavedRadarPosition();
|
||||
if (_settingsStore is not null && _settingsVm is not null)
|
||||
{
|
||||
var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
|
||||
|
|
@ -11776,6 +11835,62 @@ public sealed class GameWindow : IDisposable
|
|||
_toolbarController.SetCharacterOpen(_uiHost.IsWindowVisible(AcDream.App.UI.WindowNames.Character));
|
||||
}
|
||||
|
||||
private void SetRetailUiLocked(bool locked)
|
||||
{
|
||||
if (_persistedGameplay.LockUI == locked)
|
||||
return;
|
||||
|
||||
_persistedGameplay = _persistedGameplay with { LockUI = locked };
|
||||
if (_uiHost is not null)
|
||||
_uiHost.Root.UiLocked = locked;
|
||||
if (_settingsVm is not null)
|
||||
_settingsVm.SetGameplay(_settingsVm.GameplayDraft with { LockUI = locked });
|
||||
|
||||
try
|
||||
{
|
||||
_settingsStore?.SaveGameplay(_persistedGameplay);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: radar lock save failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySavedRadarPosition()
|
||||
{
|
||||
if (_radarRoot is null || _settingsStore is null || _window is null)
|
||||
return;
|
||||
|
||||
var saved = _settingsStore.LoadWindowPosition(
|
||||
_activeToonKey, AcDream.App.UI.WindowNames.Radar);
|
||||
if (saved is not { } position)
|
||||
return;
|
||||
|
||||
_radarRoot.Left = System.Math.Clamp(
|
||||
position.X, 0f, System.Math.Max(0f, _window.Size.X - _radarRoot.Width));
|
||||
_radarRoot.Top = System.Math.Clamp(
|
||||
position.Y, 0f, System.Math.Max(0f, _window.Size.Y - _radarRoot.Height));
|
||||
}
|
||||
|
||||
private void OnRetailWindowMoved(string name, AcDream.App.UI.UiElement window)
|
||||
{
|
||||
if (name != AcDream.App.UI.WindowNames.Radar || _settingsStore is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_settingsStore.SaveWindowPosition(
|
||||
_activeToonKey,
|
||||
name,
|
||||
new AcDream.UI.Abstractions.Panels.Settings.UiWindowPosition(
|
||||
window.Left, window.Top));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: radar position save failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateRetailCursorFeedback()
|
||||
{
|
||||
if (_uiHost is null || _cursorFeedbackController is null || _retailCursorManager is null || _input is null)
|
||||
|
|
|
|||
|
|
@ -63,6 +63,31 @@ public static class FixtureProvider
|
|||
manaText: () => "90/100");
|
||||
break;
|
||||
|
||||
case RadarController.LayoutId: // gmRadarUI
|
||||
RadarController.Bind(
|
||||
layout,
|
||||
() => new UiRadarSnapshot(
|
||||
PlayerHeadingDegrees: 32f,
|
||||
Blips:
|
||||
[
|
||||
new UiRadarBlip(0x50000001u, "Portal to Holtburg", 82f, 43f,
|
||||
RadarColor(AcDream.Core.Ui.RadarBlipColors.Portal),
|
||||
AcDream.Core.Ui.RadarBlipShape.Plus),
|
||||
new UiRadarBlip(0x50000002u, "Drudge Prowler", 43f, 76f,
|
||||
RadarColor(AcDream.Core.Ui.RadarBlipColors.Creature),
|
||||
AcDream.Core.Ui.RadarBlipShape.Plus),
|
||||
new UiRadarBlip(0x50000003u, "Town Crier", 68f, 87f,
|
||||
RadarColor(AcDream.Core.Ui.RadarBlipColors.NPC),
|
||||
AcDream.Core.Ui.RadarBlipShape.Box),
|
||||
new UiRadarBlip(0x50000004u, "Selected PK", 39f, 39f,
|
||||
RadarColor(AcDream.Core.Ui.RadarBlipColors.PlayerKiller),
|
||||
AcDream.Core.Ui.RadarBlipShape.X,
|
||||
Selected: true),
|
||||
],
|
||||
CoordinatesText: "42.1N,33.3E"),
|
||||
datFont: stack.VitalsDatFont);
|
||||
break;
|
||||
|
||||
case 0x21000016u: // toolbar
|
||||
ToolbarController.Bind(
|
||||
layout,
|
||||
|
|
@ -156,4 +181,7 @@ public static class FixtureProvider
|
|||
return handle;
|
||||
};
|
||||
|
||||
private static System.Numerics.Vector4 RadarColor(AcDream.Core.Ui.RadarBlipColors.Rgba color)
|
||||
=> new(color.Red, color.Green, color.Blue, color.Alpha);
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ public static class DatWidgetFactory
|
|||
|
||||
UiElement e = info.Type switch
|
||||
{
|
||||
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
|
||||
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
|
||||
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
|
||||
|
|
|
|||
239
src/AcDream.App/UI/Layout/RadarController.cs
Normal file
239
src/AcDream.App/UI/Layout/RadarController.cs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Ui;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the runtime-created behavior of retail <c>gmRadarUI</c> to the static
|
||||
/// LayoutDesc <c>0x21000074</c> tree. Like the other gm* controllers, this class only
|
||||
/// finds children by retail id and attaches live providers; it does not recreate DAT chrome.
|
||||
/// </summary>
|
||||
public sealed class RadarController
|
||||
{
|
||||
public const uint LayoutId = 0x21000074u;
|
||||
/// <summary>Production layout property 0x1000002D, recovered directly from the retail DAT.</summary>
|
||||
public const int RadarPixelRadius = 50;
|
||||
public const uint RootId = 0x100006D3u;
|
||||
public const uint CoordinateContainerId = 0x1000003Eu;
|
||||
public const uint RadarDiscId = 0x1000003Fu;
|
||||
public const uint NorthTokenId = 0x10000040u;
|
||||
public const uint EastTokenId = 0x10000041u;
|
||||
public const uint SouthTokenId = 0x10000042u;
|
||||
public const uint WestTokenId = 0x10000043u;
|
||||
public const uint LockButtonId = 0x10000619u;
|
||||
public const uint DragButtonId = 0x100006A3u;
|
||||
|
||||
private static readonly Vector4 CoordinateColor = Vector4.One;
|
||||
|
||||
private readonly UiRadar _radar;
|
||||
private readonly UiElement? _coordinateContainer;
|
||||
private readonly UiText? _coordinateText;
|
||||
private readonly UiButton? _lockButton;
|
||||
private readonly UiElement? _dragButton;
|
||||
private readonly Action<bool>? _setUiLocked;
|
||||
private readonly CompassToken[] _tokens;
|
||||
private float _lastHeading = float.NaN;
|
||||
private string? _lastCoordinates;
|
||||
private bool _coordinatesInitialized;
|
||||
private bool? _lastUiLocked;
|
||||
|
||||
private RadarController(
|
||||
ImportedLayout layout,
|
||||
Func<UiRadarSnapshot> snapshotProvider,
|
||||
Action<uint>? selectObject,
|
||||
Action<uint?>? hoveredObjectChanged,
|
||||
Action<bool>? setUiLocked,
|
||||
UiDatFont? datFont)
|
||||
{
|
||||
_radar = layout.Root as UiRadar
|
||||
?? throw new ArgumentException(
|
||||
$"Layout root must be {nameof(UiRadar)} (retail class 0x{UiRadar.RetailClassId:X8}).",
|
||||
nameof(layout));
|
||||
|
||||
_radar.Center = ResolveCenter(layout);
|
||||
_radar.SnapshotProvider = () =>
|
||||
{
|
||||
var snapshot = snapshotProvider() ?? UiRadarSnapshot.Empty;
|
||||
ApplyPresentation(snapshot);
|
||||
return snapshot;
|
||||
};
|
||||
_radar.SelectObject = selectObject;
|
||||
_radar.HoveredObjectChanged = hoveredObjectChanged;
|
||||
_setUiLocked = setUiLocked;
|
||||
|
||||
_coordinateContainer = layout.FindElement(CoordinateContainerId);
|
||||
_coordinateText = CreateCoordinateText(_coordinateContainer, datFont);
|
||||
_lockButton = layout.FindElement(LockButtonId) as UiButton;
|
||||
_dragButton = layout.FindElement(DragButtonId);
|
||||
|
||||
if (_lockButton is not null && _setUiLocked is not null)
|
||||
_lockButton.OnClick = () => _setUiLocked(!(_lastUiLocked ?? false));
|
||||
|
||||
_tokens =
|
||||
[
|
||||
CreateToken(layout.FindElement(NorthTokenId), RadarCompassPoint.North, _radar.Center),
|
||||
CreateToken(layout.FindElement(EastTokenId), RadarCompassPoint.East, _radar.Center),
|
||||
CreateToken(layout.FindElement(SouthTokenId), RadarCompassPoint.South, _radar.Center),
|
||||
CreateToken(layout.FindElement(WestTokenId), RadarCompassPoint.West, _radar.Center),
|
||||
];
|
||||
|
||||
// Retail moves the radar only from its dedicated drag affordance. Making that
|
||||
// imported Type-2 element hit-testable lets UiRoot's existing draggable-window
|
||||
// machinery move the UiRadar ancestor while ordinary radar clicks select blips.
|
||||
if (_dragButton is not null)
|
||||
_dragButton.ClickThrough = false;
|
||||
|
||||
_radar.Draggable = true;
|
||||
_radar.ResizeX = false;
|
||||
_radar.ResizeY = false;
|
||||
_radar.Refresh();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bind the projected snapshot provider and interaction callbacks to an imported radar.
|
||||
/// No external per-frame call is required; <see cref="UiRadar"/> polls at retail's 25 ms cadence.
|
||||
/// </summary>
|
||||
public static RadarController Bind(
|
||||
ImportedLayout layout,
|
||||
Func<UiRadarSnapshot> snapshotProvider,
|
||||
Action<uint>? selectObject = null,
|
||||
Action<uint?>? hoveredObjectChanged = null,
|
||||
Action<bool>? setUiLocked = null,
|
||||
UiDatFont? datFont = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(snapshotProvider);
|
||||
return new RadarController(
|
||||
layout,
|
||||
snapshotProvider,
|
||||
selectObject,
|
||||
hoveredObjectChanged,
|
||||
setUiLocked,
|
||||
datFont);
|
||||
}
|
||||
|
||||
private void ApplyPresentation(UiRadarSnapshot snapshot)
|
||||
{
|
||||
if (!snapshot.PlayerHeadingDegrees.Equals(_lastHeading))
|
||||
{
|
||||
for (int i = 0; i < _tokens.Length; i++)
|
||||
{
|
||||
ref readonly var token = ref _tokens[i];
|
||||
if (token.Element is null)
|
||||
continue;
|
||||
|
||||
var p = RetailRadar.GetCompassTokenTopLeft(
|
||||
snapshot.PlayerHeadingDegrees,
|
||||
token.Point,
|
||||
_radar.Center,
|
||||
token.Magnitude,
|
||||
new Vector2(token.Element.Width, token.Element.Height));
|
||||
token.Element.Left = p.X;
|
||||
token.Element.Top = p.Y;
|
||||
}
|
||||
|
||||
_lastHeading = snapshot.PlayerHeadingDegrees;
|
||||
}
|
||||
|
||||
if (!_coordinatesInitialized
|
||||
|| !string.Equals(snapshot.CoordinatesText, _lastCoordinates, StringComparison.Ordinal))
|
||||
{
|
||||
_coordinatesInitialized = true;
|
||||
_lastCoordinates = snapshot.CoordinatesText;
|
||||
bool visible = !string.IsNullOrEmpty(snapshot.CoordinatesText);
|
||||
if (_coordinateContainer is not null)
|
||||
_coordinateContainer.Visible = visible;
|
||||
if (_coordinateText is not null)
|
||||
_coordinateText.Visible = visible;
|
||||
}
|
||||
|
||||
if (_lastUiLocked != snapshot.UiLocked)
|
||||
{
|
||||
_lastUiLocked = snapshot.UiLocked;
|
||||
_radar.Draggable = !snapshot.UiLocked;
|
||||
if (_dragButton is not null)
|
||||
_dragButton.Visible = !snapshot.UiLocked;
|
||||
if (_lockButton is not null)
|
||||
_lockButton.ActiveState = snapshot.UiLocked ? "LockedUI" : "UnlockedUI";
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector2 ResolveCenter(ImportedLayout layout)
|
||||
{
|
||||
// ElementReader intentionally imports the common LayoutDesc fields only; gmRadarUI's
|
||||
// custom center property 0x1000002E is not exposed yet. Derive the exact production
|
||||
// center (60,60) from the real 120x120 disc instead of duplicating it. Radius property
|
||||
// 0x1000002D is the recovered RadarPixelRadius constant above.
|
||||
if (layout.FindElement(RadarDiscId) is { } disc)
|
||||
return new Vector2(disc.Left + disc.Width * 0.5f, disc.Top + disc.Height * 0.5f);
|
||||
|
||||
return new Vector2(layout.Root.Width * 0.5f, MathF.Min(120f, layout.Root.Height) * 0.5f);
|
||||
}
|
||||
|
||||
private static CompassToken CreateToken(UiElement? element, RadarCompassPoint point, Vector2 center)
|
||||
{
|
||||
if (element is null)
|
||||
return new CompassToken(null, 0f, point);
|
||||
|
||||
var initialCenter = new Vector2(
|
||||
element.Left + element.Width * 0.5f,
|
||||
element.Top + element.Height * 0.5f);
|
||||
return new CompassToken(element, Vector2.Distance(initialCenter, center), point);
|
||||
}
|
||||
|
||||
private UiText? CreateCoordinateText(UiElement? container, UiDatFont? datFont)
|
||||
{
|
||||
if (container is null)
|
||||
return null;
|
||||
|
||||
if (container is UiText importedText)
|
||||
{
|
||||
// 0x1000003E inherits UIElement_Text from its base layout. Reuse that imported
|
||||
// behavioral widget so its 0x06004CC0 background stays the actual coordinate
|
||||
// strip and no duplicate text node is needed.
|
||||
importedText.Centered = true;
|
||||
importedText.Padding = 0f;
|
||||
importedText.DatFont = datFont ?? importedText.DatFont;
|
||||
importedText.ClickThrough = true;
|
||||
importedText.AcceptsFocus = false;
|
||||
importedText.IsEditControl = false;
|
||||
importedText.CapturesPointerDrag = false;
|
||||
importedText.LinesProvider = CoordinateLines;
|
||||
return importedText;
|
||||
}
|
||||
|
||||
var text = new UiText
|
||||
{
|
||||
Name = "gmRadarUI.Coordinates",
|
||||
Left = 0f,
|
||||
Top = 0f,
|
||||
Width = container.Width,
|
||||
Height = container.Height,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
Centered = true,
|
||||
Padding = 0f,
|
||||
DatFont = datFont,
|
||||
ClickThrough = true,
|
||||
AcceptsFocus = false,
|
||||
IsEditControl = false,
|
||||
CapturesPointerDrag = false,
|
||||
ZOrder = int.MaxValue,
|
||||
LinesProvider = CoordinateLines,
|
||||
};
|
||||
container.AddChild(text);
|
||||
return text;
|
||||
}
|
||||
|
||||
private IReadOnlyList<UiText.Line> CoordinateLines()
|
||||
=> string.IsNullOrEmpty(_lastCoordinates)
|
||||
? Array.Empty<UiText.Line>()
|
||||
: new[] { new UiText.Line(_lastCoordinates, CoordinateColor) };
|
||||
|
||||
private readonly record struct CompassToken(
|
||||
UiElement? Element,
|
||||
float Magnitude,
|
||||
RadarCompassPoint Point);
|
||||
}
|
||||
149
src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs
Normal file
149
src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Core.Ui;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Joins acdream's existing two-table world model into the immutable projected frame
|
||||
/// consumed by <see cref="UiRadar"/>. This is the App-layer equivalent of retail's
|
||||
/// <c>gmRadarUI::DrawObjects</c> object-maintenance walk: Core still owns every
|
||||
/// AC-specific classification and math decision, while the retained widget remains a
|
||||
/// backend-only renderer.
|
||||
/// </summary>
|
||||
public sealed class RadarSnapshotProvider
|
||||
{
|
||||
private static readonly Vector2 ProductionCenter = new(60f, 60f);
|
||||
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly IReadOnlyDictionary<uint, WorldEntity> _worldEntities;
|
||||
private readonly IReadOnlyDictionary<uint, WorldSession.EntitySpawn> _spawns;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<float> _playerYawRadians;
|
||||
private readonly Func<uint> _playerCellId;
|
||||
private readonly Func<uint?> _selectedGuid;
|
||||
private readonly Func<bool> _coordinatesOnRadar;
|
||||
private readonly Func<bool> _uiLocked;
|
||||
private readonly Func<uint, RadarRelationshipTraits>? _relationshipFor;
|
||||
|
||||
public RadarSnapshotProvider(
|
||||
ClientObjectTable objects,
|
||||
IReadOnlyDictionary<uint, WorldEntity> worldEntities,
|
||||
IReadOnlyDictionary<uint, WorldSession.EntitySpawn> spawns,
|
||||
Func<uint> playerGuid,
|
||||
Func<float> playerYawRadians,
|
||||
Func<uint> playerCellId,
|
||||
Func<uint?> selectedGuid,
|
||||
Func<bool> coordinatesOnRadar,
|
||||
Func<bool> uiLocked,
|
||||
Func<uint, RadarRelationshipTraits>? relationshipFor = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_worldEntities = worldEntities ?? throw new ArgumentNullException(nameof(worldEntities));
|
||||
_spawns = spawns ?? throw new ArgumentNullException(nameof(spawns));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
_playerYawRadians = playerYawRadians ?? throw new ArgumentNullException(nameof(playerYawRadians));
|
||||
_playerCellId = playerCellId ?? throw new ArgumentNullException(nameof(playerCellId));
|
||||
_selectedGuid = selectedGuid ?? throw new ArgumentNullException(nameof(selectedGuid));
|
||||
_coordinatesOnRadar = coordinatesOnRadar ?? throw new ArgumentNullException(nameof(coordinatesOnRadar));
|
||||
_uiLocked = uiLocked ?? throw new ArgumentNullException(nameof(uiLocked));
|
||||
_relationshipFor = relationshipFor;
|
||||
}
|
||||
|
||||
public UiRadarSnapshot BuildSnapshot()
|
||||
{
|
||||
bool uiLocked = _uiLocked();
|
||||
uint playerGuid = _playerGuid();
|
||||
if (playerGuid == 0u || !_worldEntities.TryGetValue(playerGuid, out var playerEntity))
|
||||
return UiRadarSnapshot.Empty with { UiLocked = uiLocked };
|
||||
|
||||
float heading = MoveToMath.HeadingFromYaw(_playerYawRadians());
|
||||
uint playerCellId = _playerCellId();
|
||||
bool isOutside = RadarCoordinates.TryFromCell(playerCellId, out var playerCoordinates);
|
||||
string? coordinates = _coordinatesOnRadar() && isOutside
|
||||
? playerCoordinates.CombinedText
|
||||
: null;
|
||||
|
||||
uint playerPwd = _spawns.TryGetValue(playerGuid, out var playerSpawn)
|
||||
? playerSpawn.ObjectDescriptionFlags ?? 0u
|
||||
: 0u;
|
||||
var playerTraits = RadarObjectTraits.FromPublicWeenieDescription(
|
||||
(uint)(_objects.Get(playerGuid)?.Type ?? ItemType.None), playerPwd);
|
||||
float range = RetailRadar.GetRangeMeters(isOutside);
|
||||
|
||||
var blips = new List<UiRadarBlip>(_worldEntities.Count);
|
||||
foreach (var pair in _worldEntities)
|
||||
{
|
||||
uint guid = pair.Key;
|
||||
if (guid == playerGuid)
|
||||
continue;
|
||||
|
||||
var entity = pair.Value;
|
||||
var clientObject = _objects.Get(guid);
|
||||
_spawns.TryGetValue(guid, out var spawn);
|
||||
|
||||
byte? rawBehavior = clientObject?.RadarBehavior ?? spawn.RadarBehavior;
|
||||
if (rawBehavior is null
|
||||
|| !RetailRadar.IsShowable((RadarBehavior)rawBehavior.Value, hasPhysicsObject: true))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
uint itemType = (uint)(clientObject?.Type ?? ItemType.None);
|
||||
if (itemType == 0u)
|
||||
itemType = spawn.ItemType ?? 0u;
|
||||
uint pwd = spawn.ObjectDescriptionFlags ?? 0u;
|
||||
byte colorOverride = clientObject?.RadarBlipColor ?? spawn.RadarBlipColor ?? 0;
|
||||
var traits = RadarObjectTraits.FromPublicWeenieDescription(
|
||||
itemType, pwd, colorOverride);
|
||||
|
||||
var relationship = _relationshipFor?.Invoke(guid) ?? default;
|
||||
relationship = relationship with
|
||||
{
|
||||
PlayerIsPlayerKiller = playerTraits.IsPlayerKiller,
|
||||
PlayerIsPkLite = playerTraits.IsPkLite,
|
||||
};
|
||||
var shape = RetailRadar.GetBlipShape(traits, relationship);
|
||||
if (shape == RadarBlipShape.Undef)
|
||||
continue;
|
||||
|
||||
Vector3 playerSpace = MoveToMath.GlobalToLocalVec(
|
||||
playerEntity.Rotation,
|
||||
entity.Position - playerEntity.Position);
|
||||
if (!RetailRadar.TryProject(
|
||||
playerSpace,
|
||||
ProductionCenter,
|
||||
RadarController.RadarPixelRadius,
|
||||
range,
|
||||
out var projection))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var color = RadarBlipColors.For(traits, relationship)
|
||||
.DimRgb(projection.RgbMultiplier);
|
||||
string? name = clientObject?.Name;
|
||||
if (string.IsNullOrEmpty(name))
|
||||
name = spawn.Name ?? $"0x{guid:X8}";
|
||||
|
||||
blips.Add(new UiRadarBlip(
|
||||
ObjectId: guid,
|
||||
Name: name,
|
||||
PixelX: projection.Pixel.X,
|
||||
PixelY: projection.Pixel.Y,
|
||||
Color: new Vector4(color.Red, color.Green, color.Blue, color.Alpha),
|
||||
Shape: shape,
|
||||
Selected: _selectedGuid() == guid));
|
||||
}
|
||||
|
||||
return new UiRadarSnapshot(
|
||||
PlayerHeadingDegrees: heading,
|
||||
Blips: blips,
|
||||
CoordinatesText: coordinates,
|
||||
BlankBlips: false,
|
||||
UiLocked: uiLocked);
|
||||
}
|
||||
}
|
||||
|
|
@ -156,6 +156,10 @@ public abstract class UiElement
|
|||
/// whose Left/Top are screen coordinates (Root sits at the origin).</summary>
|
||||
public bool Draggable { get; set; }
|
||||
|
||||
/// <summary>Clamp a dragged top-level window fully inside its parent. Retail
|
||||
/// <c>gmRadarUI::MoveTo</c> enables this; most windows retain the toolkit default.</summary>
|
||||
public bool ConstrainDragToParent { get; set; }
|
||||
|
||||
/// <summary>If true, a left-drag starting near this element's edge/corner
|
||||
/// resizes it (window resize). Intended for top-level panels.</summary>
|
||||
public bool Resizable { get; set; }
|
||||
|
|
|
|||
239
src/AcDream.App/UI/UiRadar.cs
Normal file
239
src/AcDream.App/UI/UiRadar.cs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Ui;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// One already-projected radar object. <paramref name="PixelX"/> and
|
||||
/// <paramref name="PixelY"/> are local pixels in the 120x120 radar disc. World-to-player
|
||||
/// projection stays in the Core retail port; this record is the backend-facing draw seam.
|
||||
/// </summary>
|
||||
public readonly record struct UiRadarBlip(
|
||||
uint ObjectId,
|
||||
string Name,
|
||||
float PixelX,
|
||||
float PixelY,
|
||||
Vector4 Color,
|
||||
RadarBlipShape Shape,
|
||||
bool Selected = false);
|
||||
|
||||
/// <summary>
|
||||
/// Immutable frame consumed by <see cref="UiRadar"/>. A null
|
||||
/// <paramref name="CoordinatesText"/> hides retail's coordinate strip (indoors or when
|
||||
/// CoordinatesOnRadar is disabled). <paramref name="BlankBlips"/> mirrors
|
||||
/// <c>ClientUISystem::m_bRadarBlank</c>: static chrome and the player marker remain visible.
|
||||
/// </summary>
|
||||
public sealed record UiRadarSnapshot(
|
||||
float PlayerHeadingDegrees,
|
||||
IReadOnlyList<UiRadarBlip> Blips,
|
||||
string? CoordinatesText,
|
||||
bool BlankBlips = false,
|
||||
bool UiLocked = false)
|
||||
{
|
||||
public static UiRadarSnapshot Empty { get; } = new(
|
||||
0f,
|
||||
Array.Empty<UiRadarBlip>(),
|
||||
null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retained-mode implementation of retail <c>gmRadarUI</c>, registered as class id
|
||||
/// <c>0x10000010</c>. The imported LayoutDesc supplies all static sprites; this widget
|
||||
/// draws the dynamic object blips and fixed player marker after those children.
|
||||
///
|
||||
/// <para>Retail references:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>gmRadarUI::DrawBlip</c> 0x004D8A30 and shape helpers 0x004D7C30-0x004D8531.</item>
|
||||
/// <item><c>gmRadarUI::DrawChildren</c> 0x004D9720 (static children, objects, player marker).</item>
|
||||
/// <item><c>gmRadarUI::UseTime</c> 0x004D98A0 (25 ms refresh cadence).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class UiRadar : UiElement
|
||||
{
|
||||
public const uint RetailClassId = 0x10000010u;
|
||||
public const float RetailRefreshSeconds = RetailRadar.UpdateIntervalSeconds;
|
||||
public const float HoverRadiusPixels = 6f;
|
||||
|
||||
private static readonly Vector4 PlayerMarkerColor = new(0f, 1f, 0f, 1f);
|
||||
|
||||
private UiRadarSnapshot _snapshot = UiRadarSnapshot.Empty;
|
||||
private double _refreshAccumulator;
|
||||
private uint? _hoveredObjectId;
|
||||
private string? _hoveredObjectName;
|
||||
|
||||
/// <summary>Local center of the radar disc. Filled by <c>RadarController</c> from LayoutDesc 0x21000074.</summary>
|
||||
public Vector2 Center { get; set; } = new(60f, 60f);
|
||||
|
||||
/// <summary>Polled at retail's 25 ms cadence. The provider performs Core-to-UI adaptation.</summary>
|
||||
public Func<UiRadarSnapshot>? SnapshotProvider { get; set; }
|
||||
|
||||
/// <summary>Invoked when the user clicks the nearest blip under the pointer.</summary>
|
||||
public Action<uint>? SelectObject { get; set; }
|
||||
|
||||
/// <summary>Optional host hook for target highlights/status text.</summary>
|
||||
public Action<uint?>? HoveredObjectChanged { get; set; }
|
||||
|
||||
public UiRadarSnapshot Snapshot => _snapshot;
|
||||
public uint? HoveredObjectId => _hoveredObjectId;
|
||||
|
||||
/// <summary>The radar itself must receive a click inside a draggable window; its dedicated
|
||||
/// drag-handle child remains a non-HandlesClick target so UiRoot moves the window from there.</summary>
|
||||
public override bool HandlesClick => true;
|
||||
|
||||
/// <summary>Apply one projected frame immediately. Used once by the binder, then by the 25 ms tick.</summary>
|
||||
public void ApplySnapshot(UiRadarSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
_snapshot = snapshot;
|
||||
|
||||
// An object can disappear between pointer events. Clear stale hover immediately so a
|
||||
// later click cannot select a guid that is no longer in the provider snapshot.
|
||||
if (_hoveredObjectId is uint hovered)
|
||||
{
|
||||
bool stillPresent = false;
|
||||
for (int i = 0; i < snapshot.Blips.Count; i++)
|
||||
{
|
||||
if (snapshot.Blips[i].ObjectId == hovered)
|
||||
{
|
||||
stillPresent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!stillPresent)
|
||||
SetHovered(null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Poll the provider now, without waiting for the next retained-tree tick.</summary>
|
||||
public void Refresh()
|
||||
{
|
||||
if (SnapshotProvider is { } provider)
|
||||
ApplySnapshot(provider() ?? UiRadarSnapshot.Empty);
|
||||
}
|
||||
|
||||
protected override void OnTick(double deltaSeconds)
|
||||
{
|
||||
if (SnapshotProvider is null)
|
||||
return;
|
||||
|
||||
_refreshAccumulator += deltaSeconds;
|
||||
if (_refreshAccumulator < RetailRefreshSeconds)
|
||||
return;
|
||||
|
||||
// Retail schedules the next update from Timer::cur_time rather than replaying missed
|
||||
// ticks. Keep only the sub-interval remainder after a long frame.
|
||||
_refreshAccumulator %= RetailRefreshSeconds;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
protected override bool OnHitTest(float localX, float localY)
|
||||
{
|
||||
bool inside = base.OnHitTest(localX, localY);
|
||||
if (!inside)
|
||||
{
|
||||
SetHovered(null, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
UpdateHoveredBlip(localX, localY);
|
||||
return true;
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.HoverLeave)
|
||||
{
|
||||
SetHovered(null, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e.Type == UiEventType.Click && _hoveredObjectId is uint guid)
|
||||
{
|
||||
SelectObject?.Invoke(guid);
|
||||
return SelectObject is not null;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public override string? GetTooltipText() => _hoveredObjectName;
|
||||
|
||||
protected override void OnDrawAfterChildren(UiRenderContext ctx)
|
||||
{
|
||||
// Retail order is UIRegion::DrawChildren -> DrawObjects -> fixed bright-green player
|
||||
// marker (gmRadarUI::DrawChildren 0x004D9720). OnDrawAfterChildren gives the retained
|
||||
// tree that exact composition order without reimplementing any static DAT sprite.
|
||||
if (!_snapshot.BlankBlips)
|
||||
{
|
||||
for (int i = 0; i < _snapshot.Blips.Count; i++)
|
||||
DrawBlip(ctx, _snapshot.Blips[i]);
|
||||
}
|
||||
|
||||
int cx = RoundPixel(Center.X);
|
||||
int cy = RoundPixel(Center.Y);
|
||||
DrawPixels(ctx, cx, cy, PlayerMarkerColor, RetailRadar.PlayerMarkerPixels);
|
||||
}
|
||||
|
||||
private void UpdateHoveredBlip(float localX, float localY)
|
||||
{
|
||||
uint? nearestId = null;
|
||||
string? nearestName = null;
|
||||
float nearestDistanceSquared = HoverRadiusPixels * HoverRadiusPixels;
|
||||
|
||||
if (!_snapshot.BlankBlips)
|
||||
{
|
||||
for (int i = 0; i < _snapshot.Blips.Count; i++)
|
||||
{
|
||||
var blip = _snapshot.Blips[i];
|
||||
float dx = localX - blip.PixelX;
|
||||
float dy = localY - blip.PixelY;
|
||||
float distanceSquared = dx * dx + dy * dy;
|
||||
if (distanceSquared <= nearestDistanceSquared)
|
||||
{
|
||||
nearestDistanceSquared = distanceSquared;
|
||||
nearestId = blip.ObjectId;
|
||||
nearestName = blip.Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SetHovered(nearestId, nearestName);
|
||||
}
|
||||
|
||||
private void SetHovered(uint? id, string? name)
|
||||
{
|
||||
if (_hoveredObjectId == id && _hoveredObjectName == name)
|
||||
return;
|
||||
|
||||
_hoveredObjectId = id;
|
||||
_hoveredObjectName = name;
|
||||
HoveredObjectChanged?.Invoke(id);
|
||||
}
|
||||
|
||||
private static void DrawBlip(UiRenderContext ctx, in UiRadarBlip blip)
|
||||
{
|
||||
int x = RoundPixel(blip.PixelX);
|
||||
int y = RoundPixel(blip.PixelY);
|
||||
DrawPixels(ctx, x, y, blip.Color, RetailRadar.GetBlipPixels(blip.Shape));
|
||||
|
||||
if (blip.Selected)
|
||||
DrawPixels(ctx, x, y, blip.Color, RetailRadar.SelectionPixels);
|
||||
}
|
||||
|
||||
private static void DrawPixels(
|
||||
UiRenderContext ctx,
|
||||
int centerX,
|
||||
int centerY,
|
||||
Vector4 color,
|
||||
ReadOnlySpan<RadarPixelOffset> offsets)
|
||||
{
|
||||
for (int i = 0; i < offsets.Length; i++)
|
||||
ctx.DrawFill(centerX + offsets[i].X, centerY + offsets[i].Y, 1, 1, color);
|
||||
}
|
||||
|
||||
private static int RoundPixel(float value)
|
||||
=> checked((int)MathF.Round(value, MidpointRounding.ToEven));
|
||||
}
|
||||
|
|
@ -68,6 +68,10 @@ public sealed class UiRoot : UiElement
|
|||
/// <summary>True when a widget holds keyboard focus (e.g. a focused chat input).</summary>
|
||||
public bool WantsKeyboard => KeyboardFocus is not null;
|
||||
|
||||
/// <summary>Retail PlayerModule::LockUI gate. Blocks all retained-window
|
||||
/// move/resize interactions without disabling their buttons or content.</summary>
|
||||
public bool UiLocked { get; set; }
|
||||
|
||||
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
|
||||
public UiElement? DragSource { get; private set; }
|
||||
public object? DragPayload { get; private set; }
|
||||
|
|
@ -79,7 +83,7 @@ public sealed class UiRoot : UiElement
|
|||
{
|
||||
var target = Pick(MouseX, MouseY);
|
||||
var window = FindWindow(target);
|
||||
return window is { Resizable: true }
|
||||
return !UiLocked && window is { Resizable: true }
|
||||
? HitEdges(window, MouseX, MouseY, ResizeGrip)
|
||||
: ResizeEdges.None;
|
||||
}
|
||||
|
|
@ -90,7 +94,7 @@ public sealed class UiRoot : UiElement
|
|||
{
|
||||
var target = Pick(MouseX, MouseY);
|
||||
var window = FindWindow(target);
|
||||
if (target is null || window is not { Draggable: true })
|
||||
if (UiLocked || target is null || window is not { Draggable: true })
|
||||
return false;
|
||||
if (HoverResizeEdges != ResizeEdges.None)
|
||||
return false;
|
||||
|
|
@ -141,6 +145,9 @@ public sealed class UiRoot : UiElement
|
|||
/// <summary>Raised when a drag is released over no UI element.</summary>
|
||||
public event Action<object /*payload*/, int /*x*/, int /*y*/>? DragReleasedOutsideUi;
|
||||
|
||||
/// <summary>Raised after a registered top-level window finishes moving.</summary>
|
||||
public event Action<string, UiElement>? WindowMoved;
|
||||
|
||||
private uint _nextEventId = 0x10000001u;
|
||||
|
||||
public override void AddChild(UiElement child)
|
||||
|
|
@ -221,8 +228,16 @@ public sealed class UiRoot : UiElement
|
|||
// Window-move drag takes precedence over drag-drop / hover / fall-through.
|
||||
if (_windowDragTarget is not null)
|
||||
{
|
||||
_windowDragTarget.Left = x - _windowDragOffX;
|
||||
_windowDragTarget.Top = y - _windowDragOffY;
|
||||
float left = x - _windowDragOffX;
|
||||
float top = y - _windowDragOffY;
|
||||
if (_windowDragTarget.ConstrainDragToParent
|
||||
&& _windowDragTarget.Parent is { } parent)
|
||||
{
|
||||
left = Math.Clamp(left, 0f, Math.Max(0f, parent.Width - _windowDragTarget.Width));
|
||||
top = Math.Clamp(top, 0f, Math.Max(0f, parent.Height - _windowDragTarget.Height));
|
||||
}
|
||||
_windowDragTarget.Left = left;
|
||||
_windowDragTarget.Top = top;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -285,7 +300,7 @@ public sealed class UiRoot : UiElement
|
|||
var window = FindWindow(target);
|
||||
// Retail-faithful: pressing on a window raises it above its peers.
|
||||
if (window is not null) BringToFront(window);
|
||||
if (btn == UiMouseButton.Left && window is not null)
|
||||
if (btn == UiMouseButton.Left && window is not null && !UiLocked)
|
||||
{
|
||||
var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None;
|
||||
if (edges != ResizeEdges.None)
|
||||
|
|
@ -370,8 +385,15 @@ public sealed class UiRoot : UiElement
|
|||
|
||||
if (_windowDragTarget is not null)
|
||||
{
|
||||
var movedWindow = _windowDragTarget;
|
||||
_windowDragTarget = null;
|
||||
ReleaseCapture();
|
||||
foreach (var pair in _windows)
|
||||
if (ReferenceEquals(pair.Value, movedWindow))
|
||||
{
|
||||
WindowMoved?.Invoke(pair.Key, movedWindow);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,4 +9,5 @@ public static class WindowNames
|
|||
public const string Character = "character";
|
||||
public const string Inventory = "inventory";
|
||||
public const string Chat = "chat";
|
||||
public const string Radar = "radar";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,7 +187,13 @@ public static class CreateObject
|
|||
uint? Priority = null,
|
||||
int? Structure = null,
|
||||
int? MaxStructure = null,
|
||||
float? Workmanship = null);
|
||||
float? Workmanship = null,
|
||||
// PublicWeenieDesc optional-tail bytes (_blipColor/_radar_enum at
|
||||
// acclient.h:37191-37192; retail UnPack 0x005AD470 reads both as u8).
|
||||
// Nullable preserves the wire distinction between an absent flag and
|
||||
// an explicitly transmitted zero (the enum's undefined/default value).
|
||||
byte? RadarBlipColor = null,
|
||||
byte? RadarBehavior = null);
|
||||
|
||||
/// <summary>
|
||||
/// The relevant subset of the server-sent <c>MovementData</c> /
|
||||
|
|
@ -720,8 +726,8 @@ public static class CreateObject
|
|||
// 0x00010000 ValidLocations u32 (skip)
|
||||
// 0x00020000 CurrentlyWieldedLocation u32 (skip)
|
||||
// 0x00040000 Priority u32 (skip)
|
||||
// 0x00100000 RadarBlipColor u8 (skip)
|
||||
// 0x00800000 RadarBehavior u8 (skip)
|
||||
// 0x00100000 RadarBlipColor u8 CAPTURE
|
||||
// 0x00800000 RadarBehavior u8 CAPTURE
|
||||
// 0x08000000 PScript u16 (skip)
|
||||
// 0x01000000 Workmanship f32 (skip)
|
||||
// 0x00200000 Burden u16 (skip)
|
||||
|
|
@ -742,6 +748,8 @@ public static class CreateObject
|
|||
uint? useability = null;
|
||||
float? useRadius = null;
|
||||
uint? targetType = null;
|
||||
byte? radarBlipColor = null;
|
||||
byte? radarBehavior = null;
|
||||
uint iconOverlayId = 0;
|
||||
uint iconUnderlayId = 0;
|
||||
uint uiEffects = 0;
|
||||
|
|
@ -864,12 +872,12 @@ public static class CreateObject
|
|||
if ((weenieFlags & 0x00100000u) != 0) // RadarBlipColor u8
|
||||
{
|
||||
if (body.Length - pos < 1) throw new FormatException("trunc RadarBlipColor");
|
||||
pos += 1;
|
||||
radarBlipColor = body[pos]; pos += 1;
|
||||
}
|
||||
if ((weenieFlags & 0x00800000u) != 0) // RadarBehavior u8
|
||||
{
|
||||
if (body.Length - pos < 1) throw new FormatException("trunc RadarBehavior");
|
||||
pos += 1;
|
||||
radarBehavior = body[pos]; pos += 1;
|
||||
}
|
||||
if ((weenieFlags & 0x08000000u) != 0) // PScript u16
|
||||
{
|
||||
|
|
@ -957,7 +965,8 @@ public static class CreateObject
|
|||
ContainerId: wContainerId, WielderId: wWielderId,
|
||||
ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation,
|
||||
Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure,
|
||||
Workmanship: wWorkmanship);
|
||||
Workmanship: wWorkmanship,
|
||||
RadarBlipColor: radarBlipColor, RadarBehavior: radarBehavior);
|
||||
|
||||
// Local helper: if we ran out of fields past PhysicsData, still
|
||||
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
|
||||
|
|
|
|||
|
|
@ -83,5 +83,7 @@ public static class ObjectTableWiring
|
|||
MaxStructure: s.MaxStructure,
|
||||
Workmanship: s.Workmanship,
|
||||
Useability: s.Useability,
|
||||
TargetType: s.TargetType);
|
||||
TargetType: s.TargetType,
|
||||
RadarBlipColor: s.RadarBlipColor,
|
||||
RadarBehavior: s.RadarBehavior);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,63 @@ public sealed class WorldSession : IDisposable
|
|||
// MOVEMENT_TS / SERVER_CONTROLLED_MOVE_TS).
|
||||
ushort InstanceSequence = 0,
|
||||
ushort MovementSequence = 0,
|
||||
ushort ServerControlSequence = 0);
|
||||
ushort ServerControlSequence = 0,
|
||||
// PublicWeenieDesc optional-tail bytes. null means the corresponding
|
||||
// flag was absent; zero means the server explicitly sent the enum's
|
||||
// undefined/default value.
|
||||
byte? RadarBlipColor = null,
|
||||
byte? RadarBehavior = null);
|
||||
|
||||
/// <summary>
|
||||
/// Projects the wire-level CreateObject result into the stable session
|
||||
/// event payload. Kept as a focused seam so every optional field's
|
||||
/// absent-versus-explicit-default semantics can be tested without a UDP
|
||||
/// session.
|
||||
/// </summary>
|
||||
internal static EntitySpawn ToEntitySpawn(CreateObject.Parsed parsed) => new(
|
||||
parsed.Guid,
|
||||
parsed.Position,
|
||||
parsed.SetupTableId,
|
||||
parsed.AnimPartChanges,
|
||||
parsed.TextureChanges,
|
||||
parsed.SubPalettes,
|
||||
parsed.BasePaletteId,
|
||||
parsed.ObjScale,
|
||||
parsed.Name,
|
||||
parsed.ItemType,
|
||||
parsed.MotionState,
|
||||
parsed.MotionTableId,
|
||||
parsed.PhysicsState,
|
||||
parsed.ObjectDescriptionFlags,
|
||||
parsed.Friction,
|
||||
parsed.Elasticity,
|
||||
parsed.Useability,
|
||||
parsed.UseRadius,
|
||||
parsed.TargetType,
|
||||
parsed.IconId,
|
||||
parsed.IconOverlayId,
|
||||
parsed.IconUnderlayId,
|
||||
parsed.UiEffects,
|
||||
parsed.WeenieClassId,
|
||||
parsed.Value,
|
||||
parsed.StackSize,
|
||||
parsed.StackSizeMax,
|
||||
parsed.Burden,
|
||||
parsed.ItemsCapacity,
|
||||
parsed.ContainersCapacity,
|
||||
parsed.ContainerId,
|
||||
parsed.WielderId,
|
||||
parsed.ValidLocations,
|
||||
parsed.CurrentWieldedLocation,
|
||||
parsed.Priority,
|
||||
parsed.Structure,
|
||||
parsed.MaxStructure,
|
||||
parsed.Workmanship,
|
||||
InstanceSequence: parsed.InstanceSequence,
|
||||
MovementSequence: parsed.MovementSequence,
|
||||
ServerControlSequence: parsed.ServerControlSequence,
|
||||
RadarBlipColor: parsed.RadarBlipColor,
|
||||
RadarBehavior: parsed.RadarBehavior);
|
||||
|
||||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||
public event Action<EntitySpawn>? EntitySpawned;
|
||||
|
|
@ -799,48 +855,7 @@ public sealed class WorldSession : IDisposable
|
|||
_forcePositionSequence = parsed.Value.ForcePositionSequence;
|
||||
}
|
||||
|
||||
EntitySpawned?.Invoke(new EntitySpawn(
|
||||
parsed.Value.Guid,
|
||||
parsed.Value.Position,
|
||||
parsed.Value.SetupTableId,
|
||||
parsed.Value.AnimPartChanges,
|
||||
parsed.Value.TextureChanges,
|
||||
parsed.Value.SubPalettes,
|
||||
parsed.Value.BasePaletteId,
|
||||
parsed.Value.ObjScale,
|
||||
parsed.Value.Name,
|
||||
parsed.Value.ItemType,
|
||||
parsed.Value.MotionState,
|
||||
parsed.Value.MotionTableId,
|
||||
parsed.Value.PhysicsState,
|
||||
parsed.Value.ObjectDescriptionFlags,
|
||||
parsed.Value.Friction,
|
||||
parsed.Value.Elasticity,
|
||||
parsed.Value.Useability,
|
||||
parsed.Value.UseRadius,
|
||||
parsed.Value.TargetType,
|
||||
parsed.Value.IconId,
|
||||
parsed.Value.IconOverlayId,
|
||||
parsed.Value.IconUnderlayId,
|
||||
parsed.Value.UiEffects,
|
||||
parsed.Value.WeenieClassId,
|
||||
parsed.Value.Value,
|
||||
parsed.Value.StackSize,
|
||||
parsed.Value.StackSizeMax,
|
||||
parsed.Value.Burden,
|
||||
parsed.Value.ItemsCapacity,
|
||||
parsed.Value.ContainersCapacity,
|
||||
parsed.Value.ContainerId,
|
||||
parsed.Value.WielderId,
|
||||
parsed.Value.ValidLocations,
|
||||
parsed.Value.CurrentWieldedLocation,
|
||||
parsed.Value.Priority,
|
||||
parsed.Value.Structure,
|
||||
parsed.Value.MaxStructure,
|
||||
parsed.Value.Workmanship,
|
||||
InstanceSequence: parsed.Value.InstanceSequence,
|
||||
MovementSequence: parsed.Value.MovementSequence,
|
||||
ServerControlSequence: parsed.Value.ServerControlSequence));
|
||||
EntitySpawned?.Invoke(ToEntitySpawn(parsed.Value));
|
||||
}
|
||||
}
|
||||
else if (op == DeleteObject.Opcode)
|
||||
|
|
|
|||
|
|
@ -178,6 +178,10 @@ public sealed class ClientObject
|
|||
public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order
|
||||
public uint? Useability { get; set; } // ITEM_USEABLE from PublicWeenieDesc
|
||||
public uint? TargetType { get; set; } // ITEM_TYPE mask for targeted-use compatibility
|
||||
/// <summary>Retail PublicWeenieDesc <c>_blipColor</c>; null until supplied by CreateObject.</summary>
|
||||
public byte? RadarBlipColor { get; set; }
|
||||
/// <summary>Retail PublicWeenieDesc <c>_radar_enum</c>; null until supplied by CreateObject.</summary>
|
||||
public byte? RadarBehavior { get; set; }
|
||||
public int Structure { get; set; } // charges/uses remaining
|
||||
public int MaxStructure { get; set; }
|
||||
public float Workmanship { get; set; } // 0..10 (fractional on the wire)
|
||||
|
|
@ -217,7 +221,9 @@ public readonly record struct WeenieData(
|
|||
int? MaxStructure,
|
||||
float? Workmanship,
|
||||
uint? Useability = null,
|
||||
uint? TargetType = null);
|
||||
uint? TargetType = null,
|
||||
byte? RadarBlipColor = null,
|
||||
byte? RadarBehavior = null);
|
||||
|
||||
/// <summary>
|
||||
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
|
||||
|
|
|
|||
|
|
@ -386,6 +386,8 @@ public sealed class ClientObjectTable
|
|||
if (d.Priority is { } pr) obj.Priority = pr;
|
||||
if (d.Useability is { } use) obj.Useability = use;
|
||||
if (d.TargetType is { } targetType) obj.TargetType = targetType;
|
||||
if (d.RadarBlipColor is { } radarBlipColor) obj.RadarBlipColor = radarBlipColor;
|
||||
if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior;
|
||||
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
|
||||
if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc;
|
||||
if (d.Structure is { } st) obj.Structure = st;
|
||||
|
|
|
|||
|
|
@ -3,91 +3,190 @@ using AcDream.Core.Items;
|
|||
namespace AcDream.Core.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// B.7 (2026-05-15) — port of retail's <c>gmRadarUI::GetBlipColor</c>
|
||||
/// (named decomp <c>0x004d76f0</c>). Returns the radar / target-indicator
|
||||
/// colour for an entity based on its <see cref="ItemType"/> and the
|
||||
/// raw <c>PublicWeenieDesc._bitfield</c> we already parse out of
|
||||
/// <c>CreateObject</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// Used by the Vivid Target Indicator (Phase B.7) to colour the four
|
||||
/// corner triangles around the selected entity. Same value retail
|
||||
/// would have shown on the radar blip — so the indicator + radar agree.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Dispatch order matches the retail decomp at lines 219913+ of
|
||||
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>. The
|
||||
/// PWD bit layout matches <c>acclient.h:6431-6463</c>:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>BF_PLAYER = 0x8</c></item>
|
||||
/// <item><c>BF_PLAYER_KILLER = 0x20</c></item>
|
||||
/// <item><c>BF_VENDOR = 0x200</c> (byte[1] & 0x02 in retail)</item>
|
||||
/// <item><c>BF_PORTAL = 0x40000</c></item>
|
||||
/// <item><c>BF_FREE_PKSTATUS = 0x200000</c> (hostile-flagged player)</item>
|
||||
/// <item><c>BF_PKLITE_PKSTATUS = 0x2000000</c></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>RGBA values</b> are hand-tuned to visually match retail screenshots
|
||||
/// (yellow creature, red PK, pink PKLite, green vendor, cyan portal,
|
||||
/// white default). Real <c>RGBAColor_Radar*</c> constants live in retail
|
||||
/// static data — if they're ever recovered the table can be tightened.
|
||||
/// </para>
|
||||
/// Facts used by retail's radar color/shape classification. Relationship state is
|
||||
/// intentionally separate because fellowship and allegiance membership do not come
|
||||
/// from <c>PublicWeenieDesc</c>.
|
||||
/// </summary>
|
||||
public readonly record struct RadarObjectTraits(
|
||||
bool IsValid = true,
|
||||
byte BlipColorOverride = 0,
|
||||
bool IsPortal = false,
|
||||
bool IsVendor = false,
|
||||
bool IsAttackable = false,
|
||||
bool IsCreature = false,
|
||||
bool IsPlayer = false,
|
||||
bool IsAdmin = false,
|
||||
bool IsHiddenAdmin = false,
|
||||
bool IsPlayerKiller = false,
|
||||
bool IsPkLite = false,
|
||||
bool IsFreePk = false)
|
||||
{
|
||||
// PublicWeenieDesc::BitfieldIndex, acclient.h:6431.
|
||||
private const uint BfPlayer = 0x00000008u;
|
||||
private const uint BfAttackable = 0x00000010u;
|
||||
private const uint BfPlayerKiller = 0x00000020u;
|
||||
private const uint BfHiddenAdmin = 0x00000040u;
|
||||
private const uint BfVendor = 0x00000200u;
|
||||
private const uint BfPortal = 0x00040000u;
|
||||
private const uint BfAdmin = 0x00100000u;
|
||||
private const uint BfFreePk = 0x00200000u;
|
||||
private const uint BfPkLite = 0x02000000u;
|
||||
|
||||
/// <summary>
|
||||
/// Build the subset of retail traits present in a CreateObject PWD. Fellowship
|
||||
/// and allegiance state must still be supplied separately.
|
||||
/// </summary>
|
||||
public static RadarObjectTraits FromPublicWeenieDescription(
|
||||
uint itemType,
|
||||
uint bitfield,
|
||||
byte blipColorOverride = 0)
|
||||
=> new(
|
||||
IsValid: (bitfield & 0x80000000u) == 0,
|
||||
BlipColorOverride: blipColorOverride,
|
||||
IsPortal: (bitfield & BfPortal) != 0,
|
||||
IsVendor: (bitfield & BfVendor) != 0,
|
||||
IsAttackable: (bitfield & BfAttackable) != 0,
|
||||
IsCreature: (itemType & (uint)ItemType.Creature) != 0,
|
||||
IsPlayer: (bitfield & BfPlayer) != 0,
|
||||
IsAdmin: (bitfield & BfAdmin) != 0,
|
||||
IsHiddenAdmin: (bitfield & BfHiddenAdmin) != 0,
|
||||
IsPlayerKiller: (bitfield & BfPlayerKiller) != 0,
|
||||
IsPkLite: (bitfield & BfPkLite) != 0,
|
||||
IsFreePk: (bitfield & BfFreePk) != 0);
|
||||
}
|
||||
|
||||
/// <summary>Relationship facts owned by the client player/fellowship systems.</summary>
|
||||
public readonly record struct RadarRelationshipTraits(
|
||||
bool IsFellowshipMember = false,
|
||||
bool IsFellowshipLeader = false,
|
||||
bool IsAllegianceMember = false,
|
||||
bool PlayerIsPlayerKiller = false,
|
||||
bool PlayerIsPkLite = false);
|
||||
|
||||
/// <summary>
|
||||
/// Exact retail radar palette and <c>gmRadarUI::GetBlipColor</c> dispatch.
|
||||
/// Named retail anchors: <c>gmRadarUI::GetBlipColor @ 0x004D76F0</c> and
|
||||
/// <c>RGBAColor_Radar* @ 0x008190E8</c>.
|
||||
/// </summary>
|
||||
public static class RadarBlipColors
|
||||
{
|
||||
public readonly record struct Rgba(byte R, byte G, byte B, byte A);
|
||||
/// <summary>
|
||||
/// Retail stores radar colors as four floats. The full names expose those exact
|
||||
/// channels; R/G/B/A are compatibility RGBA8 projections for older UI callers.
|
||||
/// </summary>
|
||||
public readonly record struct Rgba(float Red, float Green, float Blue, float Alpha)
|
||||
{
|
||||
public byte R => ToByte(Red);
|
||||
public byte G => ToByte(Green);
|
||||
public byte B => ToByte(Blue);
|
||||
public byte A => ToByte(Alpha);
|
||||
|
||||
public static readonly Rgba Item = new(220, 220, 220, 255); // light grey (default object)
|
||||
public static readonly Rgba Default = new(255, 255, 255, 255); // white (friendly player)
|
||||
public static readonly Rgba Creature = new(255, 220, 80, 255); // yellow (NPC / monster)
|
||||
public static readonly Rgba PlayerKiller = new(255, 64, 64, 255); // red (PK)
|
||||
public static readonly Rgba PKLite = new(255, 128, 192, 255); // pink (PKLite)
|
||||
public static readonly Rgba Vendor = new( 64, 192, 64, 255); // green (vendor NPC)
|
||||
public static readonly Rgba Portal = new( 64, 192, 255, 255); // cyan (portal)
|
||||
public Rgba DimRgb(float multiplier)
|
||||
=> new(Red * multiplier, Green * multiplier, Blue * multiplier, Alpha);
|
||||
|
||||
/// <summary>ImGui/OpenGL-style packed <c>0xAABBGGRR</c> projection.</summary>
|
||||
public uint ToAbgr32()
|
||||
=> ((uint)A << 24) | ((uint)B << 16) | ((uint)G << 8) | R;
|
||||
|
||||
private static byte ToByte(float value)
|
||||
=> (byte)Math.Clamp((int)MathF.Round(value * 255f), 0, 255);
|
||||
}
|
||||
|
||||
// Verbatim float constants at acclient.exe 0x008190E8..0x00819184.
|
||||
public static readonly Rgba Blue = new(0.25f, 0.660000026f, 1f, 1f);
|
||||
public static readonly Rgba Gold = new(1f, 0.670000017f, 0f, 1f);
|
||||
public static readonly Rgba Yellow = new(1f, 1f, 0.5f, 1f);
|
||||
public static readonly Rgba White = new(1f, 1f, 1f, 1f);
|
||||
public static readonly Rgba Red = new(1f, 0.25f, 0.389999986f, 1f);
|
||||
public static readonly Rgba Purple = new(0.75f, 0.389999986f, 1f, 1f);
|
||||
public static readonly Rgba Pink = new(1f, 0.660000026f, 0.75f, 1f);
|
||||
public static readonly Rgba Green = new(0f, 0.5f, 0.25f, 1f);
|
||||
public static readonly Rgba Cyan = new(0f, 1f, 1f, 1f);
|
||||
public static readonly Rgba BrightGreen = new(0f, 1f, 0f, 1f);
|
||||
|
||||
// Retail role aliases initialized at 0x006EC780..0x006F90E7.
|
||||
public static readonly Rgba Default = White;
|
||||
public static readonly Rgba Item = White;
|
||||
public static readonly Rgba Admin = Cyan;
|
||||
public static readonly Rgba Advocate = Pink;
|
||||
public static readonly Rgba Creature = Gold;
|
||||
public static readonly Rgba LifeStone = Blue;
|
||||
public static readonly Rgba NPC = Yellow;
|
||||
public static readonly Rgba PlayerKiller = Red;
|
||||
public static readonly Rgba Portal = Purple;
|
||||
public static readonly Rgba Sentinel = Cyan;
|
||||
public static readonly Rgba Vendor = Yellow;
|
||||
public static readonly Rgba Fellowship = BrightGreen;
|
||||
public static readonly Rgba FellowshipLeader = BrightGreen;
|
||||
public static readonly Rgba PKLite = Pink;
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the radar-blip colour for an entity. Caller supplies the
|
||||
/// raw <see cref="ItemType"/> (from <c>CreateObject.ItemType</c>) and
|
||||
/// <paramref name="pwdBitfield"/> (from
|
||||
/// <c>CreateObject.ObjectDescriptionFlags</c>) — both are already
|
||||
/// parsed and stashed on <c>EntitySpawn</c> at spawn time.
|
||||
///
|
||||
/// <para>
|
||||
/// Returns <see cref="Default"/> for friendly players, <see cref="Creature"/>
|
||||
/// for NPCs/monsters, <see cref="Item"/> for everything else.
|
||||
/// Special types (PK, PKLite, Vendor, Portal) win over the base
|
||||
/// type when their flag is set.
|
||||
/// </para>
|
||||
/// Compatibility entry point for metadata already surfaced by CreateObject.
|
||||
/// Classification which requires fellowship state is available through the
|
||||
/// explicit-traits overload.
|
||||
/// </summary>
|
||||
public static Rgba For(uint itemType, uint pwdBitfield)
|
||||
=> For(RadarObjectTraits.FromPublicWeenieDescription(itemType, pwdBitfield));
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmRadarUI::GetBlipColor @ 0x004D76F0</c>. An explicit wire
|
||||
/// override wins; otherwise portal/vendor/creature/player classification runs
|
||||
/// in retail order, followed by the fellowship override.
|
||||
/// </summary>
|
||||
public static Rgba For(
|
||||
RadarObjectTraits traits,
|
||||
RadarRelationshipTraits relationship = default)
|
||||
{
|
||||
// Special-type early returns. Order matches retail dispatch
|
||||
// (Portal first, then Vendor) — same target can't logically
|
||||
// be both, but in case of bit collision retail's order wins.
|
||||
if ((pwdBitfield & 0x40000u) != 0) return Portal;
|
||||
if ((pwdBitfield & 0x200u) != 0) return Vendor;
|
||||
if (!traits.IsValid)
|
||||
return Default;
|
||||
|
||||
bool isCreature = (itemType & (uint)ItemType.Creature) != 0;
|
||||
bool isPlayer = (pwdBitfield & 0x8u) != 0;
|
||||
if (traits.BlipColorOverride != 0)
|
||||
return ForOverride(traits.BlipColorOverride);
|
||||
|
||||
// Creature that isn't a player → NPC / monster → yellow.
|
||||
if (isCreature && !isPlayer)
|
||||
if (traits.IsPortal)
|
||||
return Portal;
|
||||
|
||||
if (traits.IsVendor)
|
||||
return Vendor;
|
||||
|
||||
if (traits.IsAttackable && traits.IsCreature && !traits.IsPlayer)
|
||||
return Creature;
|
||||
|
||||
if (isPlayer)
|
||||
{
|
||||
bool isPK = (pwdBitfield & 0x20u) != 0;
|
||||
bool isPKLite = (pwdBitfield & 0x2000000u) != 0;
|
||||
if (isPK) return PlayerKiller;
|
||||
if (isPKLite) return PKLite;
|
||||
if (!traits.IsPlayer)
|
||||
return Default;
|
||||
}
|
||||
|
||||
// Not a special type, not a creature, not a player → an item /
|
||||
// object on the ground or in the world.
|
||||
return Item;
|
||||
Rgba color = Default;
|
||||
if (traits.IsAdmin && !traits.IsHiddenAdmin)
|
||||
color = Admin;
|
||||
else if (traits.IsPlayerKiller)
|
||||
color = PlayerKiller;
|
||||
else if (traits.IsPkLite)
|
||||
color = PKLite;
|
||||
else if (traits.IsFreePk)
|
||||
color = Creature;
|
||||
|
||||
if (relationship.IsFellowshipLeader)
|
||||
color = FellowshipLeader;
|
||||
else if (relationship.IsFellowshipMember)
|
||||
color = Fellowship;
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
/// <summary>Retail PWD <c>_blipColor</c> override table (values 1..10).</summary>
|
||||
public static Rgba ForOverride(byte overrideValue)
|
||||
=> overrideValue switch
|
||||
{
|
||||
1 => Blue,
|
||||
2 => Gold,
|
||||
3 => White,
|
||||
4 => Purple,
|
||||
5 => Red,
|
||||
6 => Pink,
|
||||
7 => Green,
|
||||
8 => Yellow,
|
||||
9 => Cyan,
|
||||
10 => BrightGreen,
|
||||
_ => Default,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
275
src/AcDream.Core/Ui/RetailRadar.cs
Normal file
275
src/AcDream.Core/Ui/RetailRadar.cs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Ui;
|
||||
|
||||
/// <summary>Verbatim retail <c>RadarEnum</c> values from <c>acclient.h:6467</c>.</summary>
|
||||
public enum RadarBehavior : byte
|
||||
{
|
||||
Undef = 0,
|
||||
ShowNever = 1,
|
||||
ShowMovement = 2,
|
||||
ShowAttacking = 3,
|
||||
ShowAlways = 4,
|
||||
}
|
||||
|
||||
/// <summary>Verbatim retail <c>RadarBlipShape</c> values from <c>acclient.h:6575</c>.</summary>
|
||||
public enum RadarBlipShape
|
||||
{
|
||||
Undef = 0,
|
||||
Circle = 1,
|
||||
Box = 2,
|
||||
X = 3,
|
||||
Plus = 4,
|
||||
Triangle = 5,
|
||||
InvertedTriangle = 6,
|
||||
XBox = 7,
|
||||
|
||||
Default = Plus,
|
||||
AllegianceMember = Box,
|
||||
FellowshipLeader = Triangle,
|
||||
Fellowship = InvertedTriangle,
|
||||
Threat = X,
|
||||
ThreatAllegiance = XBox,
|
||||
}
|
||||
|
||||
public enum RadarCompassPoint
|
||||
{
|
||||
North,
|
||||
East,
|
||||
South,
|
||||
West,
|
||||
}
|
||||
|
||||
public readonly record struct RadarPixelOffset(int X, int Y);
|
||||
|
||||
public readonly record struct RadarProjection(Vector2 Pixel, float RgbMultiplier);
|
||||
|
||||
/// <summary>
|
||||
/// Pure retail radar behavior used by the retained UI. The caller supplies positions
|
||||
/// already converted to player space, matching retail's
|
||||
/// <c>SmartBox::convert_to_player_space</c> call.
|
||||
/// </summary>
|
||||
public static class RetailRadar
|
||||
{
|
||||
public const float OutdoorRangeMeters = 75f;
|
||||
public const float IndoorRangeMeters = 25f;
|
||||
public const float AltitudeDimThresholdMeters = 5f;
|
||||
public const float AltitudeDimMultiplier = 0.65f;
|
||||
public const float UpdateIntervalSeconds = 0.025f;
|
||||
|
||||
private static readonly RadarPixelOffset[] PointPixels = [new(0, 0)];
|
||||
private static readonly RadarPixelOffset[] EdgePixels = [new(0, -1), new(0, 1), new(-1, 0), new(1, 0)];
|
||||
private static readonly RadarPixelOffset[] CornerPixels = [new(1, 1), new(-1, -1), new(-1, 1), new(1, -1)];
|
||||
private static readonly RadarPixelOffset[] BoxPixels = [.. EdgePixels, .. CornerPixels];
|
||||
private static readonly RadarPixelOffset[] XPixels = [new(0, 0), .. CornerPixels];
|
||||
private static readonly RadarPixelOffset[] PlusPixels = [new(0, 0), .. EdgePixels];
|
||||
private static readonly RadarPixelOffset[] TrianglePixels = [new(0, 0), new(-1, 1), new(0, 1), new(1, 1)];
|
||||
private static readonly RadarPixelOffset[] InvertedTrianglePixels = [new(0, 0), new(-1, -1), new(0, -1), new(1, -1)];
|
||||
private static readonly RadarPixelOffset[] XBoxPixels =
|
||||
[
|
||||
.. EdgePixels,
|
||||
.. CornerPixels,
|
||||
new(-2, -2),
|
||||
new(2, -2),
|
||||
new(-2, 2),
|
||||
new(2, 2),
|
||||
];
|
||||
|
||||
private static readonly RadarPixelOffset[] PlayerMarkerPixelArray =
|
||||
[
|
||||
new(0, 0),
|
||||
.. EdgePixels,
|
||||
new(-2, 0),
|
||||
new(2, 0),
|
||||
new(0, -2),
|
||||
new(0, 2),
|
||||
];
|
||||
|
||||
private static readonly RadarPixelOffset[] SelectionPixelArray = BuildSelectionPixels();
|
||||
|
||||
/// <summary>
|
||||
/// <c>ACCWeenieObject::InqShowableOnRadar @ 0x0058C250</c>. The enum names do
|
||||
/// not imply a live motion/attack test here: retail accepts all three values.
|
||||
/// </summary>
|
||||
public static bool IsShowable(RadarBehavior behavior, bool hasPhysicsObject)
|
||||
=> hasPhysicsObject && behavior is
|
||||
RadarBehavior.ShowMovement or RadarBehavior.ShowAttacking or RadarBehavior.ShowAlways;
|
||||
|
||||
/// <summary><c>gmRadarUI::GetBlipShape @ 0x004D7B60</c>.</summary>
|
||||
public static RadarBlipShape GetBlipShape(
|
||||
RadarObjectTraits target,
|
||||
RadarRelationshipTraits relationship = default)
|
||||
{
|
||||
if (!target.IsValid)
|
||||
return RadarBlipShape.Undef;
|
||||
|
||||
if (relationship.IsFellowshipLeader)
|
||||
return RadarBlipShape.FellowshipLeader;
|
||||
|
||||
if (relationship.IsFellowshipMember)
|
||||
return RadarBlipShape.Fellowship;
|
||||
|
||||
if (relationship.IsAllegianceMember)
|
||||
return RadarBlipShape.AllegianceMember;
|
||||
|
||||
if ((target.IsPlayerKiller && relationship.PlayerIsPlayerKiller) ||
|
||||
(target.IsPkLite && relationship.PlayerIsPkLite))
|
||||
{
|
||||
return RadarBlipShape.Threat;
|
||||
}
|
||||
|
||||
return RadarBlipShape.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exact points filled by retail's DrawPoint/Edges/Corners/Cross/X/Triangle/
|
||||
/// InvertedTriangle/Hollow/XBox functions at <c>0x004D7C30..0x004D8531</c>.
|
||||
/// </summary>
|
||||
public static ReadOnlySpan<RadarPixelOffset> GetBlipPixels(RadarBlipShape shape)
|
||||
=> shape switch
|
||||
{
|
||||
RadarBlipShape.Circle => PointPixels,
|
||||
RadarBlipShape.Box => BoxPixels,
|
||||
RadarBlipShape.X => XPixels,
|
||||
RadarBlipShape.Plus => PlusPixels,
|
||||
RadarBlipShape.Triangle => TrianglePixels,
|
||||
RadarBlipShape.InvertedTriangle => InvertedTrianglePixels,
|
||||
RadarBlipShape.XBox => XBoxPixels,
|
||||
_ => [],
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Fixed bright-green player marker drawn by <c>gmRadarUI::DrawChildren
|
||||
/// @ 0x004D9720</c>: a plus with four extra points two pixels from center.
|
||||
/// </summary>
|
||||
public static ReadOnlySpan<RadarPixelOffset> PlayerMarkerPixels => PlayerMarkerPixelArray;
|
||||
|
||||
/// <summary>Selected-object outline from <c>gmRadarUI::DrawSelected @ 0x004D7FE0</c>.</summary>
|
||||
public static ReadOnlySpan<RadarPixelOffset> SelectionPixels => SelectionPixelArray;
|
||||
|
||||
public static float GetRangeMeters(bool isOutside)
|
||||
=> isOutside ? OutdoorRangeMeters : IndoorRangeMeters;
|
||||
|
||||
/// <summary>
|
||||
/// Project a player-local position to radar pixels exactly as
|
||||
/// <c>gmRadarUI::DrawObjects @ 0x004D9380</c>. Objects at or beyond
|
||||
/// <c>(range - 1 m)</c> are excluded. Positive player-space Y is screen-up.
|
||||
/// </summary>
|
||||
public static bool TryProject(
|
||||
Vector3 playerSpaceMeters,
|
||||
Vector2 centerPixels,
|
||||
int radarRadiusPixels,
|
||||
float radarRangeMeters,
|
||||
out RadarProjection projection)
|
||||
{
|
||||
float inclusionRadius = radarRangeMeters - 1f;
|
||||
float horizontalDistanceSquared =
|
||||
(playerSpaceMeters.X * playerSpaceMeters.X) +
|
||||
(playerSpaceMeters.Y * playerSpaceMeters.Y);
|
||||
|
||||
if (horizontalDistanceSquared >= inclusionRadius * inclusionRadius)
|
||||
{
|
||||
projection = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
float scale = radarRadiusPixels / radarRangeMeters;
|
||||
projection = new RadarProjection(
|
||||
new Vector2(
|
||||
(int)(centerPixels.X + (playerSpaceMeters.X * scale)),
|
||||
(int)(centerPixels.Y - (playerSpaceMeters.Y * scale))),
|
||||
GetAltitudeRgbMultiplier(playerSpaceMeters.Z));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>RGB is dimmed at exactly 5 m; alpha is left unchanged by the caller.</summary>
|
||||
public static float GetAltitudeRgbMultiplier(float playerSpaceZMeters)
|
||||
=> MathF.Abs(playerSpaceZMeters) < AltitudeDimThresholdMeters
|
||||
? 1f
|
||||
: AltitudeDimMultiplier;
|
||||
|
||||
/// <summary>
|
||||
/// Compute a compass token's top-left pixel from
|
||||
/// <c>gmRadarUI::UpdateCompassTokens @ 0x004D9060</c>. Heading is the physical
|
||||
/// player's heading in degrees, not camera yaw.
|
||||
/// </summary>
|
||||
public static Vector2 GetCompassTokenTopLeft(
|
||||
float playerHeadingDegrees,
|
||||
RadarCompassPoint point,
|
||||
Vector2 radarCenterPixels,
|
||||
float tokenMagnitudePixels,
|
||||
Vector2 tokenSizePixels)
|
||||
{
|
||||
// Retail stores headingRadians as float, then performs the trig on x87.
|
||||
// Preserve that float rounding while using double sin/cos like the x87 path.
|
||||
float headingRadians = playerHeadingDegrees * 0.0174532924f;
|
||||
double angle = headingRadians + (point switch
|
||||
{
|
||||
RadarCompassPoint.North => Math.PI,
|
||||
RadarCompassPoint.East => (double)1.57079637f,
|
||||
RadarCompassPoint.South => 0d,
|
||||
RadarCompassPoint.West => (double)4.71238899f,
|
||||
_ => 0d,
|
||||
});
|
||||
|
||||
float offsetX = (float)(Math.Sin(angle) * tokenMagnitudePixels);
|
||||
float tokenCenterX = (float)(offsetX + radarCenterPixels.X);
|
||||
float tokenCenterY = (float)((Math.Cos(angle) * tokenMagnitudePixels) + radarCenterPixels.Y);
|
||||
|
||||
return new Vector2(
|
||||
(int)(tokenCenterX - (tokenSizePixels.X * 0.5f)),
|
||||
(int)(tokenCenterY - (tokenSizePixels.Y * 0.5f)));
|
||||
}
|
||||
|
||||
private static RadarPixelOffset[] BuildSelectionPixels()
|
||||
{
|
||||
var pixels = new RadarPixelOffset[20];
|
||||
int index = 0;
|
||||
for (int x = -2; x <= 2; x++)
|
||||
{
|
||||
pixels[index++] = new RadarPixelOffset(x, 3);
|
||||
pixels[index++] = new RadarPixelOffset(x, -3);
|
||||
}
|
||||
|
||||
for (int y = -2; y <= 2; y++)
|
||||
{
|
||||
pixels[index++] = new RadarPixelOffset(3, y);
|
||||
pixels[index++] = new RadarPixelOffset(-3, y);
|
||||
}
|
||||
|
||||
return pixels;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retail outdoor coordinate text derived from the player's landcell.</summary>
|
||||
public readonly record struct RadarCoordinates(double X, double Y)
|
||||
{
|
||||
public string XText => FormatAxis(X, "W", "E");
|
||||
public string YText => FormatAxis(Y, "S", "N");
|
||||
public string CombinedText => $"{YText},{XText}";
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>CPlayerSystem::InqPlayerCoords @ 0x00560090</c>. Indoor cells
|
||||
/// fail because retail obtains coordinates through <c>get_landscape_coord</c>.
|
||||
/// </summary>
|
||||
public static bool TryFromCell(uint cellId, out RadarCoordinates coordinates)
|
||||
{
|
||||
if (!LandDefs.GidToLcoord(cellId, out int lx, out int ly))
|
||||
{
|
||||
coordinates = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
coordinates = new RadarCoordinates(
|
||||
((lx - 1024) * 0.1) + 0.5,
|
||||
((ly - 1024) * 0.1) + 0.5);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatAxis(double value, string negativeSuffix, string positiveSuffix)
|
||||
{
|
||||
string suffix = value < 0d ? negativeSuffix : value > 0d ? positiveSuffix : string.Empty;
|
||||
return $"{Math.Abs(value).ToString("F1", System.Globalization.CultureInfo.InvariantCulture)}{suffix}";
|
||||
}
|
||||
}
|
||||
|
|
@ -51,7 +51,9 @@ public sealed record GameplaySettings(
|
|||
ShowTooltips: true,
|
||||
VividTargetingIndicator: false,
|
||||
SideBySideVitals: false,
|
||||
CoordinatesOnRadar: false,
|
||||
// Retail default character-options mask 0x50C4A54A includes
|
||||
// CoordinatesOnRadar (0x00400000).
|
||||
CoordinatesOnRadar: true,
|
||||
SpellDuration: true,
|
||||
AllowGive: true,
|
||||
ShowHelm: true,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ using AcDream.UI.Abstractions.Settings;
|
|||
|
||||
namespace AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
/// <summary>Per-character retained-window position in screen pixels.</summary>
|
||||
public readonly record struct UiWindowPosition(float X, float Y);
|
||||
|
||||
/// <summary>
|
||||
/// JSON-backed persistence for non-keybind settings (Display today; future
|
||||
/// tabs Audio / Gameplay / Chat / Character will be added to the same
|
||||
|
|
@ -290,6 +293,70 @@ public sealed class SettingsStore
|
|||
File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
/// <summary>Load a named retained-window position for one character.</summary>
|
||||
public UiWindowPosition? LoadWindowPosition(string toonKey, string windowName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(toonKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
|
||||
if (!File.Exists(_path)) return null;
|
||||
try
|
||||
{
|
||||
var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject;
|
||||
var node = root?["windowPositions"]?[toonKey]?[windowName] as JsonObject;
|
||||
if (node is null) return null;
|
||||
float? x = node["x"]?.GetValue<float>();
|
||||
float? y = node["y"]?.GetValue<float>();
|
||||
return x is not null && y is not null ? new UiWindowPosition(x.Value, y.Value) : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: failed to load window position from {_path}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save a named retained-window position under
|
||||
/// <c>windowPositions[toonKey][windowName]</c>, preserving all settings sections.
|
||||
/// </summary>
|
||||
public void SaveWindowPosition(string toonKey, string windowName, UiWindowPosition position)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(toonKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
|
||||
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
JsonObject root;
|
||||
if (File.Exists(_path))
|
||||
{
|
||||
try { root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject(); }
|
||||
catch { root = new JsonObject(); }
|
||||
}
|
||||
else
|
||||
{
|
||||
root = new JsonObject();
|
||||
}
|
||||
|
||||
if (root["windowPositions"] is not JsonObject positions)
|
||||
{
|
||||
positions = new JsonObject();
|
||||
root["windowPositions"] = positions;
|
||||
}
|
||||
if (positions[toonKey] is not JsonObject toon)
|
||||
{
|
||||
toon = new JsonObject();
|
||||
positions[toonKey] = toon;
|
||||
}
|
||||
toon[windowName] = new JsonObject
|
||||
{
|
||||
["x"] = position.X,
|
||||
["y"] = position.Y,
|
||||
};
|
||||
root["version"] = CurrentSchemaVersion;
|
||||
File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
private static SortedDictionary<string, object> BuildChatObject(ChatSettings c)
|
||||
=> new(StringComparer.Ordinal)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -16,6 +16,17 @@ public class DatWidgetFactoryTests
|
|||
Assert.IsType<UiMeter>(e);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailRadarClass_MakesUiRadar()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(
|
||||
new ElementInfo { Type = UiRadar.RetailClassId, Width = 120, Height = 140 },
|
||||
NoTex,
|
||||
null);
|
||||
|
||||
Assert.IsType<UiRadar>(e);
|
||||
}
|
||||
|
||||
// ── Test 2: Unknown type → UiDatElement fallback ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -57,6 +57,14 @@ public static class FixtureLoader
|
|||
public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
|
||||
=> LoadInfos("chat_21000006.json");
|
||||
|
||||
/// <summary>Builds the committed retail radar LayoutDesc 0x21000074 fixture.</summary>
|
||||
public static ImportedLayout LoadRadar()
|
||||
=> LayoutImporter.Build(LoadRadarInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
/// <summary>Returns the resolved ElementInfo tree for retail radar LayoutDesc 0x21000074.</summary>
|
||||
public static AcDream.App.UI.Layout.ElementInfo LoadRadarInfos()
|
||||
=> LoadInfos("radar_21000074.json");
|
||||
|
||||
// ── Shared loader ────────────────────────────────────────────────────────
|
||||
|
||||
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
|
||||
|
|
|
|||
159
tests/AcDream.App.Tests/UI/Layout/RadarControllerTests.cs
Normal file
159
tests/AcDream.App.Tests/UI/Layout/RadarControllerTests.cs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Ui;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class RadarControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Bind_RealFixture_ReusesImportedCoordinateTextAndDatBackground()
|
||||
{
|
||||
var layout = FixtureLoader.LoadRadar();
|
||||
_ = RadarController.Bind(
|
||||
layout,
|
||||
() => new UiRadarSnapshot(0f, Array.Empty<UiRadarBlip>(), "42.1N,33.6E"));
|
||||
|
||||
var coordinates = Assert.IsType<UiText>(
|
||||
layout.FindElement(RadarController.CoordinateContainerId));
|
||||
Assert.Equal(0x06004CC0u, coordinates.BackgroundSprite);
|
||||
Assert.Empty(coordinates.Children);
|
||||
Assert.True(coordinates.ClickThrough);
|
||||
Assert.False(coordinates.AcceptsFocus);
|
||||
Assert.Equal("42.1N,33.6E", Assert.Single(coordinates.LinesProvider()).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_UsesImportedChildrenForCompassCoordinatesAndLockChrome()
|
||||
{
|
||||
var layout = BuildRadarLayout();
|
||||
var state = new UiRadarSnapshot(
|
||||
0f,
|
||||
Array.Empty<UiRadarBlip>(),
|
||||
"42.1N,33.6E",
|
||||
UiLocked: false);
|
||||
bool? requestedLock = null;
|
||||
|
||||
_ = RadarController.Bind(
|
||||
layout,
|
||||
() => state,
|
||||
setUiLocked: value => requestedLock = value);
|
||||
|
||||
var radar = Assert.IsType<UiRadar>(layout.Root);
|
||||
Assert.Equal(new Vector2(60f, 60f), radar.Center);
|
||||
Assert.True(radar.Draggable);
|
||||
|
||||
var coordinateContainer = Assert.IsType<UiDatElement>(
|
||||
layout.FindElement(RadarController.CoordinateContainerId));
|
||||
Assert.True(coordinateContainer.Visible);
|
||||
var coordinateText = Assert.IsType<UiText>(Assert.Single(coordinateContainer.Children));
|
||||
Assert.Equal("42.1N,33.6E", Assert.Single(coordinateText.LinesProvider()).Text);
|
||||
|
||||
var north = layout.FindElement(RadarController.NorthTokenId)!;
|
||||
Assert.Equal(55f, north.Left);
|
||||
Assert.Equal(1f, north.Top);
|
||||
|
||||
var lockButton = Assert.IsType<UiButton>(layout.FindElement(RadarController.LockButtonId));
|
||||
Assert.Equal("UnlockedUI", lockButton.ActiveState);
|
||||
var click = new UiEvent(lockButton.EventId, lockButton, UiEventType.Click);
|
||||
Assert.True(lockButton.OnEvent(in click));
|
||||
Assert.True(requestedLock);
|
||||
|
||||
state = state with
|
||||
{
|
||||
PlayerHeadingDegrees = 90f,
|
||||
CoordinatesText = null,
|
||||
UiLocked = true,
|
||||
};
|
||||
radar.Refresh();
|
||||
|
||||
Assert.False(radar.Draggable);
|
||||
Assert.False(coordinateContainer.Visible);
|
||||
Assert.False(coordinateText.Visible);
|
||||
Assert.Equal("LockedUI", lockButton.ActiveState);
|
||||
|
||||
var drag = layout.FindElement(RadarController.DragButtonId)!;
|
||||
Assert.False(drag.Visible);
|
||||
Assert.False(drag.ClickThrough);
|
||||
|
||||
float northMagnitude = Vector2.Distance(new Vector2(60f, 5.5f), radar.Center);
|
||||
var expectedNorth = RetailRadar.GetCompassTokenTopLeft(
|
||||
90f,
|
||||
RadarCompassPoint.North,
|
||||
radar.Center,
|
||||
northMagnitude,
|
||||
new Vector2(north.Width, north.Height));
|
||||
Assert.Equal(expectedNorth.X, north.Left);
|
||||
Assert.Equal(expectedNorth.Y, north.Top);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_LeavesStaticDatChildrenInRetainedTree()
|
||||
{
|
||||
var layout = BuildRadarLayout();
|
||||
_ = RadarController.Bind(layout, () => UiRadarSnapshot.Empty);
|
||||
|
||||
Assert.Equal(8, layout.Root.Children.Count);
|
||||
Assert.IsType<UiDatElement>(layout.FindElement(RadarController.RadarDiscId));
|
||||
Assert.IsType<UiButton>(layout.FindElement(RadarController.LockButtonId));
|
||||
Assert.IsType<UiDatElement>(layout.FindElement(RadarController.DragButtonId));
|
||||
Assert.False(layout.FindElement(RadarController.CoordinateContainerId)!.Visible);
|
||||
}
|
||||
|
||||
private static ImportedLayout BuildRadarLayout()
|
||||
{
|
||||
var root = new ElementInfo
|
||||
{
|
||||
Id = RadarController.RootId,
|
||||
Type = UiRadar.RetailClassId,
|
||||
Width = 120,
|
||||
Height = 140,
|
||||
};
|
||||
|
||||
root.Children.Add(Sprite(RadarController.CoordinateContainerId, 0, 120, 120, 18, 0x06004CC0u));
|
||||
root.Children.Add(Sprite(RadarController.RadarDiscId, 0, 0, 120, 120, 0x06004CC1u));
|
||||
root.Children.Add(Sprite(RadarController.NorthTokenId, 55, 1, 10, 9, 0x060011FBu));
|
||||
root.Children.Add(Sprite(RadarController.EastTokenId, 110, 55, 10, 9, 0x06001938u));
|
||||
root.Children.Add(Sprite(RadarController.SouthTokenId, 55, 110, 10, 9, 0x0600193Au));
|
||||
root.Children.Add(Sprite(RadarController.WestTokenId, 0, 55, 10, 9, 0x0600193Cu));
|
||||
|
||||
var lockButton = new ElementInfo
|
||||
{
|
||||
Id = RadarController.LockButtonId,
|
||||
Type = 1,
|
||||
X = 6,
|
||||
Y = 6,
|
||||
Width = 27,
|
||||
Height = 27,
|
||||
};
|
||||
lockButton.StateMedia["LockedUI"] = (0x060074B7u, 3);
|
||||
lockButton.StateMedia["UnlockedUI"] = (0x060074B8u, 3);
|
||||
root.Children.Add(lockButton);
|
||||
|
||||
root.Children.Add(Sprite(RadarController.DragButtonId, 87, 6, 27, 27, 0x060074C9u, type: 2));
|
||||
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
|
||||
}
|
||||
|
||||
private static ElementInfo Sprite(
|
||||
uint id,
|
||||
float x,
|
||||
float y,
|
||||
float width,
|
||||
float height,
|
||||
uint sprite,
|
||||
uint type = 3)
|
||||
{
|
||||
var info = new ElementInfo
|
||||
{
|
||||
Id = id,
|
||||
Type = type,
|
||||
X = x,
|
||||
Y = y,
|
||||
Width = width,
|
||||
Height = height,
|
||||
};
|
||||
info.StateMedia[""] = (sprite, 1);
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class RadarLayoutConformanceTests
|
||||
{
|
||||
[Fact]
|
||||
public void RadarFixture_HasRetailRootAndAllStaticAssets()
|
||||
{
|
||||
var root = FixtureLoader.LoadRadarInfos();
|
||||
|
||||
Assert.Equal(RadarController.RootId, root.Id);
|
||||
Assert.Equal(UiRadar.RetailClassId, root.Type);
|
||||
Assert.Equal(120f, root.Width);
|
||||
Assert.Equal(140f, root.Height);
|
||||
Assert.Equal(8, root.Children.Count);
|
||||
|
||||
AssertElement(root, RadarController.CoordinateContainerId, 0, 120, 120, 18, 0x06004CC0u);
|
||||
AssertElement(root, RadarController.RadarDiscId, 0, 0, 120, 120, 0x06004CC1u);
|
||||
AssertElement(root, RadarController.NorthTokenId, 55, 1, 10, 9, 0x060011FBu);
|
||||
AssertElement(root, RadarController.EastTokenId, 110, 55, 10, 9, 0x06001938u);
|
||||
AssertElement(root, RadarController.SouthTokenId, 55, 110, 10, 9, 0x0600193Au);
|
||||
AssertElement(root, RadarController.WestTokenId, 0, 55, 10, 9, 0x0600193Cu);
|
||||
AssertElement(root, RadarController.DragButtonId, 87, 6, 27, 27, 0x060074C9u);
|
||||
|
||||
var lockButton = Find(root, RadarController.LockButtonId)!;
|
||||
Assert.Equal(1u, lockButton.Type);
|
||||
Assert.Equal(0x060074B7u, lockButton.StateMedia["LockedUI"].File);
|
||||
Assert.Equal(0x060074B8u, lockButton.StateMedia["UnlockedUI"].File);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadarFixture_BuildsDedicatedWidgetAndPreservesStaticChildren()
|
||||
{
|
||||
var layout = FixtureLoader.LoadRadar();
|
||||
|
||||
Assert.IsType<UiRadar>(layout.Root);
|
||||
Assert.Equal(8, layout.Root.Children.Count);
|
||||
var coordinates = Assert.IsType<UiText>(
|
||||
layout.FindElement(RadarController.CoordinateContainerId));
|
||||
Assert.Equal(0x06004CC0u, coordinates.BackgroundSprite);
|
||||
Assert.IsType<UiDatElement>(layout.FindElement(RadarController.RadarDiscId));
|
||||
Assert.IsType<UiButton>(layout.FindElement(RadarController.LockButtonId));
|
||||
}
|
||||
|
||||
private static void AssertElement(
|
||||
ElementInfo root,
|
||||
uint id,
|
||||
float x,
|
||||
float y,
|
||||
float width,
|
||||
float height,
|
||||
uint sprite)
|
||||
{
|
||||
var element = Find(root, id)!;
|
||||
Assert.Equal(x, element.X);
|
||||
Assert.Equal(y, element.Y);
|
||||
Assert.Equal(width, element.Width);
|
||||
Assert.Equal(height, element.Height);
|
||||
Assert.Equal(sprite, element.StateMedia[""].File);
|
||||
}
|
||||
|
||||
private static ElementInfo? Find(ElementInfo node, uint id)
|
||||
{
|
||||
if (node.Id == id)
|
||||
return node;
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
var found = Find(child, id);
|
||||
if (found is not null)
|
||||
return found;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using AcDream.App.UI.Layout;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>One-off generator for the committed retail radar LayoutDesc fixture.</summary>
|
||||
public sealed class RadarLayoutFixtureGenerator
|
||||
{
|
||||
[Fact(Skip = "manual: regenerates the committed radar fixture; needs the real dats (ACDREAM_DAT_DIR)")]
|
||||
public void GenerateRadarFixture()
|
||||
{
|
||||
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var info = LayoutImporter.ImportInfos(dats, RadarController.LayoutId);
|
||||
Assert.NotNull(info);
|
||||
|
||||
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
|
||||
{
|
||||
IncludeFields = true,
|
||||
WriteIndented = true,
|
||||
});
|
||||
File.WriteAllText(FixturePath(), json);
|
||||
}
|
||||
|
||||
private static string FixturePath([CallerFilePath] string thisFile = "")
|
||||
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "radar_21000074.json");
|
||||
}
|
||||
146
tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs
Normal file
146
tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Ui;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class RadarSnapshotProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildSnapshot_JoinsWorldAndWeenieTables_UsingCoreRetailMath()
|
||||
{
|
||||
const uint player = 1u;
|
||||
const uint monster = 2u;
|
||||
var objects = new ClientObjectTable();
|
||||
objects.Ingest(Weenie(player, "Player", ItemType.Creature));
|
||||
objects.Ingest(Weenie(monster, "Drudge", ItemType.Creature) with
|
||||
{
|
||||
RadarBehavior = (byte)RadarBehavior.ShowAlways,
|
||||
});
|
||||
|
||||
var entities = new Dictionary<uint, WorldEntity>
|
||||
{
|
||||
[player] = Entity(player, Vector3.Zero, Quaternion.Identity),
|
||||
[monster] = Entity(monster, new Vector3(0f, 15f, 6f), Quaternion.Identity),
|
||||
};
|
||||
var spawns = new Dictionary<uint, WorldSession.EntitySpawn>
|
||||
{
|
||||
[player] = Spawn(player) with { ObjectDescriptionFlags = 0x00000008u }, // BF_PLAYER
|
||||
[monster] = Spawn(monster) with { ObjectDescriptionFlags = 0x00000010u }, // BF_ATTACKABLE
|
||||
};
|
||||
|
||||
uint? selected = monster;
|
||||
var provider = new RadarSnapshotProvider(
|
||||
objects, entities, spawns,
|
||||
playerGuid: () => player,
|
||||
playerYawRadians: () => MathF.PI / 2f, // retail heading 0 degrees
|
||||
playerCellId: () => 0xA9B40001u,
|
||||
selectedGuid: () => selected,
|
||||
coordinatesOnRadar: () => true,
|
||||
uiLocked: () => false);
|
||||
|
||||
var snapshot = provider.BuildSnapshot();
|
||||
|
||||
Assert.Equal(0f, snapshot.PlayerHeadingDegrees, 3);
|
||||
Assert.Equal(RadarCoordinates.TryFromCell(0xA9B40001u, out var coords)
|
||||
? coords.CombinedText : null, snapshot.CoordinatesText);
|
||||
var blip = Assert.Single(snapshot.Blips);
|
||||
Assert.Equal(monster, blip.ObjectId);
|
||||
Assert.Equal("Drudge", blip.Name);
|
||||
Assert.Equal(60f, blip.PixelX);
|
||||
Assert.Equal(50f, blip.PixelY); // 15 m * 50 px / 75 m
|
||||
Assert.Equal(RadarBlipShape.Plus, blip.Shape);
|
||||
Assert.True(blip.Selected);
|
||||
Assert.Equal(RadarBlipColors.Gold.Red * 0.65f, blip.Color.X, 5);
|
||||
Assert.Equal(RadarBlipColors.Gold.Green * 0.65f, blip.Color.Y, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSnapshot_RejectsShowNever_AndHidesIndoorCoordinates()
|
||||
{
|
||||
const uint player = 10u;
|
||||
const uint hidden = 11u;
|
||||
var objects = new ClientObjectTable();
|
||||
objects.Ingest(Weenie(player, "Player", ItemType.Creature));
|
||||
objects.Ingest(Weenie(hidden, "Hidden", ItemType.Portal) with
|
||||
{
|
||||
RadarBehavior = (byte)RadarBehavior.ShowNever,
|
||||
});
|
||||
var entities = new Dictionary<uint, WorldEntity>
|
||||
{
|
||||
[player] = Entity(player, Vector3.Zero, Quaternion.Identity),
|
||||
[hidden] = Entity(hidden, new Vector3(2f, 2f, 0f), Quaternion.Identity),
|
||||
};
|
||||
var spawns = new Dictionary<uint, WorldSession.EntitySpawn>
|
||||
{
|
||||
[player] = Spawn(player),
|
||||
[hidden] = Spawn(hidden),
|
||||
};
|
||||
var provider = new RadarSnapshotProvider(
|
||||
objects, entities, spawns,
|
||||
playerGuid: () => player,
|
||||
playerYawRadians: () => 0f,
|
||||
playerCellId: () => 0xA9B40100u, // EnvCell: no landscape coordinate
|
||||
selectedGuid: () => null,
|
||||
coordinatesOnRadar: () => true,
|
||||
uiLocked: () => true);
|
||||
|
||||
var snapshot = provider.BuildSnapshot();
|
||||
|
||||
Assert.Empty(snapshot.Blips);
|
||||
Assert.Null(snapshot.CoordinatesText);
|
||||
Assert.True(snapshot.UiLocked);
|
||||
}
|
||||
|
||||
private static WorldEntity Entity(uint guid, Vector3 position, Quaternion rotation) => new()
|
||||
{
|
||||
Id = guid,
|
||||
ServerGuid = guid,
|
||||
SourceGfxObjOrSetupId = 0u,
|
||||
Position = position,
|
||||
Rotation = rotation,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid) => new(
|
||||
Guid: guid,
|
||||
Position: null,
|
||||
SetupTableId: null,
|
||||
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
|
||||
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
|
||||
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
BasePaletteId: null,
|
||||
ObjScale: null,
|
||||
Name: null,
|
||||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null);
|
||||
|
||||
private static WeenieData Weenie(uint guid, string name, ItemType type) => new(
|
||||
Guid: guid,
|
||||
Name: name,
|
||||
Type: type,
|
||||
WeenieClassId: 1u,
|
||||
IconId: 0u,
|
||||
IconOverlayId: 0u,
|
||||
IconUnderlayId: 0u,
|
||||
Effects: 0u,
|
||||
Value: null,
|
||||
StackSize: null,
|
||||
StackSizeMax: null,
|
||||
Burden: null,
|
||||
ContainerId: null,
|
||||
WielderId: null,
|
||||
ValidLocations: null,
|
||||
CurrentWieldedLocation: null,
|
||||
Priority: null,
|
||||
ItemsCapacity: null,
|
||||
ContainersCapacity: null,
|
||||
Structure: null,
|
||||
MaxStructure: null,
|
||||
Workmanship: null);
|
||||
}
|
||||
250
tests/AcDream.App.Tests/UI/Layout/fixtures/radar_21000074.json
Normal file
250
tests/AcDream.App.Tests/UI/Layout/fixtures/radar_21000074.json
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
{
|
||||
"Id": 268437203,
|
||||
"Type": 268435472,
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Width": 120,
|
||||
"Height": 140,
|
||||
"Left": 1,
|
||||
"Top": 1,
|
||||
"Right": 1,
|
||||
"Bottom": 1,
|
||||
"ReadOrder": 0,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 0,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {},
|
||||
"StateCursors": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": [
|
||||
{
|
||||
"Id": 268435518,
|
||||
"Type": 12,
|
||||
"X": 0,
|
||||
"Y": 120,
|
||||
"Width": 120,
|
||||
"Height": 18,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 1,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 1073741825,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100682944,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"StateCursors": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268435519,
|
||||
"Type": 3,
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Width": 120,
|
||||
"Height": 120,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 2,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 0,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100682945,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"StateCursors": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268435520,
|
||||
"Type": 3,
|
||||
"X": 55,
|
||||
"Y": 1,
|
||||
"Width": 10,
|
||||
"Height": 9,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 5,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 0,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100667899,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"StateCursors": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268435521,
|
||||
"Type": 3,
|
||||
"X": 110,
|
||||
"Y": 55,
|
||||
"Width": 10,
|
||||
"Height": 9,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 6,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 0,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100669752,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"StateCursors": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268435522,
|
||||
"Type": 3,
|
||||
"X": 55,
|
||||
"Y": 110,
|
||||
"Width": 10,
|
||||
"Height": 9,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 7,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 0,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100669754,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"StateCursors": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268437155,
|
||||
"Type": 2,
|
||||
"X": 87,
|
||||
"Y": 6,
|
||||
"Width": 27,
|
||||
"Height": 27,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 4,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 0,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100693193,
|
||||
"Item2": 3
|
||||
}
|
||||
},
|
||||
"StateCursors": {
|
||||
"": {
|
||||
"File": 100688153,
|
||||
"HotspotX": 16,
|
||||
"HotspotY": 16,
|
||||
"IsValid": true
|
||||
}
|
||||
},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268437017,
|
||||
"Type": 1,
|
||||
"X": 6,
|
||||
"Y": 6,
|
||||
"Width": 27,
|
||||
"Height": 27,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 3,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 0,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {
|
||||
"LockedUI": {
|
||||
"Item1": 100693175,
|
||||
"Item2": 3
|
||||
},
|
||||
"UnlockedUI": {
|
||||
"Item1": 100693176,
|
||||
"Item2": 3
|
||||
}
|
||||
},
|
||||
"StateCursors": {},
|
||||
"DefaultStateName": "Normal",
|
||||
"Children": []
|
||||
},
|
||||
{
|
||||
"Id": 268435523,
|
||||
"Type": 3,
|
||||
"X": 0,
|
||||
"Y": 55,
|
||||
"Width": 10,
|
||||
"Height": 9,
|
||||
"Left": 0,
|
||||
"Top": 0,
|
||||
"Right": 0,
|
||||
"Bottom": 0,
|
||||
"ReadOrder": 8,
|
||||
"ZLevel": 0,
|
||||
"FontDid": 0,
|
||||
"HJustify": 1,
|
||||
"VJustify": 1,
|
||||
"FontColor": null,
|
||||
"StateMedia": {
|
||||
"": {
|
||||
"Item1": 100669756,
|
||||
"Item2": 1
|
||||
}
|
||||
},
|
||||
"StateCursors": {},
|
||||
"DefaultStateName": "",
|
||||
"Children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
91
tests/AcDream.App.Tests/UI/UiRadarTests.cs
Normal file
91
tests/AcDream.App.Tests/UI/UiRadarTests.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Ui;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class UiRadarTests
|
||||
{
|
||||
[Fact]
|
||||
public void HoverAndClick_SelectNearestProjectedBlip()
|
||||
{
|
||||
uint? selected = null;
|
||||
uint? hovered = null;
|
||||
var radar = new UiRadar
|
||||
{
|
||||
Width = 120,
|
||||
Height = 140,
|
||||
SelectObject = guid => selected = guid,
|
||||
HoveredObjectChanged = guid => hovered = guid,
|
||||
};
|
||||
radar.ApplySnapshot(new UiRadarSnapshot(
|
||||
0f,
|
||||
[
|
||||
new UiRadarBlip(0x10u, "Far", 31f, 30f, Vector4.One, RadarBlipShape.Plus),
|
||||
new UiRadarBlip(0x20u, "Near", 28f, 30f, Vector4.One, RadarBlipShape.Circle),
|
||||
],
|
||||
"1.0N,1.0E"));
|
||||
|
||||
var root = new UiRoot { Width = 200, Height = 200 };
|
||||
root.AddChild(radar);
|
||||
|
||||
root.OnMouseMove(27, 30);
|
||||
Assert.Equal(0x20u, radar.HoveredObjectId);
|
||||
Assert.Equal(0x20u, hovered);
|
||||
Assert.Equal("Near", radar.GetTooltipText());
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 27, 30);
|
||||
root.OnMouseUp(UiMouseButton.Left, 27, 30);
|
||||
Assert.Equal(0x20u, selected);
|
||||
|
||||
root.OnMouseMove(100, 100);
|
||||
Assert.Null(radar.HoveredObjectId);
|
||||
Assert.Null(hovered);
|
||||
Assert.Null(radar.GetTooltipText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlankBlips_DisablesHoverButKeepsSnapshot()
|
||||
{
|
||||
var radar = new UiRadar { Width = 120, Height = 140 };
|
||||
radar.ApplySnapshot(new UiRadarSnapshot(
|
||||
0f,
|
||||
[new UiRadarBlip(0x10u, "Hidden", 30f, 30f, Vector4.One, RadarBlipShape.Plus)],
|
||||
null,
|
||||
BlankBlips: true));
|
||||
|
||||
var root = new UiRoot { Width = 200, Height = 200 };
|
||||
root.AddChild(radar);
|
||||
root.OnMouseMove(30, 30);
|
||||
|
||||
Assert.Single(radar.Snapshot.Blips);
|
||||
Assert.Null(radar.HoveredObjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_PollsProviderAtRetailTwentyFiveMillisecondCadence()
|
||||
{
|
||||
int calls = 0;
|
||||
var radar = new UiRadar
|
||||
{
|
||||
Width = 120,
|
||||
Height = 140,
|
||||
SnapshotProvider = () =>
|
||||
{
|
||||
calls++;
|
||||
return UiRadarSnapshot.Empty;
|
||||
},
|
||||
};
|
||||
var root = new UiRoot { Width = 200, Height = 200 };
|
||||
root.AddChild(radar);
|
||||
|
||||
root.Tick(0.024, 24);
|
||||
Assert.Equal(0, calls);
|
||||
|
||||
root.Tick(0.0011, 25);
|
||||
Assert.Equal(1, calls);
|
||||
|
||||
root.Tick(0.100, 125);
|
||||
Assert.Equal(2, calls); // retail schedules one fresh update; it does not replay four stale ticks
|
||||
}
|
||||
}
|
||||
|
|
@ -187,6 +187,59 @@ public class UiRootInputTests
|
|||
Assert.Equal(80f, panel.Top);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowDrag_ConstrainedPanel_StaysFullyInsideParent()
|
||||
{
|
||||
var root = new UiRoot { Width = 200, Height = 150 };
|
||||
var panel = new UiPanel
|
||||
{
|
||||
Left = 10,
|
||||
Top = 10,
|
||||
Width = 120,
|
||||
Height = 100,
|
||||
Draggable = true,
|
||||
ConstrainDragToParent = true,
|
||||
};
|
||||
root.AddChild(panel);
|
||||
root.RegisterWindow("radar", panel);
|
||||
(string name, UiElement window)? moved = null;
|
||||
root.WindowMoved += (name, window) => moved = (name, window);
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 20, 20);
|
||||
root.OnMouseMove(500, 500);
|
||||
Assert.Equal(80f, panel.Left);
|
||||
Assert.Equal(50f, panel.Top);
|
||||
|
||||
root.OnMouseMove(-500, -500);
|
||||
Assert.Equal(0f, panel.Left);
|
||||
Assert.Equal(0f, panel.Top);
|
||||
root.OnMouseUp(UiMouseButton.Left, -500, -500);
|
||||
Assert.Equal("radar", moved?.name);
|
||||
Assert.Same(panel, moved?.window);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiLocked_BlocksWindowMoveWithoutDisablingWindow()
|
||||
{
|
||||
var root = new UiRoot { Width = 200, Height = 150, UiLocked = true };
|
||||
var panel = new UiPanel
|
||||
{
|
||||
Left = 10,
|
||||
Top = 10,
|
||||
Width = 100,
|
||||
Height = 60,
|
||||
Draggable = true,
|
||||
};
|
||||
root.AddChild(panel);
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 20, 20);
|
||||
root.OnMouseMove(100, 100);
|
||||
|
||||
Assert.Equal(10f, panel.Left);
|
||||
Assert.Equal(10f, panel.Top);
|
||||
Assert.True(panel.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonDraggablePanel_DoesNotMoveOnDrag()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -172,6 +172,70 @@ public sealed class CreateObjectTests
|
|||
Assert.Equal((uint)ItemType.Creature, parsed!.Value.TargetType);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Retail radar: PublicWeenieDesc carries two independently gated bytes.
|
||||
// Absence is distinct from an explicitly transmitted zero because zero is
|
||||
// the enum's undefined/default value, not proof the field was omitted.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TryParse_NoRadarFlags_LeavesRadarFieldsNull()
|
||||
{
|
||||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||
guid: 0x5000000Au,
|
||||
name: "Unspecified Radar Object",
|
||||
itemType: (uint)ItemType.Misc);
|
||||
|
||||
var parsed = CreateObject.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Null(parsed.Value.RadarBlipColor);
|
||||
Assert.Null(parsed.Value.RadarBehavior);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_RadarFlags_CapturesBothBytesAndContinuesTailWalk()
|
||||
{
|
||||
const uint radarBlipColorFlag = 0x00100000u;
|
||||
const uint radarBehaviorFlag = 0x00800000u;
|
||||
const uint workmanshipFlag = 0x01000000u;
|
||||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||
guid: 0x5000000Bu,
|
||||
name: "Radar Portal",
|
||||
itemType: (uint)ItemType.Portal,
|
||||
weenieFlags: radarBlipColorFlag | radarBehaviorFlag | workmanshipFlag,
|
||||
radarBlipColor: 4,
|
||||
radarBehavior: 4,
|
||||
workmanship: 6.25f);
|
||||
|
||||
var parsed = CreateObject.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal((byte)4, parsed.Value.RadarBlipColor);
|
||||
Assert.Equal((byte)4, parsed.Value.RadarBehavior);
|
||||
Assert.Equal(6.25f, parsed.Value.Workmanship);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_RadarFlagsWithZero_PreservesExplicitDefaultValues()
|
||||
{
|
||||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||
guid: 0x5000000Cu,
|
||||
name: "Explicit Radar Defaults",
|
||||
itemType: (uint)ItemType.Misc,
|
||||
weenieFlags: 0x00100000u | 0x00800000u,
|
||||
radarBlipColor: 0,
|
||||
radarBehavior: 0);
|
||||
|
||||
var parsed = CreateObject.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.True(parsed.Value.RadarBlipColor.HasValue);
|
||||
Assert.Equal((byte)0, parsed.Value.RadarBlipColor.Value);
|
||||
Assert.True(parsed.Value.RadarBehavior.HasValue);
|
||||
Assert.Equal((byte)0, parsed.Value.RadarBehavior.Value);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// D.5.1 (2026-06-16): IconId was discarded at cs:516 — surface it so the
|
||||
// action bar / equipment UI can read icon dat ids from spawn messages.
|
||||
|
|
@ -500,6 +564,8 @@ public sealed class CreateObjectTests
|
|||
uint? currentWieldedLocation = null,
|
||||
uint? priority = null,
|
||||
float? workmanship = null,
|
||||
byte? radarBlipColor = null,
|
||||
byte? radarBehavior = null,
|
||||
ushort movementSeq = 0)
|
||||
{
|
||||
var bytes = new List<byte>();
|
||||
|
|
@ -562,8 +628,8 @@ public sealed class CreateObjectTests
|
|||
if ((weenieFlags & 0x00010000u) != 0) WriteU32(bytes, validLocations ?? 0); // ValidLocations u32
|
||||
if ((weenieFlags & 0x00020000u) != 0) WriteU32(bytes, currentWieldedLocation ?? 0); // CurrentlyWieldedLocation u32
|
||||
if ((weenieFlags & 0x00040000u) != 0) WriteU32(bytes, priority ?? 0); // Priority u32
|
||||
if ((weenieFlags & 0x00100000u) != 0) bytes.Add(0); // RadarBlipColor u8
|
||||
if ((weenieFlags & 0x00800000u) != 0) bytes.Add(0); // RadarBehavior u8
|
||||
if ((weenieFlags & 0x00100000u) != 0) bytes.Add(radarBlipColor ?? 0); // RadarBlipColor u8
|
||||
if ((weenieFlags & 0x00800000u) != 0) bytes.Add(radarBehavior ?? 0); // RadarBehavior u8
|
||||
if ((weenieFlags & 0x08000000u) != 0) WriteU16(bytes, 0); // PScript u16
|
||||
if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32
|
||||
{
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ public sealed class ObjectTableWiringTests
|
|||
Workmanship = 4.5f,
|
||||
Useability = 0x000A0008u,
|
||||
TargetType = (uint)ItemType.Creature,
|
||||
RadarBlipColor = 4,
|
||||
RadarBehavior = 3,
|
||||
};
|
||||
|
||||
var d = ObjectTableWiring.ToWeenieData(spawn);
|
||||
|
|
@ -100,6 +102,8 @@ public sealed class ObjectTableWiringTests
|
|||
Assert.Equal(4.5f, d.Workmanship);
|
||||
Assert.Equal(0x000A0008u, d.Useability);
|
||||
Assert.Equal((uint)ItemType.Creature, d.TargetType);
|
||||
Assert.Equal((byte)4, d.RadarBlipColor);
|
||||
Assert.Equal((byte)3, d.RadarBehavior);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
44
tests/AcDream.Core.Net.Tests/WorldSessionRadarTests.cs
Normal file
44
tests/AcDream.Core.Net.Tests/WorldSessionRadarTests.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Core.Net.Tests;
|
||||
|
||||
public sealed class WorldSessionRadarTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToEntitySpawn_PreservesRadarBytes()
|
||||
{
|
||||
var parsed = MinimalParsed() with
|
||||
{
|
||||
RadarBlipColor = 7,
|
||||
RadarBehavior = 3,
|
||||
};
|
||||
|
||||
var spawn = WorldSession.ToEntitySpawn(parsed);
|
||||
|
||||
Assert.Equal((byte)7, spawn.RadarBlipColor);
|
||||
Assert.Equal((byte)3, spawn.RadarBehavior);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToEntitySpawn_PreservesMissingRadarFields()
|
||||
{
|
||||
var spawn = WorldSession.ToEntitySpawn(MinimalParsed());
|
||||
|
||||
Assert.Null(spawn.RadarBlipColor);
|
||||
Assert.Null(spawn.RadarBehavior);
|
||||
}
|
||||
|
||||
private static CreateObject.Parsed MinimalParsed() => new(
|
||||
Guid: 0x50000001u,
|
||||
Position: null,
|
||||
SetupTableId: null,
|
||||
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
|
||||
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
|
||||
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
BasePaletteId: null,
|
||||
ObjScale: null,
|
||||
Name: "Radar Test",
|
||||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null);
|
||||
}
|
||||
|
|
@ -277,6 +277,28 @@ public sealed class ClientObjectTableTests
|
|||
Assert.Equal((uint)ItemType.Creature, item.TargetType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingest_RadarMetadata_PatchesOnlyPresentWireFields()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.Ingest(FullWeenie(0x500000B8u) with
|
||||
{
|
||||
RadarBlipColor = 4,
|
||||
RadarBehavior = 3,
|
||||
});
|
||||
|
||||
table.Ingest(FullWeenie(0x500000B8u) with
|
||||
{
|
||||
RadarBlipColor = null,
|
||||
RadarBehavior = 4,
|
||||
});
|
||||
|
||||
var item = table.Get(0x500000B8u);
|
||||
Assert.NotNull(item);
|
||||
Assert.Equal((byte)4, item!.RadarBlipColor);
|
||||
Assert.Equal((byte)4, item.RadarBehavior);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordMembership_CreatesEntry_AndSetsEquip()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,83 +1,132 @@
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Ui;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Ui;
|
||||
|
||||
public sealed class RadarBlipColorsTests
|
||||
{
|
||||
// PWD bit constants per docs/research/named-retail/acclient.h:6431-6463
|
||||
private const uint BF_PLAYER = 0x8u;
|
||||
private const uint BF_PLAYER_KILLER = 0x20u;
|
||||
private const uint BF_VENDOR = 0x200u;
|
||||
private const uint BF_PORTAL = 0x40000u;
|
||||
private const uint BF_PKLITE_PKSTATUS = 0x2000000u;
|
||||
// PublicWeenieDesc::BitfieldIndex, acclient.h:6431.
|
||||
private const uint BfPlayer = 0x00000008u;
|
||||
private const uint BfAttackable = 0x00000010u;
|
||||
private const uint BfPlayerKiller = 0x00000020u;
|
||||
private const uint BfHiddenAdmin = 0x00000040u;
|
||||
private const uint BfVendor = 0x00000200u;
|
||||
private const uint BfPortal = 0x00040000u;
|
||||
private const uint BfAdmin = 0x00100000u;
|
||||
private const uint BfFreePk = 0x00200000u;
|
||||
private const uint BfPkLite = 0x02000000u;
|
||||
|
||||
[Fact]
|
||||
public void Item_NoFlags_ReturnsItemColor()
|
||||
public void Palette_UsesExactRetailFloatConstants()
|
||||
{
|
||||
// SpellComponents is itemType=0x1000 (e.g. a Taper) — not a creature.
|
||||
var result = RadarBlipColors.For(itemType: (uint)ItemType.SpellComponents, pwdBitfield: 0);
|
||||
Assert.Equal(RadarBlipColors.Item, result);
|
||||
Assert.Equal(new RadarBlipColors.Rgba(0.25f, 0.660000026f, 1f, 1f), RadarBlipColors.Blue);
|
||||
Assert.Equal(new RadarBlipColors.Rgba(1f, 0.670000017f, 0f, 1f), RadarBlipColors.Gold);
|
||||
Assert.Equal(new RadarBlipColors.Rgba(1f, 1f, 0.5f, 1f), RadarBlipColors.Yellow);
|
||||
Assert.Equal(new RadarBlipColors.Rgba(1f, 0.25f, 0.389999986f, 1f), RadarBlipColors.Red);
|
||||
Assert.Equal(new RadarBlipColors.Rgba(0.75f, 0.389999986f, 1f, 1f), RadarBlipColors.Purple);
|
||||
Assert.Equal(new RadarBlipColors.Rgba(1f, 0.660000026f, 0.75f, 1f), RadarBlipColors.Pink);
|
||||
Assert.Equal(new RadarBlipColors.Rgba(0f, 0.5f, 0.25f, 1f), RadarBlipColors.Green);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Misc_NoFlags_ReturnsItemColor()
|
||||
public void Override_MapsValuesOneThroughTenInRetailOrder()
|
||||
{
|
||||
var result = RadarBlipColors.For((uint)ItemType.Misc, pwdBitfield: 0);
|
||||
Assert.Equal(RadarBlipColors.Item, result);
|
||||
RadarBlipColors.Rgba[] expected =
|
||||
[
|
||||
RadarBlipColors.Blue,
|
||||
RadarBlipColors.Gold,
|
||||
RadarBlipColors.White,
|
||||
RadarBlipColors.Purple,
|
||||
RadarBlipColors.Red,
|
||||
RadarBlipColors.Pink,
|
||||
RadarBlipColors.Green,
|
||||
RadarBlipColors.Yellow,
|
||||
RadarBlipColors.Cyan,
|
||||
RadarBlipColors.BrightGreen,
|
||||
];
|
||||
|
||||
for (byte value = 1; value <= expected.Length; value++)
|
||||
Assert.Equal(expected[value - 1], RadarBlipColors.ForOverride(value));
|
||||
|
||||
Assert.Equal(RadarBlipColors.Default, RadarBlipColors.ForOverride(11));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Creature_NotPlayer_ReturnsCreatureColor()
|
||||
public void WireOverride_WinsOverObjectClassification()
|
||||
{
|
||||
// NPC: itemType has Creature bit, no Player flag.
|
||||
var result = RadarBlipColors.For((uint)ItemType.Creature, pwdBitfield: 0);
|
||||
Assert.Equal(RadarBlipColors.Creature, result);
|
||||
var traits = new RadarObjectTraits(
|
||||
BlipColorOverride: 1,
|
||||
IsPortal: true,
|
||||
IsVendor: true,
|
||||
IsPlayer: true,
|
||||
IsPlayerKiller: true);
|
||||
|
||||
Assert.Equal(RadarBlipColors.Blue, RadarBlipColors.For(traits));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FriendlyPlayer_ReturnsDefaultColor()
|
||||
public void PortalAndVendor_UsePurpleAndYellow()
|
||||
{
|
||||
// Friendly player: Creature itemType + Player flag, no PK bits.
|
||||
var result = RadarBlipColors.For((uint)ItemType.Creature, pwdBitfield: BF_PLAYER);
|
||||
Assert.Equal(RadarBlipColors.Default, result);
|
||||
Assert.Equal(
|
||||
RadarBlipColors.Purple,
|
||||
RadarBlipColors.For((uint)ItemType.Creature, BfPortal | BfVendor));
|
||||
Assert.Equal(
|
||||
RadarBlipColors.Yellow,
|
||||
RadarBlipColors.For((uint)ItemType.Creature, BfVendor));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PK_Player_ReturnsPlayerKillerColor()
|
||||
public void Creature_MustBeAttackableAndNotAPlayer()
|
||||
{
|
||||
var result = RadarBlipColors.For((uint)ItemType.Creature,
|
||||
pwdBitfield: BF_PLAYER | BF_PLAYER_KILLER);
|
||||
Assert.Equal(RadarBlipColors.PlayerKiller, result);
|
||||
Assert.Equal(
|
||||
RadarBlipColors.Gold,
|
||||
RadarBlipColors.For((uint)ItemType.Creature, BfAttackable));
|
||||
Assert.Equal(
|
||||
RadarBlipColors.White,
|
||||
RadarBlipColors.For((uint)ItemType.Creature, 0));
|
||||
Assert.Equal(
|
||||
RadarBlipColors.White,
|
||||
RadarBlipColors.For((uint)ItemType.Creature, BfAttackable | BfPlayer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PKLite_Player_ReturnsPKLiteColor()
|
||||
public void Player_StatusDispatch_MatchesRetailPriority()
|
||||
{
|
||||
var result = RadarBlipColors.For((uint)ItemType.Creature,
|
||||
pwdBitfield: BF_PLAYER | BF_PKLITE_PKSTATUS);
|
||||
Assert.Equal(RadarBlipColors.PKLite, result);
|
||||
uint creature = (uint)ItemType.Creature;
|
||||
|
||||
Assert.Equal(RadarBlipColors.Cyan, RadarBlipColors.For(creature, BfPlayer | BfAdmin));
|
||||
Assert.Equal(RadarBlipColors.Red, RadarBlipColors.For(creature, BfPlayer | BfPlayerKiller));
|
||||
Assert.Equal(RadarBlipColors.Pink, RadarBlipColors.For(creature, BfPlayer | BfPkLite));
|
||||
Assert.Equal(RadarBlipColors.Gold, RadarBlipColors.For(creature, BfPlayer | BfFreePk));
|
||||
Assert.Equal(
|
||||
RadarBlipColors.Red,
|
||||
RadarBlipColors.For(creature, BfPlayer | BfAdmin | BfHiddenAdmin | BfPlayerKiller));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vendor_BeatsCreatureFlag()
|
||||
public void Fellowship_OverridesPlayerStatusColor()
|
||||
{
|
||||
// A vendor NPC: Creature itemType + Vendor flag. Vendor wins per
|
||||
// retail's dispatch order (vendor check happens before creature
|
||||
// check at 0x004d7946-004d7973).
|
||||
var result = RadarBlipColors.For((uint)ItemType.Creature,
|
||||
pwdBitfield: BF_VENDOR);
|
||||
Assert.Equal(RadarBlipColors.Vendor, result);
|
||||
var traits = new RadarObjectTraits(IsPlayer: true, IsPlayerKiller: true);
|
||||
var relationship = new RadarRelationshipTraits(
|
||||
IsFellowshipMember: true,
|
||||
IsFellowshipLeader: true);
|
||||
|
||||
Assert.Equal(RadarBlipColors.BrightGreen, RadarBlipColors.For(traits, relationship));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Portal_TopPriority()
|
||||
public void InvalidDescription_ReturnsDefault()
|
||||
{
|
||||
// Portal flag wins over everything else (retail dispatch order
|
||||
// checks BF_PORTAL first).
|
||||
var result = RadarBlipColors.For((uint)ItemType.Creature,
|
||||
pwdBitfield: BF_PORTAL | BF_PLAYER | BF_PLAYER_KILLER);
|
||||
Assert.Equal(RadarBlipColors.Portal, result);
|
||||
var traits = new RadarObjectTraits(IsValid: false, BlipColorOverride: 1, IsPortal: true);
|
||||
|
||||
Assert.Equal(RadarBlipColors.Default, RadarBlipColors.For(traits));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rgba8Projection_RemainsAvailableToExistingUiConsumers()
|
||||
{
|
||||
Assert.Equal((byte)64, RadarBlipColors.Blue.R);
|
||||
Assert.Equal((byte)168, RadarBlipColors.Blue.G);
|
||||
Assert.Equal(0xFFFFA840u, RadarBlipColors.Blue.ToAbgr32());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
209
tests/AcDream.Core.Tests/Ui/RetailRadarTests.cs
Normal file
209
tests/AcDream.Core.Tests/Ui/RetailRadarTests.cs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Ui;
|
||||
|
||||
namespace AcDream.Core.Tests.Ui;
|
||||
|
||||
public sealed class RetailRadarTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(RadarBehavior.Undef, true, false)]
|
||||
[InlineData(RadarBehavior.ShowNever, true, false)]
|
||||
[InlineData(RadarBehavior.ShowMovement, true, true)]
|
||||
[InlineData(RadarBehavior.ShowAttacking, true, true)]
|
||||
[InlineData(RadarBehavior.ShowAlways, true, true)]
|
||||
[InlineData(RadarBehavior.ShowAlways, false, false)]
|
||||
public void Showability_AcceptsRetailEnumValuesWithoutLiveStateChecks(
|
||||
RadarBehavior behavior,
|
||||
bool hasPhysics,
|
||||
bool expected)
|
||||
=> Assert.Equal(expected, RetailRadar.IsShowable(behavior, hasPhysics));
|
||||
|
||||
[Fact]
|
||||
public void ShapeClassification_UsesFellowshipAllegianceAndMutualPkPriority()
|
||||
{
|
||||
var pk = new RadarObjectTraits(IsPlayerKiller: true);
|
||||
var pkLite = new RadarObjectTraits(IsPkLite: true);
|
||||
|
||||
Assert.Equal(RadarBlipShape.Default, RetailRadar.GetBlipShape(pk));
|
||||
Assert.Equal(
|
||||
RadarBlipShape.Threat,
|
||||
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(PlayerIsPlayerKiller: true)));
|
||||
Assert.Equal(
|
||||
RadarBlipShape.Threat,
|
||||
RetailRadar.GetBlipShape(pkLite, new RadarRelationshipTraits(PlayerIsPkLite: true)));
|
||||
Assert.Equal(
|
||||
RadarBlipShape.AllegianceMember,
|
||||
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(
|
||||
IsAllegianceMember: true,
|
||||
PlayerIsPlayerKiller: true)));
|
||||
Assert.Equal(
|
||||
RadarBlipShape.Fellowship,
|
||||
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(
|
||||
IsFellowshipMember: true,
|
||||
IsAllegianceMember: true,
|
||||
PlayerIsPlayerKiller: true)));
|
||||
Assert.Equal(
|
||||
RadarBlipShape.FellowshipLeader,
|
||||
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(
|
||||
IsFellowshipMember: true,
|
||||
IsFellowshipLeader: true)));
|
||||
Assert.Equal(
|
||||
RadarBlipShape.Undef,
|
||||
RetailRadar.GetBlipShape(new RadarObjectTraits(IsValid: false)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlipPixelShapes_MatchRetailDrawRoutines()
|
||||
{
|
||||
AssertOffsets(RadarBlipShape.Circle, [(0, 0)]);
|
||||
AssertOffsets(RadarBlipShape.Plus, [(0, 0), (0, -1), (0, 1), (-1, 0), (1, 0)]);
|
||||
AssertOffsets(RadarBlipShape.X, [(0, 0), (1, 1), (-1, -1), (-1, 1), (1, -1)]);
|
||||
AssertOffsets(
|
||||
RadarBlipShape.Box,
|
||||
[(0, -1), (0, 1), (-1, 0), (1, 0), (1, 1), (-1, -1), (-1, 1), (1, -1)]);
|
||||
AssertOffsets(RadarBlipShape.Triangle, [(0, 0), (-1, 1), (0, 1), (1, 1)]);
|
||||
AssertOffsets(RadarBlipShape.InvertedTriangle, [(0, 0), (-1, -1), (0, -1), (1, -1)]);
|
||||
AssertOffsets(
|
||||
RadarBlipShape.XBox,
|
||||
[
|
||||
(0, -1), (0, 1), (-1, 0), (1, 0),
|
||||
(1, 1), (-1, -1), (-1, 1), (1, -1),
|
||||
(-2, -2), (2, -2), (-2, 2), (2, 2),
|
||||
]);
|
||||
Assert.Empty(RetailRadar.GetBlipPixels(RadarBlipShape.Undef).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CenterMarkerAndSelectionOutline_MatchRetailPixelCountsAndExtents()
|
||||
{
|
||||
var player = RetailRadar.PlayerMarkerPixels.ToArray();
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
new RadarPixelOffset(0, 0),
|
||||
new RadarPixelOffset(0, -1),
|
||||
new RadarPixelOffset(0, 1),
|
||||
new RadarPixelOffset(-1, 0),
|
||||
new RadarPixelOffset(1, 0),
|
||||
new RadarPixelOffset(-2, 0),
|
||||
new RadarPixelOffset(2, 0),
|
||||
new RadarPixelOffset(0, -2),
|
||||
new RadarPixelOffset(0, 2),
|
||||
},
|
||||
player);
|
||||
|
||||
var selected = RetailRadar.SelectionPixels.ToArray();
|
||||
Assert.Equal(20, selected.Length);
|
||||
Assert.All(selected, p => Assert.True(Math.Abs(p.X) == 3 || Math.Abs(p.Y) == 3));
|
||||
for (int coordinate = -2; coordinate <= 2; coordinate++)
|
||||
{
|
||||
Assert.Contains(new RadarPixelOffset(coordinate, -3), selected);
|
||||
Assert.Contains(new RadarPixelOffset(coordinate, 3), selected);
|
||||
Assert.Contains(new RadarPixelOffset(-3, coordinate), selected);
|
||||
Assert.Contains(new RadarPixelOffset(3, coordinate), selected);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Projection_UsesRetailRangeScaleAndScreenYAxis()
|
||||
{
|
||||
bool visible = RetailRadar.TryProject(
|
||||
new Vector3(10f, 20f, 4.999f),
|
||||
new Vector2(60f, 60f),
|
||||
radarRadiusPixels: 55,
|
||||
radarRangeMeters: RetailRadar.OutdoorRangeMeters,
|
||||
out var projection);
|
||||
|
||||
Assert.True(visible);
|
||||
Assert.Equal(new Vector2(67f, 45f), projection.Pixel);
|
||||
Assert.Equal(1f, projection.RgbMultiplier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Projection_ExcludesExactRangeMinusOneBoundary()
|
||||
{
|
||||
Assert.True(RetailRadar.TryProject(
|
||||
new Vector3(73.999f, 0f, 0f),
|
||||
Vector2.Zero,
|
||||
55,
|
||||
RetailRadar.OutdoorRangeMeters,
|
||||
out _));
|
||||
Assert.False(RetailRadar.TryProject(
|
||||
new Vector3(74f, 0f, 0f),
|
||||
Vector2.Zero,
|
||||
55,
|
||||
RetailRadar.OutdoorRangeMeters,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(4.999f, 1f)]
|
||||
[InlineData(-4.999f, 1f)]
|
||||
[InlineData(5f, 0.65f)]
|
||||
[InlineData(-5f, 0.65f)]
|
||||
public void AltitudeDim_UsesStrictFiveMeterThreshold(float z, float expected)
|
||||
=> Assert.Equal(expected, RetailRadar.GetAltitudeRgbMultiplier(z));
|
||||
|
||||
[Fact]
|
||||
public void CompassTokens_OrbitUsingPhysicalHeading()
|
||||
{
|
||||
Vector2 center = new(60f, 60f);
|
||||
Vector2 size = new(10f, 10f);
|
||||
|
||||
Assert.Equal(
|
||||
new Vector2(55f, 0f),
|
||||
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.North, center, 55f, size));
|
||||
Assert.Equal(
|
||||
// Retail's east offset is the float 1.57079637; after x87 cosine
|
||||
// and C++ float-to-int truncation its center is one subpixel above 60.
|
||||
new Vector2(110f, 54f),
|
||||
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.East, center, 55f, size));
|
||||
Assert.Equal(
|
||||
new Vector2(55f, 110f),
|
||||
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.South, center, 55f, size));
|
||||
Assert.Equal(
|
||||
new Vector2(0f, 55f),
|
||||
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.West, center, 55f, size));
|
||||
|
||||
Assert.Equal(
|
||||
new Vector2(0f, 55f),
|
||||
RetailRadar.GetCompassTokenTopLeft(90f, RadarCompassPoint.North, center, 55f, size));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Coordinates_UseOutdoorLandcellAndRetailAxisOrder()
|
||||
{
|
||||
uint cellId = LandDefs.LcoordToGid(1358, 1440);
|
||||
|
||||
Assert.True(RadarCoordinates.TryFromCell(cellId, out var coordinates));
|
||||
Assert.Equal(33.9, coordinates.X, precision: 10);
|
||||
Assert.Equal(42.1, coordinates.Y, precision: 10);
|
||||
Assert.Equal("33.9E", coordinates.XText);
|
||||
Assert.Equal("42.1N", coordinates.YText);
|
||||
Assert.Equal("42.1N,33.9E", coordinates.CombinedText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Coordinates_FormatWestSouthAndZeroWithoutDirection()
|
||||
{
|
||||
uint cellId = LandDefs.LcoordToGid(900, 800);
|
||||
Assert.True(RadarCoordinates.TryFromCell(cellId, out var coordinates));
|
||||
Assert.Equal("11.9W", coordinates.XText);
|
||||
Assert.Equal("21.9S", coordinates.YText);
|
||||
|
||||
cellId = LandDefs.LcoordToGid(1019, 1019);
|
||||
Assert.True(RadarCoordinates.TryFromCell(cellId, out coordinates));
|
||||
Assert.Equal("0.0", coordinates.XText);
|
||||
Assert.Equal("0.0,0.0", coordinates.CombinedText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Coordinates_AreUnavailableIndoors()
|
||||
=> Assert.False(RadarCoordinates.TryFromCell(0xA9B40164u, out _));
|
||||
|
||||
private static void AssertOffsets(RadarBlipShape shape, (int X, int Y)[] expected)
|
||||
=> Assert.Equal(
|
||||
expected.Select(p => new RadarPixelOffset(p.X, p.Y)),
|
||||
RetailRadar.GetBlipPixels(shape).ToArray());
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ public sealed class GameplaySettingsTests
|
|||
Assert.True(d.ShowTooltips);
|
||||
Assert.False(d.VividTargetingIndicator);
|
||||
Assert.False(d.SideBySideVitals);
|
||||
Assert.False(d.CoordinatesOnRadar);
|
||||
Assert.True(d.CoordinatesOnRadar); // retail default mask includes 0x00400000
|
||||
Assert.True(d.SpellDuration);
|
||||
Assert.True(d.AllowGive);
|
||||
Assert.True(d.ShowHelm);
|
||||
|
|
|
|||
|
|
@ -368,4 +368,20 @@ public sealed class SettingsStoreTests : System.IDisposable
|
|||
Assert.False(store.LoadChat().HearTradeChat);
|
||||
Assert.Equal("Fellowship", store.LoadCharacter("+Acdream").DefaultChatChannel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowPositions_RoundTripPerCharacterAndPreserveSettings()
|
||||
{
|
||||
var store = new SettingsStore(_tempPath);
|
||||
store.SaveGameplay(GameplaySettings.Default with { LockUI = true });
|
||||
store.SaveWindowPosition("Alice", "radar", new UiWindowPosition(321.5f, 18f));
|
||||
store.SaveWindowPosition("Bob", "radar", new UiWindowPosition(44f, 55f));
|
||||
|
||||
Assert.Equal(new UiWindowPosition(321.5f, 18f),
|
||||
store.LoadWindowPosition("Alice", "radar"));
|
||||
Assert.Equal(new UiWindowPosition(44f, 55f),
|
||||
store.LoadWindowPosition("Bob", "radar"));
|
||||
Assert.Null(store.LoadWindowPosition("Alice", "inventory"));
|
||||
Assert.True(store.LoadGameplay().LockUI);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue