feat(ui): port retail radar and compass

This commit is contained in:
Erik 2026-07-10 16:14:37 +02:00
parent c4af181b92
commit 3cbe4b00a1
43 changed files with 2882 additions and 262 deletions

View file

@ -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).

View file

@ -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.

View file

@ -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 12 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, 1SrcAlpha) — 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)

View file

@ -488,7 +488,7 @@ behavior. Estimated 1726 days focused work, 35 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.

View file

@ -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.3D.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:**

View file

@ -0,0 +1,103 @@
# Retail radar/compass pseudocode — 2026-07-10
## Oracle and scope
This note records the primary retail behavior used by acdream's retained
radar port. The oracle is the September 2013 named client:
- `gmRadarUI` fields: `named-retail/acclient.h:54506-54548`
- color/shape: `gmRadarUI::GetBlipColor` `0x004D76F0`,
`gmRadarUI::GetBlipShape` `0x004D7B60`
- coordinates/compass/draw: `UpdateCoordinates` `0x004D8C80`,
`UpdateCompassTokens` `0x004D9060`, `DrawObjects` `0x004D9380`,
`DrawChildren` `0x004D9720`
- cadence/list maintenance: `UseTime` `0x004D98A0`, `AddObject`
`0x004D9A00`, `Init` `0x004D9B90`, `PostInit` `0x004D9D30`
- production DAT layout: `LayoutDesc 0x21000074` in
`2026-06-25-retail-ui-layout-dump.json`
The older AC2D-derived `1.18 × range`, rotating player arrow, and horizontal
scrolling compass strip are rejected by the named retail implementation.
## Retained UI ownership
The production root is a 120×140 `gmRadarUI` element of class
`0x10000010`. Custom properties set center `(60,60)` (`0x1000002E`,
nested `0x1000002F/30`) and pixel radius `50` (`0x1000002D`).
`LayoutImporter` owns the static DAT children and media.
A dedicated `UiRadar` owns the dynamic draw/tick/input behavior. Core owns
the pure projection, classification, marker geometry, compass, and coordinate
algorithms. `GameWindow` supplies live snapshots and selection/settings
actions only.
This is a screen-space retained panel. It is not an ImGui panel and does not
use the raw world-space TextRenderer overlay path.
## Per-tick pseudocode
```text
every 0.025 seconds:
range = player is outdoors ? 75 : 25
heading = physical player heading in degrees
move N/E/S/W child tokens around radar center
if CoordinatesOnRadar and LandDefs.gid_to_lcoord(playerCell):
ns = (lcoordY - 1024) * 0.1 + 0.5
ew = (lcoordX - 1024) * 0.1 + 0.5
coordinateText = abs(ns) + N/S + "," + abs(ew) + E/W
show footer/text
else:
hide footer/text
```
```text
for each maintained object except the player:
require a physics/world entity
require RadarBehavior in { ShowMovement=2, ShowAttacking=3, ShowAlways=4 }
relative = convert object position into player's oriented frame
if relative.X² + relative.Y² >= (range - 1)²:
continue
scale = radarPixelRadius / range
x = trunc(centerX + relative.X * scale)
y = trunc(centerY - relative.Y * scale)
rgbBrightness = abs(relative.Z) < 5 ? 1.0 : 0.65
color = exact _blipColor override 1..10, otherwise retail classification
shape = fellowship/allegiance/PK relationship shape, otherwise Plus
draw marker pixels
if selected: draw the open selection outline at offsets ±3
```
After all object blips, draw the player's fixed bright-green center plus.
Hover selects the nearest projected blip within squared distance 36; its
name is the tooltip. A left click selects that object.
The lock button drives the existing retained-window drag gate; the drag
affordance is hidden while locked. Completed moves are clamped inside the UI
root and persisted per character through `SettingsStore`.
## Production assets
| Role | Element | Rect | RenderSurface |
|---|---:|---:|---:|
| Face | `0x1000003F` | `(0,0) 120×120` | `0x06004CC1` |
| Coordinate footer | `0x1000003E` | `(0,120) 120×18` | `0x06004CC0` |
| N / E / S / W | `0x10000040..43` | four 10×9 tokens | `0x060011FB`, `0x06001938`, `0x0600193A`, `0x0600193C` |
| Lock | `0x10000619` | `(6,6) 27×27` | `0x060074B7` / `0x060074B8` |
| Drag | `0x100006A3` | `(87,6) 27×27` | `0x060074C9` |
The child token orbit uses `center + (sin(angle), cos(angle)) * magnitude`
with angles N=`heading+π`, E=`heading+π/2`, S=`heading`, W=`heading+3π/2`.
## Wire contract
PublicWeenieDesc flag `0x00100000` carries nullable byte
`RadarBlipColor`; flag `0x00800000` carries nullable byte `RadarBehavior`.
Absent is distinct from an explicitly transmitted zero. Both values flow
through `CreateObject.Parsed`, `WorldSession.EntitySpawn`, and
`ClientObjectTable`.
Do not infer ShowMovement/ShowAttacking from local motion. Retail's
`InqShowableOnRadar` accepts values 2, 3, and 4 directly; server/quality
changes are authoritative.

View file

@ -172,103 +172,88 @@ public sealed class VitalOrb
### A.2 Radar / compass
The radar is the canonical "small circular polar plot of nearby creatures
with the player at the center". The compass is a thin bar across the top of
the screen showing the 16 cardinal directions as the camera rotates.
Retail implements both pieces as one `gmRadarUI` element, not as a radar
plus a separate scrolling strip. Primary sources are the named September
2013 decomp (`gmRadarUI::UpdateCompassTokens`, `DrawObjects`,
`UpdateCoordinates`, and `DrawChildren`) and production `LayoutDesc
0x21000074` in `2026-06-25-retail-ui-layout-dump.json`. The earlier AC2D
comparison in this section was useful secondary evidence but its `1.18`
projection, rotating player arrow, and horizontal compass strip are not
retail behavior.
**Radar — data sources**
**Production layout (`0x21000074`, root type `0x10000010`):**
- Player world position (`Position` packet, 24-byte LandCell + XYZ).
- Every nearby object's `CreateObject` / `UpdatePosition` with heading.
- Per-object `RadarColor` override (hostile = red, green = friendly NPC,
etc.) + `ObjectFlags2` bits (`0x08` = item, `0x10` = blue-book NPC).
- 120×140 root (`gmRadarUI`).
- Root custom properties: center `(60,60)` and radar pixel radius `50`.
- Circular face `0x06004CC1` at `(0,0)`, 120×120.
- Coordinate footer `0x06004CC0` at `(0,120)`, 120×18.
- N/E/S/W token sprites `0x060011FB`, `0x06001938`, `0x0600193A`,
`0x0600193C`, each 10×9 and initially centered at the four cardinal
points.
- Lock/unlock sprites `0x060074B7` / `0x060074B8`; drag sprite
`0x060074C9`.
**Radar — retail dat IDs** (AC2D, `cInterface.cpp:139-144`):
- `0x06001388` — radar window titlebar/toolbar icon.
- `0x06004CC1` — radar background art (the circular bezel).
**Radar — the player arrow + blip placement**
AC2D `cRadar::OnRender` (`cCustomWindows.h:1004-1070`) is the clearest
retail-equivalent. The math:
**Projection (`gmRadarUI::DrawObjects` `0x004D9380`):**
```text
for each nearby object obj:
delta = obj.pos - player.pos
delta = delta.RotateAround(Z, -player.heading) // align to radar-up = camera-forward
screen.x = radar.left + radar.w/2 + (delta.x / (1.18 * range)) * (radar.w/2)
screen.y = radar.top + radar.h/2 - (delta.y / (1.18 * range)) * (radar.h/2)
color = PickRadarColor(obj.radar_override, obj.flags2)
DrawQuad2x2(screen, color)
relative = object position converted into the physical player's oriented frame
range = outdoors ? 75 : 25
if relative.X² + relative.Y² >= (range - 1)²:
skip
scale = radarPixelRadius / range
pixelX = trunc(centerX + relative.X * scale)
pixelY = trunc(centerY - relative.Y * scale)
rgbMod = abs(relative.Z) < 5 ? 1.0 : 0.65
```
The `1.18` factor is retail-observed — it shrinks the effective range
slightly so blips near the edge stay visible before the bezel clips them.
Player's own arrow is NOT in the blip loop; it is drawn as a fixed
centered sprite (the "player dot") rotated by `player.heading`.
The player's own object is excluded. `DrawChildren` paints a fixed
bright-green plus at the center after the object blips; it is not a rotating
arrow. Hover chooses the nearest blip within 6 pixels, exposes its object
name as the tooltip, and clicking selects that object.
**Compass — data sources**
- `player.heading` in radians.
**Compass — how the rose is drawn**
The rose is a **seamless horizontal strip texture** where 360° is tiled
across some multiple of the screen width. The U-offset is
`heading_normalized * strip_u_period`, with the visible portion cropped to
a narrow strip at the top of the screen. This is the classic "scrolling
texture" approach used by most 3D clients; retail AC follows it.
Holtburger's TUI compass (`hud/status.rs:11-18`) enumerates the 16
cardinal-direction labels that retail paints onto the strip:
**Compass tokens (`gmRadarUI::UpdateCompassTokens` `0x004D9060`):**
```text
["W", "WNW", "NW", "NNW", "N", "NNE", "NE", "ENE",
"E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW"]
tokenX = centerX + sin(angle) * tokenMagnitude - tokenWidth/2
tokenY = centerY + cos(angle) * tokenMagnitude - tokenHeight/2
N angle = heading + π
E angle = heading + π/2
S angle = heading
W angle = heading + 3π/2
```
22.5° per segment, first label centered on 11.25°.
Heading comes from the physical player in degrees, not from the camera.
There is no numeric heading label in `gmRadarUI`.
**Render-path pseudocode:**
**Visibility, color, and shapes:**
```text
function DrawCompassStrip(heading_rad, bar_x, bar_y, bar_w, bar_h):
heading_deg = (heading_rad * 180 / PI) mod 360
// Texture is 360° wide in world-space; we crop to bar_w centered on heading
u_center = heading_deg / 360 // 0..1
u_half = bar_w / strip_texture_w / 2
u_left = u_center - u_half
u_right = u_center + u_half
DrawSpriteUV(compass_strip_tex, bar_x, bar_y, bar_w, bar_h,
u0=u_left, u1=u_right, v0=0, v1=1)
```
- `RadarEnum`: undefined 0, never 1, movement 2, attacking 3, always 4.
Retail accepts 2/3/4 as showable and does not invent a client-side
movement/attack filter.
- The optional PublicWeenieDesc bytes are `RadarBlipColor` (header flag
`0x00100000`) and `RadarBehavior` (`0x00800000`).
- `_blipColor` overrides 1..10 select the exact blue, gold, white, purple,
red, pink, green, yellow, cyan, and bright-green retail constants.
- Default classification covers portals, vendors, attackable creatures,
visible admins, PK/PKLite/free-PK players, and fellowship overrides.
- Marker shapes are point, hollow box, X, plus, triangle, inverted triangle,
and X-box. Fellowship/allegiance/PK relationships select the special
shapes; the ordinary default is a five-pixel plus.
**C# port sketch:**
**Coordinates (`gmRadarUI::UpdateCoordinates` `0x004D8C80`):**
```csharp
public void DrawRadar(IUiRenderer r, Rect bounds, float playerHeading,
Vector3 playerPos, IEnumerable<WorldEntity> nearby, float range)
{
r.DrawSprite(/*0x06004CC1*/ _radarBgId, bounds);
For an outdoor cell, `LandDefs::gid_to_lcoord` supplies integer landscape
cell X/Y. Each axis becomes `(lcoord - 1024) * 0.1 + 0.5`, then formats as
`{abs(Y):0.0}{N|S},{abs(X):0.0}{E|W}` (no space). The footer is hidden for
indoor cells or when `CoordinatesOnRadar` (`0x00400000`) is disabled.
var cx = bounds.X + bounds.Width * 0.5f;
var cy = bounds.Y + bounds.Height * 0.5f;
foreach (var e in nearby)
{
var d = e.Position - playerPos;
d = Vector3.Transform(d, Matrix4x4.CreateRotationZ(-playerHeading));
var sx = cx + (d.X / (1.18f * range)) * (bounds.Width * 0.5f);
var sy = cy - (d.Y / (1.18f * range)) * (bounds.Height * 0.5f);
var col = PickRadarColor(e);
r.DrawFilledQuad(sx - 1, sy - 1, 2, 2, col);
}
// Player arrow - always on top
r.DrawSprite(_playerArrowId, cx - 5, cy - 5, 10, 10, rotation: playerHeading);
}
```
`gmRadarUI::UseTime` refreshes range, tokens, coordinates, and dirty state
every 25 ms (40 Hz). Static art remains data-driven through the retained
LayoutDesc importer; dynamic behavior belongs in a dedicated retained
radar widget/controller, with `GameWindow` limited to state providers.
### A.3 Quickbar / hotbar

