WORLD_MAP_UPDATE fires inside the verb that moves the map, as in 1.12.1 (#829)

Reopening the world map after zooming out showed the old map for a frame before the current zone.

In 1.12.1, every Lua verb that moves the map selection (`SetMapZoom`, `SetMapToCurrentZone`, a `ProcessMapClick` hit) goes through the setter `0x4a67a0`. The setter stores the selection, rebuilds the landmark list, then fires `WORLD_MAP_UPDATE` in place before returning (`0x4a6ce4`). So `WorldMapFrame`'s OnShow repaints the tiles before the first draw.

benilla queued the event to the next tick, so the first frame painted the previous map. The three verbs now run the setter whole. The landmark list is projected from per-row sources inside the setter, so the new tiles never show the old map's markers. The engine's own pushes still queue, as a narrowed deviation.

Tests replay the report's steps on the stock UI and check the event fires inside the verb with landmarks rebuilt first; they fail before the fix.

Fixes #811

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-08 11:24:25 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 208b1e0bb0
commit d0d1b5def9
6 changed files with 440 additions and 209 deletions
@@ -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::<i64>("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::<i64>("return NUM_BATTLEFIELDMAP_POIS").unwrap(),
@@ -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::<bool>("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::<i64>("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::<String>("return WorldMapDetailTile1:GetTexture()")
.unwrap()
};
s.run("ToggleWorldMap()").unwrap();
s.tick(0.01);
assert!(s.eval::<bool>("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::<bool>("return WorldMapFrame:IsVisible()").unwrap());
s.run("ToggleWorldMap()").unwrap();
assert!(s.eval::<bool>("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());
}
+115 -135
View File
@@ -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<WorldMapContinentView>
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<u32>)) -> 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<u32>),
map: u32,
states: u64,
explored: Vec<u32>,
@@ -360,27 +334,24 @@ struct LandmarkKey {
impl LandmarkKey {
fn matches(
&self,
selection: (u32, u32, Option<u32>),
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<WorldMapLandmarkSource> = 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, &nothing_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.
+2 -2
View File
@@ -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;
+82 -18
View File
@@ -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::<i64>("return heard").unwrap(),
0,
"nothing fires inside the SetMapZoom call itself"
);
s.tick(0.01);
assert_eq!(s.eval::<i64>("return heard").unwrap(), 1);
let heard = |s: &UiScript, call: &str| {
s.eval::<String>(&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::<i64>("return heard").unwrap(),
s.eval::<i64>("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<u32>; 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::<String>(&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"
);
}
+154 -37
View File
@@ -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<u32>; 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<u32>)) -> 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<u32>,
/// What the landmark list is built from, pushed by the host when it changes.
pub landmark_sources: Vec<WorldMapLandmarkSource>,
/// The displayed map's landmarks, built from the sources whenever the selection is stored.
pub landmarks: Vec<WorldMapLandmarkView>,
}
@@ -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<WorldMapLandmarkView>) {
/// 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<WorldMapLandmarkSource>) {
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<u32>) {
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<WorldMapLandmarkView> {
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<i64>)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
select(&mut model, c, z.unwrap_or(0));
select(
&mut lua.app_data_mut::<Model>().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::<Model>().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::<Model>().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::<Model>().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::<Model>().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::<Model>().expect("model app_data"),
c,
z,
);
fire_world_map_update(lua);
}
Ok(())
})?,