settings live in one registry, and the net drain starts to peel

- the CVars are one engine-side table that outlives every interface reload: a change fires its observer, the eight latched rows wait for RestartGx, and /console gains set, cvar_reset, cvar_default, cvarlist and help (2303)
- every push into the interface runs in one feed phase after the net drain and before the tick, held by a structural test on the built schedule (2304)
- subsystems own their packet handlers, dispatched in packet order; the auction house is the first family out of the 273-arm drain (2305)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-17 16:47:09 +07:00
co-authored by Claude Fable 5.1
parent a1355ea02b
commit edec5b2207
117 changed files with 3844 additions and 2067 deletions
Generated
+23
View File
@@ -624,6 +624,7 @@ dependencies = [
"num-bigint",
"rand 0.8.6",
"sha1",
"strum",
]
[[package]]
@@ -6138,6 +6139,28 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "svg_fmt"
version = "0.4.5"
+3 -2
View File
@@ -221,9 +221,10 @@ impl Plugin for BindingsPlugin {
.chain()
.in_set(crate::ui_script::UiInput)
.in_set(BindingSet)
.before(benilla_world::schedule::WorldStage::Input)
.in_set(InWorldGated),
drain_binding_requests,
// After the tick: the requests are Lua's own (`SaveBindings`, `RunBinding`),
// queued by handlers the tick dispatched, and a save must not wait a frame.
drain_binding_requests.after(crate::ui_script::UiInput),
),
);
}
+2 -1
View File
@@ -996,7 +996,8 @@ fn hold_clock(mut clock: ResMut<Time<Virtual>>) {
/// The three scene-population queries the `FPS_PROBE` line prints — bundled because they are one
/// concern (how much world is resident, and how much of it survived the cull) and because
/// `drive_capture` sits against Bevy's 16-parameter ceiling, which `cvars::KnobParams` hit first.
/// `drive_capture` sits against Bevy's 16-parameter ceiling, which the CVar host's old knob
/// bundle hit first (retired by 2303).
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct ProbeCensus<'w, 's> {
particles: Query<'w, 's, &'static benilla_world::particles::ParticleEmitter>,
+34 -27
View File
@@ -516,13 +516,13 @@ fn status_label<'a>(strings: &'a GlueStrings, token: &'a str) -> &'a str {
}
/// Flip the *Load out of date AddOns* box: the `checkAddonVersion` CVar **inverted** (1292 §2,
/// byte-verified — ticked = `"0"`). Written through [`UiScript::set_cvar_engine`] so it rides the
/// change queue like a Lua `SetCVar` and the host's sync persists it (the minimap-zoom pattern) —
/// nothing else to do: the statuses repaint from [`drive_addons_panel`]'s per-frame mirror, no
/// rescan, because the gate re-reads the flag per query (1292 §2.2).
fn toggle_force_load(script: &mut UiScript) {
let checking = script.cvar("checkAddonVersion").is_none_or(|v| v != "0");
script.set_cvar_engine("checkAddonVersion", if checking { "0" } else { "1" });
/// byte-verified — ticked = `"0"`). A host write into the registry (2303), which is what
/// persists it and what the VM's mirror learns — nothing else to do: the statuses repaint from
/// [`drive_addons_panel`]'s per-frame mirror, no rescan, because the gate re-reads the flag per
/// query (1292 §2.2).
fn toggle_force_load(cvars: &mut crate::cvars::Cvars) {
let checking = cvars.addon_version_check();
cvars.set("checkAddonVersion", if checking { "0" } else { "1" });
}
/// The panel's clickable parts.
@@ -578,7 +578,8 @@ pub(super) fn drive_addons_panel(
art: Res<GlueArt>,
assets: Res<AssetServer>,
strings: Option<Res<GlueStrings>>,
mut script: Option<NonSendMut<UiScript>>,
script: Option<NonSendMut<UiScript>>,
mut cvars: ResMut<crate::cvars::Cvars>,
keys: Res<ButtonInput<KeyCode>>,
mouse: Res<ButtonInput<MouseButton>>,
mut wheel: MessageReader<bevy::input::mouse::MouseWheel>,
@@ -641,9 +642,7 @@ pub(super) fn drive_addons_panel(
AddonsAction::ForceLoad => {
// No VM (a bare test world / a capture): nothing to write to and nothing the
// walk would read differently — the box is inert, honestly.
if let Some(script) = script.as_deref_mut() {
toggle_force_load(script);
}
toggle_force_load(&mut cvars);
}
}
}
@@ -2007,28 +2006,36 @@ mod tests {
let _ = std::fs::remove_dir_all(&tmp);
}
/// The force-load box IS the `checkAddonVersion` CVar inverted, and a toggle goes through
/// the engine write ([`UiScript::set_cvar_engine`]) so it rides the change queue — that is
/// the whole persistence story (the host's sync drains the queue and dirties the config);
/// the panel itself only repaints from its per-frame mirror, rescanning nothing (1292 §2.2).
/// The force-load box IS the `checkAddonVersion` CVar inverted, and a toggle is a host
/// write into the registry (2303) — that is the whole persistence story (the write dirties
/// the config and reaches the VM's mirror through the outbox); the panel itself only
/// repaints from its per-frame mirror, rescanning nothing (1292 §2.2).
#[test]
fn the_force_load_box_flips_the_cvar_through_the_engine_queue() {
let mut script = UiScript::new().unwrap();
script.register_cvars([("checkAddonVersion", "1")]);
fn the_force_load_box_flips_the_cvar_in_the_registry() {
let mut cvars = crate::cvars::Cvars::default();
assert!(
cvars.addon_version_check(),
"the registrar default: check ON"
);
toggle_force_load(&mut script);
toggle_force_load(&mut cvars);
assert_eq!(
script.take_cvar_changes(),
vec![("checkAddonVersion".to_string(), "0".to_string())],
cvars.get("checkAddonVersion"),
Some("0"),
"tick: check ON (\"1\") flips to \"0\" — box ticked = version gate open"
);
assert_eq!(script.cvar("checkAddonVersion").as_deref(), Some("0"));
toggle_force_load(&mut script);
assert!(!cvars.addon_version_check());
assert_eq!(
script.take_cvar_changes(),
vec![("checkAddonVersion".to_string(), "1".to_string())],
"untick: back to the registrar default, and the queue carries it again"
cvars.take_events().len(),
1,
"a host write is an accepted move — the config dirties and the mirror learns it"
);
toggle_force_load(&mut cvars);
assert_eq!(
cvars.get("checkAddonVersion"),
Some("1"),
"untick: back to the registrar default"
);
}
+44 -120
View File
@@ -173,6 +173,11 @@ impl Plugin for CharSelectPlugin {
)
.chain()
.before(crate::glue::GlueVisuals)
// After the UI tick: one member holds the VM (the addons panel reads
// the manifest through it), and every VM holder in `Update` declares
// its side of the tick (decision 2304). A glue screen has no push the
// tick must see, so the whole chain takes the drain side.
.after(crate::ui_script::UiInput)
.run_if(in_state(ClientState::CharSelect)),
)
.chain()
@@ -407,9 +412,9 @@ fn last_character_row(value: &str) -> Option<usize> {
/// Out of range falls back to the **first** row, never to the nearest one: a character deleted
/// since you last played, or a realm with fewer characters, puts you at the top of the list rather
/// than beside where the old row used to be.
fn remembered_row(persist: &crate::cvars::CvarPersist, len: usize) -> usize {
persist
.stored(CVAR_LAST_CHARACTER)
fn remembered_row(cvars: &crate::cvars::Cvars, len: usize) -> usize {
cvars
.get(CVAR_LAST_CHARACTER)
.and_then(last_character_row)
.filter(|&row| row < len)
.unwrap_or(0)
@@ -423,42 +428,28 @@ fn remembered_row(persist: &crate::cvars::CvarPersist, len: usize) -> usize {
/// and clicks and arrow keys reach `0x472740` without ever going near it. [`Roster::pending_pick`]
/// is exactly that moment for us, so [`Roster::pending_index`] is what this reads.
///
/// The write goes through [`UiScript::set_cvar_engine`] so it rides the change queue like a Lua
/// `SetCVar` and the host's sync persists it — the minimap-zoom pattern (1131), already used from
/// this screen by the AddOns panel's force-load box (1293). **One divergence, stated:** the
/// The write is a host write into the registry ([`crate::cvars::Cvars::set`]), which is what
/// persists it and what the VM's mirror learns (2303) — the shape the AddOns panel's force-load
/// box uses from this same screen (1293). **One divergence, stated:** the
/// reference flushes `Config.wtf` synchronously in the same call (`0x46b6f6`), while ours reaches
/// disk on the exit edge with every other CVar (1528) — so a crash between entering the world and
/// quitting loses the memory, where the reference would not. That is the CVar store's shape, not
/// this key's, and changing it is an autosave design (1528's own "what this does NOT fix").
fn persist_last_character(
roster: Res<Roster>,
mut script: Option<NonSendMut<benilla_ui::script::UiScript>>,
// Memory about the VM's CVar table, so it dies with the VM (decision 1290). A bare `Local`
// here would be betting that `cvars::sync_cvars`'s per-VM seed carries this key into the next
// table — true today, and exactly the "correct against one VM, silently wrong against the
// next" shape 1290 built its structural gate to refuse. `get_for` because this system also
// runs while there is no VM at all.
mut mirrored: Local<crate::ui_script::VmMemo<Option<usize>>>,
mut cvars: ResMut<crate::cvars::Cvars>,
// A plain `Local` is honest here since 2303: the registry outlives every VM, so the row
// written once stays written — the per-VM memo this used to need was betting on the seed.
mut mirrored: Local<Option<usize>>,
) {
let Some(row) = roster.pending_index() else {
return; // nobody is entering the world — nothing to remember
};
let mirrored = mirrored.get_for(script.as_deref());
if *mirrored == Some(row) {
return;
}
let Some(script) = script.as_deref_mut() else {
return;
};
script.set_cvar_engine(CVAR_LAST_CHARACTER, &last_character_value(row));
// Latch only once the table actually took it. An engine write to a name the host has not
// registered yet is a deliberate silent no-op (`script::cvars::set_from_engine`), and the
// per-VM seed that registers it runs in `cvars::sync_cvars` — a sibling `Update` system with
// no ordering against this one. Latching on the frame the write was dropped would swallow
// exactly one entry, and it would be the session's first.
if script.cvar(CVAR_LAST_CHARACTER).is_some() {
*mirrored = Some(row);
}
cvars.set(CVAR_LAST_CHARACTER, &last_character_value(row));
*mirrored = Some(row);
}
/// Ask the parked IO thread to log in as `guid` (the pick channel) and remember it as pending.
@@ -478,11 +469,11 @@ fn apply_roster_policy(
mut msgs: MessageReader<CharListMessage>,
mut roster: ResMut<Roster>,
pick: Res<CharPick>,
// The remembered row (decision 1622) — read off the persist state rather than the VM's table
// because it is a value the *file* owns and the session only mirrors, and because reading it
// The remembered row (decision 1622) — read off the registry rather than the VM's table
// because the registry is the store that outlives every VM (2303), and because reading it
// here keeps this system send-able. It is consulted exactly once per process: `selected` is
// `None` only before the first roster lands.
persist: Res<crate::cvars::CvarPersist>,
cvars: Res<crate::cvars::Cvars>,
// Is the character pick already spoken for? Present only when a rig is driving this run
// (decision 1174's always-present run fact) — absent in every ordinary run, which is the
// player answer and the one this screen was written for.
@@ -543,7 +534,7 @@ fn apply_roster_policy(
// reference reads the CVar in the char-list rebuild itself (`0x4724d0` → `0x472740`),
// not once at startup, so the screen always opens on whoever you last entered the
// world as. Out of range falls back to the first row; see `remembered_row`.
let row = remembered_row(&persist, roster.chars.len());
let row = remembered_row(&cvars, roster.chars.len());
// Greppable, and it names the character rather than only the index: "the first row
// happened to be right" and "the memory worked" are the same picture on screen, and
// this line is the only thing that tells them apart in a log.
@@ -1448,8 +1439,8 @@ mod tests {
.init_resource::<Roster>()
.insert_resource(CharPick(tx))
.insert_resource(match stored {
Some(v) => crate::cvars::CvarPersist::with_stored(CVAR_LAST_CHARACTER, v),
None => crate::cvars::CvarPersist::default(),
Some(v) => crate::cvars::Cvars::with_value(CVAR_LAST_CHARACTER, v),
None => crate::cvars::Cvars::default(),
})
.add_message::<CharListMessage>()
.add_message::<AppExit>()
@@ -1571,10 +1562,7 @@ mod tests {
app.add_plugins(MinimalPlugins)
.init_resource::<Roster>()
.insert_resource(CharPick(tx))
.insert_resource(crate::cvars::CvarPersist::with_stored(
CVAR_LAST_CHARACTER,
"0",
))
.insert_resource(crate::cvars::Cvars::with_value(CVAR_LAST_CHARACTER, "0"))
.add_message::<CharListMessage>()
.add_message::<AppExit>()
.add_systems(Update, apply_roster_policy);
@@ -1624,14 +1612,11 @@ mod tests {
#[test]
fn only_entering_the_world_writes_the_cvar() {
let (tx, _rx) = crossbeam_channel::unbounded();
let script = benilla_ui::script::UiScript::new().unwrap();
script.register_cvars([(CVAR_LAST_CHARACTER, "0")]);
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<Roster>()
.init_resource::<crate::cvars::Cvars>()
.insert_resource(CharPick(tx))
.insert_non_send_resource(script)
.add_systems(Update, persist_last_character);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
@@ -1645,96 +1630,35 @@ mod tests {
}
app.update();
assert!(
app.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>()
.take_cvar_changes()
.is_empty(),
!app.world().resource::<crate::cvars::Cvars>().has_events(),
"a selection alone must NOT be remembered — the reference writes nothing here",
);
// …and now Enter World, on the row that was selected.
app.world_mut().resource_mut::<Roster>().pending_pick = Some(2);
app.update();
let moved: Vec<(String, String)> = app
.world_mut()
.resource_mut::<crate::cvars::Cvars>()
.take_events()
.into_iter()
.map(|e| (e.name, e.new))
.collect();
assert_eq!(
app.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>()
.take_cvar_changes(),
moved,
vec![(CVAR_LAST_CHARACTER.to_string(), "1".to_string())],
"guid 2 sits at row 1, and the row is what rides the queue",
"guid 2 sits at row 1, and the row is what the registry took",
);
}
/// **The 1290 property, at this call site.** A login replaces the VM, and the memo of "the
/// table already says 1" must die with it — otherwise the mirror stays quiet against a table
/// that has never been told, and the memory survives only for as long as some *other* module
/// happens to carry the key across (`cvars::sync_cvars`'s saved-base seed does, today).
#[test]
fn a_replaced_vm_is_told_the_remembered_row_again() {
let (tx, _rx) = crossbeam_channel::unbounded();
let fresh = || {
let s = benilla_ui::script::UiScript::new().unwrap();
s.register_cvars([(CVAR_LAST_CHARACTER, "0")]);
s
};
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<Roster>()
.insert_resource(CharPick(tx))
.insert_non_send_resource(fresh())
.add_systems(Update, persist_last_character);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
roster.chars = vec![character(1, "Kerwind"), character(2, "Xero")];
roster.pending_pick = Some(2);
}
// Steady frames stay quiet, and the next VM is seeded from the registry — which is what
// replaced the per-VM memo this system used to carry (1290's property, now held by the
// store outliving the VM, 2303).
app.update();
app.update(); // steady frames stay quiet — the memo does its job within one VM
// The login edge: `ui_script::lifecycle` drops the VM and installs a boot VM in its place.
app.world_mut()
.insert_non_send_resource::<benilla_ui::script::UiScript>(fresh());
app.update();
assert_eq!(
app.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>()
.take_cvar_changes(),
vec![(CVAR_LAST_CHARACTER.to_string(), "1".to_string())],
"the new VM's table must be told the row too — a memo that outlived the old one \
would leave this table on its default and lose the memory at quit",
);
}
/// The write must survive the frame in which the host has not registered its table yet: an
/// engine write to an unregistered name is a deliberate silent no-op, so latching on it would
/// swallow the session's FIRST entry — the one launch-to-launch memory exists for.
#[test]
fn an_entry_made_before_the_cvar_table_exists_is_not_lost() {
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<Roster>()
.insert_resource(CharPick(tx))
.insert_non_send_resource(benilla_ui::script::UiScript::new().unwrap())
.add_systems(Update, persist_last_character);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
roster.chars = vec![character(1, "Kerwind"), character(2, "Xero")];
roster.pending_pick = Some(2);
}
app.update(); // the table has no such name yet — the write is dropped
app.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>()
.register_cvars([(CVAR_LAST_CHARACTER, "0")]);
app.update(); // ...and the next frame must still catch it up
assert_eq!(
app.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>()
.take_cvar_changes(),
vec![(CVAR_LAST_CHARACTER.to_string(), "1".to_string())],
);
let cvars = app.world().resource::<crate::cvars::Cvars>();
assert!(!cvars.has_events());
assert!(cvars
.vm_seed()
.iter()
.any(|r| r.name == CVAR_LAST_CHARACTER && r.value == "1"));
}
/// The reverse arrival order (result first, roster after) stays armed and is answered by the
+10
View File
@@ -812,8 +812,18 @@ pub(crate) struct BubbleSet;
pub(crate) struct ChatBubblePlugin;
/// The two bubble switches' change callback (1139, 2303): flags, like every other pair.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut bubbles: ResMut<BubbleConfig>) {
match ev.key().as_str() {
"chatbubbles" => bubbles.all = ev.flag(),
"chatbubblesparty" => bubbles.party = ev.flag(),
_ => {}
}
}
impl Plugin for ChatBubblePlugin {
fn build(&self, app: &mut App) {
app.add_observer(on_cvar);
app.init_resource::<BubbleQueue>()
.init_resource::<BubbleConfig>()
.init_resource::<Bubbles>()
+11
View File
@@ -585,10 +585,21 @@ fn melee_impact_text(
/// inside the append window the mesh rebuild waits on).
pub(crate) struct CombatTextPlugin;
/// The damage-text rows' change callback (decision 2303): three flags.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut gates: ResMut<DamageTextGates>) {
match ev.key().as_str() {
"combatdamage" => gates.combat_damage = ev.flag(),
"petmeleedamage" => gates.pet_melee = ev.flag(),
"petspelldamage" => gates.pet_spell = ev.flag(),
_ => {}
}
}
impl Plugin for CombatTextPlugin {
fn build(&self, app: &mut App) {
// The Update append window (see [`UiQuadAppend`]): after the camera controller, and
// projecting through the camera's FRESH Transform (not the stale propagated global).
app.add_observer(on_cvar);
app.init_resource::<WorldTexts>()
.init_resource::<DamageTextFont>()
.init_resource::<DamageTextGates>()
+412
View File
@@ -0,0 +1,412 @@
//! The in-game **console command registry** (decision 2303) — the reference's `ConsoleCommand`
//! table, host side: `ConsoleCommandRegister 0x63f9e0` over a `TSExplicitList<CONSOLECOMMAND>`,
//! with parse / register / lookup / execute (wow-re `console/console.md`, the upper band). A
//! subsystem registers its commands from its own plugin ([`ConsoleCommandApp::console_command`]),
//! the way each of the reference's registers into the one table, and `/console <line>` reaches
//! [`execute`] through the chat drain.
//!
//! **A CVar is a command too.** The reference's `CVar::Register` installs a per-CVar command
//! under the CVar's own name (`0x63dde0`): a bare name prints `CVar "%s" is "%s"`, a name with a
//! value sets it. So does this — over [`Cvars`] — after the registered commands, which is the
//! reference's own lookup order. The four commands `ConsoleVar.cpp`'s `Initialize` registers
//! (`set`, `cvar_reset`, `cvar_default`, `cvarlist`) are the built-ins [`ConsolePlugin`] adds,
//! plus `help`, which the reference's registry runtime owns.
//!
//! There is no console screen here — the drop-down that `` ` `` toggles in the reference — so a
//! command's output is a system line in the chat frame. That is the seam `/console` already
//! used; the registry is what the arm match behind it became.
use std::collections::BTreeMap;
use bevy::prelude::*;
use crate::cvars::{Cvars, SetOutcome};
/// A command's body: the whole world, the argument tail after the command name (trimmed), and
/// the lines to print in return.
pub(crate) type ConsoleHandler = fn(&mut World, &str) -> Vec<String>;
#[derive(Clone, Copy)]
struct ConsoleCommand {
/// The registered spelling, as `help` prints it.
name: &'static str,
help: &'static str,
run: ConsoleHandler,
}
/// The registry — lowercased name → command, in name order.
#[derive(Resource, Default)]
pub(crate) struct ConsoleCommands {
by_key: BTreeMap<String, ConsoleCommand>,
}
/// `app.console_command(name, help, handler)` — a subsystem registering its own command from
/// its plugin, the reference's `ConsoleCommandRegister`.
pub(crate) trait ConsoleCommandApp {
fn console_command(
&mut self,
name: &'static str,
help: &'static str,
run: ConsoleHandler,
) -> &mut Self;
}
impl ConsoleCommandApp for App {
fn console_command(
&mut self,
name: &'static str,
help: &'static str,
run: ConsoleHandler,
) -> &mut Self {
let mut table = self.world_mut().get_resource_or_init::<ConsoleCommands>();
let key = name.to_ascii_lowercase();
// Two registrations of one name is a wiring bug, never a runtime condition — and the
// reference's registry would silently shadow one with the other.
assert!(
!table.by_key.contains_key(&key),
"console command {name:?} registered twice"
);
table.by_key.insert(key, ConsoleCommand { name, help, run });
self
}
}
/// The built-ins: the reference's four `ConsoleVar.cpp` commands and `help`.
pub(crate) struct ConsolePlugin;
impl Plugin for ConsolePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ConsoleCommands>()
.console_command("set", "Set the value of a CVar.", set)
.console_command("cvar_reset", "Reset a CVar to its default.", cvar_default)
.console_command("cvar_default", "Reset a CVar to its default.", cvar_default)
.console_command(
"cvarlist",
"List the CVars, optionally matching a string.",
cvarlist,
)
.console_command("help", "List the console commands, or describe one.", help);
}
}
/// Run one console line — the reference's `ConsoleCommandExecute`: the first token is a command
/// name (case-insensitive), the rest is its argument tail. A registered command runs; else a
/// registered CVar answers as its per-CVar command does; else the line is reported as unknown.
/// Returns the lines to print.
///
/// A CVar write here is applied **at once** — its observers run before this returns — which is
/// the reference's shape (the callback runs inside `CVar::Set`), and what makes a typed
/// `/console farclip 500` visible on the next frame rather than the one after.
pub(crate) fn execute(world: &mut World, line: &str) -> Vec<String> {
let line = line.trim();
let (name, args) = line
.split_once(char::is_whitespace)
.map_or((line, ""), |(n, a)| (n, a.trim()));
if name.is_empty() {
return vec!["console: no command given (`help` lists the commands)".to_string()];
}
let command = world
.get_resource::<ConsoleCommands>()
.and_then(|t| t.by_key.get(&name.to_ascii_lowercase()).copied());
if let Some(command) = command {
return (command.run)(world, args);
}
if world
.get_resource::<Cvars>()
.is_some_and(|c| c.row(name).is_some())
{
return if args.is_empty() {
vec![print_cvar(world, name)]
} else {
set_cvar(world, name, args)
};
}
vec![format!(
"console: '{name}' is not a command or a CVar (`help` lists the commands)"
)]
}
/// `CVar "%s" is "%s"` — the per-CVar command's empty-argument arm (`0x63dde0`), with the
/// staged value beside it when one is waiting for the latch boundary.
fn print_cvar(world: &World, name: &str) -> String {
let cvars = world.resource::<Cvars>();
let Some(row) = cvars.row(name) else {
return format!("console: no CVar named '{name}'");
};
match &row.pending {
Some(staged) => format!(
"CVar \"{}\" is \"{}\" (\"{staged}\" staged until the next RestartGx)",
row.name, row.value
),
None => format!("CVar \"{}\" is \"{}\"", row.name, row.value),
}
}
/// A write through the registry, its observers run, and the outcome said back.
fn set_cvar(world: &mut World, name: &str, value: &str) -> Vec<String> {
let (outcome, events) = {
let mut cvars = world.resource_mut::<Cvars>();
let outcome = cvars.set(name, value);
(outcome, cvars.take_events())
};
for event in events {
world.trigger(event);
}
match outcome {
SetOutcome::Unknown => vec![format!("console: no CVar named '{name}'")],
SetOutcome::Refused => vec![format!(
"console: '{value}' is not a number, and {name} takes one"
)],
SetOutcome::Unchanged | SetOutcome::Changed => Vec::new(),
SetOutcome::Staged => vec![format!(
"{name} staged as \"{value}\" — applies at the next RestartGx"
)],
}
}
/// `set <name> <value>` — the reference's `0x63d500`. **One stated divergence:** the reference
/// registers an unknown name as a new category-5 record, invisible to Lua; ours refuses it,
/// because a row nothing reads is exactly what the registry's honest-tree rule exists to keep
/// out, and `config.toml` preserves an unknown key on its own.
fn set(world: &mut World, args: &str) -> Vec<String> {
let (name, value) = args
.split_once(char::is_whitespace)
.map_or((args, ""), |(n, v)| (n, v.trim()));
if name.is_empty() || value.is_empty() {
return vec!["usage: set <cvar> <value>".to_string()];
}
set_cvar(world, name, value)
}
/// `cvar_reset <name>` / `cvar_default <name>` — the reference's `0x63d590`/`0x63d640`. One
/// body for both: our rows carry no separate reset value, so the reset target is the default.
fn cvar_default(world: &mut World, args: &str) -> Vec<String> {
let name = args.split_whitespace().next().unwrap_or("");
if name.is_empty() {
return vec!["usage: cvar_default <cvar>".to_string()];
}
let default = world
.resource::<Cvars>()
.default_of(name)
.map(str::to_string);
let Some(default) = default else {
return vec![format!("console: no CVar named '{name}'")];
};
set_cvar(world, name, &default)
}
/// `cvarlist [match]` — the reference's `0x63d6f0`, in a format of our own: one line per row,
/// the value, the default when the value moved, the staged value when one is waiting, and
/// whether the session owns the row or an addon declared it.
fn cvarlist(world: &mut World, args: &str) -> Vec<String> {
let wanted = args.split_whitespace().next().map(str::to_ascii_lowercase);
let cvars = world.resource::<Cvars>();
let mut rows: Vec<_> = cvars
.rows()
.filter(|r| {
wanted
.as_deref()
.is_none_or(|w| r.name.to_ascii_lowercase().contains(w))
})
.collect();
rows.sort_by_key(|r| r.name.to_ascii_lowercase());
let mut out: Vec<String> = rows
.iter()
.map(|r| {
let mut line = format!("{} = \"{}\"", r.name, r.value);
if r.value != r.default {
line.push_str(&format!(" (default \"{}\")", r.default));
}
if let Some(staged) = &r.pending {
line.push_str(&format!(" [staged \"{staged}\"]"));
}
if cvars.is_session_owned(&r.name) {
line.push_str(" [session]");
}
if r.addon {
line.push_str(" [addon]");
}
line
})
.collect();
out.push(format!("{} CVar(s)", rows.len()));
out
}
/// `help [command]` — the registry's own listing.
fn help(world: &mut World, args: &str) -> Vec<String> {
let table = world.resource::<ConsoleCommands>();
let wanted = args.split_whitespace().next();
match wanted {
Some(name) => match table.by_key.get(&name.to_ascii_lowercase()) {
Some(c) => vec![format!("{} — {}", c.name, c.help)],
None => vec![format!("console: no command named '{name}'")],
},
None => {
let mut out: Vec<String> = table
.by_key
.values()
.map(|c| format!("{} — {}", c.name, c.help))
.collect();
out.push(
"…and every CVar by name: `<cvar>` prints it, `<cvar> <value>` sets it".into(),
);
out
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn console_app() -> App {
let mut app = App::new();
app.add_plugins(bevy::MinimalPlugins)
.init_resource::<Cvars>()
.add_plugins(ConsolePlugin);
app
}
fn run(app: &mut App, line: &str) -> Vec<String> {
execute(app.world_mut(), line)
}
/// **The lookup order is the reference's**: a registered command first, then the CVar's own
/// per-name command (bare name prints, a value sets), then unknown — case-insensitively at
/// every step, like the engine's own table lookup.
#[test]
fn a_line_is_a_command_then_a_cvar_then_unknown() {
let mut app = console_app();
assert_eq!(
run(&mut app, "MusicVolume"),
vec!["CVar \"MusicVolume\" is \"0.4\"".to_string()]
);
assert!(
run(&mut app, "musicvolume 0.7").is_empty(),
"a set is quiet"
);
assert_eq!(
app.world().resource::<Cvars>().get("MusicVolume"),
Some("0.7")
);
assert_eq!(
run(&mut app, "SET MusicVolume 0.2"),
Vec::<String>::new(),
"`set` is a registered command, matched case-insensitively"
);
assert_eq!(
app.world().resource::<Cvars>().get("MusicVolume"),
Some("0.2")
);
assert_eq!(
run(&mut app, "cvar_default musicVolume"),
Vec::<String>::new()
);
assert_eq!(
app.world().resource::<Cvars>().get("MusicVolume"),
Some("0.4")
);
assert_eq!(
run(&mut app, "nosuchthing 1"),
vec![
"console: 'nosuchthing' is not a command or a CVar (`help` lists the commands)"
.to_string()
]
);
assert_eq!(
run(&mut app, "set nosuchthing 1"),
vec!["console: no CVar named 'nosuchthing'".to_string()],
"the stated divergence: no record conjured at the console"
);
assert_eq!(
run(&mut app, "MusicVolume wat"),
vec!["console: 'wat' is not a number, and MusicVolume takes one".to_string()]
);
assert_eq!(
run(&mut app, " "),
vec!["console: no command given (`help` lists the commands)".to_string()]
);
}
/// A write to a latched row says it is staged, prints as staged, and the list shows it.
#[test]
fn a_latched_cvar_reports_its_stage() {
let mut app = console_app();
assert_eq!(
run(&mut app, "gxVSync 0"),
vec!["gxVSync staged as \"0\" — applies at the next RestartGx".to_string()]
);
assert_eq!(
run(&mut app, "gxvsync"),
vec!["CVar \"gxVSync\" is \"1\" (\"0\" staged until the next RestartGx)".to_string()]
);
let list = run(&mut app, "cvarlist gxv");
assert_eq!(
list,
vec![
"gxVSync = \"1\" [staged \"0\"]".to_string(),
"1 CVar(s)".to_string()
]
);
}
/// `cvarlist` marks a moved value with its default, a session-owned row, and an addon's.
#[test]
fn cvarlist_says_where_each_row_stands() {
let mut app = console_app();
{
let mut cvars = app.world_mut().resource_mut::<Cvars>();
cvars.set("farclip", "500");
cvars.own_for_session("uiScale", Some("1.2"));
cvars.learn_addon_row("myAddonKnob", "7");
}
let list = run(&mut app, "cvarlist");
assert!(
list.contains(&"farclip = \"500\" (default \"350\")".to_string()),
"{list:?}"
);
assert!(
list.contains(&"uiScale = \"1.2\" (default \"0.9\") [session]".to_string()),
"{list:?}"
);
assert!(
list.contains(&"myAddonKnob = \"7\" [addon]".to_string()),
"{list:?}"
);
assert!(list.last().unwrap().ends_with(" CVar(s)"));
let some = run(&mut app, "cvarlist FARCLIP");
assert_eq!(some.len(), 2, "matched case-insensitively: {some:?}");
}
/// `help` lists what is registered, and a subsystem's registration is one call.
#[test]
fn help_lists_the_registry_and_a_registration_is_one_call() {
fn shout(_: &mut World, args: &str) -> Vec<String> {
vec![format!("{}!", args.to_ascii_uppercase())]
}
let mut app = console_app();
app.console_command("shout", "Shout the argument back.", shout);
assert_eq!(
run(&mut app, "Shout hello there"),
vec!["HELLO THERE!".to_string()]
);
let listing = run(&mut app, "help");
assert!(listing.contains(&"shout — Shout the argument back.".to_string()));
assert!(listing.contains(&"set — Set the value of a CVar.".to_string()));
assert_eq!(
run(&mut app, "help cvarlist"),
vec!["cvarlist — List the CVars, optionally matching a string.".to_string()]
);
}
#[test]
#[should_panic(expected = "registered twice")]
fn a_name_registered_twice_is_a_wiring_bug() {
fn nothing(_: &mut World, _: &str) -> Vec<String> {
Vec::new()
}
let mut app = console_app();
app.console_command("set", "again", nothing);
}
}
+14 -2
View File
@@ -336,11 +336,23 @@ impl Plugin for CursorPlugin {
app.init_resource::<DisplayedCursor>();
#[cfg(target_os = "macos")]
app.add_systems(Startup, macos::setup.after(AssetSet::Open))
.add_systems(Update, (drive_displayed_cursor, macos::drive).chain());
.add_systems(
Update,
(drive_displayed_cursor, macos::drive)
.chain()
// After the tick: a FrameXML `SetCursor` made this frame is read here.
.after(crate::ui_script::UiInput),
);
#[cfg(not(target_os = "macos"))]
app.init_resource::<other::PayloadCursorImages>()
.add_systems(Startup, other::setup.after(AssetSet::Open))
.add_systems(Update, (drive_displayed_cursor, other::drive).chain());
.add_systems(
Update,
(drive_displayed_cursor, other::drive)
.chain()
// After the tick: a FrameXML `SetCursor` made this frame is read here.
.after(crate::ui_script::UiInput),
);
}
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -16,7 +16,7 @@ use benilla_ui::script::{DeathAction, DeathUiState, ScriptValue, UiScript};
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfGuid, SelfPlayer};
use crate::ui_action::Spells;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
/// Where our corpse is — the `MSG_CORPSE_QUERY` answer (decision 0308 §5). Raw WoW coords.
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -718,7 +718,7 @@ impl Plugin for DeathPlugin {
.add_systems(
Update,
(
feed_death.before(UiInput),
feed_death.in_set(UiFeed),
drain_death.after(UiInput),
drive_death_look,
// Before the feed, so the frame a session ends is already a frame the feed
+170 -5
View File
@@ -231,6 +231,9 @@ impl PluginGroup for GamePlugins {
// 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(crate::cvars::CvarPlugin)
// The console command registry (decision 2303): the reference's `ConsoleCommand` table,
// its four CVar commands and `help`; subsystems register their own from their plugins.
.add(crate::console::ConsolePlugin)
// 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(crate::bindings::BindingsPlugin)
@@ -519,6 +522,10 @@ pub(crate) mod schedule_tests {
pub dependencies: Vec<(SystemKey, SystemKey)>,
/// The systems that must run on the main thread (the VM's, the audio layer's).
pub non_send: Vec<SystemKey>,
/// The systems whose declared access includes the Lua VM — read off each system's
/// own access set, not off the conflicts it happens to have (a holder whose every VM
/// pair is declared would not show there).
pub holds_vm: HashSet<SystemKey>,
}
impl Census {
@@ -544,6 +551,13 @@ pub(crate) mod schedule_tests {
ambiguity_detection: LogLevel::Warn,
..default()
});
// The systems' access is filled by `initialize`, which the build below would run
// anyway (bevy drains one list of uninitialized systems, once). Done first, so pass
// 1 can read it while the graph still holds the systems.
schedule.graph_mut().systems.initialize(world);
let vm = world
.components()
.get_resource_id(TypeId::of::<benilla_ui::script::UiScript>());
// Pass 1, before the build: the graph still holds the systems.
let graph = schedule.graph();
let set_conditions: HashMap<_, Vec<String>> = graph
@@ -560,7 +574,14 @@ pub(crate) mod schedule_tests {
})
.collect();
let mut systems = HashMap::new();
let mut holds_vm = HashSet::new();
for (key, system, conds) in graph.systems.iter() {
if let (Some(vm), Some(with_access)) = (vm, graph.systems.get(key)) {
let access = with_access.access.combined_access();
if access.has_resource_read(vm) || access.has_resource_write(vm) {
holds_vm.insert(key);
}
}
let mut sets = Vec::new();
let mut conditions: Vec<String> = conds
.iter()
@@ -665,6 +686,7 @@ pub(crate) mod schedule_tests {
classes,
dependencies,
non_send,
holds_vm,
}
})
}
@@ -688,9 +710,11 @@ pub(crate) mod schedule_tests {
///
/// - **A non-`Send` owner** — the Lua VM, the audio layer's handles. Their systems run on
/// the main thread one at a time under any executor, so no order among them is a race.
/// *Which feed fires its Lua events first* is a real question — 2265 §A3(a)'s, answered
/// once for the `UiFeed` set, not per pair. Derived, not listed: every registration that
/// is not `Send + Sync`.
/// *Which feed fires its Lua events first* is a real question, answered by the `UiFeed`
/// phase (decision 2304): every push after the drain and before the tick, and among the
/// pushes an order declared where it matters (the chat cascade, the cooldown events),
/// registration order otherwise. Derived, not listed: every registration that is not
/// `Send + Sync`.
/// - **A pure cache** — a read is a write because a miss records itself: the ask-once
/// caches mark the key pending and send one query (`NameCache`, the GameObject
/// templates, the page texts), the load-once caches build the entry (`WorldAssets`,
@@ -704,6 +728,13 @@ pub(crate) mod schedule_tests {
/// writers commute, and a drain's order against a writer is one frame of latency, never
/// a loss. (2283's ten pairs were all this shape: a new verb drain against its siblings
/// over the chat and error sinks.)
/// - **An exclusive edge** — a pair bevy reports with NO component list: one side is a
/// build-inserted `ApplyDeferred` sync point or an exclusive system, which conflicts on
/// `World` itself. Its order against a system it shares nothing with is immaterial by
/// construction (the build already placed the barrier after the commands it flushes and
/// before their dependents), and there is nothing to declare it against. Until 2304 an
/// empty list read as "VM only" vacuously, so that class rose and fell with the barrier
/// count; now it is named.
/// - **A random stream** — `SoundKits`, which every sound system holds to play a kit: a
/// decode cache, a per-kit last-variation memory and one xorshift stream. Any
/// interleaving of draws is a valid draw, and that is the reference's own contract for
@@ -798,6 +829,9 @@ pub(crate) mod schedule_tests {
/// Which class explains this pair — `None` if it is actionable.
pub fn class_of(&self, what: &[ComponentId]) -> Option<&'static str> {
if what.is_empty() {
return Some("exclusive");
}
if !what.iter().all(|id| self.explains(*id)) {
return None;
}
@@ -919,7 +953,7 @@ pub(crate) mod schedule_tests {
/// Raising this ceiling is a claim that a new undeclared order is acceptable; make it with
/// the reason, or declare the order instead (`.after`, a set, a `chain`). If the pair is
/// about a resource that commutes by construction, the claim belongs in [`Classes`].
const UPDATE_ACTIONABLE_CEILING: usize = 3_289;
const UPDATE_ACTIONABLE_CEILING: usize = 2_975;
const UPDATE_ACTIONABLE_SLACK: usize = 40;
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
@@ -1122,7 +1156,7 @@ pub(crate) mod schedule_tests {
.non_send
.iter()
.copied()
.filter(|k| holds(*k, &|id| Some(id) == c.classes.vm))
.filter(|k| c.holds_vm.contains(k))
.collect();
let audio: Vec<SystemKey> = c
.non_send
@@ -1180,6 +1214,137 @@ pub(crate) mod schedule_tests {
eprintln!(" declared edges only: {w3} waves (every conflict ordered, everything Send)");
}
/// Why a system that holds the VM in `Update` and is ordered before the tick may stay OUT
/// of [`crate::ui_script::UiFeed`] — keyed by a suffix of the system's full name, with the
/// reason. Read beside [`every_vm_holder_in_update_declares_its_side_of_the_tick`].
/// Empty at 2304: every holder ordered before the tick joined the phase (`ui_session::
/// feed_interact_npc`, seated inside `WorldStage::Net` for decision 2022's reason, looked
/// like the one exception and is not — it writes a resource the unit feed reads, and
/// never holds the VM).
const OUTSIDE_THE_FEED_PHASE: &[(&str, &str)] = &[];
/// **Every system that holds the VM in `Update` declares its side of the tick** (decision
/// 2304). The VM ticks once a frame (`extract::tick_script`, in `UiInput`): a push the tick
/// must see rides `UiFeed`, which the plugin chains after the net drain and before the
/// tick; a drain of what the tick produced is `.after(UiInput)`. Read off the built graph,
/// so a membership through a parent set and an order through a chain both count. Three
/// things fail here: a holder with no declared path to or from the tick; one ordered
/// before the tick without riding the feed phase — so it may run before this frame's
/// packets land, the class 2265 §A3 counted as "ordered only against `UiInput`" — unless
/// it is argued in [`OUTSIDE_THE_FEED_PHASE`]; and a feed-phase member the graph does not
/// actually place after the drain and before the tick, the set's contract checked rather
/// than trusted. A row arguing a system the graph places elsewhere fails too.
#[test]
fn every_vm_holder_in_update_declares_its_side_of_the_tick() {
let mut app = headless_client();
let c = census(&mut app, Update);
if !type_names_available(&c) {
eprintln!(
"skipped: this build carries no type names (bevy/debug rides with dev, 1451)"
);
return;
}
let one = |suffix: &str| -> SystemKey {
let mut hits = c.systems.iter().filter(|(_, s)| s.name.ends_with(suffix));
match (hits.next(), hits.next()) {
(Some((k, _)), None) => *k,
_ => panic!("exactly one system named `…{suffix}` in Update"),
}
};
let tick = one("::extract::tick_script");
let drain = one("::net::apply::apply_net_updates");
let mut succ: HashMap<SystemKey, Vec<SystemKey>> = HashMap::new();
let mut pred: HashMap<SystemKey, Vec<SystemKey>> = HashMap::new();
for (a, b) in &c.dependencies {
succ.entry(*a).or_default().push(*b);
pred.entry(*b).or_default().push(*a);
}
let reach = |start: SystemKey, edges: &HashMap<SystemKey, Vec<SystemKey>>| {
let mut seen = HashSet::new();
let mut stack = vec![start];
while let Some(n) = stack.pop() {
for m in edges.get(&n).into_iter().flatten() {
if seen.insert(*m) {
stack.push(*m);
}
}
}
seen
};
let before_tick = reach(tick, &pred);
let after_tick = reach(tick, &succ);
let after_drain = reach(drain, &succ);
let mut holders: Vec<SystemKey> = c.holds_vm.iter().copied().collect();
holders.sort_by(|a, b| c.name(*a).cmp(c.name(*b)));
let (mut feed, mut post, mut argued) = (0, 0, 0);
let mut offenders = Vec::new();
let mut stale = Vec::new();
for k in &holders {
if *k == tick {
continue;
}
let s = &c.systems[k];
let in_feed = s.sets.iter().any(|set| set == "UiFeed");
let row = OUTSIDE_THE_FEED_PHASE
.iter()
.find(|(suffix, _)| s.name.ends_with(suffix));
if in_feed {
feed += 1;
if !(after_drain.contains(k) && before_tick.contains(k)) {
offenders.push(format!(
"{}: in `UiFeed`, but the graph does not place it after the net drain and before the tick",
s.name
));
}
} else if after_tick.contains(k) {
post += 1;
} else if before_tick.contains(k) {
if row.is_some() {
argued += 1;
continue;
}
offenders.push(format!(
"{}: ordered before the tick but not in `UiFeed` — it may run before this frame's packets land; `.in_set(UiFeed)`",
s.name
));
} else {
offenders.push(format!(
"{}: no declared side of the tick — `.in_set(UiFeed)` for a push the tick must see this frame, `.after(UiInput)` for a drain of what the tick produced",
s.name
));
}
if row.is_some() {
stale.push(format!(
"{}: argued in OUTSIDE_THE_FEED_PHASE but the graph places it {} — drop the row",
s.name,
if in_feed {
"in the feed phase"
} else {
"after the tick"
}
));
}
}
for (suffix, _) in OUTSIDE_THE_FEED_PHASE {
if !holders.iter().any(|k| c.name(*k).ends_with(suffix)) {
stale.push(format!(
"`…{suffix}` is not a VM holder in Update — drop its row"
));
}
}
eprintln!(
"VM holders in Update: {} — the tick, {feed} in the feed phase, {post} after the tick, {argued} argued outside the phase",
holders.len()
);
assert!(
offenders.is_empty(),
"these systems hold the VM in `Update` without declaring their side of the tick \
(decision 2304):\n {}",
offenders.join("\n ")
);
assert!(stale.is_empty(), "stale rows:\n {}", stale.join("\n "));
}
/// The consumer markers 2220's census used: a system that holds the VM and does one of
/// these to host state has spent something this frame that nothing can re-spend.
const CONSUMES: &[&str] = &["fire_event(", "mem::take(", ".drain("];
+20 -10
View File
@@ -268,12 +268,24 @@ pub(crate) fn tip_bundle() -> impl Bundle {
pub(crate) struct GameTipPlugin;
/// The tip rows' change callback (decision 2303): the switch, and the cursor — which is not a
/// preference: a hand-edited or downgraded value lands here verbatim and [`raise`] clamps it,
/// which is the reference's own tolerance (`0x46b682`).
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut setting: ResMut<GameTipSetting>) {
match ev.key().as_str() {
"showgametips" => setting.show = ev.flag(),
"gametip" => setting.next = ev.num() as i64,
_ => {}
}
}
impl Plugin for GameTipPlugin {
fn build(&self, app: &mut App) {
app.add_observer(on_cvar);
app.init_resource::<GameTips>()
// The CVar mirror, init'd HERE and not by the CVar host: `KnobParams` takes it as a
// plain `ResMut`, so a build that registers the row without the resource panics the
// first `load_config` — which is what a live run caught and no unit test could, the
// The CVar knob, init'd HERE and not by the CVar host: [`on_cvar`] takes it as a
// plain `ResMut`, so a build that registers the row without the resource panics on
// the first write — which is what a live run caught and no unit test could, the
// test harness having its own `init_resource` chain.
.init_resource::<GameTipSetting>()
.add_systems(
@@ -330,9 +342,9 @@ fn drive_game_tip(
windows: Query<&Window>,
assets: Res<AssetServer>,
mut commands: Commands,
// The cursor persists through the VM's table, not the knob — see `cvars::write_host_cvar`.
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut persist: ResMut<crate::cvars::CvarPersist>,
// The cursor persists through the registry, not the knob alone: a host write there is what
// the file is composed from and what the VM's mirror learns (decision 2303).
mut cvars: ResMut<crate::cvars::Cvars>,
) {
let Some(edge) = screen.take_tip_edge() else {
return;
@@ -356,9 +368,7 @@ fn drive_game_tip(
// is `EnterWorld`'s own bookkeeping and belongs to the PICK, not to the draw: it stands
// even on a frame the paint below cannot complete.
setting.next = i64::from(next);
if let Some(mut script) = script {
crate::cvars::write_host_cvar(&mut script, &mut persist, "gameTip", &next.to_string());
}
cvars.set("gameTip", &next.to_string());
}
// The text is laid out once per raise, not per frame, exactly as the reference does.
@@ -573,7 +583,7 @@ mod tests {
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()));
crate::text_reshape::harness::add_text_plugins(&mut app);
app.add_systems(PostUpdate, bevy::text::detect_text_needs_rerender::<Text>);
app.init_resource::<crate::cvars::CvarPersist>();
app.init_resource::<crate::cvars::Cvars>();
app.init_resource::<GameTipSetting>();
app.insert_resource(GameTips {
catalog: GameTipsCatalog::from_tips(vec![
+1
View File
@@ -58,6 +58,7 @@ mod chat_bubble;
mod chr_classes;
mod cinematic;
mod combat_text;
mod console;
mod cooldowns;
mod crash;
mod creature_anim;
+7 -1
View File
@@ -95,6 +95,11 @@ impl Plugin for LoginPlugin {
)
.chain()
.before(crate::glue::GlueVisuals)
// After the UI tick: one member holds the VM (`answer_dialog` writes
// into it), and every VM holder in `Update` declares its side of the
// tick (decision 2304). A glue screen has no push the tick must see,
// so the whole chain takes the drain side.
.after(crate::ui_script::UiInput)
.run_if(in_state(ClientState::Login)),
(smoke::debug_login_smoke, screen::debug_login_shot),
)
@@ -156,7 +161,8 @@ impl LoginIntent {
/// the channel to the parked IO thread, the abandon generation a Cancel bumps, and — since
/// decision 1667 — the realmlist it dials.
///
/// A bundle rather than four parameters, for `cvars::KnobParams`' reason: adding the realmlist put
/// A bundle rather than four parameters, for the reason the CVar host's old knob bundle had
/// (retired by 2303): adding the realmlist put
/// [`login_input`] at **seventeen** parameters, one past Bevy's ceiling, and the three systems
/// that submit were already re-typing the same four names. Now a submit is one call on one param,
/// and the next thing an attempt needs is one field here instead of a fourth signature to widen.
+29 -8
View File
@@ -238,6 +238,23 @@ pub(crate) struct MinimapZoom {
pub(crate) inside: u8,
}
/// The two zoom indices' change callback (1131, 2303): each index lands on its own field,
/// clamped exactly like the client's `set_zoom` (`0x6daa10`: clamp at 5) — the widget clamps
/// again on the way in, so a hand-edited level lands in range whichever path it takes.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut zoom: ResMut<MinimapZoom>) {
match ev.key().as_str() {
"minimapzoom" => zoom.outdoor = zoom_index(ev.num()),
"minimapinsidezoom" => zoom.inside = zoom_index(ev.num()),
_ => {}
}
}
/// A stored minimap zoom level → a valid index: truncate to int and clamp into
/// `[0, MINIMAP_ZOOM_LEVELS)`, the client's own `set_zoom` clamp.
fn zoom_index(v: f32) -> u8 {
v.clamp(0.0, f32::from(benilla_ui::widget::MINIMAP_ZOOM_LEVELS - 1)) as u8
}
impl Default for MinimapZoom {
fn default() -> Self {
Self {
@@ -1058,6 +1075,7 @@ pub(crate) struct MinimapPlugin;
impl Plugin for MinimapPlugin {
fn build(&self, app: &mut App) {
app.add_observer(on_cvar);
app.init_resource::<MinimapWidget>()
.init_resource::<MinimapZoom>()
.init_resource::<MinimapTileCache>()
@@ -1086,13 +1104,16 @@ impl Plugin for MinimapPlugin {
composite::drive_composite.after(UiQuadAppend),
// Before the script tick, so a zoom button pressed this frame routes to the
// indoor/outdoor index that matches where the player actually is.
feed_minimap_inside.before(crate::ui_script::UiInput),
feed_minimap_inside.in_set(crate::ui_script::UiFeed),
// Before the script tick, so an addon's OnUpdate steers off this frame's
// heading rather than last frame's.
feed_minimap_player_facing.before(crate::ui_script::UiInput),
// Before the emit that reads `MinimapAssets::mask`, so a mask set this frame
// is the one this frame draws with.
feed_minimap_mask.before(UiQuadAppend),
feed_minimap_player_facing.in_set(crate::ui_script::UiFeed),
// After the tick that can call `Minimap:SetMaskTexture`, before the emit that
// reads `MinimapAssets::mask`: a mask set this frame is the one this frame
// draws with.
feed_minimap_mask
.after(crate::ui_script::UiInput)
.before(UiQuadAppend),
// Before the script tick, and after the containment verdict it reads: the
// `MINIMAP_PING` event and `Minimap:GetPingPosition()`'s value land in the
// same tick, on a ping the renderer already drew at the end of last frame.
@@ -1103,16 +1124,16 @@ impl Plugin for MinimapPlugin {
// Gated, the latch simply waits for the first frame with an interface.
ping::drive_minimap_ping
.after(feed_minimap_inside)
.before(crate::ui_script::UiInput)
.in_set(crate::ui_script::UiFeed)
.run_if(crate::ui_script::ingame_ui_up),
// Before the script tick, so GameTimeFrame's OnUpdate reads this frame's
// minute, not last frame's.
feed_game_time.before(crate::ui_script::UiInput),
feed_game_time.in_set(crate::ui_script::UiFeed),
// After the world-mouseover drive (UnitFeed): a same-frame world-hover→blip
// transition must end with the blip tooltip shown, not the fade.
blips::drive_blip_tooltip
.after(crate::ui_unit::UnitFeed)
.before(crate::ui_script::UiInput),
.in_set(crate::ui_script::UiFeed),
),
);
}
+12
View File
@@ -801,8 +801,20 @@ pub(crate) struct NameplatesPlugin;
#[derive(Resource)]
struct NameAnchorTrace(bool);
/// The overhead-name rows' change callback (decision 2303): the name trio and the guild line.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut names: ResMut<NameConfig>) {
match ev.key().as_str() {
"unitnameplayer" => names.player = ev.flag(),
"unitnamenpc" => names.npc = ev.flag(),
"unitnameown" => names.own = ev.flag(),
"unitnameplayerguild" => names.player_guild = ev.flag(),
_ => {}
}
}
impl Plugin for NameplatesPlugin {
fn build(&self, app: &mut App) {
app.add_observer(on_cvar);
app.insert_resource(NameAnchorTrace(
std::env::var_os("WOW_PROBE_NAME_TRACE").is_some(),
))
+2
View File
@@ -27,11 +27,13 @@ use crossbeam_channel::{Receiver, Sender};
use benilla_world::schedule::WorldStage;
mod apply;
pub(crate) mod handlers;
pub(crate) mod io;
mod motion;
pub(crate) use apply::apply_net_updates;
use apply::tag_self_player;
pub(crate) use handlers::NetHandlerApp;
// The per-frame motion model lives in [`motion`]: `RemoteMotion`/`Spline` are re-exported for the
// crate (the animation selector reads them); the integration systems + pose helpers stay `pub(super)`
+31 -43
View File
@@ -14,7 +14,6 @@ use super::{
};
mod anim;
mod auction;
mod chat;
mod combat;
mod combat_chat;
@@ -86,11 +85,28 @@ fn addressed_store<'a>(
// ── The per-frame bridge systems ─────────────────────────────────────────────────────────────────
/// Drain the inbound event channel and mutate real ECS entities: spawn on create, move existing,
/// despawn on remove, attach/clear movement splines, and surface teleport/worldport/clock changes.
pub(crate) fn apply_net_updates(
/// The drain: take this frame's events off the channel and run them, **in packet order**, through
/// the two halves of the dispatch (decision 2305) — first the kinds the `match` below still owns,
/// through [`apply_unpeeled`], then the kinds a subsystem has claimed in the handler table
/// ([`super::handlers`]), each handler a one-shot system over this exclusive world. One frame,
/// packet order, before anything else in [`benilla_world::schedule::WorldStage::Net`] runs: the
/// property 0006 built and 2265 said every split must keep.
pub(crate) fn apply_net_updates(world: &mut World) {
let events: Vec<SessionEvent> = world.resource::<NetEvents>().0.try_iter().collect();
super::handlers::dispatch(world, events, |world, unclaimed| {
if let Err(e) = world.run_system_cached_with(apply_unpeeled, unclaimed) {
panic!("the drain's dispatch match did not run: {e}");
}
});
}
/// The dispatch `match` — every kind no subsystem has claimed yet (2305's migration runs one
/// window family at a time out of here into its own module's handlers). Mutates real ECS
/// entities: spawn on create, move existing, despawn on remove, attach/clear movement splines,
/// and surface teleport/worldport/clock changes; parks the rest into window state.
fn apply_unpeeled(
In(events): In<Vec<SessionEvent>>,
mut commands: Commands,
events: Res<NetEvents>,
net_commands: Res<NetCommands>,
mut index: ResMut<GuidIndex>,
mut self_guid: ResMut<SelfGuid>,
@@ -233,7 +249,6 @@ pub(crate) fn apply_net_updates(
mut tabard,
mut poi_marker,
mut inspect_honor,
mut auction_open,
mut ping,
mut gm_ticket,
mut registrar,
@@ -295,7 +310,7 @@ pub(crate) fn apply_net_updates(
}
};
}
for ev in events.0.try_iter() {
for ev in events {
match ev {
SessionEvent::LoginStage { stage } => session::login_stage(stage, &mut login_stages),
SessionEvent::LoginQueued { position, realm } => {
@@ -375,7 +390,6 @@ pub(crate) fn apply_net_updates(
&mut mail_open,
&mut mail_pending,
&mut trade_session,
&mut auction_open,
&mut bank_open,
&mut duel,
&mut social,
@@ -1889,41 +1903,6 @@ pub(crate) fn apply_net_updates(
SessionEvent::NextMailTime { seconds } => {
mail::next_mail_time(seconds, &mut mail_pending)
}
// The auction house arc (decision 1511 P1): the hello reply OPENS the window — our
// send does not — and the three list results fill the three tabs.
SessionEvent::AuctionHello {
auctioneer,
house_id,
} => auction::auction_hello(auctioneer, house_id, &mut auction_open),
SessionEvent::AuctionCommandResult {
auction_id,
action,
error,
tail,
} => {
auction::auction_command_result(auction_id, action, error, &tail, &mut auction_open)
}
SessionEvent::AuctionListResult {
auctions,
total_count,
} => auction::auction_list_result(auctions, total_count, &mut auction_open),
SessionEvent::AuctionOwnerListResult {
auctions,
total_count,
} => auction::auction_owner_list_result(auctions, total_count, &mut auction_open),
SessionEvent::AuctionBidderListResult {
auctions,
total_count,
} => auction::auction_bidder_list_result(auctions, total_count, &mut auction_open),
SessionEvent::AuctionBidderNotification(n) => {
auction::auction_bidder_notification(&n, &mut auction_open)
}
SessionEvent::AuctionOwnerNotification(n) => {
auction::auction_owner_notification(&n, &mut auction_open)
}
SessionEvent::AuctionRemovedNotification { item_entry, .. } => {
auction::auction_removed_notification(item_entry, &mut auction_open)
}
// The player-trade arc (decision 0592 P1): the status packet drives the open/accept/close
// state machine, the extended snapshot replaces one side's item/gold — both into the
// `TradeSession` the trade feed (`crate::ui_trade`) reads.
@@ -1939,6 +1918,15 @@ pub(crate) fn apply_net_updates(
SessionEvent::WorldStates { scope, states } => {
world::world_states(scope, states, &mut world_states)
}
// A kind with no arm here is one a subsystem has claimed in the handler table
// (decision 2305) — routed there by the drain, so it never reaches this match — or a
// new kind nobody handles, which `every_session_event_kind_has_one_owner` names at
// test time and this names at run time: the reference discards an unregistered
// opcode in silence; this client says so.
other => error!(
"net: {:?} reached the dispatch match with no arm — neither peeled nor handled",
benilla_protocol::SessionEventKind::from(&other)
),
}
}
// Flush the staged descriptor seeds/deltas onto the entities born this drain (now spawned by the
@@ -222,9 +222,6 @@ pub(crate) struct WindowStores<'w> {
/// another player's honor numbers, so the reply parks here and `crate::ui_honor` pushes it
/// into the pane and fires `INSPECT_HONOR_UPDATE`.
pub inspect_honor: ResMut<'w, crate::ui_honor::InspectHonor>,
/// The auctioneer session (decision 1511) — the hello reply opens it, the three list results
/// fill it, and `crate::ui_auction` feeds it to the window.
pub auction_open: ResMut<'w, crate::ui_auction::AuctionOpen>,
/// The minimap ping (decision 1596) — a group member's `MSG_MINIMAP_PING` seats the world
/// point here and the minimap renderer derives everything else from it.
pub ping: ResMut<'w, crate::minimap::MinimapPing>,
@@ -194,7 +194,6 @@ pub(super) fn disconnected(
mail: &mut MailOpen,
mail_pending: &mut crate::ui_mail::MailPending,
trade: &mut crate::ui_trade::TradeSession,
auction: &mut crate::ui_auction::AuctionOpen,
bank: &mut crate::ui_bank::BankOpen,
duel: &mut crate::ui_duel::DuelState,
social: &mut crate::ui_social::SocialState,
@@ -263,10 +262,6 @@ pub(super) fn disconnected(
// The arrival countdown is login-scoped (decision 0544 P3): a fresh login re-queries
// `MSG_QUERY_NEXT_MAIL_TIME` at world-enter, so nothing carries over across a reconnect.
*mail_pending = crate::ui_mail::MailPending::default();
// An open auction house dies with the socket (decision 1511): every auction command
// re-validates the auctioneer server-side, so a session that survived a reconnect would be a
// window whose every button silently failed.
auction.clear_session();
// An open trade dies with the socket too (decision 0592) — the reconnect starts with no trade.
trade.clear_session();
// The bank window dies with the socket (decision 0604) — a reconnect re-opens via the banker.
+329
View File
@@ -0,0 +1,329 @@
//! The inbound **handler table** (decision 2305) — the reference's own shape for the wire's
//! arrival side, and the cut 2265 §A1 asked for between the net bridge and the game windows.
//!
//! The real client keeps an opcode → handler table inside `NetClient` (`+0x74`, 828 slots; 387
//! registrations by 37 subsystem clusters — wow-re `net.md`) and its dispatcher `0x537aa0` knows
//! none of them: it looks the opcode up and calls what it finds, **in packet order**, discarding an
//! unregistered opcode in silence. Here the table is [`NetHandlers`]: a [`SessionEventKind`] →
//! handlers map that every subsystem fills for itself through [`NetHandlerApp::net_handler`], and
//! the drain ([`super::apply_net_updates`]) is exclusive over the world so the handlers — ordinary
//! systems taking `In<SessionEvent>`, registered as one-shots — run one after another in the
//! order the packets arrived, each seeing what the one before it did.
//!
//! **Why not one typed `Message<T>` per family with a reader system each** (2265's sketch): a
//! reader per family runs *after* the whole drain, so two families' packets interleaved in one
//! frame are handled family by family, not in packet order — a chat line and a combat-log line
//! that arrived in one drain would swap. The table keeps the one property 2265 said must survive
//! any split: one frame, packet order, before anything else runs.
//!
//! **The migration.** The dispatch `match` in `apply.rs` still owns every kind no subsystem has
//! claimed. One drain runs in two halves: the unclaimed events through the match first, in packet
//! order, then the claimed ones through the table, in packet order — so a window handler always
//! sees this frame's object updates, whatever the interleaving. A kind is owned by exactly one of
//! the two, checked on the built app by `every_session_event_kind_has_one_owner`; the one
//! exception is [`BROADCAST`], a session-end the match still handles and peeled windows also listen
//! to, which reaches both. When the last family leaves the match, the match, the halves and the
//! broadcast list go with it.
use std::collections::HashMap;
use benilla_protocol::{SessionEvent, SessionEventKind};
use bevy::ecs::system::SystemId;
use bevy::prelude::*;
/// One registered handler: the one-shot system and the name it registers under (for the census).
type Handler = (SystemId<In<SessionEvent>>, &'static str);
/// The table. Filled at plugin build by every subsystem that answers a packet; read by the drain.
#[derive(Resource, Default)]
pub(crate) struct NetHandlers {
by_kind: HashMap<SessionEventKind, Vec<Handler>>,
}
/// The kinds the dispatch match still owns **and** peeled subsystems listen to — a session end,
/// which every window that dies with the socket answers for itself. Cloned to the table after the
/// match has run. Empty once the session family itself is peeled.
pub(crate) const BROADCAST: &[SessionEventKind] = &[SessionEventKind::Disconnected];
impl NetHandlers {
/// Is there at least one handler for this kind?
pub(crate) fn handles(&self, kind: SessionEventKind) -> bool {
self.by_kind.contains_key(&kind)
}
/// Every kind with a handler, with the handlers' names in registration order — the owner
/// test's view of the table.
#[cfg(test)]
pub(crate) fn census(&self) -> std::collections::BTreeMap<SessionEventKind, Vec<&'static str>> {
self.by_kind
.iter()
.map(|(k, v)| (*k, v.iter().map(|(_, n)| *n).collect()))
.collect()
}
fn push(&mut self, kind: SessionEventKind, handler: Handler) {
self.by_kind.entry(kind).or_default().push(handler);
}
/// Run every handler registered for the event's kind, in registration order, each with the
/// event (cloned for all but the last).
fn run(&self, world: &mut World, ev: SessionEvent) {
let Some(list) = self.by_kind.get(&SessionEventKind::from(&ev)) else {
return;
};
let Some(((last_id, last_name), rest)) = list.split_last() else {
return;
};
for (id, name) in rest {
call(world, *id, name, ev.clone());
}
call(world, *last_id, last_name, ev);
}
}
fn call(world: &mut World, id: SystemId<In<SessionEvent>>, name: &str, ev: SessionEvent) {
if let Err(e) = world.run_system_with(id, ev) {
// A registered handler that cannot run is a bug in the registering plugin (a missing
// resource, most likely), never the wire's fault — loud, so the smoke gate sees it.
error!("net: handler `{name}` did not run: {e}");
}
}
/// Registering a packet handler from a plugin: `app.net_handler(SessionEventKind::X, on_x)`,
/// where `on_x` is an ordinary system taking `In<SessionEvent>` and whatever it needs. Several
/// handlers may answer one kind; they run in registration order.
pub(crate) trait NetHandlerApp {
fn net_handler<M>(
&mut self,
kind: SessionEventKind,
handler: impl IntoSystem<In<SessionEvent>, (), M> + 'static,
) -> &mut Self;
}
impl NetHandlerApp for App {
fn net_handler<M>(
&mut self,
kind: SessionEventKind,
handler: impl IntoSystem<In<SessionEvent>, (), M> + 'static,
) -> &mut Self {
let name = std::any::type_name_of_val(&handler);
let id = self.world_mut().register_system(handler);
self.world_mut()
.get_resource_or_init::<NetHandlers>()
.push(kind, (id, name));
self
}
}
/// One drain's dispatch: the events the table does not own go through `unclaimed` first — the
/// dispatch match, in packet order — then the owned ones run through the table, in packet order.
/// A [`BROADCAST`] kind reaches both.
pub(crate) fn dispatch(
world: &mut World,
events: Vec<SessionEvent>,
unclaimed: impl FnOnce(&mut World, Vec<SessionEvent>),
) {
let handlers = world.remove_resource::<NetHandlers>().unwrap_or_default();
let mut to_match = Vec::with_capacity(events.len());
let mut to_table = Vec::new();
for ev in events {
let kind = SessionEventKind::from(&ev);
if !handlers.handles(kind) {
to_match.push(ev);
} else if BROADCAST.contains(&kind) {
to_table.push(ev.clone());
to_match.push(ev);
} else {
to_table.push(ev);
}
}
unclaimed(world, to_match);
for ev in to_table {
handlers.run(world, ev);
}
world.insert_resource(handlers);
}
#[cfg(test)]
mod tests {
use super::*;
/// What ran, in order — a handler's name and the event it saw.
#[derive(Resource, Default)]
struct Log(Vec<String>);
fn on_queued(In(ev): In<SessionEvent>, mut log: ResMut<Log>) {
let SessionEvent::LoginQueued { position, .. } = ev else {
panic!("the table routes by kind");
};
log.0.push(format!("queued:{}", position.unwrap_or(0)));
}
fn on_logged_out(In(ev): In<SessionEvent>, mut log: ResMut<Log>) {
assert!(matches!(ev, SessionEvent::LoggedOut));
log.0.push("logged_out".into());
}
fn on_logged_out_too(In(_): In<SessionEvent>, mut log: ResMut<Log>) {
log.0.push("logged_out_too".into());
}
fn on_disconnected(In(ev): In<SessionEvent>, mut log: ResMut<Log>) {
let SessionEvent::Disconnected { reason, .. } = ev else {
panic!("the table routes by kind");
};
log.0.push(format!("disconnected:{reason}"));
}
fn app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).init_resource::<Log>();
app
}
fn queued(position: u32) -> SessionEvent {
SessionEvent::LoginQueued {
position: Some(position),
realm: None,
}
}
fn run(app: &mut App, events: Vec<SessionEvent>) -> Vec<String> {
dispatch(app.world_mut(), events, |world, unclaimed| {
let mut log = world.resource_mut::<Log>();
for ev in unclaimed {
log.0
.push(format!("match:{:?}", SessionEventKind::from(&ev)));
}
});
std::mem::take(&mut app.world_mut().resource_mut::<Log>().0)
}
#[test]
fn owned_kinds_run_in_packet_order_after_the_match_has_run_the_rest() {
let mut app = app();
app.net_handler(SessionEventKind::LoginQueued, on_queued)
.net_handler(SessionEventKind::LoggedOut, on_logged_out);
let log = run(
&mut app,
vec![
queued(1),
SessionEvent::LoginStage {
stage: benilla_protocol::LoginStage::Connecting,
},
SessionEvent::LoggedOut,
queued(2),
],
);
assert_eq!(
log,
vec!["match:LoginStage", "queued:1", "logged_out", "queued:2"]
);
}
#[test]
fn several_handlers_on_one_kind_run_in_registration_order_each_with_the_event() {
let mut app = app();
app.net_handler(SessionEventKind::LoggedOut, on_logged_out)
.net_handler(SessionEventKind::LoggedOut, on_logged_out_too);
let log = run(&mut app, vec![SessionEvent::LoggedOut]);
assert_eq!(log, vec!["logged_out", "logged_out_too"]);
assert_eq!(
app.world().resource::<NetHandlers>().census()[&SessionEventKind::LoggedOut].len(),
2
);
}
#[test]
fn a_broadcast_kind_reaches_the_match_and_the_table() {
let mut app = app();
app.net_handler(SessionEventKind::Disconnected, on_disconnected);
let log = run(
&mut app,
vec![SessionEvent::Disconnected {
reason: "socket".into(),
end: benilla_protocol::SessionEnd::Lost,
}],
);
assert_eq!(log, vec!["match:Disconnected", "disconnected:socket"]);
}
#[test]
fn with_no_table_at_all_everything_goes_to_the_match() {
let mut app = app();
let log = run(&mut app, vec![SessionEvent::LoggedOut, queued(0)]);
assert_eq!(log, vec!["match:LoggedOut", "match:LoginQueued"]);
assert!(app.world().resource::<NetHandlers>().census().is_empty());
}
/// **Every kind has exactly one owner** — the dispatch match in `net/apply.rs` or the table,
/// read off the built client — and the [`BROADCAST`] rows are the only kinds in both. A kind
/// neither owns is a packet the client decodes and then drops on the floor.
#[test]
fn every_session_event_kind_has_one_owner() {
use std::collections::BTreeSet;
let source = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/net/apply.rs"),
)
.expect("the drain's source");
let stripped = regex_lite_strip_comments(&source);
let by_name: HashMap<String, SessionEventKind> = SessionEventKind::all()
.map(|k| (format!("{k:?}"), k))
.collect();
let mut in_match: BTreeSet<SessionEventKind> = BTreeSet::new();
for token in stripped.split("SessionEvent::").skip(1) {
let name: String = token
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
if let Some(k) = by_name.get(&name) {
in_match.insert(*k);
}
}
let mut app = crate::game_plugins::schedule_tests::headless_client();
let table = app.world_mut().resource::<NetHandlers>().census();
let in_table: BTreeSet<SessionEventKind> = table.keys().copied().collect();
let mut problems = Vec::new();
for kind in SessionEventKind::all() {
let m = in_match.contains(&kind);
let t = in_table.contains(&kind);
let b = BROADCAST.contains(&kind);
match (m, t, b) {
(true, true, true) | (true, false, false) | (false, true, false) => {}
(true, true, false) => problems.push(format!(
"{kind:?}: owned by the match AND handled by {:?} — a peeled kind leaves the match (or is a BROADCAST row)",
table[&kind]
)),
(true, false, true) => problems.push(format!(
"{kind:?}: a BROADCAST row nobody listens to — drop the row"
)),
(false, true, true) => problems.push(format!(
"{kind:?}: a BROADCAST row the match no longer owns — drop the row"
)),
(false, false, _) => problems.push(format!(
"{kind:?}: decoded and dropped — no arm in the match, no handler in the table"
)),
}
}
eprintln!(
"session event kinds: {} — {} in the dispatch match, {} in the handler table ({} handlers), {} broadcast",
SessionEventKind::all().count(),
in_match.len(),
in_table.len(),
table.values().map(Vec::len).sum::<usize>(),
BROADCAST.len()
);
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
/// `//` comments out, so a variant named in prose does not count as an arm.
fn regex_lite_strip_comments(s: &str) -> String {
s.lines()
.map(|l| match l.find("//") {
Some(i) => &l[..i],
None => l,
})
.collect::<Vec<_>>()
.join("\n")
}
}
+10
View File
@@ -64,6 +64,15 @@ const JOURNAL_HEADER: &str = "t,x,y,z,mean_ms,p95_ms,streamed,entities,cpu_ms,ma
gpu_ms,gpu_opaque,gpu_static,gpu_transp,gpu_glow,gpu_post,gpu_ui,\
gpu_other\n";
/// The FPS journal switch's change callback (2008, 2303): a flag, the client's int-parse +
/// `!= 0`. The journal system reads the knob every frame, so the file opens on the next second
/// and closes the second it is turned off.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut journal: ResMut<FpsJournalSetting>) {
if ev.is("fpsJournal") {
journal.0 = ev.flag();
}
}
impl Plugin for FpsJournalPlugin {
fn build(&self, app: &mut App) {
// bevy's per-pass render diagnostics — the source of the GPU columns, and (under the
@@ -72,6 +81,7 @@ impl Plugin for FpsJournalPlugin {
// journal is exactly the build that has to carry it (2008).
app.add_plugins(RenderDiagnosticsPlugin)
.init_resource::<FpsJournalSetting>()
.add_observer(on_cvar)
.insert_resource(FpsJournal {
env_path: std::env::var("WOW_FPS_JOURNAL")
.ok()
+3 -1
View File
@@ -82,7 +82,9 @@ pub(crate) use clock::{process_cpu_secs, process_faults, system_cpu_ticks, threa
pub(crate) use gpu::{GpuMsShared, WgpuCensusShared};
#[cfg(feature = "dev")]
pub(crate) use hud::PerfHud;
pub(crate) use journal::{FpsJournalPlugin, FpsJournalSetting};
pub(crate) use journal::FpsJournalPlugin;
#[cfg(test)]
pub(crate) use journal::{on_cvar, FpsJournalSetting};
#[cfg(feature = "dev")]
pub(crate) use main_split::MainThreadSplit;
+2
View File
@@ -326,6 +326,8 @@ impl Plugin for PlayerPlugin {
Update,
world_focus::release_post_snap_hold.after(benilla_world::schedule::WorldStage::Stream),
);
app.add_observer(camera::on_cvar);
app.add_observer(camera_dynamics::on_cvar);
app.init_resource::<camera::LookConfig>();
app.init_resource::<camera::ZoomLimit>();
app.init_resource::<camera::FollowConfig>();
+54 -2
View File
@@ -152,7 +152,9 @@ impl ZoomLimit {
self.max = CAM_DIST_BASE_MAX * f;
}
/// The live factor — what the CVar table and the config file carry.
/// The live factor — the inverse of [`Self::set_factor`], kept for the weld test (the
/// registry holds the string itself since 2303).
#[cfg(test)]
pub(crate) fn factor(&self) -> f32 {
self.max / CAM_DIST_BASE_MAX
}
@@ -173,6 +175,54 @@ const LOOK_SENSITIVITY: f32 = 0.003;
/// reproduces the shipped feel exactly.
pub(crate) const MOUSE_SPEED_RANGE: std::ops::RangeInclusive<f32> = 0.5..=1.5;
/// The camera rows' change callback (decision 2303) — the look, zoom and follow knobs.
pub(crate) fn on_cvar(
ev: On<crate::cvars::CvarChanged>,
mut look: ResMut<LookConfig>,
mut zoom: ResMut<ZoomLimit>,
mut follow: ResMut<FollowConfig>,
) {
let v = ev.num();
match ev.key().as_str() {
"mouseinvertpitch" => look.invert_pitch = v != 0.0,
// The 1.12 slider's own range; an off-grid hand-edit rides between stops, like the others.
"mousespeed" => {
look.sensitivity = v.clamp(*MOUSE_SPEED_RANGE.start(), *MOUSE_SPEED_RANGE.end());
}
// The reference's `0x50b330` validator REJECTS an out-of-range value rather than clamping
// it: it prints `Value out of range (%f - %f)` and `CVar::Set` never stores, so the old
// value stands. That is a different posture from every clamping row, and it is the
// faithful one — a script writing 1e9 gets a refusal, not a silently pinned camera.
"camerayawmovespeed" | "camerapitchmovespeed" => {
if !CAMERA_SPEED_RANGE.contains(&v) {
warn!(
"cvar {}: value out of range ({} - {}) — ignored",
ev.name,
CAMERA_SPEED_RANGE.start(),
CAMERA_SPEED_RANGE.end()
);
return;
}
if ev.is("cameraYawMoveSpeed") {
look.yaw_speed = v;
} else {
look.pitch_speed = v;
}
}
"cameradistancemaxfactor" => zoom.set_factor(v),
// The three stops are 1 Smart / 2 Always / 3 Never; anything else reads as the registrar
// default rather than as a dead camera (`FollowStyle::from_cvar`).
"camerasmoothstyle" => follow.style = FollowStyle::from_cvar(v),
// Its sibling selector — the one the reference swaps in for the externally-driven states.
"camerasmoothtrackingstyle" => follow.tracking_style = FollowStyle::from_cvar(v),
// The auto-follow rate, clamped to 1.12's own AUTO_FOLLOW_SPEED slider range.
"camerayawsmoothspeed" => {
follow.yaw_speed = v.clamp(*FOLLOW_SPEED_RANGE.start(), *FOLLOW_SPEED_RANGE.end());
}
_ => {}
}
}
/// **The mouse-look rate law, and the one place benilla's units are not the reference's.**
///
/// The reference's own law is byte-VERIFIED (wow-re `world-click-drag-arbitration.md` §3.3):
@@ -330,7 +380,9 @@ impl FollowStyle {
}
}
/// The CVar string this style is — the value the table and `config.toml` carry.
/// The CVar string this style is — the inverse of [`Self::from_cvar`], kept for the
/// round-trip test (the registry holds the string itself since 2303).
#[cfg(test)]
pub(crate) fn cvar(self) -> &'static str {
match self {
Self::Never => "0",
@@ -104,6 +104,30 @@ pub(crate) struct CameraOptions {
pub(crate) water_collision: bool,
}
/// The camera options' change callback (2149, 2303). The numeric rows take the value
/// straight: the reference's own validator on them is `0x50b330`'s range REFUSAL, which is the
/// camera-speed rows' business ([`super::camera::on_cvar`]), not these.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut opts: ResMut<CameraOptions>) {
let v = ev.num();
match ev.key().as_str() {
"camerapivot" => opts.pivot = v != 0.0,
"camerawatercollision" => opts.water_collision = v != 0.0,
"camerapivotdxmax" => opts.pivot_dx_max = v,
"camerapivotdymin" => opts.pivot_dy_min = v,
"cameratargetsmoothspeed" => opts.target_smooth_speed = v,
"cameraterraintilt" => opts.terrain_tilt = v != 0.0,
"cameragroundsmoothspeed" => opts.ground_smooth_speed = v,
"cameraterraintilttimemin" => opts.tilt_time_min = v,
"cameraterraintilttimemax" => opts.tilt_time_max = v,
"camerabobbing" => opts.bobbing = v != 0.0,
"camerabobbinglramplitude" => opts.bob_lr_amplitude = v,
"camerabobbingudamplitude" => opts.bob_ud_amplitude = v,
"camerabobbingfrequency" => opts.bob_frequency = v,
"camerabobbingsmoothspeed" => opts.bob_smooth_speed = v,
_ => {}
}
}
impl Default for CameraOptions {
fn default() -> Self {
Self {
+25 -46
View File
@@ -146,17 +146,6 @@ pub(crate) const VIEW_DEFAULTS: [[&str; 3]; VIEW_COUNT] = [
/// the client opens on `THIRD_PERSON_A`.
pub(crate) const ACTIVE_VIEW_DEFAULT: &str = "1";
/// True if `name` is one of this module's sixteen CVars (case-insensitively, like every CVar
/// lookup). [`crate::cvars::apply_to_knobs`] asks so a `SaveView` marks the config dirty and the
/// view is actually persisted; there is nothing to apply, because [`CameraViews`] is the writer.
pub(crate) fn is_view_cvar(name: &str) -> bool {
name.eq_ignore_ascii_case(CVAR_ACTIVE_VIEW)
|| VIEW_CVARS
.iter()
.flatten()
.any(|n| n.eq_ignore_ascii_case(name))
}
/// One view's pose, in **benilla's** units: yards, and radians with [`FlyCam::pitch`]'s
/// positive-is-UP sign. `yaw` is an offset from the subject's facing (see the module header), so a
/// view whose yaw is `0.0` is "directly behind" whichever way the character is pointing.
@@ -270,17 +259,17 @@ impl CameraViews {
/// Startup: seed the five views (and the live index) from `config.toml`.
///
/// Reads [`crate::cvars::CvarPersist::stored`] rather than the VM's table for the reason 1622's
/// remembered-character row does: the VM's CVar table is a per-VM `Update` seed and does not exist
/// yet, while the persisted values are a resource from `CvarLoad` onward and stay current across a
/// VM replacement (1291). An absent key is the shipped default, which is every first run.
fn load_saved_views(persist: Res<crate::cvars::CvarPersist>, mut views: ResMut<CameraViews>) {
/// Reads the registry ([`crate::cvars::Cvars::get`]) rather than the VM's table for the reason
/// 1622's remembered-character row does: the VM's mirror is a per-VM `Update` seed and does not
/// exist yet, while the registry is a resource from `CvarLoad` onward and outlives every VM
/// (2303). An absent key answers the shipped default, which is every first run.
fn load_saved_views(cvars: Res<crate::cvars::Cvars>, mut views: ResMut<CameraViews>) {
for (view, slot) in views.views.iter_mut().enumerate() {
// Each field is independently optional, like the pose file's two keys: an absent or
// hand-mangled value costs that one number, not the whole view.
let field = |i: usize| {
persist
.stored(VIEW_CVARS[view][i])
cvars
.get(VIEW_CVARS[view][i])
.and_then(|s| s.trim().parse::<f32>().ok())
.filter(|v| v.is_finite())
};
@@ -294,8 +283,8 @@ fn load_saved_views(persist: Res<crate::cvars::CvarPersist>, mut views: ResMut<C
};
*slot = ViewPose::from_reference(raw(0), raw(1), raw(2));
}
if let Some(v) = persist
.stored(CVAR_ACTIVE_VIEW)
if let Some(v) = cvars
.get(CVAR_ACTIVE_VIEW)
.and_then(|s| s.trim().parse::<usize>().ok())
.filter(|v| *v < VIEW_COUNT)
{
@@ -316,7 +305,11 @@ struct ViewTargets<'w, 's> {
///
/// Ordered before [`super::control`] so a view taken this frame is the pose this frame's camera
/// seat is computed from, exactly like the pose file's restore.
fn drain_view_requests(script: Option<NonSendMut<UiScript>>, mut targets: ViewTargets) {
fn drain_view_requests(
script: Option<NonSendMut<UiScript>>,
mut cvars: ResMut<crate::cvars::Cvars>,
mut targets: ViewTargets,
) {
let Some(mut script) = script else {
return;
};
@@ -399,10 +392,10 @@ fn drain_view_requests(script: Option<NonSendMut<UiScript>>, mut targets: ViewTa
}
}
// Mirror into the CVar table so the change dirties `config.toml` and the view survives a
// restart — `set_cvar_engine` is the minimap-zoom pattern (the engine's own value moved, so
// the table follows AND queues the change). The resource stays authoritative either way, so a
// run with no VM at all (a bare test app) still has working views.
// Mirror into the registry so the change dirties `config.toml` and the view survives a
// restart — a host write (2303: the engine's own value moved, so the table follows and the
// VM's mirror learns it). The resource stays authoritative either way, so a run with no VM
// at all (a bare test app) still has working views.
for view in write_slots {
// A reset writes the reference's default STRING verbatim, not a re-rendered `%f` of it:
// `crate::cvars`'s file is a diff against the registered default, and only the exact text
@@ -415,11 +408,11 @@ fn drain_view_requests(script: Option<NonSendMut<UiScript>>, mut targets: ViewTa
} else {
rendered[field].as_str()
};
script.set_cvar_engine(VIEW_CVARS[view][field], value);
cvars.set(VIEW_CVARS[view][field], value);
}
}
if write_active {
script.set_cvar_engine(CVAR_ACTIVE_VIEW, &targets.views.current.to_string());
cvars.set(CVAR_ACTIVE_VIEW, &targets.views.current.to_string());
}
}
@@ -451,6 +444,9 @@ pub(super) fn plugin(app: &mut App) {
.add_systems(
Update,
drain_view_requests
// After the tick that queued them (`SetView`/`SaveView` are Lua's), before the
// control pass that seats the pose.
.after(crate::ui_script::UiInput)
.before(super::control)
// Capture parks the camera itself (`capture::probe_cam`), and `control` is gated
// off there for the same reason; a queued view must not steal a parked pose.
@@ -630,8 +626,8 @@ mod tests {
}
/// **The weld** ([`crate::cvars`]'s own convention): every one of the sixteen names is
/// registered, with the reference's default string, and nothing here has drifted from the
/// table `is_view_cvar` answers from.
/// registered, with the reference's default string. No observer watches them — this module
/// is their writer, and the registry persists a host write on its own (2303).
#[test]
fn the_view_cvars_are_registered_with_the_references_defaults() {
let registered = |name: &str| {
@@ -648,25 +644,8 @@ mod tests {
Some(VIEW_DEFAULTS[view][field]),
"{name} is not registered with its shipped default"
);
assert!(
is_view_cvar(name),
"{name} is not recognised as a view cvar"
);
assert!(
is_view_cvar(&name.to_ascii_uppercase()),
"{name} is case-sensitive"
);
}
}
assert_eq!(registered(CVAR_ACTIVE_VIEW), Some(ACTIVE_VIEW_DEFAULT));
assert!(is_view_cvar(CVAR_ACTIVE_VIEW));
assert!(
!is_view_cvar("cameraDistanceMaxFactor"),
"the zoom ceiling is not a view"
);
assert!(
!is_view_cvar("cameraDistanceE"),
"there are only five views"
);
}
}
+8
View File
@@ -1204,11 +1204,19 @@ pub(crate) struct BoothFraming<'w> {
/// Owns the portrait bake pipeline: the [`PortraitImages`] bridge + the per-slot off-screen booths.
pub(crate) struct PortraitPlugin;
/// The body panes' half-rate switch's change callback (1444, 2303): a flag.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut rate: ResMut<PaneRate>) {
if ev.is("boothHalfRate") {
rate.half = ev.flag();
}
}
impl Plugin for PortraitPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<PortraitImages>()
.init_resource::<PortraitBakes>()
.init_resource::<PaneRate>()
.add_observer(on_cvar)
.init_resource::<PaperDollBooth>()
.init_resource::<InspectBooth>()
.init_resource::<PetDollBooth>()
+6 -2
View File
@@ -374,7 +374,7 @@ fn apply_realm_policy(
mut msgs: MessageReader<RealmListMessage>,
mut realms: ResMut<Realms>,
choice: Res<RealmChoice>,
persist: Res<crate::cvars::CvarPersist>,
cvars: Res<crate::cvars::Cvars>,
) {
if !realms.env_read {
realms.env_read = true;
@@ -396,7 +396,11 @@ fn apply_realm_policy(
if realms.shown {
continue;
}
let remembered = persist.stored(CVAR_REALM_NAME).map(str::to_string);
// The registered default is the empty string — a client that has never connected.
let remembered = cvars
.get(CVAR_REALM_NAME)
.filter(|s| !s.is_empty())
.map(str::to_string);
if realms.current.is_none() {
realms.current = remembered.clone();
}
+13
View File
@@ -29,8 +29,21 @@ use bevy::prelude::*;
/// before `cvars::load_config` applies the saved value at `Startup` — `lib.rs` orders it there.
pub(crate) struct RealmlistPlugin;
/// `realmList`'s change callback (1667, 2303): a string row — a value that is not an address
/// is consumed with a warn, and the resource keeps its truth.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut realmlist: ResMut<Realmlist>) {
if !ev.is(CVAR_REALMLIST) {
return;
}
match normalize(&ev.new) {
Some(address) => realmlist.set(&address),
None => warn!("cvar realmList: unusable realmlist '{}' ignored", ev.new),
}
}
impl Plugin for RealmlistPlugin {
fn build(&self, app: &mut App) {
app.add_observer(on_cvar);
app.init_resource::<Realmlist>();
}
}
+2 -2
View File
@@ -65,7 +65,7 @@ use bevy::tasks::IoTaskPool;
use benilla_ui::script::UiScript;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
/// The reference's own file stem (`WoWScrnShot_`), kept: a player who knows what WoW screenshots
/// are called finds the same names here, and so does every screenshot-organizing tool the
@@ -255,7 +255,7 @@ impl Plugin for ScreenshotPlugin {
app.init_resource::<ScreenshotState>().add_systems(
Update,
(
report_captures.before(UiInput),
report_captures.in_set(UiFeed),
ask_for_captures
.after(UiInput)
.after(crate::bindings::BindingSet),
+26
View File
@@ -524,8 +524,34 @@ fn world_audio_live(
pub(crate) struct SoundPlugin;
/// The sound rows' change callback (decision 2303): the volumes clamp to `[0, 1]`, the enables
/// are the client's int-parse + `!= 0` — `SoundReverb`'s own parse is literally that
/// (`0x4574d0`: `setne al`). Writes only the arm it matched, so a `SoundConfig` change is a
/// sound setting moving and nothing else.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut sound: ResMut<SoundConfig>) {
let v = ev.num();
match ev.key().as_str() {
"mastervolume" => sound.master = v.clamp(0.0, 1.0),
"soundvolume" => sound.sfx = v.clamp(0.0, 1.0),
"musicvolume" => sound.music = v.clamp(0.0, 1.0),
"ambiencevolume" => sound.ambience = v.clamp(0.0, 1.0),
"mastersoundeffects" => sound.enabled = v != 0.0,
"enablemusic" => sound.music_enabled = v != 0.0,
"enableambience" => sound.ambience_enabled = v != 0.0,
"enableerrorspeech" => sound.error_speech = v != 0.0,
"sound_enablesoundwhengameisinbg" => sound.background_sound = v != 0.0,
"soundreverb" => sound.reverb = v != 0.0,
"soundoutputlimiter" => sound.limiter = v != 0.0,
"soundlisteneratcharacter" => sound.listener_at_character = v != 0.0,
"emotesounds" => sound.emote_sounds = v != 0.0,
"soundzonemusicnodelay" => sound.zone_music_no_delay = v != 0.0,
_ => {}
}
}
impl Plugin for SoundPlugin {
fn build(&self, app: &mut App) {
app.add_observer(on_cvar);
// Who gets sound: a run a human launched, and only that. The default posture is audible
// (decision 1026 — `SoundConfig::muted` starts false), so the silence has to be opt-in by
// the *automated* callers, both of which are unattended by construction:
+14
View File
@@ -389,11 +389,25 @@ impl Default for ClickConfig {
#[derive(Resource, Default)]
pub(crate) struct AssistAttack(pub(crate) bool);
/// The targeting rows' change callback (decision 2303): two flags.
pub(crate) fn on_cvar(
ev: On<crate::cvars::CvarChanged>,
mut click: ResMut<ClickConfig>,
mut assist: ResMut<AssistAttack>,
) {
match ev.key().as_str() {
"deselectonclick" => click.deselect_on_click = ev.flag(),
"assistattack" => assist.0 = ev.flag(),
_ => {}
}
}
/// Targeting: click-to-select + the ground selection ring.
pub(crate) struct TargetPlugin;
impl Plugin for TargetPlugin {
fn build(&self, app: &mut App) {
app.add_observer(on_cvar);
app.init_resource::<Selection>()
.init_resource::<ClickConfig>()
.init_resource::<AssistAttack>()
+11
View File
@@ -595,8 +595,19 @@ fn load_text_filter_lists(
pub(crate) struct TextFilterPlugin;
/// The two filter switches' change callback (decision 2303): flags — the reference's own
/// callbacks (`0x403570`, `0x4035b0`) mirror `SStrToInt(newValue)` into a global the same way.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut switches: ResMut<TextFilterSwitches>) {
match ev.key().as_str() {
"profanityfilter" => switches.profanity = ev.flag(),
"spamfilter" => switches.spam = ev.flag(),
_ => {}
}
}
impl Plugin for TextFilterPlugin {
fn build(&self, app: &mut App) {
app.add_observer(on_cvar);
app.init_resource::<TextFilterSwitches>()
.init_resource::<TextFilter>()
.add_systems(
+2 -2
View File
@@ -35,7 +35,7 @@ use benilla_ui::script::{ScriptValue, UiScript};
use crate::char_select::ClientState;
use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, SelfPlayer};
use crate::player::Player;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
/// The popup cue `0x4b5390` plays on both legs (`0x846b68`), gated on `MasterSoundEffects` at
/// the mixer like every SFX kit.
@@ -599,7 +599,7 @@ impl Plugin for TutorialPlugin {
// The gate costs nothing: the cascade still arms and still triggers, `fired`
// simply waits, and the first frame with an interface delivers the set.
feed_tutorials
.before(UiInput)
.in_set(UiFeed)
.run_if(crate::ui_script::ingame_ui_up),
drain_tutorials.after(UiInput),
),
+11 -6
View File
@@ -65,6 +65,13 @@ pub(crate) struct CooldownEvents;
// `cast_send`, so a second send path cannot be written by accident (decision 0914).
pub(crate) use cast_send::{CastCommit, CastLadder};
pub(crate) use cast_target::AutoSelfCast;
/// `autoSelfCast`'s change callback (decision 2303): a flag.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut auto: ResMut<AutoSelfCast>) {
if ev.is("autoSelfCast") {
auto.0 = ev.flag();
}
}
pub(crate) use errors::{
attack_actor_blocked, attack_actor_refusal, keyed_line, keyed_line_s, reagent_totem_refusal,
show_messages, ui_error_text, CastErrors, CastFail, Caster, FillArg, MessageSink, MountErrors,
@@ -357,6 +364,7 @@ pub(crate) struct UiActionPlugin;
impl Plugin for UiActionPlugin {
fn build(&self, app: &mut App) {
app.add_observer(on_cvar);
app.init_resource::<PlayerActions>()
.init_resource::<LearnedAbilities>()
.init_resource::<CastErrors>()
@@ -389,12 +397,11 @@ impl Plugin for UiActionPlugin {
ranks::normalize_action_ranks
.in_set(UnitFeed)
.before(feed::feed_actions),
feed::feed_actions.in_set(UnitFeed).before(UiInput),
feed::feed_actions.in_set(UnitFeed),
state::feed_action_state
.in_set(UnitFeed)
.in_set(CooldownEvents)
.after(feed::feed_actions)
.before(UiInput),
.after(feed::feed_actions),
drain::drain_action_sets.after(UiInput),
drain::drain_action_uses.after(UiInput),
// The T binding (0997): the attack arm's twin door, after the dispatch wrote
@@ -422,9 +429,7 @@ impl Plugin for UiActionPlugin {
// the next frame's cursor drive reads the mode. The cursor pre-empt, the
// right-press cancel, and the click commit register in the TARGET chain
// (ordering against the classifier and the select click is theirs to own).
targeting::feed_targeting_to_vm
.in_set(UnitFeed)
.before(UiInput),
targeting::feed_targeting_to_vm.in_set(UnitFeed),
targeting::drain_stop_targeting.after(UiInput),
// The item half's commit (decision 0923) — the bag / paper-doll click seam's
// `0x495d60`. A UI drain like the others: after the input pass, so a click
+7 -3
View File
@@ -24,7 +24,8 @@
//! refusal. The window opening clears the gate, so the first search is always allowed. (INTERIM,
//! decision 1511 — pinned to the in-flight §5's TU-2.)
//!
//! The net bridge ([`crate::net::apply::auction`]) fills [`AuctionOpen`] from the wire. Each frame
//! The packet handlers ([`net`], in the net handler table — decision 2305) fill [`AuctionOpen`]
//! from the wire. Each frame
//! [`feed_auction`] resolves each [`AuctionListEntry`] to a Lua-facing row (name/quality/icon via
//! the ask-once item-template cache + `ItemDisplayInfo.dbc`, seller via the ask-once name cache,
//! the time-left bucket from the wire's milliseconds), applies the sort, pushes the snapshot, and
@@ -49,13 +50,15 @@ use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_action::{show_messages, ui_error_text, MessageSink, Shown, UiError};
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
mod sort;
use sort::SortStack;
mod net;
/// The browse query rate limit, in seconds. **VERIFIED** (wow-re §5 TU-2): the reference arms the
/// gate with `tick + 0x1388` *after* the packet goes out, re-checks it inside the query itself,
/// and its refusal path fires **nothing at all** — no event, no error. That silence is why the
@@ -371,6 +374,7 @@ pub(crate) struct UiAuctionPlugin;
impl Plugin for UiAuctionPlugin {
fn build(&self, app: &mut App) {
net::register(app);
app.init_resource::<AuctionOpen>().add_systems(
Update,
(
@@ -388,7 +392,7 @@ impl Plugin for UiAuctionPlugin {
// packets set survive as wire re-asks either way.
feed_auction
.after(crate::ui_unit::UnitFeed)
.before(UiInput)
.in_set(UiFeed)
.run_if(crate::ui_script::ingame_ui_up),
drain_auction.after(UiInput),
),
@@ -1,23 +1,120 @@
//! The auction house's wire→session bridge (decision 1511 P1) — every `SMSG_AUCTION_*` lands here
//! and becomes state on [`AuctionOpen`], which [`crate::ui_auction::feed_auction`] turns into the
//! window's events on the next frame. Nothing here touches the VM: the feed owns the script.
//! The auction house's packet handlers (decision 1511 P1; in the net handler table since 2305 —
//! the first family out of the drain's dispatch match) — every `SMSG_AUCTION_*` lands here and
//! becomes state on [`AuctionOpen`], which [`super::feed_auction`] turns into the window's events
//! on the next frame. Nothing here touches the VM: the feed owns the script.
use benilla_protocol::messages::{
auction_action, auction_error, AuctionBidderNotification, AuctionCommandTail, AuctionListEntry,
AuctionOwnerNotification,
};
use benilla_protocol::{SessionEvent, SessionEventKind};
use benilla_ui::script::{BIDDER, LIST, OWNER};
use bevy::prelude::*;
use crate::ui_auction::{AuctionMessage, AuctionOpen};
use super::{AuctionMessage, AuctionOpen};
use crate::net::NetHandlerApp;
/// Register the house's handlers — called from [`super::UiAuctionPlugin`]. One per `SMSG_AUCTION_*`
/// kind, plus the session-end listener.
pub(super) fn register(app: &mut App) {
use SessionEventKind as K;
app.net_handler(K::AuctionHello, on_hello)
.net_handler(K::AuctionCommandResult, on_command_result)
.net_handler(K::AuctionListResult, on_list_result)
.net_handler(K::AuctionOwnerListResult, on_owner_list_result)
.net_handler(K::AuctionBidderListResult, on_bidder_list_result)
.net_handler(K::AuctionBidderNotification, on_bidder_notification)
.net_handler(K::AuctionOwnerNotification, on_owner_notification)
.net_handler(K::AuctionRemovedNotification, on_removed_notification)
.net_handler(K::Disconnected, on_session_end);
}
fn on_hello(In(ev): In<SessionEvent>, mut auction: ResMut<AuctionOpen>) {
if let SessionEvent::AuctionHello {
auctioneer,
house_id,
} = ev
{
auction_hello(auctioneer, house_id, &mut auction);
}
}
fn on_command_result(In(ev): In<SessionEvent>, mut auction: ResMut<AuctionOpen>) {
if let SessionEvent::AuctionCommandResult {
auction_id,
action,
error,
tail,
} = ev
{
auction_command_result(auction_id, action, error, &tail, &mut auction);
}
}
fn on_list_result(In(ev): In<SessionEvent>, mut auction: ResMut<AuctionOpen>) {
if let SessionEvent::AuctionListResult {
auctions,
total_count,
} = ev
{
auction_list_result(auctions, total_count, &mut auction);
}
}
fn on_owner_list_result(In(ev): In<SessionEvent>, mut auction: ResMut<AuctionOpen>) {
if let SessionEvent::AuctionOwnerListResult {
auctions,
total_count,
} = ev
{
auction_owner_list_result(auctions, total_count, &mut auction);
}
}
fn on_bidder_list_result(In(ev): In<SessionEvent>, mut auction: ResMut<AuctionOpen>) {
if let SessionEvent::AuctionBidderListResult {
auctions,
total_count,
} = ev
{
auction_bidder_list_result(auctions, total_count, &mut auction);
}
}
fn on_bidder_notification(In(ev): In<SessionEvent>, mut auction: ResMut<AuctionOpen>) {
if let SessionEvent::AuctionBidderNotification(n) = ev {
auction_bidder_notification(&n, &mut auction);
}
}
fn on_owner_notification(In(ev): In<SessionEvent>, mut auction: ResMut<AuctionOpen>) {
if let SessionEvent::AuctionOwnerNotification(n) = ev {
auction_owner_notification(&n, &mut auction);
}
}
fn on_removed_notification(In(ev): In<SessionEvent>, mut auction: ResMut<AuctionOpen>) {
if let SessionEvent::AuctionRemovedNotification { item_entry, .. } = ev {
auction_removed_notification(item_entry, &mut auction);
}
}
/// An open auction house dies with the socket (decision 1511): every auction command
/// re-validates the auctioneer server-side, so a session that survived a reconnect would be a
/// window whose every button silently failed. A listener on the session end, which the drain's
/// dispatch match still owns ([`crate::net::handlers::BROADCAST`]).
fn on_session_end(In(_): In<SessionEvent>, mut auction: ResMut<AuctionOpen>) {
auction.clear_session();
}
/// `MSG_AUCTION_HELLO`'s reply — **this**, not our send, is what opens the window (wow-re: the
/// window's opener runs inside the hello handler). The house id keys the deposit rate.
pub(super) fn auction_hello(auctioneer: u64, house_id: u32, auction: &mut AuctionOpen) {
fn auction_hello(auctioneer: u64, house_id: u32, auction: &mut AuctionOpen) {
auction.open(auctioneer, house_id);
}
/// One of the three list results. They share a frame and a record; only the tab differs.
pub(super) fn auction_list(
fn auction_list(
which: usize,
auctions: Vec<AuctionListEntry>,
total_count: u32,
@@ -34,7 +131,7 @@ pub(super) fn auction_list(
auction.set_list(which, auctions, total_count);
}
pub(super) fn auction_list_result(
fn auction_list_result(
auctions: Vec<AuctionListEntry>,
total_count: u32,
auction: &mut AuctionOpen,
@@ -42,7 +139,7 @@ pub(super) fn auction_list_result(
auction_list(LIST, auctions, total_count, auction);
}
pub(super) fn auction_owner_list_result(
fn auction_owner_list_result(
auctions: Vec<AuctionListEntry>,
total_count: u32,
auction: &mut AuctionOpen,
@@ -54,7 +151,7 @@ pub(super) fn auction_owner_list_result(
/// currently hold the bid on, so **one auction can appear twice in a page** — deduped here by
/// auction id, keeping the first occurrence, because a duplicated row would be two rows the player
/// can click that address the same auction.
pub(super) fn auction_bidder_list_result(
fn auction_bidder_list_result(
auctions: Vec<AuctionListEntry>,
total_count: u32,
auction: &mut AuctionOpen,
@@ -73,7 +170,7 @@ pub(super) fn auction_bidder_list_result(
/// server writes `auc ? auc->Id : 0`), so it is not a correlation handle on an error; and several
/// refusals send **no packet at all** (a bid the player cannot afford, a cancel whose cut they
/// cannot pay), which is why nothing in this arc blocks its UI waiting for an ack.
pub(super) fn auction_command_result(
fn auction_command_result(
auction_id: u32,
action: u32,
error: u32,
@@ -154,10 +251,7 @@ fn command_error_key(error: u32) -> Option<&'static str> {
///
/// **`bid_or_zero == 0` means WON**, not "no bid" — the server overloads the field, and reading it
/// the obvious way turns every win into an outbid notice.
pub(super) fn auction_bidder_notification(
notice: &AuctionBidderNotification,
auction: &mut AuctionOpen,
) {
fn auction_bidder_notification(notice: &AuctionBidderNotification, auction: &mut AuctionOpen) {
let won = notice.bid_or_zero == 0;
auction.messages.push(AuctionMessage::chat_item(
if won {
@@ -172,10 +266,7 @@ pub(super) fn auction_bidder_notification(
/// `SMSG_AUCTION_OWNER_NOTIFICATION` — one of ours sold, or took a bid. An all-zero bidder guid is
/// the "sold" signal (the server zeroes it on a sale).
pub(super) fn auction_owner_notification(
notice: &AuctionOwnerNotification,
auction: &mut AuctionOpen,
) {
fn auction_owner_notification(notice: &AuctionOwnerNotification, auction: &mut AuctionOpen) {
// **Two stages, and the first one decides whether anything is said at all** (wow-re §11.4).
// A NON-zero bidder guid is "somebody bid on your auction": the row updates and the client says
// nothing — `[0x4cd25f, 0x4cd3bd)` holds no display call. Only a zeroed guid reaches the message
@@ -197,7 +288,7 @@ pub(super) fn auction_owner_notification(
///
/// One id, unconditionally (wow-re §11.4: `0x4cd480` has exactly one `call 0x496720` and no branch
/// selecting an id).
pub(super) fn auction_removed_notification(item_entry: u32, auction: &mut AuctionOpen) {
fn auction_removed_notification(item_entry: u32, auction: &mut AuctionOpen) {
auction.messages.push(AuctionMessage::chat_item(
"ERR_AUCTION_REMOVED_S",
item_entry,
@@ -210,6 +301,42 @@ mod tests {
use super::*;
use benilla_ui::messages::MsgKind;
/// The registration, end to end: a hello and a session end through the real table.
#[test]
fn the_table_routes_the_hello_and_the_session_end_to_the_house() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<AuctionOpen>();
register(&mut app);
let through_the_match = |_: &mut World, unclaimed: Vec<SessionEvent>| {
let kinds: Vec<SessionEventKind> =
unclaimed.iter().map(SessionEventKind::from).collect();
assert_eq!(
kinds,
vec![SessionEventKind::Disconnected],
"the broadcast reaches the match too"
);
};
crate::net::handlers::dispatch(
app.world_mut(),
vec![SessionEvent::AuctionHello {
auctioneer: 0x10,
house_id: 1,
}],
|_, unclaimed| assert!(unclaimed.is_empty()),
);
assert_eq!(app.world().resource::<AuctionOpen>().auctioneer, Some(0x10));
crate::net::handlers::dispatch(
app.world_mut(),
vec![SessionEvent::Disconnected {
reason: "socket".into(),
end: benilla_protocol::SessionEnd::Lost,
}],
through_the_match,
);
assert_eq!(app.world().resource::<AuctionOpen>().auctioneer, None);
}
fn keys(auction: &AuctionOpen) -> Vec<(&'static str, MsgKind, Option<u32>)> {
auction
.messages
+1 -7
View File
@@ -158,13 +158,7 @@ impl Plugin for UiAuraPlugin {
.init_resource::<AuraFeedMemory>()
// Feed before the VM dispatch (like the unit feed), so a frame's OnEvent sees the fresh
// list; drain the cancels after, once the VM has queued them.
.add_systems(
Update,
feed_auras
.in_set(UnitFeed)
.in_set(AuraEvents)
.before(UiInput),
)
.add_systems(Update, feed_auras.in_set(UnitFeed).in_set(AuraEvents))
.add_systems(Update, drain_aura_cancels.after(UiInput))
// The ONLY teardown of the aura state, and it hangs off the session edge — never off
// the avatar entity's existence, which a worldport interrupts mid-session (0900).
+2 -2
View File
@@ -25,7 +25,7 @@ use bevy::prelude::*;
use benilla_ui::script::{BankState, UiScript};
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
/// The client-side `BankBagSlotPrices.dbc` table (decision 0604: the purchase ladder is client
@@ -95,7 +95,7 @@ impl Plugin for UiBankPlugin {
// Range-close before the feed so the clear turns into BANKFRAME_CLOSED the
// same frame; push before the input pass; drain after it (the merchant shape).
close_npc_session_out_of_range::<BankOpen>.before(feed_bank),
feed_bank.before(UiInput),
feed_bank.in_set(UiFeed),
drain_bank.after(UiInput),
),
);
+5 -3
View File
@@ -38,7 +38,7 @@ use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands};
use crate::player::Player;
use crate::ui_dialog_verbs::BattlefieldQueue;
use crate::ui_party::GroupState;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
/// The leash radius, squared: the CRT initialiser's `fld [0x806574]; fmul` of the `.rdata` f32
/// `5.55555534362793` (§7.1).
@@ -365,11 +365,13 @@ impl Plugin for BattlefieldPlugin {
app.init_resource::<Battlefield>().add_systems(
Update,
(
reset_on_world_enter.before(feed_battlefield),
reset_on_world_enter
.in_set(crate::ui_script::UiFeed)
.before(feed_battlefield),
feed_battlefield
.before(crate::ui_battlefield_score::feed_battlefield_score)
.before(crate::ui_dialog_verbs::feed_dialog_verbs)
.before(UiInput),
.in_set(UiFeed),
drain_battlefield.after(UiInput),
),
);
@@ -35,7 +35,7 @@ use crate::names::NameCache;
use crate::net::{ClientCommand, EnteredWorldMessage, GuidIndex, NetCommands, NetEntity, SelfGuid};
use crate::ui_dialog_verbs::BattlefieldQueue;
use crate::ui_party::GroupState;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_world_map::{project_on_displayed, WorldMapUiData};
/// The reference's request throttle: `RequestBattlefieldPositions` sends at most once per 5000 ms.
@@ -231,7 +231,7 @@ impl Plugin for BattlefieldPositionsPlugin {
Update,
(
reset_on_world_enter.before(feed_battlefield_positions),
feed_battlefield_positions.before(UiInput),
feed_battlefield_positions.in_set(UiFeed),
drain_battlefield_positions.after(UiInput),
),
);
@@ -26,7 +26,7 @@ use benilla_ui::script::{BattlefieldScoreRow, BattlefieldScores, BattlefieldStat
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_dialog_verbs::BattlefieldQueue;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
use crate::world_state_ui::WorldStateUiRes;
/// `RequestBattlefieldScoreData`'s throttle — `0x4aa170`: `now + 0x1388`.
@@ -182,7 +182,7 @@ impl Plugin for BattlefieldScorePlugin {
// status-3 message, as in the client.
feed_battlefield_score
.before(crate::ui_dialog_verbs::feed_dialog_verbs)
.before(UiInput),
.in_set(UiFeed),
drain_battlefield_score.after(UiInput),
),
);
+2 -2
View File
@@ -45,7 +45,7 @@ use bevy::prelude::*;
use crate::area::AreaTableRes;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
/// The innkeeper's pending bind question. Written by the net drain's `BinderConfirm` arm, read by
@@ -266,7 +266,7 @@ impl Plugin for UiBinderPlugin {
// Range-close before the feed so walking away takes the dialog down the same
// frame (the gossip window's ordering, for the same reason).
close_npc_session_out_of_range::<BinderState>.before(feed_binder),
feed_binder.before(UiInput),
feed_binder.in_set(UiFeed),
drain_binder.after(UiInput),
),
);
+1 -5
View File
@@ -16,7 +16,6 @@ use benilla_ui::script::{ScriptValue, UiScript};
use crate::creature_anim::{CastEvent, CastEventKind, Casting, PlaySeq};
use crate::net::{ClientCommand, GuidIndex, NetCommands, SelfGuid};
use crate::ui_action::Spells;
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
/// One edge of our own cast's lifecycle, queued by the net bridge for the cast bar.
@@ -785,10 +784,7 @@ impl Plugin for UiCastPlugin {
.init_resource::<LocalMoveStart>()
.add_systems(
Update,
(local_self_cancel, feed_cast_bar)
.chain()
.in_set(UnitFeed)
.before(UiInput),
(local_self_cancel, feed_cast_bar).chain().in_set(UnitFeed),
);
}
}
+4 -4
View File
@@ -55,7 +55,7 @@ use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::pending_item_ops::PendingItemOps;
use crate::portrait::PaperDollBooth;
use crate::ui_items::{find_equip_slot, item_link};
use crate::ui_script::{gate, UiInput};
use crate::ui_script::{gate, UiFeed, UiInput};
/// Equipment slots, 0-based (`EQUIPMENT_SLOT_*`): the inv-slot array index of the main hand,
/// off hand, and ranged slots the weapon-skill / offhand / wand resolutions read.
@@ -132,9 +132,9 @@ impl Plugin for UiCharPlugin {
// fire" rule, across the two feeds.
feed_char
.before(crate::ui_items::feed::feed_containers)
.before(UiInput),
watch_skill_ups.before(UiInput),
feed_skills.before(UiInput),
.in_set(UiFeed),
watch_skill_ups.in_set(UiFeed),
feed_skills.in_set(UiFeed),
drain_skill_abandons.after(UiInput),
),
);
+19 -8
View File
@@ -10,7 +10,7 @@
use bevy::prelude::*;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
#[cfg(test)]
mod ace_gate_tests;
@@ -75,6 +75,7 @@ pub(crate) struct UiChatPlugin;
impl Plugin for UiChatPlugin {
fn build(&self, app: &mut App) {
app.add_observer(combat::on_cvar);
app.init_resource::<ChatLog>()
.init_resource::<away::AfkMirror>()
.init_resource::<away::AfkMirrorMemo>()
@@ -85,9 +86,9 @@ impl Plugin for UiChatPlugin {
.init_resource::<recruitment::GuildRecruitmentCascade>()
.init_resource::<language::ChatLanguages>()
.init_resource::<combat::CombatLogRanges>()
// `CombatLogPeriodicSpells`' knob, beside its sibling range set — both are
// `KnobParams` members, so a missing one is not a dormant default but a
// STARTUP PANIC in `cvars::load_config` (which takes them all as `ResMut`).
// `CombatLogPeriodicSpells`' knob, beside its sibling range set — both are what
// `combat::on_cvar` writes, so a missing one is not a dormant default but a
// PANIC on the first write of either row (2303; before that, at startup).
// 947ba585f registered it only in the `cvar_app()` test helper, and the client
// stopped booting; the unit suites never noticed because each builds its own
// world. `scripts/smoke.sh` is the gate that sees this class.
@@ -145,7 +146,12 @@ impl Plugin for UiChatPlugin {
// The world broadcasts' resolve pass — before the drain that renders what it produces,
// so an alarm or a shutdown countdown lands on the frame it decodes like every other
// chat source.
.add_systems(Update, broadcast::feed_broadcasts.before(feed::feed_chat))
.add_systems(
Update,
broadcast::feed_broadcasts
.in_set(crate::ui_script::UiFeed)
.before(feed::feed_chat),
)
// **Never against the boot VM** (B376's second half). `feed_chat` takes the whole
// queue with `mem::take` and fires each line as a real `CHAT_MSG_*` — so a drain
// against a VM with no ChatFrame does not defer the lines, it DESTROYS them, with no
@@ -183,7 +189,7 @@ impl Plugin for UiChatPlugin {
.add_systems(
Update,
feed::feed_chat
.before(UiInput)
.in_set(UiFeed)
.after(crate::ui_unit::UnitFeed)
.after(crate::ui_guild::GuildFeed)
.run_if(crate::ui_script::ingame_ui_up),
@@ -194,11 +200,16 @@ impl Plugin for UiChatPlugin {
.add_systems(Update, idle::stamp_input.before(UiInput))
// A fresh VM gets the joined-channel mirror re-pushed once (decision 1291) — before
// the feed, so the reload frame's first routed line already renders numbered.
.add_systems(Update, channels::seed_channels.before(feed::feed_chat))
.add_systems(
Update,
channels::seed_channels
.in_set(crate::ui_script::UiFeed)
.before(feed::feed_chat),
)
// RequestTimePlayed() -> CMSG_PLAYED_TIME, and SMSG_PLAYED_TIME -> TIME_PLAYED_MSG.
// Beside the chat feed because /played is a chat command and the answer prints there
// too; before the input pass for the same reason feed_chat is.
.add_systems(Update, feed::played_time_bridge.before(UiInput))
.add_systems(Update, feed::played_time_bridge.in_set(UiFeed))
// The input: open on ENTER (after the UI input pass has set UiKeyboardCapture, so we
// don't reopen the box that's already eating keys), then drain any submitted line. Both
// touch the single NonSend VM, so they chain. In-world only (decision 0193): at the
+2 -2
View File
@@ -283,14 +283,14 @@ pub(crate) fn reconcile_afk_mirror(
/// reference tables that happen to overlap, not one shared idea.
pub(crate) fn movement_clears_afk(
binds: Res<crate::bindings::BindingsState>,
cvars: Res<crate::cvars::CvarPersist>,
cvars: Res<crate::cvars::Cvars>,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
commands: Res<crate::net::NetCommands>,
mut chat: ResMut<ChatLog>,
mut mirror: ResMut<AfkMirror>,
) {
use crate::bindings::cmd;
if !mirror.is_afk() || cvars.stored("autoClearAFK").is_some_and(|v| v == "0") {
if !mirror.is_afk() || cvars.flag("autoClearAFK") == Some(false) {
return;
}
// The inline-array idiom `player::follow`'s `move_start` uses — its six, plus JUMP.
+25
View File
@@ -172,6 +172,17 @@ impl CombatLogRanges {
self.death
}
/// Whether `name` is one of the eight — asked before [`Self::set`] so an observer holding
/// the resource mutably does not flag a change it did not make.
pub(crate) fn is_range_cvar(&self, name: &str) -> bool {
name.eq_ignore_ascii_case(DEATH_LOG_RANGE_CVAR)
|| (0..self.class.len()).any(|i| {
UnitClass::from_index(i)
.range_cvar()
.is_some_and(|c| c.eq_ignore_ascii_case(name))
})
}
/// Apply one `SetCVar` to the table — `true` if the name was one of the eight.
///
/// The class names are walked through [`UnitClass::range_cvar`] so this module keeps exactly
@@ -195,6 +206,20 @@ impl CombatLogRanges {
}
}
/// The combat log rows' change callback (decision 2303): the eight display ranges (yards, the
/// CVar's float field) and the periodic-effects switch.
pub(crate) fn on_cvar(
ev: On<crate::cvars::CvarChanged>,
mut ranges: ResMut<CombatLogRanges>,
mut periodic: ResMut<LogPeriodicSpells>,
) {
if ev.is(LOG_PERIODIC_CVAR) {
periodic.0 = ev.flag();
} else if ranges.is_range_cvar(&ev.name) {
ranges.set(&ev.name, ev.num());
}
}
/// **`CombatLogPeriodicSpells`** — "Log Periodic Effects", and its blast radius is wider than the
/// options row's wording.
///
+29 -51
View File
@@ -15,7 +15,7 @@ use bevy::prelude::*;
mod parse;
#[cfg(test)]
pub(super) use parse::lua_quoted_string;
pub(super) use parse::{console_command, parse_line, ParsedChat};
pub(super) use parse::{parse_line, ParsedChat};
use crate::creature_anim::{move_flags, MovementState};
use crate::net::{ClientCommand, NetCommands, SelfPlayer};
@@ -116,7 +116,10 @@ pub(super) struct ChatProbes<'w, 's> {
/// [`crate::target`]'s shared resolver so they commit through the same SetSelection path a click
/// does. Chat never writes [`Selection`] itself.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct ChatOut<'w> {
pub(super) struct ChatOut<'w, 's> {
/// The console registry's lane: a `/console` line runs against the world at the next sync
/// point ([`crate::console::execute`]).
console: Commands<'w, 's>,
stand: MessageWriter<'w, crate::player::StandStateRequest>,
sheath: MessageWriter<'w, crate::creature_anim::SheathRequest>,
target: MessageWriter<'w, crate::target::TargetByNameRequest>,
@@ -134,10 +137,6 @@ pub(super) struct ChatOut<'w> {
/// since a resource reachable twice from one system is a `B0002` panic on the first live frame
/// (1903). Nothing else in this system touches `UiErrorKeys`.
ui_errors: ResMut<'w, crate::ui_action::UiErrorKeys>,
/// `/console detailDoodadAlpha` — the ground-clutter cutout reference. Rides this bundle for
/// the same arity reason as `ui_errors` above, and like it, nothing else in this system
/// touches `ClutterConfig` (a resource reachable twice from one system is a `B0002` panic).
clutter: ResMut<'w, benilla_world::clutter::ClutterConfig>,
}
// One parameter per concern — the chat drain fans out to every command's consumer.
@@ -168,12 +167,11 @@ fn engine_verbs(
out.push((String::new(), ParsedChat::Uninvite { name: Some(name) }));
}
for line in script.take_console_lines() {
// `ConsoleExec` already wrote the CVar lines to the store; what reaches here is a
// console COMMAND — a name the engine's own command table owns rather than
// `CVar::Register`'s, which is why `detailDoodadAlpha` lives here and not in the CVar
// store (wow-re: registrar `0x63f9e0`, so it never persists — 2012).
let parsed = console_command(&line);
out.push((line, parsed));
// `ConsoleExec` already wrote the valued CVar lines to the store; what reaches here is
// a console COMMAND — a name the engine's own command table owns rather than
// `CVar::Register`'s (`detailDoodadAlpha`: registrar `0x63f9e0`, so it never persists —
// 2012) — or a bare CVar name for the registry to print (2303).
out.push((line.clone(), ParsedChat::Console { line }));
}
for cmd in script.take_channel_commands() {
out.push((String::new(), ParsedChat::Channel(cmd)));
@@ -946,46 +944,26 @@ pub(super) fn drain_chat_input(
ParsedChat::Quit => {
script.queue_session_request(benilla_ui::script::SessionRequest::Quit)
}
// `/reload` and `/console reloadUI` — the same deferred rebuild `ReloadUI()` queues
// (decision 1291), through the same session seam as the exit verbs above.
// `/reload` — the same deferred rebuild `ReloadUI()` queues (decision 1291), through
// the same session seam as the exit verbs above.
ParsedChat::ReloadUi => {
script.queue_session_request(benilla_ui::script::SessionRequest::ReloadUi)
}
// `/console detailDoodadAlpha [0..255]` — the reference's own console command
// (`0x6739a0`), and the dial that decides where ground clutter first appears: the
// detail-doodad draw alpha-tests `texel.a x distance_ramp` against it, so 128 (the
// default) hides everything past ~61 yd of the 70 yd fade horizon and a lower value
// walks that onset out toward the horizon. Non-persistent, exactly as the reference's
// command table is.
ParsedChat::DetailDoodadAlpha { value } => {
let text = match value {
Some(v) => {
chat_out.clutter.alpha_ref = f32::from(v) / 255.0;
format!("detailDoodadAlpha set to {v}")
// A `/console` line for the command registry (decision 2303). Deferred to the world
// because a command's body is `fn(&mut World, &str)` — the reference's handlers run
// against the whole client too — and its lines land in the chat frame as system
// text, the seam `/console` output has always used.
ParsedChat::Console { line } => {
chat_out.console.queue(move |world: &mut World| {
let lines = crate::console::execute(world, &line);
let mut log = world.resource_mut::<super::feed::ChatLog>();
for text in lines {
log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
text,
));
}
None => format!(
"detailDoodadAlpha is {} (usage: /console detailDoodadAlpha 0-255)",
(chat_out.clutter.alpha_ref * 255.0).round() as u32
),
};
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
text,
));
}
ParsedChat::ConsoleUnknown { cmd } => {
const IMPLEMENTED: &str = "reloadUI, detailDoodadAlpha, and `<cvar> <value>`";
let text = if cmd.is_empty() {
format!("console: no command given (this client implements: {IMPLEMENTED})")
} else {
format!(
"console: '{cmd}' is not implemented (this client implements: {IMPLEMENTED})"
)
};
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
text,
));
});
}
// The reference's own handler body, run in the VM (the 0668 posture) — `/trade`,
// `/inspect`, the loot-method trio, and `/script`'s raw chunk.
@@ -1571,8 +1549,8 @@ fn is_dnd(self_q: &Query<&crate::net::ObjectStore, With<crate::net::SelfPlayer>>
/// `autoClearAFK` — registered default `"1"` (`0x5e24d4 push 0x82e748`). Its reader tests
/// `[cvar+0x28]` for non-zero (`0x5eb84b`), and with the CVar OFF the clear is a **total** no-op:
/// no echo, no mirror write, no packet.
fn auto_clear_afk(cvars: &crate::cvars::CvarPersist) -> bool {
cvars.stored("autoClearAFK").is_none_or(|v| v != "0")
fn auto_clear_afk(cvars: &crate::cvars::Cvars) -> bool {
cvars.flag("autoClearAFK").unwrap_or(true)
}
/// Turn an addon's `SendChatMessage` calls into sends (decision 1199).
@@ -1591,7 +1569,7 @@ pub(super) fn drain_addon_chat_sends(
// The optimistic AFK mirror (`[0xb6e5cc]`) the `/afk` toggle reads and writes — 2088.
mut mirror: ResMut<super::away::AfkMirror>,
// `autoClearAFK`, whose registered default is `"1"` — the gate on the implicit clear.
cvars: Res<crate::cvars::CvarPersist>,
cvars: Res<crate::cvars::Cvars>,
// Our own descriptor, for the DND arm's LIVE `PLAYER_FLAGS & 0x4` read (DND has no mirror).
self_q: Query<&crate::net::ObjectStore, With<crate::net::SelfPlayer>>,
) {
+10 -37
View File
@@ -163,23 +163,17 @@ pub(in crate::ui_chat) enum ParsedChat {
/// straight out of the shipped `GlobalStrings.lua` (decision 0983). Resolved in the drain,
/// which holds the VM, so the text can never go stale against the install.
MacroHelp,
/// `/reload`, `/console reloadUI`, or `ReloadUI()` typed through `/script` — tear the UI
/// session down and build a new one without leaving the world (decision 1291). Queued on the
/// session seam like [`ParsedChat::Logout`]; [`crate::ui_script::run_pending_reload`] runs it
/// at the top of the next frame.
/// `/reload`, or `ReloadUI()` typed through `/script` — tear the UI session down and build a
/// new one without leaving the world (decision 1291). Queued on the session seam like
/// [`ParsedChat::Logout`]; [`crate::ui_script::run_pending_reload`] runs it at the top of
/// the next frame. (`/console reloadUI` reaches the same request through the command
/// registry's `reloadUI`.)
ReloadUi,
/// `/console detailDoodadAlpha [0..255]` — the **ground-clutter cutout reference**, a real
/// console command in the reference (`0x6739a0`; registrar `0x63f9e0`, a command table and not
/// `CVar::Register`, so it never persists — 1804 does not apply to it). It decides where grass
/// first appears: the detail-doodad draw alpha-tests `texel.a x distance_ramp` against this, so
/// at the default 128 nothing survives past ~61 yd of the 70 yd horizon, and lowering it walks
/// that onset out toward the horizon. Bare = report the current value (the reference reads an
/// uninitialised stack slot there; a readout is the useful reading of "no argument").
DetailDoodadAlpha { value: Option<u8> },
/// A `/console` command this client does not implement — the engine console is otherwise not
/// here. Answered with a system line naming what was asked, because a claimed alias that
/// silently drops its argument reads as a hang.
ConsoleUnknown { cmd: String },
/// A `/console` line the engine's CVar store did not consume — a command name, or a bare
/// CVar name to print — for the command registry ([`crate::console::execute`], decision
/// 2303): the reference's `ConsoleCommand` table, which every subsystem registers into and
/// which answers an unknown line by saying so.
Console { line: String },
/// A slash line matching neither a chat command nor an `EmotesText` name — dropped.
Unknown,
}
@@ -278,27 +272,6 @@ pub(in crate::ui_chat) fn lua_quoted_string(s: &str) -> String {
/// The per-command argument grammar. Each arm is the reference handler's own body reduced to what
/// it does with `msg` — the aliases that reach it are the table's business, never this function's.
/// Classify one **console command** line — what is left after `ConsoleExec` has written the CVar
/// lines (2008), i.e. a name the engine's own command table owns rather than `CVar::Register`'s.
/// Pure, so the arms are testable without a VM; called from `engine_verbs`'s console drain.
pub(in crate::ui_chat) fn console_command(line: &str) -> ParsedChat {
let mut words = line.split_whitespace();
match words.next() {
Some(cmd) if cmd.eq_ignore_ascii_case("reloadui") => ParsedChat::ReloadUi,
// The reference rejects an out-of-range value rather than saturating
// (`0x6739b9: cmp eax,0xff; jbe`), so out-of-range and unparseable are the same `None`
// here and the handler says so. A bare name reports the current value.
Some(cmd) if cmd.eq_ignore_ascii_case("detaildoodadalpha") => {
ParsedChat::DetailDoodadAlpha {
value: words.next().and_then(|v| v.parse::<u8>().ok()),
}
}
_ => ParsedChat::ConsoleUnknown {
cmd: line.trim().to_string(),
},
}
}
fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
use SlashIndex as S;
match index {
+5 -1
View File
@@ -130,7 +130,11 @@ pub(super) fn sync_chat_logging(
pub(super) fn plugin(app: &mut App) {
app.add_systems(
Update,
sync_chat_logging.in_set(crate::char_select::InWorldGated),
// After the tick: the flags are Lua's (`LoggingChat`/`LoggingCombat`), read the frame
// they move.
sync_chat_logging
.after(crate::ui_script::UiInput)
.in_set(crate::char_select::InWorldGated),
);
}
+7 -2
View File
@@ -809,11 +809,16 @@ pub(super) fn plugin(app: &mut App) {
// [`restore_chat_looks`], decision 2119. Only the watcher belongs in `Update`.
.add_systems(
Update,
watch_chat_looks.in_set(crate::char_select::InWorldGated),
// After the tick: every write it watches is Lua's.
watch_chat_looks
.after(crate::ui_script::UiInput)
.in_set(crate::char_select::InWorldGated),
)
.add_systems(
Update,
save_chat_looks.in_set(crate::char_select::InWorldGated),
save_chat_looks
.after(crate::ui_script::UiInput)
.in_set(crate::char_select::InWorldGated),
);
// **The session-end flush is not registered here.** It is called from inside
// `ui_script::end_ui_session` ([`fold_dying_vm_chat_cache`]) so that it covers `/reload` — a
-41
View File
@@ -2540,47 +2540,6 @@ fn level_up_gains_are_matched_by_level_and_a_miss_is_not_an_absence() {
);
}
/// `detailDoodadAlpha` — the reference's own console command (`0x6739a0`), and the dial that
/// decides where ground clutter first appears (the detail-doodad draw alpha-tests
/// `texel.a × distance_ramp` against it). It is a **console command**, not a CVar (registrar
/// `0x63f9e0`, not `CVar::Register`), so it arrives through the console drain rather than the
/// CVar store `ConsoleExec` writes — this pins the classifier that splits the two. The three
/// cases the reference distinguishes: in range sets it, out of range is REJECTED rather than
/// saturated (`0x6739b9: cmp eax,0xff; jbe`), and unparseable is that same rejection.
#[test]
fn console_detail_doodad_alpha_parses() {
use super::input::{console_command, ParsedChat};
assert_eq!(
console_command("detailDoodadAlpha 8"),
ParsedChat::DetailDoodadAlpha { value: Some(8) }
);
// The console is case-insensitive, like the engine's own table lookup.
assert_eq!(
console_command("DETAILDOODADALPHA 255"),
ParsedChat::DetailDoodadAlpha { value: Some(255) }
);
assert_eq!(
console_command("detailDoodadAlpha 256"),
ParsedChat::DetailDoodadAlpha { value: None }
);
assert_eq!(
console_command("detailDoodadAlpha wat"),
ParsedChat::DetailDoodadAlpha { value: None }
);
// Bare = report the current value; and the sibling command still routes.
assert_eq!(
console_command("detailDoodadAlpha"),
ParsedChat::DetailDoodadAlpha { value: None }
);
assert_eq!(console_command("reloadUI"), ParsedChat::ReloadUi);
assert_eq!(
console_command("nosuchthing"),
ParsedChat::ConsoleUnknown {
cmd: "nosuchthing".to_string()
}
);
}
/// **The ding block is printed once, by the reference's own window.**
///
/// `benilla.toc` sources `Interface\FrameXML\ChatFrame.xml` off the player's chain, and stock
+1 -4
View File
@@ -59,10 +59,7 @@ impl Plugin for UiCraftPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<CraftOpen>().add_systems(
Update,
(
feed_craft.in_set(UnitFeed).before(UiInput),
drain_craft.after(UiInput),
),
(feed_craft.in_set(UnitFeed), drain_craft.after(UiInput)),
);
}
}
+4 -3
View File
@@ -52,7 +52,7 @@ use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfGuid, SelfPlayer};
use crate::ui_party::GroupState;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
/// The pet trainer's pending question: the latch (`0xc4d7b0/b4`) and the cost (`0xc4d7b8`).
@@ -1100,7 +1100,7 @@ impl Plugin for UiDialogVerbsPlugin {
// countdown popup, would be fired at the boot VM in 2214's one-frame window
// and lost. Gated, the queue waits; nothing here clears without a VM.
feed_dialog_verbs
.before(UiInput)
.in_set(UiFeed)
.run_if(crate::ui_script::ingame_ui_up),
// **After the frame's pick, which is the reference's own order.** The poll
// is `0x4923b0`, called from `CGWorldFrame`'s OnUpdate at `0x4818ca` — and
@@ -1133,9 +1133,10 @@ impl Plugin for UiDialogVerbsPlugin {
// In-world only: the claim is about the VM, but the query is a world
// packet, and the boot VM exists at the glue screen too.
meeting_stone_enter_world
.in_set(crate::ui_script::UiFeed)
.before(feed_meeting_stone)
.in_set(crate::char_select::InWorldGated),
feed_meeting_stone.before(UiInput),
feed_meeting_stone.in_set(UiFeed),
// **Before the target chain, not merely after the input pass** — and this one
// is a correctness order, not a tidiness one.
//
+3 -3
View File
@@ -2,7 +2,7 @@
//! window's `DressUpModel` widget verbs ([`DressUpIntent`], queued engine-side) and the booth's
//! look ([`DressUpPreview`]).
//!
//! Three jobs, each frame, before the VM ticks ([`UiInput`]):
//! Three jobs, each frame, in the feed phase before the VM ticks ([`UiFeed`]):
//!
//! - **Apply the intents, in order.** `Dress` drops every substitution (the ref's
//! `SetUnit("player")` on open and `Dress()` on Reset); `Undress` strips every worn piece but
@@ -37,7 +37,7 @@ use crate::entities::equip_slot;
use crate::items::Items;
use crate::net::{NetCommands, NetEntity, ObjectStore, SelfPlayer};
use crate::portrait::{DressUpLook, DressUpPreview};
use crate::ui_script::UiInput;
use crate::ui_script::UiFeed;
/// The equipment slots a dressing-room look reads off the player — every rendered slot
/// (`EQUIPMENT_SLOT_*`), which is exactly the set [`equip_slot`] can map an item into.
@@ -229,7 +229,7 @@ pub(crate) struct DressUpUiPlugin;
impl Plugin for DressUpUiPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<DressUpRoom>()
.add_systems(Update, feed_dressup.in_set(UiInput));
.add_systems(Update, feed_dressup.in_set(UiFeed));
}
}
+2 -2
View File
@@ -49,7 +49,7 @@ use crate::names::NameCache;
use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, SelfPlayer};
use crate::target::Selection;
use crate::ui_action::Spells;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
/// `SPELL_EFFECT_DUEL` — the `Effect[0]` value that identifies the duel spell in the player's own
/// spellbook (7266 "Duel" on 1.12 data, granted to every race/class at creation). The reference
@@ -468,7 +468,7 @@ impl Plugin for UiDuelPlugin {
// the VM — so the tick has to run first or every "Duel starting: N" lands a
// frame late.
tick_countdown.before(feed_duel),
feed_duel.before(UiInput),
feed_duel.in_set(UiFeed),
drain_duel.after(UiInput),
),
);
+2 -2
View File
@@ -53,7 +53,7 @@ use bevy::prelude::*;
use benilla_ui::script::{FollowRequest as ScriptFollowRequest, ScriptValue, UiScript};
use crate::player::{FollowRequest, FollowState};
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
/// What the VM has last been told, so the two events fire exactly on transitions and never once a
/// frame. Mirrors [`FollowState::guid`]; `None` means the VM believes no follow is running.
@@ -126,7 +126,7 @@ impl Plugin for UiFollowPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<FollowFeed>().add_systems(
Update,
(feed_follow.before(UiInput), drain_follow.after(UiInput)),
(feed_follow.in_set(UiFeed), drain_follow.after(UiInput)),
);
}
}
+10
View File
@@ -315,8 +315,18 @@ fn use_gamma_ui_shaders(
pub(crate) struct UiGammaPlugin;
/// Brightness's change callback (2182, 2303). The clamp is OURS and the reference has none —
/// the reason it costs one is on [`GAMMA_RANGE`], and nothing a player can reach from the panel
/// meets it.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut gamma: ResMut<DisplayGamma>) {
if ev.is(benilla_ui::script::CVAR_GAMMA) {
gamma.0 = ev.num().clamp(*GAMMA_RANGE.start(), *GAMMA_RANGE.end());
}
}
impl Plugin for UiGammaPlugin {
fn build(&self, app: &mut App) {
app.add_observer(on_cvar);
app.init_resource::<DisplayGamma>()
.add_plugins(ExtractComponentPlugin::<UiGammaLane>::default())
.add_systems(Update, stamp_lane_gamma);
+2 -2
View File
@@ -69,7 +69,7 @@ use benilla_assets::coords::bevy_to_wow;
use crate::net::{ClientCommand, NetCommands};
use crate::player::Player;
use crate::ui_script::{UiInput, VmMemo};
use crate::ui_script::{UiFeed, UiInput, VmMemo};
/// Spell 7355 "Stuck" — what `Stuck()` casts, and the Help window's "Auto-Unstuck".
///
@@ -449,7 +449,7 @@ impl Plugin for UiGmTicketPlugin {
.add_systems(
Update,
(
feed_gm_ticket.before(UiInput),
feed_gm_ticket.in_set(UiFeed),
drain_gm_ticket.after(UiInput),
),
);
+2 -2
View File
@@ -34,7 +34,7 @@ use benilla_ui::script::{GossipMenu, GossipOptionView, GossipQuestRow, ScriptVal
use crate::names::NameCache;
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_quest::{row_is_active, row_is_one_click};
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
/// The open gossip menu, filled by the net bridge ([`crate::net`]) and read by [`feed_gossip`]. The
@@ -201,7 +201,7 @@ impl Plugin for UiGossipPlugin {
// push before the input pass so an open/close is on screen the same frame; drain
// after it so a click's intent goes out the same frame (mirrors ui_items).
close_npc_session_out_of_range::<GossipState>.before(feed_gossip),
feed_gossip.before(UiInput),
feed_gossip.in_set(UiFeed),
drain_gossip.after(UiInput),
),
);
+11 -2
View File
@@ -46,7 +46,7 @@ use bevy::prelude::*;
use crate::net::{ClientCommand, NetCommands};
use crate::query_cache::QueryCache;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
mod feed;
mod lines;
@@ -780,15 +780,24 @@ pub(crate) struct GuildFeed;
/// The guild windows' session: the wire mirror, the VM feed, and the outbound intents.
pub(crate) struct UiGuildPlugin;
/// Guild Member Alert's change callback (1589, 2303) — conjunct 2 of the sign-on/sign-off
/// line's condition; a flag.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut notify: ResMut<GuildMemberNotify>) {
if ev.is("guildMemberNotify") {
notify.0 = ev.flag();
}
}
impl Plugin for UiGuildPlugin {
fn build(&self, app: &mut App) {
app.add_observer(on_cvar);
crate::query_cache::register::<GuildState>(app);
app.init_resource::<GuildState>()
.init_resource::<GuildMemberNotify>()
.add_systems(
Update,
(
feed::feed_guild.before(UiInput).in_set(GuildFeed),
feed::feed_guild.in_set(UiFeed).in_set(GuildFeed),
feed::drain_guild.after(UiInput),
)
// **Never against the boot VM** (1348/1978, and B376's half of it): the feed
+2 -2
View File
@@ -56,7 +56,7 @@ use bevy::prelude::*;
use benilla_ui::script::{HonorState, InspectHonorData, ScriptValue, UiScript};
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_script::{UiInput, VmMemo};
use crate::ui_script::{UiFeed, VmMemo};
/// The inspect-honor reply we currently hold, or `None` before one lands.
///
@@ -90,7 +90,7 @@ impl Plugin for UiHonorPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<InspectHonor>()
.init_resource::<HonorFeedState>()
.add_systems(Update, feed_honor.in_set(UiInput));
.add_systems(Update, feed_honor.in_set(UiFeed));
}
}
+2 -2
View File
@@ -47,7 +47,7 @@ use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore};
use crate::portrait::InspectBooth;
use crate::ui_script::UiInput;
use crate::ui_script::UiFeed;
/// Which unit the inspect window is bound to — the ref's `InspectFrame.unit`, mirrored app-side so
/// the feed knows whether to resolve anything. The **token** is the identity (not the guid): the
@@ -81,7 +81,7 @@ impl Plugin for InspectUiPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<InspectTarget>()
.init_resource::<InspectFeedState>()
.add_systems(Update, feed_inspect.in_set(UiInput));
.add_systems(Update, feed_inspect.in_set(UiFeed));
}
}
+2 -2
View File
@@ -113,7 +113,7 @@ use bevy::prelude::*;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
/// The window `CanShowResetInstances()` offers the row in, in seconds — `cmp eax, 0x15f90` at
/// `0x495ce6` against `time(0) - <when we left>`, unsigned, so the boundary is inclusive.
@@ -590,7 +590,7 @@ impl Plugin for UiInstancePlugin {
// a map change and its answer should not be a frame apart.
clear_witness_on_logout.before(track_instance_state),
track_instance_state.before(feed_instance),
feed_instance.before(UiInput),
feed_instance.in_set(UiFeed),
drain_instance.after(UiInput),
),
);
+2 -2
View File
@@ -38,7 +38,7 @@ use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
use crate::query_cache::QueryCache;
use crate::ui_mail::MailOpen;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
/// One page of a book as the wire gave it (`SMSG_PAGE_TEXT_QUERY_RESPONSE`).
pub(crate) struct PageText {
@@ -190,7 +190,7 @@ impl Plugin for UiItemTextPlugin {
(
// Feed before the input pass so an open paints the same frame; drain after it so
// a Close click clears the same frame (the ui_mail ordering).
feed_item_text.before(UiInput),
feed_item_text.in_set(UiFeed),
drain_item_text.after(UiInput),
),
);
+5 -6
View File
@@ -1174,16 +1174,15 @@ impl Plugin for UiItemsPlugin {
// pie waits for the NEXT store change).
feed_containers
.in_set(UnitFeed)
.before(crate::ui_action::CooldownEvents)
.before(UiInput),
.before(crate::ui_action::CooldownEvents),
// The shared item-tooltip store: answer stat asks before the input pass so a
// re-hover the very next frame already sees them.
feed_item_stats.in_set(UnitFeed).before(UiInput),
feed_item_sets.in_set(UnitFeed).before(UiInput),
feed_item_stats.in_set(UnitFeed),
feed_item_sets.in_set(UnitFeed),
// The roll table, pushed whole once per VM (1547) — before the input pass, so
// the first hover of the session already resolves a drop's suffix lines.
feed_random_properties.in_set(UnitFeed).before(UiInput),
feed_player_req.in_set(UnitFeed).before(UiInput),
feed_random_properties.in_set(UnitFeed),
feed_player_req.in_set(UnitFeed),
// After the input pass, so a click's UseContainerItem goes out the same frame.
drain_container_uses.after(UiInput),
// The left-click pick/place/split drain — a queued move → CMSG_SWAP_INV_ITEM /
+10 -2
View File
@@ -337,8 +337,16 @@ impl Plugin for UiLayoutPlugin {
// which is the reference's own single write site and the only one a `/reload` reaches.
app.init_resource::<LayoutFile>().add_systems(
Update,
(load_layout, watch_layout, save_layout)
.chain()
(
// The restore is a push the tick should see the frame it lands: the feed phase.
load_layout.in_set(crate::ui_script::UiFeed),
// The watcher and the save read what the drag pump — Lua's, in the tick — did:
// after it. Load precedes watch through the phases, as the old chain had it,
// so the watcher never reads the restore's own move.
(watch_layout, save_layout)
.chain()
.after(crate::ui_script::UiInput),
)
.in_set(crate::char_select::InWorldGated),
);
}
+2 -2
View File
@@ -45,7 +45,7 @@ use benilla_ui::script::{SessionRequest, UiScript};
use bevy::prelude::*;
use crate::net::{ClientCommand, LoggedOutMessage, NetCommands, SelfGuid};
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
/// The reference's refusal line, as a **catalog key** rather than a literal — message id `0x180`,
/// the sole argument of the `DisplayError` at `0x5aaf26` (decision 1821). VERIFIED, superseding
@@ -264,7 +264,7 @@ impl Plugin for UiLogoutPlugin {
app.init_resource::<LogoutState>().add_systems(
Update,
(
feed_logout.before(UiInput),
feed_logout.in_set(UiFeed),
drain_logout.after(UiInput),
exit_on_logout_complete.after(UiInput),
),
+14 -3
View File
@@ -34,7 +34,7 @@ use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_items::KEYRING_CONTAINER;
use crate::ui_party::{GroupState, GROUPTYPE_RAID, GROUP_MEMBER_SUBGROUP};
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
/// The coin-pile row icons (direct `Interface\Icons` paths — `SetTexture` takes them as-is, no DBC),
/// **six of them, one per decade of copper**, all VERIFIED to extract from `interface.MPQ`.
@@ -470,7 +470,7 @@ impl LootState {
/// `show_loot_spam` is 1.12's own `showLootSpam` — the *Detailed Loot Information* checkbox, whose
/// subject is **group loot rolls**, not loot messages generally (decision 1589, the Chat page).
/// It rides here rather than on [`crate::ui_loot_roll`] because it is one loot knob among the
/// loot knobs and `cvars::KnobParams` fetches this resource already. VERIFIED at the bytes (wow-re
/// loot knobs and [`on_cvar`] writes both. VERIFIED at the bytes (wow-re
/// `system/object-layer/scratch/lootroll-chat-and-lifecycle.md` §4): the CVar is `0xb4e2bc`,
/// registered at `0x48fd1c` with default `"1"` and flags 5, and a byte census over the whole
/// binary finds exactly four references — one writer and three readers, all three inside the
@@ -618,8 +618,19 @@ pub(crate) struct LootMoveStart(pub(crate) bool);
pub(crate) struct UiLootPlugin;
/// The loot rows' change callback (decision 2303): two flags.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut loot: ResMut<LootConfig>) {
match ev.key().as_str() {
"autolootdefault" => loot.auto_loot = ev.flag(),
// The loot-roll detail switch (1589) — a flag over the roll-line composer's two shapes.
"showlootspam" => loot.show_loot_spam = ev.flag(),
_ => {}
}
}
impl Plugin for UiLootPlugin {
fn build(&self, app: &mut App) {
app.add_observer(on_cvar);
app.init_resource::<LootState>()
.init_resource::<LootConfig>()
.init_resource::<LootLatch>()
@@ -630,7 +641,7 @@ impl Plugin for UiLootPlugin {
(
// Push before the input pass so an open/close is on screen the same frame; drain
// after it so a click's intent goes out the same frame (mirrors ui_merchant).
feed_loot.before(UiInput),
feed_loot.in_set(UiFeed),
drain_loot.after(UiInput),
// Predicate B, per frame, after the net drain that arms the latch. The anim
// driver then orders itself after THIS (`crate::creature_anim`), closing the
+2 -2
View File
@@ -94,7 +94,7 @@ use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, SelfGuid};
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
/// Give up re-checking a pending announcement's names after this many frames — the same budget and
/// reasoning as [`crate::ui_loot`]'s receive lines (a negative-cached entry never resolves).
@@ -299,7 +299,7 @@ impl Plugin for UiLootRollPlugin {
// Same ordering rule as the loot window (ui_loot): push before the input pass so a
// freshly opened roll is on screen the same frame, drain after it so a Need/Greed/
// Pass click goes out the same frame.
feed_loot_rolls.before(UiInput),
feed_loot_rolls.in_set(UiFeed),
drain_loot_rolls.after(UiInput),
),
);
+10 -5
View File
@@ -71,19 +71,24 @@ impl Plugin for UiMacroPlugin {
// old answer to the ordering half alone — after `AssetSet::Open` this ran
// before the VM existed and silently pushed no icon list at all — and a
// session-keyed claim answers both halves at once.
load_icon_catalog,
load_icon_catalog.in_set(crate::ui_script::UiFeed),
// Before the action feeds read it (they run in `UnitFeed`), so a macro edited
// this frame reports its new spell's cooldown the same frame.
rebind_macro_spells.before(crate::ui_unit::UnitFeed),
rebind_macro_spells
.in_set(crate::ui_script::UiFeed)
.before(crate::ui_unit::UnitFeed),
// Load runs in-world only: the per-character file needs the character, and the
// roster only names it once a login is live. It self-gates on the identity, so
// a re-entry with a different character reloads and a re-entry with the same
// one is a no-op.
load_macros.in_set(InWorldGated),
load_macros
.in_set(crate::ui_script::UiFeed)
.in_set(InWorldGated),
// The save edge is checked every frame, in or out of world: the macro window is
// `whileDead = 1` and reachable from the game menu, and a `/logout` must not
// strand an unsaved edit.
save_dirty_macros.after(load_macros),
// strand an unsaved edit. After the tick that dirtied it — which also puts it
// after the load, in the feed phase.
save_dirty_macros.after(crate::ui_script::UiInput),
),
);
}
+2 -2
View File
@@ -55,7 +55,7 @@ use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, SelfPlayer};
use crate::query_cache::QueryCache;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
mod pending;
@@ -217,7 +217,7 @@ impl Plugin for UiMailPlugin {
// out the same frame (the ui_merchant ordering exactly). After the UnitFeed set
// so the SetInboxItem tooltip reads a landed item-template store.
close_npc_session_out_of_range::<MailOpen>.before(feed_mail),
feed_mail.after(crate::ui_unit::UnitFeed).before(UiInput),
feed_mail.after(crate::ui_unit::UnitFeed).in_set(UiFeed),
drain_mail.after(UiInput),
// The world-enter one-shot (decision 0548 §7: "once at UI/login load") — its
// own system, ordering-independent of the feed/drain pair above.
+2 -4
View File
@@ -30,7 +30,7 @@ use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_items::{item_link, slot_guid, wire_pos};
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
use benilla_protocol::messages::BAG_PLAYER_INVENTORY;
@@ -145,9 +145,7 @@ impl Plugin for UiMerchantPlugin {
// unordered, the first paint races feed_item_stats/feed_player_req and the
// usable reds drop until the next content repaint.
close_npc_session_out_of_range::<MerchantOpen>.before(feed_merchant),
feed_merchant
.after(crate::ui_unit::UnitFeed)
.before(UiInput),
feed_merchant.after(crate::ui_unit::UnitFeed).in_set(UiFeed),
drain_merchant.after(UiInput),
),
);
+1 -2
View File
@@ -22,7 +22,6 @@ use bevy::prelude::*;
use benilla_protocol::messages::{MirrorTimerKind, MirrorTimerStart};
use crate::ui_action::Spells;
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
/// One mirror-timer edge off the wire, queued by the net bridge for the bars.
@@ -197,7 +196,7 @@ pub(crate) struct UiMirrorPlugin;
impl Plugin for UiMirrorPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<MirrorTimerFeed>()
.add_systems(Update, feed_mirror_timers.in_set(UnitFeed).before(UiInput));
.add_systems(Update, feed_mirror_timers.in_set(UnitFeed));
}
}
+1 -1
View File
@@ -653,7 +653,7 @@ impl Plugin for UiModelsPlugin {
// published (the UI pass's `paint_script`) — see [`forget_dead_vm_tiles`].
.add_systems(
Update,
forget_dead_vm_tiles.before(crate::ui_script::UiInput),
forget_dead_vm_tiles.in_set(crate::ui_script::UiFeed),
)
// After the extract published this frame's requests, and before the pose/palette
// passes read the roots' transforms (they run in PostUpdate).
+2 -2
View File
@@ -21,13 +21,13 @@ use benilla_ui::script::UiScript;
use benilla_assets::LockRecover;
use crate::net::PingShared;
use crate::ui_script::UiInput;
use crate::ui_script::UiFeed;
pub(crate) struct UiNetPlugin;
impl Plugin for UiNetPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, feed_net_stats.before(UiInput));
app.add_systems(Update, feed_net_stats.in_set(UiFeed));
}
}
+2 -2
View File
@@ -32,7 +32,7 @@ use benilla_protocol::messages::{
};
use bevy::prelude::*;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
mod feed;
pub(crate) use feed::{
@@ -47,7 +47,7 @@ impl Plugin for UiPartyPlugin {
app.init_resource::<GroupState>().add_systems(
Update,
(
feed::feed_party.before(UiInput),
feed::feed_party.in_set(UiFeed),
feed::drain_party.after(UiInput),
),
);
+3 -5
View File
@@ -155,13 +155,11 @@ impl Plugin for UiPetPlugin {
// edges against last frame's answer and the Pet tab never came up.
feed_pet_bar
.in_set(UnitFeed)
.after(crate::ui_pet_stats::PetSnapshot)
.before(UiInput),
.after(crate::ui_pet_stats::PetSnapshot),
feed_pet_unit
.in_set(UnitFeed)
.after(crate::ui_pet_stats::PetSnapshot)
.before(UiInput),
feed_pet_menu.in_set(UnitFeed).before(UiInput),
.after(crate::ui_pet_stats::PetSnapshot),
feed_pet_menu.in_set(UnitFeed),
drain_pet_actions.after(UiInput),
drain_pet_menu.after(UiInput),
),
+1 -2
View File
@@ -72,8 +72,7 @@ impl Plugin for UiPetBookPlugin {
// tab flipped this frame already reads a populated book.
feed_pet_book
.in_set(UnitFeed)
.before(crate::ui_action::CooldownEvents)
.before(UiInput),
.before(crate::ui_action::CooldownEvents),
// After the input pass, and — like `ui_pet`'s own drain — writing back into
// `PetBar`, whose next feed carries the mirrored autocast bit.
drain_pet_book.after(UiInput),
+2 -3
View File
@@ -1,7 +1,7 @@
//! The app-side **pet paper-doll feed** (decision 1057) — `PetPaperDollFrame`'s data, the
//! [`crate::ui_char`] pattern pointed at the pet.
//!
//! Two jobs, each frame, before the VM ticks ([`UiInput`]):
//! Two jobs, each frame, in the feed phase before the VM ticks ([`crate::ui_script::UiFeed`]):
//!
//! - **The pet's [`UnitCombatStats`]**, built through [`crate::ui_char::unit_combat_stats`] — the
//! descriptor-only core, which is all a creature has. It is the *same* snapshot type and the
@@ -35,7 +35,6 @@ use benilla_ui::script::{UiScript, UnitCombatStats};
use crate::portrait::PetDollBooth;
use crate::ui_char::{fire_stat_transitions, unit_combat_stats};
use crate::ui_pet::{PetBar, PetUnit};
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
pub(crate) struct UiPetDollPlugin;
@@ -44,7 +43,7 @@ impl Plugin for UiPetDollPlugin {
fn build(&self, app: &mut App) {
// Rides the unit feed beside the pet bar's and the stat block's, and before the VM ticks —
// the whole page repaints out of the one pass that pushes the pet's health.
app.add_systems(Update, feed_pet_doll.in_set(UnitFeed).before(UiInput));
app.add_systems(Update, feed_pet_doll.in_set(UnitFeed));
}
}
+1 -8
View File
@@ -34,7 +34,6 @@ use benilla_ui::script::{PetStats, ScriptValue, UiScript};
use crate::names::NameCache;
use crate::net::{NetCommands, ObjectStore};
use crate::ui_pet::{PetBar, PetUnit};
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
/// `UNIT_FIELD_BYTES_0` byte 1 == 3 — **Hunter**, the class the four stat bindings gate on
@@ -93,13 +92,7 @@ impl Plugin for UiPetStatsPlugin {
fn build(&self, app: &mut App) {
// Rides the unit feed beside the pet bar's own, and before the VM ticks — the pet frame
// repaints out of the same pass that pushes its health.
app.add_systems(
Update,
feed_pet_stats
.in_set(UnitFeed)
.in_set(PetSnapshot)
.before(UiInput),
);
app.add_systems(Update, feed_pet_stats.in_set(UnitFeed).in_set(PetSnapshot));
}
}
+2 -2
View File
@@ -96,7 +96,7 @@ use bevy::prelude::*;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
use crate::query_cache::QueryCache;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::NpcSession;
mod feed;
@@ -424,7 +424,7 @@ impl Plugin for UiPetitionPlugin {
// frame — every other NPC window's ordering.
crate::ui_session::close_npc_session_out_of_range::<GuildRegistrarState>
.before(feed::feed_petition),
feed::feed_petition.before(UiInput),
feed::feed_petition.in_set(UiFeed),
feed::drain_petition.after(UiInput),
),
);
+2 -2
View File
@@ -30,7 +30,7 @@ use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_action::{show_messages, ui_error_text, MessageSink, Shown, Spells, UiError};
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
/// The open questgiver view — exactly the wire packet that opened the current panel. The feed turns
@@ -264,7 +264,7 @@ impl Plugin for UiQuestPlugin {
// drain after it so a click's intent goes out the same frame (mirrors
// ui_gossip/merchant).
close_npc_session_out_of_range::<QuestGiver>.before(feed_quest),
feed_quest.before(UiInput),
feed_quest.in_set(UiFeed),
drain_quest.after(UiInput),
),
);
+3 -3
View File
@@ -56,7 +56,7 @@ use crate::names::NameCache;
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
use crate::query_cache::QueryCache;
use crate::ui_action::Spells;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_unit::UnitFeed;
/// The top bit `QuestObjective::creature_or_go` carries for a gameobject objective —
@@ -143,10 +143,10 @@ impl Plugin for UiQuestLogPlugin {
.add_systems(
Update,
(
feed_quest_log.in_set(UnitFeed).before(UiInput),
feed_quest_log.in_set(UnitFeed),
// Before the script tick, so a QuestTimerFrame OnUpdate this frame reads this
// frame's clock (the `minimap::feed_game_time` shape).
feed_server_clock.before(UiInput),
feed_server_clock.in_set(UiFeed),
drain_quest_log_abandons.after(UiInput),
drain_quest_log_pushes.after(UiInput),
drain_quest_log_collapses.after(UiInput),
+2 -2
View File
@@ -48,7 +48,7 @@ use bevy::prelude::*;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_action::{show_messages, ui_error_text, MessageSink, Shown, UiError};
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
/// How many frames a queued line waits for its `%s` before it is dropped — [`crate::ui_loot`]'s
/// `RECEIVE_MAX_TRIES`, and the same reasoning: a name query is one round trip, so any real answer
@@ -144,7 +144,7 @@ impl Plugin for QuestSharePlugin {
app.init_resource::<QuestShare>().add_systems(
Update,
(
feed_quest_share.before(UiInput),
feed_quest_share.in_set(UiFeed),
drain_quest_share.after(UiInput),
),
);
+2 -2
View File
@@ -39,7 +39,7 @@ use benilla_ui::script::{FactionEntry, ReputationSend, ReputationState, UiScript
use crate::net::{ClientCommand, NetCommands, ObjectStore, Reputations, SelfPlayer};
use crate::target::Factions;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
/// The cumulative standing edges of the eight ranks: `RANK_BOUNDS[r]` is rank `r`'s absolute floor
/// and `RANK_BOUNDS[r + 1]` its ceiling. The widths between them are vmangos's `PointsInRank`
@@ -57,7 +57,7 @@ impl Plugin for UiReputationPlugin {
app.add_systems(
Update,
(
feed_reputation.before(UiInput),
feed_reputation.in_set(UiFeed),
drain_reputation_sends.after(UiInput),
),
);
+16 -17
View File
@@ -238,7 +238,7 @@ pub(crate) fn arm_entry_ui_load(world: &mut World) {
/// phase can write CVars — the character screen's AddOns panel is the live case, a *Load out of
/// date AddOns* click — and `sync_cvars` (1291) registers them onto whichever VM is in the world,
/// so this is the last moment that table exists. Folded into the persist state, the
/// `set_cvar_saved_base` + `register_cvars` pair in the load below starts the new VM at the
/// `set_cvar_saved_base` + `seed_cvars` pair in the load below starts the new VM at the
/// player's value. That is exactly the route `/reload` has always taken through
/// [`end_ui_session`]; 2226 makes the first login take it too.
///
@@ -483,15 +483,19 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// gets a turn — so this edge would load a client with no CVar table at all. Nothing had ever
// noticed, because until now no interface file read a CVar at load.
//
// The two lines are `sync_cvars`'s own first two, in its own order, and the order is
// load-bearing: the saved config goes in FIRST so registration starts each key at the player's
// value rather than the factory one (1291) — reversed, a reload would quietly reset every
// knobless CVar to its default. `sync_cvars` still does its full knob-derived pass on the next
// `Update`; both calls are idempotent, and a re-register never clobbers a live value.
if let Some(persist) = world.get_resource::<crate::cvars::CvarPersist>() {
script.set_cvar_saved_base(persist.saved_base());
// The two lines are `sync_cvars`'s own seed, in its own order, and the order is
// load-bearing: the file's unclaimed entries go in FIRST so an addon's `RegisterCVar` starts
// its key at the player's value rather than the declared one (1291); then the registry's
// whole table at its live values (2303) — the store that outlived the last VM. `sync_cvars`
// still runs its own per-VM seed on the next `Update`; both are idempotent. A world with no
// registry (a bare test app) gets the registered defaults.
match world.get_resource::<crate::cvars::Cvars>() {
Some(cvars) => {
script.set_cvar_saved_base(cvars.orphans());
script.seed_cvars(cvars.vm_seed());
}
None => script.register_cvars(crate::cvars::registered_pairs()),
}
script.register_cvars(crate::cvars::registered_pairs());
// The realm name goes in BEFORE the UI loads, because `GetRealmName()` is read at addon file
// scope — `MyAddonDB[GetRealmName()] = …` is the corpus idiom, and 24 addons stop on it
// (decision 1195). The roster carries the auth realm-list entry this session connected to.
@@ -531,14 +535,9 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// character select reached here inside the save debounce; the fold carries that click now, by
// the route the reload path always used. Absent both (a bare test world) is the registrar
// default: check ON.
let version_check = script
.cvar("checkAddonVersion")
.map(|v| v != "0")
.unwrap_or_else(|| {
world
.get_resource::<crate::cvars::CvarPersist>()
.is_none_or(crate::cvars::CvarPersist::addon_version_check)
});
let version_check = world
.get_resource::<crate::cvars::Cvars>()
.is_none_or(crate::cvars::Cvars::addon_version_check);
// **…and the rest of the same class** (decision 2241). Each of these was a per-VM claim in
// `Update`, which answers *which* VM and not *when inside its life* — and every one of them
// backs a Lua getter the load burst below reads:
+63 -11
View File
@@ -281,13 +281,31 @@ pub(crate) struct UiPassState {
#[derive(Resource, Default)]
pub(crate) struct PointerOverUiPanel(pub(crate) bool);
/// The player-UI FEED phase — every push into the VM that this frame's tick must see: the
/// snapshot feeds, the one-shot drains that `fire_event`, the per-VM seeds. Chained
/// `UiFeed` → [`UiInput`] → [`UiPaint`] and ordered after [`WorldStage::Net`] (decision 2304), so
/// a feed reads what this frame's packets did, and a handler its event fires reads what every
/// other feed pushed — the reference's own frame, where packet dispatch precedes every
/// `FrameScript_SignalEvent` and both precede `OnUpdate`. The failure the order closes was found
/// in the unit feed first ([`crate::ui_unit::UnitFeed`]'s doc): unordered, `apply_net_updates`
/// could land between two feeds, and a synchronous handler fired by the later one re-read the
/// earlier one's pre-mutation push. **No run condition on the set** — whether a feed may run
/// before the in-game interface exists is a judgement per feed (2232), and the ones that may not
/// ride `UnitFeed`, a gated sub-phase of this one. Every system holding the VM in `Update`
/// declares its side of the tick — this set, or `.after(UiInput)` — and
/// `game_plugins::schedule_tests` holds it on the built schedule.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UiFeed;
/// The player-UI PAINT pass — the quad walk ([`extract::paint_script`]), ordered after the world's
/// camera update so that world-anchored widgets are drawn from this frame's camera (decision 2168).
/// The last of the three UI phases (see [`UiFeed`]).
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UiPaint;
/// The player-UI input pass — hit-testing + handler firing. Ordered before [`WorldStage::Input`] so
/// the [`PlayerUiHover`] it produces is folded into `PointerOverUi` before `player::control` reads it.
/// The player-UI input pass — the tick, hit-testing + handler firing. Ordered before
/// [`WorldStage::Input`] so the [`PlayerUiHover`] it produces is folded into `PointerOverUi` before
/// `player::control` reads it; the middle of the three UI phases (see [`UiFeed`]).
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UiInput;
@@ -403,8 +421,31 @@ pub(crate) fn seam_scale(window_h: f32, ui_scale: f32) -> f32 {
/// tick → resolve → extract pipeline into [`UiQuads`], plus the input pass ([`feed_ui_input`]).
pub(crate) struct UiScriptPlugin;
/// `uiScale`'s change callback (decision 2303): the dial's own `[0.5, 1.5]` clamp.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut scale: ResMut<UiScaleCvar>) {
if ev.is("uiScale") {
scale.0 = ev.num().clamp(0.5, 1.5);
}
}
/// `/console reloadUI` — the reference's own console command, and the same deferred rebuild
/// `ReloadUI()` and `/reload` queue (decision 1291): through the session seam, run by
/// [`run_pending_reload`] at the top of the next frame.
fn console_reload_ui(world: &mut World, _args: &str) -> Vec<String> {
match world.get_non_send_resource_mut::<UiScript>() {
Some(mut script) => {
script.queue_session_request(benilla_ui::script::SessionRequest::ReloadUi);
Vec::new()
}
None => vec!["reloadUI: no interface to reload".to_string()],
}
}
impl Plugin for UiScriptPlugin {
fn build(&self, app: &mut App) {
use crate::console::ConsoleCommandApp;
app.add_observer(on_cvar);
app.console_command("reloadUI", "Reload the interface.", console_reload_ui);
// **The quit root of the shutdown tail, on the exit edge** (decision 1528). It was
// `.add_systems(Update, shutdown_on_exit)` below, which cannot see the `AppExit` a player
// produces — the close button's is written in `PostUpdate` — so quitting from in-world
@@ -474,14 +515,26 @@ impl Plugin for UiScriptPlugin {
PreUpdate,
(run_pending_reload, lifecycle::run_pending_entry_load).chain(),
)
// `GetFramerate()`'s host half (decision 1195), before the VM ticks so an `OnUpdate`
// **The three UI phases, in order** (decision 2304): every push after the net
// drain, then the tick, then the paint. `UiFeed`'s own doc has the why; `UiInput`
// sits before the world's input stage so the hover it produces reaches the pointer
// arbiter in time (its doc).
.configure_sets(
Update,
(
UiFeed.after(WorldStage::Net),
UiInput.before(WorldStage::Input),
UiPaint,
)
.chain(),
)
// `GetFramerate()`'s host half (decision 1195), in the feed phase so an `OnUpdate`
// handler reads this frame's number rather than the previous one's.
.add_systems(Update, feed_framerate.before(UiInput))
.add_systems(Update, feed_framerate.in_set(UiFeed))
// `tick_script` resolves layout; `feed_ui_input` hit-tests against those rects, so they
// chain (also required because both take the single `NonSend` VM). The pair runs before
// `WorldStage::Input` so the hover result reaches the pointer arbiter in time. The input
// pass is in-world only (decision 0193): the character-select glue screen owns the
// pointer + keyboard there (its exit edge resets the latches this pass normally drives).
// chain (also required because both take the single `NonSend` VM). The input pass is
// in-world only (decision 0193): the character-select glue screen owns the pointer +
// keyboard there (its exit edge resets the latches this pass normally drives).
.init_resource::<UiPassState>()
.init_resource::<PointerOverUiPanel>()
.add_systems(
@@ -495,8 +548,7 @@ impl Plugin for UiScriptPlugin {
// The binding dispatch (0997) runs in this same set, after the feed: it
// reads the capture gate the feed just wrote, so a key a focused box
// consumed this frame never also fires a binding.
.before(crate::bindings::BindingSet)
.before(WorldStage::Input),
.before(crate::bindings::BindingSet),
)
// **The paint half, after the camera** (decision 2168): the quad walk runs once the
// world's own update has happened, so a widget anchored to the world — the nameplates,
@@ -527,8 +579,8 @@ impl Plugin for UiScriptPlugin {
app.add_systems(
Update,
demo_unit_feed
.in_set(UiFeed)
.after(UnitFeed)
.before(UiInput)
.run_if(capture_ui_active),
);
}
-1
View File
@@ -214,7 +214,6 @@ impl Plugin for UiShapeshiftPlugin {
// after the input pass so a stance click goes out the same frame.
feed_shapeshift_bar
.in_set(UnitFeed)
.before(UiInput)
.before(crate::ui_action::CooldownEvents)
.before(crate::ui_aura::AuraEvents),
drain_shapeshift_casts.after(UiInput),
+2 -2
View File
@@ -42,7 +42,7 @@
use benilla_protocol::messages::{friend_result, friend_status, FriendEntry, FriendStatusUpdate};
use bevy::prelude::*;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
mod feed;
mod query;
@@ -322,7 +322,7 @@ impl Plugin for UiSocialPlugin {
app.init_resource::<SocialState>().add_systems(
Update,
(
feed::feed_social.before(UiInput),
feed::feed_social.in_set(UiFeed),
feed::drain_social.after(UiInput),
),
);
+1 -2
View File
@@ -103,8 +103,7 @@ impl Plugin for UiSpellbookPlugin {
// book buttons re-read them (that set's own doc).
feed_spellbook
.in_set(UnitFeed)
.before(crate::ui_action::CooldownEvents)
.before(UiInput),
.before(crate::ui_action::CooldownEvents),
drain_spell_casts.after(UiInput),
),
);
+2 -2
View File
@@ -41,7 +41,7 @@ use benilla_ui::script::{StableIntent, StablePetSlot, StableState, UiScript, NUM
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_pet_stats::{PetFamilyTables, PetStatTables};
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
pub(crate) struct UiStablePlugin;
@@ -57,7 +57,7 @@ impl Plugin for UiStablePlugin {
// frame; push before the input pass so an open/close is on screen the same frame;
// drain after it (the trainer/merchant/gossip ordering).
close_npc_session_out_of_range::<StableOpen>.before(feed_stable),
feed_stable.before(UiInput),
feed_stable.in_set(UiFeed),
drain_stable.after(UiInput),
// AFTER the VM ticks, unlike every other booth feed: this one reads the
// *selection*, which a click on a slot writes during `UiInput` — and
+2 -2
View File
@@ -75,7 +75,7 @@ use bevy::prelude::*;
use crate::area::AreaTableRes;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
/// The pending summon question — the reference's four-global bank (module doc, pin 1). Written by
/// the net drain's `SummonRequest` arm through [`apply::request`], read by [`feed_summon`] (which
@@ -301,7 +301,7 @@ impl Plugin for UiSummonPlugin {
// Before the feed, so the frame a session ends is already a frame the feed sees no
// memory of the last one ([`crate::death`]'s ordering, for the same reason).
end_session_summon.before(feed_summon),
feed_summon.before(UiInput),
feed_summon.in_set(UiFeed),
drain_summon.after(UiInput),
),
);
+2 -2
View File
@@ -35,7 +35,7 @@ use benilla_ui::script::{TabardHost, TabardIntent, UiScript, TABARD_COUNTS, TABA
use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, SelfPlayer};
use crate::portrait::PaperDollBooth;
use crate::ui_guild::GuildState;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
/// The open designer: the vendor it was opened on (`[0xbdcee8]`), and the save-in-flight latch
@@ -298,7 +298,7 @@ impl Plugin for TabardUiPlugin {
(
reset_on_world_enter.before(feed_tabard),
close_npc_session_out_of_range::<TabardOpen>.before(feed_tabard),
feed_tabard.before(UiInput),
feed_tabard.in_set(UiFeed),
drain_tabard.after(UiInput),
),
);
+1 -1
View File
@@ -66,7 +66,7 @@ impl Plugin for UiTalentPlugin {
(
// Feed before UiInput (an N-key open this frame sees a populated window);
// the learn drain after (a click's wire goes out the same frame).
feed_talents.in_set(UnitFeed).before(UiInput),
feed_talents.in_set(UnitFeed),
drain_talent_learns.after(UiInput),
),
);
+2 -2
View File
@@ -51,7 +51,7 @@ use benilla_ui::script::{ScriptValue, UiScript};
use bevy::prelude::*;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
/// The pending respec question. Written by the net drain's `TalentWipeConfirm` arm, read by
@@ -206,7 +206,7 @@ impl Plugin for UiTalentWipePlugin {
// Range-close before the feed so walking away takes the dialog down the same
// frame (the binder question's ordering, for the same reason).
close_npc_session_out_of_range::<TalentWipeState>.before(feed_talent_wipe),
feed_talent_wipe.before(UiInput),
feed_talent_wipe.in_set(UiFeed),
drain_talent_wipe.after(UiInput),
),
);
+2 -2
View File
@@ -36,7 +36,7 @@ use benilla_ui::script::{TaxiUiState, UiScript};
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
use crate::player::Player;
use crate::ui_script::UiInput;
use crate::ui_script::{UiFeed, UiInput};
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
mod routing;
@@ -337,7 +337,7 @@ impl Plugin for UiTaxiPlugin {
// frame; push before the input pass so an open/close is on screen the same
// frame; drain after it (mirrors ui_merchant/ui_trainer).
close_npc_session_out_of_range::<TaxiState>.before(feed_taxi),
feed_taxi.before(UiInput),
feed_taxi.in_set(UiFeed),
drain_taxi.after(UiInput),
),
);

Some files were not shown because too many files have changed in this diff Show More