diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index fa44d69c..f474d786 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -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)") { diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index f4064d10..cf127dfc 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -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>, +} + +/// `$WOW_CAPTURE_EVAL=`: 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>) { + 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::(&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())); diff --git a/crates/benilla-app/src/ui_script/battlefield_minimap_tests.rs b/crates/benilla-app/src/ui_script/battlefield_minimap_tests.rs index f7e0f749..6aeb780d 100644 --- a/crates/benilla-app/src/ui_script/battlefield_minimap_tests.rs +++ b/crates/benilla-app/src/ui_script/battlefield_minimap_tests.rs @@ -92,6 +92,8 @@ fn catalog() -> Vec { 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 { diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index ba168a5b..c306e1f5 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -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> = 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 diff --git a/crates/benilla-app/src/ui_world_map.rs b/crates/benilla-app/src/ui_world_map.rs index f69d5a20..29eda793 100644 --- a/crates/benilla-app/src/ui_world_map.rs +++ b/crates/benilla-app/src/ui_world_map.rs @@ -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 diff --git a/crates/benilla-ui/src/script/compat/superwow.rs b/crates/benilla-ui/src/script/compat/superwow.rs index 74afd53e..ef02b507 100644 --- a/crates/benilla-ui/src/script/compat/superwow.rs +++ b/crates/benilla-ui/src/script/compat/superwow.rs @@ -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 { diff --git a/crates/benilla-ui/src/script/tests/worldmap.rs b/crates/benilla-ui/src/script/tests/worldmap.rs index 6073591e..27717af7 100644 --- a/crates/benilla-ui/src/script/tests/worldmap.rs +++ b/crates/benilla-ui/src/script/tests/worldmap.rs @@ -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![ diff --git a/crates/benilla-ui/src/script/uimap.rs b/crates/benilla-ui/src/script/uimap.rs index d6418789..2b164dff 100644 --- a/crates/benilla-ui/src/script/uimap.rs +++ b/crates/benilla-ui/src/script/uimap.rs @@ -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![ diff --git a/crates/benilla-ui/src/script/worldmap.rs b/crates/benilla-ui/src/script/worldmap.rs index cfd27b8a..e2bb8212 100644 --- a/crates/benilla-ui/src/script/worldmap.rs +++ b/crates/benilla-ui/src/script/worldmap.rs @@ -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 (`.zmp`, read at `0x4a5feb`) as 1-based indices into diff --git a/crates/benilla-world/src/map_proj.rs b/crates/benilla-world/src/map_proj.rs index 44e2a0a1..3f5bf17e 100644 --- a/crates/benilla-world/src/map_proj.rs +++ b/crates/benilla-world/src/map_proj.rs @@ -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] diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 201e3840..1f190da4 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -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. \ No newline at end of file +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=`** — 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.