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 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`. (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 — **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 capture-harness-first), #116 slide-response. **D.6 retained retail radar/compass**
interleaves at the issue level (next: container-switching — `claude-memory/project_d2b_retail_ui.md`); 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 **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. 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). → LoginComplete, DddInterrogation → response, PlayerTeleport).
- **`spatial/physics.rs`** — dead-reckoning solver (how the client - **`spatial/physics.rs`** — dead-reckoning solver (how the client
advances position between server updates). 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 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`. (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 — **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 capture-harness-first), #116 slide-response. **D.6 retained retail radar/compass**
interleaves at the issue level (next: container-switching — `claude-memory/project_d2b_retail_ui.md`); 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 **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. 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-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-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-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-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-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 | | 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-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-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-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) ## 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. - **✓ 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.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.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.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. - ~~**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. - **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.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 + - **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. - **L.1f** — NPC/monster + item-use animation coverage.
**Freeze on landing:** **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 ### A.2 Radar / compass
The radar is the canonical "small circular polar plot of nearby creatures Retail implements both pieces as one `gmRadarUI` element, not as a radar
with the player at the center". The compass is a thin bar across the top of plus a separate scrolling strip. Primary sources are the named September
the screen showing the 16 cardinal directions as the camera rotates. 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). - 120×140 root (`gmRadarUI`).
- Every nearby object's `CreateObject` / `UpdatePosition` with heading. - Root custom properties: center `(60,60)` and radar pixel radius `50`.
- Per-object `RadarColor` override (hostile = red, green = friendly NPC, - Circular face `0x06004CC1` at `(0,0)`, 120×120.
etc.) + `ObjectFlags2` bits (`0x08` = item, `0x10` = blue-book NPC). - 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`): **Projection (`gmRadarUI::DrawObjects` `0x004D9380`):**
- `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:
```text ```text
for each nearby object obj: relative = object position converted into the physical player's oriented frame
delta = obj.pos - player.pos range = outdoors ? 75 : 25
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) if relative.X² + relative.Y² >= (range - 1)²:
screen.y = radar.top + radar.h/2 - (delta.y / (1.18 * range)) * (radar.h/2) skip
color = PickRadarColor(obj.radar_override, obj.flags2)
DrawQuad2x2(screen, color) 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 The player's own object is excluded. `DrawChildren` paints a fixed
slightly so blips near the edge stay visible before the bezel clips them. bright-green plus at the center after the object blips; it is not a rotating
Player's own arrow is NOT in the blip loop; it is drawn as a fixed arrow. Hover chooses the nearest blip within 6 pixels, exposes its object
centered sprite (the "player dot") rotated by `player.heading`. name as the tooltip, and clicking selects that object.
**Compass — data sources** **Compass tokens (`gmRadarUI::UpdateCompassTokens` `0x004D9060`):**
- `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:
```text ```text
["W", "WNW", "NW", "NNW", "N", "NNE", "NE", "ENE", tokenX = centerX + sin(angle) * tokenMagnitude - tokenWidth/2
"E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW"] 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 - `RadarEnum`: undefined 0, never 1, movement 2, attacking 3, always 4.
function DrawCompassStrip(heading_rad, bar_x, bar_y, bar_w, bar_h): Retail accepts 2/3/4 as showable and does not invent a client-side
heading_deg = (heading_rad * 180 / PI) mod 360 movement/attack filter.
// Texture is 360° wide in world-space; we crop to bar_w centered on heading - The optional PublicWeenieDesc bytes are `RadarBlipColor` (header flag
u_center = heading_deg / 360 // 0..1 `0x00100000`) and `RadarBehavior` (`0x00800000`).
u_half = bar_w / strip_texture_w / 2 - `_blipColor` overrides 1..10 select the exact blue, gold, white, purple,
u_left = u_center - u_half red, pink, green, yellow, cyan, and bright-green retail constants.
u_right = u_center + u_half - Default classification covers portals, vendors, attackable creatures,
DrawSpriteUV(compass_strip_tex, bar_x, bar_y, bar_w, bar_h, visible admins, PK/PKLite/free-PK players, and fellowship overrides.
u0=u_left, u1=u_right, v0=0, v1=1) - 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 For an outdoor cell, `LandDefs::gid_to_lcoord` supplies integer landscape
public void DrawRadar(IUiRenderer r, Rect bounds, float playerHeading, cell X/Y. Each axis becomes `(lcoord - 1024) * 0.1 + 0.5`, then formats as
Vector3 playerPos, IEnumerable<WorldEntity> nearby, float range) `{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.
r.DrawSprite(/*0x06004CC1*/ _radarBgId, bounds);
var cx = bounds.X + bounds.Width * 0.5f; `gmRadarUI::UseTime` refreshes range, tokens, coordinates, and dirty state
var cy = bounds.Y + bounds.Height * 0.5f; every 25 ms (40 Hz). Static art remains data-driven through the retained
LayoutDesc importer; dynamic behavior belongs in a dedicated retained
foreach (var e in nearby) radar widget/controller, with `GameWindow` limited to state providers.
{
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);
}
```
### A.3 Quickbar / hotbar ### A.3 Quickbar / hotbar

View file

@ -79,3 +79,17 @@
2026-04-24 callout block). 2026-04-24 callout block).
- Custom-backend research (applies to D.2b, NOT D.2a): - Custom-backend research (applies to D.2b, NOT D.2a):
`docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. `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; private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
// Phase D.5.3a — selected-object strip controller (name, overlay state, health meter). // Phase D.5.3a — selected-object strip controller (name, overlay state, health meter).
private AcDream.App.UI.Layout.SelectedObjectController? _selectedObjectController; 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). // Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter).
private AcDream.App.UI.Layout.InventoryController? _inventoryController; private AcDream.App.UI.Layout.InventoryController? _inventoryController;
// Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler). // Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler).
@ -1738,6 +1743,11 @@ public sealed class GameWindow : IDisposable
try try
{ {
settingsStore.SaveGameplay(gameplay); 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( Console.WriteLine(
"settings: gameplay saved to " "settings: gameplay saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + 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); _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
_itemInteractionController = new AcDream.App.UI.ItemInteractionController( _itemInteractionController = new AcDream.App.UI.ItemInteractionController(
Objects, Objects,
playerGuid: () => _playerServerGuid, playerGuid: () => _playerServerGuid,
@ -2025,6 +2036,7 @@ public sealed class GameWindow : IDisposable
sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost), sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost),
sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits)); sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits));
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside; _uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
_uiHost.Root.WindowMoved += OnRetailWindowMoved;
// Feed Silk input to the UiRoot tree so windows drag / close / select. // Feed Silk input to the UiRoot tree so windows drag / close / select.
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the // 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."); 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), // Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI),
// the same importer path as vitals. ChatWindowController binds the transcript, // the same importer path as vitals. ChatWindowController binds the transcript,
// input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter. // input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter.
@ -2866,6 +2924,7 @@ public sealed class GameWindow : IDisposable
_liveSession.EnterWorld(user, characterIndex: 0); _liveSession.EnterWorld(user, characterIndex: 0);
_activeToonKey = chosen.Name; _activeToonKey = chosen.Name;
ApplySavedRadarPosition();
if (_settingsStore is not null && _settingsVm is not null) if (_settingsStore is not null && _settingsVm is not null)
{ {
var toonBag = _settingsStore.LoadCharacter(_activeToonKey); var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
@ -11776,6 +11835,62 @@ public sealed class GameWindow : IDisposable
_toolbarController.SetCharacterOpen(_uiHost.IsWindowVisible(AcDream.App.UI.WindowNames.Character)); _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() private void UpdateRetailCursorFeedback()
{ {
if (_uiHost is null || _cursorFeedbackController is null || _retailCursorManager is null || _input is null) 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"); manaText: () => "90/100");
break; 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 case 0x21000016u: // toolbar
ToolbarController.Bind( ToolbarController.Bind(
layout, layout,
@ -156,4 +181,7 @@ public static class FixtureProvider
return handle; 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 UiElement e = info.Type switch
{ {
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828) 1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
6 => new UiMenu(), // UIElement_Menu (reg :120163) 6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter 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> /// whose Left/Top are screen coordinates (Root sits at the origin).</summary>
public bool Draggable { get; set; } 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 /// <summary>If true, a left-drag starting near this element's edge/corner
/// resizes it (window resize). Intended for top-level panels.</summary> /// resizes it (window resize). Intended for top-level panels.</summary>
public bool Resizable { get; set; } 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> /// <summary>True when a widget holds keyboard focus (e.g. a focused chat input).</summary>
public bool WantsKeyboard => KeyboardFocus is not null; 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> /// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
public UiElement? DragSource { get; private set; } public UiElement? DragSource { get; private set; }
public object? DragPayload { get; private set; } public object? DragPayload { get; private set; }
@ -79,7 +83,7 @@ public sealed class UiRoot : UiElement
{ {
var target = Pick(MouseX, MouseY); var target = Pick(MouseX, MouseY);
var window = FindWindow(target); var window = FindWindow(target);
return window is { Resizable: true } return !UiLocked && window is { Resizable: true }
? HitEdges(window, MouseX, MouseY, ResizeGrip) ? HitEdges(window, MouseX, MouseY, ResizeGrip)
: ResizeEdges.None; : ResizeEdges.None;
} }
@ -90,7 +94,7 @@ public sealed class UiRoot : UiElement
{ {
var target = Pick(MouseX, MouseY); var target = Pick(MouseX, MouseY);
var window = FindWindow(target); 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; return false;
if (HoverResizeEdges != ResizeEdges.None) if (HoverResizeEdges != ResizeEdges.None)
return false; return false;
@ -141,6 +145,9 @@ public sealed class UiRoot : UiElement
/// <summary>Raised when a drag is released over no UI element.</summary> /// <summary>Raised when a drag is released over no UI element.</summary>
public event Action<object /*payload*/, int /*x*/, int /*y*/>? DragReleasedOutsideUi; 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; private uint _nextEventId = 0x10000001u;
public override void AddChild(UiElement child) 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. // Window-move drag takes precedence over drag-drop / hover / fall-through.
if (_windowDragTarget is not null) if (_windowDragTarget is not null)
{ {
_windowDragTarget.Left = x - _windowDragOffX; float left = x - _windowDragOffX;
_windowDragTarget.Top = y - _windowDragOffY; 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; return;
} }
@ -285,7 +300,7 @@ public sealed class UiRoot : UiElement
var window = FindWindow(target); var window = FindWindow(target);
// Retail-faithful: pressing on a window raises it above its peers. // Retail-faithful: pressing on a window raises it above its peers.
if (window is not null) BringToFront(window); 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; var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None;
if (edges != ResizeEdges.None) if (edges != ResizeEdges.None)
@ -370,8 +385,15 @@ public sealed class UiRoot : UiElement
if (_windowDragTarget is not null) if (_windowDragTarget is not null)
{ {
var movedWindow = _windowDragTarget;
_windowDragTarget = null; _windowDragTarget = null;
ReleaseCapture(); ReleaseCapture();
foreach (var pair in _windows)
if (ReferenceEquals(pair.Value, movedWindow))
{
WindowMoved?.Invoke(pair.Key, movedWindow);
break;
}
return; return;
} }

View file

@ -9,4 +9,5 @@ public static class WindowNames
public const string Character = "character"; public const string Character = "character";
public const string Inventory = "inventory"; public const string Inventory = "inventory";
public const string Chat = "chat"; public const string Chat = "chat";
public const string Radar = "radar";
} }

View file

@ -187,7 +187,13 @@ public static class CreateObject
uint? Priority = null, uint? Priority = null,
int? Structure = null, int? Structure = null,
int? MaxStructure = 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> /// <summary>
/// The relevant subset of the server-sent <c>MovementData</c> / /// The relevant subset of the server-sent <c>MovementData</c> /
@ -720,8 +726,8 @@ public static class CreateObject
// 0x00010000 ValidLocations u32 (skip) // 0x00010000 ValidLocations u32 (skip)
// 0x00020000 CurrentlyWieldedLocation u32 (skip) // 0x00020000 CurrentlyWieldedLocation u32 (skip)
// 0x00040000 Priority u32 (skip) // 0x00040000 Priority u32 (skip)
// 0x00100000 RadarBlipColor u8 (skip) // 0x00100000 RadarBlipColor u8 CAPTURE
// 0x00800000 RadarBehavior u8 (skip) // 0x00800000 RadarBehavior u8 CAPTURE
// 0x08000000 PScript u16 (skip) // 0x08000000 PScript u16 (skip)
// 0x01000000 Workmanship f32 (skip) // 0x01000000 Workmanship f32 (skip)
// 0x00200000 Burden u16 (skip) // 0x00200000 Burden u16 (skip)
@ -742,6 +748,8 @@ public static class CreateObject
uint? useability = null; uint? useability = null;
float? useRadius = null; float? useRadius = null;
uint? targetType = null; uint? targetType = null;
byte? radarBlipColor = null;
byte? radarBehavior = null;
uint iconOverlayId = 0; uint iconOverlayId = 0;
uint iconUnderlayId = 0; uint iconUnderlayId = 0;
uint uiEffects = 0; uint uiEffects = 0;
@ -864,12 +872,12 @@ public static class CreateObject
if ((weenieFlags & 0x00100000u) != 0) // RadarBlipColor u8 if ((weenieFlags & 0x00100000u) != 0) // RadarBlipColor u8
{ {
if (body.Length - pos < 1) throw new FormatException("trunc RadarBlipColor"); if (body.Length - pos < 1) throw new FormatException("trunc RadarBlipColor");
pos += 1; radarBlipColor = body[pos]; pos += 1;
} }
if ((weenieFlags & 0x00800000u) != 0) // RadarBehavior u8 if ((weenieFlags & 0x00800000u) != 0) // RadarBehavior u8
{ {
if (body.Length - pos < 1) throw new FormatException("trunc RadarBehavior"); if (body.Length - pos < 1) throw new FormatException("trunc RadarBehavior");
pos += 1; radarBehavior = body[pos]; pos += 1;
} }
if ((weenieFlags & 0x08000000u) != 0) // PScript u16 if ((weenieFlags & 0x08000000u) != 0) // PScript u16
{ {
@ -957,7 +965,8 @@ public static class CreateObject
ContainerId: wContainerId, WielderId: wWielderId, ContainerId: wContainerId, WielderId: wWielderId,
ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation, ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation,
Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure, 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 // Local helper: if we ran out of fields past PhysicsData, still
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion). // 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, MaxStructure: s.MaxStructure,
Workmanship: s.Workmanship, Workmanship: s.Workmanship,
Useability: s.Useability, 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). // MOVEMENT_TS / SERVER_CONTROLLED_MOVE_TS).
ushort InstanceSequence = 0, ushort InstanceSequence = 0,
ushort MovementSequence = 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> /// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned; public event Action<EntitySpawn>? EntitySpawned;
@ -799,48 +855,7 @@ public sealed class WorldSession : IDisposable
_forcePositionSequence = parsed.Value.ForcePositionSequence; _forcePositionSequence = parsed.Value.ForcePositionSequence;
} }
EntitySpawned?.Invoke(new EntitySpawn( EntitySpawned?.Invoke(ToEntitySpawn(parsed.Value));
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));
} }
} }
else if (op == DeleteObject.Opcode) 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 Priority { get; set; } // ClothingPriority / CoverageMask layer order
public uint? Useability { get; set; } // ITEM_USEABLE from PublicWeenieDesc public uint? Useability { get; set; } // ITEM_USEABLE from PublicWeenieDesc
public uint? TargetType { get; set; } // ITEM_TYPE mask for targeted-use compatibility 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 Structure { get; set; } // charges/uses remaining
public int MaxStructure { get; set; } public int MaxStructure { get; set; }
public float Workmanship { get; set; } // 0..10 (fractional on the wire) public float Workmanship { get; set; } // 0..10 (fractional on the wire)
@ -217,7 +221,9 @@ public readonly record struct WeenieData(
int? MaxStructure, int? MaxStructure,
float? Workmanship, float? Workmanship,
uint? Useability = null, uint? Useability = null,
uint? TargetType = null); uint? TargetType = null,
byte? RadarBlipColor = null,
byte? RadarBehavior = null);
/// <summary> /// <summary>
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0). /// 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.Priority is { } pr) obj.Priority = pr;
if (d.Useability is { } use) obj.Useability = use; if (d.Useability is { } use) obj.Useability = use;
if (d.TargetType is { } targetType) obj.TargetType = targetType; 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.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc; if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc;
if (d.Structure is { } st) obj.Structure = st; if (d.Structure is { } st) obj.Structure = st;

View file

@ -3,91 +3,190 @@ using AcDream.Core.Items;
namespace AcDream.Core.Ui; namespace AcDream.Core.Ui;
/// <summary> /// <summary>
/// B.7 (2026-05-15) — port of retail's <c>gmRadarUI::GetBlipColor</c> /// Facts used by retail's radar color/shape classification. Relationship state is
/// (named decomp <c>0x004d76f0</c>). Returns the radar / target-indicator /// intentionally separate because fellowship and allegiance membership do not come
/// colour for an entity based on its <see cref="ItemType"/> and the /// from <c>PublicWeenieDesc</c>.
/// raw <c>PublicWeenieDesc._bitfield</c> we already parse out of /// </summary>
/// <c>CreateObject</c>. public readonly record struct RadarObjectTraits(
/// bool IsValid = true,
/// <para> byte BlipColorOverride = 0,
/// Used by the Vivid Target Indicator (Phase B.7) to colour the four bool IsPortal = false,
/// corner triangles around the selected entity. Same value retail bool IsVendor = false,
/// would have shown on the radar blip — so the indicator + radar agree. bool IsAttackable = false,
/// </para> bool IsCreature = false,
/// bool IsPlayer = false,
/// <para> bool IsAdmin = false,
/// Dispatch order matches the retail decomp at lines 219913+ of bool IsHiddenAdmin = false,
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>. The bool IsPlayerKiller = false,
/// PWD bit layout matches <c>acclient.h:6431-6463</c>: bool IsPkLite = false,
/// <list type="bullet"> bool IsFreePk = false)
/// <item><c>BF_PLAYER = 0x8</c></item> {
/// <item><c>BF_PLAYER_KILLER = 0x20</c></item> // PublicWeenieDesc::BitfieldIndex, acclient.h:6431.
/// <item><c>BF_VENDOR = 0x200</c> (byte[1] &amp; 0x02 in retail)</item> private const uint BfPlayer = 0x00000008u;
/// <item><c>BF_PORTAL = 0x40000</c></item> private const uint BfAttackable = 0x00000010u;
/// <item><c>BF_FREE_PKSTATUS = 0x200000</c> (hostile-flagged player)</item> private const uint BfPlayerKiller = 0x00000020u;
/// <item><c>BF_PKLITE_PKSTATUS = 0x2000000</c></item> private const uint BfHiddenAdmin = 0x00000040u;
/// </list> private const uint BfVendor = 0x00000200u;
/// </para> private const uint BfPortal = 0x00040000u;
/// private const uint BfAdmin = 0x00100000u;
/// <para> private const uint BfFreePk = 0x00200000u;
/// <b>RGBA values</b> are hand-tuned to visually match retail screenshots private const uint BfPkLite = 0x02000000u;
/// (yellow creature, red PK, pink PKLite, green vendor, cyan portal,
/// white default). Real <c>RGBAColor_Radar*</c> constants live in retail /// <summary>
/// static data — if they're ever recovered the table can be tightened. /// Build the subset of retail traits present in a CreateObject PWD. Fellowship
/// </para> /// 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> /// </summary>
public static class RadarBlipColors 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 Rgba DimRgb(float multiplier)
public static readonly Rgba Default = new(255, 255, 255, 255); // white (friendly player) => new(Red * multiplier, Green * multiplier, Blue * multiplier, Alpha);
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) /// <summary>ImGui/OpenGL-style packed <c>0xAABBGGRR</c> projection.</summary>
public static readonly Rgba PKLite = new(255, 128, 192, 255); // pink (PKLite) public uint ToAbgr32()
public static readonly Rgba Vendor = new( 64, 192, 64, 255); // green (vendor NPC) => ((uint)A << 24) | ((uint)B << 16) | ((uint)G << 8) | R;
public static readonly Rgba Portal = new( 64, 192, 255, 255); // cyan (portal)
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> /// <summary>
/// Resolve the radar-blip colour for an entity. Caller supplies the /// Compatibility entry point for metadata already surfaced by CreateObject.
/// raw <see cref="ItemType"/> (from <c>CreateObject.ItemType</c>) and /// Classification which requires fellowship state is available through the
/// <paramref name="pwdBitfield"/> (from /// explicit-traits overload.
/// <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>
/// </summary> /// </summary>
public static Rgba For(uint itemType, uint pwdBitfield) 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 if (!traits.IsValid)
// (Portal first, then Vendor) — same target can't logically return Default;
// be both, but in case of bit collision retail's order wins.
if ((pwdBitfield & 0x40000u) != 0) return Portal;
if ((pwdBitfield & 0x200u) != 0) return Vendor;
bool isCreature = (itemType & (uint)ItemType.Creature) != 0; if (traits.BlipColorOverride != 0)
bool isPlayer = (pwdBitfield & 0x8u) != 0; return ForOverride(traits.BlipColorOverride);
// Creature that isn't a player → NPC / monster → yellow. if (traits.IsPortal)
if (isCreature && !isPlayer) return Portal;
if (traits.IsVendor)
return Vendor;
if (traits.IsAttackable && traits.IsCreature && !traits.IsPlayer)
return Creature; return Creature;
if (isPlayer) if (!traits.IsPlayer)
{
bool isPK = (pwdBitfield & 0x20u) != 0;
bool isPKLite = (pwdBitfield & 0x2000000u) != 0;
if (isPK) return PlayerKiller;
if (isPKLite) return PKLite;
return Default; return Default;
}
// Not a special type, not a creature, not a player → an item / Rgba color = Default;
// object on the ground or in the world. if (traits.IsAdmin && !traits.IsHiddenAdmin)
return Item; color = Admin;
else if (traits.IsPlayerKiller)
color = PlayerKiller;
else if (traits.IsPkLite)
color = PKLite;
else if (traits.IsFreePk)
color = Creature;
if (relationship.IsFellowshipLeader)
color = FellowshipLeader;
else if (relationship.IsFellowshipMember)
color = Fellowship;
return color;
} }
/// <summary>Retail PWD <c>_blipColor</c> override table (values 1..10).</summary>
public static Rgba ForOverride(byte overrideValue)
=> overrideValue switch
{
1 => Blue,
2 => Gold,
3 => White,
4 => Purple,
5 => Red,
6 => Pink,
7 => Green,
8 => Yellow,
9 => Cyan,
10 => BrightGreen,
_ => Default,
};
} }

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, ShowTooltips: true,
VividTargetingIndicator: false, VividTargetingIndicator: false,
SideBySideVitals: false, SideBySideVitals: false,
CoordinatesOnRadar: false, // Retail default character-options mask 0x50C4A54A includes
// CoordinatesOnRadar (0x00400000).
CoordinatesOnRadar: true,
SpellDuration: true, SpellDuration: true,
AllowGive: true, AllowGive: true,
ShowHelm: true, ShowHelm: true,

View file

@ -7,6 +7,9 @@ using AcDream.UI.Abstractions.Settings;
namespace AcDream.UI.Abstractions.Panels.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> /// <summary>
/// JSON-backed persistence for non-keybind settings (Display today; future /// JSON-backed persistence for non-keybind settings (Display today; future
/// tabs Audio / Gameplay / Chat / Character will be added to the same /// 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 })); 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) private static SortedDictionary<string, object> BuildChatObject(ChatSettings c)
=> new(StringComparer.Ordinal) => new(StringComparer.Ordinal)
{ {

View file

@ -16,6 +16,17 @@ public class DatWidgetFactoryTests
Assert.IsType<UiMeter>(e); 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 ───────────────────────── // ── Test 2: Unknown type → UiDatElement fallback ─────────────────────────
[Fact] [Fact]

View file

@ -57,6 +57,14 @@ public static class FixtureLoader
public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos() public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
=> LoadInfos("chat_21000006.json"); => 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 ──────────────────────────────────────────────────────── // ── Shared loader ────────────────────────────────────────────────────────
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName) 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); 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] [Fact]
public void NonDraggablePanel_DoesNotMoveOnDrag() public void NonDraggablePanel_DoesNotMoveOnDrag()
{ {

View file

@ -172,6 +172,70 @@ public sealed class CreateObjectTests
Assert.Equal((uint)ItemType.Creature, parsed!.Value.TargetType); 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 // 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. // action bar / equipment UI can read icon dat ids from spawn messages.
@ -500,6 +564,8 @@ public sealed class CreateObjectTests
uint? currentWieldedLocation = null, uint? currentWieldedLocation = null,
uint? priority = null, uint? priority = null,
float? workmanship = null, float? workmanship = null,
byte? radarBlipColor = null,
byte? radarBehavior = null,
ushort movementSeq = 0) ushort movementSeq = 0)
{ {
var bytes = new List<byte>(); 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 & 0x00010000u) != 0) WriteU32(bytes, validLocations ?? 0); // ValidLocations u32
if ((weenieFlags & 0x00020000u) != 0) WriteU32(bytes, currentWieldedLocation ?? 0); // CurrentlyWieldedLocation u32 if ((weenieFlags & 0x00020000u) != 0) WriteU32(bytes, currentWieldedLocation ?? 0); // CurrentlyWieldedLocation u32
if ((weenieFlags & 0x00040000u) != 0) WriteU32(bytes, priority ?? 0); // Priority u32 if ((weenieFlags & 0x00040000u) != 0) WriteU32(bytes, priority ?? 0); // Priority u32
if ((weenieFlags & 0x00100000u) != 0) bytes.Add(0); // RadarBlipColor u8 if ((weenieFlags & 0x00100000u) != 0) bytes.Add(radarBlipColor ?? 0); // RadarBlipColor u8
if ((weenieFlags & 0x00800000u) != 0) bytes.Add(0); // RadarBehavior u8 if ((weenieFlags & 0x00800000u) != 0) bytes.Add(radarBehavior ?? 0); // RadarBehavior u8
if ((weenieFlags & 0x08000000u) != 0) WriteU16(bytes, 0); // PScript u16 if ((weenieFlags & 0x08000000u) != 0) WriteU16(bytes, 0); // PScript u16
if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32 if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32
{ {

View file

@ -59,6 +59,8 @@ public sealed class ObjectTableWiringTests
Workmanship = 4.5f, Workmanship = 4.5f,
Useability = 0x000A0008u, Useability = 0x000A0008u,
TargetType = (uint)ItemType.Creature, TargetType = (uint)ItemType.Creature,
RadarBlipColor = 4,
RadarBehavior = 3,
}; };
var d = ObjectTableWiring.ToWeenieData(spawn); var d = ObjectTableWiring.ToWeenieData(spawn);
@ -100,6 +102,8 @@ public sealed class ObjectTableWiringTests
Assert.Equal(4.5f, d.Workmanship); Assert.Equal(4.5f, d.Workmanship);
Assert.Equal(0x000A0008u, d.Useability); Assert.Equal(0x000A0008u, d.Useability);
Assert.Equal((uint)ItemType.Creature, d.TargetType); 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); 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] [Fact]
public void RecordMembership_CreatesEntry_AndSetsEquip() public void RecordMembership_CreatesEntry_AndSetsEquip()
{ {

View file

@ -1,83 +1,132 @@
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Ui; using AcDream.Core.Ui;
using Xunit;
namespace AcDream.Core.Tests.Ui; namespace AcDream.Core.Tests.Ui;
public sealed class RadarBlipColorsTests public sealed class RadarBlipColorsTests
{ {
// PWD bit constants per docs/research/named-retail/acclient.h:6431-6463 // PublicWeenieDesc::BitfieldIndex, acclient.h:6431.
private const uint BF_PLAYER = 0x8u; private const uint BfPlayer = 0x00000008u;
private const uint BF_PLAYER_KILLER = 0x20u; private const uint BfAttackable = 0x00000010u;
private const uint BF_VENDOR = 0x200u; private const uint BfPlayerKiller = 0x00000020u;
private const uint BF_PORTAL = 0x40000u; private const uint BfHiddenAdmin = 0x00000040u;
private const uint BF_PKLITE_PKSTATUS = 0x2000000u; 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] [Fact]
public void Item_NoFlags_ReturnsItemColor() public void Palette_UsesExactRetailFloatConstants()
{ {
// SpellComponents is itemType=0x1000 (e.g. a Taper) — not a creature. Assert.Equal(new RadarBlipColors.Rgba(0.25f, 0.660000026f, 1f, 1f), RadarBlipColors.Blue);
var result = RadarBlipColors.For(itemType: (uint)ItemType.SpellComponents, pwdBitfield: 0); Assert.Equal(new RadarBlipColors.Rgba(1f, 0.670000017f, 0f, 1f), RadarBlipColors.Gold);
Assert.Equal(RadarBlipColors.Item, result); 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] [Fact]
public void Misc_NoFlags_ReturnsItemColor() public void Override_MapsValuesOneThroughTenInRetailOrder()
{ {
var result = RadarBlipColors.For((uint)ItemType.Misc, pwdBitfield: 0); RadarBlipColors.Rgba[] expected =
Assert.Equal(RadarBlipColors.Item, result); [
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] [Fact]
public void Creature_NotPlayer_ReturnsCreatureColor() public void WireOverride_WinsOverObjectClassification()
{ {
// NPC: itemType has Creature bit, no Player flag. var traits = new RadarObjectTraits(
var result = RadarBlipColors.For((uint)ItemType.Creature, pwdBitfield: 0); BlipColorOverride: 1,
Assert.Equal(RadarBlipColors.Creature, result); IsPortal: true,
IsVendor: true,
IsPlayer: true,
IsPlayerKiller: true);
Assert.Equal(RadarBlipColors.Blue, RadarBlipColors.For(traits));
} }
[Fact] [Fact]
public void FriendlyPlayer_ReturnsDefaultColor() public void PortalAndVendor_UsePurpleAndYellow()
{ {
// Friendly player: Creature itemType + Player flag, no PK bits. Assert.Equal(
var result = RadarBlipColors.For((uint)ItemType.Creature, pwdBitfield: BF_PLAYER); RadarBlipColors.Purple,
Assert.Equal(RadarBlipColors.Default, result); RadarBlipColors.For((uint)ItemType.Creature, BfPortal | BfVendor));
Assert.Equal(
RadarBlipColors.Yellow,
RadarBlipColors.For((uint)ItemType.Creature, BfVendor));
} }
[Fact] [Fact]
public void PK_Player_ReturnsPlayerKillerColor() public void Creature_MustBeAttackableAndNotAPlayer()
{ {
var result = RadarBlipColors.For((uint)ItemType.Creature, Assert.Equal(
pwdBitfield: BF_PLAYER | BF_PLAYER_KILLER); RadarBlipColors.Gold,
Assert.Equal(RadarBlipColors.PlayerKiller, result); 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] [Fact]
public void PKLite_Player_ReturnsPKLiteColor() public void Player_StatusDispatch_MatchesRetailPriority()
{ {
var result = RadarBlipColors.For((uint)ItemType.Creature, uint creature = (uint)ItemType.Creature;
pwdBitfield: BF_PLAYER | BF_PKLITE_PKSTATUS);
Assert.Equal(RadarBlipColors.PKLite, result); 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] [Fact]
public void Vendor_BeatsCreatureFlag() public void Fellowship_OverridesPlayerStatusColor()
{ {
// A vendor NPC: Creature itemType + Vendor flag. Vendor wins per var traits = new RadarObjectTraits(IsPlayer: true, IsPlayerKiller: true);
// retail's dispatch order (vendor check happens before creature var relationship = new RadarRelationshipTraits(
// check at 0x004d7946-004d7973). IsFellowshipMember: true,
var result = RadarBlipColors.For((uint)ItemType.Creature, IsFellowshipLeader: true);
pwdBitfield: BF_VENDOR);
Assert.Equal(RadarBlipColors.Vendor, result); Assert.Equal(RadarBlipColors.BrightGreen, RadarBlipColors.For(traits, relationship));
} }
[Fact] [Fact]
public void Portal_TopPriority() public void InvalidDescription_ReturnsDefault()
{ {
// Portal flag wins over everything else (retail dispatch order var traits = new RadarObjectTraits(IsValid: false, BlipColorOverride: 1, IsPortal: true);
// checks BF_PORTAL first).
var result = RadarBlipColors.For((uint)ItemType.Creature, Assert.Equal(RadarBlipColors.Default, RadarBlipColors.For(traits));
pwdBitfield: BF_PORTAL | BF_PLAYER | BF_PLAYER_KILLER); }
Assert.Equal(RadarBlipColors.Portal, result);
[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.True(d.ShowTooltips);
Assert.False(d.VividTargetingIndicator); Assert.False(d.VividTargetingIndicator);
Assert.False(d.SideBySideVitals); Assert.False(d.SideBySideVitals);
Assert.False(d.CoordinatesOnRadar); Assert.True(d.CoordinatesOnRadar); // retail default mask includes 0x00400000
Assert.True(d.SpellDuration); Assert.True(d.SpellDuration);
Assert.True(d.AllowGive); Assert.True(d.AllowGive);
Assert.True(d.ShowHelm); Assert.True(d.ShowHelm);

View file

@ -368,4 +368,20 @@ public sealed class SettingsStoreTests : System.IDisposable
Assert.False(store.LoadChat().HearTradeChat); Assert.False(store.LoadChat().HearTradeChat);
Assert.Equal("Fellowship", store.LoadCharacter("+Acdream").DefaultChatChannel); 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);
}
} }