View file

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

View file

@ -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)

View file

@ -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);
}

View file

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

View file

@ -0,0 +1,239 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.UI;
using AcDream.Core.Ui;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the runtime-created behavior of retail <c>gmRadarUI</c> to the static
/// LayoutDesc <c>0x21000074</c> tree. Like the other gm* controllers, this class only
/// finds children by retail id and attaches live providers; it does not recreate DAT chrome.
/// </summary>
public sealed class RadarController
{
public const uint LayoutId = 0x21000074u;
/// <summary>Production layout property 0x1000002D, recovered directly from the retail DAT.</summary>
public const int RadarPixelRadius = 50;
public const uint RootId = 0x100006D3u;
public const uint CoordinateContainerId = 0x1000003Eu;
public const uint RadarDiscId = 0x1000003Fu;
public const uint NorthTokenId = 0x10000040u;
public const uint EastTokenId = 0x10000041u;
public const uint SouthTokenId = 0x10000042u;
public const uint WestTokenId = 0x10000043u;
public const uint LockButtonId = 0x10000619u;
public const uint DragButtonId = 0x100006A3u;
private static readonly Vector4 CoordinateColor = Vector4.One;
private readonly UiRadar _radar;
private readonly UiElement? _coordinateContainer;
private readonly UiText? _coordinateText;
private readonly UiButton? _lockButton;
private readonly UiElement? _dragButton;
private readonly Action<bool>? _setUiLocked;
private readonly CompassToken[] _tokens;
private float _lastHeading = float.NaN;
private string? _lastCoordinates;
private bool _coordinatesInitialized;
private bool? _lastUiLocked;
private RadarController(
ImportedLayout layout,
Func<UiRadarSnapshot> snapshotProvider,
Action<uint>? selectObject,
Action<uint?>? hoveredObjectChanged,
Action<bool>? setUiLocked,
UiDatFont? datFont)
{
_radar = layout.Root as UiRadar
?? throw new ArgumentException(
$"Layout root must be {nameof(UiRadar)} (retail class 0x{UiRadar.RetailClassId:X8}).",
nameof(layout));
_radar.Center = ResolveCenter(layout);
_radar.SnapshotProvider = () =>
{
var snapshot = snapshotProvider() ?? UiRadarSnapshot.Empty;
ApplyPresentation(snapshot);
return snapshot;
};
_radar.SelectObject = selectObject;
_radar.HoveredObjectChanged = hoveredObjectChanged;
_setUiLocked = setUiLocked;
_coordinateContainer = layout.FindElement(CoordinateContainerId);
_coordinateText = CreateCoordinateText(_coordinateContainer, datFont);
_lockButton = layout.FindElement(LockButtonId) as UiButton;
_dragButton = layout.FindElement(DragButtonId);
if (_lockButton is not null && _setUiLocked is not null)
_lockButton.OnClick = () => _setUiLocked(!(_lastUiLocked ?? false));
_tokens =
[
CreateToken(layout.FindElement(NorthTokenId), RadarCompassPoint.North, _radar.Center),
CreateToken(layout.FindElement(EastTokenId), RadarCompassPoint.East, _radar.Center),
CreateToken(layout.FindElement(SouthTokenId), RadarCompassPoint.South, _radar.Center),
CreateToken(layout.FindElement(WestTokenId), RadarCompassPoint.West, _radar.Center),
];
// Retail moves the radar only from its dedicated drag affordance. Making that
// imported Type-2 element hit-testable lets UiRoot's existing draggable-window
// machinery move the UiRadar ancestor while ordinary radar clicks select blips.
if (_dragButton is not null)
_dragButton.ClickThrough = false;
_radar.Draggable = true;
_radar.ResizeX = false;
_radar.ResizeY = false;
_radar.Refresh();
}
/// <summary>
/// Bind the projected snapshot provider and interaction callbacks to an imported radar.
/// No external per-frame call is required; <see cref="UiRadar"/> polls at retail's 25 ms cadence.
/// </summary>
public static RadarController Bind(
ImportedLayout layout,
Func<UiRadarSnapshot> snapshotProvider,
Action<uint>? selectObject = null,
Action<uint?>? hoveredObjectChanged = null,
Action<bool>? setUiLocked = null,
UiDatFont? datFont = null)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(snapshotProvider);
return new RadarController(
layout,
snapshotProvider,
selectObject,
hoveredObjectChanged,
setUiLocked,
datFont);
}
private void ApplyPresentation(UiRadarSnapshot snapshot)
{
if (!snapshot.PlayerHeadingDegrees.Equals(_lastHeading))
{
for (int i = 0; i < _tokens.Length; i++)
{
ref readonly var token = ref _tokens[i];
if (token.Element is null)
continue;
var p = RetailRadar.GetCompassTokenTopLeft(
snapshot.PlayerHeadingDegrees,
token.Point,
_radar.Center,
token.Magnitude,
new Vector2(token.Element.Width, token.Element.Height));
token.Element.Left = p.X;
token.Element.Top = p.Y;
}
_lastHeading = snapshot.PlayerHeadingDegrees;
}
if (!_coordinatesInitialized
|| !string.Equals(snapshot.CoordinatesText, _lastCoordinates, StringComparison.Ordinal))
{
_coordinatesInitialized = true;
_lastCoordinates = snapshot.CoordinatesText;
bool visible = !string.IsNullOrEmpty(snapshot.CoordinatesText);
if (_coordinateContainer is not null)
_coordinateContainer.Visible = visible;
if (_coordinateText is not null)
_coordinateText.Visible = visible;
}
if (_lastUiLocked != snapshot.UiLocked)
{
_lastUiLocked = snapshot.UiLocked;
_radar.Draggable = !snapshot.UiLocked;
if (_dragButton is not null)
_dragButton.Visible = !snapshot.UiLocked;
if (_lockButton is not null)
_lockButton.ActiveState = snapshot.UiLocked ? "LockedUI" : "UnlockedUI";
}
}
private static Vector2 ResolveCenter(ImportedLayout layout)
{
// ElementReader intentionally imports the common LayoutDesc fields only; gmRadarUI's
// custom center property 0x1000002E is not exposed yet. Derive the exact production
// center (60,60) from the real 120x120 disc instead of duplicating it. Radius property
// 0x1000002D is the recovered RadarPixelRadius constant above.
if (layout.FindElement(RadarDiscId) is { } disc)
return new Vector2(disc.Left + disc.Width * 0.5f, disc.Top + disc.Height * 0.5f);
return new Vector2(layout.Root.Width * 0.5f, MathF.Min(120f, layout.Root.Height) * 0.5f);
}
private static CompassToken CreateToken(UiElement? element, RadarCompassPoint point, Vector2 center)
{
if (element is null)
return new CompassToken(null, 0f, point);
var initialCenter = new Vector2(
element.Left + element.Width * 0.5f,
element.Top + element.Height * 0.5f);
return new CompassToken(element, Vector2.Distance(initialCenter, center), point);
}
private UiText? CreateCoordinateText(UiElement? container, UiDatFont? datFont)
{
if (container is null)
return null;
if (container is UiText importedText)
{
// 0x1000003E inherits UIElement_Text from its base layout. Reuse that imported
// behavioral widget so its 0x06004CC0 background stays the actual coordinate
// strip and no duplicate text node is needed.
importedText.Centered = true;
importedText.Padding = 0f;
importedText.DatFont = datFont ?? importedText.DatFont;
importedText.ClickThrough = true;
importedText.AcceptsFocus = false;
importedText.IsEditControl = false;
importedText.CapturesPointerDrag = false;
importedText.LinesProvider = CoordinateLines;
return importedText;
}
var text = new UiText
{
Name = "gmRadarUI.Coordinates",
Left = 0f,
Top = 0f,
Width = container.Width,
Height = container.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
Centered = true,
Padding = 0f,
DatFont = datFont,
ClickThrough = true,
AcceptsFocus = false,
IsEditControl = false,
CapturesPointerDrag = false,
ZOrder = int.MaxValue,
LinesProvider = CoordinateLines,
};
container.AddChild(text);
return text;
}
private IReadOnlyList<UiText.Line> CoordinateLines()
=> string.IsNullOrEmpty(_lastCoordinates)
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(_lastCoordinates, CoordinateColor) };
private readonly record struct CompassToken(
UiElement? Element,
float Magnitude,
RadarCompassPoint Point);
}

View file

@ -0,0 +1,149 @@
using System.Numerics;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Physics.Motion;
using AcDream.Core.Ui;
using AcDream.Core.World;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Joins acdream's existing two-table world model into the immutable projected frame
/// consumed by <see cref="UiRadar"/>. This is the App-layer equivalent of retail's
/// <c>gmRadarUI::DrawObjects</c> object-maintenance walk: Core still owns every
/// AC-specific classification and math decision, while the retained widget remains a
/// backend-only renderer.
/// </summary>
public sealed class RadarSnapshotProvider
{
private static readonly Vector2 ProductionCenter = new(60f, 60f);
private readonly ClientObjectTable _objects;
private readonly IReadOnlyDictionary<uint, WorldEntity> _worldEntities;
private readonly IReadOnlyDictionary<uint, WorldSession.EntitySpawn> _spawns;
private readonly Func<uint> _playerGuid;
private readonly Func<float> _playerYawRadians;
private readonly Func<uint> _playerCellId;
private readonly Func<uint?> _selectedGuid;
private readonly Func<bool> _coordinatesOnRadar;
private readonly Func<bool> _uiLocked;
private readonly Func<uint, RadarRelationshipTraits>? _relationshipFor;
public RadarSnapshotProvider(
ClientObjectTable objects,
IReadOnlyDictionary<uint, WorldEntity> worldEntities,
IReadOnlyDictionary<uint, WorldSession.EntitySpawn> spawns,
Func<uint> playerGuid,
Func<float> playerYawRadians,
Func<uint> playerCellId,
Func<uint?> selectedGuid,
Func<bool> coordinatesOnRadar,
Func<bool> uiLocked,
Func<uint, RadarRelationshipTraits>? relationshipFor = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_worldEntities = worldEntities ?? throw new ArgumentNullException(nameof(worldEntities));
_spawns = spawns ?? throw new ArgumentNullException(nameof(spawns));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_playerYawRadians = playerYawRadians ?? throw new ArgumentNullException(nameof(playerYawRadians));
_playerCellId = playerCellId ?? throw new ArgumentNullException(nameof(playerCellId));
_selectedGuid = selectedGuid ?? throw new ArgumentNullException(nameof(selectedGuid));
_coordinatesOnRadar = coordinatesOnRadar ?? throw new ArgumentNullException(nameof(coordinatesOnRadar));
_uiLocked = uiLocked ?? throw new ArgumentNullException(nameof(uiLocked));
_relationshipFor = relationshipFor;
}
public UiRadarSnapshot BuildSnapshot()
{
bool uiLocked = _uiLocked();
uint playerGuid = _playerGuid();
if (playerGuid == 0u || !_worldEntities.TryGetValue(playerGuid, out var playerEntity))
return UiRadarSnapshot.Empty with { UiLocked = uiLocked };
float heading = MoveToMath.HeadingFromYaw(_playerYawRadians());
uint playerCellId = _playerCellId();
bool isOutside = RadarCoordinates.TryFromCell(playerCellId, out var playerCoordinates);
string? coordinates = _coordinatesOnRadar() && isOutside
? playerCoordinates.CombinedText
: null;
uint playerPwd = _spawns.TryGetValue(playerGuid, out var playerSpawn)
? playerSpawn.ObjectDescriptionFlags ?? 0u
: 0u;
var playerTraits = RadarObjectTraits.FromPublicWeenieDescription(
(uint)(_objects.Get(playerGuid)?.Type ?? ItemType.None), playerPwd);
float range = RetailRadar.GetRangeMeters(isOutside);
var blips = new List<UiRadarBlip>(_worldEntities.Count);
foreach (var pair in _worldEntities)
{
uint guid = pair.Key;
if (guid == playerGuid)
continue;
var entity = pair.Value;
var clientObject = _objects.Get(guid);
_spawns.TryGetValue(guid, out var spawn);
byte? rawBehavior = clientObject?.RadarBehavior ?? spawn.RadarBehavior;
if (rawBehavior is null
|| !RetailRadar.IsShowable((RadarBehavior)rawBehavior.Value, hasPhysicsObject: true))
{
continue;
}
uint itemType = (uint)(clientObject?.Type ?? ItemType.None);
if (itemType == 0u)
itemType = spawn.ItemType ?? 0u;
uint pwd = spawn.ObjectDescriptionFlags ?? 0u;
byte colorOverride = clientObject?.RadarBlipColor ?? spawn.RadarBlipColor ?? 0;
var traits = RadarObjectTraits.FromPublicWeenieDescription(
itemType, pwd, colorOverride);
var relationship = _relationshipFor?.Invoke(guid) ?? default;
relationship = relationship with
{
PlayerIsPlayerKiller = playerTraits.IsPlayerKiller,
PlayerIsPkLite = playerTraits.IsPkLite,
};
var shape = RetailRadar.GetBlipShape(traits, relationship);
if (shape == RadarBlipShape.Undef)
continue;
Vector3 playerSpace = MoveToMath.GlobalToLocalVec(
playerEntity.Rotation,
entity.Position - playerEntity.Position);
if (!RetailRadar.TryProject(
playerSpace,
ProductionCenter,
RadarController.RadarPixelRadius,
range,
out var projection))
{
continue;
}
var color = RadarBlipColors.For(traits, relationship)
.DimRgb(projection.RgbMultiplier);
string? name = clientObject?.Name;
if (string.IsNullOrEmpty(name))
name = spawn.Name ?? $"0x{guid:X8}";
blips.Add(new UiRadarBlip(
ObjectId: guid,
Name: name,
PixelX: projection.Pixel.X,
PixelY: projection.Pixel.Y,
Color: new Vector4(color.Red, color.Green, color.Blue, color.Alpha),
Shape: shape,
Selected: _selectedGuid() == guid));
}
return new UiRadarSnapshot(
PlayerHeadingDegrees: heading,
Blips: blips,
CoordinatesText: coordinates,
BlankBlips: false,
UiLocked: uiLocked);
}
}

