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 f4acefd7..a6df8844 100644 --- a/crates/benilla-app/src/ui_script/battlefield_minimap_tests.rs +++ b/crates/benilla-app/src/ui_script/battlefield_minimap_tests.rs @@ -4,7 +4,7 @@ use benilla_ui::script::{ BattlefieldFlagView, BattlefieldPositionView, QuadContent, UiScript, UnitState, - WorldMapContinentView, WorldMapLandmarkView, WorldMapOverlayView, WorldMapZoneView, + WorldMapContinentView, WorldMapLandmarkSource, WorldMapOverlayView, WorldMapZoneView, WorldStateUiView, ARROW_MODEL, }; @@ -411,14 +411,17 @@ fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() { open(&mut s); assert_eq!(s.eval::("return NUM_BATTLEFIELDMAP_POIS").unwrap(), 0); - let landmark = |name: &str, icon: u32, uv: (f32, f32)| WorldMapLandmarkView { + // A world position here is already map UV, so a landmark source sits where it says. + s.set_world_loc_projector(Box::new(|_, _, x, y| Some((x, y)))); + let landmark = |name: &str, icon: u32, uv: (f32, f32)| WorldMapLandmarkSource { name: name.into(), description: String::new(), - texture_index: icon, - uv, + map: 0, + pos: uv, + icons: [Some(icon); 3], }; // Icon 6 is `ICON_POI_REDFLAG`, icon 9 the second row's first cell of the 8×8 `POIIcons` atlas. - s.set_world_map_landmarks(vec![ + s.set_world_map_landmark_sources(vec![ landmark("Silverwing Flag", 6, (0.25, 0.5)), landmark("Warsong Flag", 9, (0.75, 0.25)), ]); @@ -466,7 +469,7 @@ fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() { "DEFAULT_POI_ICON_SIZE × GetBattlefieldMapIconScale()" ); - s.set_world_map_landmarks(vec![landmark("Warsong Flag", 9, (0.75, 0.25))]); + s.set_world_map_landmark_sources(vec![landmark("Warsong Flag", 9, (0.75, 0.25))]); s.tick(0.0); assert_eq!( s.eval::("return NUM_BATTLEFIELDMAP_POIS").unwrap(), diff --git a/crates/benilla-app/src/ui_script/world_map_tests.rs b/crates/benilla-app/src/ui_script/world_map_tests.rs index fb0f65f8..bf6609fa 100644 --- a/crates/benilla-app/src/ui_script/world_map_tests.rs +++ b/crates/benilla-app/src/ui_script/world_map_tests.rs @@ -3,7 +3,7 @@ //! marker, [`crate::poi_marker`]), the unit blips, the player arrow and the full-screen quads. use benilla_ui::script::{ - BattlefieldFlagView, BattlefieldPositionView, QuadContent, UiScript, WorldMapLandmarkView, + BattlefieldFlagView, BattlefieldPositionView, QuadContent, UiScript, WorldMapLandmarkSource, ARROW_MODEL, }; @@ -34,6 +34,8 @@ fn harness() -> UiScript { super::test_ui::load_ui(&s, file); } assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + // A world position here is already map UV, so a landmark source sits where it says. + s.set_world_loc_projector(Box::new(|_, _, x, y| Some((x, y)))); s } @@ -60,12 +62,14 @@ fn update(s: &mut UiScript) { .unwrap(); } -fn landmark(name: &str, icon: u32, uv: (f32, f32)) -> WorldMapLandmarkView { - WorldMapLandmarkView { +/// A landmark at map UV `uv` on every level, through [`harness`]'s identity projection. +fn landmark(name: &str, icon: u32, uv: (f32, f32)) -> WorldMapLandmarkSource { + WorldMapLandmarkSource { name: name.into(), description: String::new(), - texture_index: icon, - uv, + map: 0, + pos: uv, + icons: [Some(icon); 3], } } @@ -75,7 +79,7 @@ fn landmark(name: &str, icon: u32, uv: (f32, f32)) -> WorldMapLandmarkView { fn a_landmark_draws_its_poi_icon_at_its_map_position() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = harness(); - s.set_world_map_landmarks(vec![landmark("Stormwind Warrior Trainer", 6, (0.25, 0.5))]); + s.set_world_map_landmark_sources(vec![landmark("Stormwind Warrior Trainer", 6, (0.25, 0.5))]); s.run("WorldMapFrame_Update()").unwrap(); assert_eq!( @@ -115,7 +119,7 @@ fn a_landmark_draws_its_poi_icon_at_its_map_position() { fn the_poi_pool_grows_and_parks_its_tail() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = harness(); - s.set_world_map_landmarks(vec![ + s.set_world_map_landmark_sources(vec![ landmark("The Bank", 6, (0.1, 0.1)), landmark("The Inn", 6, (0.2, 0.2)), landmark("The Auction House", 6, (0.3, 0.3)), @@ -126,7 +130,7 @@ fn the_poi_pool_grows_and_parks_its_tail() { .eval::("return WorldMapFramePOI3:IsShown()") .unwrap()); - s.set_world_map_landmarks(vec![landmark("The Inn", 6, (0.2, 0.2))]); + s.set_world_map_landmark_sources(vec![landmark("The Inn", 6, (0.2, 0.2))]); s.run("WorldMapFrame_Update()").unwrap(); assert_eq!( s.eval::("return NUM_WORLDMAP_POIS").unwrap(), @@ -158,7 +162,7 @@ fn the_poi_pool_grows_and_parks_its_tail() { fn hovering_a_poi_names_it_and_adds_a_status_line_only_when_there_is_one() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = harness(); - s.set_world_map_landmarks(vec![landmark("Lion's Pride Inn", 6, (0.5, 0.5))]); + s.set_world_map_landmark_sources(vec![landmark("Lion's Pride Inn", 6, (0.5, 0.5))]); s.run("WorldMapFrame_Update()").unwrap(); s.run("this = WorldMapFramePOI1 this:GetScript(\"OnEnter\")() this = nil") .unwrap(); @@ -178,7 +182,7 @@ fn hovering_a_poi_names_it_and_adds_a_status_line_only_when_there_is_one() { let mut with_status = landmark("Stables", 6, (0.5, 0.5)); with_status.description = "In Conflict".into(); - s.set_world_map_landmarks(vec![with_status]); + s.set_world_map_landmark_sources(vec![with_status]); s.run("WorldMapFrame_Update()").unwrap(); s.run("this = WorldMapFramePOI1 this:GetScript(\"OnEnter\")() this = nil") .unwrap(); @@ -197,7 +201,7 @@ fn the_landmark_getter_returns_the_references_five_values() { let mut s = harness(); let mut with_status = landmark("Stables", 9, (0.25, 0.75)); with_status.description = "In Conflict".into(); - s.set_world_map_landmarks(vec![landmark("Woo Ping", 6, (0.5, 0.5)), with_status]); + s.set_world_map_landmark_sources(vec![landmark("Woo Ping", 6, (0.5, 0.5)), with_status]); let (name, desc_is_nil, icon, x, y): (String, bool, i64, f32, f32) = s .eval("local n, d, t, x, y = GetMapLandmarkInfo(1) return n, d == nil, t, x, y") @@ -859,3 +863,66 @@ fn the_fullscreen_quads_follow_a_resize() { ); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } + +/// Reopening the map after zooming out shows the player's zone from its first paint. The OnShow +/// calls `SetMapToCurrentZone()` (WorldMapFrame.xml:600), whose setter `0x4a67a0` fires +/// `WORLD_MAP_UPDATE` before it returns (`0x4a6ce4`, `SignalEvent` dispatching in place), so the +/// stock `WorldMapFrame_Update` sets the zone's tiles inside the show, before any tick or paint. +#[test] +fn a_reopened_map_shows_the_players_zone_before_its_first_paint() { + use benilla_ui::script::{WorldMapContinentView, WorldMapZoneView}; + let _data = benilla_formats::wow_data_or_skip!(); + // The whole stock UI: the OnShow's `UpdateMicroButtons` and the full-screen panel slot. + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1600.0, 900.0); + s.set_unit( + "player", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Probefour".into()), + level: 60, + ..Default::default() + }), + ); + let failures = super::load_default_ui(&s); + assert!(failures.is_empty(), "manifest load errors: {failures:#?}"); + s.set_world_map_catalog(vec![WorldMapContinentView { + name: "Eastern Kingdoms".into(), + map_file: "Azeroth".into(), + zones: vec![WorldMapZoneView { + name: "Elwynn Forest".into(), + area_id: 12, + map_file: "Elwynn".into(), + ..Default::default() + }], + ..Default::default() + }]); + // The player stands in Elwynn Forest. + s.set_world_map_feed(Some((1, 1)), None, 0.0, None, Vec::new(), Vec::new()); + s.tick(0.01); + let tile = |s: &UiScript| { + s.eval::("return WorldMapDetailTile1:GetTexture()") + .unwrap() + }; + + s.run("ToggleWorldMap()").unwrap(); + s.tick(0.01); + assert!(s.eval::("return WorldMapFrame:IsVisible()").unwrap()); + assert_eq!(tile(&s), "Interface\\WorldMap\\Elwynn\\Elwynn1"); + // A right-click on the map zooms out to the continent (WorldMapFrame.lua:294). + s.run("WorldMapZoomOutButton_OnClick()").unwrap(); + s.tick(0.01); + assert_eq!(tile(&s), "Interface\\WorldMap\\Azeroth\\Azeroth1"); + + s.run("ToggleWorldMap()").unwrap(); + s.tick(0.01); + assert!(!s.eval::("return WorldMapFrame:IsVisible()").unwrap()); + s.run("ToggleWorldMap()").unwrap(); + assert!(s.eval::("return WorldMapFrame:IsVisible()").unwrap()); + assert_eq!( + tile(&s), + "Interface\\WorldMap\\Elwynn\\Elwynn1", + "the reopened map paints its first frame with the continent it was closed on" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} diff --git a/crates/benilla-app/src/ui_world_map.rs b/crates/benilla-app/src/ui_world_map.rs index e2ff9ce4..63a6e28b 100644 --- a/crates/benilla-app/src/ui_world_map.rs +++ b/crates/benilla-app/src/ui_world_map.rs @@ -4,7 +4,8 @@ //! `AreaTable`, `WorldMapContinent`, `Map` and the `.zmp` bitmaps, built as the reference's //! `0x4a5d00` builds it, with the instance maps no continent owns beside it. //! - [`feed_world_map`], every frame: the player's selection (`0x4a6650`), the blips projected -//! onto the displayed map, and the landmark list (`0x4a67a0`), rebuilt when its inputs change. +//! onto the displayed map, and the landmark sources the setter `0x4a67a0` builds its list +//! from, pushed when they change. //! - [`dev_map_jump`]: Alt+click the map to go there. use bevy::ecs::system::NonSendMut; @@ -16,7 +17,8 @@ use benilla_formats::{ load_zone_map, WorldMapArea, }; use benilla_ui::script::{ - UiScript, WorldMapContinentView, WorldMapLandmarkView, WorldMapOverlayView, WorldMapZoneView, + UiScript, WorldMapContinentView, WorldMapLandmarkSource, WorldMapLevel, WorldMapOverlayView, + WorldMapZoneView, }; use crate::net::{ObjectStore, SelfPlayer}; @@ -316,39 +318,11 @@ fn build_catalog_from_world(world: &World) -> Option<(Vec build_catalog(&mut chain, &areas.0, maps) } -/// The displayed level the landmark gates branch on, from the selection cells `[0x84506c]`, -/// `[0x845070]` and `[0x845074]`. An instance map is `Zone`: both gates treat it as zone level -/// (`0x4a79de`; `0x4a8856`/`0x4a885f` fall through to `0x4a8868`). -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum MapLevel { - /// Both continents on one sheet (the reference's `continent == -1`). - World, - /// One continent, no zone selected (`continent >= 0, zone == -1`). - Continent, - /// One zone (`continent >= 0, zone >= 0`), or an instance map (`continent == -2`). - Zone, -} - -impl MapLevel { - /// From the engine's selection, the 1-based `(continent, zone)` with 0 for whole plus the - /// direct area, which is read first because it shares the world sheet's `(0, 0)`. - fn of((continent, zone, direct): (u32, u32, Option)) -> Self { - match (continent, zone, direct) { - (_, _, Some(_)) => Self::Zone, - (0, _, None) => Self::World, - (_, 0, None) => Self::Continent, - _ => Self::Zone, - } - } -} - -/// The inputs the landmark list is a pure function of. `0x4a67a0` rebuilds only on these: a -/// selection change, a world-state push (`0x48fa0d`), a `PLAYER_EXPLORED_ZONES` change +/// The inputs the landmark sources are a pure function of. Besides a selection change, `0x4a67a0` +/// rebuilds the list on these: a world-state push (`0x48fa0d`), a `PLAYER_EXPLORED_ZONES` change /// (`0x4a6477`, an UpdateFields callback on byte `0xe6c`) and the gossip marker's set and clear. #[derive(PartialEq, Eq)] struct LandmarkKey { - /// All three selection cells, the direct area's included. - selection: (u32, u32, Option), map: u32, states: u64, explored: Vec, @@ -360,27 +334,24 @@ struct LandmarkKey { impl LandmarkKey { fn matches( &self, - selection: (u32, u32, Option), map: u32, states: u64, explored: &[u32], marker: Option<(u32, [u32; 3], u32)>, ) -> bool { - self.selection == selection - && self.map == map + self.map == map && self.states == states && self.marker == marker && self.explored == explored } } -/// The builder's near-zero skip (`0x4a6868`/`0x4a687a`, epsilon `2.384e-7`): a UV of 0 on both -/// axes is off the displayed map, which is how POIs are filtered by continent; a POI on a rect's -/// edge has one non-zero axis and stays. -fn is_degenerate(uv: (f32, f32)) -> bool { - const EPS: f32 = 2.384e-7; - uv.0.abs() < EPS && uv.1.abs() < EPS -} +/// [`WorldMapLandmarkSource::icons`]' order. +const LEVELS: [WorldMapLevel; 3] = [ + WorldMapLevel::World, + WorldMapLevel::Continent, + WorldMapLevel::Zone, +]; /// `0x4a67a0`'s AreaPOI gates, in the reference's order: /// @@ -394,15 +365,15 @@ fn is_degenerate(uv: (f32, f32)) -> bool { /// The loop reads no `ContinentID`, `Importance`, `FactionID` or `Icon`. fn landmark_gates_pass( poi: &benilla_formats::AreaPoi, - level: MapLevel, + level: WorldMapLevel, areas: &benilla_formats::AreaTableCatalog, explored: &[u32], world_states: &crate::world_state::WorldStates, ) -> bool { let level_ok = match level { - MapLevel::Zone => poi.flags & 0x04 != 0, - MapLevel::Continent => poi.flags & 0x08 != 0, - MapLevel::World => poi.flags & 0x10 != 0 && poi.flags & 0x08 != 0, + WorldMapLevel::Zone => poi.flags & 0x04 != 0, + WorldMapLevel::Continent => poi.flags & 0x08 != 0, + WorldMapLevel::World => poi.flags & 0x10 != 0 && poi.flags & 0x08 != 0, }; if !level_ok { return false; @@ -429,10 +400,10 @@ fn explored_bit(explored: &[u32], bit: u32) -> bool { /// `GetMapLandmarkInfo`'s `textureIndex` (`0x4a8848`-`0x4a8877`): the row's `Icon`, but cell 15 /// at zone level for a row without `Flags & 0x80`, which the shipped data sets on exactly the /// world-state rows. -fn landmark_texture_index(poi: &benilla_formats::AreaPoi, level: MapLevel) -> u32 { +fn landmark_texture_index(poi: &benilla_formats::AreaPoi, level: WorldMapLevel) -> u32 { /// The generic zone-level POI cell of `Interface\Minimap\POIIcons`. const ZONE_SUBSTITUTE: u32 = 15; - match poi.flags & 0x80 != 0 || level != MapLevel::Zone { + match poi.flags & 0x80 != 0 || level != WorldMapLevel::Zone { true => poi.icon, false => ZONE_SUBSTITUTE, } @@ -594,6 +565,16 @@ fn feed_world_map( let top_zone = world.area().and_then(|aid| areas.0.top_zone(aid)); let player_sel = resolve_player_selection(&data, map.0, top_zone); + // `GetWorldLocMapPosition` and the setter's landmark pass project synchronously with the + // engine's own selection, so the engine holds the projection over its own copy of the catalog. + { + let memo = memo.get(&script); + if !memo.projector || data.is_changed() { + memo.projector = true; + install_world_loc_projector(&mut script, &data); + } + } + // First world enter: the zone updater `0x494780` (`crate::area`), finding the cached zone id // 0, selects the player's zone itself through `0x4a6650` and the setter `0x4a67a0` before any // Lua asks. Once per VM, each login being a first enter. @@ -606,16 +587,6 @@ fn feed_world_map( } } - // `GetWorldLocMapPosition` projects synchronously with the engine's own selection, so the - // engine holds the projection over its own copy of the catalog. - { - let memo = memo.get(&script); - if !memo.projector || data.is_changed() { - memo.projector = true; - install_world_loc_projector(&mut script, &data); - } - } - let selection = script.world_map_selection(); let project = |pos_map: u32, px: f32, py: f32| project_on_displayed(&data, selection, pos_map, px, py); @@ -629,7 +600,8 @@ fn feed_world_map( ) }); - // The landmarks (`GetNumMapLandmarks`), rebuilt only when a `LandmarkKey` input moves. + // The landmark sources, rebuilt only when a `LandmarkKey` input moves; the engine projects + // them onto each selection it stores. let marker = poi_marker .on_map(map.0) .map(|m| (m.continent_id, m.pos.map(f32::to_bits), m.icon)); @@ -638,53 +610,52 @@ fn feed_world_map( .get(&script) .landmarks .as_ref() - .is_some_and(|k| k.matches(selection, map.0, states_gen, &explored, marker)); + .is_some_and(|k| k.matches(map.0, states_gen, &explored, marker)); if !unchanged { - let level = MapLevel::of(selection); - let mut landmarks = Vec::new(); // The DBC rows first, in file order, the Lua landmark index (`0x4a6819`'s walk of // `[0xc0e054]`). - if let Some(pois) = pois.as_ref() { - for (_, poi) in pois.0.rows() { - if !landmark_gates_pass(poi, level, &areas.0, &explored, &world_states) { - continue; - } - let Some(uv) = project(poi.continent_id, poi.pos[0], poi.pos[1]) else { - continue; - }; - if is_degenerate(uv) { - continue; - } - landmarks.push(WorldMapLandmarkView { - name: poi.name.clone(), - description: poi.description.clone(), - texture_index: landmark_texture_index(poi, level), - uv, - }); - } - } + let mut sources: Vec = pois + .as_ref() + .map(|pois| { + pois.0 + .rows() + .filter_map(|(_, poi)| { + let icons = LEVELS.map(|level| { + landmark_gates_pass(poi, level, &areas.0, &explored, &world_states) + .then(|| landmark_texture_index(poi, level)) + }); + icons + .iter() + .any(Option::is_some) + .then(|| WorldMapLandmarkSource { + name: poi.name.clone(), + description: poi.description.clone(), + map: poi.continent_id, + pos: (poi.pos[0], poi.pos[1]), + icons, + }) + }) + .collect() + }) + .unwrap_or_default(); // Then the guard-directions marker, last (`0x4a69c7`): no gate and no icon substitution, - // only the degenerate-projection test. + // only the near-zero skip. if let Some(poi) = poi_marker.on_map(map.0) { - if let Some(uv) = project(poi.continent_id, poi.pos[0], poi.pos[1]) { - if !is_degenerate(uv) { - landmarks.push(WorldMapLandmarkView { - name: poi.name.clone(), - description: poi.description.clone(), - texture_index: poi.icon, - uv, - }); - } - } + sources.push(WorldMapLandmarkSource { + name: poi.name.clone(), + description: poi.description.clone(), + map: poi.continent_id, + pos: (poi.pos[0], poi.pos[1]), + icons: [Some(poi.icon); 3], + }); } memo.get(&script).landmarks = Some(LandmarkKey { - selection, map: map.0, states: states_gen, explored: explored.clone(), marker, }); - script.set_world_map_landmarks(landmarks); + script.set_world_map_landmark_sources(sources); } // The `party1..4` blips: a streamed member's transform, else the `i16` position from @@ -856,24 +827,24 @@ mod tests { }; // A town (0x0d = 0x01|0x04|0x08): zone and continent, not the world sheet. - assert!(pass(0x0d, MapLevel::Zone)); - assert!(pass(0x0d, MapLevel::Continent)); - assert!(!pass(0x0d, MapLevel::World)); + assert!(pass(0x0d, WorldMapLevel::Zone)); + assert!(pass(0x0d, WorldMapLevel::Continent)); + assert!(!pass(0x0d, WorldMapLevel::World)); // A capital (0x1d = 0x0d|0x10): all three. - assert!(pass(0x1d, MapLevel::Zone)); - assert!(pass(0x1d, MapLevel::Continent)); - assert!(pass(0x1d, MapLevel::World)); + assert!(pass(0x1d, WorldMapLevel::Zone)); + assert!(pass(0x1d, WorldMapLevel::Continent)); + assert!(pass(0x1d, WorldMapLevel::World)); // An Eastern Plaguelands tower (0x87 = 0x01|0x02|0x04|0x80): zone only. - assert!(pass(0x87, MapLevel::Zone)); - assert!(!pass(0x87, MapLevel::Continent)); - assert!(!pass(0x87, MapLevel::World)); + assert!(pass(0x87, WorldMapLevel::Zone)); + assert!(!pass(0x87, WorldMapLevel::Continent)); + assert!(!pass(0x87, WorldMapLevel::World)); // `0x10` alone, which 1.12 does not ship: world level still wants `0x08`. - assert!(!pass(0x10, MapLevel::World)); - assert!(!pass(0x10, MapLevel::Continent)); - assert!(!pass(0x10, MapLevel::Zone)); + assert!(!pass(0x10, WorldMapLevel::World)); + assert!(!pass(0x10, WorldMapLevel::Continent)); + assert!(!pass(0x10, WorldMapLevel::Zone)); } #[test] @@ -885,7 +856,7 @@ mod tests { let horde = poi(0x87, 0, 2373); let plain = poi(0x87, 0, 0); let pass = |p: &AreaPoi, st: &WorldStates| { - landmark_gates_pass(p, MapLevel::Zone, &areas, &explored, st) + landmark_gates_pass(p, WorldMapLevel::Zone, &areas, &explored, st) }; assert!( @@ -928,8 +899,9 @@ mod tests { v[1] = 1 << 8; v }; - let pass = - |p: &AreaPoi, ex: &[u32]| landmark_gates_pass(p, MapLevel::Zone, &areas, ex, &states); + let pass = |p: &AreaPoi, ex: &[u32]| { + landmark_gates_pass(p, WorldMapLevel::Zone, &areas, ex, &states) + }; assert!(!pass(&poi(0x04, 139, 0), &unexplored), "not discovered yet"); assert!(pass(&poi(0x04, 139, 0), &explored)); @@ -952,13 +924,20 @@ mod tests { fn the_zone_level_icon_substitution_spares_the_world_state_rows() { let town = poi(0x0d, 0, 0); let tower = poi(0x87, 0, 2372); - assert_eq!(landmark_texture_index(&town, MapLevel::Zone), 15); + assert_eq!(landmark_texture_index(&town, WorldMapLevel::Zone), 15); assert_eq!( - landmark_texture_index(&town, MapLevel::Continent), + landmark_texture_index(&town, WorldMapLevel::Continent), town.icon ); - assert_eq!(landmark_texture_index(&town, MapLevel::World), town.icon); - for level in [MapLevel::Zone, MapLevel::Continent, MapLevel::World] { + assert_eq!( + landmark_texture_index(&town, WorldMapLevel::World), + town.icon + ); + for level in [ + WorldMapLevel::Zone, + WorldMapLevel::Continent, + WorldMapLevel::World, + ] { assert_eq!( landmark_texture_index(&tower, level), tower.icon, @@ -967,26 +946,17 @@ mod tests { } } - #[test] - fn only_a_both_axes_zero_projection_is_dropped() { - assert!(is_degenerate((0.0, 0.0))); - assert!(is_degenerate((1e-8, -1e-8))); - assert!(!is_degenerate((0.0, 0.5))); - assert!(!is_degenerate((0.5, 0.0))); - assert!(!is_degenerate((1e-6, 0.0))); - } - #[test] fn the_selection_pair_maps_onto_the_reference_levels() { - assert_eq!(MapLevel::of((0, 0, None)), MapLevel::World); + assert_eq!(WorldMapLevel::of((0, 0, None)), WorldMapLevel::World); assert_eq!( - MapLevel::of((0, 3, None)), - MapLevel::World, + WorldMapLevel::of((0, 3, None)), + WorldMapLevel::World, "continent 0 wins" ); - assert_eq!(MapLevel::of((2, 0, None)), MapLevel::Continent); - assert_eq!(MapLevel::of((2, 7, None)), MapLevel::Zone); - assert_eq!(MapLevel::of((0, 0, Some(443))), MapLevel::Zone); + assert_eq!(WorldMapLevel::of((2, 0, None)), WorldMapLevel::Continent); + assert_eq!(WorldMapLevel::of((2, 7, None)), WorldMapLevel::Zone); + assert_eq!(WorldMapLevel::of((0, 0, Some(443))), WorldMapLevel::Zone); } #[test] @@ -1019,7 +989,7 @@ mod tests { v }; let tower_shows = |st: &WorldStates, ex: &[u32]| { - landmark_gates_pass(tower, MapLevel::Zone, &areas, ex, st) + landmark_gates_pass(tower, WorldMapLevel::Zone, &areas, ex, st) }; assert!( !tower_shows(&states, &explored_epl), @@ -1032,7 +1002,13 @@ mod tests { "and it still needs the zone discovered" ); assert!( - !landmark_gates_pass(tower, MapLevel::Continent, &areas, &explored_epl, &states), + !landmark_gates_pass( + tower, + WorldMapLevel::Continent, + &areas, + &explored_epl, + &states + ), "a tower is a zone-level row (Flags 0x87 carries no 0x08)" ); @@ -1050,19 +1026,23 @@ mod tests { u32::MAX, "continent-wide, so no exploration gate" ); - for level in [MapLevel::World, MapLevel::Continent, MapLevel::Zone] { + for level in [ + WorldMapLevel::World, + WorldMapLevel::Continent, + WorldMapLevel::Zone, + ] { assert!( landmark_gates_pass(city, level, &areas, ¬hing_explored, &states), "{name} shows at every level on a fresh character" ); } assert_eq!( - landmark_texture_index(city, MapLevel::Continent), + landmark_texture_index(city, WorldMapLevel::Continent), city.icon, "the city icon is its own at continent level" ); assert_eq!( - landmark_texture_index(city, MapLevel::Zone), + landmark_texture_index(city, WorldMapLevel::Zone), 15, "and the generic cell inside a zone map" ); @@ -1250,8 +1230,8 @@ mod player_zone_tests { None, "the world sheet has no continent for map 489 at all" ); - assert_eq!(MapLevel::of(sel), MapLevel::Zone); - assert_eq!(MapLevel::of((0, 0, None)), MapLevel::World); + assert_eq!(WorldMapLevel::of(sel), WorldMapLevel::Zone); + assert_eq!(WorldMapLevel::of((0, 0, None)), WorldMapLevel::World); } /// `0x494780` calls `0x4a6650` at `0x4947ac` when the cached zone id `[0xb4e314]` was 0. diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index ad73cae4..cb085436 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -270,8 +270,8 @@ pub use unit::{ pub use weapon_enchant::WeaponEnchant; pub use who_sort::{WhoSortChain, WhoSortKey}; pub use worldmap::{ - WorldMapContinentView, WorldMapLandmarkView, WorldMapOverlayView, WorldMapState, - WorldMapZoneView, + WorldMapContinentView, WorldMapLandmarkSource, WorldMapLandmarkView, WorldMapLevel, + WorldMapOverlayView, WorldMapState, WorldMapZoneView, }; pub use worldstate::WorldStateUiView; pub use worn_display::WornDisplay; diff --git a/crates/benilla-ui/src/script/tests/worldmap.rs b/crates/benilla-ui/src/script/tests/worldmap.rs index 099b6f6c..757aacc1 100644 --- a/crates/benilla-ui/src/script/tests/worldmap.rs +++ b/crates/benilla-ui/src/script/tests/worldmap.rs @@ -1,5 +1,5 @@ -//! The world-map bindings: catalog and feed pushes, the engine-owned selection, and the deferred -//! `WORLD_MAP_UPDATE` queue. +//! The world-map bindings: catalog and feed pushes, the engine-owned selection, and the +//! `WORLD_MAP_UPDATE` the setter fires. use super::common::script; use crate::script::*; @@ -193,7 +193,7 @@ fn a_direct_area_is_the_third_selection_state() { // (`0x48f9f6`, `0x4a6460`, `0x6d93d8`, `0x6d9a17`, `0x6dac72`); passing the zone alone would // drop an instance map to the world view on the next refresh. s.set_world_map_explored(vec![u32::MAX; 64]); - s.set_world_map_landmarks(Vec::new()); + s.set_world_map_landmark_sources(Vec::new()); s.run("SetMapToCurrentZone()").unwrap(); s.sync_world_map_to_player_zone(0, 0, Some(443)); assert_eq!( @@ -556,36 +556,100 @@ fn worldmap_zone_level_hover_names_without_highlight() { assert!(tx > 0.0 && ty > 0.0); } -/// `SetMapZoom` queues `WORLD_MAP_UPDATE` rather than firing it, since a binding cannot re-enter -/// dispatch; the registered frame hears it on the next tick. +/// Every Lua verb that moves the selection fires `WORLD_MAP_UPDATE` before it returns: the setter +/// `0x4a67a0` signals it at its tail (`0x4a6ce4`), and `SignalEvent` (`0x703e50`) runs each +/// listener in place. The handler already reads the new selection. #[test] -fn worldmap_update_event_fires_on_next_tick() { +fn worldmap_update_fires_inside_the_verb_that_moves_the_selection() { let mut s = script(); push_catalog(&mut s); + s.set_world_map_feed(Some((2, 1)), None, 0.0, None, Vec::new(), Vec::new()); // Drain the catalog push's own queued event first. s.tick(0.01); s.run( r#" - heard = 0 + heard = {} f = CreateFrame("Frame", "MapListener") f:RegisterEvent("WORLD_MAP_UPDATE") - f:SetScript("OnEvent", function() heard = heard + 1 end) - SetMapZoom(1) + f:SetScript("OnEvent", function() + table.insert(heard, GetCurrentMapContinent() .. "/" .. GetCurrentMapZone()) + end) "#, ) .unwrap(); - assert_eq!( - s.eval::("return heard").unwrap(), - 0, - "nothing fires inside the SetMapZoom call itself" - ); - s.tick(0.01); - assert_eq!(s.eval::("return heard").unwrap(), 1); + let heard = |s: &UiScript, call: &str| { + s.eval::(&format!( + "heard = {{}} {call} return table.concat(heard, ',')" + )) + .unwrap() + }; + assert_eq!(heard(&s, "SetMapZoom(1)"), "1/0"); + assert_eq!(heard(&s, "SetMapToCurrentZone()"), "2/1"); + // On the Elwynn map, (0.5, 0.5) drills into the Stormwind City peer. + s.run("SetMapZoom(2, 1)").unwrap(); + assert_eq!(heard(&s, "ProcessMapClick(0.5, 0.5)"), "2/2"); s.tick(0.01); assert_eq!( - s.eval::("return heard").unwrap(), + s.eval::("return table.getn(heard)").unwrap(), 1, - "the queue drains — no re-fire" + "nothing is left queued to fire again on the next tick" + ); +} + +/// The setter rebuilds the landmark list for the map it stores before its `WORLD_MAP_UPDATE` +/// (`0x4a6819`, then `0x4a6ce4`): a handler reads the new map's landmarks, each source admitted by +/// its icon at the displayed level and dropped at the near-zero UV (`0x4a6868`/`0x4a687a`). +#[test] +fn the_setter_builds_the_landmarks_before_its_event_fires() { + let mut s = script(); + push_catalog(&mut s); + // Map 0's positions project to themselves on Eastern Kingdoms and its zones, and nowhere else. + s.set_world_loc_projector(Box::new(|(c, _, _), map, x, y| { + (c == 2 && map == 0).then_some((x, y)) + })); + let source = |name: &str, pos: (f32, f32), icons: [Option; 3]| WorldMapLandmarkSource { + name: name.into(), + description: String::new(), + map: 0, + pos, + icons, + }; + s.set_world_map_landmark_sources(vec![ + source("Stormwind", (0.25, 0.5), [None, Some(6), Some(15)]), + source("Goldshire", (0.5, 0.75), [None, None, Some(15)]), + // On a rect's edge, one axis non-zero: kept. + source("Edge", (0.0, 0.5), [None, Some(7), Some(7)]), + source("Corner", (1e-8, -1e-8), [Some(1), Some(1), Some(1)]), + ]); + s.tick(0.01); + s.run( + r#" + f = CreateFrame("Frame") + f:RegisterEvent("WORLD_MAP_UPDATE") + f:SetScript("OnEvent", function() + seen = {} + for i = 1, GetNumMapLandmarks() do + local name, _, icon = GetMapLandmarkInfo(i) + table.insert(seen, name .. ":" .. icon) + end + end) + "#, + ) + .unwrap(); + let seen = |s: &UiScript, call: &str| { + s.eval::(&format!("seen = nil {call} return table.concat(seen, ',')")) + .unwrap() + }; + assert_eq!(seen(&s, "SetMapZoom(2)"), "Stormwind:6,Edge:7"); + assert_eq!( + seen(&s, "SetMapZoom(2, 1)"), + "Stormwind:15,Goldshire:15,Edge:7" + ); + assert_eq!(seen(&s, "SetMapZoom(1)"), "", "off the projection, none"); + assert_eq!( + seen(&s, "SetMapZoom(0)"), + "", + "and none admitted at world level" ); } diff --git a/crates/benilla-ui/src/script/worldmap.rs b/crates/benilla-ui/src/script/worldmap.rs index 4daaf89b..040c2f78 100644 --- a/crates/benilla-ui/src/script/worldmap.rs +++ b/crates/benilla-ui/src/script/worldmap.rs @@ -6,8 +6,14 @@ //! world sheet, zone 0 the whole continent; continents in `WorldMapArea` file order (`0x4a5d00`), //! zones by case-insensitive name (`0x4a6390`). An instance map is a third state (`0x4a67a0`). //! -//! Deviation: `WORLD_MAP_UPDATE` fires at the next tick, where the reference fires it -//! synchronously, because it only triggers a repaint and one tick is invisible. +//! A Lua verb that moves the selection is the reference's setter `0x4a67a0` whole: it stores the +//! cells, rebuilds the landmark list for the new map, then fires `WORLD_MAP_UPDATE` before it +//! returns (`0x4a6ce4`), so `WorldMapFrame`'s OnShow repaints the map before its first draw. +//! +//! Deviation: the engine's own pushes (the world-enter sync, the explored areas, the landmark +//! sources, the catalog) queue `WORLD_MAP_UPDATE` for the next frame's tick and fire only on a +//! change, where the reference fires in place from the handler that moved the data: the app's +//! feeds run after the frame's tick. use mlua::{Lua, MultiValue, Value}; @@ -41,6 +47,47 @@ pub struct WorldMapLandmarkView { pub uv: (f32, f32), } +/// What a landmark depends on besides the displayed map, which the host pushes when it changes: +/// an `AreaPOI.dbc` row past its exploration and world-state gates, or the guard-directions +/// marker. The setter projects each onto the selection it stores (`0x4a6819`, `0x4a69c7`). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct WorldMapLandmarkSource { + pub name: String, + pub description: String, + /// The map id the position is on, the row's `ContinentID`. + pub map: u32, + /// The world `(x, y)`. + pub pos: (f32, f32), + /// The `textureIndex` at each [`WorldMapLevel`], `None` where the row's level flag drops it. + pub icons: [Option; 3], +} + +/// The displayed level the landmark gates branch on, from the selection cells `[0x84506c]`, +/// `[0x845070]` and `[0x845074]`. An instance map is `Zone`: both gates treat it as zone level +/// (`0x4a79de`; `0x4a8856`/`0x4a885f` fall through to `0x4a8868`). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum WorldMapLevel { + /// Both continents on one sheet (the reference's `continent == -1`). + World, + /// One continent, no zone selected (`continent >= 0, zone == -1`). + Continent, + /// One zone (`continent >= 0, zone >= 0`), or an instance map (`continent == -2`). + Zone, +} + +impl WorldMapLevel { + /// From the engine's selection, the 1-based `(continent, zone)` with 0 for whole plus the + /// direct area, which is read first because it shares the world sheet's `(0, 0)`. + pub fn of((continent, zone, direct): (u32, u32, Option)) -> Self { + match (continent, zone, direct) { + (_, _, Some(_)) => Self::Zone, + (0, _, None) => Self::World, + (_, 0, None) => Self::Continent, + _ => Self::Zone, + } + } +} + /// A sub-area's map art, drawn over the base tiles once any of its explore bits is set. #[derive(Clone, Debug, Default, PartialEq)] pub struct WorldMapOverlayView { @@ -182,6 +229,9 @@ pub struct WorldMapState { pub corpse_uv: Option<(f32, f32)>, /// `PLAYER_EXPLORED_ZONES_1`, 64 words, bit n being `AreaTable.dbc` exploreFlag n. pub explored: Vec, + /// What the landmark list is built from, pushed by the host when it changes. + pub landmark_sources: Vec, + /// The displayed map's landmarks, built from the sources whenever the selection is stored. pub landmarks: Vec, } @@ -380,15 +430,15 @@ impl super::UiScript { } } - /// Pushes the displayed map's landmarks; a change queues `WORLD_MAP_UPDATE` to re-seat them. - pub fn set_world_map_landmarks(&mut self, landmarks: Vec) { + /// Pushes the landmark sources and rebuilds the displayed map's list from them; a change to + /// the list queues `WORLD_MAP_UPDATE` to re-seat it. + pub fn set_world_map_landmark_sources(&mut self, sources: Vec) { let mut model = self.model_mut(); - if model.worldmap.landmarks != landmarks { - model.worldmap.landmarks = landmarks; - model - .pending_events - .push(("WORLD_MAP_UPDATE".to_string(), Vec::new())); + if model.worldmap.landmark_sources == sources { + return; } + model.worldmap.landmark_sources = sources; + refresh_landmarks(&mut model); } /// Pushes the per-frame feed: the player's zone, the projected positions and the facing. @@ -418,15 +468,19 @@ impl super::UiScript { (wm.selection.0, wm.selection.1, wm.direct_area) } - /// Install the host's world-to-map projection behind `GetWorldLocMapPosition`. + /// Install the host's world-to-map projection behind `GetWorldLocMapPosition` and the + /// landmark list. pub fn set_world_loc_projector(&mut self, projector: super::WorldLocProjector) { - self.model_mut().world_loc_projector = Some(projector); + let mut model = self.model_mut(); + model.world_loc_projector = Some(projector); + refresh_landmarks(&mut model); } /// Moves the selection from the engine, as the zone updater `0x494780` calls the setter /// `0x4a67a0`: a fresh login shows the player's map before any Lua runs, not the world sheet, /// whose `GetPlayerMapPosition` is sheet UV (`0x4a7360`). It takes the resolver's whole - /// answer, orphan leg included, as `0x4947ac` calls `0x4a6650`. + /// answer, orphan leg included, as `0x4947ac` calls `0x4a6650`. Its `WORLD_MAP_UPDATE` is + /// queued for the next frame's tick (the module's deviation). pub fn sync_world_map_to_player_zone( &mut self, continent: u32, @@ -435,6 +489,9 @@ impl super::UiScript { ) { let mut model = self.model_mut(); select_resolved(&mut model, continent, zone, direct_area); + model + .pending_events + .push(("WORLD_MAP_UPDATE".to_string(), Vec::new())); } /// The map UV at UI point `(x, y)`, or `None` off the art, normalized over `WorldMapButton` @@ -458,12 +515,55 @@ impl super::UiScript { /// The one writer of the three selection cells, as the setter `0x4a67a0` is: every leg but /// `ecx == -2` clears the direct cell (`0x4a67ea`), and that one stores the id (`0x4a67c1`). +/// The landmark list follows the new map (`0x4a6819`); the caller fires `WORLD_MAP_UPDATE`. fn store_selection(model: &mut Model, selection: (u32, u32), direct_area: Option) { model.worldmap.selection = selection; model.worldmap.direct_area = direct_area; - model - .pending_events - .push(("WORLD_MAP_UPDATE".to_string(), Vec::new())); + model.worldmap.landmarks = build_landmarks(model); +} + +/// The setter's landmark pass: each source the displayed level admits, projected onto the +/// displayed map, dropped off it or at the near-zero UV (`0x4a6868`/`0x4a687a`, epsilon +/// `[0x8029d4]` = 2^-22), which is how the rows are filtered by continent. +fn build_landmarks(model: &Model) -> Vec { + const EPS: f32 = f32::from_bits(0x3480_0000); + let wm = &model.worldmap; + let Some(project) = model.world_loc_projector.as_ref() else { + return Vec::new(); + }; + let selection = (wm.selection.0, wm.selection.1, wm.direct_area); + let level = WorldMapLevel::of(selection) as usize; + wm.landmark_sources + .iter() + .filter_map(|src| { + let texture_index = src.icons[level]?; + let uv = project(selection, src.map, src.pos.0, src.pos.1)?; + (uv.0.abs() >= EPS || uv.1.abs() >= EPS).then(|| WorldMapLandmarkView { + name: src.name.clone(), + description: src.description.clone(), + texture_index, + uv, + }) + }) + .collect() +} + +/// Rebuilds the list in place for the stored selection, after an input other than the selection +/// moved; a changed list queues `WORLD_MAP_UPDATE` to re-seat it. +fn refresh_landmarks(model: &mut Model) { + let landmarks = build_landmarks(model); + if model.worldmap.landmarks != landmarks { + model.worldmap.landmarks = landmarks; + model + .pending_events + .push(("WORLD_MAP_UPDATE".to_string(), Vec::new())); + } +} + +/// Fires the setter's `WORLD_MAP_UPDATE` in place (`0x4a6ce4` into `SignalEvent`), for a Lua +/// verb that has dropped its model borrow. +fn fire_world_map_update(lua: &Lua) { + super::tick::fire_event_into(lua, "WORLD_MAP_UPDATE", Vec::new()); } /// Clamps and stores a continent and zone, clearing the direct area: the tail of `SetMapZoom`, @@ -557,8 +657,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "SetMapZoom", lua.create_function(|lua, (c, z): (i64, Option)| { - let mut model = lua.app_data_mut::().expect("model app_data"); - select(&mut model, c, z.unwrap_or(0)); + select( + &mut lua.app_data_mut::().expect("model app_data"), + c, + z.unwrap_or(0), + ); + fire_world_map_update(lua); Ok(()) })?, )?; @@ -568,10 +672,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "SetMapToCurrentZone", lua.create_function(|lua, ()| { - let mut model = lua.app_data_mut::().expect("model app_data"); - let (c, z) = model.worldmap.player_zone.unwrap_or((0, 0)); - let direct = model.worldmap.player_direct_area; - select_resolved(&mut model, c, z, direct); + { + let mut model = lua.app_data_mut::().expect("model app_data"); + let (c, z) = model.worldmap.player_zone.unwrap_or((0, 0)); + let direct = model.worldmap.player_direct_area; + select_resolved(&mut model, c, z, direct); + } + fire_world_map_update(lua); Ok(()) })?, )?; @@ -676,23 +783,33 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "ProcessMapClick", lua.create_function(|lua, (x, y): (f32, f32)| { - let mut model = lua.app_data_mut::().expect("model app_data"); - // An instance map swallows the click (`0x4a7540` opens `cmp esi,-2; je 0x4a760d`), - // tested first as it shares the world sheet's `continent == 0`. - if model.worldmap.direct_area.is_some() { - return Ok(()); - } - if model.worldmap.selection.0 == 0 { - let hit = model.worldmap.continents.iter().position(|cont| { - let (u0, v0, u1, v1) = cont.world_rect; - (u0..=u1).contains(&x) && (v0..=v1).contains(&y) - }); - if let Some(i) = hit { - select(&mut model, i as i64 + 1, 0); + let target = { + let model = lua.app_data_ref::().expect("model app_data"); + let wm = &model.worldmap; + // An instance map swallows the click (`0x4a7540` opens `cmp esi,-2; je + // 0x4a760d`), tested first as it shares the world sheet's `continent == 0`. + if wm.direct_area.is_some() { + None + } else if wm.selection.0 == 0 { + wm.continents + .iter() + .position(|cont| { + let (u0, v0, u1, v1) = cont.world_rect; + (u0..=u1).contains(&x) && (v0..=v1).contains(&y) + }) + .map(|i| (i as i64 + 1, 0)) + } else { + grid_area(wm, x, y).map(|zi| (i64::from(wm.selection.0), i64::from(zi))) } - } else if let Some(zi) = grid_area(&model.worldmap, x, y) { - let c = i64::from(model.worldmap.selection.0); - select(&mut model, c, i64::from(zi)); + }; + // Only a hit reaches the setter (`0x4a75bd`, `0x4a7608`). + if let Some((c, z)) = target { + select( + &mut lua.app_data_mut::().expect("model app_data"), + c, + z, + ); + fire_world_map_update(lua); } Ok(()) })?,