feat(ui): port retail radar and compass
This commit is contained in:
parent
c4af181b92
commit
3cbe4b00a1
43 changed files with 2882 additions and 262 deletions
103
docs/research/2026-07-10-retail-radar-pseudocode.md
Normal file
103
docs/research/2026-07-10-retail-radar-pseudocode.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# Retail radar/compass pseudocode — 2026-07-10
|
||||
|
||||
## Oracle and scope
|
||||
|
||||
This note records the primary retail behavior used by acdream's retained
|
||||
radar port. The oracle is the September 2013 named client:
|
||||
|
||||
- `gmRadarUI` fields: `named-retail/acclient.h:54506-54548`
|
||||
- color/shape: `gmRadarUI::GetBlipColor` `0x004D76F0`,
|
||||
`gmRadarUI::GetBlipShape` `0x004D7B60`
|
||||
- coordinates/compass/draw: `UpdateCoordinates` `0x004D8C80`,
|
||||
`UpdateCompassTokens` `0x004D9060`, `DrawObjects` `0x004D9380`,
|
||||
`DrawChildren` `0x004D9720`
|
||||
- cadence/list maintenance: `UseTime` `0x004D98A0`, `AddObject`
|
||||
`0x004D9A00`, `Init` `0x004D9B90`, `PostInit` `0x004D9D30`
|
||||
- production DAT layout: `LayoutDesc 0x21000074` in
|
||||
`2026-06-25-retail-ui-layout-dump.json`
|
||||
|
||||
The older AC2D-derived `1.18 × range`, rotating player arrow, and horizontal
|
||||
scrolling compass strip are rejected by the named retail implementation.
|
||||
|
||||
## Retained UI ownership
|
||||
|
||||
The production root is a 120×140 `gmRadarUI` element of class
|
||||
`0x10000010`. Custom properties set center `(60,60)` (`0x1000002E`,
|
||||
nested `0x1000002F/30`) and pixel radius `50` (`0x1000002D`).
|
||||
`LayoutImporter` owns the static DAT children and media.
|
||||
A dedicated `UiRadar` owns the dynamic draw/tick/input behavior. Core owns
|
||||
the pure projection, classification, marker geometry, compass, and coordinate
|
||||
algorithms. `GameWindow` supplies live snapshots and selection/settings
|
||||
actions only.
|
||||
|
||||
This is a screen-space retained panel. It is not an ImGui panel and does not
|
||||
use the raw world-space TextRenderer overlay path.
|
||||
|
||||
## Per-tick pseudocode
|
||||
|
||||
```text
|
||||
every 0.025 seconds:
|
||||
range = player is outdoors ? 75 : 25
|
||||
heading = physical player heading in degrees
|
||||
move N/E/S/W child tokens around radar center
|
||||
if CoordinatesOnRadar and LandDefs.gid_to_lcoord(playerCell):
|
||||
ns = (lcoordY - 1024) * 0.1 + 0.5
|
||||
ew = (lcoordX - 1024) * 0.1 + 0.5
|
||||
coordinateText = abs(ns) + N/S + "," + abs(ew) + E/W
|
||||
show footer/text
|
||||
else:
|
||||
hide footer/text
|
||||
```
|
||||
|
||||
```text
|
||||
for each maintained object except the player:
|
||||
require a physics/world entity
|
||||
require RadarBehavior in { ShowMovement=2, ShowAttacking=3, ShowAlways=4 }
|
||||
|
||||
relative = convert object position into player's oriented frame
|
||||
if relative.X² + relative.Y² >= (range - 1)²:
|
||||
continue
|
||||
|
||||
scale = radarPixelRadius / range
|
||||
x = trunc(centerX + relative.X * scale)
|
||||
y = trunc(centerY - relative.Y * scale)
|
||||
rgbBrightness = abs(relative.Z) < 5 ? 1.0 : 0.65
|
||||
|
||||
color = exact _blipColor override 1..10, otherwise retail classification
|
||||
shape = fellowship/allegiance/PK relationship shape, otherwise Plus
|
||||
draw marker pixels
|
||||
if selected: draw the open selection outline at offsets ±3
|
||||
```
|
||||
|
||||
After all object blips, draw the player's fixed bright-green center plus.
|
||||
Hover selects the nearest projected blip within squared distance 36; its
|
||||
name is the tooltip. A left click selects that object.
|
||||
|
||||
The lock button drives the existing retained-window drag gate; the drag
|
||||
affordance is hidden while locked. Completed moves are clamped inside the UI
|
||||
root and persisted per character through `SettingsStore`.
|
||||
|
||||
## Production assets
|
||||
|
||||
| Role | Element | Rect | RenderSurface |
|
||||
|---|---:|---:|---:|
|
||||
| Face | `0x1000003F` | `(0,0) 120×120` | `0x06004CC1` |
|
||||
| Coordinate footer | `0x1000003E` | `(0,120) 120×18` | `0x06004CC0` |
|
||||
| N / E / S / W | `0x10000040..43` | four 10×9 tokens | `0x060011FB`, `0x06001938`, `0x0600193A`, `0x0600193C` |
|
||||
| Lock | `0x10000619` | `(6,6) 27×27` | `0x060074B7` / `0x060074B8` |
|
||||
| Drag | `0x100006A3` | `(87,6) 27×27` | `0x060074C9` |
|
||||
|
||||
The child token orbit uses `center + (sin(angle), cos(angle)) * magnitude`
|
||||
with angles N=`heading+π`, E=`heading+π/2`, S=`heading`, W=`heading+3π/2`.
|
||||
|
||||
## Wire contract
|
||||
|
||||
PublicWeenieDesc flag `0x00100000` carries nullable byte
|
||||
`RadarBlipColor`; flag `0x00800000` carries nullable byte `RadarBehavior`.
|
||||
Absent is distinct from an explicitly transmitted zero. Both values flow
|
||||
through `CreateObject.Parsed`, `WorldSession.EntitySpawn`, and
|
||||
`ClientObjectTable`.
|
||||
|
||||
Do not infer ShowMovement/ShowAttacking from local motion. Retail's
|
||||
`InqShowableOnRadar` accepts values 2, 3, and 4 directly; server/quality
|
||||
changes are authoritative.
|
||||
|
|
@ -172,103 +172,88 @@ public sealed class VitalOrb
|
|||
|
||||
### A.2 Radar / compass
|
||||
|
||||
The radar is the canonical "small circular polar plot of nearby creatures
|
||||
with the player at the center". The compass is a thin bar across the top of
|
||||
the screen showing the 16 cardinal directions as the camera rotates.
|
||||
Retail implements both pieces as one `gmRadarUI` element, not as a radar
|
||||
plus a separate scrolling strip. Primary sources are the named September
|
||||
2013 decomp (`gmRadarUI::UpdateCompassTokens`, `DrawObjects`,
|
||||
`UpdateCoordinates`, and `DrawChildren`) and production `LayoutDesc
|
||||
0x21000074` in `2026-06-25-retail-ui-layout-dump.json`. The earlier AC2D
|
||||
comparison in this section was useful secondary evidence but its `1.18`
|
||||
projection, rotating player arrow, and horizontal compass strip are not
|
||||
retail behavior.
|
||||
|
||||
**Radar — data sources**
|
||||
**Production layout (`0x21000074`, root type `0x10000010`):**
|
||||
|
||||
- Player world position (`Position` packet, 24-byte LandCell + XYZ).
|
||||
- Every nearby object's `CreateObject` / `UpdatePosition` with heading.
|
||||
- Per-object `RadarColor` override (hostile = red, green = friendly NPC,
|
||||
etc.) + `ObjectFlags2` bits (`0x08` = item, `0x10` = blue-book NPC).
|
||||
- 120×140 root (`gmRadarUI`).
|
||||
- Root custom properties: center `(60,60)` and radar pixel radius `50`.
|
||||
- Circular face `0x06004CC1` at `(0,0)`, 120×120.
|
||||
- Coordinate footer `0x06004CC0` at `(0,120)`, 120×18.
|
||||
- N/E/S/W token sprites `0x060011FB`, `0x06001938`, `0x0600193A`,
|
||||
`0x0600193C`, each 10×9 and initially centered at the four cardinal
|
||||
points.
|
||||
- Lock/unlock sprites `0x060074B7` / `0x060074B8`; drag sprite
|
||||
`0x060074C9`.
|
||||
|
||||
**Radar — retail dat IDs** (AC2D, `cInterface.cpp:139-144`):
|
||||
|
||||
- `0x06001388` — radar window titlebar/toolbar icon.
|
||||
- `0x06004CC1` — radar background art (the circular bezel).
|
||||
|
||||
**Radar — the player arrow + blip placement**
|
||||
|
||||
AC2D `cRadar::OnRender` (`cCustomWindows.h:1004-1070`) is the clearest
|
||||
retail-equivalent. The math:
|
||||
**Projection (`gmRadarUI::DrawObjects` `0x004D9380`):**
|
||||
|
||||
```text
|
||||
for each nearby object obj:
|
||||
delta = obj.pos - player.pos
|
||||
delta = delta.RotateAround(Z, -player.heading) // align to radar-up = camera-forward
|
||||
screen.x = radar.left + radar.w/2 + (delta.x / (1.18 * range)) * (radar.w/2)
|
||||
screen.y = radar.top + radar.h/2 - (delta.y / (1.18 * range)) * (radar.h/2)
|
||||
color = PickRadarColor(obj.radar_override, obj.flags2)
|
||||
DrawQuad2x2(screen, color)
|
||||
relative = object position converted into the physical player's oriented frame
|
||||
range = outdoors ? 75 : 25
|
||||
|
||||
if relative.X² + relative.Y² >= (range - 1)²:
|
||||
skip
|
||||
|
||||
scale = radarPixelRadius / range
|
||||
pixelX = trunc(centerX + relative.X * scale)
|
||||
pixelY = trunc(centerY - relative.Y * scale)
|
||||
rgbMod = abs(relative.Z) < 5 ? 1.0 : 0.65
|
||||
```
|
||||
|
||||
The `1.18` factor is retail-observed — it shrinks the effective range
|
||||
slightly so blips near the edge stay visible before the bezel clips them.
|
||||
Player's own arrow is NOT in the blip loop; it is drawn as a fixed
|
||||
centered sprite (the "player dot") rotated by `player.heading`.
|
||||
The player's own object is excluded. `DrawChildren` paints a fixed
|
||||
bright-green plus at the center after the object blips; it is not a rotating
|
||||
arrow. Hover chooses the nearest blip within 6 pixels, exposes its object
|
||||
name as the tooltip, and clicking selects that object.
|
||||
|
||||
**Compass — data sources**
|
||||
|
||||
- `player.heading` in radians.
|
||||
|
||||
**Compass — how the rose is drawn**
|
||||
|
||||
The rose is a **seamless horizontal strip texture** where 360° is tiled
|
||||
across some multiple of the screen width. The U-offset is
|
||||
`heading_normalized * strip_u_period`, with the visible portion cropped to
|
||||
a narrow strip at the top of the screen. This is the classic "scrolling
|
||||
texture" approach used by most 3D clients; retail AC follows it.
|
||||
|
||||
Holtburger's TUI compass (`hud/status.rs:11-18`) enumerates the 16
|
||||
cardinal-direction labels that retail paints onto the strip:
|
||||
**Compass tokens (`gmRadarUI::UpdateCompassTokens` `0x004D9060`):**
|
||||
|
||||
```text
|
||||
["W", "WNW", "NW", "NNW", "N", "NNE", "NE", "ENE",
|
||||
"E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW"]
|
||||
tokenX = centerX + sin(angle) * tokenMagnitude - tokenWidth/2
|
||||
tokenY = centerY + cos(angle) * tokenMagnitude - tokenHeight/2
|
||||
|
||||
N angle = heading + π
|
||||
E angle = heading + π/2
|
||||
S angle = heading
|
||||
W angle = heading + 3π/2
|
||||
```
|
||||
|
||||
22.5° per segment, first label centered on 11.25°.
|
||||
Heading comes from the physical player in degrees, not from the camera.
|
||||
There is no numeric heading label in `gmRadarUI`.
|
||||
|
||||
**Render-path pseudocode:**
|
||||
**Visibility, color, and shapes:**
|
||||
|
||||
```text
|
||||
function DrawCompassStrip(heading_rad, bar_x, bar_y, bar_w, bar_h):
|
||||
heading_deg = (heading_rad * 180 / PI) mod 360
|
||||
// Texture is 360° wide in world-space; we crop to bar_w centered on heading
|
||||
u_center = heading_deg / 360 // 0..1
|
||||
u_half = bar_w / strip_texture_w / 2
|
||||
u_left = u_center - u_half
|
||||
u_right = u_center + u_half
|
||||
DrawSpriteUV(compass_strip_tex, bar_x, bar_y, bar_w, bar_h,
|
||||
u0=u_left, u1=u_right, v0=0, v1=1)
|
||||
```
|
||||
- `RadarEnum`: undefined 0, never 1, movement 2, attacking 3, always 4.
|
||||
Retail accepts 2/3/4 as showable and does not invent a client-side
|
||||
movement/attack filter.
|
||||
- The optional PublicWeenieDesc bytes are `RadarBlipColor` (header flag
|
||||
`0x00100000`) and `RadarBehavior` (`0x00800000`).
|
||||
- `_blipColor` overrides 1..10 select the exact blue, gold, white, purple,
|
||||
red, pink, green, yellow, cyan, and bright-green retail constants.
|
||||
- Default classification covers portals, vendors, attackable creatures,
|
||||
visible admins, PK/PKLite/free-PK players, and fellowship overrides.
|
||||
- Marker shapes are point, hollow box, X, plus, triangle, inverted triangle,
|
||||
and X-box. Fellowship/allegiance/PK relationships select the special
|
||||
shapes; the ordinary default is a five-pixel plus.
|
||||
|
||||
**C# port sketch:**
|
||||
**Coordinates (`gmRadarUI::UpdateCoordinates` `0x004D8C80`):**
|
||||
|
||||
```csharp
|
||||
public void DrawRadar(IUiRenderer r, Rect bounds, float playerHeading,
|
||||
Vector3 playerPos, IEnumerable<WorldEntity> nearby, float range)
|
||||
{
|
||||
r.DrawSprite(/*0x06004CC1*/ _radarBgId, bounds);
|
||||
For an outdoor cell, `LandDefs::gid_to_lcoord` supplies integer landscape
|
||||
cell X/Y. Each axis becomes `(lcoord - 1024) * 0.1 + 0.5`, then formats as
|
||||
`{abs(Y):0.0}{N|S},{abs(X):0.0}{E|W}` (no space). The footer is hidden for
|
||||
indoor cells or when `CoordinatesOnRadar` (`0x00400000`) is disabled.
|
||||
|
||||
var cx = bounds.X + bounds.Width * 0.5f;
|
||||
var cy = bounds.Y + bounds.Height * 0.5f;
|
||||
|
||||
foreach (var e in nearby)
|
||||
{
|
||||
var d = e.Position - playerPos;
|
||||
d = Vector3.Transform(d, Matrix4x4.CreateRotationZ(-playerHeading));
|
||||
var sx = cx + (d.X / (1.18f * range)) * (bounds.Width * 0.5f);
|
||||
var sy = cy - (d.Y / (1.18f * range)) * (bounds.Height * 0.5f);
|
||||
var col = PickRadarColor(e);
|
||||
r.DrawFilledQuad(sx - 1, sy - 1, 2, 2, col);
|
||||
}
|
||||
|
||||
// Player arrow - always on top
|
||||
r.DrawSprite(_playerArrowId, cx - 5, cy - 5, 10, 10, rotation: playerHeading);
|
||||
}
|
||||
```
|
||||
`gmRadarUI::UseTime` refreshes range, tokens, coordinates, and dirty state
|
||||
every 25 ms (40 Hz). Static art remains data-driven through the retained
|
||||
LayoutDesc importer; dynamic behavior belongs in a dedicated retained
|
||||
radar widget/controller, with `GameWindow` limited to state providers.
|
||||
|
||||
### A.3 Quickbar / hotbar
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue