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

@ -122,7 +122,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs |
| AP-16 | Point/spot lights selected per-object / per-cell as the **8 nearest reaching lights** (sphere-overlap, nearest-first) via `LightManager.SelectForObject`, capped at `MaxLightsPerObject=8`; called from `WbDrawDispatcher.ComputeEntityLightSet` (objects) and `EnvCellRenderer.GetCellLightSet` (cell shells). Retail's bake (`SetStaticLightingVertexColors`) sums ALL reaching static lights per vertex with no count cap. Retail's *hardware* path (`minimize_object_lighting` 0x0054d480) DOES cap at 8 per object, so the cap is faithful to retail's hardware path — not to its bake path. The `LightManager.Tick` UBO path survives for DIRECTIONAL (sun) lights only; `mesh_modern.vert`'s UBO loop skips point/spot entries (`posAndKind.w != 0 → continue`) — point lights reach the shader exclusively via the per-object SSBO (binding 5) | `src/AcDream.Core/Lighting/LightManager.cs:234` (`SelectForObject`); `MaxLightsPerObject` ~line 174; call sites `WbDrawDispatcher.ComputeEntityLightSet` + `EnvCellRenderer.GetCellLightSet` | Matches retail's hardware constraint (8 lights per object/cell); selection is nearest-sphere-overlap which faithfully allocates lights to the surfaces that actually see them | Surfaces reached by >8 point lights are dimmer than retail's uncapped bake — rare (a dungeon room has a handful of torches), but real; see AP-35 for the bake-vs-GPU-evaluate architecture difference | `minimize_object_lighting` 0x0054d480 (retail's 8-light hardware cap); `SetStaticLightingVertexColors` 0x0059cfe0 (retail's bake, no count cap) |
| AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E |
| AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) |
| ~~AP-18~~ | **RETIRED 2026-07-10 — faithful retail radar port.** Exact `RGBAColor_Radar*` floats were recovered from named static data and `gmRadarUI::GetBlipColor` was re-ported with `_blipColor` overrides plus portal/vendor/attackable-creature/admin/PK/PKLite/free-PK/fellowship precedence. The old implementation was not merely hue-tuned: it also had wrong portal/vendor colors and an incomplete dispatch matrix. | `src/AcDream.Core/Ui/RadarBlipColors.cs` + radar classification tests | — | — | `gmRadarUI::GetBlipColor` 0x004d76f0; static RGBA initializers at named decomp pc:1089736-1089804 |
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
| AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 12 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |
| AP-21 | Entity translucency: two-pass alpha-test (N.5 Decision 2, invented 0.95/0.05 thresholds); AlphaBlend + Additive + InvAlpha all composite under (SrcAlpha, 1SrcAlpha) — retail applies per-surface D3D blend incl. true additive. EnvCellRenderer + ParticleBatcher DO switch to additive; divergence confined to GfxObj/Setup entities via WbDrawDispatcher | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1563` (+ `Shaders/mesh_modern.frag:10`; #52 amendment removed the α≥0.95 discard) | Matches original WB's model; keeps the bindless MDI pipeline at two indirect draws; spec §6 documents the falsifiable fallback — a third indirect call with `glBlendFunc(SrcAlpha, One)` (~30 min) on a magic-content regression | Additive glow/magic entity surfaces composite darker / occlude instead of brightening — the predicted regression once spell VFX density increases; α<0.05 discard drops faint fringes retail blends | SurfaceType.Additive D3DBLEND_ONE per-surface routing |
@ -193,6 +193,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the 96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions `|Body.Position worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body garbage resolved pos the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only the 4 m guard is the load-bearing backstop | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap 96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
| AP-88 | **Remote omega is reconstructed with a player/NPC fork retail does not have** (remote-creature de-overlap #184 Slice 2b, 2026-07-08): retail's `UpdateObjectInternal` applies ONE angular-velocity integration to every object; acdream reconstructs remote omega from the wire and keeps a player/NPC split inherited from the two former DR paths — a grounded PLAYER remote applies `ObservedOmega ∥ seqOmega` (falls back to the sequencer's synthesised cycle omega when the wire-TurnCommand-derived `ObservedOmega` is 0 — the "circling player sends RunForward+TurnLeft on ONE UM" case) in the WORLD frame (pre-multiply, `Quaternion.Concatenate`); an NPC or an AIRBORNE body applies `ObservedOmega`-only in the BODY frame (post-multiply, `Quaternion.Multiply`). Both feed the same downstream integrate; `calc_acceleration` zeroes `Body.Omega` for grounded bodies so `UpdatePhysicsInternal` never double-integrates | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick`, Step 2 omega fork) | For an UPRIGHT body (the only remote pose — creatures/players never pitch or roll; the wire orientation is yaw-only) rotating about world-Z (the only turn axis) the pre- and post-multiply orders COMMUTE and both branches reduce to the same yaw increment; the seqOmega fallback only adds rotation a circling player genuinely has, and applying it to NPCs would spuriously add their baked cycle omega — so the fork is behaviourally faithful for every reachable pose (it is what the pre-2b Path A / Path B already did, now explicit in one method). ALSO: the merge applies omega BEFORE `ComputeOffset` for everyone (Path B's order), whereas pre-2b Path A applied a grounded PLAYER's omega AFTER its compose — so the `ori` fed to the anim-root-motion fallback (`Transform(seqVel·dt, ori)`, used only when the interp queue is empty/head-reached) is yaw-advanced by one tick (~ω·dt ≈ 2° at 2.24 rad/s, non-accumulating, zero while the catch-up is active). Keeping Path B's order leaves the shipped/gate-passed NPC omega untouched and only nudges a turning grounded player's fallback direction ~2° in rare queue-empty frames — cosmetically negligible; adopting Path A's order instead would have perturbed NPCs by the same amount | If a remote ever acquires a non-yaw orientation (pitch/roll — a future flying mount / ragdoll) the two multiplication orders diverge and a player would rotate differently from an NPC at the same omega; collapse to one order + a shared fallback policy then | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (single apply_physics omega integration, no object-class fork); `apply_interpreted_movement` 0x00528600 (retail's server-driven omega source acdream reconstructs) |
| AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 translucency`, applied to all 4 D3D9 material alpha channels) |
| AP-90 | **Radar fellowship/allegiance relationship state is modeled but not yet delivered at runtime.** `RetailRadar.GetBlipShape` and `RadarBlipColors.For` implement retail's leader/member/allegiance precedence, and `RadarSnapshotProvider` exposes a `relationshipFor(guid)` seam, but acdream does not yet maintain live fellowship membership and its `AllegianceTree` is not wired into GameWindow. PK/PKLite relationship shapes do work from PWD flags. | `src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs`; `src/AcDream.Core/Ui/RetailRadar.cs`; `src/AcDream.Core/Ui/RadarBlipColors.cs` | Preserve the exact model/seam now and avoid inventing membership from names or chat; connect it when the social game-event state is ported | Fellowship members render their ordinary player color/shape instead of bright-green leader/member triangles; allegiance members render an ordinary plus instead of a hollow box | `gmRadarUI::GetBlipColor` 0x004D76F0; `gmRadarUI::GetBlipShape` 0x004D7B60 |
## 4. Temporary stopgap (TS) — 38 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation)

View file

@ -488,7 +488,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository``ClientObjectTable` / `ItemInstance``ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
- **☐ D.5.3 — Toolbar selected-object display (issue #140) + spell shortcuts.** Wire the B.4 `WorldPicker`/selection state → the two hidden meters (`0x100001A1` health / `0x100001A2` mana) + the stack slider (`0x100001A4`) + the object-name line, so the bar shows the player's currently-selected world object. Plus **spell shortcuts** — pinned *spells* (vs items) don't render their glyphs yet (`ToolbarController.Populate` skips `ObjectGuid==0`). Together these finish "the bar." (Click-to-use + the peace/war stance indicator landed in D.5.1.)
- **☐ D.5.5+ — Core panels.** Inventory (`gmInventoryUI`/`gmBackpackUI`), equipment/paperdoll (`gmPaperDollUI`/`gm3DItemsUI` + the `UiViewport` 3D doll), vendor, trade, spellbook. Research drop done (`docs/research/2026-06-16-*`). Depends on **D.5.4** (data model) + the item-slot/list/icon spine (D.5.1/D.5.2) + the **window manager** (Plan 2: open/close/z-order/persist + faithful grip/dragbar drag-resize) + the drag-drop spine wired (`UiRoot` has the chain; the per-cell accept/drop hooks are still stubs in `UiField`). Also deferred from D.5.1: drag/reorder + the `AddShortcut`/`RemoveShortcut` mutate wire.
- **D.6 — HUD.** Vital orbs (scissor-rect partial fill, dat sprites `0x060013B2`), radar (`0x06001388` / `0x06004CC1`, 1.18× range factor), compass strip (scrolling U), target name plate, damage floaters, selection indicator. See slice 06. **(Targets `AcDream.UI.Abstractions` — ships with D.2a; reskinned by D.2b.)** Phase I.2 retired the StbTrueTypeSharp `DebugOverlay` but kept `TextRenderer` + `BitmapFont` alive specifically for D.6's world-space HUD elements (damage floaters, name plates) — they need raw GL text drawing that ImGui can't reach into the 3D scene.
- **D.6 — HUD.** **Radar/compass IMPLEMENTED 2026-07-10; live-world visual gate pending.** It is the retained retail `gmRadarUI` (`LayoutDesc 0x21000074`): a 120×140 circular radar (`0x06004CC1`) with N/E/S/W token sprites orbiting the face, a fixed bright-green player marker, and the coordinate footer (`0x06004CC0`). Projection is `radarPixelRadius / radarRange` with 75 m outdoor / 25 m indoor ranges — there is no 1.18 factor or scrolling compass strip. It lives in `AcDream.App/UI` beside the other imported retail panels. Remaining D.6: target name plate, damage floaters, and other world-space HUD elements through the raw `TextRenderer` path. See slice 06 §A.2, `docs/research/2026-07-10-retail-radar-pseudocode.md`, and the named `gmRadarUI` decomp.
- **D.7 — Cursor manager.** OS + dat-sourced custom cursors (`FUN_0043c1c0` GDI HCURSOR builder pattern from slice 03). **(D.2b dependency.)**
- ~~**D.8 — Sound.**~~ **Superseded — shipped as Phase E.2** (`SoundTable`/`Sound` dat decode, OpenAL 16-voice engine, per-entity 3D positional audio via `AudioHookSink`). Entry kept here for history; see the shipped table.

View file

@ -534,7 +534,9 @@ Open the inventory panel — retail-skinned with the right font, icons, and
- **C.4** — Double-sided translucent polys.
- **D.2b** — Custom retail-look UI backend. **In progress:** vitals + chat + toolbar + inventory (window manager, B-Grid, B-Controller, B-Wire, drag-drop, container-switching) + paperdoll (equip slots, 3-D doll UiViewport, Slots toggle) + **Character window** (Attributes tab, visually confirmed 2026-06-26) + **UI Studio** (standalone panel previewer + 26-window retail dump + interactive click-routing + headless screenshot) + **importer dat-fidelity** (Fix A/B/C: justification, FontColor, per-element FontDid resolver [studio-only; #157 tracks live-game path]) are shipped. Next: D.5.3 selected-object bar + spell shortcuts; #158 Character window polish deferred.
- **D.3D.7** — AcFont + dat sprites + core panels reskinned + HUD orbs +
cursor manager.
cursor manager. **D.6 radar/compass shipped 2026-07-10** through the retained
UI architecture using the retail DAT layout and recovered runtime behavior;
live-game visual confirmation remains the user gate.
- **L.1f** — NPC/monster + item-use animation coverage.
**Freeze on landing:**

View file

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

View file

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