View file

@ -156,6 +156,10 @@ public abstract class UiElement
/// whose Left/Top are screen coordinates (Root sits at the origin).</summary>
public bool Draggable { get; set; }
/// <summary>Clamp a dragged top-level window fully inside its parent. Retail
/// <c>gmRadarUI::MoveTo</c> enables this; most windows retain the toolkit default.</summary>
public bool ConstrainDragToParent { get; set; }
/// <summary>If true, a left-drag starting near this element's edge/corner
/// resizes it (window resize). Intended for top-level panels.</summary>
public bool Resizable { get; set; }

View file

@ -0,0 +1,239 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Ui;
namespace AcDream.App.UI;
/// <summary>
/// One already-projected radar object. <paramref name="PixelX"/> and
/// <paramref name="PixelY"/> are local pixels in the 120x120 radar disc. World-to-player
/// projection stays in the Core retail port; this record is the backend-facing draw seam.
/// </summary>
public readonly record struct UiRadarBlip(
uint ObjectId,
string Name,
float PixelX,
float PixelY,
Vector4 Color,
RadarBlipShape Shape,
bool Selected = false);
/// <summary>
/// Immutable frame consumed by <see cref="UiRadar"/>. A null
/// <paramref name="CoordinatesText"/> hides retail's coordinate strip (indoors or when
/// CoordinatesOnRadar is disabled). <paramref name="BlankBlips"/> mirrors
/// <c>ClientUISystem::m_bRadarBlank</c>: static chrome and the player marker remain visible.
/// </summary>
public sealed record UiRadarSnapshot(
float PlayerHeadingDegrees,
IReadOnlyList<UiRadarBlip> Blips,
string? CoordinatesText,
bool BlankBlips = false,
bool UiLocked = false)
{
public static UiRadarSnapshot Empty { get; } = new(
0f,
Array.Empty<UiRadarBlip>(),
null);
}
/// <summary>
/// Retained-mode implementation of retail <c>gmRadarUI</c>, registered as class id
/// <c>0x10000010</c>. The imported LayoutDesc supplies all static sprites; this widget
/// draws the dynamic object blips and fixed player marker after those children.
///
/// <para>Retail references:</para>
/// <list type="bullet">
/// <item><c>gmRadarUI::DrawBlip</c> 0x004D8A30 and shape helpers 0x004D7C30-0x004D8531.</item>
/// <item><c>gmRadarUI::DrawChildren</c> 0x004D9720 (static children, objects, player marker).</item>
/// <item><c>gmRadarUI::UseTime</c> 0x004D98A0 (25 ms refresh cadence).</item>
/// </list>
/// </summary>
public sealed class UiRadar : UiElement
{
public const uint RetailClassId = 0x10000010u;
public const float RetailRefreshSeconds = RetailRadar.UpdateIntervalSeconds;
public const float HoverRadiusPixels = 6f;
private static readonly Vector4 PlayerMarkerColor = new(0f, 1f, 0f, 1f);
private UiRadarSnapshot _snapshot = UiRadarSnapshot.Empty;
private double _refreshAccumulator;
private uint? _hoveredObjectId;
private string? _hoveredObjectName;
/// <summary>Local center of the radar disc. Filled by <c>RadarController</c> from LayoutDesc 0x21000074.</summary>
public Vector2 Center { get; set; } = new(60f, 60f);
/// <summary>Polled at retail's 25 ms cadence. The provider performs Core-to-UI adaptation.</summary>
public Func<UiRadarSnapshot>? SnapshotProvider { get; set; }
/// <summary>Invoked when the user clicks the nearest blip under the pointer.</summary>
public Action<uint>? SelectObject { get; set; }
/// <summary>Optional host hook for target highlights/status text.</summary>
public Action<uint?>? HoveredObjectChanged { get; set; }
public UiRadarSnapshot Snapshot => _snapshot;
public uint? HoveredObjectId => _hoveredObjectId;
/// <summary>The radar itself must receive a click inside a draggable window; its dedicated
/// drag-handle child remains a non-HandlesClick target so UiRoot moves the window from there.</summary>
public override bool HandlesClick => true;
/// <summary>Apply one projected frame immediately. Used once by the binder, then by the 25 ms tick.</summary>
public void ApplySnapshot(UiRadarSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
_snapshot = snapshot;
// An object can disappear between pointer events. Clear stale hover immediately so a
// later click cannot select a guid that is no longer in the provider snapshot.
if (_hoveredObjectId is uint hovered)
{
bool stillPresent = false;
for (int i = 0; i < snapshot.Blips.Count; i++)
{
if (snapshot.Blips[i].ObjectId == hovered)
{
stillPresent = true;
break;
}
}
if (!stillPresent)
SetHovered(null, null);
}
}
/// <summary>Poll the provider now, without waiting for the next retained-tree tick.</summary>
public void Refresh()
{
if (SnapshotProvider is { } provider)
ApplySnapshot(provider() ?? UiRadarSnapshot.Empty);
}
protected override void OnTick(double deltaSeconds)
{
if (SnapshotProvider is null)
return;
_refreshAccumulator += deltaSeconds;
if (_refreshAccumulator < RetailRefreshSeconds)
return;
// Retail schedules the next update from Timer::cur_time rather than replaying missed
// ticks. Keep only the sub-interval remainder after a long frame.
_refreshAccumulator %= RetailRefreshSeconds;
Refresh();
}
protected override bool OnHitTest(float localX, float localY)
{
bool inside = base.OnHitTest(localX, localY);
if (!inside)
{
SetHovered(null, null);
return false;
}
UpdateHoveredBlip(localX, localY);
return true;
}
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.HoverLeave)
{
SetHovered(null, null);
return false;
}
if (e.Type == UiEventType.Click && _hoveredObjectId is uint guid)
{
SelectObject?.Invoke(guid);
return SelectObject is not null;
}
return false;
}
public override string? GetTooltipText() => _hoveredObjectName;
protected override void OnDrawAfterChildren(UiRenderContext ctx)
{
// Retail order is UIRegion::DrawChildren -> DrawObjects -> fixed bright-green player
// marker (gmRadarUI::DrawChildren 0x004D9720). OnDrawAfterChildren gives the retained
// tree that exact composition order without reimplementing any static DAT sprite.
if (!_snapshot.BlankBlips)
{
for (int i = 0; i < _snapshot.Blips.Count; i++)
DrawBlip(ctx, _snapshot.Blips[i]);
}
int cx = RoundPixel(Center.X);
int cy = RoundPixel(Center.Y);
DrawPixels(ctx, cx, cy, PlayerMarkerColor, RetailRadar.PlayerMarkerPixels);
}
private void UpdateHoveredBlip(float localX, float localY)
{
uint? nearestId = null;
string? nearestName = null;
float nearestDistanceSquared = HoverRadiusPixels * HoverRadiusPixels;
if (!_snapshot.BlankBlips)
{
for (int i = 0; i < _snapshot.Blips.Count; i++)
{
var blip = _snapshot.Blips[i];
float dx = localX - blip.PixelX;
float dy = localY - blip.PixelY;
float distanceSquared = dx * dx + dy * dy;
if (distanceSquared <= nearestDistanceSquared)
{
nearestDistanceSquared = distanceSquared;
nearestId = blip.ObjectId;
nearestName = blip.Name;
}
}
}
SetHovered(nearestId, nearestName);
}
private void SetHovered(uint? id, string? name)
{
if (_hoveredObjectId == id && _hoveredObjectName == name)
return;
_hoveredObjectId = id;
_hoveredObjectName = name;
HoveredObjectChanged?.Invoke(id);
}
private static void DrawBlip(UiRenderContext ctx, in UiRadarBlip blip)
{
int x = RoundPixel(blip.PixelX);
int y = RoundPixel(blip.PixelY);
DrawPixels(ctx, x, y, blip.Color, RetailRadar.GetBlipPixels(blip.Shape));
if (blip.Selected)
DrawPixels(ctx, x, y, blip.Color, RetailRadar.SelectionPixels);
}
private static void DrawPixels(
UiRenderContext ctx,
int centerX,
int centerY,
Vector4 color,
ReadOnlySpan<RadarPixelOffset> offsets)
{
for (int i = 0; i < offsets.Length; i++)
ctx.DrawFill(centerX + offsets[i].X, centerY + offsets[i].Y, 1, 1, color);
}
private static int RoundPixel(float value)
=> checked((int)MathF.Round(value, MidpointRounding.ToEven));
}

View file

@ -68,6 +68,10 @@ public sealed class UiRoot : UiElement
/// <summary>True when a widget holds keyboard focus (e.g. a focused chat input).</summary>
public bool WantsKeyboard => KeyboardFocus is not null;
/// <summary>Retail PlayerModule::LockUI gate. Blocks all retained-window
/// move/resize interactions without disabling their buttons or content.</summary>
public bool UiLocked { get; set; }
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
public UiElement? DragSource { get; private set; }
public object? DragPayload { get; private set; }
@ -79,7 +83,7 @@ public sealed class UiRoot : UiElement
{
var target = Pick(MouseX, MouseY);
var window = FindWindow(target);
return window is { Resizable: true }
return !UiLocked && window is { Resizable: true }
? HitEdges(window, MouseX, MouseY, ResizeGrip)
: ResizeEdges.None;
}
@ -90,7 +94,7 @@ public sealed class UiRoot : UiElement
{
var target = Pick(MouseX, MouseY);
var window = FindWindow(target);
if (target is null || window is not { Draggable: true })
if (UiLocked || target is null || window is not { Draggable: true })
return false;
if (HoverResizeEdges != ResizeEdges.None)
return false;
@ -141,6 +145,9 @@ public sealed class UiRoot : UiElement
/// <summary>Raised when a drag is released over no UI element.</summary>
public event Action<object /*payload*/, int /*x*/, int /*y*/>? DragReleasedOutsideUi;
/// <summary>Raised after a registered top-level window finishes moving.</summary>
public event Action<string, UiElement>? WindowMoved;
private uint _nextEventId = 0x10000001u;
public override void AddChild(UiElement child)
@ -221,8 +228,16 @@ public sealed class UiRoot : UiElement
// Window-move drag takes precedence over drag-drop / hover / fall-through.
if (_windowDragTarget is not null)
{
_windowDragTarget.Left = x - _windowDragOffX;
_windowDragTarget.Top = y - _windowDragOffY;
float left = x - _windowDragOffX;
float top = y - _windowDragOffY;
if (_windowDragTarget.ConstrainDragToParent
&& _windowDragTarget.Parent is { } parent)
{
left = Math.Clamp(left, 0f, Math.Max(0f, parent.Width - _windowDragTarget.Width));
top = Math.Clamp(top, 0f, Math.Max(0f, parent.Height - _windowDragTarget.Height));
}
_windowDragTarget.Left = left;
_windowDragTarget.Top = top;
return;
}
@ -285,7 +300,7 @@ public sealed class UiRoot : UiElement
var window = FindWindow(target);
// Retail-faithful: pressing on a window raises it above its peers.
if (window is not null) BringToFront(window);
if (btn == UiMouseButton.Left && window is not null)
if (btn == UiMouseButton.Left && window is not null && !UiLocked)
{
var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None;
if (edges != ResizeEdges.None)
@ -370,8 +385,15 @@ public sealed class UiRoot : UiElement
if (_windowDragTarget is not null)
{
var movedWindow = _windowDragTarget;
_windowDragTarget = null;
ReleaseCapture();
foreach (var pair in _windows)
if (ReferenceEquals(pair.Value, movedWindow))
{
WindowMoved?.Invoke(pair.Key, movedWindow);
break;
}
return;
}

View file

@ -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";
}

View file

@ -187,7 +187,13 @@ public static class CreateObject
uint? Priority = null,
int? Structure = null,
int? MaxStructure = null,
float? Workmanship = null);
float? Workmanship = null,
// PublicWeenieDesc optional-tail bytes (_blipColor/_radar_enum at
// acclient.h:37191-37192; retail UnPack 0x005AD470 reads both as u8).
// Nullable preserves the wire distinction between an absent flag and
// an explicitly transmitted zero (the enum's undefined/default value).
byte? RadarBlipColor = null,
byte? RadarBehavior = null);
/// <summary>
/// The relevant subset of the server-sent <c>MovementData</c> /
@ -720,8 +726,8 @@ public static class CreateObject
// 0x00010000 ValidLocations u32 (skip)
// 0x00020000 CurrentlyWieldedLocation u32 (skip)
// 0x00040000 Priority u32 (skip)
// 0x00100000 RadarBlipColor u8 (skip)
// 0x00800000 RadarBehavior u8 (skip)
// 0x00100000 RadarBlipColor u8 CAPTURE
// 0x00800000 RadarBehavior u8 CAPTURE
// 0x08000000 PScript u16 (skip)
// 0x01000000 Workmanship f32 (skip)
// 0x00200000 Burden u16 (skip)
@ -742,6 +748,8 @@ public static class CreateObject
uint? useability = null;
float? useRadius = null;
uint? targetType = null;
byte? radarBlipColor = null;
byte? radarBehavior = null;
uint iconOverlayId = 0;
uint iconUnderlayId = 0;
uint uiEffects = 0;
@ -864,12 +872,12 @@ public static class CreateObject
if ((weenieFlags & 0x00100000u) != 0) // RadarBlipColor u8
{
if (body.Length - pos < 1) throw new FormatException("trunc RadarBlipColor");
pos += 1;
radarBlipColor = body[pos]; pos += 1;
}
if ((weenieFlags & 0x00800000u) != 0) // RadarBehavior u8
{
if (body.Length - pos < 1) throw new FormatException("trunc RadarBehavior");
pos += 1;
radarBehavior = body[pos]; pos += 1;
}
if ((weenieFlags & 0x08000000u) != 0) // PScript u16
{
@ -957,7 +965,8 @@ public static class CreateObject
ContainerId: wContainerId, WielderId: wWielderId,
ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation,
Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure,
Workmanship: wWorkmanship);
Workmanship: wWorkmanship,
RadarBlipColor: radarBlipColor, RadarBehavior: radarBehavior);
// Local helper: if we ran out of fields past PhysicsData, still
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).

