Files
benilla-octo/crates/benilla-app/src/lib.rs
T
gromandClaude Opus 5 ecee994ab6 app+ui: the player's own cast state, and SPELL_CAST_EVENT off the commit
`GetCurrentCastingInfo` and `GetCastInfo` are the two most-called
nampower functions in grom's corpus — 25 and 20 call sites outside
`NampowerAPI.lua` — and one of them calls with no existence guard at
all: `local _,_,_,_,_,_,autoattack = GetCurrentCastingInfo()`
(SuperCleveRoidMacros/Core.lua:94), in the frame that keeps clearing the
target while an autoattack is queued. On this client that line raised.

The earlier doc row said the client had "a spell id, a deadline and a
guard bit — no start instant, no channel state, no GCD end". That was
true of `PendingCast` and wrong about the tree: `ui_cast_events.rs`
already folds a cast window per caster off the same packets, with a
start, an end, a channel bit and the accumulated pushback, behind
`C_Spell.UnitCastingInfo`. What was actually missing was the caster's
own machinery — the reference's cast globals, which have no descriptor
field anywhere — and the GCD, which `spell/cooldowns.rs` has run
correctly all along and never published.

So `GetCastInfo` folds two halves: its timing columns come from the
player's own window, its identity and GCD columns from a new per-tick
`SelfCast` snapshot. The feed declares itself after the window drain, so
the two halves can never describe different casts. Added:

- `SelfCast` (benilla-ui) — the inflight spell `0xceca88`, the bar's
  spell `0xceac48`, the auto-repeat key `0xceac30`, casting/channeling/
  onswing/autoattack, the cast counter, the cast type, the target, and
  the GCD's start and end on the GetTime clock.
- `ui_self_cast.rs` (benilla-app) — the push, plus `SPELL_CAST_EVENT`.
- `Cooldowns::gcd` — the running global cooldown as one timer. `info`
  still answers the per-category question every cooldown swirl asks;
  this answers "when does the GCD end", which has no spell to key on
  because its consumers divide it (`API.GetGCDRemainingMs`) or compare
  it against seconds (`[gcd:<1]`).
- `PendingCast`'s cast counter, in the one place every class of commit
  already passes through.

Three decisions where the tempting answer was the wrong one:

- `GetCastInfo` is **nil** when nothing is casting, and an instant's GCD
  does not count. Answering a table whenever the GCD is up would make
  the consumer's fast path always hit — and `API.IsCasting()` *is*
  `GetCastInfo() ~= nil`, so `[casting]` would read true for a second
  and a half after every instant. The wrapper's own comment says what to
  do instead ("GetSpellIdCooldown … works for instant casts").

- `castId` is two different numbers under one upstream name: a counter
  in `GetCastInfo`'s table, beside a separate `spellId`; a **spell id**
  in `GetCurrentCastingInfo`'s returns, which is how both of its readers
  treat it (Init.lua:89 stores it as castingSpellId, Core.lua:5427 hands
  it to C_Spell.GetSpellName). Each is implemented as what its own
  consumer reads, and both are spelled out at the binding.

- `SPELL_CAST_EVENT` fires for commits, not refusals. Upstream's
  `success` can be 0; this client's local refusals funnel through
  `CastErrors`, which carries a spell id and a reason byte and neither a
  cast type nor a target — a row from there would invent two of five
  fields. Nothing in the corpus reads the failing branch
  (Core.lua:5177 is one `if success == 1 then` with no else). Written
  down at the type rather than left to be found.

Tests run the consumers' own bodies transcribed, as `cast_info.rs` next
door does: `API.GetCastInfo` with its `GetCurrentCastingInfo` fallback
arm, `API.IsCasting`, `API.GetGCDRemainingMs`, `API.GetCastRemainingMs`
and `CleveRoids.UpdateCastingState`. A positional seven-return shape and
a ten-key table are only correct relative to the code that reads them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-24 07:16:30 -06:00

