probe bg: a leave leg, so WOW_PROBE_BG can measure the worldport out

The probe's last act was a GM teleport, which never exercises the worldport a leave causes — so
it could say nothing about the scoreboard or the movement latches after one. `WOW_PROBE_BG_LEAVE=1`
swaps the flag and graveyard legs for the real thing: wait inside for the match to end, leave with
the stock `LeaveBattlefield()`, and census for 90 s across the worldport.

A solo match never reaches `min_players_per_team`, so vmangos ends it with `WINNER_NONE` about
300 s after the doors, and a non-nil winner is what the modal needs to show itself
(`BattleGround.cpp:318` is the AV exception). A census gained `SCOREFRAME IsShown=` with the three
latches that can freeze a body — `settling`, `world_stale`, `control_lost` — beside the worldport
ack, and a `moved=` column on the position so "stuck" is one number rather than an eyeball.
`PLAYER_ENTERING_WORLD` joins the tap, to see whether the world-entry event fires at all.

The tap's window is drained once per frame before the phase match, for the three in-battleground
phases, so the one-shot consume stays in `bg_probe` where `game_plugins.rs` argues for it.
This commit is contained in:
chairmanpow
2026-10-02 17:56:08 +01:00
parent 7f99a3f35a
commit c2d83dc22e
2 changed files with 272 additions and 21 deletions
+267 -21
View File
@@ -11,10 +11,17 @@
//! off once inside: while testing is on, the premature-finish countdown never decrements
//! (`BattleGround.cpp:337`).
//!
//! A census reports the map, area and position, terrain streaming, entity counts by kind, the
//! gameobject entries, the raw world-state table, `GetNumWorldStateUI()`,
//! `GetBattlefieldStatus(1..3)`, the scoreboard, the nearest spirit guide, the dropped opcodes and
//! the Lua event tap's window.
//! A census reports the map, area and position (and how far the body moved since the last one),
//! terrain streaming, entity counts by kind, the gameobject entries, the raw world-state table,
//! `GetNumWorldStateUI()`, `GetBattlefieldStatus(1..3)`, the scoreboard, the end-of-battleground
//! modal's visibility, the three player latches that can freeze a body, the nearest spirit guide,
//! the dropped opcodes and the Lua event tap's window.
//!
//! `WOW_PROBE_BG_LEAVE=1` runs one leg instead of the flag and graveyard: it waits inside for the
//! match to end, leaves with the stock `LeaveBattlefield()`, and censuses for [`LEAVE_WATCH_SECS`]
//! across the worldport. That is the only shape that reaches the leave's worldport, which a GM
//! teleport does not. Hold forward across it with `WOW_PROBE_KEY="W@<s>:<hold>"`, and read the
//! stuck symptom off the `moved=` column.
//!
//! The probe stamps input every frame, so it never exercises the idle handler.
//!
@@ -146,6 +153,24 @@ const FLAG_SAMPLES: u32 = 5;
/// `0x4924c0` keys on.
const NPC_FLAG_SPIRITGUIDE: u32 = 1 << 6;
/// [`WOW_PROBE_BG_LEAVE`] swaps the flag and graveyard legs for the leave leg: wait for the match to
/// end, leave it with `LeaveBattlefield()`, and keep censusing across the worldport. Without it the
/// run's last act is a GM teleport, which never exercises the worldport the leave causes.
fn leave_leg() -> bool {
std::env::var("WOW_PROBE_BG_LEAVE")
.ok()
.is_some_and(|v| !v.is_empty() && v != "0")
}
/// How long the leave leg watches: the worldport, the modal's return and the settle all land inside
/// it, and the worldport ack is paid on the settle release.
const LEAVE_WATCH_SECS: f64 = 90.0;
/// The leave leg's sample period, short enough to place a modal's return between two samples.
const LEAVE_WATCH_EVERY: f64 = 4.0;
/// How long the leave leg waits for a match to end before calling it a failure: the doors plus
/// vmangos's 300 s, with room for the queue.
const LEAVE_END_TIMEOUT: f64 = 600.0;
/// A Lua frame that records every battleground event the stock interface listens for, with its
/// `arg1`: a chat kind that reaches the VM logs nothing, so only a tap shows the countdown's
/// `CHAT_MSG_BG_SYSTEM_NEUTRAL` lines (`BattleGround.cpp:383-418`). Written in 1.12 dialect
@@ -169,6 +194,7 @@ BenillaBgTap:RegisterEvent("AREA_SPIRIT_HEALER_OUT_OF_RANGE");
BenillaBgTap:RegisterEvent("PLAYER_DEAD");
BenillaBgTap:RegisterEvent("PLAYER_UNGHOST");
BenillaBgTap:RegisterEvent("PLAYER_ALIVE");
BenillaBgTap:RegisterEvent("PLAYER_ENTERING_WORLD");
BenillaBgTap:RegisterEvent("ZONE_CHANGED_NEW_AREA");
"#;
@@ -221,6 +247,9 @@ struct BgProbe {
waiting_since: Option<f64>,
/// Events drained before the first census, reported with it rather than dropped.
pending_events: String,
/// The last position a census printed, so each line can say how far the body has moved since.
/// A frozen body under a held forward key is the stuck symptom, as one number.
last_pos: Option<Vec3>,
}
/// `Wait` → `Hopped` → `Toggled` → `Scanning` → `Greeted` → `Joined` (↔ `Rejoining`) → `Ported` →
@@ -258,6 +287,21 @@ enum Phase {
next_census: f64,
samples: u32,
},
/// [`WOW_PROBE_BG_LEAVE`]: inside, waiting for the match to end on its own. A solo match never
/// reaches `min_players_per_team`, so vmangos ends it with `WINNER_NONE` about 300 s after the
/// doors (`BattleGround.cpp:318` is the AV exception), and a non-nil winner is what the
/// end-of-battleground modal needs to show itself.
WaitingEnd {
entered_at: f64,
next_census: f64,
},
/// The leave leg proper: `LeaveBattlefield()` has gone out, and the body holds forward for the
/// census window so a frozen body shows as a zero `moved` against a live one.
AfterLeave {
at: f64,
next_census: f64,
samples: u32,
},
FlagHop {
at: f64,
},
@@ -347,7 +391,45 @@ fn ws(states: &WorldStates, key: u32) -> String {
.map_or_else(|| "absent".to_string(), |(_, v)| v.to_string())
}
/// The in-battleground census preamble every arm shares. The tap's window is drained by
/// `bg_probe` before the phase match and handed in here, so the self-heal can still reach `probe`.
#[allow(clippy::too_many_arguments)]
fn census_inside(
probe: &mut BgProbe,
t: f64,
arena: &Arena,
player: &Player,
units: &Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without<SelfPlayer>>,
map: Option<&CurrentMap>,
area: Option<&CurrentArea>,
load: Option<&WorldLoadProgress>,
states: &WorldStates,
dropped: &DroppedOpcodes,
go_templates: &crate::go_templates::GameObjectTemplates,
queue: &BattlefieldQueue,
script: &mut UiScript,
events: &str,
tap_broken: bool,
) {
census(
t,
arena,
player,
units,
map,
area,
load,
states,
dropped,
go_templates,
queue,
&mut probe.last_pos,
script,
events,
tap_broken,
);
}
fn bg_probe(
time: ProbeClock,
mut probe: ResMut<BgProbe>,
@@ -394,6 +476,22 @@ fn bg_probe(
}
}
// Drain the tap's window once a frame, for the phases that are inside the battleground: its
// self-heal needs the VM, and the census prints what came in. The three arms share one drain so
// the consume stays here, in the system that argues for it in `game_plugins.rs`.
let inside_bg = matches!(
probe.phase,
Phase::Inside { .. } | Phase::WaitingEnd { .. } | Phase::AfterLeave { .. }
);
let mut events = String::new();
let tap_broken = probe.tap_broken;
if inside_bg {
events.push_str(&std::mem::take(&mut probe.pending_events));
if let Some(script) = script.as_deref_mut() {
events.push_str(&drain_events(&mut probe, script));
}
}
match probe.phase {
Phase::Wait => {
// No start in GM mode: `CanUseBattleGroundObject` refuses a GM every objective with no
@@ -622,17 +720,30 @@ fn bg_probe(
Phase::Ported { sent_at } => {
let here = map.as_ref().map_or(0, |m| m.0);
if here == arena.map {
info!(
"PROBE bg: ENTERED map {} after {:.1}s — census every {CENSUS_EVERY:.0}s × {}",
arena.map,
now - sent_at,
census_samples(),
);
probe.phase = Phase::Inside {
entered_at: now,
next_census: now + CENSUS_EVERY,
samples: 0,
};
if leave_leg() {
info!(
"PROBE bg: ENTERED map {} after {:.1}s — WOW_PROBE_BG_LEAVE: waiting for the \
match to end on its own, then leaving it and watching for {LEAVE_WATCH_SECS:.0}s",
arena.map,
now - sent_at,
);
probe.phase = Phase::WaitingEnd {
entered_at: now,
next_census: now + CENSUS_EVERY,
};
} else {
info!(
"PROBE bg: ENTERED map {} after {:.1}s — census every {CENSUS_EVERY:.0}s × {}",
arena.map,
now - sent_at,
census_samples(),
);
probe.phase = Phase::Inside {
entered_at: now,
next_census: now + CENSUS_EVERY,
samples: 0,
};
}
} else if now - sent_at > 30.0 {
error!(
"PROBE bg: FAILURE — 30 s after the port the map is still {here}, not {}",
@@ -654,11 +765,8 @@ fn bg_probe(
finish(&mut probe, &net);
return;
};
// Drained here, not in `census`, so the tap's self-heal can reach `probe`.
let mut events = std::mem::take(&mut probe.pending_events);
events.push_str(&drain_events(&mut probe, &mut script));
let tap_broken = probe.tap_broken;
census(
census_inside(
&mut probe,
now - entered_at,
arena,
&player,
@@ -709,6 +817,115 @@ fn bg_probe(
};
}
}
Phase::WaitingEnd {
entered_at,
next_census,
} => {
if now < next_census {
return;
}
let Some(mut script) = script else {
error!("PROBE bg: FAILURE — the VM went away waiting for the match to end");
finish(&mut probe, &net);
return;
};
census_inside(
&mut probe,
now - entered_at,
arena,
&player,
&units,
map.as_deref(),
area.as_deref(),
load.as_deref(),
&states,
&dropped,
&go_templates,
&queue,
&mut script,
&events,
tap_broken,
);
// The end-of-battleground modal shows itself only while `GetBattlefieldWinner()` is
// non-nil (`WorldStateFrame.lua:351-355`), so a winner is the precondition for the
// leave this leg is here to measure. `LeaveBattlefield` sends nothing without the
// scoreboard's "ended" byte (`0x4abe66`), which is the same arrival.
let winner = script
.eval::<Option<i64>>("return GetBattlefieldWinner()")
.unwrap_or(None);
match winner {
Some(w) => {
info!(
"PROBE bg: ENDED — winner={w} at t={:.0}s; leaving with LeaveBattlefield(), \
watching for {LEAVE_WATCH_SECS:.0}s (hold forward with WOW_PROBE_KEY=\"W@…:…\")",
now - entered_at
);
let _ = script.eval::<()>("LeaveBattlefield()");
probe.phase = Phase::AfterLeave {
at: now,
next_census: now + LEAVE_WATCH_EVERY,
samples: 0,
};
}
None => {
if now - entered_at > LEAVE_END_TIMEOUT {
error!(
"PROBE bg: FAILURE — no winner {LEAVE_END_TIMEOUT:.0}s after entering \
map {}; vmangos ends a solo match about 300 s after the doors, so the \
match never formed (check the `.debug bg` lines above)",
arena.map
);
finish(&mut probe, &net);
return;
}
probe.phase = Phase::WaitingEnd {
entered_at,
next_census: now + CENSUS_EVERY,
};
}
}
}
Phase::AfterLeave {
at,
next_census,
samples,
} => {
if now - at >= LEAVE_WATCH_SECS {
info!("PROBE bg: WATCH done — {LEAVE_WATCH_SECS:.0}s after the leave");
finish(&mut probe, &net);
return;
}
if now < next_census {
return;
}
let Some(mut script) = script else {
error!("PROBE bg: FAILURE — the VM went away after the leave");
finish(&mut probe, &net);
return;
};
census_inside(
&mut probe,
now - at,
arena,
&player,
&units,
map.as_deref(),
area.as_deref(),
load.as_deref(),
&states,
&dropped,
&go_templates,
&queue,
&mut script,
&events,
tap_broken,
);
probe.phase = Phase::AfterLeave {
at,
next_census: now + LEAVE_WATCH_EVERY,
samples: samples + 1,
};
}
Phase::Rejoining { at } => {
if now - at < 2.0 {
return; // the decline lands and the slot clears
@@ -942,6 +1159,7 @@ fn census(
dropped: &DroppedOpcodes,
go_templates: &crate::go_templates::GameObjectTemplates,
queue: &BattlefieldQueue,
last_pos: &mut Option<Vec3>,
script: &mut UiScript,
events: &str,
tap_broken: bool,
@@ -949,6 +1167,11 @@ fn census(
use benilla_protocol::EntityKind as K;
let p = player.pos;
let moved = last_pos.map_or_else(
|| "first".to_string(),
|last| format!("{:.1} yd", (p - last).length()),
);
*last_pos = Some(p);
let terrain = load.map_or_else(
|| "no-progress".to_string(),
|l| {
@@ -960,7 +1183,8 @@ fn census(
);
info!(
// `map=none`, not `map=0`: 0 is Eastern Kingdoms.
"PROBE bg: CENSUS t={t:.0}s map={} area={:?} pos=({:.0},{:.0},{:.0}) terrain={terrain}",
"PROBE bg: CENSUS t={t:.0}s map={} area={:?} pos=({:.0},{:.0},{:.0}) moved={moved} \
terrain={terrain}",
map.map_or_else(|| "none".to_string(), |m| m.0.to_string()),
area.and_then(|a| a.0),
p.x,
@@ -1086,6 +1310,28 @@ fn census(
.unwrap_or_else(|e| format!("<raised: {e}>"));
info!("PROBE bg: SCORE n={score}");
// The end-of-battleground modal, and the gates that can freeze the body. `WorldStateScoreFrame`
// shows itself on any score or world-state event while `GetBattlefieldWinner()` is non-nil
// (`WorldStateFrame.lua:221-224`, `:351-355`), so its visibility across a leave is the whole
// question; `settling`/`world_stale`/`control_lost` are the three latches that can keep the
// body off the wire after the worldport.
let shown = script
.eval::<String>(
"local f = getglobal(\"WorldStateScoreFrame\") \
if not f then return \"not-loaded\" end \
return tostring(f:IsShown())",
)
.unwrap_or_else(|e| format!("<raised: {e}>"));
info!(
"PROBE bg: SCOREFRAME IsShown={shown} gates settling={} world_stale={} control_lost={} \
owes_ack={} map_id={:?}",
player.settling,
player.world_stale,
player.control_lost,
player.owes_worldport_ack,
map.map(|m| m.0),
);
// Every opcode the codec dropped, by name; `DroppedOpcodes` is never cleared, so the counts
// are cumulative over the session.
let mut drops: Vec<(u16, u64, u64)> = dropped
@@ -187,6 +187,11 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
purpose: "wsg|ab|av — queue for that battleground, take the port through the stock verb and census the instance from inside",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_BG_LEAVE",
purpose: "1 — with WOW_PROBE_BG, wait inside for the match to end, leave it through the stock LeaveBattlefield() and census for 90 s across the worldport",
wall_clock: true,
},
ProbeVar {
name: "WOW_PROBE_BGQUEUE",
purpose: "1 — level past the bracket floor, greet Stormwind's Warsong Gulch battlemaster and queue through his list",