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:
Generated
+23
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1461
-1326
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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("];
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -58,6 +58,7 @@ mod chat_bubble;
|
||||
mod chr_classes;
|
||||
mod cinematic;
|
||||
mod combat_text;
|
||||
mod console;
|
||||
mod cooldowns;
|
||||
mod crash;
|
||||
mod creature_anim;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
))
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
+146
-19
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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>>,
|
||||
) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user