View file

@ -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);
}

View file

@ -116,7 +116,63 @@ public sealed class WorldSession : IDisposable
// MOVEMENT_TS / SERVER_CONTROLLED_MOVE_TS).
ushort InstanceSequence = 0,
ushort MovementSequence = 0,
ushort ServerControlSequence = 0);
ushort ServerControlSequence = 0,
// PublicWeenieDesc optional-tail bytes. null means the corresponding
// flag was absent; zero means the server explicitly sent the enum's
// undefined/default value.
byte? RadarBlipColor = null,
byte? RadarBehavior = null);
/// <summary>
/// Projects the wire-level CreateObject result into the stable session
/// event payload. Kept as a focused seam so every optional field's
/// absent-versus-explicit-default semantics can be tested without a UDP
/// session.
/// </summary>
internal static EntitySpawn ToEntitySpawn(CreateObject.Parsed parsed) => new(
parsed.Guid,
parsed.Position,
parsed.SetupTableId,
parsed.AnimPartChanges,
parsed.TextureChanges,
parsed.SubPalettes,
parsed.BasePaletteId,
parsed.ObjScale,
parsed.Name,
parsed.ItemType,
parsed.MotionState,
parsed.MotionTableId,
parsed.PhysicsState,
parsed.ObjectDescriptionFlags,
parsed.Friction,
parsed.Elasticity,
parsed.Useability,
parsed.UseRadius,
parsed.TargetType,
parsed.IconId,
parsed.IconOverlayId,
parsed.IconUnderlayId,
parsed.UiEffects,
parsed.WeenieClassId,
parsed.Value,
parsed.StackSize,
parsed.StackSizeMax,
parsed.Burden,
parsed.ItemsCapacity,
parsed.ContainersCapacity,
parsed.ContainerId,
parsed.WielderId,
parsed.ValidLocations,
parsed.CurrentWieldedLocation,
parsed.Priority,
parsed.Structure,
parsed.MaxStructure,
parsed.Workmanship,
InstanceSequence: parsed.InstanceSequence,
MovementSequence: parsed.MovementSequence,
ServerControlSequence: parsed.ServerControlSequence,
RadarBlipColor: parsed.RadarBlipColor,
RadarBehavior: parsed.RadarBehavior);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned;
@ -799,48 +855,7 @@ public sealed class WorldSession : IDisposable
_forcePositionSequence = parsed.Value.ForcePositionSequence;
}
EntitySpawned?.Invoke(new EntitySpawn(
parsed.Value.Guid,
parsed.Value.Position,
parsed.Value.SetupTableId,
parsed.Value.AnimPartChanges,
parsed.Value.TextureChanges,
parsed.Value.SubPalettes,
parsed.Value.BasePaletteId,
parsed.Value.ObjScale,
parsed.Value.Name,
parsed.Value.ItemType,
parsed.Value.MotionState,
parsed.Value.MotionTableId,
parsed.Value.PhysicsState,
parsed.Value.ObjectDescriptionFlags,
parsed.Value.Friction,
parsed.Value.Elasticity,
parsed.Value.Useability,
parsed.Value.UseRadius,
parsed.Value.TargetType,
parsed.Value.IconId,
parsed.Value.IconOverlayId,
parsed.Value.IconUnderlayId,
parsed.Value.UiEffects,
parsed.Value.WeenieClassId,
parsed.Value.Value,
parsed.Value.StackSize,
parsed.Value.StackSizeMax,
parsed.Value.Burden,
parsed.Value.ItemsCapacity,
parsed.Value.ContainersCapacity,
parsed.Value.ContainerId,
parsed.Value.WielderId,
parsed.Value.ValidLocations,
parsed.Value.CurrentWieldedLocation,
parsed.Value.Priority,
parsed.Value.Structure,
parsed.Value.MaxStructure,
parsed.Value.Workmanship,
InstanceSequence: parsed.Value.InstanceSequence,
MovementSequence: parsed.Value.MovementSequence,
ServerControlSequence: parsed.Value.ServerControlSequence));
EntitySpawned?.Invoke(ToEntitySpawn(parsed.Value));
}
}
else if (op == DeleteObject.Opcode)

View file

@ -178,6 +178,10 @@ public sealed class ClientObject
public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order
public uint? Useability { get; set; } // ITEM_USEABLE from PublicWeenieDesc
public uint? TargetType { get; set; } // ITEM_TYPE mask for targeted-use compatibility
/// <summary>Retail PublicWeenieDesc <c>_blipColor</c>; null until supplied by CreateObject.</summary>
public byte? RadarBlipColor { get; set; }
/// <summary>Retail PublicWeenieDesc <c>_radar_enum</c>; null until supplied by CreateObject.</summary>
public byte? RadarBehavior { get; set; }
public int Structure { get; set; } // charges/uses remaining
public int MaxStructure { get; set; }
public float Workmanship { get; set; } // 0..10 (fractional on the wire)
@ -217,7 +221,9 @@ public readonly record struct WeenieData(
int? MaxStructure,
float? Workmanship,
uint? Useability = null,
uint? TargetType = null);
uint? TargetType = null,
byte? RadarBlipColor = null,
byte? RadarBehavior = null);
/// <summary>
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).

View file

@ -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;

View file

