map: a map's rect on another map describes art, not a click box

C_Map.GetMapRectOnMap answered, for a continent on the world sheet, the
world-level CLICK box -- continent_sheet_rect over the WorldMapContinent
tile bounds, which trace the landmass. That is the reference's own
0x4a5d00 and right for what 1.12 uses it for; the two boxes are disjoint
so a world-level click can tell the continents apart.

It is the wrong answer for art. A continent's map is a 1002x668 sheet
with the land inside it and ocean around, so its art rect is 792x528 px
on the world where its click box is 300x564. Every 1.12 WorldMapArea
rect is 3:2, because every sheet is, and the click box is 0.355 of that.
A zone's rect on its continent was already a yard un-lerp and correct,
so the error entered once and multiplied through every zone: Dun Morogh
landed 42x79 where its art is 1002x668. A stitched world built on that
draws each zone a third as wide as it should be, which is why the canvas
was grey between the zoom steps.

map_proj::continent_art_rect carries the continent's own WorldMapArea
rect through world_uv, the projection a position on it already takes.
The view keeps both rects; the Era map graph publishes the art one.
Measured after: EK, Kalimdor, Dun Morogh and Elwynn all 1.500. Rendered:
the zone view stitches its neighbours in, zoom 3 is the continent with
its zones, zoom 1 is the world sheet. The stock 1.12 map is unchanged.

