From c2d83dc22e27bdedee408762898d15a5796f3d38 Mon Sep 17 00:00:00 2001 From: chairmanpow Date: Fri, 2 Oct 2026 17:56:08 +0100 Subject: [PATCH] probe bg: a leave leg, so WOW_PROBE_BG can measure the worldport out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/benilla-app/src/capture/probe_bg.rs | 288 ++++++++++++++++++-- crates/benilla-app/src/capture/probe_env.rs | 5 + 2 files changed, 272 insertions(+), 21 deletions(-) diff --git a/crates/benilla-app/src/capture/probe_bg.rs b/crates/benilla-app/src/capture/probe_bg.rs index 4273e4e3..c474befb 100644 --- a/crates/benilla-app/src/capture/probe_bg.rs +++ b/crates/benilla-app/src/capture/probe_bg.rs @@ -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@:"`, 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, /// 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, } /// `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>, + 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, @@ -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::>("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, 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!("")); 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::( + "local f = getglobal(\"WorldStateScoreFrame\") \ + if not f then return \"not-loaded\" end \ + return tostring(f:IsShown())", + ) + .unwrap_or_else(|e| format!("")); + 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 diff --git a/crates/benilla-app/src/capture/probe_env.rs b/crates/benilla-app/src/capture/probe_env.rs index 0a11c9a9..e0f6adb8 100644 --- a/crates/benilla-app/src/capture/probe_env.rs +++ b/crates/benilla-app/src/capture/probe_env.rs @@ -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",