From 3cbe4b00a17a2872ee7da7dfd8b2f949573b0cd6 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Jul 2026 16:14:37 +0200 Subject: [PATCH] feat(ui): port retail radar and compass --- AGENTS.md | 5 +- CLAUDE.md | 4 +- .../retail-divergence-register.md | 3 +- docs/plans/2026-04-11-roadmap.md | 2 +- docs/plans/2026-05-12-milestones.md | 4 +- .../2026-07-10-retail-radar-pseudocode.md | 103 +++++++ docs/research/retail-ui/06-hud-and-assets.md | 145 +++++---- memory/project_ui_architecture.md | 14 + src/AcDream.App/Rendering/GameWindow.cs | 115 ++++++++ src/AcDream.App/Studio/FixtureProvider.cs | 28 ++ src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 1 + src/AcDream.App/UI/Layout/RadarController.cs | 239 +++++++++++++++ .../UI/Layout/RadarSnapshotProvider.cs | 149 ++++++++++ src/AcDream.App/UI/UiElement.cs | 4 + src/AcDream.App/UI/UiRadar.cs | 239 +++++++++++++++ src/AcDream.App/UI/UiRoot.cs | 32 +- src/AcDream.App/UI/WindowNames.cs | 1 + src/AcDream.Core.Net/Messages/CreateObject.cs | 21 +- src/AcDream.Core.Net/ObjectTableWiring.cs | 4 +- src/AcDream.Core.Net/WorldSession.cs | 101 ++++--- src/AcDream.Core/Items/ClientObject.cs | 8 +- src/AcDream.Core/Items/ClientObjectTable.cs | 2 + src/AcDream.Core/Ui/RadarBlipColors.cs | 241 ++++++++++----- src/AcDream.Core/Ui/RetailRadar.cs | 275 ++++++++++++++++++ .../Panels/Settings/GameplaySettings.cs | 4 +- .../Panels/Settings/SettingsStore.cs | 67 +++++ .../UI/Layout/DatWidgetFactoryTests.cs | 11 + .../UI/Layout/FixtureLoader.cs | 8 + .../UI/Layout/RadarControllerTests.cs | 159 ++++++++++ .../UI/Layout/RadarLayoutConformanceTests.cs | 77 +++++ .../UI/Layout/RadarLayoutFixtureGenerator.cs | 36 +++ .../UI/Layout/RadarSnapshotProviderTests.cs | 146 ++++++++++ .../UI/Layout/fixtures/radar_21000074.json | 250 ++++++++++++++++ tests/AcDream.App.Tests/UI/UiRadarTests.cs | 91 ++++++ .../AcDream.App.Tests/UI/UiRootInputTests.cs | 53 ++++ .../Messages/CreateObjectTests.cs | 70 ++++- .../ObjectTableWiringTests.cs | 4 + .../WorldSessionRadarTests.cs | 44 +++ .../Items/ClientObjectTableTests.cs | 22 ++ .../Ui/RadarBlipColorsTests.cs | 135 ++++++--- .../AcDream.Core.Tests/Ui/RetailRadarTests.cs | 209 +++++++++++++ .../Panels/Settings/GameplaySettingsTests.cs | 2 +- .../Panels/Settings/SettingsStoreTests.cs | 16 + 43 files changed, 2882 insertions(+), 262 deletions(-) create mode 100644 docs/research/2026-07-10-retail-radar-pseudocode.md create mode 100644 src/AcDream.App/UI/Layout/RadarController.cs create mode 100644 src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs create mode 100644 src/AcDream.App/UI/UiRadar.cs create mode 100644 src/AcDream.Core/Ui/RetailRadar.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/RadarControllerTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/RadarLayoutConformanceTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/RadarLayoutFixtureGenerator.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/fixtures/radar_21000074.json create mode 100644 tests/AcDream.App.Tests/UI/UiRadarTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/WorldSessionRadarTests.cs create mode 100644 tests/AcDream.Core.Tests/Ui/RetailRadarTests.cs diff --git a/AGENTS.md b/AGENTS.md index f10f0e2c..c753cbd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). - diff --git a/CLAUDE.md b/CLAUDE.md index b20aef89..a5396c59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a88e3b8b..002e9d75 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 273bc07e..bba53b01 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 1db2caa0..3be29344 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -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:** diff --git a/docs/research/2026-07-10-retail-radar-pseudocode.md b/docs/research/2026-07-10-retail-radar-pseudocode.md new file mode 100644 index 00000000..9a3561d2 --- /dev/null +++ b/docs/research/2026-07-10-retail-radar-pseudocode.md @@ -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. diff --git a/docs/research/retail-ui/06-hud-and-assets.md b/docs/research/retail-ui/06-hud-and-assets.md index 4ec62b52..77296799 100644 --- a/docs/research/retail-ui/06-hud-and-assets.md +++ b/docs/research/retail-ui/06-hud-and-assets.md @@ -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 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 diff --git a/memory/project_ui_architecture.md b/memory/project_ui_architecture.md index bf5e4ad6..dc735609 100644 --- a/memory/project_ui_architecture.md +++ b/memory/project_ui_architecture.md @@ -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`. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 06e242e6..a6d42706 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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) diff --git a/src/AcDream.App/Studio/FixtureProvider.cs b/src/AcDream.App/Studio/FixtureProvider.cs index 75956370..360c4fb3 100644 --- a/src/AcDream.App/Studio/FixtureProvider.cs +++ b/src/AcDream.App/Studio/FixtureProvider.cs @@ -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); + } diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 1a8964e6..69e92ae7 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -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 diff --git a/src/AcDream.App/UI/Layout/RadarController.cs b/src/AcDream.App/UI/Layout/RadarController.cs new file mode 100644 index 00000000..14247494 --- /dev/null +++ b/src/AcDream.App/UI/Layout/RadarController.cs @@ -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; + +/// +/// Binds the runtime-created behavior of retail gmRadarUI to the static +/// LayoutDesc 0x21000074 tree. Like the other gm* controllers, this class only +/// finds children by retail id and attaches live providers; it does not recreate DAT chrome. +/// +public sealed class RadarController +{ + public const uint LayoutId = 0x21000074u; + /// Production layout property 0x1000002D, recovered directly from the retail DAT. + 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? _setUiLocked; + private readonly CompassToken[] _tokens; + private float _lastHeading = float.NaN; + private string? _lastCoordinates; + private bool _coordinatesInitialized; + private bool? _lastUiLocked; + + private RadarController( + ImportedLayout layout, + Func snapshotProvider, + Action? selectObject, + Action? hoveredObjectChanged, + Action? 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(); + } + + /// + /// Bind the projected snapshot provider and interaction callbacks to an imported radar. + /// No external per-frame call is required; polls at retail's 25 ms cadence. + /// + public static RadarController Bind( + ImportedLayout layout, + Func snapshotProvider, + Action? selectObject = null, + Action? hoveredObjectChanged = null, + Action? 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 CoordinateLines() + => string.IsNullOrEmpty(_lastCoordinates) + ? Array.Empty() + : new[] { new UiText.Line(_lastCoordinates, CoordinateColor) }; + + private readonly record struct CompassToken( + UiElement? Element, + float Magnitude, + RadarCompassPoint Point); +} diff --git a/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs b/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs new file mode 100644 index 00000000..1b262c35 --- /dev/null +++ b/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs @@ -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; + +/// +/// Joins acdream's existing two-table world model into the immutable projected frame +/// consumed by . This is the App-layer equivalent of retail's +/// gmRadarUI::DrawObjects object-maintenance walk: Core still owns every +/// AC-specific classification and math decision, while the retained widget remains a +/// backend-only renderer. +/// +public sealed class RadarSnapshotProvider +{ + private static readonly Vector2 ProductionCenter = new(60f, 60f); + + private readonly ClientObjectTable _objects; + private readonly IReadOnlyDictionary _worldEntities; + private readonly IReadOnlyDictionary _spawns; + private readonly Func _playerGuid; + private readonly Func _playerYawRadians; + private readonly Func _playerCellId; + private readonly Func _selectedGuid; + private readonly Func _coordinatesOnRadar; + private readonly Func _uiLocked; + private readonly Func? _relationshipFor; + + public RadarSnapshotProvider( + ClientObjectTable objects, + IReadOnlyDictionary worldEntities, + IReadOnlyDictionary spawns, + Func playerGuid, + Func playerYawRadians, + Func playerCellId, + Func selectedGuid, + Func coordinatesOnRadar, + Func uiLocked, + Func? 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(_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); + } +} diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index eb0e9e3d..3f531dad 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -156,6 +156,10 @@ public abstract class UiElement /// whose Left/Top are screen coordinates (Root sits at the origin). public bool Draggable { get; set; } + /// Clamp a dragged top-level window fully inside its parent. Retail + /// gmRadarUI::MoveTo enables this; most windows retain the toolkit default. + public bool ConstrainDragToParent { get; set; } + /// If true, a left-drag starting near this element's edge/corner /// resizes it (window resize). Intended for top-level panels. public bool Resizable { get; set; } diff --git a/src/AcDream.App/UI/UiRadar.cs b/src/AcDream.App/UI/UiRadar.cs new file mode 100644 index 00000000..77d718fa --- /dev/null +++ b/src/AcDream.App/UI/UiRadar.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Ui; + +namespace AcDream.App.UI; + +/// +/// One already-projected radar object. and +/// 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. +/// +public readonly record struct UiRadarBlip( + uint ObjectId, + string Name, + float PixelX, + float PixelY, + Vector4 Color, + RadarBlipShape Shape, + bool Selected = false); + +/// +/// Immutable frame consumed by . A null +/// hides retail's coordinate strip (indoors or when +/// CoordinatesOnRadar is disabled). mirrors +/// ClientUISystem::m_bRadarBlank: static chrome and the player marker remain visible. +/// +public sealed record UiRadarSnapshot( + float PlayerHeadingDegrees, + IReadOnlyList Blips, + string? CoordinatesText, + bool BlankBlips = false, + bool UiLocked = false) +{ + public static UiRadarSnapshot Empty { get; } = new( + 0f, + Array.Empty(), + null); +} + +/// +/// Retained-mode implementation of retail gmRadarUI, registered as class id +/// 0x10000010. The imported LayoutDesc supplies all static sprites; this widget +/// draws the dynamic object blips and fixed player marker after those children. +/// +/// Retail references: +/// +/// gmRadarUI::DrawBlip 0x004D8A30 and shape helpers 0x004D7C30-0x004D8531. +/// gmRadarUI::DrawChildren 0x004D9720 (static children, objects, player marker). +/// gmRadarUI::UseTime 0x004D98A0 (25 ms refresh cadence). +/// +/// +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; + + /// Local center of the radar disc. Filled by RadarController from LayoutDesc 0x21000074. + public Vector2 Center { get; set; } = new(60f, 60f); + + /// Polled at retail's 25 ms cadence. The provider performs Core-to-UI adaptation. + public Func? SnapshotProvider { get; set; } + + /// Invoked when the user clicks the nearest blip under the pointer. + public Action? SelectObject { get; set; } + + /// Optional host hook for target highlights/status text. + public Action? HoveredObjectChanged { get; set; } + + public UiRadarSnapshot Snapshot => _snapshot; + public uint? HoveredObjectId => _hoveredObjectId; + + /// 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. + public override bool HandlesClick => true; + + /// Apply one projected frame immediately. Used once by the binder, then by the 25 ms tick. + 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); + } + } + + /// Poll the provider now, without waiting for the next retained-tree tick. + 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 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)); +} diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 29ce3072..c25c09bc 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -68,6 +68,10 @@ public sealed class UiRoot : UiElement /// True when a widget holds keyboard focus (e.g. a focused chat input). public bool WantsKeyboard => KeyboardFocus is not null; + /// Retail PlayerModule::LockUI gate. Blocks all retained-window + /// move/resize interactions without disabling their buttons or content. + public bool UiLocked { get; set; } + /// Current drag source (set between drag-begin and drop/cancel). 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 /// Raised when a drag is released over no UI element. public event Action? DragReleasedOutsideUi; + /// Raised after a registered top-level window finishes moving. + public event Action? 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; } diff --git a/src/AcDream.App/UI/WindowNames.cs b/src/AcDream.App/UI/WindowNames.cs index 6f2447e6..a5dae9b3 100644 --- a/src/AcDream.App/UI/WindowNames.cs +++ b/src/AcDream.App/UI/WindowNames.cs @@ -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"; } diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 408b76a4..42312015 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -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); /// /// The relevant subset of the server-sent MovementData / @@ -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). diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs index b8fee4ff..222b4377 100644 --- a/src/AcDream.Core.Net/ObjectTableWiring.cs +++ b/src/AcDream.Core.Net/ObjectTableWiring.cs @@ -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); } diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 2e05f9ea..26118783 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -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); + + /// + /// 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. + /// + 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); /// Fires when the session finishes parsing a CreateObject. public event Action? 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) diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs index 3e017e0b..23c55483 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -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 + /// Retail PublicWeenieDesc _blipColor; null until supplied by CreateObject. + public byte? RadarBlipColor { get; set; } + /// Retail PublicWeenieDesc _radar_enum; null until supplied by CreateObject. + 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); /// /// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0). diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 9ab253e2..67af6f31 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -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; diff --git a/src/AcDream.Core/Ui/RadarBlipColors.cs b/src/AcDream.Core/Ui/RadarBlipColors.cs index 685f156b..1de5129c 100644 --- a/src/AcDream.Core/Ui/RadarBlipColors.cs +++ b/src/AcDream.Core/Ui/RadarBlipColors.cs @@ -3,91 +3,190 @@ using AcDream.Core.Items; namespace AcDream.Core.Ui; /// -/// B.7 (2026-05-15) — port of retail's gmRadarUI::GetBlipColor -/// (named decomp 0x004d76f0). Returns the radar / target-indicator -/// colour for an entity based on its and the -/// raw PublicWeenieDesc._bitfield we already parse out of -/// CreateObject. -/// -/// -/// 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. -/// -/// -/// -/// Dispatch order matches the retail decomp at lines 219913+ of -/// docs/research/named-retail/acclient_2013_pseudo_c.txt. The -/// PWD bit layout matches acclient.h:6431-6463: -/// -/// BF_PLAYER = 0x8 -/// BF_PLAYER_KILLER = 0x20 -/// BF_VENDOR = 0x200 (byte[1] & 0x02 in retail) -/// BF_PORTAL = 0x40000 -/// BF_FREE_PKSTATUS = 0x200000 (hostile-flagged player) -/// BF_PKLITE_PKSTATUS = 0x2000000 -/// -/// -/// -/// -/// RGBA values are hand-tuned to visually match retail screenshots -/// (yellow creature, red PK, pink PKLite, green vendor, cyan portal, -/// white default). Real RGBAColor_Radar* constants live in retail -/// static data — if they're ever recovered the table can be tightened. -/// +/// Facts used by retail's radar color/shape classification. Relationship state is +/// intentionally separate because fellowship and allegiance membership do not come +/// from PublicWeenieDesc. +/// +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; + + /// + /// Build the subset of retail traits present in a CreateObject PWD. Fellowship + /// and allegiance state must still be supplied separately. + /// + 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); +} + +/// Relationship facts owned by the client player/fellowship systems. +public readonly record struct RadarRelationshipTraits( + bool IsFellowshipMember = false, + bool IsFellowshipLeader = false, + bool IsAllegianceMember = false, + bool PlayerIsPlayerKiller = false, + bool PlayerIsPkLite = false); + +/// +/// Exact retail radar palette and gmRadarUI::GetBlipColor dispatch. +/// Named retail anchors: gmRadarUI::GetBlipColor @ 0x004D76F0 and +/// RGBAColor_Radar* @ 0x008190E8. /// public static class RadarBlipColors { - public readonly record struct Rgba(byte R, byte G, byte B, byte A); + /// + /// 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. + /// + 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); + + /// ImGui/OpenGL-style packed 0xAABBGGRR projection. + 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; /// - /// Resolve the radar-blip colour for an entity. Caller supplies the - /// raw (from CreateObject.ItemType) and - /// (from - /// CreateObject.ObjectDescriptionFlags) — both are already - /// parsed and stashed on EntitySpawn at spawn time. - /// - /// - /// Returns for friendly players, - /// for NPCs/monsters, for everything else. - /// Special types (PK, PKLite, Vendor, Portal) win over the base - /// type when their flag is set. - /// + /// Compatibility entry point for metadata already surfaced by CreateObject. + /// Classification which requires fellowship state is available through the + /// explicit-traits overload. /// public static Rgba For(uint itemType, uint pwdBitfield) + => For(RadarObjectTraits.FromPublicWeenieDescription(itemType, pwdBitfield)); + + /// + /// Port of gmRadarUI::GetBlipColor @ 0x004D76F0. An explicit wire + /// override wins; otherwise portal/vendor/creature/player classification runs + /// in retail order, followed by the fellowship override. + /// + 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; } + + /// Retail PWD _blipColor override table (values 1..10). + 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, + }; } diff --git a/src/AcDream.Core/Ui/RetailRadar.cs b/src/AcDream.Core/Ui/RetailRadar.cs new file mode 100644 index 00000000..19072e0a --- /dev/null +++ b/src/AcDream.Core/Ui/RetailRadar.cs @@ -0,0 +1,275 @@ +using System.Numerics; +using AcDream.Core.Physics; + +namespace AcDream.Core.Ui; + +/// Verbatim retail RadarEnum values from acclient.h:6467. +public enum RadarBehavior : byte +{ + Undef = 0, + ShowNever = 1, + ShowMovement = 2, + ShowAttacking = 3, + ShowAlways = 4, +} + +/// Verbatim retail RadarBlipShape values from acclient.h:6575. +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); + +/// +/// Pure retail radar behavior used by the retained UI. The caller supplies positions +/// already converted to player space, matching retail's +/// SmartBox::convert_to_player_space call. +/// +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(); + + /// + /// ACCWeenieObject::InqShowableOnRadar @ 0x0058C250. The enum names do + /// not imply a live motion/attack test here: retail accepts all three values. + /// + public static bool IsShowable(RadarBehavior behavior, bool hasPhysicsObject) + => hasPhysicsObject && behavior is + RadarBehavior.ShowMovement or RadarBehavior.ShowAttacking or RadarBehavior.ShowAlways; + + /// gmRadarUI::GetBlipShape @ 0x004D7B60. + 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; + } + + /// + /// Exact points filled by retail's DrawPoint/Edges/Corners/Cross/X/Triangle/ + /// InvertedTriangle/Hollow/XBox functions at 0x004D7C30..0x004D8531. + /// + public static ReadOnlySpan 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, + _ => [], + }; + + /// + /// Fixed bright-green player marker drawn by gmRadarUI::DrawChildren + /// @ 0x004D9720: a plus with four extra points two pixels from center. + /// + public static ReadOnlySpan PlayerMarkerPixels => PlayerMarkerPixelArray; + + /// Selected-object outline from gmRadarUI::DrawSelected @ 0x004D7FE0. + public static ReadOnlySpan SelectionPixels => SelectionPixelArray; + + public static float GetRangeMeters(bool isOutside) + => isOutside ? OutdoorRangeMeters : IndoorRangeMeters; + + /// + /// Project a player-local position to radar pixels exactly as + /// gmRadarUI::DrawObjects @ 0x004D9380. Objects at or beyond + /// (range - 1 m) are excluded. Positive player-space Y is screen-up. + /// + 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; + } + + /// RGB is dimmed at exactly 5 m; alpha is left unchanged by the caller. + public static float GetAltitudeRgbMultiplier(float playerSpaceZMeters) + => MathF.Abs(playerSpaceZMeters) < AltitudeDimThresholdMeters + ? 1f + : AltitudeDimMultiplier; + + /// + /// Compute a compass token's top-left pixel from + /// gmRadarUI::UpdateCompassTokens @ 0x004D9060. Heading is the physical + /// player's heading in degrees, not camera yaw. + /// + 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; + } +} + +/// Retail outdoor coordinate text derived from the player's landcell. +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}"; + + /// + /// Port of CPlayerSystem::InqPlayerCoords @ 0x00560090. Indoor cells + /// fail because retail obtains coordinates through get_landscape_coord. + /// + 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}"; + } +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs index 4b1d43e4..4a84e2c1 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs @@ -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, diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index 5cb20e65..99cf3132 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -7,6 +7,9 @@ using AcDream.UI.Abstractions.Settings; namespace AcDream.UI.Abstractions.Panels.Settings; +/// Per-character retained-window position in screen pixels. +public readonly record struct UiWindowPosition(float X, float Y); + /// /// 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 })); } + /// Load a named retained-window position for one character. + 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? y = node["y"]?.GetValue(); + 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; + } + } + + /// + /// Save a named retained-window position under + /// windowPositions[toonKey][windowName], preserving all settings sections. + /// + 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 BuildChatObject(ChatSettings c) => new(StringComparer.Ordinal) { diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 2b1f283d..d602f96d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -16,6 +16,17 @@ public class DatWidgetFactoryTests Assert.IsType(e); } + [Fact] + public void RetailRadarClass_MakesUiRadar() + { + var e = DatWidgetFactory.Create( + new ElementInfo { Type = UiRadar.RetailClassId, Width = 120, Height = 140 }, + NoTex, + null); + + Assert.IsType(e); + } + // ── Test 2: Unknown type → UiDatElement fallback ───────────────────────── [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs index c7338ba1..6150902a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs +++ b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs @@ -57,6 +57,14 @@ public static class FixtureLoader public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos() => LoadInfos("chat_21000006.json"); + /// Builds the committed retail radar LayoutDesc 0x21000074 fixture. + public static ImportedLayout LoadRadar() + => LayoutImporter.Build(LoadRadarInfos(), _ => (0u, 0, 0), null); + + /// Returns the resolved ElementInfo tree for retail radar LayoutDesc 0x21000074. + public static AcDream.App.UI.Layout.ElementInfo LoadRadarInfos() + => LoadInfos("radar_21000074.json"); + // ── Shared loader ──────────────────────────────────────────────────────── private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName) diff --git a/tests/AcDream.App.Tests/UI/Layout/RadarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/RadarControllerTests.cs new file mode 100644 index 00000000..2d18244d --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/RadarControllerTests.cs @@ -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(), "42.1N,33.6E")); + + var coordinates = Assert.IsType( + 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(), + "42.1N,33.6E", + UiLocked: false); + bool? requestedLock = null; + + _ = RadarController.Bind( + layout, + () => state, + setUiLocked: value => requestedLock = value); + + var radar = Assert.IsType(layout.Root); + Assert.Equal(new Vector2(60f, 60f), radar.Center); + Assert.True(radar.Draggable); + + var coordinateContainer = Assert.IsType( + layout.FindElement(RadarController.CoordinateContainerId)); + Assert.True(coordinateContainer.Visible); + var coordinateText = Assert.IsType(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(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(layout.FindElement(RadarController.RadarDiscId)); + Assert.IsType(layout.FindElement(RadarController.LockButtonId)); + Assert.IsType(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; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/RadarLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/RadarLayoutConformanceTests.cs new file mode 100644 index 00000000..ec1fb2fb --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/RadarLayoutConformanceTests.cs @@ -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(layout.Root); + Assert.Equal(8, layout.Root.Children.Count); + var coordinates = Assert.IsType( + layout.FindElement(RadarController.CoordinateContainerId)); + Assert.Equal(0x06004CC0u, coordinates.BackgroundSprite); + Assert.IsType(layout.FindElement(RadarController.RadarDiscId)); + Assert.IsType(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; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/RadarLayoutFixtureGenerator.cs b/tests/AcDream.App.Tests/UI/Layout/RadarLayoutFixtureGenerator.cs new file mode 100644 index 00000000..57d0a0ef --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/RadarLayoutFixtureGenerator.cs @@ -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; + +/// One-off generator for the committed retail radar LayoutDesc fixture. +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"); +} diff --git a/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs new file mode 100644 index 00000000..0d9e7b34 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs @@ -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 + { + [player] = Entity(player, Vector3.Zero, Quaternion.Identity), + [monster] = Entity(monster, new Vector3(0f, 15f, 6f), Quaternion.Identity), + }; + var spawns = new Dictionary + { + [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 + { + [player] = Entity(player, Vector3.Zero, Quaternion.Identity), + [hidden] = Entity(hidden, new Vector3(2f, 2f, 0f), Quaternion.Identity), + }; + var spawns = new Dictionary + { + [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(), + }; + + private static WorldSession.EntitySpawn Spawn(uint guid) => new( + Guid: guid, + Position: null, + SetupTableId: null, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + 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); +} diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/radar_21000074.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/radar_21000074.json new file mode 100644 index 00000000..78b5a3ed --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/radar_21000074.json @@ -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": [] + } + ] +} \ No newline at end of file diff --git a/tests/AcDream.App.Tests/UI/UiRadarTests.cs b/tests/AcDream.App.Tests/UI/UiRadarTests.cs new file mode 100644 index 00000000..00f9e8fa --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiRadarTests.cs @@ -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 + } +} diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index 46807955..2da5ea6d 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -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() { diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs index e7fef4e1..feb66664 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs @@ -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(); @@ -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 { diff --git a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs index 4bf705c5..367260a6 100644 --- a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs @@ -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); } // ------------------------------------------------------------------------- diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionRadarTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionRadarTests.cs new file mode 100644 index 00000000..58f8f33c --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/WorldSessionRadarTests.cs @@ -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(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "Radar Test", + ItemType: null, + MotionState: null, + MotionTableId: null); +} diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index 509e3763..7a4cc900 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -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() { diff --git a/tests/AcDream.Core.Tests/Ui/RadarBlipColorsTests.cs b/tests/AcDream.Core.Tests/Ui/RadarBlipColorsTests.cs index 482ea3e1..1c8d8398 100644 --- a/tests/AcDream.Core.Tests/Ui/RadarBlipColorsTests.cs +++ b/tests/AcDream.Core.Tests/Ui/RadarBlipColorsTests.cs @@ -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()); } } diff --git a/tests/AcDream.Core.Tests/Ui/RetailRadarTests.cs b/tests/AcDream.Core.Tests/Ui/RetailRadarTests.cs new file mode 100644 index 00000000..265653d1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Ui/RetailRadarTests.cs @@ -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()); +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs index b21f444a..6c1ec212 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs @@ -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); diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs index b54d0f07..519c5f41 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -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); + } }