From d3d2103919d2bc73edc5d5c8953eb766d834ce35 Mon Sep 17 00:00:00 2001 From: grom Date: Fri, 23 Oct 2026 04:22:49 -0600 Subject: [PATCH] app: publish the world-map feed from the frame's own area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SetMapToCurrentZone()` answers out of the `(continent, zone)` pair `feed_world_map` pushes into the VM, because the VM cannot query the world — the reference's resolver `0x4a6650` reads the player's area live. That makes the schedule part of the verb's contract, and only half of it was declared: the feed ordered `.after(UiInput)` and nothing else, so it ran before `terrain_stream::update_current_area` wrote the area under the player and published the previous frame's zone. At a crossing the feed therefore named the zone just left, `area`'s `feed_zone_events` fired `ZONE_CHANGED_NEW_AREA` on the new one, and both addons that answer it while the map is closed reselected where the map already was. From grom's 03:57 log at the Ironforge gate: `ZONE_CHANGED_NEW_AREA 10/10 handler(s) · MINIMAP_ZONE_CHANGED 8/8 handler(s) · the UI asked to follow the player 3× · WorldMapFrame shown=Some(false)` — every handler reached, the verb called three times, the selection unmoved. Every map-derived overlay then sits a zone behind, `GetPlayerMapPosition` and so pfQuest's minimap pins included, until the player opens the world map by hand (pfQuest reselects on close), which is the workaround he reported. Ordered after `AreaAuthoritySet`, whose own doc already required it of every consumer of the area, and pinned by a schedule test over the declared graph: remove the edge and it fails naming both systems. The watchdog's zero-ask arm said "nothing has called `SetMapToCurrentZone()` since the player crossed" through all of this. The count was right and the window was not: it starts where this feed first publishes the new zone, which the stale feed made *later* than the addons' answer, so three real calls read as none. That one wrong fork cost two sessions; the wording now names the window it measures. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/src/area.rs | 15 +++--- crates/benilla-app/src/game_plugins.rs | 75 ++++++++++++++++++++++++++ crates/benilla-app/src/ui_world_map.rs | 27 ++++++++-- 3 files changed, 107 insertions(+), 10 deletions(-) diff --git a/crates/benilla-app/src/area.rs b/crates/benilla-app/src/area.rs index db6e300f..3613ad46 100644 --- a/crates/benilla-app/src/area.rs +++ b/crates/benilla-app/src/area.rs @@ -34,12 +34,15 @@ //! unbroken group. The reference cannot be doing that: it splashes "Dun Morogh" on the way out, //! and `GetZoneText` is how it does so. Chosen on that evidence rather than traced. //! -//! It is **not** what grom's 2026-10-22 stale-minimap report was: the run after it reproduced the -//! report with the zone-text interior claim reading `outdoors` at the gate, so the skip was never -//! consulted there. What that run did establish is narrower and stranger — the election fires and -//! not one of the handlers registered for it asks to follow the player, with nothing raised. The -//! fire site logs the whole handshake for that reason; `fire_event_reach` and `frame_shown` exist -//! to make its three readings tell apart. +//! It is **not** what grom's 2026-10-22 stale-minimap report was, and nothing on this side was: +//! that run reproduced the report with the zone-text interior claim reading `outdoors` at the +//! gate, so the skip was never consulted. The handshake the fire site logs +//! (`fire_event_reach`, `frame_shown`) is what finally named the cause, in one run: the election +//! fired, every registered handler ran — 10/10 and 8/8 — and the UI asked to follow the player +//! 3× on the spot. The verb was answering out of a feed a frame behind, because +//! `ui_world_map::feed_world_map` was not ordered after the area authority. Fixed there, and +//! pinned by `game_plugins::schedule_tests`; the instrument stays, since it is the only thing +//! that can tell a dropped event from an answered one. //! //! The reference's PvP display gate `[0x88272c]` (a cached boolean, not the realm type) is //! untraced; ours is always open. diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 25cf8036..b7e1e910 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -1265,6 +1265,81 @@ pub(crate) mod schedule_tests { assert!(stale.is_empty(), "stale rows:\n {}", stale.join("\n ")); } + /// `SetMapToCurrentZone()` answers out of a per-frame feed, where the reference's resolver + /// `0x4a6650` reads the player's area live. That one deviation makes the schedule part of the + /// verb's contract, in a chain of three: `update_current_area` writes the area under the + /// player, `feed_world_map` publishes the `(continent, zone)` the verb returns, and + /// `crate::area::feed_zone_events` fires the `ZONE_CHANGED*` an addon answers by calling it. + /// Any two of those in the wrong order and the verb answers for the frame before. + /// + /// Asserted rather than commented because the hole cost three play sessions and two wrong + /// diagnoses: only the downstream edge was declared, so the feed published the *previous* + /// frame's leaf, the event fired on the new one, and both addons that answer it reselected + /// the zone the player had just left. Nothing raised, nothing skipped, the verb provably + /// called — and every map-derived overlay a zone behind, `GetPlayerMapPosition` and so + /// pfQuest's minimap pins included, until the player opened the world map by hand. + #[test] + fn the_world_map_feed_sits_between_the_area_authority_and_the_zone_events() { + let mut app = headless_client(); + let c = census(&mut app, Update, SyncPoints::Declared); + if !type_names_available(&c) { + eprintln!( + "skipped: this build carries no type names (bevy/debug rides with the dev feature)" + ); + return; + } + let one = |suffix: &str| -> SystemKey { + let mut hits = c.systems.iter().filter(|(_, s)| s.name.ends_with(suffix)); + match (hits.next(), hits.next()) { + (Some((k, _)), None) => *k, + _ => panic!("exactly one system named `…{suffix}` in Update"), + } + }; + let area = one("::queries::update_current_area"); + let feed = one("::ui_world_map::feed_world_map"); + let events = one("::area::feed_zone_events"); + let mut pred: HashMap> = HashMap::new(); + for (a, b) in &c.dependencies { + pred.entry(*b).or_default().push(*a); + } + let runs_before = |a: SystemKey, b: SystemKey| { + let mut seen = HashSet::new(); + let mut stack = vec![b]; + while let Some(n) = stack.pop() { + for m in pred.get(&n).into_iter().flatten() { + if *m == a { + return true; + } + if seen.insert(*m) { + stack.push(*m); + } + } + } + false + }; + for (a, b, why) in [ + ( + area, + feed, + "the feed would publish the previous frame's zone, so the verb answers for the \ + zone the player just left", + ), + ( + feed, + events, + "the event would fire before the zone it reports reaches the verb an addon \ + answers it with", + ), + ] { + assert!( + runs_before(a, b), + "`{}` must be declared before `{}` — otherwise {why}", + c.name(a), + c.name(b) + ); + } + } + /// The consumer markers: a VM holder that does one of these to host state has spent /// something this frame that nothing can re-spend. const CONSUMES: &[&str] = &["fire_event(", "mem::take(", ".drain("]; diff --git a/crates/benilla-app/src/ui_world_map.rs b/crates/benilla-app/src/ui_world_map.rs index c09a9f53..807839ca 100644 --- a/crates/benilla-app/src/ui_world_map.rs +++ b/crates/benilla-app/src/ui_world_map.rs @@ -1001,10 +1001,16 @@ fn feed_world_map( Some(LagReport::Stuck { age, asks }) => { // The two halves have the same symptom and different fixes, so the line names // which one this is rather than leaving it to be guessed from a later session. + // The count is taken from the frame this feed first published the new zone, not + // from the crossing, and saying "since the player crossed" cost a diagnosis: the + // feed used to run before the area authority, so an addon's reselection landed a + // frame *earlier* than the baseline and read as never having happened. The feed + // now orders after it (`WorldMapFeedSet`), which is what makes the two windows + // the same one; the wording stays exact anyway. let why = if asks == 0 { - "nothing has called `SetMapToCurrentZone()` since the player crossed, so the \ - `ZONE_CHANGED*` an addon answers with it never reached one (pfQuest answers \ - all three while the map is closed, `pfQuest/map.lua:1466`)" + "nothing has called `SetMapToCurrentZone()` since this feed published the new \ + zone, so the `ZONE_CHANGED*` an addon answers with it never reached one \ + (pfQuest answers all three while the map is closed, `pfQuest/map.lua:1466`)" .to_string() } else { format!( @@ -1209,6 +1215,14 @@ fn dev_map_jump( /// (`benilla_ui::script::worldmap`'s `player_zone`) and a stale read reselects the zone the player /// just left. In the reference there is nothing to order: `SetMapToCurrentZone` calls the resolver /// `0x4a6650`, which reads the player's current area live, where ours reads a per-frame feed. +/// +/// The same reason gives the set its own upstream edge, on +/// [`benilla_world::terrain_stream::AreaAuthoritySet`], which is where the area under the player +/// is written. Ordering only the downstream half is worth nothing: this feed read the *previous* +/// frame's leaf, `crate::area` fired `ZONE_CHANGED_NEW_AREA` on the new one, and the addons +/// answered against a `player_zone` still naming the zone behind them — the verb called, the +/// selection reselected where it already was, and nothing in any log said a thing but the +/// watchdog below. That is what grom's three stale-minimap reports were. #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) struct WorldMapFeedSet; @@ -1221,7 +1235,12 @@ impl Plugin for WorldMapUiPlugin { Update, ( // After the script tick: a selection changed this tick projects on the next. - feed_world_map.after(UiInput).in_set(WorldMapFeedSet), + // After the area authority: the verb this feed backs must answer for the frame + // it is called in, not the one before (see `WorldMapFeedSet`). + feed_world_map + .after(UiInput) + .after(benilla_world::terrain_stream::AreaAuthoritySet) + .in_set(WorldMapFeedSet), // After the script tick, so the jump hit-tests this tick's frame rects. dev_map_jump.after(UiInput), ),