@ -3,91 +3,190 @@ using AcDream.Core.Items;
namespace AcDream.Core.Ui;
/// <summary>
/// B.7 (2026-05-15) — port of retail's <c>gmRadarUI::GetBlipColor</c>
/// (named decomp <c>0x004d76f0</c>). Returns the radar / target-indicator
/// colour for an entity based on its <see cref="ItemType"/> and the
/// raw <c>PublicWeenieDesc._bitfield</c> we already parse out of
/// <c>CreateObject</c>.
///
/// <para>
/// Used by the Vivid Target Indicator (Phase B.7) to colour the four
/// corner triangles around the selected entity. Same value retail
/// would have shown on the radar blip — so the indicator + radar agree.
/// </para>
///
/// <para>
/// Dispatch order matches the retail decomp at lines 219913+ of
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>. The
/// PWD bit layout matches <c>acclient.h:6431-6463</c>:
/// <list type="bullet">
/// <item><c>BF_PLAYER = 0x8</c></item>
/// <item><c>BF_PLAYER_KILLER = 0x20</c></item>
/// <item><c>BF_VENDOR = 0x200</c> (byte[1] &amp; 0x02 in retail)</item>
/// <item><c>BF_PORTAL = 0x40000</c></item>
/// <item><c>BF_FREE_PKSTATUS = 0x200000</c> (hostile-flagged player)</item>
/// <item><c>BF_PKLITE_PKSTATUS = 0x2000000</c></item>
/// </list>
/// </para>
///
/// <para>
/// <b>RGBA values</b> are hand-tuned to visually match retail screenshots
/// (yellow creature, red PK, pink PKLite, green vendor, cyan portal,
/// white default). Real <c>RGBAColor_Radar*</c> constants live in retail
/// static data — if they're ever recovered the table can be tightened.
/// </para>
/// Facts used by retail's radar color/shape classification. Relationship state is
/// intentionally separate because fellowship and allegiance membership do not come
/// from <c>PublicWeenieDesc</c>.
/// </summary>
public readonly record struct RadarObjectTraits(
bool IsValid = true,
byte BlipColorOverride = 0,
bool IsPortal = false,
bool IsVendor = false,
bool IsAttackable = false,
bool IsCreature = false,
bool IsPlayer = false,
bool IsAdmin = false,
bool IsHiddenAdmin = false,
bool IsPlayerKiller = false,
bool IsPkLite = false,
bool IsFreePk = false)
{
// PublicWeenieDesc::BitfieldIndex, acclient.h:6431.
private const uint BfPlayer = 0x00000008u;
private const uint BfAttackable = 0x00000010u;
private const uint BfPlayerKiller = 0x00000020u;
private const uint BfHiddenAdmin = 0x00000040u;
private const uint BfVendor = 0x00000200u;
private const uint BfPortal = 0x00040000u;
private const uint BfAdmin = 0x00100000u;
private const uint BfFreePk = 0x00200000u;
private const uint BfPkLite = 0x02000000u;
/// <summary>
/// Build the subset of retail traits present in a CreateObject PWD. Fellowship
/// and allegiance state must still be supplied separately.
/// </summary>
public static RadarObjectTraits FromPublicWeenieDescription(
uint itemType,
uint bitfield,
byte blipColorOverride = 0)
=> new(
IsValid: (bitfield & 0x80000000u) == 0,
BlipColorOverride: blipColorOverride,
IsPortal: (bitfield & BfPortal) != 0,
IsVendor: (bitfield & BfVendor) != 0,
IsAttackable: (bitfield & BfAttackable) != 0,
IsCreature: (itemType & (uint)ItemType.Creature) != 0,
IsPlayer: (bitfield & BfPlayer) != 0,
IsAdmin: (bitfield & BfAdmin) != 0,
IsHiddenAdmin: (bitfield & BfHiddenAdmin) != 0,
IsPlayerKiller: (bitfield & BfPlayerKiller) != 0,
IsPkLite: (bitfield & BfPkLite) != 0,
IsFreePk: (bitfield & BfFreePk) != 0);
}
/// <summary>Relationship facts owned by the client player/fellowship systems.</summary>
public readonly record struct RadarRelationshipTraits(
bool IsFellowshipMember = false,
bool IsFellowshipLeader = false,
bool IsAllegianceMember = false,
bool PlayerIsPlayerKiller = false,
bool PlayerIsPkLite = false);
/// <summary>
/// Exact retail radar palette and <c>gmRadarUI::GetBlipColor</c> dispatch.
/// Named retail anchors: <c>gmRadarUI::GetBlipColor @ 0x004D76F0</c> and
/// <c>RGBAColor_Radar* @ 0x008190E8</c>.
/// </summary>
public static class RadarBlipColors
{
public readonly record struct Rgba(byte R, byte G, byte B, byte A);
/// <summary>
/// Retail stores radar colors as four floats. The full names expose those exact
/// channels; R/G/B/A are compatibility RGBA8 projections for older UI callers.
/// </summary>
public readonly record struct Rgba(float Red, float Green, float Blue, float Alpha)
{
public byte R => ToByte(Red);
public byte G => ToByte(Green);
public byte B => ToByte(Blue);
public byte A => ToByte(Alpha);
public static readonly Rgba Item = new(220, 220, 220, 255); // light grey (default object)
public static readonly Rgba Default = new(255, 255, 255, 255); // white (friendly player)
public static readonly Rgba Creature = new(255, 220, 80, 255); // yellow (NPC / monster)
public static readonly Rgba PlayerKiller = new(255, 64, 64, 255); // red (PK)
public static readonly Rgba PKLite = new(255, 128, 192, 255); // pink (PKLite)
public static readonly Rgba Vendor = new( 64, 192, 64, 255); // green (vendor NPC)
public static readonly Rgba Portal = new( 64, 192, 255, 255); // cyan (portal)
public Rgba DimRgb(float multiplier)
=> new(Red * multiplier, Green * multiplier, Blue * multiplier, Alpha);
/// <summary>ImGui/OpenGL-style packed <c>0xAABBGGRR</c> projection.</summary>
public uint ToAbgr32()
=> ((uint)A << 24) | ((uint)B << 16) | ((uint)G << 8) | R;
private static byte ToByte(float value)
=> (byte)Math.Clamp((int)MathF.Round(value * 255f), 0, 255);
}
// Verbatim float constants at acclient.exe 0x008190E8..0x00819184.
public static readonly Rgba Blue = new(0.25f, 0.660000026f, 1f, 1f);
public static readonly Rgba Gold = new(1f, 0.670000017f, 0f, 1f);
public static readonly Rgba Yellow = new(1f, 1f, 0.5f, 1f);
public static readonly Rgba White = new(1f, 1f, 1f, 1f);
public static readonly Rgba Red = new(1f, 0.25f, 0.389999986f, 1f);
public static readonly Rgba Purple = new(0.75f, 0.389999986f, 1f, 1f);
public static readonly Rgba Pink = new(1f, 0.660000026f, 0.75f, 1f);
public static readonly Rgba Green = new(0f, 0.5f, 0.25f, 1f);
public static readonly Rgba Cyan = new(0f, 1f, 1f, 1f);
public static readonly Rgba BrightGreen = new(0f, 1f, 0f, 1f);
// Retail role aliases initialized at 0x006EC780..0x006F90E7.
public static readonly Rgba Default = White;
public static readonly Rgba Item = White;
public static readonly Rgba Admin = Cyan;
public static readonly Rgba Advocate = Pink;
public static readonly Rgba Creature = Gold;
public static readonly Rgba LifeStone = Blue;
public static readonly Rgba NPC = Yellow;
public static readonly Rgba PlayerKiller = Red;
public static readonly Rgba Portal = Purple;
public static readonly Rgba Sentinel = Cyan;
public static readonly Rgba Vendor = Yellow;
public static readonly Rgba Fellowship = BrightGreen;
public static readonly Rgba FellowshipLeader = BrightGreen;
public static readonly Rgba PKLite = Pink;
/// <summary>
/// Resolve the radar-blip colour for an entity. Caller supplies the
/// raw <see cref="ItemType"/> (from <c>CreateObject.ItemType</c>) and
/// <paramref name="pwdBitfield"/> (from
/// <c>CreateObject.ObjectDescriptionFlags</c>) — both are already
/// parsed and stashed on <c>EntitySpawn</c> at spawn time.
///
/// <para>
/// Returns <see cref="Default"/> for friendly players, <see cref="Creature"/>
/// for NPCs/monsters, <see cref="Item"/> for everything else.
/// Special types (PK, PKLite, Vendor, Portal) win over the base
/// type when their flag is set.
/// </para>
/// Compatibility entry point for metadata already surfaced by CreateObject.
/// Classification which requires fellowship state is available through the
/// explicit-traits overload.
/// </summary>
public static Rgba For(uint itemType, uint pwdBitfield)
=> For(RadarObjectTraits.FromPublicWeenieDescription(itemType, pwdBitfield));
/// <summary>
/// Port of <c>gmRadarUI::GetBlipColor @ 0x004D76F0</c>. An explicit wire
/// override wins; otherwise portal/vendor/creature/player classification runs
/// in retail order, followed by the fellowship override.
/// </summary>
public static Rgba For(
RadarObjectTraits traits,
RadarRelationshipTraits relationship = default)
{
// Special-type early returns. Order matches retail dispatch
// (Portal first, then Vendor) — same target can't logically
// be both, but in case of bit collision retail's order wins.
if ((pwdBitfield & 0x40000u) != 0) return Portal;
if ((pwdBitfield & 0x200u) != 0) return Vendor;
if (!traits.IsValid)
return Default;
bool isCreature = (itemType & (uint)ItemType.Creature) != 0;
bool isPlayer = (pwdBitfield & 0x8u) != 0;
if (traits.BlipColorOverride != 0)
return ForOverride(traits.BlipColorOverride);
// Creature that isn't a player → NPC / monster → yellow.
if (isCreature && !isPlayer)
if (traits.IsPortal)
return Portal;
if (traits.IsVendor)
return Vendor;
if (traits.IsAttackable && traits.IsCreature && !traits.IsPlayer)
return Creature;
if (isPlayer)
{
bool isPK = (pwdBitfield & 0x20u) != 0;
bool isPKLite = (pwdBitfield & 0x2000000u) != 0;
if (isPK) return PlayerKiller;
if (isPKLite) return PKLite;
if (!traits.IsPlayer)
return Default;
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;
}
// Not a special type, not a creature, not a player → an item /
// object on the ground or in the world.
return Item;
}
/// <summary>Retail PWD <c>_blipColor</c> override table (values 1..10).</summary>
public static Rgba ForOverride(byte overrideValue)
=> overrideValue switch
{
1 => Blue,
2 => Gold,
3 => White,
4 => Purple,
5 => Red,
6 => Pink,
7 => Green,
8 => Yellow,
9 => Cyan,
10 => BrightGreen,
_ => Default,
};
}

View file

@ -0,0 +1,275 @@
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.Core.Ui;
/// <summary>Verbatim retail <c>RadarEnum</c> values from <c>acclient.h:6467</c>.</summary>
public enum RadarBehavior : byte
{
Undef = 0,
ShowNever = 1,
ShowMovement = 2,
ShowAttacking = 3,
ShowAlways = 4,
}
/// <summary>Verbatim retail <c>RadarBlipShape</c> values from <c>acclient.h:6575</c>.</summary>
public enum RadarBlipShape
{
Undef = 0,
Circle = 1,
Box = 2,
X = 3,
Plus = 4,
Triangle = 5,
InvertedTriangle = 6,
XBox = 7,
Default = Plus,
AllegianceMember = Box,
FellowshipLeader = Triangle,
Fellowship = InvertedTriangle,
Threat = X,
ThreatAllegiance = XBox,
}
public enum RadarCompassPoint
{
North,
East,
South,
West,
}
public readonly record struct RadarPixelOffset(int X, int Y);
public readonly record struct RadarProjection(Vector2 Pixel, float RgbMultiplier);
/// <summary>
/// Pure retail radar behavior used by the retained UI. The caller supplies positions
/// already converted to player space, matching retail's
/// <c>SmartBox::convert_to_player_space</c> call.
/// </summary>
public static class RetailRadar
{
public const float OutdoorRangeMeters = 75f;
public const float IndoorRangeMeters = 25f;
public const float AltitudeDimThresholdMeters = 5f;
public const float AltitudeDimMultiplier = 0.65f;
public const float UpdateIntervalSeconds = 0.025f;
private static readonly RadarPixelOffset[] PointPixels = [new(0, 0)];
private static readonly RadarPixelOffset[] EdgePixels = [new(0, -1), new(0, 1), new(-1, 0), new(1, 0)];
private static readonly RadarPixelOffset[] CornerPixels = [new(1, 1), new(-1, -1), new(-1, 1), new(1, -1)];
private static readonly RadarPixelOffset[] BoxPixels = [.. EdgePixels, .. CornerPixels];
private static readonly RadarPixelOffset[] XPixels = [new(0, 0), .. CornerPixels];
private static readonly RadarPixelOffset[] PlusPixels = [new(0, 0), .. EdgePixels];
private static readonly RadarPixelOffset[] TrianglePixels = [new(0, 0), new(-1, 1), new(0, 1), new(1, 1)];
private static readonly RadarPixelOffset[] InvertedTrianglePixels = [new(0, 0), new(-1, -1), new(0, -1), new(1, -1)];
private static readonly RadarPixelOffset[] XBoxPixels =
[
.. EdgePixels,
.. CornerPixels,
new(-2, -2),
new(2, -2),
new(-2, 2),
new(2, 2),
];
private static readonly RadarPixelOffset[] PlayerMarkerPixelArray =
[
new(0, 0),
.. EdgePixels,
new(-2, 0),
new(2, 0),
new(0, -2),
new(0, 2),
];
private static readonly RadarPixelOffset[] SelectionPixelArray = BuildSelectionPixels();
/// <summary>
/// <c>ACCWeenieObject::InqShowableOnRadar @ 0x0058C250</c>. The enum names do
/// not imply a live motion/attack test here: retail accepts all three values.
/// </summary>
public static bool IsShowable(RadarBehavior behavior, bool hasPhysicsObject)
=> hasPhysicsObject && behavior is
RadarBehavior.ShowMovement or RadarBehavior.ShowAttacking or RadarBehavior.ShowAlways;
/// <summary><c>gmRadarUI::GetBlipShape @ 0x004D7B60</c>.</summary>
public static RadarBlipShape GetBlipShape(
RadarObjectTraits target,
RadarRelationshipTraits relationship = default)
{
if (!target.IsValid)
return RadarBlipShape.Undef;
if (relationship.IsFellowshipLeader)
return RadarBlipShape.FellowshipLeader;
if (relationship.IsFellowshipMember)
return RadarBlipShape.Fellowship;
if (relationship.IsAllegianceMember)
return RadarBlipShape.AllegianceMember;
if ((target.IsPlayerKiller && relationship.PlayerIsPlayerKiller) ||
(target.IsPkLite && relationship.PlayerIsPkLite))
{
return RadarBlipShape.Threat;
}
return RadarBlipShape.Default;
}
/// <summary>
/// Exact points filled by retail's DrawPoint/Edges/Corners/Cross/X/Triangle/
/// InvertedTriangle/Hollow/XBox functions at <c>0x004D7C30..0x004D8531</c>.
/// </summary>
public static ReadOnlySpan<RadarPixelOffset> GetBlipPixels(RadarBlipShape shape)
=> shape switch
{
RadarBlipShape.Circle => PointPixels,
RadarBlipShape.Box => BoxPixels,
RadarBlipShape.X => XPixels,
RadarBlipShape.Plus => PlusPixels,
RadarBlipShape.Triangle => TrianglePixels,
RadarBlipShape.InvertedTriangle => InvertedTrianglePixels,
RadarBlipShape.XBox => XBoxPixels,
_ => [],
};
/// <summary>
/// Fixed bright-green player marker drawn by <c>gmRadarUI::DrawChildren
/// @ 0x004D9720</c>: a plus with four extra points two pixels from center.
/// </summary>
public static ReadOnlySpan<RadarPixelOffset> PlayerMarkerPixels => PlayerMarkerPixelArray;
/// <summary>Selected-object outline from <c>gmRadarUI::DrawSelected @ 0x004D7FE0</c>.</summary>
public static ReadOnlySpan<RadarPixelOffset> SelectionPixels => SelectionPixelArray;
public static float GetRangeMeters(bool isOutside)
=> isOutside ? OutdoorRangeMeters : IndoorRangeMeters;
/// <summary>
/// Project a player-local position to radar pixels exactly as
/// <c>gmRadarUI::DrawObjects @ 0x004D9380</c>. Objects at or beyond
/// <c>(range - 1 m)</c> are excluded. Positive player-space Y is screen-up.
/// </summary>
public static bool TryProject(
Vector3 playerSpaceMeters,
Vector2 centerPixels,
int radarRadiusPixels,
float radarRangeMeters,
out RadarProjection projection)
{
float inclusionRadius = radarRangeMeters - 1f;
float horizontalDistanceSquared =
(playerSpaceMeters.X * playerSpaceMeters.X) +
(playerSpaceMeters.Y * playerSpaceMeters.Y);
if (horizontalDistanceSquared >= inclusionRadius * inclusionRadius)
{
projection = default;
return false;
}
float scale = radarRadiusPixels / radarRangeMeters;
projection = new RadarProjection(
new Vector2(
(int)(centerPixels.X + (playerSpaceMeters.X * scale)),
(int)(centerPixels.Y - (playerSpaceMeters.Y * scale))),
GetAltitudeRgbMultiplier(playerSpaceMeters.Z));
return true;
}
/// <summary>RGB is dimmed at exactly 5 m; alpha is left unchanged by the caller.</summary>
public static float GetAltitudeRgbMultiplier(float playerSpaceZMeters)
=> MathF.Abs(playerSpaceZMeters) < AltitudeDimThresholdMeters
? 1f
: AltitudeDimMultiplier;
/// <summary>
/// Compute a compass token's top-left pixel from
/// <c>gmRadarUI::UpdateCompassTokens @ 0x004D9060</c>. Heading is the physical
/// player's heading in degrees, not camera yaw.
/// </summary>
public static Vector2 GetCompassTokenTopLeft(
float playerHeadingDegrees,
RadarCompassPoint point,
Vector2 radarCenterPixels,
float tokenMagnitudePixels,
Vector2 tokenSizePixels)
{
// Retail stores headingRadians as float, then performs the trig on x87.
// Preserve that float rounding while using double sin/cos like the x87 path.
float headingRadians = playerHeadingDegrees * 0.0174532924f;
double angle = headingRadians + (point switch
{
RadarCompassPoint.North => Math.PI,
RadarCompassPoint.East => (double)1.57079637f,
RadarCompassPoint.South => 0d,
RadarCompassPoint.West => (double)4.71238899f,
_ => 0d,
});
float offsetX = (float)(Math.Sin(angle) * tokenMagnitudePixels);
float tokenCenterX = (float)(offsetX + radarCenterPixels.X);
float tokenCenterY = (float)((Math.Cos(angle) * tokenMagnitudePixels) + radarCenterPixels.Y);
return new Vector2(
(int)(tokenCenterX - (tokenSizePixels.X * 0.5f)),
(int)(tokenCenterY - (tokenSizePixels.Y * 0.5f)));
}
private static RadarPixelOffset[] BuildSelectionPixels()
{
var pixels = new RadarPixelOffset[20];
int index = 0;
for (int x = -2; x <= 2; x++)
{
pixels[index++] = new RadarPixelOffset(x, 3);
pixels[index++] = new RadarPixelOffset(x, -3);
}
for (int y = -2; y <= 2; y++)
{
pixels[index++] = new RadarPixelOffset(3, y);
pixels[index++] = new RadarPixelOffset(-3, y);
}
return pixels;
}
}
/// <summary>Retail outdoor coordinate text derived from the player's landcell.</summary>
public readonly record struct RadarCoordinates(double X, double Y)
{
public string XText => FormatAxis(X, "W", "E");
public string YText => FormatAxis(Y, "S", "N");
public string CombinedText => $"{YText},{XText}";
/// <summary>
/// Port of <c>CPlayerSystem::InqPlayerCoords @ 0x00560090</c>. Indoor cells
/// fail because retail obtains coordinates through <c>get_landscape_coord</c>.
/// </summary>
public static bool TryFromCell(uint cellId, out RadarCoordinates coordinates)
{
if (!LandDefs.GidToLcoord(cellId, out int lx, out int ly))
{
coordinates = default;
return false;
}
coordinates = new RadarCoordinates(
((lx - 1024) * 0.1) + 0.5,
((ly - 1024) * 0.1) + 0.5);
return true;
}
private static string FormatAxis(double value, string negativeSuffix, string positiveSuffix)
{
string suffix = value < 0d ? negativeSuffix : value > 0d ? positiveSuffix : string.Empty;
return $"{Math.Abs(value).ToString("F1", System.Globalization.CultureInfo.InvariantCulture)}{suffix}";
}
}

