Files
benilla-octo/crates/benilla-app/src/lib.rs
T
SamandClaude Fable 5 a401e5c43d the dressing room opens, and the pet gets its paper doll
- ctrl-click an item to try it on: the dressing room dresses your double, and shift-click posts the item link to chat
- the pet tab lands in the character window: paper doll stats, family and diet, the right-click menu, and dropping food on the pet feeds it
- water-only spells refuse on dry land: aquatic form gates to swimming, client-side like the real one
- the macro icon picker's white squares get their art back, and option rows explain themselves on hover

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:02:21 +07:00

970 lines
52 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `benilla` — a from-scratch World of Warcraft 1.12.1 client on Bevy, talking to a local vmangos server.
//!
//! Opens the vanilla patch chain from `$WOW_DATA` (default `WoW/Data`) and streams the world around the
//! player through Bevy's `AssetServer` (the `benilla-assets` `mpq://` pipeline): ADT terrain tiles within
//! `$WOW_TILE_RADIUS` — their splat-blended ground (tiling `$WOW_TEX_TILES`), doodads/WMOs, water, and
//! ground clutter — plus the avian colliders the character controller walks on. Lit by a time-of-day WoW
//! lighting model (`Light.dbc` sampled against the server clock) with a sky dome, sun/moon discs, and
//! distance fog; a faithful `EffectGlow` bloom on top.
//!
//! In parallel a background thread ([`net`]) logs in (`$WOW_USER`/`$WOW_PASS`/`$WOW_HOST`, default
//! `one`/`pone`/`localhost`), enters the world, and streams object updates. NPCs and GameObjects
//! render as their real models (resolved from the display id via the creature/GameObject catalogs); other
//! players stay cyan cubes, and our own avatar is blue until we take third-person control of it.
//! **The world is loaded when a character enters it, and released when they leave** — the glue
//! screens have no world behind them (decision 0777). With no server there is no world: the client
//! sits at the login screen, which is what the real one does. The scene harness (`$WOW_CAPTURE`)
//! boots straight in-world and is unaffected.
//!
//! Controls: WASD walks the avatar; right-drag turns it, left-drag orbits the camera (both
//! hide/freeze the cursor while held), scroll wheel zooms. Those are all **bindings** now (0997) —
//! rebindable, and nothing in the client squats on a bare key beside them (1043). The dev chord
//! (`Ctrl`+`Shift`) + `F` toggles free-fly, then WASD flies the camera with Space up / C down and
//! `Ctrl` boosts — a boost that exists only inside free-fly, itself behind the chord.
mod area;
mod area_trigger;
mod art_scope;
mod asset_churn;
mod assets;
mod aura_visual;
mod bgwin;
mod billboard;
mod bindings;
mod blob_shadow;
mod bowstring;
mod build_id;
mod capture;
mod char_create;
mod char_select;
mod chat_bubble;
mod clouds;
mod clutter;
mod collision;
mod combat_text;
mod cooldowns;
mod creature_anim;
mod cursor;
mod cvars;
mod dbg_trace;
mod death;
mod debug_panel;
mod decal;
mod doodad_anim;
mod entities;
mod entity_shade;
mod exterior_cull;
mod ffx_glow;
mod footprints;
mod glue;
mod glue_strings;
mod go_anim;
mod go_templates;
mod ground_fx;
mod hover_log;
mod instance_tint;
mod interact;
mod interior;
mod items;
mod lighting;
mod liquid;
mod loading_screen;
mod local_state;
mod login;
mod map_proj;
mod mesh_tag;
mod minimap;
mod model_fade;
mod model_forms;
mod model_render;
mod modkeys;
mod nameplates;
mod names;
mod net;
mod npc_text;
mod particles;
mod pending_item_ops;
mod perf;
mod pipe_warm;
mod player;
mod portrait;
mod preflight;
mod probe_shield;
mod quest_markers;
mod raid_marks;
mod ribbons;
mod rig_palette;
mod schedule;
mod sky;
mod sky_order;
mod smart_rect;
mod sound;
mod sun;
mod target;
mod terrain;
mod terrain_stream;
mod textinput;
mod thread_qos;
mod transport;
mod ui_action;
mod ui_aura;
mod ui_bank;
mod ui_cast;
mod ui_char;
mod ui_chat;
mod ui_craft;
mod ui_dressup;
mod ui_duel;
mod ui_follow;
mod ui_gamma;
mod ui_gossip;
mod ui_hide;
mod ui_inspect;
mod ui_item_text;
mod ui_items;
mod ui_logout;
mod ui_loot;
mod ui_loot_roll;
mod ui_macro;
mod ui_mail;
mod ui_merchant;
mod ui_mirror;
mod ui_net;
mod ui_party;
mod ui_pass;
mod ui_pet;
mod ui_pet_book;
mod ui_pet_doll;
mod ui_pet_stats;
mod ui_quest;
mod ui_quest_log;
mod ui_script;
mod ui_session;
mod ui_shapeshift;
mod ui_social;
mod ui_spellbook;
mod ui_talent;
mod ui_taxi;
mod ui_text;
mod ui_tooltip;
mod ui_trade;
mod ui_tradeskill;
mod ui_trainer;
mod ui_unit;
mod ui_world_map;
mod view;
mod vplates;
mod water_fx;
mod wdl;
mod weather;
mod wmo_portal;
mod wmo_sky;
mod world_map;
mod world_state;
mod zfill;
use assets::AssetPlugin;
use avian3d::prelude::*;
use bevy::prelude::*;
use billboard::BillboardPlugin;
use blob_shadow::BlobShadowPlugin;
use bowstring::BowstringPlugin;
use clouds::CloudsPlugin;
use clutter::ClutterPlugin;
use creature_anim::CreatureAnimPlugin;
use cursor::CursorPlugin;
use debug_panel::DebugPanelPlugin;
use doodad_anim::DoodadAnimPlugin;
use entities::EntitiesPlugin;
use entity_shade::EntityShadePlugin;
use footprints::FootprintsPlugin;
use interact::InteractPlugin;
use interior::InteriorPlugin;
use lighting::LightingPlugin;
use liquid::LiquidPlugin;
use loading_screen::LoadingScreenPlugin;
use net::NetPlugin;
use particles::ParticlePlugin;
use perf::PerfPlugin;
use player::PlayerPlugin;
use portrait::PortraitPlugin;
use quest_markers::QuestMarkersPlugin;
use ribbons::RibbonPlugin;
use schedule::SchedulePlugin;
use sky::SkyPlugin;
use sound::SoundPlugin;
use sun::SunPlugin;
use target::TargetPlugin;
use terrain::{TerrainMaterial, WowModelMaterial};
use terrain_stream::TerrainPlugin;
use textinput::TextInputPlugin;
use transport::TransportPlugin;
use ui_action::UiActionPlugin;
use ui_aura::UiAuraPlugin;
use ui_bank::UiBankPlugin;
use ui_cast::UiCastPlugin;
use ui_char::UiCharPlugin;
use ui_chat::UiChatPlugin;
use ui_craft::UiCraftPlugin;
use ui_duel::UiDuelPlugin;
use ui_follow::UiFollowPlugin;
use ui_gossip::UiGossipPlugin;
use ui_item_text::UiItemTextPlugin;
use ui_items::UiItemsPlugin;
use ui_logout::UiLogoutPlugin;
use ui_loot::UiLootPlugin;
use ui_loot_roll::UiLootRollPlugin;
use ui_mail::UiMailPlugin;
use ui_merchant::UiMerchantPlugin;
use ui_mirror::UiMirrorPlugin;
use ui_net::UiNetPlugin;
use ui_party::UiPartyPlugin;
use ui_pass::PlayerUiPlugin;
use ui_pet::UiPetPlugin;
use ui_pet_book::UiPetBookPlugin;
use ui_pet_doll::UiPetDollPlugin;
use ui_pet_stats::UiPetStatsPlugin;
use ui_quest::UiQuestPlugin;
use ui_quest_log::UiQuestLogPlugin;
use ui_script::UiScriptPlugin;
use ui_shapeshift::UiShapeshiftPlugin;
use ui_social::UiSocialPlugin;
use ui_spellbook::UiSpellbookPlugin;
use ui_talent::UiTalentPlugin;
use ui_taxi::UiTaxiPlugin;
use ui_text::UiTextPlugin;
use ui_tooltip::UiTooltipPlugin;
use ui_trade::UiTradePlugin;
use ui_tradeskill::UiTradeSkillPlugin;
use ui_trainer::UiTrainerPlugin;
use ui_unit::UiUnitPlugin;
use wdl::WdlPlugin;
use weather::WeatherPlugin;
use wmo_portal::WmoPortalPlugin;
use world_map::WorldMapPlugin;
// The `benilla` launcher shim (the bin package) is this library's only caller: it stamps the
// build id at compile time and hands it into [`run`]. Re-exported so the shim needs no bevy
// dep of its own.
pub use bevy::app::AppExit;
pub use build_id::BuildId;
/// Anchor the loaded terrain block on the Human start (Northshire), where `one`/`One`
/// logs in — so the player sits in the middle of the block instead of Stormwind's edge.
const SPAWN_XY: (f32, f32) = (-8949.95, -132.49);
/// Build and run the client app. `build` is the launcher shim's compile-time git stamp
/// ([`build_id`]) — passed in as plain data so the sha lives in the shim's fingerprint, not
/// this crate's, and a commit stops recompiling the app (decision 0993).
pub fn run(build: BuildId) -> AppExit {
// `WOW_CAPTURE=list` just prints the harness scenario names (the source of truth `scripts/visual.sh`
// reads) and exits before any window/asset setup.
// `WOW_HOVER_LOG_REPORT=<csv>` re-reads a recorded run and prints its report, then exits —
// no window, no game. New analysis lands on runs already captured (see `hover_log`).
if let Ok(path) = std::env::var("WOW_HOVER_LOG_REPORT") {
hover_log::report_recorded_file(&path);
return AppExit::Success;
}
if std::env::var("WOW_CAPTURE").as_deref() == Ok("list") {
capture::print_scenario_names();
return AppExit::Success;
}
let mut app = App::new();
// The stamp is plain data from here on — the panel footer and preflight banner read it back.
app.insert_resource(build);
// Visual A/B harness (decision 0008): with `$WOW_CAPTURE` set, the app runs a deterministic,
// server-less capture (net off so no NPCs stream in nondeterministically) and exits. See `capture`.
let capturing = capture::scenario_active();
// Any instrumented run — captures AND the live-probe fleet — opens in the background so it
// never fights the director's screen (decision 0703; `WOW_BG` overrides). See `bgwin`.
let background = bgwin::background_run();
if capturing {
// Ground clutter scatters with per-run randomness, so disable it for byte-stable baselines
// — clutter isn't what the lighting rework validates, and the regression diff must not be
// masked by grass wobble. Set before plugins build so `ClutterConfig::from_env` reads it.
// It is not the only source of per-run drift, though it was long documented as such: the
// other is the frame clock itself, frozen in `capture` (decision 0723).
std::env::set_var("WOW_CLUTTER_DENSITY", "0");
// Third source of per-run drift, and the one the lighting matrix (decision 0746) hit: the
// anim-LOD park/wake gate (`creature_anim::lod::gate_rig_animation`). Whether a rig is
// parked, and which pose it wakes into, depends on when its model finished loading relative
// to the frustum/room evaluation — asset-load timing, which the frozen clock does not
// control. MEASURED: the seeded wolf lands in one of exactly two poses, the pair always
// MAE 4.123 apart, and three runs with the gate off are bit-identical.
//
// Off for captures, unless explicitly overridden. A still frame is meant to contain only
// rigs that are IN view, and a rig in view is one the gate should never park — so what a
// capture loses is the gate's own correctness, not the shot's subject. That loss is named,
// not free: a rig wrongly parked in frame stays invisible to the sweep, and the deeper
// question the measurement raises — why a woken rig does not converge on the absolute-clock
// pose the gate promises — is open in 0746 and worth its own hunt.
if std::env::var("WOW_NO_ANIM_LOD").is_err() {
std::env::set_var("WOW_NO_ANIM_LOD", "1");
}
}
// The `mpq://` asset source must be registered BEFORE `AssetPlugin` (inside `DefaultPlugins`)
// builds. Reads the same `$WOW_DATA` dir as the `WorldAssets` foundation; on failure the source is
// simply absent and the AdtTile-pipeline loads fail gracefully (the terrain just doesn't appear).
let data_dir =
std::path::PathBuf::from(std::env::var("WOW_DATA").unwrap_or_else(|_| "WoW/Data".into()));
if let Err(e) = benilla_assets::register_mpq_source(&mut app, &data_dir) {
eprintln!("benilla-assets: mpq:// source unavailable ({e:#})");
}
app.add_plugins(
DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window {
title: "benilla".into(),
// UI-fixture captures shrink the window so the docked panel fills the frame —
// the capture is the look-pass instrument and the window is its subject. The
// action bar is the exception: it spans 1024px + 128px end caps along the screen
// bottom, so it gets a wide, short window instead of the tall default (else the
// caps crop). The vplates scenario pins the 1:1 gx window: at 1024×768 one gx
// unit = 1280 px, so the plate must land at the border texture's native
// 128×32 — directly diffable against the decoded BLP. Sized per-capture off
// WOW_CAPTURE.
resolution: if capturing
&& std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1")
{
// `$WOW_WIN` overrides here too — the resolution-A/B instrument for UI
// scenarios (a scale-dependent text bug looks fine at the scenario's
// default size and truncates at fullscreen heights).
if let Some(win) = std::env::var("WOW_WIN").ok().and_then(|v| {
let (w, h) = v.split_once('x')?;
Some(UVec2::new(w.parse().ok()?, h.parse().ok()?))
}) {
win
} else {
match std::env::var("WOW_CAPTURE").as_deref() {
Ok("ui-actionbar") => UVec2::new(1300, 260),
Ok("vplates") => UVec2::new(1024, 768),
// The director's small-window shape: short enough that the action bar
// strip overlaps the chat edit box rows — the overlap is the subject.
Ok("ui-chatedit") => UVec2::new(566, 377),
// The fullscreen map's chrome is a centered 1024×768 block; a hair of
// margin shows the blackout doing its job.
Ok("ui-worldmap") => UVec2::new(1100, 800),
// The 920×724 era options window wants air on every side so the
// straddling right-edge tile and the hung close X stay in frame.
Ok("ui-options")
| Ok("ui-options-audio")
| Ok("ui-options-graphics") => UVec2::new(1200, 900),
_ => UVec2::new(640, 700),
}
}
.into()
} else {
// `$WOW_WIN=WxH` (logical px): override the world capture/window size —
// the resolution-A/B instrument. The FFXGlow blur geometry is byte-pinned
// in TEXELS, so its angular footprint shrinks as resolution grows and
// thin bright features (fence rails) self-amplify at 4K where the
// 1024-era reference diluted them; matching the era's pixel density
// (e.g. `WOW_WIN=512x288` on a 2× display → 1024×576 physical) isolates
// that term. Also the knob for any future era-resolution comparison.
std::env::var("WOW_WIN")
.ok()
.and_then(|v| {
let (w, h) = v.split_once('x')?;
Some(UVec2::new(w.parse().ok()?, h.parse().ok()?))
})
.unwrap_or(UVec2::new(1600, 900))
.into()
},
// `$WOW_NOVSYNC=1`: uncap presentation so a headless FPS-journal run measures
// true frame cost, not the vsync ceiling — the same uncap the capture probe
// flips mid-run, available from boot for non-capture probes (perf triage at
// the glue screens, where no capture scenario runs).
present_mode: if std::env::var("WOW_NOVSYNC").as_deref() == Ok("1") {
bevy::window::PresentMode::AutoNoVsync
} else {
bevy::window::PresentMode::default()
},
// An instrumented run must never fight the director's screen (decision 0703).
// Focused, it steals the keyboard — on 2026-07-19 a login-shot run swallowed
// their keystrokes out of another app and typed them into the account box,
// which is also how that capture lost the bare caret it was taken to measure.
// So every probe/capture/regression run (`bgwin`) opens unfocused; an ordinary
// `cargo run` is unaffected and focuses normally.
//
// A background run is BORN at `AlwaysOnBottom` (`kCGNormalWindowLevel - 1`)
// so it can never flash over their work on the way up — winit raises a new
// window twice before our first frame runs, and at the normal level that
// showed as ~half a second of probe window on top (measured). But it does not
// STAY there: that level is a cage, and 0703 leaving it on for the whole run
// is why an instrumented window could never be raised again however hard you
// clicked it. `BgWinPlugin` promotes it back to Normal the moment the launch
// settles (decision 0709), and owns the app-level half — winit's forced macOS
// app activation — as well.
focused: !background,
window_level: if background {
bevy::window::WindowLevel::AlwaysOnBottom
} else {
bevy::window::WindowLevel::Normal
},
..default()
}),
..default()
})
// Our file assets (the WGSL shaders, the default UI XML) live beside THIS crate —
// but the binary is built by the `benilla` shim package, so Bevy's runtime
// `CARGO_MANIFEST_DIR` fallback would resolve `assets/` in the shim's dir (and a
// bare-binary run has no manifest dir at all — the silently-no-shaders trap in
// `capture/mod.rs`'s header). Bake this crate's absolute path at compile time:
// right under `cargo run` from any package, and a bare `target/debug/benilla`
// now finds its shaders too. Machine-local, like any dev build (decision 0993).
.set(bevy::asset::AssetPlugin {
file_path: concat!(env!("CARGO_MANIFEST_DIR"), "/assets").into(),
..default()
})
// Quiet wgpu/naga; our own crates stay at info. (This filter once also quieted the
// warcraft-rs parsers `wow_m2`/`wow_blp` — retired in-repo by decision 0021; the
// in-repo parsers don't log, so those entries were dead and are gone.)
.set(bevy::log::LogPlugin {
filter: "wgpu=error,naga=warn".into(),
..default()
})
// Asset streaming is this client's load bottleneck: every M2/WMO/BLP read decompresses
// from MPQ and parses **synchronously** on Bevy's IO task pool, and the AssetServer runs
// *all* loads there. Bevy's default caps that pool at 4 threads, so a teleport into a
// dense area — terrain + WMOs + their doodad props, all bursting at once — saturates it
// and the net-driven NPC/GameObject models queue behind the flood. Give IO more of the
// box (it sits idle when not streaming); the world-render path is GPU/IO-bound, not
// compute-bound, so trading some compute threads for streaming throughput is the right call.
// Thread QoS (decision 0609): Bevy's workers spawn at default QoS — the same Darwin
// scheduling class as rustc or an OBS encoder — so under a background build the
// frame's own threads queue behind the compiler for P-core time. Promote them at
// spawn: compute runs this frame's systems (user-interactive); IO/async-compute feed
// upcoming frames (user-initiated — above default, below the frame itself). The
// render thread has no spawn hook; `ThreadQosPlugin` promotes it from inside.
.set(TaskPoolPlugin {
task_pool_options: TaskPoolOptions {
io: bevy::app::TaskPoolThreadAssignmentPolicy {
min_threads: 2,
max_threads: 8,
percent: 0.5,
on_thread_spawn: Some(std::sync::Arc::new(|| {
thread_qos::promote_current_thread(thread_qos::QosClass::UserInitiated)
})),
on_thread_destroy: None,
},
async_compute: bevy::app::TaskPoolThreadAssignmentPolicy {
on_thread_spawn: Some(std::sync::Arc::new(|| {
thread_qos::promote_current_thread(thread_qos::QosClass::UserInitiated)
})),
..TaskPoolOptions::default().async_compute
},
compute: bevy::app::TaskPoolThreadAssignmentPolicy {
on_thread_spawn: Some(std::sync::Arc::new(|| {
thread_qos::promote_current_thread(
thread_qos::QosClass::UserInteractive,
)
})),
// `WOW_THREADS=1` serialises the systems that run this frame. Not a
// performance dial — a **diagnostic**: a defect that alternates frame to
// frame with no camera, geometry or draw-order change behind it is what an
// unordered write between two systems looks like, and that is separable from
// every other cause only by taking the concurrency away. Anything that
// survives `WOW_THREADS=1` is not a race.
max_threads: match std::env::var("WOW_THREADS").ok().as_deref() {
Some("1") => 1,
_ => TaskPoolOptions::default().compute.max_threads,
},
..TaskPoolOptions::default().compute
},
..default()
},
})
// Sound is kira behind our own mixer seam (decision 0070); Bevy's AudioPlugin would
// only open a second, never-used OS output stream at startup. Off (0530). Its
// rodio/cpal stack still compiles in via bevy's default feature — trimming the
// feature set is a separate, wider call.
.disable::<bevy::audio::AudioPlugin>(),
)
.add_plugins(thread_qos::ThreadQosPlugin)
// The app-side half of background instrumented runs: undo winit's forced macOS app
// activation so a probe/capture launch never yanks focus off the director's screen. The
// window-side half (unfocused + always-on-bottom) is in the `Window` above. Decision 0703.
.add_plugins(bgwin::BgWinPlugin)
.add_plugins(MaterialPlugin::<TerrainMaterial>::default())
.add_plugins(MaterialPlugin::<WowModelMaterial>::default())
// Physics (avian3d): collider storage + broadphase BVH + shape-casts for the character
// controller (decision 0009). The streamed terrain/placement entities carry `Collider`s; the
// player drives `MoveAndSlide` against them. WoW gravity (19.29 yd/s², binary-derived — now a
// feel knob, not a fidelity target) replaces avian's 9.81 default.
.add_plugins(PhysicsPlugins::default())
// One solver substep, not avian's 6: the world has NO dynamic bodies (static terrain,
// kinematic transports/attachments; the player is a shape-cast controller), so the substep
// loop's contact/joint solving iterates over nothing — and kinematic motion integrates
// exactly (constant velocity, no forces) at any substep count. Six substeps were pure
// fixed-tick schedule overhead (~10 substep-schedule runs per frame on the idle-floor
// ledger). Revisit if a dynamic body ever enters the world.
.insert_resource(avian3d::prelude::SubstepCount(1))
.insert_resource(Gravity(Vec3::NEG_Y * 19.291_105))
// The per-frame world-transition ordering (Input → Stream → Present) the loading screen relies
// on to cover a teleport the same frame it happens. See `schedule.rs`.
.add_plugins(SchedulePlugin)
// The faithful view distance (`farclip`) — one source of truth for the wall + the per-object
// cull (and, post-split, the stream radius). See `view.rs`.
.init_resource::<view::ViewDistance>()
.add_plugins(DebugPanelPlugin)
// World-interaction foundation: mouseover picking + object identity (the debug inspector reads it
// now; hover tooltips / contextual cursor / targeting will later).
.add_plugins(InteractPlugin)
// M2 billboard cards (glow halos, chains) — faced to the camera each frame.
.add_plugins(BillboardPlugin)
// The owned skin palette (decision 0720): every skinned rig's joint matrices, computed by
// us and skinned in wow_model.wgsl — Bevy's SkinnedMesh lane is fully replaced.
.add_plugins(rig_palette::plugin)
// The per-instance body tint (decision 0812), on the same slot index as that palette: the aura
// state kit's CharProc-1 colour, uploaded to its own region of the shared light buffer.
.add_plugins(instance_tint::plugin)
.add_plugins(BowstringPlugin)
.add_plugins(QuestMarkersPlugin)
// Frame-time HUD + diagnostics — the performance standard.
// After DebugPanelPlugin so the egui plugin/context it sets up already exists. Toggle: P.
.add_plugins(PerfPlugin)
// Pipeline-compile counters + the live-compile tripwire (decision 0837: macOS builds every
// pipeline synchronously on the render thread, so a live compile is a felt stall).
.add_plugins(pipe_warm::plugin)
.add_plugins(hover_log::HoverLogPlugin)
.add_plugins(asset_churn::AssetChurnPlugin)
// Within-map art residency (decision 0793): the dedup caches expire by DISTANCE, so a
// long flight inside one map stops ratcheting. Registered before AssetPlugin only so the
// census resource exists for anything that reads it at startup; it needs no ordering.
.add_plugins(art_scope::ArtScopePlugin)
.add_plugins(particles::census::plugin)
// Foundation: opens the patch chain + inserts WorldAssets/RenderConfig (AssetSet::Open), which
// every other subsystem's startup runs after.
.add_plugins(AssetPlugin)
// World-map state (Map.dbc catalog + CurrentMap), loaded right after the chain opens — the
// terrain/WDL streamers, loading screen, and lighting all key off it.
.add_plugins(WorldMapPlugin)
// Time-of-day lighting: sun + WoW shader colors, sky background, per-frame update→apply.
.add_plugins(LightingPlugin)
// Sky dome: the Light.dbc gradient backdrop (camera-centred), driven by the same lighting.
.add_plugins(SkyPlugin)
// WMO skybox: the authored sky a building's `0x40000` group swaps in for that gradient
// (Stratholme's burning city) — registered after SkyPlugin, whose dome it stands down.
.add_plugins(wmo_sky::WmoSkyPlugin)
// Cloud coverage: the reference's procedural field — glare occlusion (occ1) + the visible layer.
.add_plugins(CloudsPlugin)
// Weather: the SMSG_WEATHER state machine driving the storm light-blend + precipitation
// (decision 0310). Lighting reads its densities `.after(WeatherTick)`.
.add_plugins(WeatherPlugin)
// Sun disc + glow halo: the celestial sprites WoW draws at the sun (RE'd from CSky::Render).
.add_plugins(SunPlugin)
// Interior lighting classifier: lights M2 entities (GameObjects/NPCs/other players) standing inside a
// WMO room off the baked floor colour, day/night-independent (the streamer fills its volume registry).
.add_plugins(InteriorPlugin)
.add_plugins(EntityShadePlugin)
// WMO portal visibility: per-frame, decides which of a building's groups are reachable through
// portals from the camera's group, so the Stormwind cathedral culls from the Trade District. Only
// computes the PVS; the Visibility authority (DebugPanelPlugin) applies it (decisions 0025/0031).
.add_plugins(WmoPortalPlugin)
// The exterior scene draws only through portal windows the flood left behind (decision 0774):
// from inside a building, terrain and ADT doodads are gated on the deferred window worklist.
.add_plugins(exterior_cull::ExteriorCullPlugin)
// Streamed world entities: cube assets + display catalogs at startup, sync each frame.
.add_plugins(EntitiesPlugin)
// Creature animation: pick Stand/Walk/Run from each creature's movement state each frame (Milestone C).
.add_plugins(CreatureAnimPlugin)
// The unit blob shadow: the dark ground oval under every unit, sized from the playing
// animation's box (the byte-verified law — wow-re unit-blob-shadow RE), on the same
// surface-decal projector as the selection ring.
.add_plugins(BlobShadowPlugin)
// Footprint decals (B212, decision 1006): the prints a walking unit leaves on snow/sand,
// spawn-once projections on the same decal projector, fading off the effect stream.
.add_plugins(FootprintsPlugin)
// Doodad animation (decision 0130): placed M2s loop their first sequence + global sequences,
// gated to drawn instances.
.add_plugins(DoodadAnimPlugin)
// GameObject animation (decision 0242): net-streamed GObjects (doors/chests) play an M2 sequence
// on GAMEOBJECT_STATE change — the state-machine sibling of the doodad idle loop above.
.add_plugins(go_anim::plugin)
// Ground clutter: the GroundEffect catalog + the lazy per-chunk build lifecycle, owned
// independently of the terrain streamer (so the streamer can be swapped). Whichever streamer is
// active scatters into the ClutterChunks this builds.
.add_plugins(ClutterPlugin)
// Terrain streaming (the AdtTile pipeline) is added after this chain — see below.
// Distant low-detail terrain (WDL): the fogged horizon hills beyond the streamed tiles.
.add_plugins(WdlPlugin)
// Liquid: animated lake/river/ocean water surfaces (MCLQ), spawned with their terrain tile.
.add_plugins(LiquidPlugin)
// Particle emitters: the additive flames/glows of campfires, torches, braziers (decision 0014).
.add_plugins(ParticlePlugin)
// Water foam decals (CWater0Ripple wake/ring/step-in splash) — the record model, rebuilt from
// the byte RE + two reference-trace reconstructions (decision 0264).
.add_plugins(water_fx::WaterFxPlugin)
.add_plugins(ffx_glow::FfxGlowPlugin)
.add_plugins(RibbonPlugin)
// Avatar + camera + input.
.add_plugins(PlayerPlugin)
// The real client's hardware mouse cursor (native NSCursor on macOS).
.add_plugins(CursorPlugin)
// Stuck-modifier reconciliation: macOS system shortcuts (⇧⌘5) swallow modifier releases
// without a focus loss, wedging every bare-key binding (decision 0606).
.add_plugins(modkeys::ModKeysPlugin)
// Net↔ECS bridge: spawns the world thread, exposes the snapshot + writer resources. In capture
// mode the IO thread is skipped (`connect: false`) so the scene is deterministic.
.add_plugins(NetPlugin {
connect: !capturing,
})
// The death arc (decision 0308): the wire-fed death stores + the root/water-walk ack messages.
.add_plugins(death::DeathPlugin)
// The shared glue vocabulary both pre-world screens stand on (decision 0465): the ADD-mode UI
// material, the client-data art set, the GlueStrings table.
.add_plugins(glue::GluePlugin)
// The glue layer (decision 0193): the ClientState machine + the character-select screen
// that answers the parked IO thread's pick. Captures boot straight InWorld (no net, no picker).
.add_plugins(char_select::CharSelectPlugin {
start_in_world: capturing,
})
// The login screen (decision 0539): the faithful AccountLogin glue + the credential policy
// that answers the IO thread's pre-logon park.
.add_plugins(login::LoginPlugin)
// The character-creation screen + its live preview booth (decision 0423).
.add_plugins(char_create::CharCreatePlugin)
// The session preflight (decision 0649): one banner per world entry naming the body we logged
// into, and loud warnings for the states — dead/ghost, GM mode, server-blocked movement — that
// silently invalidate a session's readings. Never env-gated; a warning nobody switches on isn't one.
.add_plugins(preflight::PreflightPlugin)
// The probe shield (decision 0677): a body on a probe account is put into vmangos's `.cheat
// god` on every world entry — damage clamps at 1 hp instead of killing — and GM mode is turned
// OFF, because the shield replaces the only reason it was ever on. Inert on any other account.
.add_plugins(probe_shield::ProbeShieldPlugin)
// Audio: the delegated mixer + WoW's owned selection layer (decision 0070).
.add_plugins(SoundPlugin)
// Targeting: left-click a unit to select it (→ CMSG_SET_SELECTION) + draw its ground ring.
.add_plugins(TargetPlugin)
.add_plugins(TransportPlugin)
// Faithful world-load splash + progress bar on startup + cross-map teleport (the load latency
// streaming can't hide); per-map art via the Map.dbc→LoadingScreens.dbc→BLP chain.
.add_plugins(LoadingScreenPlugin)
// The player-UI quad pass (decision 0068 §2): its own composited-above-the-world,
// below-the-egui-dev-overlays camera + sorted-quad renderer. `$WOW_UI_DEMO=1` seeds a proof scene.
.add_plugins(PlayerUiPlugin)
// The HUD minimap (decision 0203 phase 1): fills the `<Minimap>` widget's extracted hole with
// the streamed tile window + mask + player arrow, and feeds the zone text.
.add_plugins(minimap::MinimapPlugin)
// The shared AreaTable catalog + the ZONE_CHANGED event family / zone-text host globals
// behind GetZoneText & co. (the zone-entry splash arc, decision 0287).
.add_plugins(area::AreaPlugin)
// The `AreaTrigger.dbc` volumes + the per-frame containment check that reports walking into
// one (`CMSG_AREATRIGGER`) — the client's whole part in portals, instance entrances and
// explore objectives; the server owns what each trigger means.
.add_plugins(area_trigger::AreaTriggerPlugin)
.add_plugins(ui_world_map::WorldMapUiPlugin)
// The glyph atlas (client TTFs -> baked bitmap) `ui_script`'s extraction draws `FontString`
// regions through. Loads at Startup, after the asset chain opens (decision 0068 §2).
.add_plugins(UiTextPlugin)
// Unit-frame portraits: the token -> off-screen-baked-face bridge the UI extract samples for a
// `SetPortraitTexture`-bound region (the modern high-res 2D model bake).
.add_plugins(PortraitPlugin)
.add_plugins((TextInputPlugin, UiScriptPlugin))
// The CVar host (decision 0954): registration, knob sync, config.toml persistence. After
// UiScriptPlugin only for reading order — its systems gate on the VM existing anyway.
.add_plugins(cvars::CvarPlugin)
// The key-binding engine (decision 0997): the chord→command dispatch every rebindable input
// runs through, its persistence, and the Key Bindings window's capture seam.
.add_plugins(bindings::BindingsPlugin)
// The unit snapshot + event feed (decision 0068 §3): pushes ECS game state into the VM as the
// plain data the `Unit*` bindings read, and fires the matching WoW events.
.add_plugins(UiUnitPlugin)
.add_plugins(UiPartyPlugin)
// Duels (decision 0633): the wire session, the client-side countdown tick, the four Era
// events, and the accept/cancel/challenge intents.
.add_plugins(UiDuelPlugin)
// Auto-follow's UI seam: the popup's Follow row + `FollowUnit`/`FollowByName` inbound, and
// the AUTOFOLLOW_BEGIN/END pair that drives the centre-screen status line outbound.
.add_plugins(UiFollowPlugin)
// Leaving (decision 0674): the game menu's Logout/Exit Game — the request, the server's
// 20-second answer narrated as the CAMP/QUIT countdown, and the process exit.
.add_plugins(UiLogoutPlugin)
.add_plugins(UiSocialPlugin)
.add_plugins(UiTooltipPlugin)
// The character-window feed (decision 0208): the combat-stats/inventory snapshots + events
// the paper doll reads, and the paper-doll booth's yaw mirror.
.add_plugins(UiCharPlugin)
// The inspect feed (decision 0631): another player's equipment off their PUBLIC visible-item
// entries, plus the "inspect" booth's unit + yaw. Right after the character feed it mirrors.
.add_plugins(ui_inspect::InspectUiPlugin)
// The dressing-room feed (decision 1060): the window's try-on intents → the player's own look
// with the tried-on items substituted in, plus the "dressup" booth's yaw. Beside the inspect
// feed, whose shape it shares (intents in, a booth look out).
.add_plugins(ui_dressup::DressUpUiPlugin)
.add_plugins(UiActionPlugin)
// The aura feed (decisions 0255/0257): the player's insertion-ordered buff/debuff cache + the
// self-only durations, pushed as the data the `UnitAura` bindings read; fires UNIT_AURA and
// drains the right-click cancels. After UiActionPlugin (shares its `Spells` catalog).
.add_plugins(UiAuraPlugin)
// The spellbook window feed (decision 0216 §8, slice 5): builds the book from
// PlayerActions.spells through the Spell.dbc/SkillLine.dbc join and drives
// SpellBookFrame.xml's snapshot + cast-drain seam — the spell SOURCE for the cursor payload
// arc (bags/doll/bars/book). After UiActionPlugin (shares its `Spells` resource + the cast
// tail `send_spell_cast`).
.add_plugins(UiSpellbookPlugin)
// The macro system (decision 0983): the icon chooser's catalog, the `benilla/macros/`
// files, `UPDATE_MACROS`, and the macro→bound-spell table the action bar's MACRO slots
// resolve their cooldown/usability through. After UiSpellbookPlugin — the bound spell is
// resolved against the book that feed pushes, by the same law `CastSpellByName` uses.
.add_plugins(ui_macro::UiMacroPlugin)
// The talent window feed (decision 0304): builds the class pages from Talent.dbc × the
// known-spell set + PLAYER_CHARACTER_POINTS, drives TalentFrame.xml through the engine's
// talent seam, and drains learn clicks into CMSG_LEARN_TALENT. After UiActionPlugin
// (shares its `Spells` catalog), beside the spellbook it mirrors.
.add_plugins(UiTalentPlugin)
// The stance/shapeshift bar feed (wow-re shapeshift-bar-api.md): builds the form list from
// PlayerActions.spells per the byte-verified admission/order, drives StanceBar.xml through
// the engine's shapeshift seam, and drains its clicks (cancel-if-active else cast). After
// UiActionPlugin (shares `Spells`, the `usable` walk, and the cast tail).
.add_plugins(UiShapeshiftPlugin)
// The pet action bar (decision 0982) — the stance bar's mirror image: server-authoritative,
// so this renders the ten packed words the last `SMSG_PET_SPELLS` delivered and sends
// intents back. After UiActionPlugin (shares `Spells` and the cooldown triple's clock).
.add_plugins(UiPetPlugin)
.add_plugins(UiPetBookPlugin)
// The pet's paper-doll stat block (happiness/loyalty/XP/training points). Its own plugin
// because it runs off descriptor fields and two DBC tables rather than off `SMSG_PET_SPELLS`.
.add_plugins(UiPetStatsPlugin)
// The pet paper doll's SHARED surface (decision 1057) — the combat-stats snapshot under the
// `"pet"` token and the page's model booth. Apart from the block above because these values
// pass through the character sheet's own bindings and events, with no hunter gate.
.add_plugins(UiPetDollPlugin)
// The connection-telemetry feed: the averaged ping RTT behind `GetNetStats()`, which the main
// bar's performance meter polls (decision 0658).
.add_plugins(UiNetPlugin)
.add_plugins(UiCastPlugin)
// The breath / fatigue bars (decision 0874): server-authoritative mirror timers off the
// wire into the transcribed MirrorTimer1/2/3 frames. Beside the cast bar it shares its
// feed→drain shape (and its art: the same UI-CastingBar-Border chrome).
.add_plugins(UiMirrorPlugin)
// Floating combat text (decision 0137 phase 2): the WORLDTEXTSTRING law — world-anchored
// damage numbers/outcome words projected into the UI quad pass each frame.
.add_plugins(combat_text::CombatTextPlugin)
// Overhead unit names (nameplates): world-billboard name text over players + NPCs.
.add_plugins(nameplates::NameplatesPlugin)
// Raid-target marker billboards (0434 §6): the mark icon over marked units, one line-pitch
// above the overhead name; plated units show the plate's raid child instead.
.add_plugins(raid_marks::RaidMarksPlugin)
// V-key nameplates (0167): the toggled health-bar plates, a 2-D overlay replacing the
// overhead name on plated units.
.add_plugins(vplates::VPlatesPlugin)
// Chat speech bubbles (0598): the over-the-head bubble a say/yell/party line spawns, the
// plates' 2-D overlay sibling — mutually exclusive with both the plate and the name.
.add_plugins(chat_bubble::ChatBubblePlugin)
// TOGGLEUI (`CTRL-Z`/`Cmd-Z`): the whole quad layer goes dark — frames, minimap, plates,
// bubbles, combat text — leaving the world and the cursor.
.add_plugins(ui_hide::UiHidePlugin)
.add_plugins(UiItemsPlugin)
// The gossip window (decision 0081): fills from the net drain's GossipState and drives
// GossipFrame.xml over the Era gossip API.
.add_plugins(UiGossipPlugin)
// The merchant window (decision 0081 phase 4): fills from the net drain's MerchantOpen and
// drives MerchantFrame.xml over the Era vendor API + the money display.
.add_plugins(UiMerchantPlugin)
// The bank window (decision 0604): the SHOW_BANK session (BankOpen) + the purchase row;
// the vault's slots ride the container feed as bags −1/5..=10.
.add_plugins(UiBankPlugin)
// The mail window (decision 0544 P1/P2): the client-side mailbox session (MailOpen), the
// NPC-session range guard, and MailFrame.xml over the Era mail API (inbox, open-letter,
// send tab).
.add_plugins(UiMailPlugin)
// Player-to-player trade (TradeFrame.xml): the two-sided trade window, driven server-side over
// the P0 wire; the partner's portrait rides the shared "npc" booth (decision 0592 P1).
.add_plugins(UiTradePlugin)
// The item-text reader (ItemTextFrame.xml): right-clicked bag letters (mail-made permanent
// copies) read in the reference reader window over the shared ask-once item-text cache.
.add_plugins(UiItemTextPlugin)
.add_plugins(UiTrainerPlugin)
// The taxi map (decision 0484 phases 1-2): the SMSG_SHOWTAXINODES-fed TaxiState resource, the
// NPC-session range guard, and the TaxiFrame.xml window feed/drain (catalogs, node
// projection/route computation, the activate send, the UnitOnTaxi ride flag).
.add_plugins(UiTaxiPlugin)
.add_plugins(UiTradeSkillPlugin)
.add_plugins(UiCraftPlugin)
// The loot window (decision 0084): fills from the net drain's LootState and drives
// LootFrame.xml over the Era loot API (coin + rows, paging).
.add_plugins(UiLootPlugin)
.add_plugins(UiLootRollPlugin)
// The questgiver window (decision 0088): fills from the net drain's QuestGiver and drives
// QuestFrame.xml's four sub-panels over the Era quest API.
.add_plugins(UiQuestPlugin)
// The quest-log window (decision 0088's deferred second slice): fills from the self player's
// PLAYER_QUEST_LOG descriptor slots + the SMSG_QUEST_QUERY_RESPONSE template cache, and drives
// QuestLogFrame.xml over the Era quest-log API.
.add_plugins(UiQuestLogPlugin)
.add_plugins(UiChatPlugin);
// Terrain streaming: the benilla-assets `AdtTile` pipeline — streams tiles around the player through
// the `AssetServer`, owning the terrain mesh/material/collision, doodads/WMOs, liquid, clutter, and
// loading-screen residency.
app.add_plugins(TerrainPlugin);
// Register benilla-assets' loaders AFTER `AssetPlugin` (they go into the live `AssetServer`).
benilla_assets::register_asset_loaders(&mut app);
// The capture harness drives one deterministic screenshot then exits — added last so it observes
// the fully-built app. Inert unless `$WOW_CAPTURE` is set.
if capturing {
app.add_plugins(capture::CapturePlugin);
}
// The LIVE probe shot (orthogonal to the harness): `WOW_LIVE_SHOT=<png>` on a NORMAL connected
// run writes one screenshot `WOW_LIVE_SHOT_AT` seconds (default 12) after startup and keeps
// running — the agent-side instrument for seeing a live server scene (NPCs, GameObjects, event
// spawns) without a scenario. Pair with `WOW_USER`/`WOW_CHAR` + an outer `timeout`.
if std::env::var("WOW_LIVE_SHOT").is_ok() {
app.add_plugins(capture::LiveShotPlugin);
}
// The probe RIG (decision 0651): `WOW_RIG="tauren druid 60 gear:heal-preraid-bis"` finds-or-
// creates that body on this slot's probe account, logs in as it, and applies level/spells/gear/
// spec/place — the one verb that replaces the hand-assembled GM recipe every session used to
// re-derive (see `capture::ProbeRigPlugin`).
if std::env::var("WOW_RIG").is_ok() {
app.add_plugins(capture::ProbeRigPlugin);
}
// Any scripted probe keeps its window un-occludable: a fully covered macOS window drops to
// ~1 fps drawables, and every probe schedule is wall-clock — a throttled run doesn't measure
// slowly, it runs the wrong script (see `capture::ProbeFocusPlugin`, decision 0906).
// (`WOW_LIVE_FPS` is in the list because an occluded SETTLE phase streams the world at ~1 fps
// and under-warms the scene before sampling even starts — the assertion has to be live from
// the first tick, not at the uncap.)
if [
"WOW_PROBE",
"WOW_PROBE_CHAT",
"WOW_PROBE_KEY",
"WOW_PROBE_LUA",
"WOW_RIG",
"WOW_LIVE_FPS",
]
.iter()
.any(|k| std::env::var(k).is_ok())
{
app.add_plugins(capture::ProbeFocusPlugin);
}
// The probe-chat one-shot: `WOW_PROBE_CHAT=".go xyz …"` sends GM/chat lines once in-world —
// the "park the probe character anywhere" instrument (see `capture::ProbeChatPlugin`).
if std::env::var("WOW_PROBE_CHAT").is_ok() {
app.add_plugins(capture::ProbeChatPlugin);
}
// The probe-lua one-shot: `WOW_PROBE_LUA="CastSpell(…)"` runs a chunk in the live UI VM once
// in-world — the "press the button headlessly" instrument (see `capture::ProbeLuaPlugin`).
if std::env::var("WOW_PROBE_LUA").is_ok() {
app.add_plugins(capture::ProbeLuaPlugin);
}
// The probe-key taps: `WOW_PROBE_KEY="Space@14"` presses keys once in-world — the "press
// space headlessly" instrument for input-gated behavior (see `capture::ProbeKeyPlugin`).
if std::env::var("WOW_PROBE_KEY").is_ok() {
app.add_plugins(capture::ProbeKeyPlugin);
}
// The probe self-termination: `WOW_PROBE_EXIT_AT=<secs>` bounds any scripted live probe's
// lifetime — its own knob, not a rider on the Lua probe (see `capture::ProbeExitPlugin`).
if std::env::var("WOW_PROBE_EXIT_AT").is_ok() {
app.add_plugins(capture::ProbeExitPlugin);
}
// The ray pick: `WOW_PICK="<x>,<y>"` names every surface along the ray through a screenshot
// pixel, nearest first — "what is at the spot `benilla-visual hotspot` flagged, and what is
// right behind it" (see `capture::PickProbePlugin`).
if std::env::var("WOW_PICK").is_ok() {
app.add_plugins(capture::PickProbePlugin);
}
// The render-phase census: `WOW_PHASE=<uniqueId>` reports, per frame, which phase each of one
// placement's batches landed in and where in the draw order — the one thing every scene-side
// instrument is blind to, namely whether a surface was submitted at all (see
// `capture::PhaseProbePlugin`).
if std::env::var("WOW_PHASE").is_ok() {
app.add_plugins(capture::PhaseProbePlugin);
}
// The depth readback: `WOW_DEPTH="<x>,<y>"` reports what depth actually won each named pixel,
// per frame, as a distance in yards — the link past submission that decides the pixel. Pair it
// with `WOW_PICK` at the same pixels to turn "what won" into "whose it was" (see
// `capture::DepthProbePlugin`).
// `WOW_DEPTH_QUADS=<bone>…` is the same readback taken at a particle quad's OWN pixels — the
// moving-subject form, which no hand-written pixel list can hold (see `capture::depth_probe`).
if std::env::var("WOW_DEPTH").is_ok() || std::env::var("WOW_DEPTH_QUADS").is_ok() {
app.add_plugins(capture::DepthProbePlugin);
}
// The bevy_ui node census — "who owns this rectangle" for UI outside the FrameXML quad pass
// (see `capture::NodeProbePlugin`).
if std::env::var("WOW_NODE_PROBE").is_ok() {
app.add_plugins(capture::NodeProbePlugin);
}
// The mid-run window resize: `WOW_PROBE_RESIZE="<secs>:<W>x<H>"` — the headless fullscreen-
// toggle stand-in for resize-reactive layout (see `capture::ProbeResizePlugin`).
if std::env::var("WOW_PROBE_RESIZE").is_ok() {
app.add_plugins(capture::ProbeResizePlugin);
}
// The particle census: `WOW_PARTICLE_CENSUS=<secs>` prints per-emitter live counts once —
// the trace-comparable coverage number (see `capture::ParticleCensusPlugin`).
if std::env::var("WOW_PARTICLE_CENSUS").is_ok() {
app.add_plugins(capture::ParticleCensusPlugin);
}
// The entity census: `WOW_ENTITY_CENSUS=<secs>` prints per-archetype entity counts once —
// what the resident entity count is made of (see `capture::EntityCensusPlugin`).
if std::env::var("WOW_ENTITY_CENSUS").is_ok() {
app.add_plugins(capture::EntityCensusPlugin);
}
// The melee live probe: `WOW_PROBE=melee` auto-fights the nearest enemy so the dbg-trace
// sink can record the combat-text timeline (see `capture::ProbeMeleePlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("melee") {
app.add_plugins(capture::ProbeMeleePlugin);
}
// The partner live probe: `WOW_PROBE=partner` auto-accepts group invites — the party arc's
// second-client instrument (decision 0434; see `capture::ProbePartnerPlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("partner") {
app.add_plugins(capture::ProbePartnerPlugin);
}
// The sea-crossing live probe: `WOW_PROBE=crossing` boards a cross-continent boat and reports
// the map seam surviving — decision 0455's instrument (see `capture::ProbeCrossingPlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("crossing") {
app.add_plugins(capture::ProbeCrossingPlugin);
}
// The taxi-flight live probe: `WOW_PROBE=taxi` opens the flight-master menu on the real wire
// and rides Stormwind → Sentinel Hill to a measured verdict — decision 0484's end-to-end
// instrument (see `capture::ProbeTaxiPlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("taxi") {
app.add_plugins(capture::ProbeTaxiPlugin);
}
// The mail-arc live probe: `WOW_PROBE_MAIL=1` GM-mails the probe's own character, opens the
// Goldshire mailbox on the real wire, and drives the inbox/take/send/delete surface through
// the live Lua VM — decisions 0544/0548's end-to-end instrument (see `capture::ProbeMailPlugin`).
if std::env::var("WOW_PROBE_MAIL").is_ok() {
app.add_plugins(capture::ProbeMailPlugin);
}
// The bank-arc live probe: `WOW_PROBE_BANK=1` GM-hops to a pure banker, drives the whole
// six-opcode bank wire (activate/deposit/withdraw/buy-slot/refusal) — decision 0604's
// end-to-end instrument (see `capture::ProbeBankPlugin`).
if std::env::var("WOW_PROBE_BANK").is_ok() {
app.add_plugins(capture::ProbeBankPlugin);
}
// The cast-cancel live probe: `WOW_PROBE=castcancel` hearths and presses W mid-cast — the
// local self-cancel's end-to-end timing instrument (see `capture::ProbeCastCancelPlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("castcancel") {
app.add_plugins(capture::ProbeCastCancelPlugin);
}
// The char-create live probe: `WOW_PROBE_CHARCREATE="<name>[,race,class,gender,…]"` creates (and
// cleans up) a character at select to verify the char-create/delete wire (decision 0423 phase 1;
// see `capture::ProbeCharCreatePlugin`).
if std::env::var("WOW_PROBE_CHARCREATE").is_ok() {
app.add_plugins(capture::ProbeCharCreatePlugin);
}
// The live FPS probe: `WOW_LIVE_FPS=<frames>` samples frame times on a NORMAL connected run
// and exits — the harness probe's numbers with the live world in (see `capture::LiveFpsPlugin`).
if std::env::var("WOW_LIVE_FPS").is_ok() {
app.add_plugins(capture::LiveFpsPlugin);
}
// The FPS journal: `WOW_FPS_JOURNAL=<csv>` appends per-second position + frame-time rows on a
// director-driven run — "where does it dip" as coordinates (see `perf::FpsJournalPlugin`).
if std::env::var("WOW_FPS_JOURNAL").is_ok() {
app.add_plugins(perf::FpsJournalPlugin);
}
// Return the app's own exit status instead of dropping it: a failed capture writes
// `AppExit::error()` (see `capture::drive_capture`), and discarding it made the process exit 0
// with no PNG on disk — which is how a sweep carried on around a missing shot (decision 0743).
app.run()
}