Files
benilla-octo/crates/benilla-app/src/minimap/mod.rs
T
SamandClaude Fable 5.1 9bf64e1a6b the minimap and world map go stock, and the chat loses its white plate
- the minimap cluster and the battleground list are the reference's own `Minimap.xml` and `BattlefieldFrame.xml`, ours deleted, and the sixteen engine names the static probe found unbound are built, not stubbed: the battleground list and queue family (the as-group join refused client-side with no packet when the map's `MaxPlayers` is below the group), the bodyless `CMSG_BATTLEFIELD_STATUS` on world enter — benilla never sent it, so a queue joined before a reconnect was invisible — the 5.5556 yd `BATTLEFIELDS_CLOSED` leash, and the meeting-stone pair over the `0x295` five-way table with both asymmetries; the ping's lifetime is the stock Lua's now (held 5 s, faded 0.5 s), `GetPingPosition` answers two numbers always — our nil was a Lua error on every frame of the stock `Minimap_OnUpdate` — and the map-change drop the reference never had is gone (1974)
- the world map is the reference's own `WorldMapFrame.xml`, our transcription deleted; built for it: the arrow-frame family (an anonymous `Model` child at its 33.6 px footprint, drawn app-side as the minimap's own arrow sprite through one shared constant), the battleground position family over `MSG_BATTLEGROUND_PLAYER_POSITIONS` both ways (the reference's 5000 ms request throttle), and `GetPlayerMapPosition` resolving `raid1..raid40` where it knew only the party (B320); `SetupFullscreenScale` — the engine binding `0x48c270` the map's and the cinematic frame's OnShow call, which our map had stood in for with Lua — is bound to the carved law `min(0.75 · aspect, 1)` off the configured aspect, written through the frame's own `SetScale` (1.0 at 4:3 and wider, 0.9375 at 5:4); a hovered POI writes the map's own area label, not the tooltip; named gap: `WorldMapPing` draws nothing yet (1980, 1981)
- the tutorial frame is the reference's own `TutorialFrame.xml`, over a tutorial system benilla never had: two bit banks filled from `SMSG_TUTORIAL_FLAGS` — the fire-once bank `TriggerTutorial` reads and sets (never a send) and the acknowledged bank `FlagTutorial` writes and sends; there is no tutorial CVar in the binary (210 `CVar::Register` sites censused), so "disable tutorials" is every bit acknowledged; the sites this app can produce are wired (the world-enter Welcome cascade, the level-up arm, item pushes, the loot sparkle, the meeting-stone and battleground arms), the rest named; the loader learned `<TitleRegion>` — the stock frame's drag handle, a region that emits no quad; and the `TabardModel` widget kind arrives ahead of its window, five ints bounded by the `.rdata` table `{170, 17, 6, 17, 51}` and the constant 100 000 tabard cost (1976, 1977)
- the chat window drew as an opaque white plate: the chat-cache loader, gated on `InWorld` alone, restored into the boot VM 130 ms before the deferred entry load filled it, and its per-VM memo then blocked the restore that would have reached the real frames — the plate the stock Lua tints black stayed the XML's white; between the entry edge and the load there is now no VM in the world (the edge parks the boot VM, the load takes it back), so no in-world feed pushes into a VM with no interface; the perf pill reads `59 fps  7.0 ms  17.3 cpu` — the MAIN thread's milliseconds in full text and the process-wide sum dim, because the sum of every thread's CPU time had been read as a frame time (a raid read 17 on it at a solid 60 with the main thread at 7); and `WOW_PROBE_UI_ONE_TEX=1` is a pricing lever, never a look, for the draw-count ceiling a UI atlas would reach before one is built (1978, 1954)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:59:53 +07:00

1176 lines
61 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The HUD minimap renderer (decision 0203 phase 1) — the app half of the `<Minimap>` widget.
//!
//! The engine side (benilla-ui) carries the widget's rect + zoom and emits a
//! `QuadContent::Minimap` hole at the frame's own draw slot; `ui_script::extract::drive_script` parks that
//! in [`MinimapWidget`], and [`emit_minimap`] (in the [`UiQuadAppend`] window) fills it: the
//! streamed tile window around the player, clipped to the widget rect and masked to the
//! `MinimapMask.blp` circle at present time, with the player arrow rotating on top. Children of
//! the widget (border art, buttons, zone text — `MinimapCluster.xml`) draw above per the normal
//! z order.
//!
//! Mechanism per wow-re's T3 minimap node (byte-verified there, transcribed here):
//! - **Tile grid**: one 256² minimap BLP per ADT tile (533.33 yd), named `map<X>_<Y>.blp` in the
//! map's directory and resolved through `md5translate.trs` to a hashed file under
//! `textures\Minimap\` ([`benilla_formats::MinimapTranslate`]). Index order = the ADT order
//! (chain-verified, see the formats re-export note).
//! - **Zoom → world radius** (`zoom_to_scale` 0x6da9b0): the client keeps **two** zoom indices,
//! selected by whether the player is inside a WMO. **Outdoors** the chunk-count table
//! `{14,12,10,8,6,4} · 0.5 · 33.333` yd of half-extent; **indoors** the radius table
//! `{150,120,90,60,40,25}` yd outright ([`INTERIOR_ZOOM_RADIUS`]). Each index persists separately
//! (CVars `minimapZoom` / `minimapInsideZoom`).
//! - **North-up orientation**: screen up = world +X (north), screen left = world +Y (west).
//!
//! Tiles stream through the `mpq://` async asset source (the terrain streamer's hitch-free bulk
//! path); handles cache per tile in [`MinimapTileCache`] and reset on a map change.
//!
//! Submodules: [`interior`] — the WMO-interior group selection (portal flood-fill) + tile-name
//! stem; [`blips`] — the phase-3 blip layer (AreaPOI landmark arrows, quest-giver dots, the
//! hover tooltip).
pub(crate) mod blips;
/// The party blip's position law, shared with the world map (report B320): one function decides
/// where a member is, so the two surfaces can never disagree about it.
pub(crate) use blips::party_member_pos;
mod composite;
mod interior;
/// The minimap ping (decision 1596) — engine-owned and pinned to a world point.
mod ping;
pub(crate) use ping::MinimapPing;
use std::collections::HashMap;
use bevy::math::{Affine3A, Rect};
use bevy::prelude::*;
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use benilla_assets::minimap_grid::group_axis_grid;
use benilla_assets::WmoModel;
use benilla_formats::{tile_to_world, world_to_tile, MinimapTranslate};
use interior::{interior_group_selection, wmo_minimap_stem};
use benilla_ui::widget::MINIMAP_DEFAULT_ZOOM;
use crate::player::Player;
use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuadMask, UiQuads, UvRect};
use benilla_assets::MapCatalogRes;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::wmo_portal::{
down_ray_seeds, terrain_z_local, WmoPortalInstance, INTERIOR_PROBE_HEIGHT,
};
use benilla_world::world_map::CurrentMap;
/// Yards per ADT tile / per MCNK chunk (16 chunks per tile edge).
const TILE_YARDS: f32 = 533.333_3;
const CHUNK_YARDS: f32 = TILE_YARDS / 16.0;
/// The north-up zoom table (wow-re minimap node, `0x8116d0`): view **diameter** in chunks per
/// zoom index; half-extent = `chunks · 0.5 · 33.333` yd (`zoom_to_scale` 0x6da9b0's unlocked leg).
/// This is the **outdoor** zoom basis.
const ZOOM_CHUNKS: [f32; 6] = [14.0, 12.0, 10.0, 8.0, 6.0, 4.0];
/// The **interior** minimap view radius per *indoor* zoom index, in yards — the client's radius table
/// `0x8116e8`, indexed by the separate indoor zoom index `0x86f69c` (CVar `minimapInsideZoom`).
/// Indoors the minimap has its **own zoom state**: a different index, a different table, and a
/// radius in raw yards rather than the outdoor chunk half-extent. That is the "different zoom states
/// inside vs outside" the director reported (2026-07-09).
///
/// On-screen radius is exactly this value (wow-re `wmo-interior-minimap.md` finding 2 **Q7
/// CORRECTION**, VERIFIED: the client composites the interior to an offscreen target at `1.5·c` and
/// blits its middle two-thirds, netting `1.0·c`). The **same `c`** sizes the tile-selection query box
/// (Sub-Q4b), so selection and draw stay coherent.
///
/// NB an earlier reading of this same node claimed the interior scale was a compile-time constant
/// (`10.0f` ⇒ a fixed 15 yd, zoom-independent). That was WRONG — it measured the *static
/// initializer*, missing the per-frame write `mov [esi+0xc], radiusTable[indoorZoom]` that reaches
/// the field through a computed pointer. Superseded in wow-re; do not "restore" a constant here.
const INTERIOR_ZOOM_RADIUS: [f32; 6] = [150.0, 120.0, 90.0, 60.0, 40.0, 25.0];
/// **How far the map reaches, in yards** — the one function that answers it. Indoors that is the
/// interior radius table indexed by the *indoor* zoom; outdoors the chunk-count table's half-extent
/// indexed by the outdoor zoom. Both branches of [`emit_minimap`] and the ping's world→normalized
/// relay go through here, so "the view radius" can only ever mean one thing (the twin of
/// [`blips::party_member_pos`]'s reason for existing: two surfaces disagreeing about the same
/// number is a bug you find months later).
fn view_radius_yd(zoom: u8, inside_zoom: u8, inside: bool) -> f32 {
let clamp = |z: u8| usize::from(z.min(5));
if inside {
INTERIOR_ZOOM_RADIUS[clamp(inside_zoom)]
} else {
ZOOM_CHUNKS[clamp(zoom)] * 0.5 * CHUNK_YARDS
}
}
/// The **outer-edge bleed**: a minimap tile on the boundary of its group's grid is drawn 1.0 yd
/// larger on that side, so a group's art extends 1 yd past its bbox all the way round and two
/// groups whose boxes touch overlap by 2 yd. Interior cell edges are shared exactly. Byte-verified
/// (`0x6a549e`…`0x6a54db`, the constant `0xca8098` built in the emitter as `0.5 + 0.5`) and fitted
/// to the reference's captured quads with zero error — wow-re
/// `system/minimap/scratch/wmo-interior-no-adt-underlay.md` §8.
const EDGE_BLEED_YD: f32 = 1.0;
/// The client's half-texel UV inset, as the quad scale that reproduces it: a tile spanning
/// `extent` yards is baked at [`YD_PER_TEXEL`](benilla_assets::minimap_grid::YD_PER_TEXEL), so it
/// is `W = extent / 0.5` texels wide, and mapping texel *centres* to the quad's edges instead of
/// texel *edges* stretches it by `W / (W − 1)`. See the call site for why it matters.
fn texel_stretch(extent_yd: f32) -> f32 {
let texels = extent_yd / benilla_assets::minimap_grid::YD_PER_TEXEL;
if texels > 1.0 {
texels / (texels - 1.0)
} else {
1.0
}
}
/// The interior tile draw's **alpha-test reference** — `224/255`, the client's
/// `glAlphaFunc(GL_GEQUAL, 0.87843144)`. It is never set explicitly: the tile draw sets EGxBlend
/// **1** (whose applicator `glDisable`s blending), and `SetRenderState`'s id-7→id-8 cascade reads
/// `.data 0x85ad20[1] = 224` and multiplies by the f32 reciprocal of 255 — `0x3F60E0E2`, one ULP
/// above `224/255` (wow-re `system/minimap/scratch/wmo-interior-minimap-composite.md`, VERIFIED).
/// Written as the exact f32 the client computes rather than the ratio, because that ULP is the
/// value fragments are compared against.
const INTERIOR_TILE_ALPHA_REF: f32 = f32::from_bits(0x3F60_E0E2);
/// The corpse blip's edge as a fraction of the widget side (the POIIcons cell is authored 16px on
/// a 140px minimap ≈ 0.11; INTERIM eyeball beside [`ARROW_FRACTION`]'s).
const CORPSE_BLIP_FRACTION: f32 = 0.11;
/// The day-night tint the reference MODULATEs the **outdoor** (ADT) minimap tiles by before drawing
/// — the tiles are NOT drawn at full white (that reads too bright). Verified in the CWorldFrame
/// minimap draw (`wow-5875-re` minimap node, tile draw `0x4eccdd`–`0x4ecd69`): from the two global
/// day-night light colours — `color_a` = the Direct/Diffuse band (`LightIntBand` 0 = the light
/// table's `table[0]`), `color_b` = the Ambient band (band 1 = `table[1]`):
///
/// ```text
/// L = luma601(color_b) # (r·77 + g·151 + b·28) >> 8, on 0..255 bytes
/// t = min(L + 96, 255) / 256 # a +96 floor: even pitch-dark tints ~0.375 toward white
/// B' = lerp(color_b, white, t)
/// A' = lerp(color_a, B', 0.75) = 0.25·color_a + 0.75·B'
/// ```
///
/// Inputs and output are **gamma-space** (`WowLighting`'s sRGB 0..1 convention); handed to the UI
/// quad as its vertex colour, whose own linearize→re-encode reproduces the client's gamma-space
/// MODULATE (decision 0089). Interior (WMO) tiles are drawn full white and skip this.
fn minimap_day_tint(ambient: [f32; 3], diffuse: [f32; 3]) -> [f32; 3] {
let (color_a, color_b) = (diffuse, ambient);
// Rec.601 luma on 0..255 bytes: the weights (77,151,28) sum to 256, so the parenthesised sum is
// a 0..1 weighted average; ×255 lifts it to the byte the client's `>> 8` produces.
let l_byte = 255.0 * (color_b[0] * 77.0 + color_b[1] * 151.0 + color_b[2] * 28.0) / 256.0;
let t = (l_byte + 96.0).min(255.0) / 256.0;
let mut out = [0.0_f32; 3];
for c in 0..3 {
let b_prime = color_b[c] + (1.0 - color_b[c]) * t; // lerp(color_b, white, t)
out[c] = color_a[c] * 0.25 + b_prime * 0.75; // lerp(color_a, B', 0.75)
}
out
}
/// A `.map()` adaptor over a `md5translate.trs` hit: stream the hashed tile off-thread (like a
/// terrain tile) but as a **minimap tile** (`BlpVariant::MapTile`: gamma bytes with no sRGB decode
/// — the reference sampler's `GL_SKIP_DECODE_EXT` — clamp, mip 0, LINEAR). Gamma bytes move the
/// sRGB decode to AFTER the filter, so every consumer must say so or draw the tile ~2× too bright
/// through the UI pass: the outdoor quads carry
/// [`UiQuad::gamma_texel`](crate::ui_pass::UiQuad::gamma_texel), and the interior composite's
/// alpha-test arm decodes explicitly for its un-encoded target. The invariant `benilla-assets`'
/// `minimap_tile_settings_reach_the_async_loader` guards these settings reaching the async loader.
/// Shared by the terrain and interior tile paths.
fn load_tile(asset_server: &AssetServer) -> impl Fn(&str) -> Handle<Image> + '_ {
move |hash: &str| {
asset_server.load_with_settings(
format!("mpq://textures/Minimap/{hash}"),
|s: &mut benilla_assets::BlpLoaderSettings| {
s.variant = benilla_assets::BlpVariant::MapTile;
},
)
}
}
/// The **headless probe's** minimap widget. `WOW_MM_PROBE` drops the player inside a building in a
/// server-less capture ([`crate::capture`]), but the real slot comes from the FrameXML `<Minimap>`
/// extraction, which needs a logged-in UI — so without this the interior branch never runs and the
/// composite cannot be looked at offline. Under the probe (and only then) we synthesise the slot the
/// script would have published: a square in the top-right corner at the client's default zoom.
///
/// This is the instrument the B141 arc needed and did not have — the interior composite was being
/// argued about from screenshots because nothing could render it without a server.
fn probe_minimap_widget(mut widget: ResMut<MinimapWidget>, windows: Query<&Window>) {
if widget.0.is_some() || std::env::var("WOW_MM_PROBE").is_err() {
return;
}
let Ok(win) = windows.single() else { return };
let side = (win.height() * 0.22).min(win.width() * 0.22);
let margin = side * 0.15;
let min = Vec2::new(win.width() - side - margin, margin);
widget.0 = Some(MinimapSlot {
rect: Rect::from_corners(min, min + Vec2::splat(side)),
z: u64::MAX / 2,
zoom: 3,
inside_zoom: 3,
alpha: 1.0,
});
}
/// This frame's extracted `<Minimap>` widget slot, written by `ui_script::extract::drive_script` (the
/// `QuadContent::Minimap` arm) — `None` when no Minimap widget is visible (cluster hidden, no XML).
#[derive(Resource, Default)]
pub(crate) struct MinimapWidget(pub(crate) Option<MinimapSlot>);
/// The player's WMO-containment verdict as of this frame — [`feed_minimap_inside`]'s answer, kept
/// so anything that has to know "which zoom table is live" can read it instead of re-running the
/// portal flood-fill. The client keeps one flag for both consumers (`0xceaa60`) and so do we.
#[derive(Resource, Default)]
pub(crate) struct MinimapInside(pub(crate) bool);
/// The **persisted** half of the minimap zoom (decision 1131) — the client's two CVar objects
/// `minimapZoom` / `minimapInsideZoom`, whose registered default is `"3"` in both cases (wow-re,
/// VERIFIED at the `RegisterCVar 0x63db90` argument slot). The *live* indices are the widget's
/// ([`benilla_ui::widget::MinimapState`]); this is the durable knob [`crate::cvars`] loads out of
/// `config.toml` and saves back into it. It is **read once** — when the in-game UI materializes and
/// the fresh widget is seeded from it (`UiScript::set_minimap_zoom`) — and written whenever
/// `Minimap:SetZoom` reports a new level, which is exactly the client's own split: `set_zoom` writes
/// the live index *and* the CVar, and the minimap reset path re-seeds the index from the CVar.
#[derive(Resource)]
pub(crate) struct MinimapZoom {
/// `minimapZoom` — the outdoor index (the chunk table's).
pub(crate) outdoor: u8,
/// `minimapInsideZoom` — the indoor index (the radius table's), persisted separately so zooming
/// indoors never disturbs the outdoor level.
pub(crate) inside: u8,
}
impl Default for MinimapZoom {
fn default() -> Self {
Self {
outdoor: MINIMAP_DEFAULT_ZOOM,
inside: MINIMAP_DEFAULT_ZOOM,
}
}
}
/// One extracted Minimap widget: where it sits on screen (y-down logical px), its paint key, and
/// its live widget state.
pub(crate) struct MinimapSlot {
pub(crate) rect: Rect,
pub(crate) z: u64,
/// The outdoor zoom index (chunk table); `inside_zoom` is its indoor twin (radius table). The
/// client persists both and picks by WMO containment — see [`INTERIOR_ZOOM_RADIUS`].
pub(crate) zoom: u8,
pub(crate) inside_zoom: u8,
pub(crate) alpha: f32,
}
/// The loaded minimap fixtures: the tile hash catalog + the circular mask + the arrow art.
/// Inserted at startup once the chain is open; absent = the minimap draws nothing (its XML
/// children still render).
#[derive(Resource)]
struct MinimapAssets {
translate: MinimapTranslate,
mask: Option<Handle<Image>>,
arrow: Option<Handle<Image>>,
/// The shared POI atlas (`Interface\Minimap\POIIcons`) — the corpse blip's skull cell
/// (decision 0308 §5) and any later POI rides it.
poi: Option<Handle<Image>>,
/// The **four** rim-arrow arts — the flat `.blp` stand-ins for the one `minimapArrowModel`
/// (`Rotating-MinimapArrow.mdx`) the reference re-animates per blip source. See
/// [`blips::RimArrow`] for the sequence→layer table and why there are four of them.
rim_arrows: blips::RimArrowArt,
/// The ping marker's three drawn layers — the flat re-expression of `MinimapPing.mdx`'s
/// coincident additive quads, at the model's own byte-measured sizes and animation
/// ([`ping::PingArt`], decisions 1596/1599).
ping: ping::PingArt,
/// The unit-blip atlas (`Interface\Minimap\ObjectIcons`, five 32-px dot cells) — the
/// quest-giver dots.
object_icons: Option<Handle<Image>>,
/// `SpellShapeshiftForm.dbc` — the tracking dots' creature-type override (a cat-form
/// druid is a Beast; decision 0564). `None` = no override (unshifted resolution only).
forms: Option<HashMap<u32, benilla_formats::ShapeshiftForm>>,
}
/// Async tile handles by ADT index, for the [`CurrentMap`] it was filled on. `None` = the tile has
/// no authored minimap art (open ocean) — cached so the translate lookup doesn't re-run per frame.
/// The interior half caches the WMO tiles by `(group, col, row)` for the WMO whose `stem` (its
/// `md5translate.trs` path stem) is resident, cleared when the player enters a different building.
#[derive(Resource, Default)]
struct MinimapTileCache {
map_id: Option<u32>,
tiles: HashMap<(u32, u32), Option<Handle<Image>>>,
interior_stem: Option<String>,
interior: HashMap<(usize, u32, u32), Option<Handle<Image>>>,
}
/// Loads the translate catalog + the mask/arrow art once the patch chain is open.
fn setup_minimap(
mut commands: Commands,
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
) {
let Some(mut assets) = world_assets else {
return;
};
let translate = {
let mut chain = assets.chain.lock_recover();
benilla_formats::load_minimap_translate(&mut chain)
};
match translate {
Ok(translate) => {
info!("minimap: md5translate.trs — {} tiles", translate.len());
let mask = assets.mask_texture("Textures\\MinimapMask", &mut images);
let arrow = assets.sprite_texture(blips::PLAYER_ARROW_TEXTURE, &mut images);
let poi = assets.sprite_texture("Interface\\Minimap\\POIIcons", &mut images);
let mut rim_arrows = blips::RimArrowArt::default();
for kind in blips::RimArrow::ALL {
rim_arrows.set(kind, assets.sprite_texture(kind.texture(), &mut images));
}
let object_icons =
assets.sprite_texture("Interface\\Minimap\\ObjectIcons", &mut images);
// `ping6` is deliberately absent: the model carries it on two quads whose weight
// track is a single key of 0, so the client culls those batches before it reads their
// blend mode. Loading it would be loading art that never draws.
let ping = ping::PingArt {
ping5: assets.sprite_texture("Interface\\Minimap\\Ping\\ping5", &mut images),
ping2: assets.sprite_texture("Interface\\Minimap\\Ping\\ping2", &mut images),
ping4: assets.sprite_texture("Interface\\Minimap\\Ping\\ping4", &mut images),
};
// Per layer, not just "none of them": the three do different jobs, and a silently
// absent `ping4` would cost the ring — the one thing the eye actually reads — while
// the marker still looked plausible (1203's rule, applied to art).
for (name, present) in [
("ping5", ping.ping5.is_some()),
("ping2", ping.ping2.is_some()),
("ping4", ping.ping4.is_some()),
] {
if !present {
warn!(
"minimap: Interface\\Minimap\\Ping\\{name} missing — the ping marker \
will draw without that layer"
);
}
}
if mask.is_none() {
warn!("minimap: MinimapMask.blp missing — the map will draw square");
}
let forms = {
let mut chain = assets.chain.lock_recover();
match benilla_formats::load_shapeshift_forms(&mut chain) {
Ok(f) => Some(f),
Err(e) => {
warn!("minimap: SpellShapeshiftForm.dbc failed — no shapeshift creature-type override: {e:#}");
None
}
}
};
commands.insert_resource(MinimapAssets {
translate,
mask,
arrow,
poi,
rim_arrows,
object_icons,
ping,
forms,
});
}
Err(e) => error!("minimap: md5translate.trs failed, minimap disabled: {e:#}"),
}
}
/// The minimap's **one** containment verdict — which map family draws, and (through
/// [`feed_minimap_inside`]) which zoom index the +/- buttons drive. The client keeps a single flag
/// for both (`0xceaa60`), so this must be computed once and shared: two probes drifting apart is
/// how the map ends up drawn at the wrong scale for the family it is showing.
///
/// The reference hard-switches map families on interior containment: standing inside a WMO group it
/// draws that building's OWN minimap tiles and SUPPRESSES the terrain (mutually exclusive, not a
/// transparent overlay). Returns the placement, model, `md5translate.trs` path stem and seed group
/// of the WMO the player is in, or `None` for the terrain family.
///
/// **The gate is the client's one indoor byte** (`0xbc8300`), and that byte is the CGLight node's
/// down-ray bit `[node+0x90] & 1` (`0x670547` — wow-re `wmo-interior-minimap-composite.md`, which
/// CORRECTED the old note's "containment resolves to a group with `0x10` set": that `0x10` is a
/// ctor-set class tag, not a group flag). The predicate is a **position cast, faces only**: the
/// nearest surface within 1000 yd straight down — terrain racing the WMO faces, closer wins and the
/// WMO wins ties — is a WMO face whose group lacks MOGP `0x8`. Terrain below ⇒ outdoors, whatever
/// building you are geometrically inside. That is exactly [`CurrentAreaInterior`]'s law
/// (`wmo_portal::area_down_ray`, the zone-text bit), so the gate reads it rather than re-deriving
/// one: the portal-crossing leg in [`down_ray_seeds`] belongs to the CAMERA's current-group system
/// and claiming an interior through a doorway plane under the eye is the abbey-yard bug's shape.
/// [`down_ray_seeds`] still supplies the flood SEED once the gate has said indoors.
fn minimap_interior<'a>(
player: &Player,
instances: &Query<&WmoPortalInstance>,
wmos: &'a Assets<WmoModel>,
world: &benilla_world::world_point::WorldPoint,
asset_server: &AssetServer,
) -> Option<(Affine3A, &'a WmoModel, String, usize)> {
if !player.active || player.detached || world.area_interior().is_none() {
return None;
}
let eye = player.pos + Vec3::Y * INTERIOR_PROBE_HEIGHT;
// The down-ray races the terrain, exactly as the interior/zone tracker does — standing on the
// grass above a mine's tunnels is not standing in the mine.
let terrain = world.terrain_height_under(eye);
instances.iter().find_map(|inst| {
let model = wmos.get(&inst.handle)?;
if model.wmo_id == 0 {
return None;
}
let local_from_world = inst.world_from_local.inverse();
let eye_local = bevy_to_wow(local_from_world.transform_point3(eye));
let terrain_local = terrain.map(|z| terrain_z_local(&local_from_world, eye, z));
let in_group = down_ray_seeds(model, eye_local, terrain_local).in_group?;
let stem = asset_server
.get_path(inst.handle.id())
.and_then(|p| wmo_minimap_stem(&p.path().to_string_lossy()))?;
Some((inst.world_from_local, model, stem, in_group))
})
}
/// Fills the extracted widget hole: the visible tile quads (clipped to the widget, masked to the
/// circle) and the player arrow, appended at the widget's own z (stable sort keeps append order
/// within a key, so the arrow rides above the tiles and below the widget's children).
#[allow(clippy::too_many_arguments)]
fn emit_minimap(
widget: Res<MinimapWidget>,
assets: Option<Res<MinimapAssets>>,
mut cache: ResMut<MinimapTileCache>,
map: Option<Res<CurrentMap>>,
catalog: Option<Res<MapCatalogRes>>,
player: Res<Player>,
lighting: Option<Res<benilla_world::lighting::WowLighting>>,
instances: Query<&WmoPortalInstance>,
wmos: Res<Assets<WmoModel>>,
world: benilla_world::world_point::WorldPoint,
asset_server: Res<AssetServer>,
death_net: Res<crate::death::DeathNet>,
blip_inputs: blips::BlipInputs,
mut composite: ResMut<composite::MinimapComposite>,
rig: Option<Res<composite::CompositeRig>>,
mut quads: ResMut<UiQuads>,
) {
let (
quest,
guids,
unit_pos,
window,
mut blip_hover,
ui_scale,
group,
tracked,
self_store,
names,
go_templates,
locks,
poi_marker,
pois,
mut ping,
script,
) = blip_inputs;
// Hover resets every frame; the blip pass below re-establishes it while the map draws.
*blip_hover = blips::MinimapBlipHover::None;
// Drain this frame's `Minimap:PingLocation` click BEFORE any early return below, so it is
// always spent in the frame it was made. Held across frames it would seat against geometry
// the player never clicked on; and a click made on a frame the map does not draw is simply
// not a ping (decision 1596).
// The stock `MiniMapPing` frame's alpha is read beside it: the ping's lifetime is that
// frame's (`ping.rs`), so the sprite wants the frame as it stands after this tick.
let (click, ping_shown) = script.map_or((None, None), |mut s| {
(
s.take_minimap_ping_request(),
s.frame_effective_alpha(ping::PING_FRAME),
)
});
let (Some(slot), Some(assets), Some(map), Some(catalog)) =
(widget.0.as_ref(), assets, map, catalog)
else {
return;
};
let side = slot.rect.width().min(slot.rect.height());
if side <= 0.0 {
return;
}
// The composite is off unless the interior branch below turns it on this frame.
composite.active = false;
let Some(rt_image) = rig.map(|r| r.image.clone()) else {
return; // the composite rig's Startup system has not run yet
};
// `WOW_MM_ZOOM=0..5` forces the zoom level of whichever map is showing — a capture instrument
// (pairs with the `WOW_MM_PROBE` interior probe). Indoors and outdoors each carry their own
// persisted index, so the override stands in for both.
let zoom_override = std::env::var("WOW_MM_ZOOM")
.ok()
.and_then(|s| s.parse::<u8>().ok());
let zoom = zoom_override.unwrap_or(slot.zoom);
let inside_zoom = zoom_override.unwrap_or(slot.inside_zoom);
let center = (slot.rect.min + slot.rect.max) * 0.5;
let wow = bevy_to_wow(player.pos);
let (wx, wy) = (wow[0], wow[1]);
// The active branch's world→px scale, for the point blips drawn after the tiles (the corpse
// marker below; both branches share the same north-up point mapping around `center`).
let mut blip_px_per_yd = 0.0_f32;
let mask = assets.mask.as_ref().map(|m| UiQuadMask {
texture: m.clone(),
rect: slot.rect,
});
let interior = minimap_interior(&player, &instances, &wmos, &world, &asset_server);
// The player's containment verdict, kept as a bool for the quest-dot grey (the branch
// below consumes `interior` itself).
let player_indoors = interior.is_some();
// How far the map reaches this frame — decided once, by the one function that decides it, and
// used by whichever family draws below.
let view_radius = view_radius_yd(zoom, inside_zoom, player_indoors);
if let Some((world_from_local, model, stem, in_group)) = interior {
// INTERIOR: the WMO's own per-group tiles, drawn FULL WHITE (the day-night tint is outdoor-
// only). The tiles are baked in the WMO's MODEL frame (north = model +X, sized to the model
// footprint — verified against the 97°-yaw Goldshire Inn: group 3's tile is 64×32 px = its
// model bbox, not the world AABB). So place each tile at its model-space centre mapped
// through the placement, and rotate the WHOLE set by ONE placement-yaw angle — not per-tile
// world AABBs (`wow-5875-re` minimap node Sub-Q6). Cached by `(group, col, row)`.
if cache.interior_stem.as_deref() != Some(stem.as_str()) {
cache.interior.clear();
cache.interior_stem = Some(stem.clone());
}
// INTERIOR ZOOM: indoors has its OWN zoom index and its own table — the view radius is
// `radiusTable[inside_zoom]` in raw yards (150 widest … 25 tightest), not the outdoor chunk
// half-extent. The zoom buttons drive `inside_zoom` while you're inside, and it persists
// separately from the outdoor level (wow-re finding 2 Q7 CORRECTION).
let radius = view_radius;
let px_per_yd = (side * 0.5) / radius;
blip_px_per_yd = px_per_yd;
// The tiles are composited into the client's own 256² TARGET, not drawn at the screen —
// both halves of the mechanism matter and only work together (decision 1466; the module
// docs in [`composite`] carry the why). Target space: y-UP, origin at the target's centre
// (= the player), and `RT_HALF_EXTENT_SCALE · radius` yards to an edge.
#[allow(clippy::cast_precision_loss)] // 256 is exact in f32
let units_per_yd =
(composite::RT_SIZE as f32 * 0.5) / (composite::RT_HALF_EXTENT_SCALE * radius);
// A model point → its north-up target position (through the placement to world, then the
// same north-up map the terrain tiles use: up = world +X north, left = +Y west).
let to_target = |m: [f32; 3]| {
let w = bevy_to_wow(world_from_local.transform_point3(wow_to_bevy(m)));
Vec2::new((wy - w[1]) * units_per_yd, (w[0] - wx) * units_per_yd)
};
// The one placement rotation: where the model +X axis points, as a CLOCKWISE-on-screen
// angle (the target's y-up frame negates it at the Transform). Same for every tile.
let x_axis = to_target([1.0, 0.0, 0.0]) - to_target([0.0, 0.0, 0.0]);
let rotation = (-x_axis.y).atan2(x_axis.x);
// The target's own edge, in target units, for the window cull below.
#[allow(clippy::cast_precision_loss)]
let rt_half = composite::RT_SIZE as f32 * 0.5;
composite.active = true;
// GROUP SELECTION: the portal flood-fill from the player's current group (wow-re
// `wmo-interior-minimap.md` Sub-Q4b, byte-verified) — NOT draw-every-group. Only the groups
// reached through portals within the query box, whose bbox overlaps the view in XY, are drawn.
// This is what stops floors the player can't reach (or that are far outside the view) from
// painting over the current one.
// The selection query box uses the SAME `c` as the draw radius (Sub-Q4b) — so we never load
// tiles we cannot show, and zooming in indoors tightens the box's Z extent too, which is what
// trims how many stacked floors bleed through.
let drawable =
interior_group_selection(model, &world_from_local, player.pos, radius, in_group);
// Draw ORDER (wow-re finding 2 Q2, VERIFIED): the composite is Z-sorted ascending by
// `Zmidpoint − playerZ`, with the player's OWN group forced to the top (the client keys it
// FLT_MAX). So floors below draw at the bottom, floors above over them, and the player's
// current floor LAST of all — a stacked storey shows only through its transparent stairwell
// gaps, never occluding the room you're in ("basement on top the kitchen", director).
let player_z = bevy_to_wow(world_from_local.inverse().transform_point3(player.pos))[2];
let sort_key = |gi: usize| -> f32 {
if gi == in_group {
f32::MAX
} else {
let gn = &model.group_nav[gi];
0.5 * (gn.bbox_min[2] + gn.bbox_max[2]) - player_z
}
};
let mut order: Vec<usize> = (0..model.group_nav.len())
.filter(|&gi| drawable[gi])
.collect();
order.sort_by(|&a, &b| sort_key(a).total_cmp(&sort_key(b)));
for gi in order {
let gn = &model.group_nav[gi];
let (nx, tw_x) = group_axis_grid(gn.bbox_max[0] - gn.bbox_min[0]);
let (ny, tw_y) = group_axis_grid(gn.bbox_max[1] - gn.bbox_min[1]);
let mid_z = 0.5 * (gn.bbox_min[2] + gn.bbox_max[2]);
for col in 0..nx {
for row in 0..ny {
// The tile's world rect: the grid cell PLUS the client's outer-edge BLEED. The
// cells themselves stride exactly `tw`, sharing their interior edges — but a
// cell on the grid's boundary is grown by 1.0 yd on that side alone
// (`0x6a549e`/`0x6a54ae`/`0x6a54be`/`0x6a54d1`, each an `fsub`/`fadd` of
// `0xca8098 = 0.5 + 0.5`; wow-re `wmo-interior-no-adt-underlay.md` §8, fitted
// to the reference's own captured quads with zero error on every bound). A
// 1×1 grid is therefore `tw + 2` across, an end cell `tw + 1`, an interior
// cell exactly `tw`.
//
// THIS is what makes the joints work. The bleed grows every group's art 1 yd
// past its bbox on each outer side, so two groups whose boxes touch OVERLAP by
// 2 yd. Without it their art merely abuts — and abutting art does not survive
// the composite's alpha test: `GEQUAL 224/255` on a LINEAR-filtered silhouette
// reaches only 0.122 of a texel past the last opaque texel centre (nearest
// would reach the texel edge, 0.5), so an abutting pair loses ~0.38 texel and
// the black clear reads through the strip for the length of the wall. That is
// B141's dashed hairline; a 2 yd overlap absorbs the same erosion with four
// texels to spare. In the reference's captured frame 16 of 220 inter-group
// tile contacts exist ONLY because of the bleed.
let x0 = gn.bbox_min[0] + col as f32 * tw_x
- if col == 0 { EDGE_BLEED_YD } else { 0.0 };
let x1 = gn.bbox_min[0]
+ (col + 1) as f32 * tw_x
+ if col + 1 == nx { EDGE_BLEED_YD } else { 0.0 };
let y0 = gn.bbox_min[1] + row as f32 * tw_y
- if row == 0 { EDGE_BLEED_YD } else { 0.0 };
let y1 = gn.bbox_min[1]
+ (row + 1) as f32 * tw_y
+ if row + 1 == ny { EDGE_BLEED_YD } else { 0.0 };
let tc = to_target([0.5 * (x0 + x1), 0.5 * (y0 + y1), mid_z]);
// Window cull: skip tiles whose centre lands well outside the TARGET (which
// holds 1.5× what the blit shows, so this is wider than the visible disc —
// deliberately: the client composites the same margin).
if tc.length() > rt_half + tw_x.max(tw_y) * units_per_yd {
continue;
}
let handle = cache.interior.entry((gi, col, row)).or_insert_with(|| {
let key = format!("{stem}_{gi:03}_{col:02}_{row:02}.blp");
assets.translate.get(&key).map(load_tile(&asset_server))
});
let Some(handle) = handle else {
continue; // this group cell has no authored tile
};
let order = composite.tiles.len();
composite.tiles.push(composite::CompositeTile {
texture: handle.clone(),
center: tc,
// The client's HALF-TEXEL UV INSET, expressed as the scale it is: it
// samples `[0.5/W, 1−0.5/W]` across the rect above (verified on the
// captured quads — every one carries exactly that UV rect), and sampling
// that range across a quad of size `Q` is the same as sampling `[0, 1]`
// across `Q·W/(W−1)` about the same centre. So the shared unit mesh keeps
// its UVs and the inset rides the Transform like everything else here, and
// the texel centres land where the client's do. `W` comes from the TILE
// (`tw / 0.5`), never from the bled rect.
size: Vec2::new(
(x1 - x0) * units_per_yd * texel_stretch(tw_x),
(y1 - y0) * units_per_yd * texel_stretch(tw_y),
),
rotation,
order,
});
}
}
}
// `WOW_MM_STATS=1` reports what the interior branch actually put in the target this frame —
// how many groups the flood-fill kept out of how many, and how many tiles that came to. The
// reference's own Stormwind capture emitted 57 tiles at indoor zoom 3, which is the number
// this is here to be compared against (wow-re `wmo-interior-no-adt-underlay.md`).
if std::env::var("WOW_MM_STATS").is_ok() {
eprintln!(
"MM-STATS: radius {radius} yd, groups {}/{} selected, {} tiles composited",
drawable.iter().filter(|d| **d).count(),
drawable.len(),
composite.tiles.len(),
);
}
// THE BLIT: the target's middle two-thirds, which is what nets `1.0 · radius` on screen
// (the client's `0x4ec440` under EGxBlend 2). One quad, masked to the minimap circle — the
// round cut belongs HERE and not to the tiles, exactly as the reference's does. The target
// is opaque everywhere (its clear is opaque black), so this quad IS the black backing the
// screen path used to push separately.
let lo = 0.5 - composite::RT_BLIT_FRACTION * 0.5;
let hi = 0.5 + composite::RT_BLIT_FRACTION * 0.5;
quads.overlays.push(UiQuad {
rect: Rect::from_center_size(center, Vec2::splat(side)),
z_key: slot.z,
texture: Some(rt_image),
uv: UvRect::from_tex_coords([lo, hi, lo, hi]),
color: [1.0, 1.0, 1.0, slot.alpha],
mask: mask.clone(),
..default()
});
} else if let Some(dir) = catalog.0.directory(map.0) {
// OUTDOOR: the ADT terrain tiles, MODULATEd by the day-night light tint (not full white —
// else too bright, the reference's CWorldFrame minimap draw). Absent lighting ⇒ white.
let half_extent = view_radius;
let px_per_yd = (side * 0.5) / half_extent;
blip_px_per_yd = px_per_yd;
if cache.map_id != Some(map.0) {
cache.tiles.clear();
cache.map_id = Some(map.0);
}
let tint = lighting
.as_ref()
.map(|l| minimap_day_tint(l.ambient, l.diffuse))
.unwrap_or([1.0, 1.0, 1.0]);
// World coords shrink as tile indices grow, so the view square's max-corner gives the low
// indices. `world_to_tile` clamps to the 64×64 grid.
let (tx_lo, ty_lo) = world_to_tile(wx + half_extent, wy + half_extent);
let (tx_hi, ty_hi) = world_to_tile(wx - half_extent, wy - half_extent);
for ty in ty_lo..=ty_hi {
for tx in tx_lo..=tx_hi {
let handle = cache.tiles.entry((tx, ty)).or_insert_with(|| {
assets
.translate
.tile(dir, tx, ty)
.map(load_tile(&asset_server))
});
let Some(handle) = handle else {
continue; // unauthored tile (open ocean) — the mask shows the clear color
};
// The tile's max-x/max-y world corner is its north-west corner = screen top-left.
let (tile_north, tile_west) = tile_to_world(tx, ty);
let left = center.x + (wy - tile_west) * px_per_yd;
let top = center.y + (tile_north - wx) * -px_per_yd;
let size = TILE_YARDS * px_per_yd;
let rect = Rect::new(left, top, left + size, top + size);
if rect.intersect(slot.rect).is_empty() {
continue;
}
quads.overlays.push(UiQuad {
rect,
z_key: slot.z,
texture: Some(handle.clone()),
color: [tint[0], tint[1], tint[2], slot.alpha],
// The tile is a SKIP_DECODE upload (gamma bytes — [`load_tile`]), so the
// day-night MODULATE above lands on the authored byte, exactly the reference's
// fixed-function stage. Without this the ordinary arm re-encodes an
// already-encoded byte — the outdoor minimap reads visibly too bright.
gamma_texel: true,
// No CPU clip (1463): the mask shader already zeroes everything outside
// `mask_rect` (`ui_quad.wgsl`'s `inside` test), and clipping a PANNING tile
// re-cut its quad every frame — constant positions, churning UVs — which is
// exactly the shape the batcher's pan gate cannot ride on a `Transform`.
// Unclipped, the tile is a pure translation and never rewrites its mesh.
mask: mask.clone(),
..default()
});
}
}
}
// ── The blip layer (decision 0203 phase 3; byte law per the 0337 fold-back): landmarks
// draw under the player arrow; the quest dots draw LAST — above it (the client's own draw
// order). Hover lands in [`blips::MinimapBlipHover`] for the tooltip drive.
// Our own descriptor's tracking state (PRIVATE fields — only ever on the self entity).
let me = self_store.iter().next();
// Our own guid — the classifier compares a candidate's charm/summon owner against it, so our
// own pet and minions never take a dot (§W15 Q2d).
let self_guid = me.map(|(_, g)| g.0);
let tracking = me
.map(|(s, _)| blips::SelfTracking {
creatures: s.0.player_track_creatures(),
resources: s.0.player_track_resources(),
stealthed: s.0.player_track_stealthed(),
})
.unwrap_or_default();
let blip_ctx = (blip_px_per_yd > 0.0).then(|| {
if std::env::var("WOW_MM_BLIP_PROBE").is_ok() {
eprintln!(
"BLIP-PROBE: arrow_art={} pois={} map={} wx={wx:.0} wy={wy:.0} px_per_yd={blip_px_per_yd:.3} track_c={:#x} track_r={:#x} track_s={}",
blips::RimArrow::ALL
.iter()
.filter(|k| assets.rim_arrows.get(**k).is_some())
.count(),
pois.as_ref().map(|c| c.0.len()).unwrap_or(0),
map.0,
tracking.creatures,
tracking.resources,
tracking.stealthed,
);
}
let win = window.iter().next();
let cursor = win.and_then(|w| w.cursor_position());
let seam = win.map_or(1.0, |w| crate::ui_script::seam_scale(w.height(), ui_scale.0));
// The player's pan term, quantized to a half-logical-pixel grid (one device px at 2×):
// every blip offset — and the rim arrows' bearing — derives from `wx`/`wy`, so the whole
// blip layer steps together a few times a second instead of re-emitting sub-pixel-shifted
// quads every frame while walking (1463; a 0.07 px/frame slide on a 16 px icon is not a
// visible motion, but each slide rewrote the batch mesh and armed the world's
// `AssetChanged` scans). The blips' world positions stay exact — only the shared pan
// origin snaps.
let q = 0.5 / blip_px_per_yd;
blips::BlipCtx {
center,
side,
px_per_yd: blip_px_per_yd,
radius_yd: (side * 0.5) / blip_px_per_yd,
z: slot.z,
alpha: slot.alpha,
wx: (wx / q).round() * q,
wy: (wy / q).round() * q,
wz: wow[2],
cursor,
// The same point in UI space (y-up, ÷s through the 0582/0584 seam — the tooltip's
// anchor resolves in the VM's 768-virtual units, not window px): the cursor seat.
cursor_ui: cursor
.zip(win)
.map(|(c, w)| Vec2::new(c.x / seam, (w.height() - c.y) / seam)),
seam,
}
});
let mut hover = blips::MinimapBlipHover::None;
if let Some(ctx) = &blip_ctx {
// The guard-directions marker rides this pass as a landmark candidate, the way the
// reference appends its static blip slot after the DBC scan — so it draws even when
// `AreaPOI.dbc` failed to load, and the pass runs on the arrow art alone.
if assets.rim_arrows.any() {
blips::emit_landmarks(
ctx,
pois.as_ref().map(|p| &p.0),
poi_marker.on_map(map.0),
map.0,
&assets.rim_arrows,
assets.poi.as_ref(),
&mut quads,
&mut hover,
);
// The party/corpse rim arrows (0434 phase 6b, `place_party_raid_blips`' out-of-range
// half) draw with the POI arrows — before the player arrow, per the client's order.
let corpse = death_net
.corpse
.filter(|cp| cp.display_map == map.0 as i32)
.map(|cp| cp.position);
blips::emit_party_arrows(
ctx,
&group,
&guids,
&unit_pos,
corpse,
&assets.rim_arrows,
&mut quads,
);
}
}
// The player arrow: centered, spun to the facing. WoW orientation 0 = north (screen up),
// growing counterclockwise (toward west = screen left); our quad rotation is clockwise on
// screen, so the arrow angle is the negated facing.
if let Some(arrow) = &assets.arrow {
// Byte-pinned quad (blips::PLAYER_ARROW_QUAD_PX): the MinimapArrow.m2 single quad at
// 1280 px/unit on the frozen 140.8 basis, its authored centre offset rotating with
// the facing (clockwise screen rotation, so the offset rotates by the same angle).
let s = side * (blips::PLAYER_ARROW_QUAD_PX / blips::BLIP_BASIS_PX);
let rotation = -player.facing();
let (sin, cos) = rotation.sin_cos();
let off = blips::PLAYER_ARROW_OFFSET_PX * (side / blips::BLIP_BASIS_PX);
let off = Vec2::new(off.x * cos - off.y * sin, off.x * sin + off.y * cos);
let rect = Rect::from_center_size(center + off, Vec2::splat(s));
quads.overlays.push(UiQuad {
rect,
z_key: slot.z,
texture: Some(arrow.clone()),
color: [1.0, 1.0, 1.0, slot.alpha],
rotation,
..default()
});
}
// The object dots draw LAST — above the player arrow (the client's draw order, 0x4ed7b7):
// tracking dots (cells 0/1) first, then quest dots (cell 3) and party dots (cell 4) — the
// draw's own cell-list order.
if let Some(ctx) = &blip_ctx {
if let Some(icons) = &assets.object_icons {
blips::emit_tracking_dots(
ctx,
tracking,
&tracked,
quest.statuses(),
self_guid,
&names,
&go_templates,
locks.as_deref().map(|l| &l.0),
assets.forms.as_ref(),
icons,
player_indoors,
|feet| world.indoors_at(feet),
&mut quads,
&mut hover,
);
blips::emit_quest_dots(
ctx,
quest.statuses(),
&tracked,
self_guid,
icons,
player_indoors,
// A dot NPC's own containment — the same faces-only down-ray the entity light
// classifier stands on (dots are few; the per-frame rays are cheap).
|feet| world.indoors_at(feet),
&mut quads,
&mut hover,
);
// The in-range party dots (blue cell 4, 1.3×) draw with the object dots — last.
blips::emit_party_dots(ctx, &group, &guids, &unit_pos, icons, &mut quads);
}
}
*blip_hover = hover;
// The corpse blip (decision 0308 §5): in range, the POIIcons skull cell (the same art the
// ref's world-map corpse uses; the engine-drawn in-range minimap corpse art is INTERIM until
// named) at the corpse's true position, through the same north-up point mapping as the
// tiles. OUT of range the corpse is the fifth `place_party_raid_blips` slot — the rotating
// rim arrow drawn with the party arrows above (the byte law replaced the old 0.92
// edge-clamped skull). Same-map only (the display coords are the entrance for a dungeon
// corpse).
if let (Some(poi), Some(cp)) = (&assets.poi, death_net.corpse) {
if cp.display_map == map.0 as i32 && blip_px_per_yd > 0.0 {
let off = Vec2::new(
(wy - cp.position[1]) * blip_px_per_yd,
-(cp.position[0] - wx) * blip_px_per_yd,
);
if off.length() <= side * 0.5 * 0.8 {
let s = side * CORPSE_BLIP_FRACTION;
let rect = Rect::from_center_size(center + off, Vec2::splat(s));
quads.overlays.push(UiQuad {
rect,
z_key: slot.z,
texture: Some(poi.clone()),
uv: crate::ui_pass::UvRect::from_tex_coords([0.875, 1.0, 0.0, 0.125]),
color: [1.0, 1.0, 1.0, slot.alpha],
..default()
});
}
}
}
// The ping draws LAST — above the dots and the player arrow. In the reference `MiniMapPing` is
// a Lua Frame child of the Minimap, so it composites over everything the engine drew into the
// widget's hole; ours is engine-drawn but keeps that place in the order.
//
// This is also where a `Minimap:PingLocation` click is drained: the geometry it must resolve
// against is the geometry standing right here, this frame (decision 1596).
if let Some(ctx) = &blip_ctx {
ping::emit_ping(ctx, &mut ping, click, ping_shown, &assets.ping, &mut quads);
}
}
/// Push the player's WMO-containment state onto the Minimap widget (the client's `0xceaa60`), so the
/// zoom buttons drive the **indoor** zoom index while indoors and the outdoor one while outside, each
/// persisting across the transition. Runs before the script tick, so a `SetZoom` fired from a button
/// handler this frame routes to the right index. The verdict is [`minimap_interior`]'s — the SAME
/// one [`emit_minimap`] draws by, because the client keeps one flag for both (it used to read the
/// camera-eye `CurrentWmoInterior` instead, which is a different ray *and*, since 1466, a different
/// mask: the buttons would have kept driving the indoor index on a Stormwind street the map was
/// drawing from terrain tiles).
/// The state is pushed on the inside↔outside *edge* and whenever the VM's Minimap-creation count
/// moved (the cluster XML loads late, and an addon can build one whenever it likes — the counter
/// is what keeps "a widget created after the last transition is still told" true without walking
/// the ~3k-frame arena every frame; the memo resets with a fresh VM, whose rebuilt widgets bump
/// the fresh counter). On the edge we also fire `MINIMAP_UPDATE_ZOOM`: the active zoom index just switched
/// to the other (independent) level, so the cluster must re-sync the +/- buttons' enabled state to it
/// — the client's own signal for "the effective zoom changed" (FrameXML `Minimap_OnEvent`). Without it
/// the buttons keep the level you left (e.g. `ZoomIn` greyed from an outdoor max-zoom, still greyed
/// indoors at level 3), which is the director's report (2026-07-09).
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
fn feed_minimap_inside(
script: Option<bevy::ecs::system::NonSendMut<benilla_ui::script::UiScript>>,
world: benilla_world::world_point::WorldPoint,
player: Res<Player>,
instances: Query<&WmoPortalInstance>,
wmos: Res<Assets<WmoModel>>,
asset_server: Res<AssetServer>,
mut inside_res: ResMut<MinimapInside>,
mut was_inside: Local<crate::ui_script::VmMemo<Option<bool>>>,
mut pushed_at: Local<crate::ui_script::VmMemo<u64>>,
) {
let inside = minimap_interior(&player, &instances, &wmos, &world, &asset_server).is_some();
inside_res.0 = inside;
let Some(mut script) = script else { return };
let edge = *was_inside.get(&script) != Some(inside);
let created = script.minimap_widgets_created();
if edge || *pushed_at.get(&script) != created {
script.set_minimap_inside(inside);
*pushed_at.get(&script) = created;
}
if edge {
*was_inside.get(&script) = Some(inside);
script.fire_event("MINIMAP_UPDATE_ZOOM", vec![]);
}
}
/// Swap the disc's mask when an addon has asked for a different one — `Minimap:SetMaskTexture`.
///
/// The renderer already masks the map through its own `UiQuadMask`; this only decides which
/// texture that is, so the whole feature is a load and a handle swap. It is what makes pfUI's
/// square minimap — the single most recognisable thing it does to the default UI — actually
/// square, and it is a real 1.12 method rather than a pfUI invention.
///
/// Memoised on the path, so the load happens on the edge and never per frame; a VM restart resets
/// the memo and re-reads, because the fresh VM's widget starts at the engine default again. A path
/// that fails to load leaves the current mask standing and says so once — a missing file must not
/// silently unmask the map into a square, which is what dropping the handle would do.
fn feed_minimap_mask(
script: Option<bevy::ecs::system::NonSendMut<benilla_ui::script::UiScript>>,
assets: Option<ResMut<MinimapAssets>>,
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut last: Local<crate::ui_script::VmMemo<Option<String>>>,
) {
let (Some(script), Some(mut assets), Some(mut world_assets)) = (script, assets, world_assets)
else {
return;
};
let want = script.minimap_mask_texture();
let last = last.get(&script);
if *last == want {
return;
}
*last = want.clone();
let path = want
.as_deref()
.unwrap_or(benilla_ui::widget::MINIMAP_DEFAULT_MASK);
match world_assets.mask_texture(path, &mut images) {
Some(handle) => assets.mask = Some(handle),
None => {
warn!("minimap: SetMaskTexture(\"{path}\") — no such texture; keeping the current mask")
}
}
}
/// Push the player's world facing onto the Minimap's engine-owned player-arrow `Model` — the
/// client's `CMinimap::SetPlayerFacing 0x4eb8e0`, which writes it verbatim into the arrow's
/// `[+0x39c]`, the field `Model:GetFacing()` reads.
///
/// **Every frame, not on an edge**, because the value is continuous: the reference calls it from
/// the per-frame minimap update with the player's live `GetFacing()`, and an addon polling it in an
/// OnUpdate (which is what reads it — Questie's `GetPlayerFacing`, pfQuest's `compat/client.lua`)
/// would otherwise steer off a stale heading. It costs one arena lookup per Minimap widget, which
/// is what `WidgetArena::minimap_kinds` exists to make true.
///
/// **The one transform, and why it is not a fudge.** `player.facing()` is benilla's `face_yaw`, an
/// unbounded accumulator — a mouse-turn just adds to it — whereas the client's unit orientation is
/// a normalized field, and the same normalization already happens on the wire (`movement_net`'s
/// `[0, 2π)` rule, which vmangos's `VerifyMovementInfo` requires). So the value published here is
/// the wire value, and an addon reading it back sees the same number the server does.
fn feed_minimap_player_facing(
script: Option<bevy::ecs::system::NonSendMut<benilla_ui::script::UiScript>>,
player: Res<Player>,
) {
let Some(mut script) = script else { return };
script.set_minimap_player_facing(player.facing().rem_euclid(std::f32::consts::TAU));
}
/// Push the live game clock into the VM when the game minute ticks — `GetGameTime()`'s backing
/// globals (the zone-text family's shape). The reference's GameTimeFrame re-reads GetGameTime
/// every OnUpdate and compares the packed minute against its cached `timeOfDay`, so a
/// minute-granular push is exactly the API's own resolution (the binding returns no seconds).
/// Before the first `SMSG_LOGIN_SETTIMESPEED` the globals stay at their 0:00 stdlib seed.
fn feed_game_time(
script: Option<bevy::ecs::system::NonSendMut<benilla_ui::script::UiScript>>,
time: Res<crate::net::ServerTime>,
mut last: Local<crate::ui_script::VmMemo<Option<u32>>>,
) {
let Some(script) = script else { return };
let last = last.get(&script);
let Some(gt) = time.0 else { return };
let minute = gt.minute_of_day();
if *last == Some(minute) {
return;
}
*last = Some(minute);
let globals = script.lua().globals();
let pushed = globals
.set("__benilla_game_hour", minute / 60)
.and_then(|()| globals.set("__benilla_game_minute", minute % 60));
if let Err(e) = pushed {
warn!("minimap: game-time globals: {e}");
}
}
/// The app half of the `<Minimap>` widget (decision 0203 phase 1) — see the module doc. The zone
/// LABEL feed lives with the rest of the zone-text data plane (`crate::area`, decision 0287's
/// fold-back): the client updates the minimap line and fires `MINIMAP_ZONE_CHANGED` from the same
/// area-update pass as the ZONE_CHANGED family (`0x494970` beside `0x494780`), and so does
/// benilla.
pub(crate) struct MinimapPlugin;
impl Plugin for MinimapPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<MinimapWidget>()
.init_resource::<MinimapZoom>()
.init_resource::<MinimapTileCache>()
.init_resource::<blips::MinimapBlipHover>()
.init_resource::<MinimapInside>()
.init_resource::<MinimapPing>()
.init_resource::<composite::MinimapComposite>()
.add_systems(Startup, setup_minimap.after(AssetSet::Open))
.add_systems(Startup, composite::setup_composite)
.add_systems(
Update,
(
// The headless probe's synthetic widget, ahead of the emit that reads it.
probe_minimap_widget
.in_set(UiQuadAppend)
.before(emit_minimap),
emit_minimap.in_set(UiQuadAppend),
// After the emit that fills it: the composite camera draws what THIS frame's
// interior branch asked for, so the target the blit quad samples is never a
// frame behind the pan.
composite::drive_composite.after(UiQuadAppend),
// Before the script tick, so a zoom button pressed this frame routes to the
// indoor/outdoor index that matches where the player actually is.
feed_minimap_inside.before(crate::ui_script::UiInput),
// Before the script tick, so an addon's OnUpdate steers off this frame's
// heading rather than last frame's.
feed_minimap_player_facing.before(crate::ui_script::UiInput),
// Before the emit that reads `MinimapAssets::mask`, so a mask set this frame
// is the one this frame draws with.
feed_minimap_mask.before(UiQuadAppend),
// Before the script tick, and after the containment verdict it reads: the
// `MINIMAP_PING` event and `Minimap:GetPingPosition()`'s value land in the
// same tick, on a ping the renderer already drew at the end of last frame.
ping::drive_minimap_ping
.after(feed_minimap_inside)
.before(crate::ui_script::UiInput),
// Before the script tick, so GameTimeFrame's OnUpdate reads this frame's
// minute, not last frame's.
feed_game_time.before(crate::ui_script::UiInput),
// After the world-mouseover drive (UnitFeed): a same-frame world-hover→blip
// transition must end with the blip tooltip shown, not the fade.
blips::drive_blip_tooltip
.after(crate::ui_unit::UnitFeed)
.before(crate::ui_script::UiInput),
),
);
}
}
#[cfg(test)]
mod tests {
use super::minimap_day_tint;
fn approx(a: [f32; 3], b: [f32; 3]) {
for c in 0..3 {
assert!((a[c] - b[c]).abs() < 0.01, "{a:?} vs {b:?} @ {c}");
}
}
#[test]
fn full_white_light_leaves_tiles_at_full_brightness() {
// Both bands white ⇒ tint white ⇒ the tile draws verbatim (the noon-ish bright case).
approx(minimap_day_tint([1.0; 3], [1.0; 3]), [1.0; 3]);
}
#[test]
fn default_light_dims_tiles_below_white() {
// The client's no-light default: ambient = gray 0x40 (≈0.251), diffuse = white. The tint is
// ≈0.79, NOT 1.0 — i.e. drawing tiles at flat white is ≈1.27× too bright (the director's
// report). 0.25·1 + 0.75·lerp(0.251, 1, (64+96)/256=0.625) = 0.25 + 0.75·0.719 ≈ 0.789.
let t = minimap_day_tint([0.251; 3], [1.0; 3]);
approx(t, [0.789; 3]);
assert!(t[0] < 0.95, "flat white would be too bright");
}
#[test]
fn pitch_black_light_still_tints_partway_to_white() {
// The +96 luma floor keeps the map dimly visible even with zero light: 0.75·(0+1·0.375) ≈ 0.28.
approx(minimap_day_tint([0.0; 3], [0.0; 3]), [0.281; 3]);
}
}