View file

@ -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,

View file

@ -7,6 +7,9 @@ using AcDream.UI.Abstractions.Settings;
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>Per-character retained-window position in screen pixels.</summary>
public readonly record struct UiWindowPosition(float X, float Y);
/// <summary>
/// JSON-backed persistence for non-keybind settings (Display today; future
/// tabs Audio / Gameplay / Chat / Character will be added to the same
@ -290,6 +293,70 @@ public sealed class SettingsStore
File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
}
/// <summary>Load a named retained-window position for one character.</summary>
public UiWindowPosition? LoadWindowPosition(string toonKey, string windowName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(toonKey);
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
if (!File.Exists(_path)) return null;
try
{
var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject;
var node = root?["windowPositions"]?[toonKey]?[windowName] as JsonObject;
if (node is null) return null;
float? x = node["x"]?.GetValue<float>();
float? y = node["y"]?.GetValue<float>();
return x is not null && y is not null ? new UiWindowPosition(x.Value, y.Value) : null;
}
catch (Exception ex)
{
Console.WriteLine($"settings: failed to load window position from {_path}: {ex.Message}");
return null;
}
}
/// <summary>
/// Save a named retained-window position under
/// <c>windowPositions[toonKey][windowName]</c>, preserving all settings sections.
/// </summary>
public void SaveWindowPosition(string toonKey, string windowName, UiWindowPosition position)
{
ArgumentException.ThrowIfNullOrWhiteSpace(toonKey);
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
var dir = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
JsonObject root;
if (File.Exists(_path))
{
try { root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject(); }
catch { root = new JsonObject(); }
}
else
{
root = new JsonObject();
}
if (root["windowPositions"] is not JsonObject positions)
{
positions = new JsonObject();
root["windowPositions"] = positions;
}
if (positions[toonKey] is not JsonObject toon)
{
toon = new JsonObject();
positions[toonKey] = toon;
}
toon[windowName] = new JsonObject
{
["x"] = position.X,
["y"] = position.Y,
};
root["version"] = CurrentSchemaVersion;
File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
}
private static SortedDictionary<string, object> BuildChatObject(ChatSettings c)
=> new(StringComparer.Ordinal)
{

View file

@ -16,6 +16,17 @@ public class DatWidgetFactoryTests
Assert.IsType<UiMeter>(e);
}
[Fact]
public void RetailRadarClass_MakesUiRadar()
{
var e = DatWidgetFactory.Create(
new ElementInfo { Type = UiRadar.RetailClassId, Width = 120, Height = 140 },
NoTex,
null);
Assert.IsType<UiRadar>(e);
}
// ── Test 2: Unknown type → UiDatElement fallback ─────────────────────────
[Fact]

View file

@ -57,6 +57,14 @@ public static class FixtureLoader
public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
=> LoadInfos("chat_21000006.json");
/// <summary>Builds the committed retail radar LayoutDesc 0x21000074 fixture.</summary>
public static ImportedLayout LoadRadar()
=> LayoutImporter.Build(LoadRadarInfos(), _ => (0u, 0, 0), null);
/// <summary>Returns the resolved ElementInfo tree for retail radar LayoutDesc 0x21000074.</summary>
public static AcDream.App.UI.Layout.ElementInfo LoadRadarInfos()
=> LoadInfos("radar_21000074.json");
// ── Shared loader ────────────────────────────────────────────────────────
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)

View file

@ -0,0 +1,159 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Ui;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RadarControllerTests
{
[Fact]
public void Bind_RealFixture_ReusesImportedCoordinateTextAndDatBackground()
{
var layout = FixtureLoader.LoadRadar();
_ = RadarController.Bind(
layout,
() => new UiRadarSnapshot(0f, Array.Empty<UiRadarBlip>(), "42.1N,33.6E"));
var coordinates = Assert.IsType<UiText>(
layout.FindElement(RadarController.CoordinateContainerId));
Assert.Equal(0x06004CC0u, coordinates.BackgroundSprite);
Assert.Empty(coordinates.Children);
Assert.True(coordinates.ClickThrough);
Assert.False(coordinates.AcceptsFocus);
Assert.Equal("42.1N,33.6E", Assert.Single(coordinates.LinesProvider()).Text);
}
[Fact]
public void Bind_UsesImportedChildrenForCompassCoordinatesAndLockChrome()
{
var layout = BuildRadarLayout();
var state = new UiRadarSnapshot(
0f,
Array.Empty<UiRadarBlip>(),
"42.1N,33.6E",
UiLocked: false);
bool? requestedLock = null;
_ = RadarController.Bind(
layout,
() => state,
setUiLocked: value => requestedLock = value);
var radar = Assert.IsType<UiRadar>(layout.Root);
Assert.Equal(new Vector2(60f, 60f), radar.Center);
Assert.True(radar.Draggable);
var coordinateContainer = Assert.IsType<UiDatElement>(
layout.FindElement(RadarController.CoordinateContainerId));
Assert.True(coordinateContainer.Visible);
var coordinateText = Assert.IsType<UiText>(Assert.Single(coordinateContainer.Children));
Assert.Equal("42.1N,33.6E", Assert.Single(coordinateText.LinesProvider()).Text);
var north = layout.FindElement(RadarController.NorthTokenId)!;
Assert.Equal(55f, north.Left);
Assert.Equal(1f, north.Top);
var lockButton = Assert.IsType<UiButton>(layout.FindElement(RadarController.LockButtonId));
Assert.Equal("UnlockedUI", lockButton.ActiveState);
var click = new UiEvent(lockButton.EventId, lockButton, UiEventType.Click);
Assert.True(lockButton.OnEvent(in click));
Assert.True(requestedLock);
state = state with
{
PlayerHeadingDegrees = 90f,
CoordinatesText = null,
UiLocked = true,
};
radar.Refresh();
Assert.False(radar.Draggable);
Assert.False(coordinateContainer.Visible);
Assert.False(coordinateText.Visible);
Assert.Equal("LockedUI", lockButton.ActiveState);
var drag = layout.FindElement(RadarController.DragButtonId)!;
Assert.False(drag.Visible);
Assert.False(drag.ClickThrough);
float northMagnitude = Vector2.Distance(new Vector2(60f, 5.5f), radar.Center);
var expectedNorth = RetailRadar.GetCompassTokenTopLeft(
90f,
RadarCompassPoint.North,
radar.Center,
northMagnitude,
new Vector2(north.Width, north.Height));
Assert.Equal(expectedNorth.X, north.Left);
Assert.Equal(expectedNorth.Y, north.Top);
}
[Fact]
public void Bind_LeavesStaticDatChildrenInRetainedTree()
{
var layout = BuildRadarLayout();
_ = RadarController.Bind(layout, () => UiRadarSnapshot.Empty);
Assert.Equal(8, layout.Root.Children.Count);
Assert.IsType<UiDatElement>(layout.FindElement(RadarController.RadarDiscId));
Assert.IsType<UiButton>(layout.FindElement(RadarController.LockButtonId));
Assert.IsType<UiDatElement>(layout.FindElement(RadarController.DragButtonId));
Assert.False(layout.FindElement(RadarController.CoordinateContainerId)!.Visible);
}
private static ImportedLayout BuildRadarLayout()
{
var root = new ElementInfo
{
Id = RadarController.RootId,
Type = UiRadar.RetailClassId,
Width = 120,
Height = 140,
};
root.Children.Add(Sprite(RadarController.CoordinateContainerId, 0, 120, 120, 18, 0x06004CC0u));
root.Children.Add(Sprite(RadarController.RadarDiscId, 0, 0, 120, 120, 0x06004CC1u));
root.Children.Add(Sprite(RadarController.NorthTokenId, 55, 1, 10, 9, 0x060011FBu));
root.Children.Add(Sprite(RadarController.EastTokenId, 110, 55, 10, 9, 0x06001938u));
root.Children.Add(Sprite(RadarController.SouthTokenId, 55, 110, 10, 9, 0x0600193Au));
root.Children.Add(Sprite(RadarController.WestTokenId, 0, 55, 10, 9, 0x0600193Cu));
var lockButton = new ElementInfo
{
Id = RadarController.LockButtonId,
Type = 1,
X = 6,
Y = 6,
Width = 27,
Height = 27,
};
lockButton.StateMedia["LockedUI"] = (0x060074B7u, 3);
lockButton.StateMedia["UnlockedUI"] = (0x060074B8u, 3);
root.Children.Add(lockButton);
root.Children.Add(Sprite(RadarController.DragButtonId, 87, 6, 27, 27, 0x060074C9u, type: 2));
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
}
private static ElementInfo Sprite(
uint id,
float x,
float y,
float width,
float height,
uint sprite,
uint type = 3)
{
var info = new ElementInfo
{
Id = id,
Type = type,
X = x,
Y = y,
Width = width,
Height = height,
};
info.StateMedia[""] = (sprite, 1);
return info;
}
}

View file

@ -0,0 +1,77 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RadarLayoutConformanceTests
{
[Fact]
public void RadarFixture_HasRetailRootAndAllStaticAssets()
{
var root = FixtureLoader.LoadRadarInfos();
Assert.Equal(RadarController.RootId, root.Id);
Assert.Equal(UiRadar.RetailClassId, root.Type);
Assert.Equal(120f, root.Width);
Assert.Equal(140f, root.Height);
Assert.Equal(8, root.Children.Count);
AssertElement(root, RadarController.CoordinateContainerId, 0, 120, 120, 18, 0x06004CC0u);
AssertElement(root, RadarController.RadarDiscId, 0, 0, 120, 120, 0x06004CC1u);
AssertElement(root, RadarController.NorthTokenId, 55, 1, 10, 9, 0x060011FBu);
AssertElement(root, RadarController.EastTokenId, 110, 55, 10, 9, 0x06001938u);
AssertElement(root, RadarController.SouthTokenId, 55, 110, 10, 9, 0x0600193Au);
AssertElement(root, RadarController.WestTokenId, 0, 55, 10, 9, 0x0600193Cu);
AssertElement(root, RadarController.DragButtonId, 87, 6, 27, 27, 0x060074C9u);
var lockButton = Find(root, RadarController.LockButtonId)!;
Assert.Equal(1u, lockButton.Type);
Assert.Equal(0x060074B7u, lockButton.StateMedia["LockedUI"].File);
Assert.Equal(0x060074B8u, lockButton.StateMedia["UnlockedUI"].File);
}
[Fact]
public void RadarFixture_BuildsDedicatedWidgetAndPreservesStaticChildren()
{
var layout = FixtureLoader.LoadRadar();
Assert.IsType<UiRadar>(layout.Root);
Assert.Equal(8, layout.Root.Children.Count);
var coordinates = Assert.IsType<UiText>(
layout.FindElement(RadarController.CoordinateContainerId));
Assert.Equal(0x06004CC0u, coordinates.BackgroundSprite);
Assert.IsType<UiDatElement>(layout.FindElement(RadarController.RadarDiscId));
Assert.IsType<UiButton>(layout.FindElement(RadarController.LockButtonId));
}
private static void AssertElement(
ElementInfo root,
uint id,
float x,
float y,
float width,
float height,
uint sprite)
{
var element = Find(root, id)!;
Assert.Equal(x, element.X);
Assert.Equal(y, element.Y);
Assert.Equal(width, element.Width);
Assert.Equal(height, element.Height);
Assert.Equal(sprite, element.StateMedia[""].File);
}
private static ElementInfo? Find(ElementInfo node, uint id)
{
if (node.Id == id)
return node;
foreach (var child in node.Children)
{
var found = Find(child, id);
if (found is not null)
return found;
}
return null;
}
}

View file

@ -0,0 +1,36 @@
using System;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text.Json;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>One-off generator for the committed retail radar LayoutDesc fixture.</summary>
public sealed class RadarLayoutFixtureGenerator
{
[Fact(Skip = "manual: regenerates the committed radar fixture; needs the real dats (ACDREAM_DAT_DIR)")]
public void GenerateRadarFixture()
{
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
using var dats = new DatCollection(datDir, DatAccessType.Read);
var info = LayoutImporter.ImportInfos(dats, RadarController.LayoutId);
Assert.NotNull(info);
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
{
IncludeFields = true,
WriteIndented = true,
});
File.WriteAllText(FixturePath(), json);
}
private static string FixturePath([CallerFilePath] string thisFile = "")
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "radar_21000074.json");
}

View file

@ -0,0 +1,146 @@
using System.Numerics;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Ui;
using AcDream.Core.World;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RadarSnapshotProviderTests
{
[Fact]
public void BuildSnapshot_JoinsWorldAndWeenieTables_UsingCoreRetailMath()
{
const uint player = 1u;
const uint monster = 2u;
var objects = new ClientObjectTable();
objects.Ingest(Weenie(player, "Player", ItemType.Creature));
objects.Ingest(Weenie(monster, "Drudge", ItemType.Creature) with
{
RadarBehavior = (byte)RadarBehavior.ShowAlways,
});
var entities = new Dictionary<uint, WorldEntity>
{
[player] = Entity(player, Vector3.Zero, Quaternion.Identity),
[monster] = Entity(monster, new Vector3(0f, 15f, 6f), Quaternion.Identity),
};
var spawns = new Dictionary<uint, WorldSession.EntitySpawn>
{
[player] = Spawn(player) with { ObjectDescriptionFlags = 0x00000008u }, // BF_PLAYER
[monster] = Spawn(monster) with { ObjectDescriptionFlags = 0x00000010u }, // BF_ATTACKABLE
};
uint? selected = monster;
var provider = new RadarSnapshotProvider(
objects, entities, spawns,
playerGuid: () => player,
playerYawRadians: () => MathF.PI / 2f, // retail heading 0 degrees
playerCellId: () => 0xA9B40001u,
selectedGuid: () => selected,
coordinatesOnRadar: () => true,
uiLocked: () => false);
var snapshot = provider.BuildSnapshot();
Assert.Equal(0f, snapshot.PlayerHeadingDegrees, 3);
Assert.Equal(RadarCoordinates.TryFromCell(0xA9B40001u, out var coords)
? coords.CombinedText : null, snapshot.CoordinatesText);
var blip = Assert.Single(snapshot.Blips);
Assert.Equal(monster, blip.ObjectId);
Assert.Equal("Drudge", blip.Name);
Assert.Equal(60f, blip.PixelX);
Assert.Equal(50f, blip.PixelY); // 15 m * 50 px / 75 m
Assert.Equal(RadarBlipShape.Plus, blip.Shape);
Assert.True(blip.Selected);
Assert.Equal(RadarBlipColors.Gold.Red * 0.65f, blip.Color.X, 5);
Assert.Equal(RadarBlipColors.Gold.Green * 0.65f, blip.Color.Y, 5);
}
[Fact]
public void BuildSnapshot_RejectsShowNever_AndHidesIndoorCoordinates()
{
const uint player = 10u;
const uint hidden = 11u;
var objects = new ClientObjectTable();
objects.Ingest(Weenie(player, "Player", ItemType.Creature));
objects.Ingest(Weenie(hidden, "Hidden", ItemType.Portal) with
{
RadarBehavior = (byte)RadarBehavior.ShowNever,
});
var entities = new Dictionary<uint, WorldEntity>
{
[player] = Entity(player, Vector3.Zero, Quaternion.Identity),
[hidden] = Entity(hidden, new Vector3(2f, 2f, 0f), Quaternion.Identity),
};
var spawns = new Dictionary<uint, WorldSession.EntitySpawn>
{
[player] = Spawn(player),
[hidden] = Spawn(hidden),
};
var provider = new RadarSnapshotProvider(
objects, entities, spawns,
playerGuid: () => player,
playerYawRadians: () => 0f,
playerCellId: () => 0xA9B40100u, // EnvCell: no landscape coordinate
selectedGuid: () => null,
coordinatesOnRadar: () => true,
uiLocked: () => true);
var snapshot = provider.BuildSnapshot();
Assert.Empty(snapshot.Blips);
Assert.Null(snapshot.CoordinatesText);
Assert.True(snapshot.UiLocked);
}
private static WorldEntity Entity(uint guid, Vector3 position, Quaternion rotation) => new()
{
Id = guid,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0u,
Position = position,
Rotation = rotation,
MeshRefs = Array.Empty<MeshRef>(),
};
private static WorldSession.EntitySpawn Spawn(uint guid) => new(
Guid: guid,
Position: null,
SetupTableId: null,
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
BasePaletteId: null,
ObjScale: null,
Name: null,
ItemType: null,
MotionState: null,
MotionTableId: null);
private static WeenieData Weenie(uint guid, string name, ItemType type) => new(
Guid: guid,
Name: name,
Type: type,
WeenieClassId: 1u,
IconId: 0u,
IconOverlayId: 0u,
IconUnderlayId: 0u,
Effects: 0u,
Value: null,
StackSize: null,
StackSizeMax: null,
Burden: null,
ContainerId: null,
WielderId: null,
ValidLocations: null,
CurrentWieldedLocation: null,
Priority: null,
ItemsCapacity: null,
ContainersCapacity: null,
Structure: null,
MaxStructure: null,
Workmanship: null);
}

View file

@ -0,0 +1,250 @@
{
"Id": 268437203,
"Type": 268435472,
"X": 0,
"Y": 0,
"Width": 120,
"Height": 140,
"Left": 1,
"Top": 1,
"Right": 1,
"Bottom": 1,
"ReadOrder": 0,
"ZLevel": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {},
"StateCursors": {},
"DefaultStateName": "",
"Children": [
{
"Id": 268435518,
"Type": 12,
"X": 0,
"Y": 120,
"Width": 120,
"Height": 18,
"Left": 0,
"Top": 0,
"Right": 0,
"Bottom": 0,
"ReadOrder": 1,
"ZLevel": 0,
"FontDid": 1073741825,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100682944,
"Item2": 1
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268435519,
"Type": 3,
"X": 0,
"Y": 0,
"Width": 120,
"Height": 120,
"Left": 0,
"Top": 0,
"Right": 0,
"Bottom": 0,
"ReadOrder": 2,
"ZLevel": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100682945,
"Item2": 1
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268435520,
"Type": 3,
"X": 55,
"Y": 1,
"Width": 10,
"Height": 9,
"Left": 0,
"Top": 0,
"Right": 0,
"Bottom": 0,
"ReadOrder": 5,
"ZLevel": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100667899,
"Item2": 1
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268435521,
"Type": 3,
"X": 110,
"Y": 55,
"Width": 10,
"Height": 9,
"Left": 0,
"Top": 0,
"Right": 0,
"Bottom": 0,
"ReadOrder": 6,
"ZLevel": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100669752,
"Item2": 1
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268435522,
"Type": 3,
"X": 55,
"Y": 110,
"Width": 10,
"Height": 9,
"Left": 0,
"Top": 0,
"Right": 0,
"Bottom": 0,
"ReadOrder": 7,
"ZLevel": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100669754,
"Item2": 1
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268437155,
"Type": 2,
"X": 87,
"Y": 6,
"Width": 27,
"Height": 27,
"Left": 0,
"Top": 0,
"Right": 0,
"Bottom": 0,
"ReadOrder": 4,
"ZLevel": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100693193,
"Item2": 3
}
},
"StateCursors": {
"": {
"File": 100688153,
"HotspotX": 16,
"HotspotY": 16,
"IsValid": true
}
},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268437017,
"Type": 1,
"X": 6,
"Y": 6,
"Width": 27,
"Height": 27,
"Left": 0,
"Top": 0,
"Right": 0,
"Bottom": 0,
"ReadOrder": 3,
"ZLevel": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"LockedUI": {
"Item1": 100693175,
"Item2": 3
},
"UnlockedUI": {
"Item1": 100693176,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "Normal",
"Children": []
},
{
"Id": 268435523,
"Type": 3,
"X": 0,
"Y": 55,
"Width": 10,
"Height": 9,
"Left": 0,
"Top": 0,
"Right": 0,
"Bottom": 0,
"ReadOrder": 8,
"ZLevel": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100669756,
"Item2": 1
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
}
]
}

View file

@ -0,0 +1,91 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.Core.Ui;
namespace AcDream.App.Tests.UI;
public sealed class UiRadarTests
{
[Fact]
public void HoverAndClick_SelectNearestProjectedBlip()
{
uint? selected = null;
uint? hovered = null;
var radar = new UiRadar
{
Width = 120,
Height = 140,
SelectObject = guid => selected = guid,
HoveredObjectChanged = guid => hovered = guid,
};
radar.ApplySnapshot(new UiRadarSnapshot(
0f,
[
new UiRadarBlip(0x10u, "Far", 31f, 30f, Vector4.One, RadarBlipShape.Plus),
new UiRadarBlip(0x20u, "Near", 28f, 30f, Vector4.One, RadarBlipShape.Circle),
],
"1.0N,1.0E"));
var root = new UiRoot { Width = 200, Height = 200 };
root.AddChild(radar);
root.OnMouseMove(27, 30);
Assert.Equal(0x20u, radar.HoveredObjectId);
Assert.Equal(0x20u, hovered);
Assert.Equal("Near", radar.GetTooltipText());
root.OnMouseDown(UiMouseButton.Left, 27, 30);
root.OnMouseUp(UiMouseButton.Left, 27, 30);
Assert.Equal(0x20u, selected);
root.OnMouseMove(100, 100);
Assert.Null(radar.HoveredObjectId);
Assert.Null(hovered);
Assert.Null(radar.GetTooltipText());
}
[Fact]
public void BlankBlips_DisablesHoverButKeepsSnapshot()
{
var radar = new UiRadar { Width = 120, Height = 140 };
radar.ApplySnapshot(new UiRadarSnapshot(
0f,
[new UiRadarBlip(0x10u, "Hidden", 30f, 30f, Vector4.One, RadarBlipShape.Plus)],
null,
BlankBlips: true));
var root = new UiRoot { Width = 200, Height = 200 };
root.AddChild(radar);
root.OnMouseMove(30, 30);
Assert.Single(radar.Snapshot.Blips);
Assert.Null(radar.HoveredObjectId);
}
[Fact]
public void Tick_PollsProviderAtRetailTwentyFiveMillisecondCadence()
{
int calls = 0;
var radar = new UiRadar
{
Width = 120,
Height = 140,
SnapshotProvider = () =>
{
calls++;
return UiRadarSnapshot.Empty;
},
};
var root = new UiRoot { Width = 200, Height = 200 };
root.AddChild(radar);
root.Tick(0.024, 24);
Assert.Equal(0, calls);
root.Tick(0.0011, 25);
Assert.Equal(1, calls);
root.Tick(0.100, 125);
Assert.Equal(2, calls); // retail schedules one fresh update; it does not replay four stale ticks
}
}

View file

