app: publish the world-map feed from the frame's own area
`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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<SystemKey, Vec<SystemKey>> = 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("];
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user