467 lines
18 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. It opens the patch chain from
//! the install (`$WOW_DATA`, the project folder on a dev build, else beside the binary), streams
//! the world around the player through the `benilla-assets` `mpq://` pipeline, and runs the network
//! session on a background thread ([`net`]).
//!
//! The world is loaded when a character enters it and released when they leave; the glue screens
//! have no world behind them, so with no server the client sits at the login screen, as the
//! reference does. The capture harness (`$WOW_CAPTURE`) boots straight in-world.
// A player build's dead code is the seam working: with `--no-default-features` every symbol only an
// instrument calls goes unused, and a `cfg` per item would spread seam knowledge into gameplay
// modules. Dev builds warn normally.
#![cfg_attr(not(feature = "dev"), allow(dead_code))]
/// The realtime-audio allocation tripwire: in debug builds an allocation inside `sound::output`'s
/// `no_alloc` scopes (the output IO callback, the render pass) aborts the process; release builds
/// carry no wrapper.
#[cfg(debug_assertions)]
#[global_allocator]
static ALLOC: assert_no_alloc::AllocDisabler = assert_no_alloc::AllocDisabler;
pub mod addon_harness;
mod area;
mod area_poi;
mod area_trigger;
#[cfg(feature = "dev")]
mod asset_churn;
mod aura_visual;
mod autologin;
mod bindings;
mod blob_shadow;
mod bowstring;
mod camera_shake;
#[cfg(feature = "dev")]
mod capture;
mod challenges;
mod char_create;
mod char_select;
mod chat_bubble;
mod chr_classes;
mod cinematic;
mod combat_log;
mod combat_text;
mod console;
mod crash;
mod creature_anim;
mod cursor;
mod cvars;
mod death;
#[cfg(feature = "dev")]
mod debug_panel;
/// The dev/player seam: the instrument groups and the boundary rule. Always compiled; what it holds
/// is not.
mod dev;
mod doodad_events;
/// MONKEY (dynamic interiors): the cvar → benilla-world bridge for the fixture-lit interior lane.
mod dynamic_interior;
mod ui_chr_classes;
mod ui_chr_races;
// MONKEY (volumetric fog): optional near-field atmosphere plugin.
mod volumetric_fog;
// GFX (volumetric light): shadow-mapped sun/moon light shafts.
mod volumetric_light;
// MONKEY (p0 graphics programme): the programme's cvar bridges (skyDither, ...).
mod monkey_gfx;
// MONKEY (post): optional world-only HDR post-processing.
mod post;
// MONKEY (sky): the skyQuality cvar bridge.
mod sky_quality;
// MONKEY (ao): optional screen-space ambient occlusion plugin.
mod ssao;
// MONKEY (skybox): the zoneSkyboxes cvar bridge.
mod character_shadow;
mod entities;
mod fishing_line;
mod footprints;
mod game_plugins;
mod glue;
mod glue_strings;
mod go_anim;
mod go_templates;
mod gpu_errors;
#[cfg(feature = "dev")]
mod hover_log;
mod items;
mod loading_screen;
mod local_state;
mod login;
mod minimap;
mod nameplates;
mod names;
mod net;
mod npc_text;
mod pending_item_ops;
/// Ships in part (the FPS journal and the clocks it reads); the rest is `dev`.
mod perf;
mod pipe_warm;
mod player;
mod poi_marker;
mod portrait;
#[cfg(feature = "dev")]
mod preflight;
#[cfg(feature = "dev")]
mod probe_shield;
mod query_cache;
mod quest_markers;
mod raid_marks;
mod ranged_flex;
mod realm_select;
mod realmlist;
mod run_mode;
mod screen_fade;
mod screenshot;
mod shaders;
mod shadow_core;
mod zone_skybox;
mod game_tip;
mod item_persist;
mod name_persist;
mod opaque2d;
/// Where "the client is going down" is observed, in `Last`; every system that persists state on the
/// way out registers through it.
mod shutdown;
mod smart_rect;
mod sound;
/// The two talent spell-modifier tables (`SMSG_SET_FLAT_/PCT_SPELL_MODIFIER`) and the read that
/// puts them on a number.
mod spell;
/// The melee swing refusal's latch + 4 s repeat (`SMSG_ATTACKSWING_*`).
mod swing_refusal;
mod target;
#[cfg(test)]
pub(crate) mod test_support;
mod text_filter;
mod text_reshape;
mod textinput;
mod torch_shadow;
mod transport;
mod tutorial;
mod ui_action;
mod ui_auction;
mod ui_aura;
mod ui_bank;
mod ui_battlefield;
mod ui_battlefield_positions;
mod ui_battlefield_score;
mod ui_bind_confirm;
mod ui_binder;
mod ui_cast;
mod ui_cast_events;
mod ui_char;
mod ui_chat;
mod ui_craft;
mod ui_dialog_verbs;
mod ui_dressup;
mod ui_duel;
mod ui_follow;
mod ui_gamma;
mod ui_gm_ticket;
mod ui_gossip;
mod ui_guild;
mod ui_hide;
mod ui_honor;
mod ui_inspect;
mod ui_instance;
mod ui_item_text;
mod ui_items;
mod ui_layout;
mod ui_logout;
mod ui_loot;
mod ui_loot_roll;
mod ui_macro;
mod ui_mail;
mod ui_merchant;
mod ui_mirror;
mod ui_models;
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_petition;
mod ui_quest;
mod ui_quest_log;
mod ui_quest_share;
mod ui_reputation;
mod ui_saved;
pub mod ui_script;
mod ui_self_cast;
mod ui_session;
mod ui_shapeshift;
mod ui_social;
mod ui_spellbook;
mod ui_stable;
mod ui_summon;
mod ui_tabard;
mod ui_talent;
mod ui_talent_wipe;
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 video;
mod vplates;
mod weapon_trail;
mod world_backdrop;
mod world_shadow;
mod world_state;
mod world_state_ui;
use bevy::prelude::*;
// The `benilla` launcher shim stamps the build id at compile time and hands it to [`run`];
// re-exported so the shim needs no bevy dependency of its own.
pub use benilla_world::build_id::BuildId;
/// The world viewer's entry point, the engine with no game attached, called by the
/// `benilla-worldview` shim.
pub use benilla_world::worldview::run as run_worldview;
pub use bevy::app::AppExit;
/// Builds and runs the client app. `build` is the launcher's compile-time git stamp, passed in as
/// data so the sha lives in the shim's fingerprint and a commit does not recompile this crate.
pub fn run(build: BuildId) -> AppExit {
// `WOW_HOVER_LOG_REPORT=<csv>` and `WOW_CAPTURE=list` print and exit before any setup.
if let Ok(path) = std::env::var("WOW_HOVER_LOG_REPORT") {
dev::report_recorded_hover_log(&path);
return AppExit::Success;
}
if std::env::var("WOW_CAPTURE").as_deref() == Ok("list") {
dev::print_scenario_names();
return AppExit::Success;
}
// `WOW_PROBE=list` likewise prints the probe environment registry.
if std::env::var("WOW_PROBE").as_deref() == Ok("list") {
dev::print_probe_vars();
return AppExit::Success;
}
// From here a panic leaves `benilla-config/Diagnostics/crash-<unix>.txt`; armed before the
// `App` exists, so a panic while plugins build is reported too.
crash::install(build);
let mut app = App::new();
// The panel footer and the preflight banner read the stamp back.
app.insert_resource(build);
// Static-scene transform tracking pinned on: the default threshold re-decides every frame with
// two full scans of the rows the tracking exists to skip, and this scene is static-heavy. It
// costs more only when most rows move in one frame (a load burst into a near-empty world).
app.insert_resource(bevy::transform::systems::StaticTransformOptimizations::enabled());
// Update runs single-threaded: most per-frame Update systems are non-Send (mlua's `UiScript`,
// kira's audio handles) and serialize through the multi-threaded executor anyway, so its
// dispatch is pure overhead (measured faster, tails flat). `WOW_MT_UPDATE=1` restores it.
if std::env::var_os("WOW_MT_UPDATE").is_none() {
app.edit_schedule(Update, |s| {
s.set_executor_kind(bevy::ecs::schedule::ExecutorKind::SingleThreaded);
});
}
// PostUpdate too: 208 systems paid about 10 µs each of multi-threaded dispatch, and
// single-threaded measured faster parked and in motion. `WOW_MT_POSTUPDATE=1` restores it; the
// log line names the config a run measured.
if std::env::var_os("WOW_MT_POSTUPDATE").is_none() {
app.edit_schedule(PostUpdate, |s| {
s.set_executor_kind(bevy::ecs::schedule::ExecutorKind::SingleThreaded);
});
println!(
"executor: PostUpdate -> single-threaded (the default; WOW_MT_POSTUPDATE=1 for MT)"
);
}
// The build banner ships in every build: which build produced a log is the first thing a report
// from another machine must establish.
app.add_systems(Startup, benilla_world::build_id::banner);
// With `$WOW_CAPTURE` set the app runs a deterministic, server-less capture (net off, so no
// NPCs stream in) and exits.
let capturing = run_mode::scenario_active();
// Every instrumented run, captures and live probes, opens in the background so it never takes
// over a person's screen; `WOW_BG` overrides.
let background = benilla_world::bgwin::background_run();
if capturing {
// Ground clutter scatters with per-run randomness, so captures turn it off for byte-stable
// baselines unless a capture asks for it (MONKEY wind does); set before plugins build so
// `ClutterConfig::default` reads it. The frame clock, frozen in `capture`, is the other
// source of drift.
if std::env::var_os("WOW_CLUTTER_DENSITY").is_none() {
std::env::set_var("WOW_CLUTTER_DENSITY", "0");
}
// Anim-LOD park/wake (`creature_anim::lod::gate_rig_animation`) hangs on asset-load timing,
// which the frozen clock does not control, so captures turn it off unless overridden. A rig
// in frame should never be parked, so the shot keeps its subject.
if std::env::var("WOW_NO_ANIM_LOD").is_err() {
std::env::set_var("WOW_NO_ANIM_LOD", "1");
}
}
// The `mpq://` source must be registered before `AssetPlugin` (in `DefaultPlugins`) builds. The
// install is found by `benilla_formats::wow_data`, as everywhere; without one the source is
// absent and terrain loads fail gracefully.
match benilla_formats::wow_data() {
Some(data_dir) => {
if let Err(e) = benilla_assets::register_mpq_source(&mut app, &data_dir) {
eprintln!("benilla-assets: mpq:// source unavailable ({e:#})");
}
}
None => eprintln!(
"benilla: no WoW install found — looked in {:?}",
benilla_formats::candidates()
),
}
// No `game://` source: the five UI shaders are compiled in by `crate::shaders`
// (`embedded://benilla_app/shaders/…`), so no build-machine path reaches the binary.
app.add_plugins(benilla_world::boot::tuned_default_plugins(Window {
title: "benilla".into(),
// Born in the player's display mode (`gxWindow` read straight off `config.toml`) rather
// than flipped into it at `Startup`, which would flash on every launch and, under
// gamescope, spend the first second in the input state fullscreen is meant to end.
// Instrumented runs stay windowed (`video::windowed_env`).
mode: video::boot_window_mode(),
// UI-fixture captures size the window to what they photograph: the action bar's 1024 px
// plus 128 px end caps get a wide, short window, and vplates pins 1024×768, where one gx
// unit is 1280 px and the plate lands at the border texture's native 128×32, diffable
// against the BLP.
resolution: video::at_requested_dpi(
// Same opt-in as the UI load (`ui_script::lifecycle::ui_wanted`); the two must agree,
// or the right content is captured at the wrong size.
if capturing && crate::run_mode::capture_ui_opted_in() {
// `$WOW_WIN` overrides here too, for scale-dependent UI bugs.
if let Some(win) = video::requested_window_size() {
win
} else {
match std::env::var("WOW_CAPTURE").as_deref() {
Ok("ui-actionbar") => UVec2::new(1300, 260),
Ok("vplates") => UVec2::new(1024, 768),
// Short enough for the action bar to overlap the chat edit box.
Ok("ui-chatedit") => UVec2::new(566, 377),
// The map's chrome is a centered 1024×768 block, with margin.
Ok("ui-worldmap") => UVec2::new(1100, 800),
// Margin keeps the 920×724 window's edge tile and close X 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) overrides the size. FFXGlow's blur is pinned in
// texels, so thin bright features self-amplify at high resolution; matching the
// reference's pixel density (`WOW_WIN=512x288` on a 2× display is 1024×576)
// isolates that term.
video::requested_window_size()
// A run that reads no pixels gets a small window: it is held `AlwaysOnTop`
// against the occlusion throttle (`capture::ProbeFocusPlugin`), and small and
// cornered keeps it out of the way. Pixel runs keep the full size.
.unwrap_or(if benilla_world::bgwin::no_pixel_run() {
UVec2::new(640, 360)
} else {
// The player's `gxResolution`; `bevy_winit` ignores it while fullscreen.
video::boot_windowed_size()
})
.into()
},
),
// `$WOW_NOVSYNC=1` uncaps presentation from boot so a headless FPS-journal run measures
// frame cost, not the vsync ceiling; otherwise the player's `gxVSync` takes over from
// `Startup` ([`crate::video`]).
present_mode: video::present_mode(!video::novsync_env()),
// A background run opens unfocused, so it cannot take keystrokes meant for another app, and
// is born `AlwaysOnBottom` (`kCGNormalWindowLevel - 1`), so winit's two raises before the
// first frame cannot flash it on top. `BgWinPlugin` promotes it to Normal once the launch
// settles, so it can still be raised.
focused: !background,
window_level: if background {
bevy::window::WindowLevel::AlwaysOnBottom
} else {
bevy::window::WindowLevel::Normal
},
..default()
}))
.add_plugins(benilla_world::thread_qos::ThreadQosPlugin)
// Undoes winit's forced macOS app activation for background runs; the window-side half is the
// `Window` above.
.add_plugins(benilla_world::bgwin::BgWinPlugin)
// macOS `Cmd+Q` goes straight to `terminate:`, which never runs another frame, so the session
// was never written; re-pointed at the window close, which [`shutdown`] sees.
.add_plugins(benilla_world::mac_quit::MacQuitPlugin)
// The engine as one group; `world_plugins.rs` has its load-bearing ordering edges and what it
// leaves out (`pipe_warm`).
.add_plugins(benilla_world::world_plugins::WorldPlugins)
// The instruments, which the engine group does not carry; `--no-default-features` compiles them
// all out (see `dev.rs`).
.add_plugins(dev::DevToolsPlugin)
// The FPS journal ships: `/console fpsJournal 1` appends a per-second row of position, frame
// cost and the GPU's per-pass split to `benilla-config/Diagnostics/fps-journal.csv`;
// `WOW_FPS_JOURNAL=<csv>` is the harness lever.
.add_plugins(perf::FpsJournalPlugin)
// The game as one group on top of the engine; `game_plugins.rs` has its members, its
// load-bearing ordering edges and the test that builds it headless.
.add_plugins(game_plugins::GamePlugins {
connect: !capturing,
start: run_mode::start_state(),
});
// The compiled-chunk cache, before anything loads a chunk: a UI load's biggest removable cost
// is the Lua compile, and a compile is the same answer every time (`ui_script::chunk_cache`).
benilla_ui::script::install_chunk_cache(
Box::new(ui_script::chunk_cache::LuaChunkCache::open()),
);
// The session's own log file, opened once the subscriber exists so the backlog replays into
// it. A player who sees something wrong for two seconds and quits can only be answered from
// what the client said at the time, and until now that lived in the terminal it was launched
// from — which, for a player who double-clicks the binary, is nowhere. `WOW_CAPTURE` runs
// resolve no state folder and so write none.
if let Some(path) = local_state::rotate_session_logs() {
match benilla_world::log_ring::attach_file(&path) {
Ok(replayed) => info!(
"log: this session also writes {} ({replayed} boot lines replayed, cap {} MiB)",
path.display(),
benilla_world::log_ring::FILE_CAP_BYTES / (1024 * 1024)
),
Err(e) => warn!("log: cannot write {}: {e}", path.display()),
}
}
// benilla-assets' loaders go into the live `AssetServer`, so they register after `AssetPlugin`.
benilla_assets::register_asset_loaders(&mut app);
// `ExtractSchedule` runs single-threaded too: bevy_render leaves it multi-threaded, with no
// non-Send member, and single-threaded measured faster. `WOW_MT_EXTRACT=1` restores it.
//
// `Render` stays multi-threaded: under pipelined rendering that executor is also bevy's route
// for non-Send systems to the main thread, and `create_surfaces` needs it because macOS makes a
// Metal layer only on the UI thread (the single-threaded executor panics at startup).
//
// Set here because the render sub-app exists only once the plugin chain has built.
if std::env::var_os("WOW_MT_EXTRACT").is_none() {
if let Some(render_app) = app.get_sub_app_mut(bevy::render::RenderApp) {
render_app.edit_schedule(bevy::render::ExtractSchedule, |s| {
s.set_executor_kind(bevy::ecs::schedule::ExecutorKind::SingleThreaded);
});
println!(
"executor: ExtractSchedule -> single-threaded (the default; WOW_MT_EXTRACT=1 for MT)"
);
} else {
// A missing sub-app would silently flip nothing.
eprintln!("executor: no render app — ExtractSchedule flip NOT applied");
}
}
// The probe fleet, last so it observes the fully-built app; compiled out by
// `--no-default-features`.
app.add_plugins(dev::DevProbesPlugin);
// Returns the app's exit status: a failed capture sets `AppExit::error()`
// (`capture::drive_capture`), which must not exit 0 with no PNG on disk.
app.run()
}