@ -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()
{

View file

@ -172,6 +172,70 @@ public sealed class CreateObjectTests
Assert.Equal((uint)ItemType.Creature, parsed!.Value.TargetType);
}
// -----------------------------------------------------------------------
// Retail radar: PublicWeenieDesc carries two independently gated bytes.
// Absence is distinct from an explicitly transmitted zero because zero is
// the enum's undefined/default value, not proof the field was omitted.
// -----------------------------------------------------------------------
[Fact]
public void TryParse_NoRadarFlags_LeavesRadarFieldsNull()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x5000000Au,
name: "Unspecified Radar Object",
itemType: (uint)ItemType.Misc);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Null(parsed.Value.RadarBlipColor);
Assert.Null(parsed.Value.RadarBehavior);
}
[Fact]
public void TryParse_RadarFlags_CapturesBothBytesAndContinuesTailWalk()
{
const uint radarBlipColorFlag = 0x00100000u;
const uint radarBehaviorFlag = 0x00800000u;
const uint workmanshipFlag = 0x01000000u;
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x5000000Bu,
name: "Radar Portal",
itemType: (uint)ItemType.Portal,
weenieFlags: radarBlipColorFlag | radarBehaviorFlag | workmanshipFlag,
radarBlipColor: 4,
radarBehavior: 4,
workmanship: 6.25f);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal((byte)4, parsed.Value.RadarBlipColor);
Assert.Equal((byte)4, parsed.Value.RadarBehavior);
Assert.Equal(6.25f, parsed.Value.Workmanship);
}
[Fact]
public void TryParse_RadarFlagsWithZero_PreservesExplicitDefaultValues()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x5000000Cu,
name: "Explicit Radar Defaults",
itemType: (uint)ItemType.Misc,
weenieFlags: 0x00100000u | 0x00800000u,
radarBlipColor: 0,
radarBehavior: 0);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.True(parsed.Value.RadarBlipColor.HasValue);
Assert.Equal((byte)0, parsed.Value.RadarBlipColor.Value);
Assert.True(parsed.Value.RadarBehavior.HasValue);
Assert.Equal((byte)0, parsed.Value.RadarBehavior.Value);
}
// -----------------------------------------------------------------------
// D.5.1 (2026-06-16): IconId was discarded at cs:516 — surface it so the
// action bar / equipment UI can read icon dat ids from spawn messages.
@ -500,6 +564,8 @@ public sealed class CreateObjectTests
uint? currentWieldedLocation = null,
uint? priority = null,
float? workmanship = null,
byte? radarBlipColor = null,
byte? radarBehavior = null,
ushort movementSeq = 0)
{
var bytes = new List<byte>();
@ -562,8 +628,8 @@ public sealed class CreateObjectTests
if ((weenieFlags & 0x00010000u) != 0) WriteU32(bytes, validLocations ?? 0); // ValidLocations u32
if ((weenieFlags & 0x00020000u) != 0) WriteU32(bytes, currentWieldedLocation ?? 0); // CurrentlyWieldedLocation u32
if ((weenieFlags & 0x00040000u) != 0) WriteU32(bytes, priority ?? 0); // Priority u32
if ((weenieFlags & 0x00100000u) != 0) bytes.Add(0); // RadarBlipColor u8
if ((weenieFlags & 0x00800000u) != 0) bytes.Add(0); // RadarBehavior u8
if ((weenieFlags & 0x00100000u) != 0) bytes.Add(radarBlipColor ?? 0); // RadarBlipColor u8
if ((weenieFlags & 0x00800000u) != 0) bytes.Add(radarBehavior ?? 0); // RadarBehavior u8
if ((weenieFlags & 0x08000000u) != 0) WriteU16(bytes, 0); // PScript u16
if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32
{

View file

@ -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);
}
// -------------------------------------------------------------------------

View file

@ -0,0 +1,44 @@
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests;
public sealed class WorldSessionRadarTests
{
[Fact]
public void ToEntitySpawn_PreservesRadarBytes()
{
var parsed = MinimalParsed() with
{
RadarBlipColor = 7,
RadarBehavior = 3,
};
var spawn = WorldSession.ToEntitySpawn(parsed);
Assert.Equal((byte)7, spawn.RadarBlipColor);
Assert.Equal((byte)3, spawn.RadarBehavior);
}
[Fact]
public void ToEntitySpawn_PreservesMissingRadarFields()
{
var spawn = WorldSession.ToEntitySpawn(MinimalParsed());
Assert.Null(spawn.RadarBlipColor);
Assert.Null(spawn.RadarBehavior);
}
private static CreateObject.Parsed MinimalParsed() => new(
Guid: 0x50000001u,
Position: null,
SetupTableId: null,
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
BasePaletteId: null,
ObjScale: null,
Name: "Radar Test",
ItemType: null,
MotionState: null,
MotionTableId: null);
}

View file

@ -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()
{

View file

@ -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());
}
}

View file

@ -0,0 +1,209 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Ui;
namespace AcDream.Core.Tests.Ui;
public sealed class RetailRadarTests
{
[Theory]
[InlineData(RadarBehavior.Undef, true, false)]
[InlineData(RadarBehavior.ShowNever, true, false)]
[InlineData(RadarBehavior.ShowMovement, true, true)]
[InlineData(RadarBehavior.ShowAttacking, true, true)]
[InlineData(RadarBehavior.ShowAlways, true, true)]
[InlineData(RadarBehavior.ShowAlways, false, false)]
public void Showability_AcceptsRetailEnumValuesWithoutLiveStateChecks(
RadarBehavior behavior,
bool hasPhysics,
bool expected)
=> Assert.Equal(expected, RetailRadar.IsShowable(behavior, hasPhysics));
[Fact]
public void ShapeClassification_UsesFellowshipAllegianceAndMutualPkPriority()
{
var pk = new RadarObjectTraits(IsPlayerKiller: true);
var pkLite = new RadarObjectTraits(IsPkLite: true);
Assert.Equal(RadarBlipShape.Default, RetailRadar.GetBlipShape(pk));
Assert.Equal(
RadarBlipShape.Threat,
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(PlayerIsPlayerKiller: true)));
Assert.Equal(
RadarBlipShape.Threat,
RetailRadar.GetBlipShape(pkLite, new RadarRelationshipTraits(PlayerIsPkLite: true)));
Assert.Equal(
RadarBlipShape.AllegianceMember,
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(
IsAllegianceMember: true,
PlayerIsPlayerKiller: true)));
Assert.Equal(
RadarBlipShape.Fellowship,
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(
IsFellowshipMember: true,
IsAllegianceMember: true,
PlayerIsPlayerKiller: true)));
Assert.Equal(
RadarBlipShape.FellowshipLeader,
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(
IsFellowshipMember: true,
IsFellowshipLeader: true)));
Assert.Equal(
RadarBlipShape.Undef,
RetailRadar.GetBlipShape(new RadarObjectTraits(IsValid: false)));
}
[Fact]
public void BlipPixelShapes_MatchRetailDrawRoutines()
{
AssertOffsets(RadarBlipShape.Circle, [(0, 0)]);
AssertOffsets(RadarBlipShape.Plus, [(0, 0), (0, -1), (0, 1), (-1, 0), (1, 0)]);
AssertOffsets(RadarBlipShape.X, [(0, 0), (1, 1), (-1, -1), (-1, 1), (1, -1)]);
AssertOffsets(
RadarBlipShape.Box,
[(0, -1), (0, 1), (-1, 0), (1, 0), (1, 1), (-1, -1), (-1, 1), (1, -1)]);
AssertOffsets(RadarBlipShape.Triangle, [(0, 0), (-1, 1), (0, 1), (1, 1)]);
AssertOffsets(RadarBlipShape.InvertedTriangle, [(0, 0), (-1, -1), (0, -1), (1, -1)]);
AssertOffsets(
RadarBlipShape.XBox,
[
(0, -1), (0, 1), (-1, 0), (1, 0),
(1, 1), (-1, -1), (-1, 1), (1, -1),
(-2, -2), (2, -2), (-2, 2), (2, 2),
]);
Assert.Empty(RetailRadar.GetBlipPixels(RadarBlipShape.Undef).ToArray());
}
[Fact]
public void CenterMarkerAndSelectionOutline_MatchRetailPixelCountsAndExtents()
{
var player = RetailRadar.PlayerMarkerPixels.ToArray();
Assert.Equal(
new[]
{
new RadarPixelOffset(0, 0),
new RadarPixelOffset(0, -1),
new RadarPixelOffset(0, 1),
new RadarPixelOffset(-1, 0),
new RadarPixelOffset(1, 0),
new RadarPixelOffset(-2, 0),
new RadarPixelOffset(2, 0),
new RadarPixelOffset(0, -2),
new RadarPixelOffset(0, 2),
},
player);
var selected = RetailRadar.SelectionPixels.ToArray();
Assert.Equal(20, selected.Length);
Assert.All(selected, p => Assert.True(Math.Abs(p.X) == 3 || Math.Abs(p.Y) == 3));
for (int coordinate = -2; coordinate <= 2; coordinate++)
{
Assert.Contains(new RadarPixelOffset(coordinate, -3), selected);
Assert.Contains(new RadarPixelOffset(coordinate, 3), selected);
Assert.Contains(new RadarPixelOffset(-3, coordinate), selected);
Assert.Contains(new RadarPixelOffset(3, coordinate), selected);
}
}
[Fact]
public void Projection_UsesRetailRangeScaleAndScreenYAxis()
{
bool visible = RetailRadar.TryProject(
new Vector3(10f, 20f, 4.999f),
new Vector2(60f, 60f),
radarRadiusPixels: 55,
radarRangeMeters: RetailRadar.OutdoorRangeMeters,
out var projection);
Assert.True(visible);
Assert.Equal(new Vector2(67f, 45f), projection.Pixel);
Assert.Equal(1f, projection.RgbMultiplier);
}
[Fact]
public void Projection_ExcludesExactRangeMinusOneBoundary()
{
Assert.True(RetailRadar.TryProject(
new Vector3(73.999f, 0f, 0f),
Vector2.Zero,
55,
RetailRadar.OutdoorRangeMeters,
out _));
Assert.False(RetailRadar.TryProject(
new Vector3(74f, 0f, 0f),
Vector2.Zero,
55,
RetailRadar.OutdoorRangeMeters,
out _));
}
[Theory]
[InlineData(4.999f, 1f)]
[InlineData(-4.999f, 1f)]
[InlineData(5f, 0.65f)]
[InlineData(-5f, 0.65f)]
public void AltitudeDim_UsesStrictFiveMeterThreshold(float z, float expected)
=> Assert.Equal(expected, RetailRadar.GetAltitudeRgbMultiplier(z));
[Fact]
public void CompassTokens_OrbitUsingPhysicalHeading()
{
Vector2 center = new(60f, 60f);
Vector2 size = new(10f, 10f);
Assert.Equal(
new Vector2(55f, 0f),
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.North, center, 55f, size));
Assert.Equal(
// Retail's east offset is the float 1.57079637; after x87 cosine
// and C++ float-to-int truncation its center is one subpixel above 60.
new Vector2(110f, 54f),
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.East, center, 55f, size));
Assert.Equal(
new Vector2(55f, 110f),
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.South, center, 55f, size));
Assert.Equal(
new Vector2(0f, 55f),
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.West, center, 55f, size));
Assert.Equal(
new Vector2(0f, 55f),
RetailRadar.GetCompassTokenTopLeft(90f, RadarCompassPoint.North, center, 55f, size));
}
[Fact]
public void Coordinates_UseOutdoorLandcellAndRetailAxisOrder()
{
uint cellId = LandDefs.LcoordToGid(1358, 1440);
Assert.True(RadarCoordinates.TryFromCell(cellId, out var coordinates));
Assert.Equal(33.9, coordinates.X, precision: 10);
Assert.Equal(42.1, coordinates.Y, precision: 10);
Assert.Equal("33.9E", coordinates.XText);
Assert.Equal("42.1N", coordinates.YText);
Assert.Equal("42.1N,33.9E", coordinates.CombinedText);
}
[Fact]
public void Coordinates_FormatWestSouthAndZeroWithoutDirection()
{
uint cellId = LandDefs.LcoordToGid(900, 800);
Assert.True(RadarCoordinates.TryFromCell(cellId, out var coordinates));
Assert.Equal("11.9W", coordinates.XText);
Assert.Equal("21.9S", coordinates.YText);
cellId = LandDefs.LcoordToGid(1019, 1019);
Assert.True(RadarCoordinates.TryFromCell(cellId, out coordinates));
Assert.Equal("0.0", coordinates.XText);
Assert.Equal("0.0,0.0", coordinates.CombinedText);
}
[Fact]
public void Coordinates_AreUnavailableIndoors()
=> Assert.False(RadarCoordinates.TryFromCell(0xA9B40164u, out _));
private static void AssertOffsets(RadarBlipShape shape, (int X, int Y)[] expected)
=> Assert.Equal(
expected.Select(p => new RadarPixelOffset(p.X, p.Y)),
RetailRadar.GetBlipPixels(shape).ToArray());
}

View file

@ -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);

View file

@ -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);
}
}