Three instruments came with it and are kept: $WOW_CAPTURE_EVAL, a chunk
run in a capture's VM at the shutter; $WOW_CAPTURE_EXPLORED, so a
stitched map's fog can be ruled out in one run; and a named warning for
a UI texture that is not in the chain, which is what refuted the other
hypothesis -- a grep said the addon's art was missing, a real render
asked for zero missing files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-11-03 16:42:09 -07:00
co-authored by Claude Opus 5
parent 388db114fc
commit 6c01be926d
11 changed files with 261 additions and 5 deletions
+9 -1
View File
@@ -718,7 +718,15 @@ pub(super) fn seed_ui_fixture(
// it at the scenario's Northshire spot.
player.pos = benilla_assets::coords::wow_to_bevy(scenario.eye);
// Alternating explore bits, so about half of each zone's overlays reveal.
script.set_world_map_explored(vec![0x5555_5555; 64]);
// `$WOW_CAPTURE_EXPLORED=all|none` overrides it: a stitched-map addon draws a zone's
// art only where the player has been, so "half the map is grey" and "the map is
// broken" are the same picture until this is pinned.
let bits: u32 = match std::env::var("WOW_CAPTURE_EXPLORED").as_deref() {
Ok("all") => 0xFFFF_FFFF,
Ok("none") => 0,
_ => 0x5555_5555,
};
script.set_world_map_explored(vec![bits; 64]);
// Continent 2, zone 10 of the alphabetical zone list, which is Dun Morogh — the
// comment here said Elwynn and the shot has never been Elwynn.
if let Err(e) = script.run("ToggleWorldMap(); SetMapZoom(2, 10)") {
+33
View File
@@ -864,6 +864,38 @@ struct LateCapture<'w> {
cvars: ResMut<'w, crate::cvars::Cvars>,
/// `$WOW_MIRROR_DUMP`: the planar mirror's target, written beside the capture.
mirror: Res<'w, benilla_assets::WaterReflectionImage>,
/// `$WOW_CAPTURE_EVAL`'s subject: `None` on a capture that seats no interface. Here rather
/// than on the system, which is at Bevy's parameter ceiling.
script: Option<NonSendMut<'w, benilla_ui::script::UiScript>>,
}
/// `$WOW_CAPTURE_EVAL=<lua>`: run a chunk in the capture's own VM at the shutter and log what it
/// answers, as `capture eval:` lines.
///
/// A capture is the only harness that has both a renderer and a player's interface, and it had no
/// way to be *asked* anything — every question about what a shot shows had to be answered by
/// looking at the picture. A chunk here is read at the one instant the image is taken, so a number
/// and the pixels it explains come from the same frame. `\n` in the answer is split across lines,
/// so a probe can return a block.
fn capture_eval(script: Option<NonSendMut<benilla_ui::script::UiScript>>) {
let Ok(chunk) = std::env::var("WOW_CAPTURE_EVAL") else {
return;
};
if chunk.is_empty() {
return;
}
let Some(script) = script else {
warn!("capture eval: no interface in this capture, nothing to ask");
return;
};
match script.eval::<String>(&chunk) {
Ok(out) => {
for line in out.lines() {
info!("capture eval: {line}");
}
}
Err(e) => warn!("capture eval: raised: {e}"),
}
}
fn drive_capture(
@@ -1046,6 +1078,7 @@ fn drive_capture(
);
Phase::ProbeWarmup(0)
} else {
capture_eval(late.script.take());
commands
.spawn(Screenshot::primary_window())
.observe(save_to_disk(ctx.out.clone()));
@@ -92,6 +92,8 @@ fn catalog() -> Vec<WorldMapContinentView> {
map_file: "Kalimdor".into(),
map_id: 1,
world_rect: (0.0, 0.0, 1.0, 1.0),
// The fixture draws no stitched world, so its art rect is its click box.
world_art_rect: (0.0, 0.0, 1.0, 1.0),
loc_rect: (10000.0, -10000.0, 10000.0, -10000.0),
zone_grid: Vec::new(),
zones: vec![WorldMapZoneView {
@@ -1118,6 +1118,41 @@ pub(super) fn paint_script(
/// One extracted entry to its screen quads, plus the side channels some arms write: link spans,
/// the minimap slot, booth pane aspects and tile requests. The full pass and the splice both call
/// this; keep [`splice_simple`] in step with each arm's side effects.
/// Name a texture path that resolved to nothing, once each.
///
/// A texture whose file is not there draws nothing and, until this, said nothing — the same shape
/// as a guarded call that does nothing, and the reason "the map is grey" read for weeks as an
/// engine fault. It is an addon's art far more often than the client's: OpenMapClassic ships
/// thirty texture families and this install has nine of them, so every land, sea, coast and gap
/// layer of its stitched world asks for a file that is not on disk.
///
/// Once per path and capped, because a pool that re-paints every frame would otherwise fill the
/// log with one line per frame.
fn report_missing_texture(path: &str) {
use std::cell::RefCell;
use std::collections::HashSet;
/// Past this many distinct paths the client stops naming them: a corpus that is missing this
/// much has been told.
const CAP: usize = 64;
thread_local! {
static SEEN: RefCell<HashSet<String>> = RefCell::new(HashSet::new());
}
SEEN.with(|seen| {
let mut seen = seen.borrow_mut();
if seen.len() >= CAP {
return;
}
if !seen.insert(path.to_string()) {
return;
}
if seen.len() == CAP {
warn!("ui texture: {path} is not in the chain (and that is {CAP} distinct missing textures — no more will be named)");
} else {
warn!("ui texture: {path} is not in the chain — the quad draws nothing");
}
});
}
fn convert_entry(
eq: benilla_ui::script::ExtractedQuad,
s: f32,
@@ -1438,6 +1473,7 @@ fn convert_entry(
a.sprite_texture(p, images)
};
if resolved.is_none() {
report_missing_texture(p);
return;
}
resolved
+16
View File
@@ -269,6 +269,21 @@ pub(crate) fn build_catalog(
}
};
// Its art's own rect on the sheet, beside the click box above: see
// `map_proj::continent_art_rect`.
let world_art_rect = proj
.map(|p| {
map_proj::continent_art_rect(
benilla_world::map_proj::ZoneRect {
left: cont.loc_left,
right: cont.loc_right,
top: cont.loc_top,
bottom: cont.loc_bottom,
},
p,
)
})
.unwrap_or(world_rect);
views.push(WorldMapContinentView {
// Map.dbc's localized name, never the art folder (`0x4a65a0`).
name: maps
@@ -279,6 +294,7 @@ pub(crate) fn build_catalog(
map_file: cont.name.clone(),
map_id: cont.map_id,
world_rect,
world_art_rect,
loc_rect: (cont.loc_left, cont.loc_right, cont.loc_top, cont.loc_bottom),
zone_grid,
zones: zones
@@ -996,6 +996,8 @@ mod tests {
map_file: "Kalimdor".into(),
map_id: 1,
world_rect: (0.0, 0.0, 1.0, 1.0),
// The fixture draws no stitched world, so its art rect is its click box.
world_art_rect: (0.0, 0.0, 1.0, 1.0),
loc_rect: (0.0, -10000.0, 5000.0, -5000.0),
zone_grid: Vec::new(),
zones: vec![WorldMapZoneView {
@@ -21,6 +21,8 @@ fn push_catalog(s: &mut UiScript) {
map_file: "Kalimdor".into(),
map_id: 1,
world_rect: (0.08882, 0.07910, 0.40020, 0.86952),
// The fixture draws no stitched world, so its art rect is its click box.
world_art_rect: (0.08882, 0.07910, 0.40020, 0.86952),
loc_rect: (17066.6, -19733.2, 12799.9, -11733.3),
zone_grid: Vec::new(),
zones: vec![
@@ -45,6 +47,8 @@ fn push_catalog(s: &mut UiScript) {
map_file: "Azeroth".into(),
map_id: 0,
world_rect: (0.62375, 0.02695, 0.92315, 0.87126),
// The fixture draws no stitched world, so its art rect is its click box.
world_art_rect: (0.62375, 0.02695, 0.92315, 0.87126),
loc_rect: (16000.0, -19199.9, 7466.6, -16000.0),
zone_grid: ek_grid,
zones: vec![
+7 -3
View File
@@ -283,9 +283,11 @@ pub fn build_graph(
continue;
}
let cont_id = era_id(&cont.map_file).unwrap_or_else(|| minted_continent_id(index));
// `world_rect` on the view is `(u0, v0, u1, v1)` from the `WorldMapContinent` tile bounds;
// the graph's order is `(left, right, top, bottom)`.
let (u0, v0, u1, v1) = cont.world_rect;
// The **art** rect, not the click box: this graph is what `C_Map.GetMapRectOnMap`
// publishes, and every caller of that verb is placing one map's art inside another's.
// `world_art_rect` is `(u0, v0, u1, v1)`; the graph's order is `(left, right, top,
// bottom)`.
let (u0, v0, u1, v1) = cont.world_art_rect;
let cont_world = (u0, u1, v0, v1);
out.push(UiMapNode {
ui_map_id: cont_id,
@@ -481,6 +483,8 @@ mod tests {
map_id: 0,
// `continent_sheet_rect`'s answer for the shipped EK row: `(u0, v0, u1, v1)`.
world_rect: (0.62375, 0.02695, 0.92315, 0.87126),
// The fixture draws no stitched world, so its art rect is its click box.
world_art_rect: (0.62375, 0.02695, 0.92315, 0.87126),
loc_rect: (17066.666, -17066.666, 12800.0, -12800.0),
zone_grid: Vec::new(),
zones: vec![
+6
View File
@@ -86,6 +86,12 @@ pub struct WorldMapContinentView {
/// The rect on the world sheet in UV `(u0, v0, u1, v1)`, from the `WorldMapContinent` tile
/// bounds (`0x4a5d00`); a world-level click takes the first that holds it (`0x4a7100`).
pub world_rect: (f32, f32, f32, f32),
/// The rect its **art** occupies on the world sheet, `(u0, v0, u1, v1)`: its own
/// `WorldMapArea` rect through the world projection, which is a different shape from
/// [`Self::world_rect`] — that one traces the landmass for the click, this one is the
/// 1002x668 sheet the continent's map is drawn on. `C_Map.GetMapRectOnMap` publishes this
/// one, because every caller of that verb is placing art.
pub world_art_rect: (f32, f32, f32, f32),
/// The `WorldMapArea` loc rect the cell law un-lerps a hover or click through.
pub loc_rect: (f32, f32, f32, f32),
/// The 128×128 area bitmap (`<Continent>.zmp`, read at `0x4a5feb`) as 1-based indices into
+63
View File
@@ -90,6 +90,31 @@ pub(crate) fn world_click_world(proj: WorldProj, u: f32, v: f32) -> (f32, f32) {
(wx, wy)
}
/// A continent's **art** rectangle on the world sheet: its own `WorldMapArea` rect carried
/// through [`world_uv`], the same projection a position on it takes.
///
/// Not [`continent_sheet_rect`], which is the world-level *click* box from the tile bounds and is
/// a different shape — the tile bounds trace the landmass, the art rect is the 1002x668 sheet the
/// continent's map is drawn on, ocean margins included. Measured on the 5875 data: Eastern
/// Kingdoms clicks as 0.299 x 0.844 and its art is 0.790 x 0.790, which on a 1002x668 sheet is
/// the 3:2 frame its own art actually has.
///
/// Both are wanted, for different questions. 1.12 only ever asks the click one. The art one is
/// what `C_Map.GetMapRectOnMap` has to answer, because every caller of that verb is placing a
/// map's art inside another map's art — a stitched-world addon reads it per zone and per
/// continent — and composing a zone's (correct) rect on its continent with the continent's click
/// box squashes every zone to a third of its width-to-height ratio.
///
/// The two continents' art rects overlap mid-ocean, as the module header says; the click boxes
/// are disjoint. That overlap is a true fact about this data, not an error in the conversion.
pub fn continent_art_rect(rect: ZoneRect, proj: WorldProj) -> (f32, f32, f32, f32) {
// `world_uv` takes u from wy and v from wx, both decreasing, so the rect's `left`/`top` (the
// max of each axis) are the low corner on the sheet.
let (u0, v0) = world_uv(proj, rect.top, rect.left);
let (u1, v1) = world_uv(proj, rect.bottom, rect.right);
(u0, v0, u1, v1)
}
/// A continent's `(u0, v0, u1, v1)` on the world sheet from its WorldMapContinent tile bounds
/// (fields 2 to 5), the `0x4a5d00` builder: the world-level click's hit test (`0x4a7100`). The two
/// rects are disjoint, unlike the WorldMapArea art rects, which overlap mid-ocean.
@@ -218,6 +243,44 @@ mod tests {
);
}
/// A continent's art rect keeps the shape of its own art; its click box does not, and that is
/// the whole difference between the two.
///
/// Every 1.12 `WorldMapArea` rect is 3:2 in yards, because every map sheet is 1002x668 of
/// art. So a continent's art rect, normalized on a world sheet that is itself 1002x668, has
/// to come out **square** — `(r-l)·1002 / ((b-t)·668) == 1.5`. The tile-bounds click box
/// traces the landmass instead and comes out a third of that, and composing a zone's correct
/// rect on its continent with that box squashed every zone by the same factor. A stitched
/// world built on it draws Dun Morogh 42x79 where its art is 1002x668.
#[test]
fn a_continents_art_rect_keeps_its_arts_shape_and_its_click_box_does_not() {
// Eastern Kingdoms' own `WorldMapArea` rect: 35200 yards of west-east by 23467 of
// north-south, the 3:2 of its 1002x668 sheet. The origin cancels, so only the spans
// matter here.
const EK_RECT: ZoneRect = ZoneRect {
left: 17600.0,
right: -17600.0,
top: 11733.5,
bottom: -11733.5,
};
let aspect = |(l, t, r, b): (f32, f32, f32, f32)| ((r - l) * 1002.0) / ((b - t) * 668.0);
let art = continent_art_rect(EK_RECT, EK);
assert!(
(aspect(art) - 1.5).abs() < 0.01,
"the art rect must be the art's own 3:2, got {:.3} from {art:?}",
aspect(art)
);
// The click box, from the tile bounds, is a different shape on purpose.
let click = continent_sheet_rect((23, 47, 15, 61), EK);
assert!(
aspect(click) < 1.0,
"the click box traces the landmass and is far from 3:2, got {:.3}",
aspect(click)
);
}
/// `0x4a5d00` at the 5875 rows, EK tiles (23,47,15,61) and Kalimdor (23,48,9,52), evaluated by
/// hand; disjoint on u, which is how the world-level click tells them apart.
#[test]
+83 -1
View File
@@ -2843,4 +2843,86 @@ That is the previous section.
The next step is to instrument `inView` and `cullBox` against those four verbs on a real open, and
find which of them hands OpenMap a view box no zone is ever inside. This is a feature, not a
defect — it will take its own session, and saying otherwise has not served anybody.
defect — it will take its own session, and saying otherwise has not served anybody.
## The stitched world: a map's rect on another map describes **art**, not a click box
grom sent two recordings — OpenMapClassic as it ran here, and what it is supposed to be — and the
second is the whole point of the canvas: one continuous world where the wheel runs from Azeroth,
through a continent with its ocean and neighbours, into a zone, into a city, with no mode change
and no seams. Ours navigated correctly and drew grey between the zoom steps.
### What the grey was
`C_Map.GetMapRectOnMap(child, parent)` was answering, for a continent on the world sheet, the
**world-level click box**: `map_proj::continent_sheet_rect`, built from the `WorldMapContinent`
tile bounds, which trace the *landmass*. That is the reference's own `0x4a5d00` and it is exactly
right for what 1.12 uses it for — a click at world level picks the first continent whose box holds
it, and the two boxes are disjoint for that reason.
It is the wrong answer for art. A continent's map is a 1002x668 **sheet** with the landmass inside
it and ocean around; its footprint on the world sheet is just the land. Measured:
```
click box art rect
Eastern Kingdoms 300 x 564 px 792 x 528 px
Kalimdor 312 x 528 px 828 x 552 px
```
Every 1.12 `WorldMapArea` rect is 3:2 in yards, because every map sheet is 1002x668 of art — so a
map's rect on a 1002x668 world sheet has to come out 3:2 as well. The click box is 0.355 of that.
A zone's rect on its **continent** was already right (an un-lerp of yard rects, which preserves
shape), so the error entered once, at the continent-to-world link, and then multiplied into every
zone through it:
```
Dun Morogh on the world: 42 x 79 px (aspect 0.53) -> 110.8 x 73.9 px (aspect 1.500)
```
A stitched world built on the first number draws Dun Morogh's 1002x668 art into a box a third as
wide as it should be. Its neighbours land on top of each other or nowhere, the view at any zoom
covers a sliver, and everything outside that sliver is canvas — the grey.
### The fix
`map_proj::continent_art_rect`: the continent's own `WorldMapArea` rect carried through
`world_uv`, the same projection a *position* on it takes. `WorldMapContinentView` now carries both
— `world_rect` (the click box, unchanged, still what the world-level click reads) and
`world_art_rect` — and the Era map graph, which is what `C_Map.GetMapRectOnMap` publishes, is
built from the art one. Every caller of that verb is placing art inside art.
The two art rects overlap mid-ocean where the click boxes do not. That is a true fact about this
data, and the module header already said so.
Measured after, through the whole graph: Eastern Kingdoms, Kalimdor, Dun Morogh and Elwynn Forest
all come out at aspect **1.500**. Rendered: the zone view draws Dun Morogh with its neighbours
stitched in around it and no grey; zoom 3 is Eastern Kingdoms with its zones fading in; zoom 1 is
the world sheet, both continents, the whirlpool and the sea monsters. The stock 1.12 map with
OpenMapClassic excluded is unchanged, which is the control that matters.
### Three instruments this needed, all kept
- **`$WOW_CAPTURE_EVAL=<lua>`** — a chunk run in the capture's own VM **at the shutter**, its
answer logged as `capture eval:` lines. A capture is the only harness with both a renderer and a
player's interface and it had no way to be asked anything: every question about a shot had to be
answered by looking at it. Reads only — anything the chunk *creates* is a frame too late to be
in the image.
- **`$WOW_CAPTURE_EXPLORED=all|none`** — the ui-worldmap fixture seeds alternating explore bits,
so about half of every zone reveals. A stitched map draws a zone's art only where the player has
been, which makes "half the map is grey" and "the map is broken" the same picture until it is
pinned. It was not the cause here, and ruling it out took one run.
- **A missing UI texture now says so.** `report_missing_texture` names each path once, capped at
64. A texture whose file is not there drew nothing and said nothing — the same shape as a
guarded call that does nothing. It is what ruled out the other hypothesis: OpenMapClassic's
source references thirty texture families and this install ships nine, so "the art is not
installed" looked certain from a `grep` — and a real render asked for **zero** missing files,
because the families it does not ship are ones this Era port no longer draws (`PlaceLand`, for
one, is dead code here). The static read would have been published as the cause.
### What is still rough, and whose it is
At the mid zooms the zones come in as rectangles rather than blending, and there are seams where
two zones meet. That is the baked composite the upstream build ships and this one does not
(`MapZones/`, "every zone map composited onto the world ... in 512-px tiles", generated by the
addon's own `tools/map-preview/render.py`). It is art, not geometry, and nothing this client does
can conjure it.