diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 012362b5..935a0d6b 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -4025,28 +4025,36 @@
-
diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc
index 0f686215..9c0acb4b 100644
--- a/crates/benilla-app/assets/ui/benilla.toc
+++ b/crates/benilla-app/assets/ui/benilla.toc
@@ -424,6 +424,33 @@ Interface\FrameXML\OptionsFrameTemplates.xml
# this manifest calls them; an addon that does gets a raise where it used to get "call a nil
# value", which is not worse and is 1203's "loudly absent" either way.
Interface\FrameXML\OptionsFrame.lua
+# ══ THE STOCK SOUND OPTIONS WINDOW — loaded, and HIDDEN, by 2115's own argument ══════════════
+# The reference's `FrameXML.toc` seats this file at l.20, exactly between the video window (l.19,
+# whose `.lua` is the row above) and the interface window (l.21, the row below). Ours now does too,
+# and the seat is the reference's rather than convenient: `SoundOptionsFrame.xml` inherits
+# `OptionsCheckButtonTemplate` and `OptionsSliderTemplate` from `OptionsFrameTemplates.xml` and
+# `GameMenuButtonTemplate` from `UIPanelTemplates.xml`, both already above.
+#
+# **It brings nothing else with it.** Unlike `UIOptionsFrame.xml`, which ``s
+# `CharacterFrameTemplates.xml`, this file's only dependency line is its own
+# ``. And nothing below depends on IT — no file on this
+# manifest indexes `SoundOptionsFrameCheckButtons`, so there is no `MultiActionBars`-shaped
+# load-order edge under it the way 2115 §3 had to argue for the interface window.
+#
+# **Zero unbuilt engine verbs**, which is why this is one row and not a project: the whole surface
+# is `Get`/`SetCVar`/`GetCVarDefault`, `getglobal`, `TEXT`, `PlaySound`, `Show`/`HideUIPanel`,
+# `GetBindingKey`/`RunBinding`, `Disable_BagButtons`, `UpdateMicroButtons`, the seven
+# `OptionsFrame_*` helpers the row above already loads, and its own functions. All present.
+#
+# **All eleven CVars it names are registered**, so its `_Load` runs clean — including
+# `slider:SetValue(value.initialValue)`, the shape-A path that raised on the interface window's
+# `cameraYawMoveSpeed`. The three that were missing — `SoundListenerAtCharacter`, `EmoteSounds`,
+# `SoundZoneMusicNoDelay` — are check buttons (nil-safe either way) and were built rather than
+# listed, so this row costs no census entry at all.
+#
+# Its own `hidden="true"` (xml l.18) is the whole mechanism, exactly as for the interface window.
+# Nothing of ours may ever `Show()` it.
+Interface\FrameXML\SoundOptionsFrame.xml
# ══ THE STOCK INTERFACE OPTIONS WINDOW — loaded, and HIDDEN (decision 2115) ══════════════════
# **This row is not a migration, and it is the one row in this file where that has to be said.**
# benilla's own options window is `OptionsFrame.xml` below, and it stays the window the player
@@ -544,8 +571,8 @@ Interface\FrameXML\PetActionBarFrame.xml
# UpdateMicroButtons this file defines, and that function reads CharacterFrame, SpellBookFrame,
# QuestLogFrame, GameMenuFrame, OptionsFrame, SoundOptionsFrame, UIOptionsFrame, FriendsFrame,
# WorldMapFrame, HelpFrame and KeyRingButton UNGUARDED at call time, so every one of them is up
-# before anything can be shown (SoundOptionsFrame and UIOptionsFrame are OptionsFrame.xml's two
-# aliases — our one options window answers for the reference's three).
+# before anything can be shown. `SoundOptionsFrame` and `UIOptionsFrame` are the reference's own
+# frames now, both loaded hidden above; `OptionsFrame` is our window's own name. All three answer.
Interface\FrameXML\MainMenuBarMicroButtons.xml
# ══ THE BATTLEGROUND LIST + THE MINIMAP CLUSTER — the reference's own, off the player's chain
# (1751; decision 1974) ══
diff --git a/crates/benilla-app/src/combat_text/law.rs b/crates/benilla-app/src/combat_text/law.rs
index 1f9d91e2..3f354a6c 100644
--- a/crates/benilla-app/src/combat_text/law.rs
+++ b/crates/benilla-app/src/combat_text/law.rs
@@ -112,12 +112,47 @@ pub(crate) fn miss_word(code: u8) -> Option<(&'static str, u8)> {
pub(crate) const COLOR_SPELL_GOLD: u32 = 0xFFFF_DE00;
pub(crate) const COLOR_PET_MELEE_ORANGE: u32 = 0xFFFF_8400;
-/// The cvar gates at their shipped defaults (like the nameplate cvars, consts until a cvar
-/// system exists): CombatDamage masters ALL floating damage text; the Pet* pair gates only the
-/// owned-source sub-cases (the self sub-case is unconditional — the byte refinement).
-const COMBAT_DAMAGE: bool = true;
-const PET_MELEE_DAMAGE: bool = true;
-const PET_SPELL_DAMAGE: bool = true;
+/// **The three floating-combat-text CVar gates, live** — `CombatDamage` (`[0xc4d944]`),
+/// `PetMeleeDamage` (`[0xc4d9cc]`) and `PetSpellDamage` (`[0xc4d99c]`), each read as the record's
+/// int `+0x28`.
+///
+/// Each has a **closed reader census** in the reference: one store and exactly two reads
+/// image-wide, one in the localized-WORD emitter `0x607140` and one in the `"%d"` NUMBER emitter
+/// `0x6128b0` (wow-re `playername/scratch/worldtext-spawn-and-law.md` §4,
+/// `combattext-color-law.md`). Both branch targets are function epilogues, so a gate that fails
+/// **suppresses the emit entirely** — it never falls through to a default colour, which is why
+/// [`damage_color`] returns `Option` rather than a colour.
+///
+/// `CombatDamage` is the master: off, nothing floats over any unit from any source, and — despite
+/// the CVar's own help text saying "damage numbers" — that includes the miss/dodge/parry/block/
+/// absorb/resist WORDS, because the word emitter carries the same gate. The `Pet*` pair are
+/// downstream sub-gates that only ever see the owned-by-you branch; the self sub-case is
+/// unconditional.
+///
+/// **`PetMeleeDamage` and `PetSpellDamage` do not split on "melee vs spell" the way a player
+/// would mean it.** The selector is `B` — no spell record (a real melee swing) *or* the spell's
+/// `AttributesEx` bit 15 — so a pet spell carrying that bit is gated by `PetMeleeDamage` and
+/// coloured orange. Which spell ids carry it is on wow-re's own Open board.
+///
+/// These were `const bool`s with the comment "consts until a cvar system exists". The CVar system
+/// existed; this is the row 2077's census was holding the place for.
+#[derive(Resource, Clone, Copy)]
+pub(crate) struct DamageTextGates {
+ pub(crate) combat_damage: bool,
+ pub(crate) pet_melee: bool,
+ pub(crate) pet_spell: bool,
+}
+
+impl Default for DamageTextGates {
+ /// The reference's registered defaults — all three `"1"`.
+ fn default() -> Self {
+ Self {
+ combat_damage: true,
+ pet_melee: true,
+ pet_spell: true,
+ }
+ }
+}
/// The `0x5efea0` source-ownership classes that may draw (`K`): the active player itself, or a
/// unit it owns (pet/guardian/totem — Summoned/CreatedBy = me). Every other source class (other
@@ -133,15 +168,19 @@ pub(crate) enum DamageSource {
/// `K`. `None` = the whole emit is gated off (CombatDamage master, or the pet path's Pet* cvar);
/// `Some(None)` = draw with the category row's default (white); `Some(Some(argb))` = draw with
/// the override. Crit never enters this pick.
-pub(crate) fn damage_color(source: DamageSource, melee: bool) -> Option> {
- if !COMBAT_DAMAGE {
+pub(crate) fn damage_color(
+ gates: DamageTextGates,
+ source: DamageSource,
+ melee: bool,
+) -> Option > {
+ if !gates.combat_damage {
return None;
}
match (source, melee) {
(DamageSource::Player, true) => Some(None),
(DamageSource::Player, false) => Some(Some(COLOR_SPELL_GOLD)),
- (DamageSource::Pet, true) => PET_MELEE_DAMAGE.then_some(Some(COLOR_PET_MELEE_ORANGE)),
- (DamageSource::Pet, false) => PET_SPELL_DAMAGE.then_some(Some(COLOR_SPELL_GOLD)),
+ (DamageSource::Pet, true) => gates.pet_melee.then_some(Some(COLOR_PET_MELEE_ORANGE)),
+ (DamageSource::Pet, false) => gates.pet_spell.then_some(Some(COLOR_SPELL_GOLD)),
}
}
@@ -467,21 +506,71 @@ mod tests {
/// (PetSpellDamage). Crit never enters the pick (it only selects the pop row).
#[test]
fn damage_color_matches_the_byte_table() {
- assert_eq!(damage_color(DamageSource::Player, true), Some(None));
+ let on = DamageTextGates::default();
+ assert_eq!(damage_color(on, DamageSource::Player, true), Some(None));
assert_eq!(
- damage_color(DamageSource::Player, false),
+ damage_color(on, DamageSource::Player, false),
Some(Some(COLOR_SPELL_GOLD))
);
assert_eq!(
- damage_color(DamageSource::Pet, true),
+ damage_color(on, DamageSource::Pet, true),
Some(Some(COLOR_PET_MELEE_ORANGE))
);
assert_eq!(
- damage_color(DamageSource::Pet, false),
+ damage_color(on, DamageSource::Pet, false),
Some(Some(COLOR_SPELL_GOLD))
);
// The byte values themselves (`0x5fa0b0`/`0x5fa0f0` hard-inits).
assert_eq!(COLOR_SPELL_GOLD, 0xFFFF_DE00);
assert_eq!(COLOR_PET_MELEE_ORANGE, 0xFFFF_8400);
}
+
+ /// **The three gates SUPPRESS; they never recolour** — both of the reference's read sites
+ /// branch to a function epilogue, so a failed gate means no emit at all. And the master's
+ /// reach is total: `CombatDamage = 0` takes the self sub-case with it, which is the one case
+ /// no `Pet*` row can reach.
+ #[test]
+ fn the_three_gates_suppress_the_emit_rather_than_recolour_it() {
+ let master_off = DamageTextGates {
+ combat_damage: false,
+ ..Default::default()
+ };
+ for source in [DamageSource::Player, DamageSource::Pet] {
+ for melee in [true, false] {
+ assert_eq!(
+ damage_color(master_off, source, melee),
+ None,
+ "CombatDamage=0 must suppress {source:?} melee={melee}"
+ );
+ }
+ }
+
+ // A pet sub-gate takes ONLY its own branch — and leaves the player's alone, because the
+ // self sub-case is unconditional in the reference.
+ let no_pet_melee = DamageTextGates {
+ pet_melee: false,
+ ..Default::default()
+ };
+ assert_eq!(damage_color(no_pet_melee, DamageSource::Pet, true), None);
+ assert_eq!(
+ damage_color(no_pet_melee, DamageSource::Pet, false),
+ Some(Some(COLOR_SPELL_GOLD)),
+ "PetSpellDamage still governs the pet's spell branch"
+ );
+ assert_eq!(
+ damage_color(no_pet_melee, DamageSource::Player, true),
+ Some(None),
+ "the self sub-case is unconditional"
+ );
+
+ let no_pet_spell = DamageTextGates {
+ pet_spell: false,
+ ..Default::default()
+ };
+ assert_eq!(damage_color(no_pet_spell, DamageSource::Pet, false), None);
+ assert_eq!(
+ damage_color(no_pet_spell, DamageSource::Pet, true),
+ Some(Some(COLOR_PET_MELEE_ORANGE))
+ );
+ }
}
diff --git a/crates/benilla-app/src/combat_text/mod.rs b/crates/benilla-app/src/combat_text/mod.rs
index 7e569632..f7a572b2 100644
--- a/crates/benilla-app/src/combat_text/mod.rs
+++ b/crates/benilla-app/src/combat_text/mod.rs
@@ -106,7 +106,7 @@ use law::{
argb, claimed_box_px, fade_alpha, melee_text, scale_value, shadow_offset_px, text_px,
CATEGORIES,
};
-pub(crate) use law::{damage_color, miss_word, spell_text, DamageSource};
+pub(crate) use law::{damage_color, miss_word, spell_text, DamageSource, DamageTextGates};
use bevy::prelude::*;
@@ -420,6 +420,7 @@ fn melee_impact_text(
self_player: Query<(), With>,
self_guid: Res,
stores: Query<&crate::net::ObjectStore>,
+ gates: Res,
mut text: MessageWriter,
) {
for crate::creature_anim::SwingImpact { swing: s, .. } in impacts.read() {
@@ -440,7 +441,7 @@ fn melee_impact_text(
} else {
continue; // K = other: never drawn
};
- let Some(color) = damage_color(source, true) else {
+ let Some(color) = damage_color(*gates, source, true) else {
continue; // the CombatDamage / PetMeleeDamage gates
};
if let Some((category, body)) = melee_text(s.hit_info, s.victim_state, s.damage) {
@@ -463,6 +464,7 @@ impl Plugin for CombatTextPlugin {
// The Update append window (see [`UiQuadAppend`]): after the camera controller, and
// projecting through the camera's FRESH Transform (not the stale propagated global).
app.init_resource::()
+ .init_resource::()
.add_message::()
.add_systems(
Update,
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index 11587a44..ce21d9fd 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -45,11 +45,13 @@ use crate::chat_bubble::BubbleConfig;
use crate::minimap::MinimapZoom;
use crate::nameplates::NameConfig;
use crate::player::camera::{
- FollowConfig, FollowStyle, LookConfig, ZoomLimit, FOLLOW_SPEED_RANGE, MOUSE_SPEED_RANGE,
+ FollowConfig, FollowStyle, LookConfig, ZoomLimit, CAMERA_SPEED_RANGE, FOLLOW_SPEED_RANGE,
+ MOUSE_SPEED_RANGE,
};
use crate::portrait::PaneRate;
use crate::sound::SoundConfig;
use crate::target::ClickConfig;
+use crate::ui_chat::combat::UnitClass as CombatClass;
use crate::ui_loot::LootConfig;
use crate::ui_script::UiScaleCvar;
use crate::video::VideoConfig;
@@ -491,6 +493,94 @@ pub(crate) const REGISTERED: &[Registered] = &[
// 8 on the weakest). 24 is on no stop of ours; 1649 broke that tie toward the denser stop,
// because erring sparse is the worse failure for a knob about ground cover.
same("WorldDetail", "1"),
+ // ── The combat log's display ranges: the reference's own `0x8629e0` table, in yards ─────────
+ //
+ // Eight rows, registered by the reference in ONE place — `0x626d00`, a loop over the
+ // `{cvarName, defaultValue}` pairs at `0x8629e0` skipping the NULL/empty names, then one
+ // unrolled call for the death range (wow-re `object-layer/scratch/combat-log-chat-law.md`
+ // §5.2). They read as the CVar record's **float** (`+0x24`), unlike the periodic gate below,
+ // which reads the int.
+ //
+ // **They are why a damage meter's range slider does something.** `BigWigs/Plugins/Range.lua`
+ // and `DPSMate/DPSMate_DataBuilder.lua` both read and write all eight; unregistered, every
+ // `SetCVar` here wrote nothing and every `GetCVar` answered nil. The reader was already built
+ // — `ui_chat::combat::in_range` has run this exact table since 1571, off the compiled-in
+ // defaults, with `UnitClass::range_cvar` parked under `#[cfg(test)]` waiting for this row.
+ //
+ // Classes 0 and 1 — you and your pet — have NO CVar in the reference's table (a NULL name and
+ // the `100000.0` sentinel), so there is nothing to register for them and nothing to miss.
+ same("CombatLogRangeParty", "50"),
+ same("CombatLogRangePartyPet", "50"),
+ same("CombatLogRangeFriendlyPlayers", "50"),
+ same("CombatLogRangeFriendlyPlayersPets", "50"),
+ same("CombatLogRangeHostilePlayers", "50"),
+ same("CombatLogRangeHostilePlayersPets", "50"),
+ same("CombatLogRangeCreature", "30"),
+ // The one range CVar OUTSIDE that table (`0x626d5f`, default string `"60"` at `0x862e14`) —
+ // and the only formatter with a range of its own. `0x62c160` reads it first and falls back to
+ // the per-class getter only when the *lookup* fails, which a registered client never sees.
+ same(crate::ui_chat::combat::DEATH_LOG_RANGE_CVAR, "60"),
+ // ── The floating-combat-text gates, and the periodic one ────────────────────────────────────
+ //
+ // `CombatDamage` (`0x6032df`, record `[0xc4d944]`) is the MASTER: its only two readers are the
+ // localized-WORD emitter `0x607140` and the `"%d"` NUMBER emitter `0x6128b0`, and both branch
+ // targets are epilogues — so at "0" nothing floats over any unit from any source, words
+ // included, despite the CVar's own help text saying "damage numbers". The two `Pet*` rows are
+ // sub-gates below it, on the owned-by-you branch only; the self sub-case is unconditional.
+ //
+ // `PetSpellDamage` has no row in `UIOptionsFrameCheckButtons` — the *Show Pet Melee Damage*
+ // box writes both (`UIOptionsFrame_Save` l.334-336) — which is why 2077's census, which reads
+ // that table, could not see it while it saw its two siblings.
+ same("CombatDamage", "1"),
+ same("PetMeleeDamage", "1"),
+ same("PetSpellDamage", "1"),
+ // `CombatLogPeriodicSpells` (`0x6033b3`, handle deliberately DISCARDED — every use re-looks it
+ // up by name). Read as the record's INT, unlike the ranges above, which read its float.
+ same(crate::ui_chat::combat::LOG_PERIODIC_CVAR, "1"),
+ // ── The three Sound-panel check buttons benilla had the machinery for and no key to ─────────
+ //
+ // All three are category-7 (sound) registrations that keep **no** `CVar::Register` handle: the
+ // reference looks each up by name at the point of use. Each already had its reader here.
+ //
+ // `SoundListenerAtCharacter` (`0x457890`, "lock listener at character"): both of its branches
+ // were already written in `update_audio_listener` — the at-character seat and the at-camera
+ // one — with the camera path reachable only as a no-character fallback. This is the selector
+ // they were missing.
+ same("SoundListenerAtCharacter", "1"),
+ // `EmoteSounds` (`0x4573b9`): the received text-emote voice kit, and only that.
+ same("EmoteSounds", "1"),
+ // `SoundZoneMusicNoDelay` (`0x4578b3`): `next_track_time`'s own comment named it as "the
+ // immediate path, a \"0\" CVar we don't expose". Now exposed, at the reference's `"0"`.
+ same("SoundZoneMusicNoDelay", "0"),
+ // `assistAttack` (`0x48fc50`, record `[0xb4d8f8]`) — `/assist`'s opt-in second leg: select the
+ // basis unit's target AND open the swing on it. Three references image-wide, two of them the
+ // shared assist tails; `CanAssist 0x6066f0` is verified NOT on the path. The `"0"` default is
+ // the one wow-re had to correct against itself — its first pass read `"3"` off the *next*
+ // registration's default (`minimapZoom`), the `mov ds:` adjacency trap — so it is worth saying
+ // plainly here: stock `/assist` selects and does not swing.
+ same("assistAttack", "0"),
+ // ── Mouse-look, per axis: the two CVars whose absence RAISED in the stock window ────────────
+ //
+ // `cameraYawMoveSpeed` is `UIOptionsFrameSliders` row 3, MOUSE_LOOK_SPEED (90…270 step 10) —
+ // and it is what made stock `UIOptionsFrame_Load()` die: `slider:SetValue(GetCVar(value.cvar))`
+ // is a shape-A binding (`0x790980`) that raises on a nil in the reference too, so the whole
+ // window's `_Load` (and `_SetDefaults`, through `GetCVarDefault`) stopped at slider 3.
+ // `cameraPitchMoveSpeed` has no row of its own: `UIOptionsFrame_Save` writes it as
+ // `sliderValue / 2` beside the yaw one (l.352-356), which is exactly this 180/90 pair.
+ //
+ // **Both defaults are the reference's, and the shipped feel does not change** — the two facts
+ // are compatible only because the unit divergence is carried in `camera::LOOK_YAW_PER_SPEED`
+ // instead of in these numbers. The reference integrates OS-accelerated `WM_MOUSEMOVE` pixels
+ // (it imports no DirectInput at all) while we integrate winit's raw device delta, so its
+ // `deg per pixel` is not our `deg per unit` and the factor between them is a per-machine
+ // pointer setting. Anchoring the scale there and keeping the defaults here is what lets 1804
+ // hold honestly rather than by picking a number that merely looks right.
+ //
+ // The validator is `0x50c000` → `0x50b330`, range [0.1, 360], and it **rejects rather than
+ // clamps** — `apply_to_knobs` does the same below, which is why these two do not use the
+ // clamping shape every other numeric row uses.
+ same("cameraYawMoveSpeed", "180"),
+ same("cameraPitchMoveSpeed", "90"),
// Mouse Sensitivity (1140): 1.12's own MOUSE_SENSITIVITY slider (`UIOptionsFrameSliders` row
// 1, 0.5..1.5 step 0.05), a MULTIPLIER over the camera's own per-pixel rate — which was a
// frozen constant until this row. Default "1" is the shipped feel exactly, welded to
@@ -971,6 +1061,10 @@ pub(crate) struct KnobParams<'w> {
auto_self_cast: ResMut<'w, crate::ui_action::AutoSelfCast>,
realmlist: ResMut<'w, crate::realmlist::Realmlist>,
fps_journal: ResMut<'w, crate::perf::FpsJournalSetting>,
+ assist_attack: ResMut<'w, crate::target::AssistAttack>,
+ combat_ranges: ResMut<'w, crate::ui_chat::combat::CombatLogRanges>,
+ damage_text: ResMut<'w, crate::combat_text::DamageTextGates>,
+ log_periodic: ResMut<'w, crate::ui_chat::combat::LogPeriodicSpells>,
}
impl KnobParams<'_> {
@@ -1009,6 +1103,10 @@ impl KnobParams<'_> {
auto_self_cast: &mut self.auto_self_cast,
realmlist: &mut self.realmlist,
fps_journal: &mut self.fps_journal,
+ assist_attack: &mut self.assist_attack,
+ combat_ranges: &mut self.combat_ranges,
+ damage_text: &mut self.damage_text,
+ log_periodic: &mut self.log_periodic,
}
}
}
@@ -1043,6 +1141,10 @@ struct Knobs<'a> {
auto_self_cast: &'a mut crate::ui_action::AutoSelfCast,
realmlist: &'a mut crate::realmlist::Realmlist,
fps_journal: &'a mut crate::perf::FpsJournalSetting,
+ assist_attack: &'a mut crate::target::AssistAttack,
+ combat_ranges: &'a mut crate::ui_chat::combat::CombatLogRanges,
+ damage_text: &'a mut crate::combat_text::DamageTextGates,
+ log_periodic: &'a mut crate::ui_chat::combat::LogPeriodicSpells,
}
/// **The string-valued rows**, matched ahead of the numeric parse every other row goes through —
@@ -1116,10 +1218,14 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
// The client's own parse for this one is literally `!= 0` too (`0x4574d0`: `setne al`).
"soundreverb" => knobs.sound.reverb = v != 0.0,
"soundoutputlimiter" => knobs.sound.limiter = v != 0.0,
+ "soundlisteneratcharacter" => knobs.sound.listener_at_character = v != 0.0,
+ "emotesounds" => knobs.sound.emote_sounds = v != 0.0,
+ "soundzonemusicnodelay" => knobs.sound.zone_music_no_delay = v != 0.0,
"uiscale" => knobs.scale.0 = v.clamp(0.5, 1.5),
"farclip" => knobs.view.farclip = v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end()),
"deselectonclick" => knobs.click.deselect_on_click = v != 0.0,
"autoselfcast" => knobs.auto_self_cast.0 = v != 0.0,
+ "assistattack" => knobs.assist_attack.0 = v != 0.0,
// The sixteen camera-view CVars have no knob to apply to: `CameraViews` is their writer,
// not their reader (it seeds itself from the persisted file at startup, and `SaveView`
// writes back). They are claimed here so the table's own "not a knob this build knows"
@@ -1141,6 +1247,33 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
"mousespeed" => {
knobs.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 above, 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 {name}: value out of range ({} - {}) — ignored",
+ CAMERA_SPEED_RANGE.start(),
+ CAMERA_SPEED_RANGE.end()
+ );
+ return true;
+ }
+ if key == "camerayawmovespeed" {
+ knobs.look.yaw_speed = v;
+ } else {
+ knobs.look.pitch_speed = v;
+ }
+ }
+ "combatdamage" => knobs.damage_text.combat_damage = v != 0.0,
+ "petmeleedamage" => knobs.damage_text.pet_melee = v != 0.0,
+ "petspelldamage" => knobs.damage_text.pet_spell = v != 0.0,
+ "combatlogperiodicspells" => knobs.log_periodic.0 = v != 0.0,
+ // The combat log's eight display ranges (yards, the CVar's float field). One arm for all
+ // of them: `CombatLogRanges::set` walks the class table through `UnitClass::range_cvar`,
+ // so the seven names live in exactly one place and this arm cannot drift from them.
+ _ if knobs.combat_ranges.set(name, v) => {}
"autolootdefault" => knobs.loot.auto_loot = v != 0.0,
"unitnameplayer" => knobs.names.player = v != 0.0,
"unitnamenpc" => knobs.names.npc = v != 0.0,
@@ -1462,6 +1595,10 @@ fn sync_cvars(
tex_filter,
realmlist,
fps_journal,
+ assist_attack,
+ combat_ranges,
+ damage_text,
+ log_periodic,
} = ¶ms;
// The config file's values go in FIRST (decision 1291): registration — ours below, or an
// addon's `RegisterCVar` later — starts a key at its saved value. This is what carries a
@@ -1488,7 +1625,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
- let session: [(&str, String); 49] = [
+ let session: [(&str, String); 67] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1503,12 +1640,21 @@ fn sync_cvars(
),
("SoundReverb", flag(sound.reverb)),
("SoundOutputLimiter", flag(sound.limiter)),
+ (
+ "SoundListenerAtCharacter",
+ flag(sound.listener_at_character),
+ ),
+ ("EmoteSounds", flag(sound.emote_sounds)),
+ ("SoundZoneMusicNoDelay", flag(sound.zone_music_no_delay)),
("uiScale", scale.0.to_string()),
("farclip", view.farclip.to_string()),
("deselectOnClick", flag(click.deselect_on_click)),
("autoSelfCast", flag(auto_self_cast.0)),
+ ("assistAttack", flag(assist_attack.0)),
("mouseInvertPitch", flag(look.invert_pitch)),
("mousespeed", look.sensitivity.to_string()),
+ ("cameraYawMoveSpeed", look.yaw_speed.to_string()),
+ ("cameraPitchMoveSpeed", look.pitch_speed.to_string()),
("cameraDistanceMaxFactor", zoom.factor().to_string()),
("cameraSmoothStyle", follow.style.cvar().to_string()),
(
@@ -1560,6 +1706,48 @@ fn sync_cvars(
crate::realmlist::CVAR_REALMLIST,
realmlist.address().to_string(),
),
+ // The combat log's eight display ranges, off the live table — written out one class at
+ // a time rather than composed in a loop, because this array is the readable census of
+ // what a session's `GetCVar` answers and a loop would hide eight rows inside one.
+ (
+ "CombatLogRangeParty",
+ combat_ranges.class(CombatClass::Party).to_string(),
+ ),
+ (
+ "CombatLogRangePartyPet",
+ combat_ranges.class(CombatClass::PartyPet).to_string(),
+ ),
+ (
+ "CombatLogRangeFriendlyPlayers",
+ combat_ranges.class(CombatClass::FriendlyPlayer).to_string(),
+ ),
+ (
+ "CombatLogRangeFriendlyPlayersPets",
+ combat_ranges.class(CombatClass::FriendlyPet).to_string(),
+ ),
+ (
+ "CombatLogRangeHostilePlayers",
+ combat_ranges.class(CombatClass::HostilePlayer).to_string(),
+ ),
+ (
+ "CombatLogRangeHostilePlayersPets",
+ combat_ranges.class(CombatClass::HostilePet).to_string(),
+ ),
+ (
+ "CombatLogRangeCreature",
+ combat_ranges.class(CombatClass::Creature).to_string(),
+ ),
+ (
+ crate::ui_chat::combat::DEATH_LOG_RANGE_CVAR,
+ combat_ranges.death().to_string(),
+ ),
+ ("CombatDamage", flag(damage_text.combat_damage)),
+ ("PetMeleeDamage", flag(damage_text.pet_melee)),
+ ("PetSpellDamage", flag(damage_text.pet_spell)),
+ (
+ crate::ui_chat::combat::LOG_PERIODIC_CVAR,
+ flag(log_periodic.0),
+ ),
];
for (name, value) in session {
script.set_cvar_host(name, &value);
@@ -1984,6 +2172,10 @@ mod tests {
let mut loot = LootConfig::default();
let mut names = NameConfig::default();
let mut plates = VPlateMode::default();
+ let mut assist_attack = crate::target::AssistAttack::default();
+ let mut combat_ranges = crate::ui_chat::combat::CombatLogRanges::default();
+ let mut damage_text = crate::combat_text::DamageTextGates::default();
+ let mut log_periodic = crate::ui_chat::combat::LogPeriodicSpells::default();
// Literal fields, not Default: ClutterConfig::default() reads the env A/B vars.
let mut clutter = ClutterConfig {
density: 3.0,
@@ -2049,6 +2241,10 @@ mod tests {
msaa_formats: &msaa_formats,
realmlist: &mut realmlist,
fps_journal: &mut fps_journal,
+ assist_attack: &mut assist_attack,
+ combat_ranges: &mut combat_ranges,
+ damage_text: &mut damage_text,
+ log_periodic: &mut log_periodic,
};
assert!(apply_to_knobs("MusicVolume", "0.7", &mut knobs));
assert_eq!(knobs.sound.music, 0.7);
@@ -2313,7 +2509,11 @@ mod tests {
formats: vec![(32, 32, 1), (32, 32, 2), (32, 32, 4)],
})
.init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
.init_resource::()
+ .init_resource::()
.init_resource::()
.init_resource::()
.init_resource::()
diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs
index 0eb3836a..741c34df 100644
--- a/crates/benilla-app/src/net/apply.rs
+++ b/crates/benilla-app/src/net/apply.rs
@@ -344,6 +344,12 @@ pub(crate) fn apply_net_updates(
// Its own queue rather than `UiErrorKeys` because its message needs TWO GlobalStrings
// lookups, and the inner one is only reachable at the drain (see the resource's doc).
ResMut,
+ // The combat-feedback CVars — the eight display ranges, `CombatLogPeriodicSpells`,
+ // and the three floating-text gates. Rides here for exactly the reason the
+ // FactionTemplate catalog two fields up does: the signature is at the 16-param ceiling
+ // and this is where the room is. One bundle rather than three `Res`, so the use sites
+ // read `cvars.ranges` instead of `ui_actions.1 .7`.
+ crate::ui_chat::combat::CombatFeedbackCvars<'_>,
),
ResMut,
ResMut,
@@ -577,6 +583,8 @@ pub(crate) fn apply_net_updates(
factions: ui_actions.1 .2.as_deref(),
reputations: &reputations,
spells: ui_actions.10.as_deref(),
+ ranges: &ui_actions.1 .5.ranges,
+ periodic: ui_actions.1 .5.periodic.0,
}
};
}
@@ -1559,12 +1567,21 @@ pub(crate) fn apply_net_updates(
&self_guid,
&stores,
ui_actions.10.as_deref(),
+ *ui_actions.1 .5.damage_text,
&mut audio.7,
&mut audio.15 .0,
&mut audio.15 .1,
)
}
SessionEvent::PeriodicAuraLog(s) => {
+ // **`CombatLogPeriodicSpells` gates the WHOLE packet body, and this arm is where
+ // that is expressible.** The reference's read site `0x626dee` is the first thing
+ // the handler `0x626dd0` does, and a zero jumps to the bare epilogue `0x6271b4`:
+ // no chat line, no floating tick number, no periodic miss word. Gating inside
+ // either half below would model it as two filters; it is one gate over both.
+ if !ui_actions.1 .5.periodic.0 {
+ continue;
+ }
combat_chat::periodic_aura_log(
&s,
&chat_ctx!(),
@@ -1578,6 +1595,7 @@ pub(crate) fn apply_net_updates(
&self_guid,
&stores,
ui_actions.10.as_deref(),
+ *ui_actions.1 .5.damage_text,
&mut audio.7,
&mut audio.15 .0,
&mut audio.15 .1,
@@ -1614,6 +1632,7 @@ pub(crate) fn apply_net_updates(
&index,
&self_guid,
&stores,
+ *ui_actions.1 .5.damage_text,
&mut audio.7,
&mut audio.15 .0,
)
@@ -1625,6 +1644,8 @@ pub(crate) fn apply_net_updates(
&index,
&self_guid,
&stores,
+ ui_actions.10.as_deref(),
+ *ui_actions.1 .5.damage_text,
&mut audio.7,
&mut audio.15 .0,
&mut audio.15 .1,
diff --git a/crates/benilla-app/src/net/apply/combat_chat.rs b/crates/benilla-app/src/net/apply/combat_chat.rs
index 6eb0282d..c6747164 100644
--- a/crates/benilla-app/src/net/apply/combat_chat.rs
+++ b/crates/benilla-app/src/net/apply/combat_chat.rs
@@ -36,6 +36,11 @@ pub(super) struct ChatCtx<'a> {
pub factions: Option<&'a crate::target::ring::Factions>,
pub reputations: &'a Reputations,
pub spells: Option<&'a crate::ui_action::Spells>,
+ /// The live display ranges — the reference's `0x8629e0` table, now CVar-backed.
+ pub ranges: &'a combat::CombatLogRanges,
+ /// `CombatLogPeriodicSpells`. The whole-packet gate lives at the `PeriodicAuraLog` dispatch
+ /// arm (it suppresses the floats too); this carries the two *chat-only* read sites.
+ pub periodic: bool,
}
impl ChatCtx<'_> {
@@ -54,7 +59,13 @@ impl ChatCtx<'_> {
/// One endpoint's half of the display-range gate — the law is
/// [`combat::in_range`]; this only supplies the context it reads.
fn in_range(&self, guid: u64, class: UnitClass, poses: &Query<&mut Transform>) -> bool {
- combat::in_range(guid, class, self.self_guid, self.index, poses)
+ combat::in_range(
+ guid,
+ self.ranges.class(class),
+ self.self_guid,
+ self.index,
+ poses,
+ )
}
/// A spell's display name, or `None` when the reference would emit **no line at all** for this
@@ -178,6 +189,12 @@ pub(super) fn spell_damage_log(
);
}
if s.periodic {
+ // The second of `CombatLogPeriodicSpells`' two chat-only read sites: `0x62d9ae` inside
+ // `0x62d9a0` skips the `call 0x628100` line emit and nothing else — both branches converge
+ // and still reach the float emitters, which is why this returns instead of gating the arm.
+ if !ctx.periodic {
+ return;
+ }
// **The TARGET's class, not the caster's** (decision 2127): this leg lands in the shared
// `PERIODICAURADAMAGE` formatter `0x628100`, whose msg-id selector takes `outClassB`
// (`0x628235`), and B is the victim at every call site.
@@ -594,9 +611,10 @@ pub(super) fn spell_insta_kill_log(
/// `IMMUNESPELL` through the plain damage selector `0x626be0`. The difference is real: an immunity
/// to a *helpful* spell still files under `…_DAMAGE`.
///
-/// `IMMUNESPELL`'s `log_format` byte is the reference's "is periodic" flag, and it is the only
-/// thing `CombatLogPeriodicSpells` would gate here — a CVar we do not read live yet, so the flag
-/// changes nothing today and is named rather than dropped.
+/// **`IMMUNESPELL`'s `log_format` byte is the reference's "is periodic" flag, and it is now read.**
+/// `0x62d25f` inside `0x62d240` gates *this formatter only*, and only when that byte is set — so a
+/// direct immunity still prints with `CombatLogPeriodicSpells` off, and a periodic one does not.
+/// `PROCRESIST` is not gated at all: the read site is in the `SPELLORDAMAGE_IMMUNE` arm.
pub(super) fn spell_outcome_log(
s: SpellOutcomeLog,
immune: bool,
@@ -605,6 +623,11 @@ pub(super) fn spell_outcome_log(
poses: &Query<&mut Transform>,
log: &mut ChatLog,
) {
+ // The periodic gate, on the one arm that has it: `IMMUNESPELL` with the packet's periodic
+ // byte set. `PROCRESIST` shares this body but not the gate — its formatter has no read site.
+ if immune && s.log_format != 0 && !ctx.periodic {
+ return;
+ }
let caster = ctx.classify(s.caster, stores);
let target = ctx.classify(s.target, stores);
let Some(spell) = ctx.spell_name(s.spell_id) else {
diff --git a/crates/benilla-app/src/net/apply/combat_log.rs b/crates/benilla-app/src/net/apply/combat_log.rs
index 652880ad..291c84fa 100644
--- a/crates/benilla-app/src/net/apply/combat_log.rs
+++ b/crates/benilla-app/src/net/apply/combat_log.rs
@@ -21,7 +21,9 @@ use benilla_protocol::messages::{
SpellDamageLog, SpellEnergizeLog, SpellHealLog, SpellLogMiss, XpGain,
};
-use crate::combat_text::{damage_color, miss_word, spell_text, CombatTextSpawn, DamageSource};
+use crate::combat_text::{
+ damage_color, miss_word, spell_text, CombatTextSpawn, DamageSource, DamageTextGates,
+};
use crate::names::NameCache;
use crate::ui_chat::ChatLog;
use crate::ui_unit::{CombatTextEvent, UnitCombatFeedback};
@@ -186,6 +188,7 @@ pub(super) fn spell_damage_log(
self_guid: &SelfGuid,
stores: &Query<&mut ObjectStore>,
spells: Option<&crate::ui_action::Spells>,
+ gates: DamageTextGates,
text: &mut MessageWriter,
feedback: &mut MessageWriter,
center: &mut MessageWriter,
@@ -231,7 +234,7 @@ pub(super) fn spell_damage_log(
};
// B = the melee-styled bit: a ranged basic shot (Throw/Auto Shot — AttributesEx3 & 0x8000)
// floats WHITE off this spell packet, exactly like the client (decision 0376).
- let Some(color) = damage_color(source, melee_styled(spells, s.spell_id)) else {
+ let Some(color) = damage_color(gates, source, melee_styled(spells, s.spell_id)) else {
return; // the CombatDamage / PetSpellDamage gates
};
if let (Some(anchor), Some((category, body))) = (
@@ -256,6 +259,7 @@ pub(super) fn periodic_aura_log(
self_guid: &SelfGuid,
stores: &Query<&mut ObjectStore>,
spells: Option<&crate::ui_action::Spells>,
+ gates: DamageTextGates,
text: &mut MessageWriter,
feedback: &mut MessageWriter,
center: &mut MessageWriter,
@@ -357,7 +361,7 @@ pub(super) fn periodic_aura_log(
};
// Same B computation as the direct-damage emit (the periodic emitter `0x626dd0` pushes the
// resolved record to the same `0x6128b0` law).
- let Some(color) = damage_color(source, melee_styled(spells, s.spell_id)) else {
+ let Some(color) = damage_color(gates, source, melee_styled(spells, s.spell_id)) else {
return;
};
let Some(anchor) = gated_anchor(s.target, index, self_guid) else {
@@ -393,6 +397,7 @@ pub(super) fn damage_shield(
index: &GuidIndex,
self_guid: &SelfGuid,
stores: &Query<&mut ObjectStore>,
+ gates: DamageTextGates,
text: &mut MessageWriter,
feedback: &mut MessageWriter,
) {
@@ -413,7 +418,7 @@ pub(super) fn damage_shield(
let Some(source) = classify_source(s.victim, index, self_guid, stores) else {
return; // K = other: never drawn
};
- let Some(color) = damage_color(source, true) else {
+ let Some(color) = damage_color(gates, source, true) else {
return;
};
if let Some(anchor) = gated_anchor(s.attacker, index, self_guid) {
@@ -526,11 +531,14 @@ fn miss_action(code: u8) -> Option<&'static str> {
/// SPELL_GO miss list in practice: vmangos routes only impact-time outcomes (immune, evade) here.
/// Source-classified like every emitter (the classifier lives inside the word twin too); the
/// site's record push is unpinned, so the words keep the row-default white (flagged open).
+#[allow(clippy::too_many_arguments)] // one dispatch arm's full writer set
pub(super) fn spell_log_miss(
s: SpellLogMiss,
index: &GuidIndex,
self_guid: &SelfGuid,
stores: &Query<&mut ObjectStore>,
+ spells: Option<&crate::ui_action::Spells>,
+ gates: DamageTextGates,
text: &mut MessageWriter,
feedback: &mut MessageWriter,
center: &mut MessageWriter,
@@ -558,9 +566,22 @@ pub(super) fn spell_log_miss(
}
}
}
- if classify_source(s.caster, index, self_guid, stores).is_none() {
+ // **The WORD emitter carries the same three CVar gates as the number emitter.** `0x607140`
+ // reads `CombatDamage` (`0x60718f`), `PetSpellDamage` (`0x6071a6`) and `PetMeleeDamage`
+ // (`0x6071c5`) exactly as `0x6128b0` does, and each failure jumps to the epilogue — so a miss
+ // word is suppressed by the same settings that suppress a number. It made no difference while
+ // the three were `const bool` = true; it does now.
+ let Some(source) = classify_source(s.caster, index, self_guid, stores) else {
return; // K = other: never drawn
- }
+ };
+ let Some(_override_color) = damage_color(gates, source, melee_styled(spells, s.spell_id))
+ else {
+ return; // the CombatDamage / Pet* gates
+ };
+ // The colour it picks is deliberately NOT applied here — that is the doc comment's "unpinned
+ // record push", and closing it is a visible change that belongs to whoever is looking at
+ // combat-text colour, not to the CVar wiring. (`melee_impact_text` already applies the
+ // override to ITS words, so the inconsistency is ours, not the reference's.)
for &(target, code) in &s.misses {
if let (Some(anchor), Some((word, category))) =
(gated_anchor(target, index, self_guid), miss_word(code))
diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs
index bba4fa70..463c1b41 100644
--- a/crates/benilla-app/src/player/camera.rs
+++ b/crates/benilla-app/src/player/camera.rs
@@ -175,6 +175,49 @@ const LOOK_SENSITIVITY: f32 = 0.003;
/// reproduces the shipped feel exactly.
pub(crate) const MOUSE_SPEED_RANGE: std::ops::RangeInclusive = 0.5..=1.5;
+/// **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):
+///
+/// ```text
+/// Δyaw_deg = cameraYawMoveSpeed × Δx / 800
+/// Δpitch_deg = cameraPitchMoveSpeed × Δy / 600
+/// ```
+///
+/// **800 × 600 is a screen, not a magic number** — the era's reference resolution. At the shipped
+/// `180`/`90` the law reads: a drag across the full screen width is a half-turn, and a drag up the
+/// full screen height is horizon-to-zenith. That is what makes the two divisors and the two
+/// defaults one design rather than four constants.
+///
+/// **What does NOT transfer is the unit.** The reference has *no DirectInput import at all*
+/// (wow-re `idle-timer-input-stamp-law.md`): it integrates `WM_MOUSEMOVE`, so its `Δ` is a
+/// **screen pixel after Windows pointer acceleration** — which is exactly why its `mousespeed`
+/// slider works by calling `SPI_SETMOUSESPEED` on the OS rather than scaling anything in-engine.
+/// benilla's `Δ` is `AccumulatedMouseMotion`, i.e. winit's `DeviceEvent::MouseMotion`: **raw,
+/// unaccelerated device units**. `deg per accelerated pixel` and `deg per raw unit` are different
+/// quantities, and the factor between them is a per-machine OS setting, not a fact about the
+/// client — so transplanting `180`/`90` as absolute numbers would be a confident guess on a
+/// load-bearing constant.
+///
+/// So we take the law's **shape** — per-axis, linear in the CVar — and anchor its **scale** to
+/// [`LOOK_SENSITIVITY`], the rate this client has shipped and the director has been looking at
+/// since 1140. The CVars therefore keep the reference's own defaults (1804: a setting's default is
+/// the reference's) and the divergence lands here, in a named constant, where it can be read.
+///
+/// **The one live divergence this leaves**, stated rather than buried: the reference is
+/// *anisotropic* at its defaults — `180/800 = 0.225` deg/px of yaw against `90/600 = 0.15` of
+/// pitch, so its yaw turns 1.5× faster than its pitch. Ours is isotropic, because
+/// [`LOOK_SENSITIVITY`] is one number. The **ratio** is unit-independent (both axes take the same
+/// acceleration curve), so unlike the absolute scale it *is* transferable — it is simply a feel
+/// change, and feel is the director's call, not a fidelity bug to fix quietly.
+const LOOK_YAW_PER_SPEED: f32 = LOOK_SENSITIVITY / 180.0;
+const LOOK_PITCH_PER_SPEED: f32 = LOOK_SENSITIVITY / 90.0;
+
+/// The reference's validator range for all four `camera*MoveSpeed`/`SmoothSpeed` CVars
+/// (`0x50c000` → `0x50b330`). It **rejects rather than clamps**: out of range prints
+/// `"Value out of range (%f - %f)"` and `CVar::Set` skips the store, so the old value stands.
+pub(crate) const CAMERA_SPEED_RANGE: std::ops::RangeInclusive = 0.1..=360.0;
+
/// The mouse-look player knobs (decision 0961): `mouseInvertPitch` is 1.12's own Interface
/// Options checkbox (UIOptionsFrame.lua index 1, CVar-backed), settable from the Options
/// window's Controls page through the CVar store (0954). Inverted, moving the mouse up pitches
@@ -189,6 +232,12 @@ pub(crate) const MOUSE_SPEED_RANGE: std::ops::RangeInclusive = 0.5..=1.5;
pub(crate) struct LookConfig {
pub(crate) invert_pitch: bool,
pub(crate) sensitivity: f32,
+ /// `cameraYawMoveSpeed` — the MOUSE_LOOK_SPEED slider (90…270 by 10). See
+ /// [`LOOK_YAW_PER_SPEED`] for why the number is the reference's and the scale is ours.
+ pub(crate) yaw_speed: f32,
+ /// `cameraPitchMoveSpeed` — no slider of its own; `UIOptionsFrame_Save` writes it as
+ /// `cameraYawMoveSpeed / 2` beside the yaw one, which is exactly the reference's 180/90 pair.
+ pub(crate) pitch_speed: f32,
}
impl Default for LookConfig {
@@ -196,6 +245,10 @@ impl Default for LookConfig {
Self {
invert_pitch: false,
sensitivity: 1.0,
+ // The reference's registered defaults, and with them the shipped feel: at these two
+ // values both axes land on `LOOK_SENSITIVITY` exactly.
+ yaw_speed: 180.0,
+ pitch_speed: 90.0,
}
}
}
@@ -205,8 +258,14 @@ impl LookConfig {
/// both readers must agree: the look rotation itself and the click-vs-drag travel budget that
/// decides whether a press was a click. Splitting them would let the slider move the drag
/// threshold out from under the gesture (decision 1140).
- pub(super) fn rate(self) -> f32 {
- LOOK_SENSITIVITY * self.sensitivity
+ pub(super) fn yaw_rate(self) -> f32 {
+ self.yaw_speed * LOOK_YAW_PER_SPEED * self.sensitivity
+ }
+
+ /// The pitch axis's rate — its own CVar, because the reference's law has its own divisor for
+ /// it (600, the reference screen's height) and its own default (90).
+ pub(super) fn pitch_rate(self) -> f32 {
+ self.pitch_speed * LOOK_PITCH_PER_SPEED * self.sensitivity
}
}
/// The auto-follow's angular rate — 1.12's `cameraYawSmoothSpeed`, registrar default **180 °/s**
@@ -998,9 +1057,9 @@ pub(super) fn run_look_session(
// the release decides. The travel is charged from the *input* delta, before the pitch clamp —
// the reference accumulates raw device motion, so a drag pinned at the pitch limit still spends
// its budget.
- let rate = look_cfg.rate();
- let dyaw = (mouse_motion.delta.x * rate).abs();
- let dpitch = (mouse_motion.delta.y * rate).abs();
+ let (yaw_rate, pitch_rate) = (look_cfg.yaw_rate(), look_cfg.pitch_rate());
+ let dyaw = (mouse_motion.delta.x * yaw_rate).abs();
+ let dpitch = (mouse_motion.delta.y * pitch_rate).abs();
for test in [&mut *left_click, &mut *right_click].into_iter().flatten() {
test.yaw_travel += dyaw;
test.pitch_travel += dpitch;
@@ -1088,14 +1147,14 @@ pub(super) fn run_look_session(
// turns the character (its facing tracks the camera yaw); left-drag leaves the character facing.
if let Some(active) = rig.look {
let delta = mouse_motion.delta;
- cam.yaw -= delta.x * rate;
+ cam.yaw -= delta.x * yaw_rate;
// `mouseInvertPitch` flips only the pitch axis (the 1.12 checkbox's whole meaning).
let dy = if look_cfg.invert_pitch {
-delta.y
} else {
delta.y
};
- cam.pitch = (cam.pitch - dy * rate).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT);
+ cam.pitch = (cam.pitch - dy * pitch_rate).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT);
if active == LookButton::Right || both_buttons {
*face_yaw = cam.yaw;
}
@@ -1782,17 +1841,72 @@ mod tests {
/// scales with the pointer instead of drifting away from it.
#[test]
fn the_sensitivity_slider_is_a_multiplier_over_the_shipped_rate() {
- assert_eq!(LookConfig::default().rate(), LOOK_SENSITIVITY);
+ // **The shipped feel is unchanged by the two move-speed CVars landing**: at the
+ // reference's own defaults BOTH axes come out at exactly the rate this client has always
+ // used. That is the whole point of anchoring the scale to `LOOK_SENSITIVITY` rather than
+ // transplanting the reference's deg-per-accelerated-pixel onto our raw device delta.
+ let d = LookConfig::default();
+ assert_eq!(d.yaw_rate(), LOOK_SENSITIVITY);
+ assert_eq!(d.pitch_rate(), LOOK_SENSITIVITY);
+
let fast = LookConfig {
sensitivity: 1.5,
..Default::default()
};
- assert_eq!(fast.rate(), LOOK_SENSITIVITY * 1.5);
+ assert_eq!(fast.yaw_rate(), LOOK_SENSITIVITY * 1.5);
+ assert_eq!(fast.pitch_rate(), LOOK_SENSITIVITY * 1.5);
let slow = LookConfig {
sensitivity: *MOUSE_SPEED_RANGE.start(),
..Default::default()
};
- assert_eq!(slow.rate(), LOOK_SENSITIVITY * 0.5);
+ assert_eq!(slow.yaw_rate(), LOOK_SENSITIVITY * 0.5);
+ assert_eq!(slow.pitch_rate(), LOOK_SENSITIVITY * 0.5);
+ }
+
+ /// **Each move-speed CVar scales its own axis, linearly, and only its own** — the reference's
+ /// law shape (`Δyaw_deg = value × Δx / 800`, `Δpitch_deg = value × Δy / 600`).
+ ///
+ /// The slider's own stops are the check: `UIOptionsFrameSliders`' MOUSE_LOOK_SPEED row runs
+ /// 90…270, so its ends are half and one-and-a-half times the shipped rate.
+ #[test]
+ fn each_move_speed_cvar_scales_its_own_axis() {
+ let doubled_yaw = LookConfig {
+ yaw_speed: 360.0,
+ ..Default::default()
+ };
+ assert_eq!(doubled_yaw.yaw_rate(), LOOK_SENSITIVITY * 2.0);
+ assert_eq!(
+ doubled_yaw.pitch_rate(),
+ LOOK_SENSITIVITY,
+ "the yaw CVar must not move the pitch axis"
+ );
+
+ let doubled_pitch = LookConfig {
+ pitch_speed: 180.0,
+ ..Default::default()
+ };
+ assert_eq!(doubled_pitch.pitch_rate(), LOOK_SENSITIVITY * 2.0);
+ assert_eq!(doubled_pitch.yaw_rate(), LOOK_SENSITIVITY);
+
+ // The MOUSE_LOOK_SPEED slider's two ends. Approximate, because the two sides multiply
+ // the same three factors in a different order and f32 is not associative — the property
+ // under test is the ratio, not the last bit.
+ let near = |a: f32, b: f32| (a - b).abs() < 1e-9;
+ for (speed, factor) in [(90.0_f32, 0.5_f32), (270.0, 1.5)] {
+ let at = LookConfig {
+ yaw_speed: speed,
+ ..Default::default()
+ };
+ assert!(near(at.yaw_rate(), LOOK_SENSITIVITY * factor));
+ }
+
+ // `mousespeed` still multiplies on top of both — the 1140 property, unchanged.
+ let both = LookConfig {
+ yaw_speed: 270.0,
+ sensitivity: 1.5,
+ ..Default::default()
+ };
+ assert!(near(both.yaw_rate(), LOOK_SENSITIVITY * 1.5 * 1.5));
}
/// **The auto-follow** (decisions 1493/1502) — 1.12's `cameraSmoothStyle`, the setting benilla
diff --git a/crates/benilla-app/src/sound/emote.rs b/crates/benilla-app/src/sound/emote.rs
index dd99252b..f84cfa63 100644
--- a/crates/benilla-app/src/sound/emote.rs
+++ b/crates/benilla-app/src/sound/emote.rs
@@ -119,6 +119,13 @@ fn emote_sounds(
};
let kit = match m.kind {
EmoteKind::Text(text_id) => {
+ // **`EmoteSounds` gates the received text-emote voice, and only that** — the
+ // reference looks the CVar up by name at play time and, on a zero, never fetches
+ // the kit at all (silence, not a muted play). The `Anim` arm below is the
+ // `Emotes.dbc` `EventSoundID` one-shot, a different channel with no such gate.
+ if !config.emote_sounds {
+ continue;
+ }
let (Some(race), Some(sex)) = (store.0.unit_race(), store.0.unit_gender()) else {
continue;
};
diff --git a/crates/benilla-app/src/sound/mod.rs b/crates/benilla-app/src/sound/mod.rs
index eab5cd30..91756f38 100644
--- a/crates/benilla-app/src/sound/mod.rs
+++ b/crates/benilla-app/src/sound/mod.rs
@@ -219,6 +219,54 @@ pub(crate) struct SoundConfig {
/// kits overlap — see [`limiter`] for the measured arithmetic. This exists so the fix can be
/// A/B'd against what it fixed: `/run SetCVar("SoundOutputLimiter", 0)` applies live.
pub limiter: bool,
+ /// **Where the 3-D listener sits** — 1.12's `SoundListenerAtCharacter` (`0x457890`, registrar
+ /// default `"1"`, help "lock listener at character"; the stock Sound panel's check button 7).
+ /// Read by [`update_audio_listener`], whose two branches ARE the reference's two branches.
+ ///
+ /// The reference keeps no `CVar::Register` handle for it: `0x481ba0` looks it up by name once
+ /// and caches the record at `[0xb4b2b4]`, and the single read is `0x483125` inside the
+ /// per-frame driver `0x482ea0`. The sink `FSOUND_3D_Listener_SetAttributes` has exactly one
+ /// call site image-wide (`0x483218`), so this one branch decides the whole listener.
+ ///
+ /// **It selects position AND orientation together, never one alone** (wow-re
+ /// `sound/scratch/benilla-pins.md` §B14, VERIFIED): `1` puts the listener on the active
+ /// mover with the character's *facing* about world-up — so volume and pan never change with
+ /// zoom or camera orbit — and `0` puts it at the camera eye with the camera's own basis.
+ /// Velocity is NULL either way, so the listener contributes no doppler in either mode.
+ ///
+ /// **A cinematic overrides it outright** and is checked first (`0x483112`, ahead of the CVar);
+ /// so do our own pre-login / free-fly / no-pivot cases, which have no character to sit on.
+ pub listener_at_character: bool,
+ /// **Emote sounds** — 1.12's `EmoteSounds` (`0x4573b9`, registrar default `"1"`; the stock
+ /// Sound panel's check button 8). Gates the **received** text-emote voice line only: the
+ /// race/sex kit from `EmotesTextSound.dbc` that `SMSG_TEXT_EMOTE` plays through
+ /// [`emote::emote_sounds`]. Looked up by name at play time in the reference (`0x63de30` over
+ /// `0x835b60`), tested at `+0x28`, and on a zero no kit is fetched at all — silence, not a
+ /// muted play.
+ ///
+ /// **It does NOT gate:** creature reaction barks, NPC greetings, `$ESD` emote-state sounds, or
+ /// FrameXML `PlaySound()` — those are the other per-unit channels.
+ ///
+ /// **One thing wow-re does not settle**, and it is recorded rather than guessed: two of its
+ /// notes place the lookup in `0x623c80` (received text-emote only), a third places it on the
+ /// shared leg `0x623c10`, which the `$CSD` M2 anim-event also enters — and if it is really
+ /// `0x623c10`, this CVar silences those too. Nothing records whether the *outgoing* local
+ /// `DoEmote` vocal is gated at all. We take the narrower, twice-recorded reading: the received
+ /// path. Widening it later is one more call site, not a redesign.
+ pub emote_sounds: bool,
+ /// **Zone music with no silence gap** — 1.12's `SoundZoneMusicNoDelay` (`0x4578b3`, registrar
+ /// default `"0"`; the stock Sound panel's check button 6, labelled *Loop Music*). Read by
+ /// [`zone::next_track_time`], which is the reference's `0x4601f0` — and its sole caller there
+ /// is the natural end-of-track reap, which is exactly ours.
+ ///
+ /// **It removes the intra-zone loop-restart gap, not the zone-change transition** (wow-re
+ /// `sound/scratch/zone-music-ambience-transition.md` Q1/Q2, which corrects that note's own
+ /// earlier framing): the thing it deletes is the randomised `ZoneMusic.dbc`
+ /// SilenceIntervalMin/Max wait between successive plays of the *same* zone's track, whose
+ /// length is data rather than a constant. A zone CHANGE is already immediate and always was —
+ /// the incoming track starts on the next tick while the outgoing fades over 4 s, an overlap
+ /// rather than a gap.
+ pub zone_music_no_delay: bool,
}
impl SoundConfig {
@@ -253,6 +301,10 @@ impl Default for SoundConfig {
world_hold: false,
music_suppressed: false,
limiter: true,
+ // The reference's registered defaults: "1", "1", "0".
+ listener_at_character: true,
+ emote_sounds: true,
+ zone_music_no_delay: false,
}
}
}
@@ -580,13 +632,15 @@ impl Plugin for SoundPlugin {
}
/// Compute this frame's [`AudioListener`] and feed it to the backend. The listener sits at the
-/// **character** (`SoundListenerAtCharacter=1`, wow-re benilla-pins B14): position at the avatar's
-/// head, orientation at the character's *facing* about world-up — so 3D volume and pan never change
-/// with zoom or camera orbit. The camera eye + basis are the fallback (the client's `=0` path): before
-/// login, in free-fly (`detached`), or before the body model attaches (no `CameraPivot` yet).
+/// **character** ([`SoundConfig::listener_at_character`], the reference's default): position at the
+/// avatar's head, orientation at the character's *facing* about world-up — so 3D volume and pan
+/// never change with zoom or camera orbit. The camera eye + basis are the client's `=0` path, and
+/// also our fallback whether or not the CVar asks for it: before login, in free-fly (`detached`),
+/// or before the body model attaches (no `CameraPivot` yet), there is no character to sit on.
fn update_audio_listener(
mut listener: ResMut,
mut out: NonSendMut,
+ config: Res,
player: Res,
cinematic: Option>,
self_av: Query<(&Transform, Option<&CameraPivot>), With>,
@@ -606,7 +660,7 @@ fn update_audio_listener(
.is_some_and(crate::cinematic::Cinematic::is_playing);
// At-character (the default). `player.pos` is the feet; the head offset is the shared
// model-derived pivot height, and the facing is the aim yaw about world-up (world +Y).
- if player.active && !player.detached && !flying {
+ if config.listener_at_character && player.active && !player.detached && !flying {
if let Ok((t, pivot)) = self_av.single() {
listener.pos = player.pos + Vec3::Y * head_height(pivot, t.scale.x);
listener.rot = Quat::from_rotation_y(player.facing());
@@ -616,7 +670,9 @@ fn update_audio_listener(
return;
}
}
- // At-camera (the `SoundListenerAtCharacter=0` path, and the cinematic override above).
+ // At-camera: the `SoundListenerAtCharacter=0` path, the cinematic override above, and the
+ // no-character cases the reference also has (it simply does not update the listener that
+ // frame; we seat it on the camera, which is where the view is).
if let Ok(t) = cam.single() {
listener.pos = t.translation;
listener.rot = t.rotation;
diff --git a/crates/benilla-app/src/sound/zone.rs b/crates/benilla-app/src/sound/zone.rs
index 1c2b44a6..a513a0a2 100644
--- a/crates/benilla-app/src/sound/zone.rs
+++ b/crates/benilla-app/src/sound/zone.rs
@@ -449,7 +449,8 @@ fn zone_audio(
if h.state() == kira::sound::PlaybackState::Stopped {
zone.music = None;
let zm = zone.zone_music;
- zone.next_track_at = next_track_time(zone, &areas.0, zm, phase, now);
+ zone.next_track_at =
+ next_track_time(zone, &areas.0, zm, phase, now, config.zone_music_no_delay);
}
}
// Silence elapsed → start the next track (same-zone cycle, the cold-start first track, or the
@@ -529,20 +530,34 @@ fn zone_music_row(cat: &AreaSoundCatalog, _id: u32) -> Option<&benilla_formats::
/// When the NEXT track should start after this one ends — the client's `0x4601f0`, whose sole
/// caller is the natural-end reap `0x4600b6`: `None` if the zone has no music; otherwise `now +`
-/// the row's randomized per-phase silence interval. (`SoundZoneMusicNoDelay`, the immediate path,
-/// is a "0" CVar we don't expose.) **Not a cold start** — `0x4601f0`'s `== 0 → now + 6000 ms` arm
-/// needs a *cleared* currently-playing row, which end-of-track flow never presents, and an entry
-/// never reaches this function at all (it takes the −1 "start now" path; module docs, 1553).
+/// the row's randomized per-phase silence interval — **unless `SoundZoneMusicNoDelay` is set, in
+/// which case the next track starts now** (`0x42c010`, the CVar's own branch, and the first of the
+/// function's three).
+///
+/// That branch is the whole of what the CVar does, and it is narrower than its options-panel
+/// label (*Loop Music*) suggests: it deletes the randomised `ZoneMusic.dbc` SilenceIntervalMin/Max
+/// wait between successive plays of the SAME zone's track. A zone CHANGE is immediate either way —
+/// the incoming track starts on the next tick while the outgoing fades over 4 s, an overlap rather
+/// than a gap (wow-re `zone-music-ambience-transition.md` Q1/Q2, which corrects an earlier framing
+/// of exactly this).
+///
+/// **Not a cold start** — `0x4601f0`'s `== 0 → now + 6000 ms` arm needs a *cleared* currently-playing
+/// row, which end-of-track flow never presents, and an entry never reaches this function at all
+/// (it takes the −1 "start now" path; module docs, 1553).
fn next_track_time(
zone: &mut ZoneAudio,
cat: &AreaSoundCatalog,
music_row: u32,
phase: usize,
now: f64,
+ no_delay: bool,
) -> Option {
if music_row == 0 {
return None;
}
+ if no_delay {
+ return Some(now);
+ }
// Read the min/max out from under the catalog borrow before the rng draw (which needs `zone`).
let interval =
zone_music_row(cat, music_row).map(|m| (m.silence_min[phase], m.silence_max[phase]));
diff --git a/crates/benilla-app/src/target/by_name.rs b/crates/benilla-app/src/target/by_name.rs
index e8a63db2..0cf8fbc7 100644
--- a/crates/benilla-app/src/target/by_name.rs
+++ b/crates/benilla-app/src/target/by_name.rs
@@ -607,6 +607,8 @@ pub(crate) struct SelectCommit<'w, 's> {
index: Res<'w, GuidIndex>,
factions: Option>,
reputations: Res<'w, crate::net::Reputations>,
+ /// `assistAttack` — the assist tail's second leg (see [`Self::assist`]).
+ assist_attack: Res<'w, super::AssistAttack>,
}
impl SelectCommit<'_, '_> {
@@ -620,8 +622,9 @@ impl SelectCommit<'_, '_> {
/// of them silent and none of them a deselect: no basis, a basis targeting nothing, and a
/// target guid that is not streamed (`0x489a40`'s arm 3 is a bare `ret`).
///
- /// The `assistAttack` swing leg is correctly absent — the CVar's registered default is `"0"`
- /// (VERIFIED at `0x48fc50`), so stock assist selects and does not swing. See the drain below.
+ /// **The `assistAttack` swing leg** (`0x489c02`/`0x489d02 call 0x5ecb70`) runs after the
+ /// selection when that CVar is non-zero — its registered default is `"0"`, so stock assist
+ /// selects and does not swing.
pub(super) fn assist(&mut self, basis: Entity, how: &str) {
let Some(guid) = self
.stores
@@ -641,7 +644,28 @@ impl SelectCommit<'_, '_> {
return;
};
info!("assist ({how}) -> the basis unit's target, guid {guid:#x}");
+ // `engaged` and the attackability walk are read BEFORE the selection, and that ordering is
+ // the reference's own seen from outside: `SetSelection` may itself have re-pointed a swing
+ // we already had (`0x4938c8`), and `0x5ecb70`'s already-attacking test then skips the
+ // *send* while still running the tail — the skip's `jmp` lands inside it. So the second leg
+ // is a no-op send plus a real sheath snap when we were already fighting, and a fresh swing
+ // when we were not.
+ let me = self.me.single().ok();
+ let engaged = me.is_some_and(|(_, _, engaged)| engaged);
+ let attackable = super::relations::can_attack(
+ self.stores.get(entity).ok(),
+ self.factions.as_deref(),
+ &self.reputations,
+ me.and_then(|(_, store, _)| store),
+ );
self.commit(entity, guid);
+ // The second leg. `0x5ecb70`'s own attackability walk is the caller's here, as it is at
+ // every other benilla call site (`start_attack_local`'s header): a friendly assist target
+ // is selected and not swung at.
+ if self.assist_attack.0 && attackable {
+ info!("assist ({how}): assistAttack is on -> opening the swing");
+ self.seam.start(guid, engaged, false);
+ }
}
/// Select a guid that is already resolved — `0x489a40`'s arm 1, plus [`scan::commit`]'s law.
diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs
index 0bcaa294..87a998e3 100644
--- a/crates/benilla-app/src/target/click.rs
+++ b/crates/benilla-app/src/target/click.rs
@@ -2165,6 +2165,7 @@ mod tests {
world.init_resource::();
world.init_resource::();
world.init_resource::();
+ world.init_resource::();
world.init_resource::();
world.init_resource::();
world.init_resource::>();
diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs
index 0493d7ab..31f38139 100644
--- a/crates/benilla-app/src/target/mod.rs
+++ b/crates/benilla-app/src/target/mod.rs
@@ -339,6 +339,24 @@ impl Default for ClickConfig {
}
}
+/// **`assistAttack`** — the second, opt-in leg of `/assist` (1.12's *Assist Attack* checkbox).
+///
+/// `/assist` always does leg 1: read the basis unit's `UNIT_FIELD_TARGET` and select it
+/// (`CMSG_SET_SELECTION`). With this CVar non-zero the reference's shared tail runs a **second**
+/// leg — `0x489c02`/`0x489d02 call 0x5ecb70` `StartAttack(&guid)` — which stands you and sends
+/// `CMSG_ATTACKSWING`. So `/assist` stops meaning "select what my friend is fighting" and starts
+/// meaning "select it and open the swing".
+///
+/// Its record `[0xb4d8f8]` has exactly **three references image-wide**: the registration store and
+/// the two shared assist tails. `CanAssist 0x6066f0` is *not* on this path (verified negative over
+/// all 25 of its call sites).
+///
+/// **Registered default `"0"`, so nothing changes until a player asks for it.** That number cost
+/// wow-re a correction it records against itself: its first pass read `"3"` off the *next*
+/// registration's default (`minimapZoom`), the `mov ds:` adjacency trap.
+#[derive(Resource, Default)]
+pub(crate) struct AssistAttack(pub(crate) bool);
+
/// Targeting: click-to-select + the ground selection ring.
pub(crate) struct TargetPlugin;
@@ -346,6 +364,7 @@ impl Plugin for TargetPlugin {
fn build(&self, app: &mut App) {
app.init_resource::()
.init_resource::()
+ .init_resource::()
.init_resource::()
.init_resource::()
.init_resource::()
diff --git a/crates/benilla-app/src/ui_chat.rs b/crates/benilla-app/src/ui_chat.rs
index 09bb9ee8..04d05747 100644
--- a/crates/benilla-app/src/ui_chat.rs
+++ b/crates/benilla-app/src/ui_chat.rs
@@ -40,6 +40,9 @@ mod input;
mod language;
/// `LoggingChat`/`LoggingCombat` — the two log files `/chatlog` and `/combatlog` toggle.
mod logging;
+/// The `AUTO_JOIN_GUILD_CHANNEL` cascade (decision 2144) — the one place the client joins or
+/// leaves `GuildRecruitment - City` on its own.
+mod recruitment;
/// The chat windows' saved look (B246, decision 1589) — where the tab menu's tint/alpha/font-size
/// picks are read from at login and written back at logout.
mod settings;
@@ -75,7 +78,16 @@ impl Plugin for UiChatPlugin {
.init_resource::()
.init_resource::()
.init_resource::()
+ .init_resource::()
.init_resource::()
+ .init_resource::()
+ // `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`).
+ // 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.
+ .init_resource::()
// `ChatChannels.dbc` — six rows, read once; the auto-join walk and every chat event's
// arg7 both come out of it. **`.after(AssetSet::Open)` is load-bearing**: without it
// this runs before the patch chain exists, takes its `assets: Option>` `None`
@@ -185,12 +197,18 @@ impl Plugin for UiChatPlugin {
// into packets, so reading last frame's answer is not a cosmetic lag: it joined the
// previous character's capital at login and then left it again, and the leave took the
// stock `ChatFrame_OnEvent`'s channel registration with it.
+ //
+ // The guild-recruitment cascade (decision 2144) runs **after the walk**, where the
+ // reference runs it — the tail of `ZoneChannelRefresh` — and reads the zone the walk
+ // just published.
.add_systems(
Update,
(
channels::end_session_channels_on_disconnect,
channels::auto_join_zone_channels
.run_if(in_state(crate::char_select::ClientState::InWorld)),
+ recruitment::guild_recruitment_cascade
+ .run_if(in_state(crate::char_select::ClientState::InWorld)),
)
.chain()
.after(benilla_world::terrain_stream::AreaAuthoritySet),
diff --git a/crates/benilla-app/src/ui_chat/ace_gate_tests.rs b/crates/benilla-app/src/ui_chat/ace_gate_tests.rs
index 184889b2..ab843a42 100644
--- a/crates/benilla-app/src/ui_chat/ace_gate_tests.rs
+++ b/crates/benilla-app/src/ui_chat/ace_gate_tests.rs
@@ -219,12 +219,14 @@ fn the_auto_join_walk_names_the_channels_the_server_resolves() {
"the 0x200 sentinel row names the channel"
);
- let out_in_the_world = super::channels::wanted_channels(&cat, "Elwynn Forest", false, city);
+ let out_in_the_world =
+ super::channels::wanted_channels(&cat, 0x0020_0003, "Elwynn Forest", false, city);
assert_eq!(
out_in_the_world,
vec!["General - Elwynn Forest", "LocalDefense - Elwynn Forest"]
);
- let in_a_capital = super::channels::wanted_channels(&cat, "Stormwind City", true, city);
+ let in_a_capital =
+ super::channels::wanted_channels(&cat, 0x0020_0003, "Stormwind City", true, city);
assert_eq!(
in_a_capital,
vec's `INITIAL` rows
-//! composed against the player's current **zone** and sent as ordinary joins. It re-runs whenever
-//! the zone (or the in-a-capital answer) changes: the zone-dependent rows carry the zone name
-//! *inside* the channel name, so crossing a border is genuinely leaving one channel and joining
-//! another — LEAVE(old) then JOIN(new), interleaved per DBC row.
+//! The walk is every [`ChatChannels.dbc`](benilla_formats::ChatChannelsCatalog) row whose bit is
+//! set in the character's **`ZONECHANNELS` mask** ([`ChannelState::zone_mask`] — seeded from the
+//! DBC's `INITIAL` rows on a fresh character, then moved only by confirmed joins and explicit
+//! leaves; decisions 2120 and 2144), composed against the player's current **zone** and sent as
+//! ordinary joins. It re-runs whenever the zone changes: the zone-dependent rows carry the zone
+//! name *inside* the channel name, so crossing a border is genuinely leaving one channel and
+//! joining another — LEAVE(old) then JOIN(new), interleaved per slot.
//!
//! The mechanism is byte-verified in wow-re's `system/ui/scratch/zone-chat-channel-autojoin.md`
//! (the whole thing is one function, `ZoneChannelRefresh 0x49a210`); each part below cites the
@@ -38,6 +40,7 @@ use crate::area::AreaTableRes;
use crate::net::{ClientCommand, NetCommands};
use super::edit::{ChannelState, SlotState};
+use super::recruitment::GuildRecruitmentCascade;
/// `AreaTable.dbc` flag `0x08` — vmangos calls it `AREA_FLAG_SLAVE_CAPITAL`, with the comment
/// *"Allow trade channel"* (`src/game/Database/DBCEnums.h:58`). In the shipped 5875 table exactly
@@ -68,18 +71,27 @@ pub(crate) fn city_word(areas: &benilla_formats::AreaTableCatalog) -> Option<&st
.filter(|n| !n.is_empty())
}
-/// What the walk currently believes it holds, and the state it composed that from.
+/// The walk's own state: the zone it last walked, the zone it can act on now, and whether there
+/// is a character session to walk for.
///
-/// Deliberately **not** [`ChannelState::joined`], which is server truth fed by YOU_JOINED /
-/// YOU_LEFT notices. This is the *request* side: keying the diff off what we asked for is what
-/// stops a join the server refuses (wrong faction, banned) from being re-sent every frame.
+/// Deliberately **not** a second copy of the joined list. The reference's pass 1 walks the slot
+/// array itself (`0x49a284`, stride `0xa0`) and decides leave-vs-join from each slot's own state
+/// (`+0x9c`); this walk does the same over [`ChannelState::joined`] (decision 2144). The request
+/// side that once lived here (`held`) fell out the moment 2137 made the walk register its slots
+/// at send time — from then on the slot array *was* the request side, and a channel joined
+/// outside the walk (`/join General`, the guild-recruitment cascade) took a slot the walk did not
+/// know about and re-joined it on the next border.
#[derive(Resource, Default)]
pub(crate) struct ZoneChannelWalk {
- /// The composed names last requested, in `ChatChannels.dbc` row order.
- held: Vec,
- /// The `(zone name, in a capital)` the held set was composed for. `None` = never walked, or
+ /// The top-level `AreaTable` zone the last walk was composed for. `None` = never walked, or
/// the session ended — either way the next in-world frame re-walks from scratch.
- at: Option<(String, bool)>,
+ at: Option,
+ /// **The zone the walk can act on THIS frame** — the reference's `ds:0xb4e314`, the zone id
+ /// `UpdateZoneText` writes and both the walk (`0x49a243`) and the guild-recruitment cascade
+ /// (`0x49ead7`) read. `None` while any gate holds it (no session, a cinematic, a world still
+ /// arriving, no area authority). Published every frame, walked or not, so the cascade one
+ /// system over resolves the same zone the walk did rather than deriving its own.
+ pub(super) zone_id: Option,
/// **Is there a character session to join channels for?** Armed by `EnteredWorldMessage`,
/// disarmed by [`end_channel_session`] — a positive edge in both directions, and the reason
/// this is a field rather than an ordering.
@@ -96,8 +108,8 @@ impl ZoneChannelWalk {
/// Forget everything: leaving the world took our channel membership with it, so the next entry
/// must re-join rather than assume. See [`end_channel_session`].
fn clear_session(&mut self) {
- self.held.clear();
self.at = None;
+ self.zone_id = None;
self.live = false;
}
}
@@ -111,22 +123,27 @@ impl ZoneChannelWalk {
/// character after a reconnect — enters the world in **zero** channels, always.
///
/// Ours used to survive that boundary, and the director caught it on a character switch: the walk
-/// still `held` the previous character's `General - Tanaris`, so its first diff on the new
+/// still held the previous character's `General - Tanaris`, so its first diff on the new
/// character sent LEAVE(Tanaris) — which the new session is genuinely not in, so the server
/// answered "Not on channel 1. General - Tanaris." — and [`ChannelState::joined`] still listed
/// those two dead rows, which is why the real joins came back numbered 3 and 4 instead of 1 and 2.
///
-/// Three things end together because they are one fact: what we asked for ([`ZoneChannelWalk`]),
-/// what the server confirmed ([`ChannelState::joined`]), and the VM's mirror of the latter (what
-/// `GetChannelName` answers an addon). The edit box's channel target goes too — it holds the wire
-/// name of a channel that no longer exists for us, and a `/2` typed on the new character would
-/// otherwise send into it.
+/// Four things end together because they are one fact: the walk's zone, what the server confirmed
+/// ([`ChannelState::joined`]), the VM's mirror of the latter (what `GetChannelName` answers an
+/// addon), and the guild-recruitment cascade's watcher (its next login must see the guild id as
+/// new). The edit box's channel target goes too — it holds the wire name of a channel that no
+/// longer exists for us, and a `/2` typed on the new character would otherwise send into it.
+///
+/// **The `ZONECHANNELS` mask is deliberately not on that list** (2120): it belongs to the
+/// character's file, and the login that reads that file is what seats it.
fn end_channel_session(
script: Option<&mut benilla_ui::script::UiScript>,
channels: &mut ChannelState,
walk: &mut ZoneChannelWalk,
+ cascade: &mut GuildRecruitmentCascade,
) {
walk.clear_session();
+ cascade.clear_session();
channels.joined.clear();
if let Some(script) = script {
script.set_joined_channels(Vec::new());
@@ -159,25 +176,37 @@ pub(super) fn end_session_channels(
script: Option>,
mut channels: ResMut,
mut walk: ResMut,
+ mut cascade: ResMut,
) {
- end_channel_session(script.map(NonSendMut::into_inner), &mut channels, &mut walk);
+ end_channel_session(
+ script.map(NonSendMut::into_inner),
+ &mut channels,
+ &mut walk,
+ &mut cascade,
+ );
}
/// The other end: a socket that died. A **recoverable** drop never leaves `InWorld` (0065 keeps the
/// avatar as the local puppet for the reconnect), so the edge above does not fire for it — but the
/// reconnect still builds a fresh `Player` server-side, with the same empty channel list a fresh
-/// login has. Both edges therefore clear, and they clear the same four things through the same
+/// login has. Both edges therefore clear, and they clear the same things through the same
/// function so neither can drift into being the more thorough one.
pub(super) fn end_session_channels_on_disconnect(
script: Option>,
mut channels: ResMut,
mut walk: ResMut,
+ mut cascade: ResMut,
mut disconnects: MessageReader,
) {
if disconnects.read().next().is_none() {
return;
}
- end_channel_session(script.map(NonSendMut::into_inner), &mut channels, &mut walk);
+ end_channel_session(
+ script.map(NonSendMut::into_inner),
+ &mut channels,
+ &mut walk,
+ &mut cascade,
+ );
}
/// Startup: read `ChatChannels.dbc` into [`ChannelState`], which owns it because the two things
@@ -201,63 +230,248 @@ pub(super) fn load_chat_channels(
}
}
-/// The names the auto-join rows compose to for a player standing in `zone_name`, in table order.
+/// The name `row` composes to for a player standing in `zone_name`, or `None` when the
+/// composition has nothing to substitute: a zone-dependent row with no zone, or a city-named row
+/// with no city word.
///
-/// `in_city` gates the city-**only** rows (the `0x10` bit); `city_word` is what a city-**named**
-/// row (`0x20`) puts in its `%s`, and an absent one skips those rows rather than composing
-/// `"Trade - "`. The client has no such guard — its capital arm null-checks the DBC row pointer
-/// but not the string, so a locale with that row's name blank would compose exactly that
+/// The client has no such guard — its capital arm null-checks the DBC row pointer but not the
+/// string, so a locale with the sentinel row's name blank would compose exactly `"Trade - "`
/// (wow-re `zone-chat-channel-autojoin.md`, branch caveat). We decline instead: joining the wrong
/// channel name is invisible and permanent, and joining none is neither.
-pub(crate) fn wanted_channels(
- catalog: &ChatChannelsCatalog,
+fn compose(
+ row: &benilla_formats::ChatChannelRow,
zone_name: &str,
- in_city: bool,
city_word: Option<&str>,
-) -> Vec {
- composed_auto_rows(catalog, zone_name, city_word)
- .filter(|(r, _)| in_city || !r.is_city_only())
- .map(|(_, name)| name)
- .collect()
+) -> Option {
+ if row.is_zone_dependent() && zone_name.is_empty() {
+ return None;
+ }
+ if row.takes_city_name() && city_word.is_none() {
+ return None;
+ }
+ Some(row.joinable_name(zone_name, city_word.unwrap_or_default()))
}
-/// **Every auto-join row that COMPOSES for this zone, city gate deliberately not applied** —
-/// the set that takes slots (decision 2137).
+/// The names the walk registers on a character with **no slots yet** — every `ChatChannels.dbc`
+/// row whose bit is set in `zone_mask` and that composes here, in table order. The city gate is
+/// deliberately not applied: this is the set that takes *slots* (decision 2137), and
+/// [`wanted_channels`] is the subset the gate then lets the walk join. A test lens over
+/// [`plan_walk`]'s pass 2 — the walk itself plans over the slot array.
///
/// The reference registers the slot (`0x49b980`) *before* it asks whether the row is eligible:
/// `0x49a50d` precedes the city gate at `0x49a512` (wow-re `zone-chat-channel-autojoin.md` §7 pass
/// 2, §11). So a character standing outside a capital still holds a `Trade - City` slot — created,
/// never joined, state 3 — and it still occupies **number 2**, because the `N.` a channel line
/// carries is `slot[+0x00]`, the client-local index, never the `ChatChannels.dbc` ChannelID.
-///
-/// [`wanted_channels`] is the subset the gate then lets us actually join. Filtering the city rows
-/// out *before* they could take a number is what made our `/2` reach LocalDefense where the
-/// reference's reaches Trade.
+#[cfg(test)]
pub(crate) fn tracked_channels(
catalog: &ChatChannelsCatalog,
+ zone_mask: u32,
zone_name: &str,
city_word: Option<&str>,
) -> Vec {
- composed_auto_rows(catalog, zone_name, city_word)
- .map(|(_, name)| name)
+ catalog
+ .rows()
+ .iter()
+ .filter(|r| zone_mask & super::edit::zone_bit(r.id) != 0)
+ .filter_map(|r| compose(r, zone_name, city_word))
.collect()
}
-/// The shared half: the auto-join rows whose name can actually be composed here, in table order.
-/// Both callers derive from this so the resolvability rules cannot drift apart — the city gate is
-/// the *only* thing that separates them.
-fn composed_auto_rows<'a>(
- catalog: &'a ChatChannelsCatalog,
- zone_name: &'a str,
- city_word: Option<&'a str>,
-) -> impl Iterator- + 'a {
+/// [`tracked_channels`] with the city gate applied — the names the walk actually **joins** from
+/// `zone_name`. `in_city` gates the city-**only** rows (the `0x10` bit); `city_word` is what a
+/// city-**named** row (`0x20`) puts in its `%s`.
+#[cfg(test)]
+pub(crate) fn wanted_channels(
+ catalog: &ChatChannelsCatalog,
+ zone_mask: u32,
+ zone_name: &str,
+ in_city: bool,
+ city_word: Option<&str>,
+) -> Vec
{
catalog
- .auto_join_rows()
- .filter(move |r| !r.takes_city_name() || city_word.is_some())
- // A zone-dependent name with no zone to put in it would join a channel called
- // "General - " — join nothing rather than something wrong.
- .filter(move |r| !r.is_zone_dependent() || !zone_name.is_empty())
- .map(move |r| (r, r.joinable_name(zone_name, city_word.unwrap_or_default())))
+ .rows()
+ .iter()
+ .filter(|r| zone_mask & super::edit::zone_bit(r.id) != 0)
+ .filter(|r| in_city || !r.is_city_only())
+ .filter_map(|r| compose(r, zone_name, city_word))
+ .collect()
+}
+
+/// One step of a walk, in **wire order** — the walk is a plan over the slot array, computed pure
+/// and applied in sequence, so that the exact packet-and-state sequence a border produces is a
+/// unit-testable value (decision 2144; the harness this module's own tests used to say was
+/// missing).
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum WalkStep {
+ /// `CMSG_LEAVE_CHANNEL` with the slot's OLD name (`0x49a367`) — only from a server-confirmed
+ /// slot (`0x49a35e`).
+ Leave(String),
+ /// `CMSG_JOIN_CHANNEL` (`0x49a3e1` in pass 1, `0x49a56e` in pass 2).
+ Join(String),
+ /// `0x49bc50` — the slot renamed **in place, at send time**, before the server can answer the
+ /// LEAVE that just went out ([`ChannelState::rename_slot`] carries the whole why).
+ Rename { old: String, new: String },
+ /// `0x49b980` — a slot claimed for a row not yet tracked, **before** the gate (2137).
+ Register(String),
+ /// `0x49bcf0` — state 3: the row stopped applying; the record and its number survive.
+ Suspend(String),
+ /// The state-3 bypass's own write, with the name unchanged: `0x49bc50` writes
+ /// `(old == 3) ? 1 : 2`, so a suspended slot re-joining under its own name goes to **1** —
+ /// a plain `YOU_JOINED` when it confirms, never `YOU_CHANGED`.
+ Confirm(String),
+}
+
+/// **The walk, as a plan** — `ZoneChannelRefresh 0x49a210`'s two passes over the current slot
+/// array and the mask, for a player standing in `zone_name` (wow-re
+/// `zone-chat-channel-autojoin.md` §7, VERIFIED).
+///
+/// **Pass 1 — the slots already held**, in slot order (`0x49a284`), each one whose name resolves
+/// to a `ChatChannels.dbc` row. The row's name is composed for this zone, **whatever the mask
+/// says** — the reference's pass 1 reads no mask bit, and a slot exists only because a join
+/// confirmed it or a walk registered it. Then, per slot:
+///
+/// - **name unchanged, slot suspended** ⇒ the state-3 bypass (`0x49a31c`): eligible here ⇒
+/// `Confirm` + `Join` — walking back into a city re-joins `Trade - City` under a name that never
+/// changed; not eligible ⇒ nothing.
+/// - **name unchanged, otherwise** ⇒ nothing, unless the row stopped being eligible: then
+/// `Leave` (only if server-confirmed) and `Suspend` — walking out of the city; the LEAVE goes
+/// out *before* the gate suspends the slot because it sits earlier in the same iteration (§5).
+/// - **name moved** ⇒ `Leave(old)` if server-confirmed (`0x49a35e`), then the gate: eligible ⇒
+/// `Rename` + `Join(new)`; not eligible ⇒ `Suspend(old)`.
+/// - **the row no longer composes here** (no city word for a city-named row) ⇒ treated as not
+/// eligible: `Leave` if confirmed, `Suspend`. The reference would compose a half-formed name
+/// instead ([`compose`]).
+///
+/// **Pass 2 — rows with no slot** (`0x49a468`), in DBC order, **only those whose mask bit is set**
+/// (`0x49a494`, the live predicate — decision 2144). Each composes, takes a slot (`Register`,
+/// before the gate), and then `Join`s or is `Suspend`ed. This is the one place the mask decides:
+/// a `/leave General` clears bit 0 and frees the slot, and from then on — this session and every
+/// later one, because the mask is the character's file — General is never registered again until
+/// a `/join General` confirms and sets the bit back.
+///
+/// LEAVE strictly precedes JOIN per slot, interleaved across slots — never a batch of leaves then
+/// a batch of joins. The retail 1.8.1 Winterspring sniff shows exactly this shape (§7).
+pub(crate) fn plan_walk(
+ channels: &ChannelState,
+ zone_name: &str,
+ in_city: bool,
+ city_word: Option<&str>,
+) -> Vec {
+ use WalkStep as W;
+ let catalog = &channels.channels;
+ let eligible = |row: &benilla_formats::ChatChannelRow| in_city || !row.is_city_only();
+ let mut steps = Vec::new();
+ let mut tracked_rows: Vec = Vec::new();
+
+ // Pass 1.
+ for slot in channels.joined.iter().flatten() {
+ let Some(row) = catalog.row_for_name(&slot.name) else {
+ continue; // a custom channel: not the walk's
+ };
+ tracked_rows.push(row.id);
+ let old = slot.name.as_str();
+ match compose(row, zone_name, city_word) {
+ Some(new) if new.eq_ignore_ascii_case(old) => {
+ if slot.state == SlotState::Suspended {
+ if eligible(row) {
+ steps.push(W::Confirm(old.to_string()));
+ steps.push(W::Join(old.to_string()));
+ }
+ } else if !eligible(row) {
+ if slot.state == SlotState::Joined {
+ steps.push(W::Leave(old.to_string()));
+ }
+ steps.push(W::Suspend(old.to_string()));
+ }
+ }
+ Some(new) => {
+ if slot.state == SlotState::Joined {
+ steps.push(W::Leave(old.to_string()));
+ }
+ if eligible(row) {
+ steps.push(W::Rename {
+ old: old.to_string(),
+ new: new.clone(),
+ });
+ steps.push(W::Join(new));
+ } else {
+ steps.push(W::Suspend(old.to_string()));
+ }
+ }
+ None => {
+ if slot.state == SlotState::Joined {
+ steps.push(W::Leave(old.to_string()));
+ }
+ if slot.state != SlotState::Suspended {
+ steps.push(W::Suspend(old.to_string()));
+ }
+ }
+ }
+ }
+
+ // Pass 2.
+ for row in catalog.rows() {
+ if !channels.zone_row_wanted(row.id) || tracked_rows.contains(&row.id) {
+ continue;
+ }
+ let Some(name) = compose(row, zone_name, city_word) else {
+ continue;
+ };
+ steps.push(W::Register(name.clone()));
+ if eligible(row) {
+ steps.push(W::Join(name));
+ } else {
+ steps.push(W::Suspend(name));
+ }
+ }
+ steps
+}
+
+/// Apply a plan in order: packets to the wire, slot writes to [`ChannelState`]. Answers whether
+/// the slot *names* moved (a rename or a registration), which is the VM mirror's cue.
+fn apply_walk(steps: &[WalkStep], channels: &mut ChannelState, commands: &NetCommands) -> bool {
+ let mut names_moved = false;
+ for step in steps {
+ match step {
+ WalkStep::Leave(name) => {
+ debug!("chat: leaving zone channel {name:?}");
+ let _ = commands
+ .0
+ .send(ClientCommand::LeaveChannel { name: name.clone() });
+ }
+ WalkStep::Join(name) => {
+ debug!("chat: joining zone channel {name:?}");
+ let _ = commands.0.send(ClientCommand::JoinChannel {
+ name: name.clone(),
+ password: String::new(),
+ });
+ }
+ WalkStep::Rename { old, new } => {
+ if let Some(n) = channels.rename_slot(old, new) {
+ debug!("chat: zone channel slot {n} renamed {old:?} → {new:?}");
+ names_moved = true;
+ }
+ }
+ WalkStep::Register(name) => match channels.claim_slot(name) {
+ Some(n) => {
+ debug!("chat: zone channel {name:?} registered as slot {n}");
+ names_moved = true;
+ }
+ None => warn!(
+ "chat: no free slot for zone channel {name:?} — all {} are taken",
+ super::edit::MAX_CHANNELS
+ ),
+ },
+ WalkStep::Suspend(name) => {
+ if let Some(n) = channels.suspend_slot(name) {
+ debug!("chat: zone channel slot {n} ({name:?}) suspended — not eligible here");
+ }
+ }
+ WalkStep::Confirm(name) => channels.confirm_slot(name),
+ }
+ }
+ names_moved
}
/// Is the zone under the player the one they are actually standing in?
@@ -299,42 +513,16 @@ fn zone_is_settled(player: Option<&crate::player::Player>) -> bool {
/// destination is resident, decision 0737), which makes this the reference's timing rather than an
/// arbitrary debounce.
///
-/// **Selection:** the client's live predicate is the *saved* `ZONECHANNELS` mask, not `flags & 1`
-/// read fresh — but that mask is **seeded from `flags & 1` when there is no usable chat-cache
-/// file** (`0x4997fc`, gated `0x4997e8`). We now persist that file and seat the mask from it
-/// ([`super::edit::ChannelState::zone_mask`], decision 2120), but the walk still reads the DBC bit
-/// directly: making the mask the join predicate means a character whose mask is 0 joins nothing at
-/// all, which 2120 deliberately left for its own change once the repair had swept through. Named
-/// here rather than left implicit — it is the one place this file diverges from the reference on
-/// purpose.
-/// Every `ChatChannels.dbc` row as the VM needs it (decision 1908; wow-re chat-cache-grammar.md
-/// §5-6): the id, the Shortcut the verbs compare a typed name against, the name composed for
-/// `zone_name` — `None` when the composition has nothing to substitute (a zone-dependent row with
-/// no zone, a city row with no city word), which is the verbs' nil leg — and whether
-/// `EnumerateServerChannels` lists it here (a city-only row, `flags & 0x10`, only in a city).
-pub(crate) fn zone_channel_catalog(
- catalog: &ChatChannelsCatalog,
- zone_name: &str,
- in_city: bool,
- city_word: Option<&str>,
-) -> Vec {
- catalog
- .rows()
- .iter()
- .map(|r| {
- let resolvable = (!r.is_zone_dependent() || !zone_name.is_empty())
- && (!r.takes_city_name() || city_word.is_some());
- benilla_ui::script::ZoneChannelRow {
- id: r.id,
- shortcut: r.shortcut.clone(),
- resolved: resolvable
- .then(|| r.joinable_name(zone_name, city_word.unwrap_or_default())),
- listed: !r.is_city_only() || in_city,
- }
- })
- .collect()
-}
-
+/// **…and NOT before the chat cache has seated the mask** ([`ChannelState::zone_mask`] is `None`
+/// until then). The reference's walk bails on its "chat system ready" flag (`0x49a219`), which the
+/// cache loader sets as its last act before calling the walk itself (`0x499a18`/`0x499a22`). The
+/// mask is the walk's row set (decision 2144), so a walk before the seat would join nothing and
+/// nothing would call it again.
+///
+/// **Selection is the `ZONECHANNELS` mask** — the reference's live predicate `0x49a494`, never
+/// `flags & INITIAL` read fresh. The DBC bit only seeds the mask on a character with no file
+/// (`0x4997fc`). This is what makes `/leave General` stick: the leave clears the bit in the
+/// character's file, and the next login's walk never registers the row ([`plan_walk`]).
#[allow(clippy::too_many_arguments)] // a Bevy system's param list IS its dependency set
pub(super) fn auto_join_zone_channels(
commands: Res,
@@ -378,17 +566,17 @@ pub(super) fn auto_join_zone_channels(
// is not the one they are standing in: no character session yet ([`ZoneChannelWalk::live`]), a
// cinematic flying the streaming focus off the body, a world still arriving ([`zone_is_settled`]),
// or an area authority with no answer for this session (decision 2130 made that a real state
- // again rather than the previous character's zone).
- let zone = (walk.live && !cinematic_running && zone_is_settled(player.as_deref()))
+ // again rather than the previous character's zone). Published for the cascade beside it.
+ let zone_id = (walk.live && !cinematic_running && zone_is_settled(player.as_deref()))
.then(|| {
let areas = areas?;
- let row = world
- .area()
- .and_then(|leaf| areas.0.top_zone(leaf))
- .and_then(|zone| areas.0.get(zone))?;
- Some((row.name.clone(), row.flags & AREA_FLAG_TRADE_CHANNEL != 0))
+ world.area().and_then(|leaf| areas.0.top_zone(leaf))
})
.flatten();
+ walk.zone_id = zone_id;
+ let zone = zone_id
+ .and_then(|id| areas?.0.get(id))
+ .map(|row| (row.name.clone(), row.flags & AREA_FLAG_TRADE_CHANNEL != 0));
// **The VM's zone-channel catalog is a `ChatChannels.dbc` fact, and it is fed BEFORE any of
// those gates** — because the thing the verbs read out of it is the row **Shortcut**, which no
@@ -426,170 +614,55 @@ pub(super) fn auto_join_zone_channels(
}
}
- let (Some(at), Some(areas)) = (zone, areas) else {
+ let (Some(zone_id), Some((zone_name, in_city)), Some(areas)) = (zone_id, zone, areas) else {
return; // nothing to walk against yet
};
- if walk.at.as_ref() == Some(&at) {
+ if channels.zone_mask.is_none() {
+ return; // the chat cache has not seated the mask — the reference's ready-flag bail
+ }
+ if walk.at == Some(zone_id) {
return; // same zone as last frame — the common case, and free
}
- let wanted = wanted_channels(&channels.channels, &at.0, at.1, city_word(&areas.0));
- // Every row that composes here — the set that takes SLOTS, city gate not applied
- // ([`tracked_channels`], decision 2137). `wanted` is the subset we may actually join.
- let tracked = tracked_channels(&channels.channels, &at.0, city_word(&areas.0));
-
- // The diff is **per DBC row**, not per name — because a zone change does not add and remove
- // channels, it *renames* one: `General - Felwood` and `General - Winterspring` are the same
- // row (ChannelID 1) with a different `%s`. Keying on the row is what makes that a leave/join
- // pair instead of an unrelated drop and add.
- let row_of = |name: &str| channels.channels.zone_channel_id(name);
- let leave = |name: &str| {
- debug!("chat: leaving zone channel {name:?}");
- let _ = commands.0.send(ClientCommand::LeaveChannel {
- name: name.to_string(),
- });
- };
- let join = |name: &str| {
- debug!("chat: joining zone channel {name:?}");
- let _ = commands.0.send(ClientCommand::JoinChannel {
- name: name.to_string(),
- password: String::new(),
- });
- };
-
- // LEAVE(old) immediately followed by JOIN(new), per row, interleaved — the client does both in
- // one iteration of its own walk (`0x49a367` sits earlier in the loop body than the join), and a
- // retail sniff shows the pairs adjacent on the wire in exactly that order: LEAVE
- // `General - Felwood` → JOIN `General - Winterspring` → LEAVE `LocalDefense - Felwood` → …
- // (wow-re `zone-chat-channel-autojoin.md` §5).
- //
- // The renames are collected rather than applied inside the loop only because `row_of` borrows
- // the catalog that lives beside the slots; the order on the wire is unchanged.
- //
- // **The walk is over `tracked`, not `wanted`** (decision 2137): every composable row gets a
- // slot, and the city gate then decides join-versus-suspend. Registering first is what gives
- // `Trade - City` number 2 on a character who is nowhere near a city.
- let eligible = |name: &String| wanted.iter().any(|w| w == name);
- let mut renamed: Vec<(String, String)> = Vec::new();
- let mut registered: Vec = Vec::new();
- let mut suspended: Vec = Vec::new();
- let mut bypassed: Vec = Vec::new();
- for name in &tracked {
- match walk.held.iter().find(|h| row_of(h) == row_of(name)) {
- // This row's name did not move. Normally nothing to do — **except** that a
- // locally-suspended slot bypasses the name comparison entirely (`0x49a31c`, §7 pass 1
- // step 2), which is exactly how walking back into a city re-joins `Trade - City` under
- // a name that never changed.
- Some(old) if old == name => {
- if eligible(name) {
- if channels.slot_state(name) == Some(SlotState::Suspended) {
- join(name);
- // **And the slot leaves state 3 at SEND time, not when the notice lands.**
- // The reference's rename runs on the bypass leg too, with the name
- // unchanged, and writes `(old == 3) ? 1 : 2` — so a bypassed slot goes to
- // **1**, not 2: a plain `YOU_JOINED` when it confirms, never
- // `YOU_CHANGED`. Our `Joined` is that state 1 (see [`SlotState`]).
- //
- // Leaving it at 3 until the notice arrived would be wrong twice: a second
- // zone change inside that window would take this arm again and send a
- // duplicate join, and walking straight back out would find a slot that is
- // not `Joined` and so skip the LEAVE for a channel we really had joined.
- bypassed.push(name.clone());
- }
- } else if channels.slot_state(name) == Some(SlotState::Joined) {
- // Walking OUT of the city: the LEAVE goes out here, before the eligibility
- // test suspends the slot — "the LEAVE still goes out, because it happens
- // earlier in the same iteration" (§5).
- leave(name);
- suspended.push(name.clone());
- }
- continue;
- }
- // The name moved. LEAVE(old) only from a server-confirmed slot (`0x49a35e`), then the
- // rename, then the gate.
- Some(old) => {
- if channels.slot_state(old) == Some(SlotState::Joined) {
- leave(old);
- }
- renamed.push((old.clone(), name.clone()));
- }
- // Never tracked: the slot is claimed BEFORE the gate.
- None => registered.push(name.clone()),
- }
- if eligible(name) {
- join(name);
- } else {
- suspended.push(name.clone());
- }
- }
- // Rows that stopped COMPOSING entirely — a zone with no name at all, which drops the row from
- // `tracked` rather than merely making it ineligible. The slot is kept and suspended, never
- // freed, for the same reason as everything above.
- for old in walk
- .held
- .iter()
- .filter(|h| !tracked.iter().any(|t| row_of(t) == row_of(h)))
- {
- if channels.slot_state(old) == Some(SlotState::Joined) {
- leave(old);
- }
- suspended.push(old.clone());
- }
-
- // **The slot is renamed in place, at send time** — [`ChannelState::rename_slot`] carries the
- // whole why. In one line: the server's `YOU_LEFT` for the old name must find no slot, or the
- // stock `ChatFrame_OnEvent` deletes the window's registration for that channel and never
- // rebuilds it.
- for (old, new) in &renamed {
- if let Some(n) = channels.rename_slot(old, new) {
- debug!("chat: zone channel slot {n} renamed {old:?} → {new:?}");
- }
- }
- // **A row we have never tracked takes its slot here — before the gate decided anything**
- // (`0x49b980` at `0x49a50d`, ahead of the city gate at `0x49a512`). In DBC row order, which is
- // what makes the numbering `1 General`, `2 Trade`, `3 LocalDefense` whether or not Trade is
- // joinable from where the player is standing.
- // The state-3 bypass's own write, before the suspends below so the two can never fight over
- // one slot in a single pass.
- for name in &bypassed {
- channels.confirm_slot(name);
- }
- for name in ®istered {
- match channels.claim_slot(name) {
- Some(n) => debug!("chat: zone channel {name:?} registered as slot {n}"),
- None => warn!(
- "chat: no free slot for zone channel {name:?} — all {} are taken",
- super::edit::MAX_CHANNELS
- ),
- }
- }
- // **…and a row that stopped applying is SUSPENDED, not freed** (`0x49bcf0`, state 3). The slot
- // and its number survive, so the `YOU_LEFT` that comes back carries the `SUSPENDED` token and
- // the window keeps its registration — otherwise walking out of Stormwind kills Trade for the
- // session exactly the way a border crossing used to kill General.
- for old in &suspended {
- if let Some(n) = channels.suspend_slot(old) {
- debug!("chat: zone channel slot {n} ({old:?}) suspended — out of its city");
- }
- }
- if !(renamed.is_empty() && registered.is_empty()) {
+ let steps = plan_walk(&channels, &zone_name, in_city, city_word(&areas.0));
+ if apply_walk(&steps, &mut channels, &commands) {
if let Some(script) = script.as_mut() {
// `GetChannelName(n)` answers the new name from this frame on, as it does in the
// reference — the slot moved, not the numbering.
script.set_joined_channels(channels.names());
}
}
+ walk.at = Some(zone_id);
+}
- // `held` tracks every COMPOSED row, not only the joined ones — that is what makes the
- // "name unchanged, eligibility changed" arms above reachable at all.
- walk.held = tracked;
- walk.at = Some(at);
+/// Every `ChatChannels.dbc` row as the VM needs it (decision 1908; wow-re chat-cache-grammar.md
+/// §5-6): the id, the Shortcut the verbs compare a typed name against, the name composed for
+/// `zone_name` — `None` when the composition has nothing to substitute (a zone-dependent row with
+/// no zone, a city row with no city word), which is the verbs' nil leg — and whether
+/// `EnumerateServerChannels` lists it here (a city-only row, `flags & 0x10`, only in a city).
+pub(crate) fn zone_channel_catalog(
+ catalog: &ChatChannelsCatalog,
+ zone_name: &str,
+ in_city: bool,
+ city_word: Option<&str>,
+) -> Vec {
+ catalog
+ .rows()
+ .iter()
+ .map(|r| benilla_ui::script::ZoneChannelRow {
+ id: r.id,
+ shortcut: r.shortcut.clone(),
+ resolved: compose(r, zone_name, city_word),
+ listed: !r.is_city_only() || in_city,
+ })
+ .collect()
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_formats::ChatChannelRow;
+ use WalkStep as W;
/// The shipped table, hand-built so this runs without an install (the real rows are asserted
/// against the DBC in `benilla_formats::chat_channels`).
@@ -618,12 +691,41 @@ mod tests {
/// sentinel — see [`city_word`]).
const CITY: Option<&str> = Some("City");
+ /// A fresh character's mask: the DBC's three `INITIAL` rows, `0x200003` (§3).
+ const SEED: u32 = 0x0020_0003;
+ /// `1 << (25 - 1)` — `GuildRecruitment`'s bit, which the cascade's confirmed join adds.
+ const GUILD_RECRUITMENT_BIT: u32 = 1 << 24;
+
+ /// A [`ChannelState`] over the shipped table with the mask seated at `mask`.
+ fn state(mask: u32) -> ChannelState {
+ ChannelState {
+ channels: catalog(),
+ zone_mask: Some(mask),
+ ..Default::default()
+ }
+ }
+
+ fn joined(name: &str) -> Option {
+ Some(crate::ui_chat::edit::ChannelSlot::joined(name))
+ }
+
+ fn suspended(name: &str) -> Option {
+ Some(crate::ui_chat::edit::ChannelSlot {
+ name: name.into(),
+ state: SlotState::Suspended,
+ })
+ }
+
+ fn s(v: &str) -> String {
+ v.to_string()
+ }
+
/// Out in the world: General and LocalDefense, both zone-named. Trade is a city channel and
- /// stays out; WorldDefense, LookingForGroup and GuildRecruitment carry no INITIAL bit.
+ /// stays out; WorldDefense, LookingForGroup and GuildRecruitment have no bit in a fresh mask.
#[test]
fn an_ordinary_zone_joins_general_and_local_defense() {
assert_eq!(
- wanted_channels(&catalog(), "Elwynn Forest", false, CITY),
+ wanted_channels(&catalog(), SEED, "Elwynn Forest", false, CITY),
vec!["General - Elwynn Forest", "LocalDefense - Elwynn Forest"]
);
}
@@ -632,7 +734,7 @@ mod tests {
#[test]
fn a_capital_adds_the_shared_trade_channel() {
assert_eq!(
- wanted_channels(&catalog(), "Stormwind City", true, CITY),
+ wanted_channels(&catalog(), SEED, "Stormwind City", true, CITY),
vec![
"General - Stormwind City",
"Trade - City",
@@ -645,8 +747,8 @@ mod tests {
/// joining it would be a bug nothing else would ever report.
#[test]
fn an_unknown_zone_joins_nothing_zone_dependent() {
- assert!(wanted_channels(&catalog(), "", false, CITY).is_empty());
- assert!(wanted_channels(&catalog(), "", true, CITY).is_empty());
+ assert!(wanted_channels(&catalog(), SEED, "", false, CITY).is_empty());
+ assert!(wanted_channels(&catalog(), SEED, "", true, CITY).is_empty());
}
/// **The walk does not believe the zone until the world has settled** (1280).
@@ -677,39 +779,52 @@ mod tests {
assert!(zone_is_settled(Some(&p)), "settled: the zone is now final");
}
- /// **Leaving the world ends the channel session — all four halves of it** (1284).
+ /// **Leaving the world ends the channel session — every half of it but the mask** (1284,
+ /// 2120).
///
/// The director's character switch: the walk still held the previous character's
/// `General - Tanaris`, so its first diff on the new character sent a LEAVE the new session
/// answered "Not on channel 1. General - Tanaris.", and the stale confirmed list pushed the
/// real joins to slots 3 and 4. Server-side there is nothing to keep — `Player::CleanupChannels`
- /// runs in `~Player` — so every one of these must be empty at the next world entry.
+ /// runs in `~Player` — so every one of these must be empty at the next world entry. The mask
+ /// is the one survivor: it is the character's file, not the session's.
#[test]
fn leaving_the_world_ends_the_channel_session() {
- let mut channels = ChannelState::default();
- // A live session: two zone channels asked for, both confirmed.
+ let mut channels = state(SEED);
+ channels.joined = vec![
+ joined("General - Tanaris"),
+ joined("LocalDefense - Tanaris"),
+ ];
let mut walk = ZoneChannelWalk {
- held: vec!["General - Tanaris".into(), "LocalDefense - Tanaris".into()],
- at: Some(("Tanaris".into(), false)),
- ..Default::default()
+ at: Some(440),
+ zone_id: Some(440),
+ live: true,
};
- channels.joined = walk
- .held
- .iter()
- .map(|n| Some(crate::ui_chat::edit::ChannelSlot::joined(n)))
- .collect();
+ let mut cascade = GuildRecruitmentCascade::default();
+ cascade.observe_guild_id(7);
// No VM in a unit test; the mirror leg is the one line this cannot reach, and
- // `end_session_channels` is a two-line wrapper over exactly this call.
- end_channel_session(None, &mut channels, &mut walk);
+ // `end_session_channels` is a thin wrapper over exactly this call.
+ end_channel_session(None, &mut channels, &mut walk, &mut cascade);
- assert!(walk.held.is_empty(), "nothing is held");
- assert_eq!(walk.at, None, "and the next entry re-walks from scratch");
+ assert_eq!(walk.at, None, "the next entry re-walks from scratch");
+ assert_eq!(walk.zone_id, None);
+ assert!(!walk.live);
assert!(
channels.joined.is_empty(),
"the confirmed list is the server's, and the server just destroyed it — a survivor \
here is what renumbers the next character's channels"
);
+ assert_eq!(
+ channels.zone_mask,
+ Some(SEED),
+ "the mask is the character's file, not the session's (2120)"
+ );
+ assert!(
+ cascade.observe_guild_id(7),
+ "the cascade's watcher forgot the guild id, so the next login's first sight of it is \
+ a change again — the reference's player-create trigger"
+ );
}
/// No city word (a locale whose sentinel row ships blank) ⇒ the city-named rows are skipped,
@@ -717,7 +832,7 @@ mod tests {
#[test]
fn a_missing_city_word_skips_the_trade_channel_rather_than_half_naming_it() {
assert_eq!(
- wanted_channels(&catalog(), "Stormwind City", true, None),
+ wanted_channels(&catalog(), SEED, "Stormwind City", true, None),
vec!["General - Stormwind City", "LocalDefense - Stormwind City"]
);
}
@@ -728,8 +843,8 @@ mod tests {
#[test]
fn crossing_a_zone_border_renames_the_same_rows() {
let cat = catalog();
- let felwood = wanted_channels(&cat, "Felwood", false, CITY);
- let winterspring = wanted_channels(&cat, "Winterspring", false, CITY);
+ let felwood = wanted_channels(&cat, SEED, "Felwood", false, CITY);
+ let winterspring = wanted_channels(&cat, SEED, "Winterspring", false, CITY);
assert_ne!(felwood, winterspring, "the names differ");
let ids = |v: &[String]| -> Vec { v.iter().map(|n| cat.zone_channel_id(n)).collect() };
assert_eq!(
@@ -740,30 +855,146 @@ mod tests {
assert_eq!(ids(&felwood), vec![1, 22]);
}
- /// **The slot number never moves across the whole city round trip** (decision 2137).
- ///
- /// The exact state sequence the walk drives for `Trade - City`: registered and joined on the
- /// way in, suspended on the way out (record kept, `0x49bcf0`), and put back to *joined at send
- /// time* on the way back in — the state-3 bypass, where the reference's rename writes
- /// `(old == 3) ? 1 : 2` and so lands on **1**, not 2. Our `Joined` is that state 1, which is
- /// why the re-join confirms as a plain `YOU_JOINED` rather than `Changed Channel`.
- ///
- /// Leaving the slot at `Suspended` until the notice arrived would break two things: a second
- /// zone change inside that window would send a duplicate join, and walking straight back out
- /// would skip the LEAVE for a channel we had really joined.
- ///
- /// **What this does NOT cover:** that the walk actually calls `confirm_slot` on the bypass leg.
- /// The state machine is asserted here; the wiring is not, because the walk is a Bevy system
- /// with no harness yet. That harness is the obvious next instrument for this module.
+ /// **A fresh character's first walk, out in the world** — pass 2 over the mask, in DBC order:
+ /// every composable mask row takes a slot *before* the gate decides join-versus-suspend
+ /// (decision 2137), which is what gives `Trade - City` number 2 on a character nowhere near a
+ /// city. `/2` therefore reaches Trade here, as it does in the reference.
+ #[test]
+ fn a_first_walk_registers_every_mask_row_before_the_gate() {
+ let mut c = state(SEED);
+ let steps = plan_walk(&c, "Elwynn Forest", false, CITY);
+ assert_eq!(
+ steps,
+ vec![
+ W::Register(s("General - Elwynn Forest")),
+ W::Join(s("General - Elwynn Forest")),
+ W::Register(s("Trade - City")),
+ W::Suspend(s("Trade - City")),
+ W::Register(s("LocalDefense - Elwynn Forest")),
+ W::Join(s("LocalDefense - Elwynn Forest")),
+ ]
+ );
+ // Applied, the numbering is 1 General, 2 Trade, 3 LocalDefense.
+ apply_steps(&steps, &mut c);
+ assert_eq!(c.number_of("General - Elwynn Forest"), Some(1));
+ assert_eq!(c.number_of("Trade - City"), Some(2));
+ assert_eq!(c.slot_state("Trade - City"), Some(SlotState::Suspended));
+ assert_eq!(c.number_of("LocalDefense - Elwynn Forest"), Some(3));
+
+ // Inside a capital every row joins, and the numbering agrees — the case that hid 2137.
+ let steps = plan_walk(&state(SEED), "Stormwind City", true, CITY);
+ assert_eq!(
+ steps,
+ vec![
+ W::Register(s("General - Stormwind City")),
+ W::Join(s("General - Stormwind City")),
+ W::Register(s("Trade - City")),
+ W::Join(s("Trade - City")),
+ W::Register(s("LocalDefense - Stormwind City")),
+ W::Join(s("LocalDefense - Stormwind City")),
+ ]
+ );
+ }
+
+ /// Apply a plan the way the system does, minus the wire — the slot writes only.
+ fn apply_steps(steps: &[WalkStep], c: &mut ChannelState) {
+ for step in steps {
+ match step {
+ W::Leave(_) | W::Join(_) => {}
+ W::Rename { old, new } => {
+ c.rename_slot(old, new);
+ }
+ W::Register(name) => {
+ c.claim_slot(name);
+ }
+ W::Suspend(name) => {
+ c.suspend_slot(name);
+ }
+ W::Confirm(name) => c.confirm_slot(name),
+ }
+ }
+ }
+
+ /// **A border crossing is LEAVE(old) → rename → JOIN(new), per slot, interleaved, in slot
+ /// order** — pass 1 over the slot array (§7; the retail 1.8.1 Winterspring sniff is the
+ /// control). The suspended Trade slot in the middle is walked too and does nothing: its
+ /// name never moves and it is not eligible here.
+ #[test]
+ fn a_border_crossing_is_leave_rename_join_per_slot_in_slot_order() {
+ let mut c = state(SEED);
+ c.joined = vec![
+ joined("General - Felwood"),
+ suspended("Trade - City"),
+ joined("LocalDefense - Felwood"),
+ ];
+ let steps = plan_walk(&c, "Winterspring", false, CITY);
+ assert_eq!(
+ steps,
+ vec![
+ W::Leave(s("General - Felwood")),
+ W::Rename {
+ old: s("General - Felwood"),
+ new: s("General - Winterspring")
+ },
+ W::Join(s("General - Winterspring")),
+ W::Leave(s("LocalDefense - Felwood")),
+ W::Rename {
+ old: s("LocalDefense - Felwood"),
+ new: s("LocalDefense - Winterspring")
+ },
+ W::Join(s("LocalDefense - Winterspring")),
+ ]
+ );
+ apply_steps(&steps, &mut c);
+ assert_eq!(
+ c.number_of("General - Winterspring"),
+ Some(1),
+ "renamed in place"
+ );
+ assert_eq!(
+ c.slot_state("General - Winterspring"),
+ Some(SlotState::Renamed)
+ );
+ assert_eq!(c.number_of("Trade - City"), Some(2), "untouched");
+ assert_eq!(c.number_of("LocalDefense - Winterspring"), Some(3));
+ }
+
+ /// **The slot number never moves across the whole city round trip** (decision 2137), and the
+ /// walk drives exactly the reference's state sequence for `Trade - City`: joined on the way
+ /// in, LEAVE-then-suspend on the way out (`0x49bcf0`, record kept), and back to *joined at
+ /// send time* on the way back in — the state-3 bypass, where the reference's rename writes
+ /// `(old == 3) ? 1 : 2` and so lands on **1**: a plain `YOU_JOINED`, never `Changed Channel`.
#[test]
fn a_city_round_trip_keeps_the_slot_and_its_number() {
- let mut c = ChannelState::default();
- assert_eq!(c.claim_slot("General - Elwynn Forest"), Some(1));
- assert_eq!(c.claim_slot("Trade - City"), Some(2));
- assert_eq!(c.claim_slot("LocalDefense - Elwynn Forest"), Some(3));
+ let mut c = state(SEED);
+ c.joined = vec![
+ joined("General - Stormwind City"),
+ joined("Trade - City"),
+ joined("LocalDefense - Stormwind City"),
+ ];
- // Out of the city: the record and the number survive.
- assert_eq!(c.suspend_slot("Trade - City"), Some(2));
+ // Out of the city.
+ let steps = plan_walk(&c, "Elwynn Forest", false, CITY);
+ assert_eq!(
+ steps,
+ vec![
+ W::Leave(s("General - Stormwind City")),
+ W::Rename {
+ old: s("General - Stormwind City"),
+ new: s("General - Elwynn Forest")
+ },
+ W::Join(s("General - Elwynn Forest")),
+ W::Leave(s("Trade - City")),
+ W::Suspend(s("Trade - City")),
+ W::Leave(s("LocalDefense - Stormwind City")),
+ W::Rename {
+ old: s("LocalDefense - Stormwind City"),
+ new: s("LocalDefense - Elwynn Forest")
+ },
+ W::Join(s("LocalDefense - Elwynn Forest")),
+ ]
+ );
+ apply_steps(&steps, &mut c);
assert_eq!(c.slot_state("Trade - City"), Some(SlotState::Suspended));
assert_eq!(c.number_of("Trade - City"), Some(2));
assert_eq!(
@@ -771,47 +1002,255 @@ mod tests {
Some(3),
"and nothing above it renumbered — the slot went quiet, it did not go away"
);
+ // The notices confirm the two renamed rows.
+ c.confirm_slot("General - Elwynn Forest");
+ c.confirm_slot("LocalDefense - Elwynn Forest");
- // Back in: the bypass sends the join AND clears the suspension in the same pass.
- c.confirm_slot("Trade - City");
+ // Back in: the bypass sends the join AND clears the suspension in the same pass, with no
+ // LEAVE — the player is not on the channel.
+ let steps = plan_walk(&c, "Stormwind City", true, CITY);
+ assert_eq!(
+ steps,
+ vec![
+ W::Leave(s("General - Elwynn Forest")),
+ W::Rename {
+ old: s("General - Elwynn Forest"),
+ new: s("General - Stormwind City")
+ },
+ W::Join(s("General - Stormwind City")),
+ W::Confirm(s("Trade - City")),
+ W::Join(s("Trade - City")),
+ W::Leave(s("LocalDefense - Elwynn Forest")),
+ W::Rename {
+ old: s("LocalDefense - Elwynn Forest"),
+ new: s("LocalDefense - Stormwind City")
+ },
+ W::Join(s("LocalDefense - Stormwind City")),
+ ]
+ );
+ apply_steps(&steps, &mut c);
assert_eq!(c.slot_state("Trade - City"), Some(SlotState::Joined));
assert_eq!(c.number_of("Trade - City"), Some(2), "still slot 2");
}
- /// **A city-only row still takes a slot when you are nowhere near a city** (decision 2137).
+ /// **`/leave General` stays left — this session and the next** (decision 2144, the director's
+ /// report: *"/leave General does not survive a relog"*).
///
- /// The reference registers the slot (`0x49b980` at `0x49a50d`) *before* the city gate
- /// (`0x49a512`), so a character standing in Elwynn Forest holds three slots — General, a
- /// created-but-never-joined `Trade - City`, and LocalDefense — and the `N.` a channel line
- /// carries is `slot[+0x00]`, the client-local index. `/2` therefore reaches **Trade** there.
- ///
- /// We filtered the city rows out before they could take a number, so ours gave LocalDefense 2
- /// and `/2` reached a different channel than the reference's. Invisible inside a capital, where
- /// all three join and the numbering agrees — which is why the director's screenshot, taken on a
- /// login into Stormwind, does not show it.
+ /// The explicit leave clears the row's bit (`0x49f10a`) and the server's `YOU_LEFT` frees the
+ /// slot (`0x49bbd0`). From then on pass 1 has no General slot to walk and pass 2 skips the row
+ /// on its bit (`0x49a494`) — on the next border here, and on the next login, because the mask
+ /// is what the character's file carries. Before 2144 the walk read `flags & INITIAL` fresh
+ /// and re-joined General on the very next border.
#[test]
- fn a_city_row_takes_its_number_even_outside_a_city() {
- let cat = catalog();
+ fn a_left_channel_stays_left_this_session_and_the_next() {
+ let mut c = state(SEED);
+ c.joined = vec![
+ joined("General - Elwynn Forest"),
+ suspended("Trade - City"),
+ joined("LocalDefense - Elwynn Forest"),
+ ];
+
+ // `/leave General`: the stock `SlashCmdList["LEAVE"]` hands `LeaveChannelByName` the bare
+ // token and the VM composes it for the zone; `/leave 1` names the slot here.
+ let target = c.leave_target("1").expect("slot 1 is confirmed");
+ assert_eq!(target, "General - Elwynn Forest", "the slot's wire name");
+ c.note_zone_channel_left(&target);
+ assert_eq!(c.zone_mask, Some(SEED & !1), "bit 0 cleared");
+ // …and the server's YOU_LEFT frees the slot, in place.
+ assert_eq!(c.free_slot(&target), Some(1));
+
+ // The next border: General is neither walked (no slot) nor registered (no bit).
+ let steps = plan_walk(&c, "Westfall", false, CITY);
assert_eq!(
- tracked_channels(&cat, "Elwynn Forest", CITY),
+ steps,
vec![
- "General - Elwynn Forest",
- "Trade - City",
- "LocalDefense - Elwynn Forest",
- ],
- "all three rows take slots in DBC order, city or no city"
- );
- assert_eq!(
- wanted_channels(&cat, "Elwynn Forest", false, CITY),
- vec!["General - Elwynn Forest", "LocalDefense - Elwynn Forest"],
- "…but only two of them are JOINED out here — the gate decides the packet, not the slot"
+ W::Leave(s("LocalDefense - Elwynn Forest")),
+ W::Rename {
+ old: s("LocalDefense - Elwynn Forest"),
+ new: s("LocalDefense - Westfall")
+ },
+ W::Join(s("LocalDefense - Westfall")),
+ ]
);
- // Inside a capital the two lists agree, which is the case that hid this.
+ // The next login: a fresh slot array under the file's mask.
+ let relog = state(SEED & !1);
+ let steps = plan_walk(&relog, "Elwynn Forest", false, CITY);
assert_eq!(
- tracked_channels(&cat, "Stormwind City", CITY),
- wanted_channels(&cat, "Stormwind City", true, CITY)
+ steps,
+ vec![
+ W::Register(s("Trade - City")),
+ W::Suspend(s("Trade - City")),
+ W::Register(s("LocalDefense - Elwynn Forest")),
+ W::Join(s("LocalDefense - Elwynn Forest")),
+ ],
+ "General is gone for good; Trade is now number 1"
);
+
+ // …until a `/join General` confirms and sets the bit back.
+ let mut relog = relog;
+ apply_steps(&steps, &mut relog);
+ relog.note_zone_channel_joined("General - Elwynn Forest");
+ relog.claim_slot("General - Elwynn Forest");
+ assert_eq!(relog.zone_mask, Some(SEED));
+ let steps = plan_walk(&relog, "Westfall", false, CITY);
+ assert!(
+ steps.contains(&W::Join(s("General - Westfall"))),
+ "walked again from its slot: {steps:?}"
+ );
+ }
+
+ /// A mask of zero is a legitimate reference state — a player who left every zone channel —
+ /// and it walks nothing. An unseated mask walks nothing either, for the opposite reason: the
+ /// file has not been read yet, and the system-level gate keeps `at` unset so the walk runs
+ /// once it has.
+ #[test]
+ fn a_zero_or_unseated_mask_registers_nothing() {
+ assert!(plan_walk(&state(0), "Elwynn Forest", false, CITY).is_empty());
+ let unseated = ChannelState {
+ channels: catalog(),
+ zone_mask: None,
+ ..Default::default()
+ };
+ assert!(plan_walk(&unseated, "Elwynn Forest", false, CITY).is_empty());
+ assert!(!unseated.zone_row_wanted(1));
+ }
+
+ /// **`GuildRecruitment` rides the walk like Trade once its bit is set** — which is the whole
+ /// of how the reference keeps an unguilded player in `GuildRecruitment - City` after the
+ /// cascade's first join (decision 2144): the confirmed join ORs bit 24, and from then on the
+ /// walk registers, suspends and re-joins it through the same city gate (`flags & 0x10`) and
+ /// the same city name (`flags & 0x20`). The DBC row order puts it last.
+ #[test]
+ fn the_guild_recruitment_bit_rides_the_walk_like_trade() {
+ let steps = plan_walk(
+ &state(SEED | GUILD_RECRUITMENT_BIT),
+ "Elwynn Forest",
+ false,
+ CITY,
+ );
+ assert_eq!(
+ &steps[6..],
+ &[
+ W::Register(s("GuildRecruitment - City")),
+ W::Suspend(s("GuildRecruitment - City")),
+ ],
+ "registered and suspended out here, number 4"
+ );
+ let steps = plan_walk(
+ &state(SEED | GUILD_RECRUITMENT_BIT),
+ "Ironforge",
+ true,
+ CITY,
+ );
+ assert_eq!(
+ &steps[6..],
+ &[
+ W::Register(s("GuildRecruitment - City")),
+ W::Join(s("GuildRecruitment - City")),
+ ]
+ );
+ }
+
+ /// **A channel joined outside the walk is walked from its slot** — a `/join General` after a
+ /// `/leave`, or the cascade's own `GuildRecruitment - City` join. The reference's pass 1 is
+ /// the slot array, so it needs no separate record of what the walk asked for; ours used to
+ /// keep one (`held`), and a slot it did not know about was registered and joined a second
+ /// time on the next border.
+ #[test]
+ fn a_channel_joined_outside_the_walk_is_walked_from_its_slot() {
+ let mut c = state(SEED | GUILD_RECRUITMENT_BIT);
+ c.joined = vec![
+ joined("General - Stormwind City"),
+ joined("Trade - City"),
+ joined("LocalDefense - Stormwind City"),
+ ];
+ // The cascade's join: a slot, then the notice sets the bit.
+ c.claim_slot("GuildRecruitment - City");
+ let steps = plan_walk(&c, "Stormwind City", true, CITY);
+ assert!(
+ steps.is_empty(),
+ "same zone, every name unchanged, every row eligible — nothing to do: {steps:?}"
+ );
+ // Walking out suspends it exactly like Trade.
+ let steps = plan_walk(&c, "Elwynn Forest", false, CITY);
+ assert!(steps.contains(&W::Leave(s("GuildRecruitment - City"))));
+ assert!(steps.contains(&W::Suspend(s("GuildRecruitment - City"))));
+ assert!(!steps.iter().any(|st| matches!(st, W::Register(_))));
+ }
+
+ /// A suspended slot whose name moved re-joins **without a LEAVE** (state ≠ 0 sends none) and
+ /// through the rename's `(old == 3) ? 1 : 2` — landing on 1, a plain `YOU_JOINED`.
+ #[test]
+ fn a_suspended_slot_whose_name_moved_rejoins_without_a_leave() {
+ let mut c = state(SEED);
+ c.joined = vec![suspended("General - Elwynn Forest")];
+ let steps = plan_walk(&c, "Westfall", false, CITY);
+ assert_eq!(
+ &steps[..2],
+ &[
+ W::Rename {
+ old: s("General - Elwynn Forest"),
+ new: s("General - Westfall")
+ },
+ W::Join(s("General - Westfall")),
+ ]
+ );
+ apply_steps(&steps, &mut c);
+ assert_eq!(
+ c.slot_state("General - Westfall"),
+ Some(SlotState::Joined),
+ "3 → 1, not 2"
+ );
+ }
+
+ /// The numeric leg of leave-by-name ([`ChannelState::leave_target`], contract §3): a non-zero
+ /// leading integer names a **confirmed** slot or makes the call a no-op; anything else is
+ /// already the wire name, composed by the VM or passed through.
+ #[test]
+ fn leave_target_resolves_a_number_to_a_confirmed_slot_or_to_nothing() {
+ let mut c = state(SEED);
+ c.joined = vec![
+ joined("General - Elwynn Forest"),
+ suspended("Trade - City"),
+ None,
+ joined("MyChan"),
+ ];
+ assert_eq!(
+ c.leave_target("1").as_deref(),
+ Some("General - Elwynn Forest")
+ );
+ assert_eq!(
+ c.leave_target("1abc").as_deref(),
+ Some("General - Elwynn Forest"),
+ "SStrToInt takes the leading digits"
+ );
+ assert_eq!(
+ c.leave_target("2"),
+ None,
+ "suspended: state 3 fails `0x49be50`, and the whole call is a no-op"
+ );
+ assert_eq!(c.leave_target("3"), None, "a hole");
+ assert_eq!(c.leave_target("9"), None, "out of range");
+ assert_eq!(c.leave_target("-1"), None);
+ assert_eq!(c.leave_target("0").as_deref(), Some("0"), "0 is a name");
+ assert_eq!(
+ c.leave_target("").as_deref(),
+ Some(""),
+ "`/leave` alone sends an empty name"
+ );
+ assert_eq!(
+ c.leave_target("General - Elwynn Forest").as_deref(),
+ Some("General - Elwynn Forest"),
+ "a composed name passes through"
+ );
+ assert_eq!(c.leave_target("mychan").as_deref(), Some("mychan"));
+
+ // …and the mask clear needs a slot carrying the wire name (contract §8).
+ c.note_zone_channel_left("General - Nowhere");
+ assert_eq!(c.zone_mask, Some(SEED), "no slot carries it: no clear");
+ c.note_zone_channel_left("General - Elwynn Forest");
+ assert_eq!(c.zone_mask, Some(SEED & !1));
}
/// The two lists never disagree about *resolvability* — only about the city gate. A missing
@@ -821,12 +1260,12 @@ mod tests {
fn the_slot_set_and_the_join_set_differ_only_by_the_city_gate() {
let cat = catalog();
assert_eq!(
- tracked_channels(&cat, "Stormwind City", None),
- wanted_channels(&cat, "Stormwind City", true, None),
+ tracked_channels(&cat, SEED, "Stormwind City", None),
+ wanted_channels(&cat, SEED, "Stormwind City", true, None),
"no city word: the city-NAMED rows are unresolvable, so neither list carries them"
);
assert!(
- tracked_channels(&cat, "", CITY).is_empty(),
+ tracked_channels(&cat, SEED, "", CITY).is_empty(),
"no zone: nothing zone-dependent composes, so nothing takes a slot either"
);
}
diff --git a/crates/benilla-app/src/ui_chat/combat.rs b/crates/benilla-app/src/ui_chat/combat.rs
index e7ed0f3b..a96dad45 100644
--- a/crates/benilla-app/src/ui_chat/combat.rs
+++ b/crates/benilla-app/src/ui_chat/combat.rs
@@ -89,24 +89,23 @@ impl Poses for Query<'_, '_, &Transform> {
/// OUT and a NaN is out. 1571 used `<=`; the boundary is exact in the binary and there is no reason
/// for us to be looser.
///
-/// **The ranges are the compiled-in defaults, not live CVars** — the same standing shape as
-/// [`crate::combat_text`]'s `COMBAT_DAMAGE`/`PET_*` gates, and for the same reason: the values are
-/// byte-read and correct, and the CVars (`CombatLogRangeParty` and its six siblings) can be wired to
-/// the live table without changing anything here. [`UnitClass::default_range`] carries them,
-/// including the two sentinels that make the gate a no-op for you and your pet (`100000.0`) and
-/// unconditional for an unresolvable unit (`0.0`).
+/// **The range is the caller's, read off [`CombatLogRanges`]** — the live table the reference's
+/// `0x626810` walks, not a compiled-in constant. The caller picks which entry: a class range for
+/// the fourteen two-ended and one-ended formatters, and [`CombatLogRanges::death`] for the one
+/// formatter that has a range CVar of its own. The two sentinels ride in the table like any other
+/// entry: `100000.0` makes the gate a no-op for you and your pet, `0.0` refuses an unresolvable
+/// unit outright.
///
/// A pose we do not hold is treated as **in** range: dropping a line because a unit's transform had
/// not landed yet would silently lose the killing blow on a mob that despawns, which is a worse
/// failure than logging one fight too far away.
pub(crate) fn in_range(
guid: u64,
- class: UnitClass,
+ range: f32,
self_guid: &SelfGuid,
index: &GuidIndex,
poses: &impl Poses,
) -> bool {
- let range = class.default_range();
if range >= 100_000.0 {
return true;
}
@@ -120,6 +119,135 @@ pub(crate) fn in_range(
me.distance_squared(them) < range * range
}
+/// **The combat log's display ranges, live** — the reference's `{cvarName, defaultValue}` table at
+/// `0x8629e0` plus the one range CVar that sits outside it, `CombatDeathLogRange`.
+///
+/// The reference registers all eight in one place (`0x626d00`, a loop over `0x8629e0` skipping the
+/// NULL/empty names, then one unrolled call — wow-re `combat-log-chat-law.md` §5.2), stores **no
+/// handle for any of them**, and looks each up by name at every use. We keep the resolved numbers
+/// instead: the lookup-by-name is the reference's way of not caching, not a behaviour, and
+/// `crate::cvars` already owns the string table.
+///
+/// **Yards, and read as the CVar's FLOAT field.** The record carries both `+0x24` float and `+0x28`
+/// int, written from the same string at registration; the range gate reads the float and the
+/// periodic gates read the int (§5.1). `0` is a real value and means *silence this class* —
+/// `dist² < 0` is never true — which is why nothing here clamps to a floor.
+#[derive(Resource, Clone, Copy)]
+pub(crate) struct CombatLogRanges {
+ /// By [`UnitClass`] index `0..=9`. Classes 0/1 (you and your pet) have no CVar and sit at the
+ /// reference's `100000.0` sentinel; class 9 (unresolvable) sits at its `0.0`.
+ class: [f32; 10],
+ /// `CombatDeathLogRange` — the death line's own range, and the **only** formatter that has
+ /// one. Not part of the `0x8629e0` table.
+ death: f32,
+}
+
+impl Default for CombatLogRanges {
+ fn default() -> Self {
+ let mut class = [0.0; 10];
+ for (i, slot) in class.iter_mut().enumerate() {
+ *slot = UnitClass::from_index(i).default_range();
+ }
+ Self {
+ class,
+ death: DEATH_LOG_RANGE_DEFAULT,
+ }
+ }
+}
+
+impl CombatLogRanges {
+ /// This class's live display range, in yards.
+ pub(crate) fn class(&self, class: UnitClass) -> f32 {
+ self.class[class as usize]
+ }
+
+ /// The death line's live range — `CombatDeathLogRange`, for every class alike.
+ ///
+ /// **It really is every class.** The death formatter `0x62c160` looks the CVar up first
+ /// (`0x62c19c`) and falls back to the per-class getter only when the *lookup* fails; the CVar
+ /// is registered at startup, so the fallback is unreachable in a running client. So your own
+ /// death passes on distance 0 rather than on class 0's sentinel, and an unresolvable unit's
+ /// death is logged at 60 yd rather than refused by class 9's `0.0`.
+ pub(crate) fn death(&self) -> f32 {
+ self.death
+ }
+
+ /// 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
+ /// one copy of them.
+ pub(crate) fn set(&mut self, name: &str, value: f32) -> bool {
+ if name.eq_ignore_ascii_case(DEATH_LOG_RANGE_CVAR) {
+ self.death = value;
+ return true;
+ }
+ for i in 0..self.class.len() {
+ let class = UnitClass::from_index(i);
+ if class
+ .range_cvar()
+ .is_some_and(|c| c.eq_ignore_ascii_case(name))
+ {
+ self.class[i] = value;
+ return true;
+ }
+ }
+ false
+ }
+}
+
+/// **`CombatLogPeriodicSpells`** — "Log Periodic Effects", and its blast radius is wider than the
+/// options row's wording.
+///
+/// Its first read site (`0x626dee`) is at the **top of the `SMSG_PERIODICAURALOG` handler**
+/// `0x626dd0`, and a zero jumps to `0x6271b4`, a bare epilogue — so the whole packet body is
+/// suppressed: every `CHAT_MSG_SPELL_PERIODIC_*` line **and** the floating DoT/HoT tick number
+/// **and** the periodic miss-word. The other two sites are pure chat-line filters that leave the
+/// floats alone: `0x62d9ae` (the `SMSG_SPELLNONMELEEDAMAGELOG` leg whose `periodicLog` byte is
+/// set) and `0x62d25f` (`SMSG_SPELLORDAMAGE_IMMUNE`'s `IMMUNESPELL*` lines, only when the
+/// packet's periodic byte is set).
+///
+/// Read as the CVar record's **int** `+0x28`, unlike [`CombatLogRanges`] which reads the float —
+/// both fields are written from the same string at registration (wow-re
+/// `object-layer/scratch/combat-log-chat-law.md` §5.1/§5.4).
+///
+/// A **missing record counts as OFF** in the reference, because the gate is
+/// `cvar == NULL || cvar->int == 0`. That cannot happen here — the row is registered at startup —
+/// and it is why the reference's own default `"1"` is load-bearing rather than incidental.
+#[derive(Resource, Clone, Copy)]
+pub(crate) struct LogPeriodicSpells(pub(crate) bool);
+
+impl Default for LogPeriodicSpells {
+ /// The reference's registered default, `"1"`.
+ fn default() -> Self {
+ Self(true)
+ }
+}
+
+/// `CombatLogPeriodicSpells`' registered name.
+pub(crate) const LOG_PERIODIC_CVAR: &str = "CombatLogPeriodicSpells";
+
+/// **The combat-feedback CVars, as one system parameter** — what a packet handler needs to know
+/// about the player's settings before it emits a line or a floating number.
+///
+/// Bundled for the reason [`crate::cvars::KnobParams`] is: `net::apply::apply_net_updates` lives
+/// against Bevy's 16-parameter ceiling, and three more `Res` would have gone into a nested tuple
+/// as positional fields nobody can read at the use site. They are one concern anyway — the
+/// reference reads all three inside the same combat-log/world-text translation unit.
+#[derive(bevy::ecs::system::SystemParam)]
+pub(crate) struct CombatFeedbackCvars<'w> {
+ /// The eight display ranges.
+ pub ranges: Res<'w, CombatLogRanges>,
+ /// `CombatLogPeriodicSpells`.
+ pub periodic: Res<'w, LogPeriodicSpells>,
+ /// `CombatDamage` + the two `Pet*` sub-gates.
+ pub damage_text: Res<'w, crate::combat_text::DamageTextGates>,
+}
+
+/// `CombatDeathLogRange`'s registered name and default — `0x626d5f`, default string `"60"`
+/// (`0x862e14`).
+pub(crate) const DEATH_LOG_RANGE_CVAR: &str = "CombatDeathLogRange";
+pub(crate) const DEATH_LOG_RANGE_DEFAULT: f32 = 60.0;
+
/// A unit's standing relative to the active player — the `0..9` index every combat-log selector in
/// the reference takes, in both parameter positions.
///
@@ -161,15 +289,29 @@ pub(crate) enum UnitClass {
}
impl UnitClass {
+ /// The `0..=9` index back to its class — the inverse of the discriminant, for walking the
+ /// reference's own table order.
+ pub(crate) fn from_index(i: usize) -> Self {
+ match i {
+ 0 => Self::Me,
+ 1 => Self::MyPet,
+ 2 => Self::Party,
+ 3 => Self::PartyPet,
+ 4 => Self::FriendlyPlayer,
+ 5 => Self::FriendlyPet,
+ 6 => Self::HostilePlayer,
+ 7 => Self::HostilePet,
+ 8 => Self::Creature,
+ _ => Self::Unknown,
+ }
+ }
+
/// The CVar naming this class's display range, `None` for the two ungated classes (0/1).
///
- /// **Test-only, deliberately.** The gate itself runs on [`Self::default_range`] — the
- /// compiled-in values — following the standing shape of [`crate::combat_text`]'s own cvar
- /// gates. This function is the other half of that table, kept so
- /// `tests::the_class_range_table_is_the_binarys` can pin what was read out of `WoW.exe`; it
- /// becomes the production lookup the day the live CVar table is wired in, and until then a
- /// name that drifted would otherwise have nothing checking it.
- #[cfg(test)]
+ /// **This is the production lookup** — `CombatLogRanges::set` walks the classes through it to
+ /// find the one a `SetCVar` names, so the table above is the only place these seven names are
+ /// written down. It was `#[cfg(test)]` until the CVars were registered, with a comment saying
+ /// it "becomes the production lookup the day the live CVar table is wired in".
pub(crate) fn range_cvar(self) -> Option<&'static str> {
Some(match self {
Self::Me | Self::MyPet | Self::Unknown => return None,
@@ -183,7 +325,9 @@ impl UnitClass {
})
}
- /// The compiled-in default range in yards, before any CVar override.
+ /// The **registered default** range in yards — the `defaultValue` half of the reference's own
+ /// `{cvarName, defaultValue}` pairs at `0x8629e0`, and therefore what [`CombatLogRanges`] seeds
+ /// itself with and what `cvars::REGISTERED` ships. The live value is the CVar's.
pub(crate) fn default_range(self) -> f32 {
match self {
Self::Me | Self::MyPet => 100_000.0,
diff --git a/crates/benilla-app/src/ui_chat/combat/tests.rs b/crates/benilla-app/src/ui_chat/combat/tests.rs
index 97f046e9..299cd772 100644
--- a/crates/benilla-app/src/ui_chat/combat/tests.rs
+++ b/crates/benilla-app/src/ui_chat/combat/tests.rs
@@ -482,3 +482,51 @@ fn the_class_range_table_is_the_binarys() {
assert_eq!(C::Creature.default_range(), 30.0);
assert_eq!(C::Unknown.default_range(), 0.0);
}
+
+/// **The eight range CVars reach the gate** — the table is live, not compiled in.
+///
+/// The reference registers all eight in one place (`0x626d00`) and looks each up by name at every
+/// use; we resolve them once into [`CombatLogRanges`]. What this pins is the property that made
+/// them worth registering: a `SetCVar` moves the number the gate actually compares against, for
+/// each of the seven classes independently and for the death line separately.
+///
+/// The control is class 0: it has NO CVar in the reference's table, so no name may reach it.
+#[test]
+fn a_set_cvar_moves_the_range_the_gate_compares_against() {
+ use super::{CombatLogRanges, UnitClass as C};
+
+ let mut r = CombatLogRanges::default();
+ // Seeded from the reference's own `{cvarName, defaultValue}` pairs.
+ assert_eq!(r.class(C::Party), 50.0);
+ assert_eq!(r.class(C::Creature), 30.0);
+ assert_eq!(r.class(C::Me), 100_000.0);
+ assert_eq!(r.death(), 60.0);
+
+ // Each of the seven moves its own class and nothing else — BigWigs' slider, which wrote
+ // nothing at all before these rows were registered.
+ assert!(r.set("CombatLogRangeParty", 200.0));
+ assert_eq!(r.class(C::Party), 200.0);
+ assert_eq!(r.class(C::PartyPet), 50.0, "a sibling class must not move");
+ assert!(r.set("CombatLogRangeCreature", 15.0));
+ assert_eq!(r.class(C::Creature), 15.0);
+
+ // The name match is case-insensitive, like the reference's own `SStrCmpI` lookup.
+ assert!(r.set("combatlograngehostileplayers", 80.0));
+ assert_eq!(r.class(C::HostilePlayer), 80.0);
+
+ // The death range is its own store, and moving it leaves every class alone.
+ assert!(r.set(super::DEATH_LOG_RANGE_CVAR, 5.0));
+ assert_eq!(r.death(), 5.0);
+ assert_eq!(r.class(C::Party), 200.0);
+
+ // Zero is a real value, not "unset": the reference's `dist² < 0` is never true, so the class
+ // goes silent. Nothing may clamp it up to a floor.
+ assert!(r.set("CombatLogRangePartyPet", 0.0));
+ assert_eq!(r.class(C::PartyPet), 0.0);
+
+ // The control: classes 0 and 1 have a NULL name in the reference's table, and an unrelated
+ // name is refused rather than silently swallowed.
+ assert!(!r.set("CombatLogRangeMe", 10.0));
+ assert!(!r.set("mousespeed", 10.0));
+ assert_eq!(r.class(C::Me), 100_000.0);
+}
diff --git a/crates/benilla-app/src/ui_chat/combat/watch.rs b/crates/benilla-app/src/ui_chat/combat/watch.rs
index b975333b..e6c6d7db 100644
--- a/crates/benilla-app/src/ui_chat/combat/watch.rs
+++ b/crates/benilla-app/src/ui_chat/combat/watch.rs
@@ -19,8 +19,8 @@ use bevy::ecs::entity::EntityHashMap;
use bevy::prelude::*;
use super::{
- aura_gone_kind, classify, death_kind, in_range, periodic_kind, Family, Fills, Named,
- PendingCombat, UnitClass, Variant,
+ aura_gone_kind, classify, death_kind, in_range, periodic_kind, CombatLogRanges, Family, Fills,
+ Named, PendingCombat, UnitClass, Variant,
};
use crate::net::{GuidIndex, ObjectStore, Reputations, SelfGuid};
use crate::target::ring::Factions;
@@ -38,6 +38,8 @@ pub(crate) struct WatchCtx<'w> {
pub factions: Option>,
pub reputations: Res<'w, Reputations>,
pub spells: Option>,
+ /// The live display ranges — the reference's `0x8629e0` table plus `CombatDeathLogRange`.
+ pub ranges: Res<'w, CombatLogRanges>,
}
impl WatchCtx<'_> {
@@ -69,8 +71,13 @@ impl WatchCtx<'_> {
/// The reference's ONE-SIDED range test — the shape 15 formatters run instead of the two-ended
/// gate (§5.2), the death and aura lines among them. One participant, one distance.
- fn in_range(&self, guid: u64, class: UnitClass, poses: &Query<&Transform>) -> bool {
- in_range(guid, class, &self.self_guid, &self.index, poses)
+ ///
+ /// **`range` is the caller's**, because the death line does not use the class table: its
+ /// formatter `0x62c160` reads `CombatDeathLogRange` first (`0x62c19c`) and only falls back to
+ /// the per-class getter when that *lookup* fails — which never happens in a client that
+ /// registered it at startup. The aura lines have no CVar of their own and stay per-class.
+ fn in_range(&self, guid: u64, range: f32, poses: &Query<&Transform>) -> bool {
+ in_range(guid, range, &self.self_guid, &self.index, poses)
}
}
@@ -83,10 +90,11 @@ fn queue_one(
kind: ChatEventKind,
family: Family,
subject: (u64, UnitClass),
+ range: f32,
fills: Fills,
named: Named,
) {
- if !ctx.in_range(subject.0, subject.1, poses) {
+ if !ctx.in_range(subject.0, range, poses) {
return;
}
log.push_combat(PendingCombat {
@@ -164,6 +172,8 @@ pub(crate) fn death_lines(
death_kind(class),
family,
(guid, class),
+ // The death line's own CVar, not the class table — the one formatter that has one.
+ ctx.ranges.death(),
Fills::default(),
Named::Ready,
);
@@ -255,6 +265,7 @@ pub(crate) fn aura_lines(
super::AURAAPPLICATIONADDED_HELPFUL
},
(guid, class),
+ ctx.ranges.class(class),
fills(i64::from(stacks)),
Named::Ready,
);
@@ -274,6 +285,7 @@ pub(crate) fn aura_lines(
super::AURAADDED_HELPFUL
},
(guid, class),
+ ctx.ranges.class(class),
fills(0),
Named::Ready,
);
@@ -295,6 +307,7 @@ pub(crate) fn aura_lines(
aura_gone_kind(class),
super::AURAREMOVED,
(guid, class),
+ ctx.ranges.class(class),
Fills {
spell,
..Default::default()
diff --git a/crates/benilla-app/src/ui_chat/edit.rs b/crates/benilla-app/src/ui_chat/edit.rs
index 1bbb34ec..d0cd0d4a 100644
--- a/crates/benilla-app/src/ui_chat/edit.rs
+++ b/crates/benilla-app/src/ui_chat/edit.rs
@@ -128,8 +128,8 @@ pub(crate) const MAX_CHANNELS: usize = 10;
/// model the one value of it that changes what the player sees: **3, locally suspended**. States
/// 0 (server-confirmed), 1 (join not yet acknowledged) and 2 (renamed, re-join pending) collapse
/// here, and that is sound rather than lazy — the reference reads 0 to decide whether a LEAVE goes
-/// out, and our walk decides that from [`super::channels::ZoneChannelWalk::held`], the request side
-/// (1284). State 3 does not collapse: it is the difference between keeping a channel and losing it.
+/// out, and our walk decides that from the slot's own state on the request side (1284). State 3
+/// does not collapse: it is the difference between keeping a channel and losing it.
#[derive(Resource, Default)]
pub(crate) struct ChannelState {
/// Slot `i` is channel number `i + 1`; `None` is a freed slot, kept so the numbers above it
@@ -168,7 +168,16 @@ pub(crate) struct ChannelState {
///
/// Not cleared by the session end: it belongs to the character's file, and the login that
/// reads that file is what seats it.
- pub zone_mask: u32,
+ ///
+ /// **`None` until that login has read the file** — the reference's "chat system ready" flag
+ /// `ds:0xb6e5c8`, set at the tail of the cache loader (`0x499a18`) and the first thing
+ /// `ZoneChannelRefresh` tests (`0x49a219`, a full bail). The walk is the mask's consumer
+ /// (decision 2144: **the mask is the join predicate**, `0x49a494`), so a walk before the seat
+ /// would read an empty word and join nothing — and nothing re-triggers it when the word
+ /// lands. An `Option` says "not seated" in the type rather than in a second flag that could
+ /// drift from it; the saver refuses to compose a file from `None` for the same reason
+ /// (writing `ZONECHANNELS 0` is the damage 2120 repaired).
+ pub zone_mask: Option,
}
/// One joined-channel record — the reference's `[0xb4fe04] + n*0xa0` slot, in the fields this
@@ -191,7 +200,8 @@ pub(crate) struct ChannelSlot {
pub(crate) enum SlotState {
/// `0` (server-confirmed) and `1` (join not yet acknowledged), which we cannot tell apart and
/// do not need to: the reference reads `0` to decide whether a LEAVE goes out, and our walk
- /// decides that from [`super::channels::ZoneChannelWalk::held`], the request side (1284).
+ /// reads this state for the same decision — a slot it registered and never confirmed sends
+ /// one LEAVE the server answers "Not on channel", which is the one cost of the collapse.
#[default]
Joined,
/// `2` — **renamed, re-join pending**: the zone walk moved this row's name because the player
@@ -238,14 +248,71 @@ impl ChannelState {
/// which ORs `1 << (slot.ChannelID - 1)` in the `YOU_JOINED` arm. A custom channel has no DBC
/// id and so no bit, which is why this is a no-op for one.
pub(crate) fn note_zone_channel_joined(&mut self, name: &str) {
- self.zone_mask |= zone_bit(self.channels.zone_channel_id(name));
+ let bit = zone_bit(self.channels.zone_channel_id(name));
+ match &mut self.zone_mask {
+ Some(mask) => *mask |= bit,
+ // Nothing sends a join before the cache loader has run — the walk waits for the seat
+ // and the file's own custom re-joins come after it — so this is a broken ordering,
+ // not a state to absorb.
+ None if bit != 0 => warn!(
+ "chat: {name:?} confirmed joined before the chat cache seated the zone mask — bit {bit:#x} dropped"
+ ),
+ None => {}
+ }
}
/// An **explicit** leave clears the bit — `0x49f10a`/`0x49f11a` inside leave-by-name
/// `0x49ee70`, and only there. The zone walk's LEAVE goes out on a different path and leaves
/// the mask alone: crossing a border is not "I left this channel".
+ ///
+ /// Keyed the way the reference keys it (wow-re `leavechannelbyname-contract.md` §8): the slot
+ /// found by the **wire name**, and its own DBC id — so a name no slot carries clears nothing,
+ /// whatever row it would resolve to.
pub(crate) fn note_zone_channel_left(&mut self, name: &str) {
- self.zone_mask &= !zone_bit(self.channels.zone_channel_id(name));
+ if self.number_of(name).is_none() {
+ return;
+ }
+ if let Some(mask) = &mut self.zone_mask {
+ *mask &= !zone_bit(self.channels.zone_channel_id(name));
+ }
+ }
+
+ /// Does the mask carry `id`'s bit — is this `ChatChannels.dbc` row one the walk joins? The
+ /// reference's live predicate `0x49a494` (decision 2144). `None` (not seated) answers false.
+ pub(crate) fn zone_row_wanted(&self, id: u32) -> bool {
+ self.zone_mask.is_some_and(|mask| mask & zone_bit(id) != 0)
+ }
+
+ /// **The numeric leg of leave-by-name** — `0x49ee70` step 1 (wow-re
+ /// `leavechannelbyname-contract.md` §3, VERIFIED): a `SStrToInt` of the argument that is not
+ /// zero names joined slot `n`, and only a **server-confirmed** one (`slot+0x9c == 0`,
+ /// `0x49be50`); a hole, an out-of-range number or a suspended slot make the whole call a
+ /// no-op — no packet, no mask change. `None` is that no-op. Anything else is already the wire
+ /// name: the VM's `LeaveChannelByName` composed a shortcut or passed a custom name through.
+ ///
+ /// Here rather than in the VM because the slot **states** live here; the VM's mirror carries
+ /// names alone.
+ pub(crate) fn leave_target(&self, arg: &str) -> Option {
+ let digits: String = arg
+ .strip_prefix('-')
+ .unwrap_or(arg)
+ .chars()
+ .take_while(|c| c.is_ascii_digit())
+ .collect();
+ if digits.is_empty() || digits.chars().all(|c| c == '0') {
+ return Some(arg.to_string());
+ }
+ if arg.starts_with('-') {
+ return None; // a negative never names a slot
+ }
+ digits
+ .parse::()
+ .ok()
+ .filter(|n| *n > 0)
+ .and_then(|n| self.joined.get(n - 1))
+ .and_then(|slot| slot.as_ref())
+ .filter(|slot| slot.state == SlotState::Joined)
+ .map(|slot| slot.name.clone())
}
/// **Rename a slot in place — the zone walk crossing a border** (decision 2130).
@@ -286,9 +353,15 @@ impl ChannelState {
let n = self.number_of(old)?;
let slot = self.joined[n as usize - 1].as_mut()?;
slot.name = new.to_string();
- // `0x49bcd3`: `(old == 3) ? 1 : 2` — either way the slot is awaiting a re-join, and the
- // confirming notice is the one that resolves it.
- slot.state = SlotState::Renamed;
+ // `0x49bcd3`: `(old == 3) ? 1 : 2`. A suspended slot comes back as a plain pending join —
+ // our `Joined` is that state 1 — so its confirming notice reads `YOU_JOINED`, not
+ // `YOU_CHANGED`; any other state is a rename awaiting its re-join, and the notice resolves
+ // it.
+ slot.state = if slot.state == SlotState::Suspended {
+ SlotState::Joined
+ } else {
+ SlotState::Renamed
+ };
Some(n)
}
diff --git a/crates/benilla-app/src/ui_chat/feed.rs b/crates/benilla-app/src/ui_chat/feed.rs
index 6b362dab..71d17d70 100644
--- a/crates/benilla-app/src/ui_chat/feed.rs
+++ b/crates/benilla-app/src/ui_chat/feed.rs
@@ -688,6 +688,16 @@ pub(super) fn deliver(
// alternates are what keep a renamed or suspended channel registered with the window
// ([`super::event::notice_token`], decision 2130).
event.slot_state = channels.slot_state(&event.channel);
+ if let Some(byte) = notice {
+ // The one trace of the server's half of every join and leave — a probe log's only way to
+ // tell "we asked" from "the server agreed" (decision 2144's live runs read it).
+ debug!(
+ "chat: channel notice {byte:#04x} for {:?} (slot {:?}, {:?})",
+ event.channel,
+ channels.number_of(&event.channel),
+ event.slot_state
+ );
+ }
if notice == Some(channel_notice::YOU_JOINED) {
// The reference's `0x49bbaf`: the confirmed join is what sets the channel's
// `ZONECHANNELS` bit, and it is the ONLY thing that grows that mask at runtime
diff --git a/crates/benilla-app/src/ui_chat/input/mod.rs b/crates/benilla-app/src/ui_chat/input/mod.rs
index 71888e21..d42c70c0 100644
--- a/crates/benilla-app/src/ui_chat/input/mod.rs
+++ b/crates/benilla-app/src/ui_chat/input/mod.rs
@@ -183,6 +183,26 @@ fn engine_verbs(
out
}
+/// **A manual join or leave of `GuildRecruitment` turns the auto-join option off** — the
+/// reference's `0x49ed3d` (join) and `0x49ef8f` (leave), each `call 0x49ea70(0)` gated on the
+/// matched `ChatChannels.dbc` row carrying `flags & 0x20000` and on the caller's own flag, which
+/// the Lua bindings pass and the cascade's internal calls do not (wow-re
+/// `guild-recruitment-mode.md` §3; decision 2144). The player has taken manual control, and an
+/// option left checked would silently re-join or re-leave behind them.
+fn manual_join_or_leave(
+ channels: &super::edit::ChannelState,
+ script: &mut benilla_ui::script::UiScript,
+ wire_name: &str,
+) {
+ let guild_row = channels
+ .channels
+ .row_for_name(wire_name)
+ .is_some_and(|r| r.is_guild_recruitment());
+ if guild_row && script.reset_guild_recruitment_mode() {
+ info!("chat: manual {wire_name:?} — auto-join guild recruitment channel switched off");
+ }
+}
+
#[allow(clippy::too_many_arguments)]
pub(super) fn drain_chat_input(
script: Option>,
@@ -261,16 +281,27 @@ pub(super) fn drain_chat_input(
// `/r` is `SLASH_REPLY`, one of the reference's own built-ins (`ChatEdit_ParseText`'s
// REPLY arm over the box's tell ring) — the line never reaches this queue.
ParsedChat::Reply { .. } => {}
+ // `/join` and `/leave` are stock built-ins (`SlashCmdList["JOIN"]`/`["LEAVE"]`, ref
+ // `ChatFrame.lua` l.778/l.803), so a typed line never reaches here — only a probe's,
+ // which skips the edit box. It takes the same handler, not a shortcut past it: the
+ // command-table arm used to send the token verbatim, and a probe's `/join General`
+ // created a CUSTOM channel called "General" on the server (decision 2144's live
+ // run D). The handlers resolve through `JoinChannelByName`/`LeaveChannelByName`,
+ // whose commands the `Channel` arm below drains next frame.
ParsedChat::Join { name, password } => {
- let _ = commands
- .0
- .send(ClientCommand::JoinChannel { name, password });
+ let body = format!(
+ "SlashCmdList['JOIN']({:?})",
+ format!("{name} {password}").trim_end()
+ );
+ if let Err(e) = script.run(&body) {
+ warn!("ui_chat: {body}: {e}");
+ }
}
ParsedChat::Leave { name } => {
- // `/leave` is leave-by-name: the reference clears the mask bit here and nowhere
- // else (decision 2120).
- channels.note_zone_channel_left(&name);
- let _ = commands.0.send(ClientCommand::LeaveChannel { name });
+ let body = format!("SlashCmdList['LEAVE']({name:?})");
+ if let Err(e) = script.run(&body) {
+ warn!("ui_chat: {body}: {e}");
+ }
}
ParsedChat::ChatList { name } => {
let _ = commands.0.send(ClientCommand::ChannelList { name });
@@ -975,10 +1006,19 @@ pub(super) fn drain_chat_input(
ParsedChat::Channel(cmd) => {
use benilla_ui::script::ChannelCommand as C;
let cmd = match cmd {
- C::Join { name, password } => ClientCommand::JoinChannel { name, password },
+ C::Join { name, password } => {
+ manual_join_or_leave(&channels, &mut script, &name);
+ ClientCommand::JoinChannel { name, password }
+ }
C::Leave { name } => {
- // `LeaveChannelByName` — the same leave-by-name path as `/leave`, so the
- // same mask clear (decision 2120).
+ // `LeaveChannelByName` (`0x4a0000` → `0x49ee70`): the VM composed a
+ // shortcut or passed a custom name; a number names a confirmed slot here
+ // or the call is a no-op. The mask clear is this path's and no other's
+ // (decisions 2120, 2144).
+ let Some(name) = channels.leave_target(&name) else {
+ continue;
+ };
+ manual_join_or_leave(&channels, &mut script, &name);
channels.note_zone_channel_left(&name);
ClientCommand::LeaveChannel { name }
}
diff --git a/crates/benilla-app/src/ui_chat/recruitment.rs b/crates/benilla-app/src/ui_chat/recruitment.rs
new file mode 100644
index 00000000..d934c6d8
--- /dev/null
+++ b/crates/benilla-app/src/ui_chat/recruitment.rs
@@ -0,0 +1,273 @@
+//! The `AUTO_JOIN_GUILD_CHANNEL` cascade — the reference's `0x49ea90`, the thing
+//! `SetGuildRecruitmentMode(1)` tail-jumps into and the one place the client joins or leaves
+//! `GuildRecruitment - City` on its own (decision 2144; every byte fact below is wow-re
+//! `system/ui/scratch/guild-recruitment-mode.md`, a §5 round, and its callers' census in
+//! `zone-chat-channel-autojoin.md` §11.5).
+//!
+//! **What it is for.** `GuildRecruitment` is the channel *unguilded* players sit in to be found.
+//! Its `ChatChannels.dbc` row carries no `INITIAL` bit, so the zone walk never seeds it; instead
+//! the option's latch (`[0x843608]`, `1` = AUTO, the shipped default) drives a cascade that reads
+//! the local player's own `PLAYER_GUILDID` and acts on the wire:
+//!
+//! - **guilded** ⇒ leave-by-name `"GuildRecruitment - City"` (`0x49eb17` → `0x49ee70`):
+//! `CMSG_LEAVE_CHANNEL`, the channel stripped from every chat window's list, its `ZONECHANNELS`
+//! bit cleared, then `UPDATE_CHAT_WINDOWS`. **No already-a-member check.**
+//! - **unguilded, in a capital** (`AreaTable Flags & 0x100`) ⇒ join `"GuildRecruitment"`
+//! (`0x49eb55` → `0x49eb70`): a slot, the composed name into chat window 1's list,
+//! `CMSG_JOIN_CHANNEL`, then `UPDATE_CHAT_WINDOWS`. **No already-joined check.**
+//! - **otherwise** — no resolvable zone row, or unguilded outside a capital ⇒ the one-shot
+//! `[0xb6e5e4]` is armed and `ZoneChannelRefresh`'s tail consumes it on the next walk
+//! (`0x49a6a4`–`0x49a6b2`).
+//!
+//! Once the join confirms, the server's `YOU_JOINED` ORs bit 24 into the mask (`0x49bbaf`), and
+//! from then on **the zone walk owns the channel exactly like Trade** — registered before the
+//! gate, suspended outside a capital, re-joined on the way back in through the state-3 bypass
+//! ([`super::channels::plan_walk`]). The cascade is the *entry*, not the upkeep.
+//!
+//! **Triggers** (`0x49ea90`'s closed caller census, §4.2): `SetGuildRecruitmentMode(1)`; the
+//! chat-cache loader seating `AUTO`; the local player's `PLAYER_GUILDID` field-change watcher
+//! (`0x5e2770`); and every `CGPlayer` create (`0x5dec1e`) — which includes the local player's own
+//! at login. Ours: the Lua verb's ask, and a watcher over the local player's guild id whose first
+//! sight of it *is* the create. The loader's call is subsumed (the watcher fires after the seat,
+//! never before, because the walk's zone — which the cascade waits on — resolves only after the
+//! world-entry load). **The per-*remote*-player re-fire is deliberately not modelled**: on the
+//! bytes a guilded player re-sends the LEAVE every time another player streams in, which vmangos
+//! answers "Not on channel" — a notice the stock `ChatFrame_OnEvent` then drops, because no slot
+//! and no window carries the channel (`found == 0`). Invisible on screen, a packet per player on
+//! the wire, and a quirk of where the hook sits rather than a behaviour; recorded in 2144.
+//!
+//! **Retry, generalised.** The reference arms a one-shot and the walk's tail consumes it on the
+//! next zone change. Ours keeps the request pending and re-evaluates on every frame the zone
+//! resolves; the observable is the same — it acts on the first zone change into a capital — and
+//! it does not depend on the walk happening to run.
+
+use bevy::prelude::*;
+
+use benilla_ui::script::UiScript;
+
+use crate::area::AreaTableRes;
+use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
+
+use super::channels::{city_word, ZoneChannelWalk};
+use super::edit::ChannelState;
+
+/// `AreaTable.dbc` `Flags & 0x100` — vmangos `AREA_FLAG_CAPITAL`. The cascade's own capital test
+/// (`0x49eb2e test ch,1`): a *different* bit from the walk's eligibility gate (`0x8`), read here
+/// as its own flag because the client reads it as one. In the 1.12.1 data both sit on exactly the
+/// same six rows (autojoin §5).
+const AREA_FLAG_CAPITAL: u32 = 0x100;
+
+/// The cascade's own state: whether a run is owed, and the guild id its watcher last saw.
+#[derive(Resource, Default)]
+pub(crate) struct GuildRecruitmentCascade {
+ /// A run is owed — the reference's `[0xb6e5e4]` one-shot and the tail-jump out of `0x49ea70`,
+ /// folded into one flag: set by every trigger, cleared when the cascade acts or finds the latch
+ /// off, **kept** while it cannot act (module doc, "Retry, generalised").
+ pending: bool,
+ /// The local player's `PLAYER_GUILDID` as last observed — the field-change watcher
+ /// `0x5e2770`. `None` until the player streams in, so the first observation is a change: that
+ /// is the reference's player-create trigger (`0x5dec1e`), which is what runs the cascade at
+ /// login.
+ last_guild_id: Option,
+}
+
+impl GuildRecruitmentCascade {
+ /// A trigger fired: `SetGuildRecruitmentMode(1)`, or the watcher.
+ pub(super) fn request(&mut self) {
+ self.pending = true;
+ }
+
+ /// The watcher: note the local player's guild id, answering whether it moved (the first sight
+ /// counts). A move requests a run.
+ pub(super) fn observe_guild_id(&mut self, guild_id: u32) -> bool {
+ if self.last_guild_id == Some(guild_id) {
+ return false;
+ }
+ self.last_guild_id = Some(guild_id);
+ self.pending = true;
+ true
+ }
+
+ /// Session end: the next login's first sight of the guild id must be a change again, and a
+ /// run owed to a character who logged out is not owed to the next one.
+ pub(super) fn clear_session(&mut self) {
+ self.pending = false;
+ self.last_guild_id = None;
+ }
+}
+
+/// What `0x49ea90` does once the player and the zone row have resolved.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum Cascade {
+ /// Guilded: leave `GuildRecruitment - City` (`0x49eb0e`–`0x49eb2b`).
+ Leave,
+ /// Unguilded, in a capital: join `GuildRecruitment` (`0x49eb2b`–`0x49eb64`).
+ Join,
+ /// Unguilded, not in a capital: arm the retry (`0x49eb33`), no packet.
+ Deferred,
+}
+
+/// `0x49ea90`'s verdict, pure. `None` = the latch is not AUTO (`0x49ea96`): nothing at all, the
+/// retry not even armed. The guild test comes **before** the capital test (`0x49eb0c`), so a
+/// guilded player leaves from anywhere.
+pub(crate) fn cascade(auto: bool, guild_id: u32, in_capital: bool) -> Option {
+ if !auto {
+ return None;
+ }
+ if guild_id != 0 {
+ return Some(Cascade::Leave);
+ }
+ Some(if in_capital {
+ Cascade::Join
+ } else {
+ Cascade::Deferred
+ })
+}
+
+/// The cascade as a system — chained **after** the walk, which is where the reference runs it
+/// (the tail of `ZoneChannelRefresh`) and where the zone it reads has just been resolved.
+pub(super) fn guild_recruitment_cascade(
+ script: Option>,
+ mut state: ResMut,
+ mut channels: ResMut,
+ walk: Res,
+ areas: Option>,
+ self_q: Query<&ObjectStore, With>,
+ commands: Res,
+) {
+ let Some(mut script) = script else { return };
+ // Trigger: `SetGuildRecruitmentMode(1)` — `0x49ea70`'s tail-jump, whether or not the value
+ // moved (the reference's store is unconditional and the jump keys on the new value alone).
+ if script.take_guild_recruitment_cascade() {
+ state.request();
+ }
+ // Trigger: the local player's guild id, first sight included. Only while the avatar is
+ // streamed — a frame between the logout despawn and the teardown must not read as "left the
+ // guild" (`ui_guild::feed` keeps the same rule).
+ let Some(guild_id) = self_q.iter().next().map(|s| s.0.player_guild_id()) else {
+ return;
+ };
+ state.observe_guild_id(guild_id);
+ if !state.pending {
+ return;
+ }
+ // The latch and the mask are seated by the same chat-cache restore; before it, the latch is
+ // the boot value and not the character's. The walk holds on the same condition.
+ if channels.zone_mask.is_none() {
+ return;
+ }
+ // The zone row: unresolvable ⇒ the retry stays armed (`0x49eaf5`).
+ let Some(areas) = areas.as_deref() else {
+ return;
+ };
+ let Some(row) = walk.zone_id.and_then(|id| areas.0.get(id)) else {
+ return;
+ };
+ let auto = script.guild_recruitment_mode() == 1;
+ let verdict = cascade(auto, guild_id, row.flags & AREA_FLAG_CAPITAL != 0);
+ let Some(action) = verdict else {
+ state.pending = false; // STANDARD: nothing, not even the retry
+ return;
+ };
+ if action == Cascade::Deferred {
+ return; // `0x49eb33`: the retry stays armed
+ }
+ // The row and its composed name: `0x49f140` composes `Name_lang` against the `"City"` row
+ // regardless of the zone (the join side is handed the bare shortcut and `0x49eb70` composes
+ // it again the same way). No such row, or no city word (the walk's own guard against a
+ // half-formed name): nothing to name, consumed rather than retried.
+ let target = channels
+ .channels
+ .rows()
+ .iter()
+ .find(|r| r.is_guild_recruitment())
+ .cloned()
+ .and_then(|row| {
+ let name = row.joinable_name("", city_word(&areas.0)?);
+ Some((row, name))
+ });
+ let Some((row, name)) = target else {
+ state.pending = false;
+ return;
+ };
+ match action {
+ Cascade::Leave => {
+ info!("chat: guild recruitment cascade — guilded, leaving {name:?}");
+ let _ = commands
+ .0
+ .send(ClientCommand::LeaveChannel { name: name.clone() });
+ // `0x49ee70`'s other two halves: the strip from all ten windows, keyed on the
+ // Shortcut (§6), and the mask bit (`0x49f10a`/`0x49f11a`).
+ script.strip_chat_window_channel(&row.shortcut);
+ channels.note_zone_channel_left(&name);
+ }
+ Cascade::Join => {
+ info!("chat: guild recruitment cascade — unguilded in a capital, joining {name:?}");
+ // `0x49eb70`: the slot (`0x49b980`, de-duplicated by name), window 1's list
+ // (`frameIdx = 0`), then the send — unconditional, so a repeat re-sends.
+ match channels.claim_slot(&name) {
+ Some(n) => {
+ debug!("chat: {name:?} registered as slot {n}");
+ script.set_joined_channels(channels.names());
+ }
+ None => warn!(
+ "chat: no free slot for {name:?} — all {} are taken",
+ super::edit::MAX_CHANNELS
+ ),
+ }
+ script.register_chat_window_channel(0, &row.shortcut, row.id);
+ let _ = commands.0.send(ClientCommand::JoinChannel {
+ name,
+ password: String::new(),
+ });
+ }
+ Cascade::Deferred => {}
+ }
+ // Both acting arms fire it (`0x49eb21`, `0x49eb5f`): the stock `ChatFrame_OnEvent` re-reads
+ // every window's channel list on this event and nothing else does.
+ script.fire_event("UPDATE_CHAT_WINDOWS", vec![]);
+ state.pending = false;
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// The four legs of `0x49ea90`, and the order of its two tests.
+ #[test]
+ fn the_cascade_reads_the_latch_then_the_guild_then_the_capital() {
+ assert_eq!(
+ cascade(false, 0, true),
+ None,
+ "STANDARD: nothing, not even the retry"
+ );
+ assert_eq!(cascade(false, 7, true), None);
+ assert_eq!(
+ cascade(true, 7, false),
+ Some(Cascade::Leave),
+ "guilded leaves from anywhere — the guild test precedes the capital test"
+ );
+ assert_eq!(cascade(true, 7, true), Some(Cascade::Leave));
+ assert_eq!(cascade(true, 0, true), Some(Cascade::Join));
+ assert_eq!(cascade(true, 0, false), Some(Cascade::Deferred));
+ }
+
+ /// The watcher: the first sight of the guild id is a change (the player-create trigger), a
+ /// repeat is not, a move is, and the session end forgets — so the next login's first sight is
+ /// a change again.
+ #[test]
+ fn the_watcher_fires_on_first_sight_and_on_change() {
+ let mut c = GuildRecruitmentCascade::default();
+ assert!(!c.pending);
+ assert!(c.observe_guild_id(0), "first sight, even of 'no guild'");
+ assert!(c.pending);
+ c.pending = false;
+ assert!(!c.observe_guild_id(0), "unchanged");
+ assert!(!c.pending);
+ assert!(c.observe_guild_id(42), "joined a guild");
+ assert!(c.pending);
+ c.clear_session();
+ assert!(!c.pending);
+ assert!(c.observe_guild_id(42), "a new session sees it fresh");
+ }
+}
diff --git a/crates/benilla-app/src/ui_chat/settings.rs b/crates/benilla-app/src/ui_chat/settings.rs
index d073ad64..8d445ff1 100644
--- a/crates/benilla-app/src/ui_chat/settings.rs
+++ b/crates/benilla-app/src/ui_chat/settings.rs
@@ -629,7 +629,9 @@ pub(crate) fn restore_chat_looks(world: &mut World, script: &mut UiScript) {
// the DBC seed when it did not, and from then on the confirmed joins' own OR.
let mask = parsed.zone_mask.unwrap_or(seed_mask);
if let Some(mut channels) = world.get_resource_mut::() {
- channels.zone_mask = mask;
+ // `Some` is the reference's "chat system ready" flag (`0x499a18`): the walk and the
+ // guild-recruitment cascade both hold until this line has run (decision 2144).
+ channels.zone_mask = Some(mask);
}
script.set_guild_recruitment_mode(u8::from(parsed.guild_recruitment_auto));
script.set_chat_colors(parsed.colors);
@@ -677,11 +679,20 @@ fn watch_chat_looks(script: Option>, mut file: ResMut bool {
+ if exiting {
+ restored
+ } else {
+ dirty && quiet
+ }
+}
+
+/// The write itself, on the terms [`owes_write`] set.
+fn flush(
+ script: &UiScript,
+ channels: &super::edit::ChannelState,
+ file: &mut ChatWindowFile,
+ exiting: bool,
+) {
+ let restored = file.identity.get(script).is_some();
+ let dirty = *file.dirty.get(script);
+ let quiet = file.last_change.is_none_or(|t| t.elapsed() >= SAVE_QUIET);
+ if !owes_write(exiting, restored, dirty, quiet) {
+ return;
+ }
+ if let Some(path) = file.path.clone() {
+ write(script, channels, &path);
+ }
+ *file.dirty.get(script) = false;
+}
+
/// The debounced save, and the `AppExit` flush.
fn save_chat_looks(
script: Option>,
@@ -698,34 +752,17 @@ fn save_chat_looks(
) {
let exiting = exits.read().next().is_some();
let Some(script) = script else { return };
- if !*file.dirty.get(&script) {
- return;
- }
- if !(exiting || file.last_change.is_none_or(|t| t.elapsed() >= SAVE_QUIET)) {
- return;
- }
- let Some(path) = file.path.clone() else {
- *file.dirty.get(&script) = false;
- return;
- };
- write(&script, &channels, &path);
- *file.dirty.get(&script) = false;
+ flush(&script, &channels, &mut file, exiting);
}
-/// The session-end flush — `OnExit(InWorld)`.
+/// The session-end flush — `OnExit(InWorld)`, the reference's chat teardown.
fn save_on_session_end(
script: Option>,
channels: Res,
mut file: ResMut,
) {
let Some(script) = script else { return };
- if !*file.dirty.get(&script) {
- return;
- }
- if let Some(path) = file.path.clone() {
- write(&script, &channels, &path);
- }
- *file.dirty.get(&script) = false;
+ flush(&script, &channels, &mut file, true);
}
pub(super) fn plugin(app: &mut App) {
@@ -986,13 +1023,45 @@ mod tests {
]),
..Default::default()
};
+ // Unseated, a join is dropped rather than absorbed into a word nobody has read yet.
+ state.note_zone_channel_joined("General - Elwynn Forest");
+ assert_eq!(state.zone_mask, None, "not seated: nothing to OR into");
+ state.zone_mask = Some(0);
state.note_zone_channel_joined("General - Elwynn Forest");
state.note_zone_channel_joined("Trade - City");
- assert_eq!(state.zone_mask, 0b11);
+ assert_eq!(state.zone_mask, Some(0b11));
state.note_zone_channel_joined("MyChan");
- assert_eq!(state.zone_mask, 0b11, "a custom channel has no bit");
+ assert_eq!(state.zone_mask, Some(0b11), "a custom channel has no bit");
+ // The clear is keyed on the SLOT the wire name finds, and its own id (decision 2144,
+ // wow-re `leavechannelbyname-contract.md` §8) — a name we hold no slot for clears nothing.
state.note_zone_channel_left("Trade - City");
- assert_eq!(state.zone_mask, 0b01, "an explicit leave clears one bit");
+ assert_eq!(state.zone_mask, Some(0b11), "no slot carries it yet");
+ state.claim_slot("Trade - City");
+ state.note_zone_channel_left("Trade - City");
+ assert_eq!(
+ state.zone_mask,
+ Some(0b01),
+ "an explicit leave clears one bit"
+ );
+ }
+
+ /// **The teardown write is unconditional; only the debounce reads the dirty flag** (decision
+ /// 2144). A `/leave General` moves host state alone — the mask — and the reference persists
+ /// it because its teardown saver has no dirty flag; ours gated the same write on a Lua-side
+ /// flag, so the leave came back on the next login unless a window had also been dragged.
+ #[test]
+ fn a_flush_writes_whatever_was_restored_and_the_debounce_writes_only_lua_moves() {
+ // The flush: restored is the whole condition.
+ assert!(owes_write(true, true, false, false));
+ assert!(owes_write(true, true, true, true));
+ assert!(
+ !owes_write(true, false, true, true),
+ "a VM that never read the file has nothing of the player's to write"
+ );
+ // The debounce: dirty AND quiet, never on host-only state.
+ assert!(owes_write(false, true, true, true));
+ assert!(!owes_write(false, true, true, false), "still being dragged");
+ assert!(!owes_write(false, true, false, true), "nothing Lua moved");
}
/// The header is a comment block and survives the round trip as one — a reader that choked on
diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs
index faca6229..de48bfa2 100644
--- a/crates/benilla-app/src/ui_script/options_tests.rs
+++ b/crates/benilla-app/src/ui_script/options_tests.rs
@@ -4380,39 +4380,87 @@ fn without_a_seated_measurer_the_same_fit_reads_zero() {
// and the reason is the row's whole point — "no key without a reader" (1134 §4) is why the CVar is
// absent, and this is where that decision is written down per setting.
const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
- ("autointeract", "click-to-move"),
- ("assistAttack", "assist-attack"),
- ("UnitNamePlayerGuild", "guild names over player nameplates"),
+ // **A blocker is now a SPEC, not a label.** Every row below has had its mechanism derived at
+ // the bytes since 2115 wrote this list, so what is missing is the *feature*, never the
+ // knowledge — and saying which is the difference between a row somebody can pick up and a row
+ // that just reads "not done".
+ (
+ "autointeract",
+ "click-to-move — the one row here that is a whole movement mode rather than a knob. \
+ `CanAutoInteract 0x60f900` gates the world-click pick mask's bit 0 and inverts \
+ `0x5ec110`'s interaction-distance refusal, so an out-of-range corpse/GO/NPC/cast click \
+ queues an approach (`0x60fed0`, move kinds 6/7/9/0xa) instead of refusing. The commit \
+ itself puts NOTHING on the wire — it stamps a local goal at `0x611130` — and the packets \
+ are its consequences (a stand, then the original verb on arrival). benilla has only the \
+ keyboard controller and /follow (mode 3). Registered default is \"0\" on every locale but \
+ koKR (`0x603374` selects on the locale index), so stock West ships it OFF",
+ ),
+ (
+ "UnitNamePlayerGuild",
+ "the `\\n<%s>` guild line in the OVERHEAD name stack — slot a5 of `0x608f50`, gated by bit \
+ `0x10` of the render mask `[0xce8720]`, player branch only, text from `0x5e09f0` through \
+ the WGLD guild-identity cache. NOT the V-key nameplate (`ShouldShowName 0x6070a0` \
+ returns 0 whenever a plate frame is live) and NOT the tooltip (FrameXML's own \
+ `GetGuildInfo`). The data resolves here now — `ui_guild::GuildState` serves \
+ `GetGuildInfo` — so what is missing is the third line in `nameplates::drive_nameplates` \
+ plus its `lines_current` comparator arm and that comparator's differential test",
+ ),
(
"UnitNamePlayerPVPTitle",
- "PvP titles over player nameplates",
+ "the PvP rank prefix on the overhead name line — slot a4 of `0x608f50`, bit `0x20` of the \
+ same mask, resolved by `0x609370` through the `PVP_RANK_%d_%d` GlobalStrings key. Blocked \
+ one step further back than its guild twin: the rank byte streams, but the key's second \
+ index is a FACTION SIDE that `ui_unit` does not resolve for an arbitrary player yet",
),
- ("cameraTerrainTilt", "camera terrain tilt"),
- ("cameraBobbing", "camera head bob"),
- ("cameraWaterCollision", "camera water collision"),
- ("cameraPivot", "the smart-pivot camera"),
(
- "CombatDamage",
- "the floating-combat-text master gate is `combat_text::law::COMBAT_DAMAGE`, a const bool \
- — the same shape ChatBubbles had before 1139: a faithful gate with no way to reach it",
+ "cameraTerrainTilt",
+ "the camera's ground-pitch channel. VERIFIED end to end by the §5 round this work \
+ dispatched (wow-re `ui/scratch/camera-cvar-gates.md`): the CVar gates predicate \
+ `0x5105a0`, whose false leg zeroes `[cam+0xa0]` at `0x50d922` and skips both `0x672170` \
+ terrain raycasts; `0x50d900` forms `slope = (Δz + 5/3)/√(Δx²+Δy²)`, walks the 10-entry \
+ slope→pitch table at `0x808a40`, clamps ±20°, throttled to 100 ms. Registered \"0\", so \
+ building it changes nothing until a player asks",
),
- ("CombatLogPeriodicSpells", "a combat log"),
(
- "PetMeleeDamage",
- "`combat_text::law::PET_MELEE_DAMAGE`, a const bool — see CombatDamage",
+ "cameraBobbing",
+ "head bob. VERIFIED by the same §5 round, including the threshold nobody had read: \
+ `[0x8089ac] = 1/6` is a camera DISTANCE and the compare is inclusive, so head bob is \
+ FIRST-PERSON ONLY. `0x511920` computes `ampH/ampV` from the two amplitude CVars × 1/36, \
+ `speed = clamp(moveSpeed/7.2, [0.5,1.5])`, horizontal at `freq` and vertical at `2×freq`, \
+ into the same eye-translation accumulator as the shake; `0x5106f0` is the DECAY \
+ predicate and fades the residual rather than snapping it. Registered \"0\"",
),
- // …and the SLIDERS' half, which this census was blind to until 2115 extended it from
- // `UIOptionsFrameCheckButtons` to `UIOptionsFrameSliders` as well. One row, and it is not
- // cosmetic: `UIOptionsFrame_Load` does `slider:SetValue(GetCVar(value.cvar))`, and
- // `Slider:SetValue` is a shape-A binding (`0x790980`, wow-re `numeric-arg-coercion-law.md`)
- // that RAISES on a nil in the reference too — so this is what stops the stock window's
- // `_Load()` running clean, and pfUI's GVAR path with it.
(
- "cameraYawMoveSpeed",
- "the Mouse Look Speed slider — benilla's mouse-look yaw rate is not a CVar-driven knob \
- yet; its sibling `cameraYawSmoothSpeed` (auto-follow) is registered and drives \
- `follow.yaw_speed`, and this one has no counterpart in `player::camera`",
+ "cameraWaterCollision",
+ "two consumers, both VERIFIED by the §5 round. (1) the collision sweep's class mask \
+ becomes `0x1f0171` instead of `0x100171` — the `0xf0000` nibble is the ADT liquid layers \
+ — and `0x511ad0`'s bands re-base the pivot floor/cap. (2) a band CROSSING (not a level \
+ test: `0x50eb14` snapshots the previous frame's bits before `0x511ad0` rewrites them) \
+ arms a 5° pitch through the `+0xf4` channel. Registered \"1\", so this one is ON in the \
+ reference and benilla is the divergence — but the eye/arm verdict of \
+ `camera-arm-liquid-blind.md` still stands: it is the PIVOT that is liquid-aware",
),
+ (
+ "cameraPivot",
+ "smart pivot. VERIFIED by the §5 round, which also corrected the ±89° claim: predicate \
+ `0x510690` is `enabled ∧ TYPEMASK_UNIT ∧ not moving/strafing (`&0xf`, so turning in place \
+ passes) ∧ pitched down ∧ the collision solver's own clip flags `0x30000` nonzero` — i.e. \
+ it only engages in a frame the camera was ACTUALLY clipped. A mostly-vertical drag then \
+ routes into the pitch-BIAS channel `cam+0x104` instead of the pitch integrator, with a \
+ one-sided floor, and `0x5107f0` eases it back at `cameraTargetSmoothSpeed` when the \
+ predicate drops. Registered \"1\"",
+ ),
+ // **The SLIDERS' half is empty, and that is the point.** 2115 extended this census from
+ // `UIOptionsFrameCheckButtons` to `UIOptionsFrameSliders` and it caught exactly one row —
+ // `cameraYawMoveSpeed`, the MOUSE_LOOK_SPEED slider — which was not cosmetic:
+ // `UIOptionsFrame_Load` does `slider:SetValue(GetCVar(value.cvar))`, `Slider:SetValue` is a
+ // shape-A binding (`0x790980`) that RAISES on a nil in the reference too, and that raise is
+ // what stopped the stock window's `_Load()` (and `_SetDefaults`, through `GetCVarDefault`).
+ // It is registered now, so all four slider CVars are backed and the window's `_Load()` runs to
+ // completion. Every row left below is a check button, and a check button's
+ // `GetCVar(x) == "1"` is nil-safe — so nothing on this list can raise any more. A new SLIDER
+ // row arriving here is therefore a live raise, not a missing feature, and should be read that
+ // way by whoever adds it.
];
/// **Every CVar the reference's own options table names is registered here, or listed above with
diff --git a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
index c0991bb9..7ce107a9 100644
--- a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
+++ b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
@@ -1651,6 +1651,128 @@ fn the_stock_interface_options_window_loads_hidden_and_ours_is_still_the_players
);
}
+/// **The reference's own Sound Options window is on the manifest, HIDDEN, and the alias is gone**
+/// — 2115's argument applied to the window its §9 handed over.
+///
+/// Four claims, each with its own way to fail:
+///
+/// 1. **`SoundOptionsFrame` is the reference's frame, not an alias onto ours.** This one is not
+/// the interface window's story repeated: our `OptionsFrame.xml` loads *after* the stock file,
+/// so `SoundOptionsFrame = OptionsFrame` would not have sat beside the real frame — it would
+/// have **clobbered** it, and pfUI's `options-sound.lua` would then have stripped textures off
+/// and re-anchored the window the player opens. The check is the reference's own CHILDREN,
+/// which an alias could never grow, plus the identity itself.
+/// 2. **It is never shown** — `hidden="true"` at the stock file's own xml l.18.
+/// 3. **`SoundOptionsFrame_Load()` runs clean**, which is the Sound window's counterpart to the
+/// interface window's `cameraYawMoveSpeed` hole: its `_Load` reaches
+/// `slider:SetValue(value.initialValue)` for four sliders, and all four of their CVars are
+/// registered. The three check-button CVars that were missing are built now too, so the walk
+/// has nothing to trip on.
+/// 4. **Ours is still the player's** — the ESC menu's Options button opens `OptionsFrame`, and
+/// neither stock window.
+#[test]
+fn the_stock_sound_options_window_loads_hidden_and_the_alias_is_gone() {
+ let _data = benilla_formats::wow_data_or_skip!();
+ let mut s = benilla_ui::script::UiScript::new().unwrap();
+ s.set_screen_size(1024.0, 768.0);
+ s.set_unit(
+ "player",
+ Some(benilla_ui::script::UnitState {
+ exists: true,
+ name: Some("Probefive".into()),
+ level: 60,
+ class: Some("Warrior".into()),
+ class_file: Some("WARRIOR".into()),
+ ..Default::default()
+ }),
+ );
+ let failures = super::load_default_ui(&s);
+ assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
+
+ // 1 · the reference's own frame, by the children only it declares — and this list IS the set
+ // pfUI's `skins/blizzard/options-sound.lua` walks (there is deliberately no CheckButton3 in
+ // the stock file; pfUI's own `if btn then` guard handles the hole).
+ for name in [
+ "SoundOptionsFrame",
+ "SoundOptionsFrameHeader",
+ "SoundOptionsFrameOkay",
+ "SoundOptionsFrameCancel",
+ "SoundOptionsFrameDefaults",
+ "SoundOptionsFrameCheckButton1",
+ "SoundOptionsFrameCheckButton2",
+ "SoundOptionsFrameCheckButton4",
+ "SoundOptionsFrameCheckButton8",
+ "SoundOptionsFrameSlider1",
+ "SoundOptionsFrameSlider4",
+ ] {
+ assert!(
+ s.eval::(&format!("return getglobal({name:?}) ~= nil"))
+ .unwrap(),
+ "{name} — pfUI's options-sound skin walks every one of these"
+ );
+ }
+ assert!(
+ s.eval::("return SoundOptionsFrame ~= OptionsFrame")
+ .unwrap(),
+ "the alias is gone — and because OUR file loads later, an alias would have CLOBBERED the \
+ real frame rather than merely shadowed it"
+ );
+ assert!(
+ s.eval::("return SoundOptionsFrameCheckButton3 == nil")
+ .unwrap(),
+ "the stock file declares 1,2,4..8 — a CheckButton3 here means this is not that file"
+ );
+
+ // 2 · …and hidden, by its own file's attribute.
+ assert!(
+ !s.eval::("return SoundOptionsFrame:IsShown()")
+ .unwrap(),
+ "the stock Sound window must never be on screen"
+ );
+
+ // 3 · `_Load` runs clean, and every CVar its two tables name answers.
+ s.run("this = SoundOptionsFrameOkay SoundOptionsFrame_Load()")
+ .expect("_Load runs to completion");
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+ let unbacked: Vec = s
+ .eval(
+ "local out = {} \
+ for _, v in ipairs(SoundOptionsFrameSliders) do \
+ if GetCVar(v.cvar) == nil then table.insert(out, v.cvar) end \
+ end \
+ for _, v in ipairs(SoundOptionsFrameCheckButtons) do \
+ if v.cvar and GetCVar(v.cvar) == nil then table.insert(out, v.cvar) end \
+ end \
+ return out",
+ )
+ .expect("read the Sound window's own two tables");
+ assert!(
+ unbacked.is_empty(),
+ "these Sound-window CVars answer nil: {unbacked:?} — a SLIDER among them is a live raise"
+ );
+
+ // 4 · ours is still the window the ESC menu opens.
+ s.run("GameMenuButtonOptions:Click()").unwrap();
+ assert!(
+ s.eval::("return OptionsFrame:IsShown()").unwrap(),
+ "the player's Options button opens OUR window"
+ );
+ assert!(
+ !s.eval::("return SoundOptionsFrame:IsShown()")
+ .unwrap(),
+ "…and never the stock Sound one"
+ );
+
+ // The alias's two stock consumers still answer correctly WITHOUT it: both read all three
+ // window names unguarded in one `or` chain, and that chain already contains
+ // `OptionsFrame:IsVisible()` — this frame's own name. Ours is open here, so both say yes.
+ assert!(
+ s.eval::("return IsOptionFrameOpen() and true or false")
+ .unwrap(),
+ "UIParent.lua:997 must still see an open options window with the alias retired"
+ );
+}
+
/// **pfUI's `UIOptionsFrame_Save()` path runs clean, and `_Load()` stops at exactly one thing**
/// (decision 2115).
///
@@ -1658,14 +1780,13 @@ fn the_stock_interface_options_window_loads_hidden_and_ours_is_still_the_players
/// `UIOptionsFrame_Load()` … `UIOptionsFrame_Save()`, so both are reached at runtime by a real
/// addon and both had to be more than nil.
///
-/// `_Save` runs clean. `_Load` does not, and the failure is **pinned rather than hidden** because
-/// it names a real gap this record surfaced: `UIOptionsFrameSliders` row 3 is
-/// `cameraYawMoveSpeed`, which `cvars::REGISTERED` does not carry, and `_Load` does
-/// `slider:SetValue(GetCVar(value.cvar))` — `Slider:SetValue` is a shape-A binding (`0x790980`,
-/// wow-re `numeric-arg-coercion-law.md`) that raises on a nil in the reference too. The row is
-/// listed in [`super::options_tests`]'s unbacked census with its blocker; the day something
-/// registers it, THIS assertion goes red and gets deleted, which is the point of writing it as an
-/// expectation rather than an ignore.
+/// **Both run clean now.** 2115 shipped this test asserting that `_Load` *raised* — pinning the
+/// gap rather than hiding it, and saying in as many words that "the day something registers it,
+/// THIS assertion goes red and gets deleted". That day is this change: `UIOptionsFrameSliders`
+/// row 3 is `cameraYawMoveSpeed`, `_Load` does `slider:SetValue(GetCVar(value.cvar))`, and
+/// `Slider:SetValue` is a shape-A binding (`0x790980`, wow-re `numeric-arg-coercion-law.md`) that
+/// raises on a nil in the reference too. All four slider CVars are registered, so the walk reaches
+/// its end — and `_SetDefaults`, which does the same through `GetCVarDefault`, with it.
#[test]
fn the_stock_options_windows_load_and_save_are_reachable_for_addons() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1677,6 +1798,17 @@ fn the_stock_options_windows_load_and_save_are_reachable_for_addons() {
exists: true,
name: Some("Probefive".into()),
level: 60,
+ // **The player has a CLASS, because a real one always does** — and `_Load`'s tail
+ // needs it: `UIOptionsFrame_UpdateDependencies` (lua l.759-763) does
+ // `local temp, class = UnitClass("player"); class = strupper(class)` to decide whether
+ // the combo-point combat-text box applies, and `strupper(nil)` raises. It could not be
+ // reached before, because `_Load` died at slider 3 forty lines earlier — so this is
+ // 2115 §10's lesson a second time, in the same window: a probe VM that runs the stock
+ // interface has to be a VM the stock interface's own assumptions hold in. Warrior
+ // rather than Rogue/Druid so the DISABLE branch runs, which is the one with a call in
+ // it (`OptionsFrame_DisableCheckBox`).
+ class: Some("Warrior".into()),
+ class_file: Some("WARRIOR".into()),
..Default::default()
}),
);
@@ -1688,6 +1820,52 @@ fn the_stock_options_windows_load_and_save_are_reachable_for_addons() {
// `SHOW_PARTY_PETS` arm reaches `RefreshBuffs`, whose first act is `this.hasDispellable = nil`
// (`BuffFrame.lua:266`), so the reference's ambient handler global has to be set. It is, at
// every real call site: the Okay button here, and pfUI's own checkbox on its GVAR path.
+ // **`_Load` first, because that is the window's own order** — the reference loads on show and
+ // saves on Okay, and driving Okay over a window nothing had loaded is what made an earlier cut
+ // of this test read a slider floor back out of the CVar. It also makes the Okay run below
+ // exercise a *populated* window, which is strictly the better coverage.
+ s.run("this = UIOptionsFrameOkay UIOptionsFrame_Load()")
+ .expect("_Load runs to completion — every slider CVar it reads is registered");
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+
+ // The four slider CVars, by the file's own table rather than by a list retyped here: every one
+ // must answer, because a single nil among them puts the raise back.
+ let unbacked: Vec = s
+ .eval(
+ "local out = {} \
+ for _, v in ipairs(UIOptionsFrameSliders) do \
+ if GetCVar(v.cvar) == nil then table.insert(out, v.cvar) end \
+ end \
+ return out",
+ )
+ .expect("read UIOptionsFrameSliders");
+ assert!(
+ unbacked.is_empty(),
+ "these slider CVars answer nil, and `_Load` raises on the first of them: {unbacked:?}"
+ );
+
+ // …and the walk really did reach slider 3 rather than stopping short of it: the Mouse Look
+ // Speed slider is sitting on `cameraYawMoveSpeed`'s registered 180. Slider 3 is the one that
+ // matters — it is the row whose nil raised, forty lines into a walk of 69 check buttons.
+ let slider3: f64 = s
+ .eval("return UIOptionsFrameSlider3:GetValue()")
+ .expect("the Mouse Look Speed slider's value");
+ assert!(
+ (slider3 - 180.0).abs() < 0.001,
+ "slider 3 should carry cameraYawMoveSpeed's registered 180, got {slider3}"
+ );
+
+ // `_SetDefaults` is the same walk through `GetCVarDefault`, and it had the same raise.
+ s.run("this = UIOptionsFrameDefaults UIOptionsFrame_SetDefaults()")
+ .expect("_SetDefaults runs to completion too");
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+
+ // The Okay path, over the window `_Load` just populated. Driven through the reference's OWN
+ // caller — `UIOptionsFrameOkay`'s `` (xml l.1205-1209) — rather than as a bare call,
+ // and that is not ceremony: the `SHOW_PARTY_PETS` arm reaches `RefreshBuffs`, whose first act
+ // is `this.hasDispellable = nil` (`BuffFrame.lua:266`), so the reference's ambient handler
+ // global has to be set. It is, at every real call site: the Okay button here, and pfUI's own
+ // checkbox on its GVAR path.
s.run("UIOptionsFrameOkay:Click()")
.expect("the stock Okay button's own handler");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -1696,17 +1874,18 @@ fn the_stock_options_windows_load_and_save_are_reachable_for_addons() {
"…and the window it hides on the way out was hidden to begin with"
);
- let err = s
- .run("this = UIOptionsFrameOkay UIOptionsFrame_Load()")
- .expect_err("_Load stops at the one unregistered slider CVar — see the doc comment");
- let err = err.to_string();
- assert!(
- err.contains("SetValue"),
- "the ONLY thing that may stop _Load is slider 3's nil CVar; got: {err}"
+ // **`_Save` writes the two move-speed CVars for real now**, which is the other half of the
+ // wiring and was a silent no-op before they were registered: it writes the yaw slider's value
+ // and, beside it, `cameraPitchMoveSpeed = value / 2` (lua l.355-356) — the relation the
+ // reference's own registered defaults confirm, 180 and 90.
+ let (yaw, pitch): (f64, f64) = (
+ s.eval(r#"return tonumber(GetCVar("cameraYawMoveSpeed"))"#)
+ .expect("yaw move speed"),
+ s.eval(r#"return tonumber(GetCVar("cameraPitchMoveSpeed"))"#)
+ .expect("pitch move speed"),
);
assert!(
- s.eval::(r#"return GetCVar("cameraYawMoveSpeed") == nil"#)
- .unwrap(),
- "…and that is the cause: cameraYawMoveSpeed is the unregistered one"
+ (yaw - 180.0).abs() < 0.001 && (pitch - 90.0).abs() < 0.001,
+ "_Save should write the slider's 180 and its half; got yaw={yaw} pitch={pitch}"
);
}
diff --git a/crates/benilla-app/src/ui_script/test_ui.rs b/crates/benilla-app/src/ui_script/test_ui.rs
index ec372007..6d0d3a06 100644
--- a/crates/benilla-app/src/ui_script/test_ui.rs
+++ b/crates/benilla-app/src/ui_script/test_ui.rs
@@ -281,8 +281,10 @@ pub(super) const UIPARENT_STAND_INS: &str = r#"
end
-- The four options/menu windows `IsOptionFrameOpen` (l.997) and `ToggleGameMenu` (l.1467)
-- index unguarded. `IsOptionFrameOpen` is on the path of every window close, so a kit that
- -- loads no options window raised on the first bag click; ours answers all three of the
- -- reference's options windows (1987) and a kit may load none of them.
+ -- loads no options window raised on the first bag click. In the shipped manifest all four
+ -- names are real — `OptionsFrame` is our window's own, `UIOptionsFrame` and
+ -- `SoundOptionsFrame` are the reference's own files loaded hidden — but a KIT is a prefix of
+ -- the manifest and may load none of them, which is what these stand-ins are for.
local function benilla_seat_options()
benilla_seat({ "GameMenuFrame", "OptionsFrame", "UIOptionsFrame", "SoundOptionsFrame" })
if not OptionsFrameCancel then
diff --git a/crates/benilla-app/tests/common/mod.rs b/crates/benilla-app/tests/common/mod.rs
index 378990f6..883ae5f4 100644
--- a/crates/benilla-app/tests/common/mod.rs
+++ b/crates/benilla-app/tests/common/mod.rs
@@ -138,8 +138,10 @@ const UIPARENT_STAND_INS: &str = r#"
end
-- The four options/menu windows `IsOptionFrameOpen` (l.997) and `ToggleGameMenu` (l.1467)
-- index unguarded. `IsOptionFrameOpen` is on the path of every window close, so a kit that
- -- loads no options window raised on the first bag click; ours answers all three of the
- -- reference's options windows (1987) and a kit may load none of them.
+ -- loads no options window raised on the first bag click. In the shipped manifest all four
+ -- names are real — `OptionsFrame` is our window's own, `UIOptionsFrame` and
+ -- `SoundOptionsFrame` are the reference's own files loaded hidden — but a KIT is a prefix of
+ -- the manifest and may load none of them, which is what these stand-ins are for.
local function benilla_seat_options()
benilla_seat({ "GameMenuFrame", "OptionsFrame", "UIOptionsFrame", "SoundOptionsFrame" })
if not OptionsFrameCancel then
diff --git a/crates/benilla-formats/src/chat_channels.rs b/crates/benilla-formats/src/chat_channels.rs
index f7cf3b26..aba7e055 100644
--- a/crates/benilla-formats/src/chat_channels.rs
+++ b/crates/benilla-formats/src/chat_channels.rs
@@ -90,6 +90,15 @@ impl ChatChannelRow {
self.flags & flags::CITY_ONLY != 0
}
+ /// Is this the row a **guild** decides about — `GUILD_REQ` (`0x20000`), `GuildRecruitment`
+ /// alone in the 1.12 table? The client keys three things on it: the cascade's composer
+ /// `0x49f140` finds the row by this bit, and a manual `JoinChannelByName`/`LeaveChannelByName`
+ /// on it forces the auto-join option off (`0x49ed3d`/`0x49ef8f`; wow-re
+ /// `guild-recruitment-mode.md` §3/§5).
+ pub fn is_guild_recruitment(&self) -> bool {
+ self.flags & flags::GUILD_REQ != 0
+ }
+
/// Does this row's `%s` take the shared **city** word instead of the zone's name?
///
/// A different bit from [`Self::is_city_only`] — `CITY_ONLY2` (`0x20`) — and a different test
diff --git a/crates/benilla-ui/src/script/channel.rs b/crates/benilla-ui/src/script/channel.rs
index 175cea04..2f83c68d 100644
--- a/crates/benilla-ui/src/script/channel.rs
+++ b/crates/benilla-ui/src/script/channel.rs
@@ -152,6 +152,25 @@ impl super::UiScript {
pub fn take_guild_recruitment_change(&mut self) -> bool {
std::mem::take(&mut self.model_mut().guild_recruitment_changed)
}
+
+ /// Has Lua called `SetGuildRecruitmentMode(1)` since the last drain? The cascade's cue
+ /// (`0x49ea70` → `0x49ea90`; decision 2144).
+ pub fn take_guild_recruitment_cascade(&mut self) -> bool {
+ std::mem::take(&mut self.model_mut().guild_recruitment_cascade)
+ }
+
+ /// **A manual join or leave of `GuildRecruitment` forces the latch to 0** — the reference's
+ /// `0x49ed3d`/`0x49ef8f`, `call 0x49ea70(0)`: a player gesture, so unlike the host seat it
+ /// **does** arm the save. No cascade (mode 0 is the latch alone). Answers whether it moved.
+ pub fn reset_guild_recruitment_mode(&mut self) -> bool {
+ let mut model = self.model_mut();
+ if model.guild_recruitment_mode == 0 {
+ return false;
+ }
+ model.guild_recruitment_mode = 0;
+ model.guild_recruitment_changed = true;
+ true
+ }
}
fn push(lua: &Lua, cmd: ChannelCommand) {
@@ -219,36 +238,25 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(f64::from(model.guild_recruitment_mode))
})?,
)?;
- // ── NAMED, NOT BUILT: the `ecx == 1` cascade (decision 2115) ───────────────────────
+ // ── The `ecx == 1` cascade (decision 2144) ──────────────────────────────────────────
// `0x49ea70` writes the latch and then tail-jumps into `0x49ea90` **only when the new
- // mode is 1**, and `0x49ea90` is not bookkeeping — it acts, on the wire:
- //
- // * player IS guilded (`PLAYER_GUILDID`, player-block index 3) → `0x49eb17` leaves
- // `GuildRecruitment - City`: `CMSG_LEAVE_CHANNEL` (`push 0x98`), the channel
- // stripped from all ten chat windows, the ZONECHANNELS bit cleared;
- // * player is NOT guilded and IS in a capital (`AreaTable Flags & 0x100`) →
- // `0x49eb55` joins `GuildRecruitment`: `CMSG_JOIN_CHANNEL` (`push 0x97`);
- // * otherwise — no resolvable zone row, or unguilded outside a capital — it merely
- // arms the one-shot flag `[0xb6e5e4]`, which the next autojoin pass consumes.
- //
- // Both acting arms fire `UPDATE_CHAT_WINDOWS`, and `0x4a0060` fires it again, so an
- // acting `Set(1)` fires it twice. `Set(0)` is the latch alone — `0x49ea70`'s `jne`
+ // mode is 1**, and `0x49ea90` is not bookkeeping — it acts, on the wire: a guilded
+ // player leaves `GuildRecruitment - City`, an unguilded one in a capital joins it, and
+ // otherwise the one-shot `[0xb6e5e4]` is armed for the next autojoin pass. The whole of
+ // it lives on the app side (`ui_chat::recruitment`), where the player's guild id, the
+ // zone row and the wire are; this binding only raises the ask
+ // (`take_guild_recruitment_cascade`). `Set(0)` is the latch alone — `0x49ea70`'s `jne`
// stops at `0x49ea80`, no packet.
//
- // **benilla does none of that**, and the reason is that it is a FEATURE this client
- // has never had rather than a line of this verb: the zone-channel walk
- // (`ui_chat::channels`) deliberately never joins GuildRecruitment (its row carries no
- // INITIAL bit), so there is no guild-membership-driven join/leave to hang off. Building
- // it means the guilded/unguilded gate, the capital-city gate and two channel commands,
- // and it changes what the client puts on the wire when a player joins or leaves a
- // guild — its own change, with its own record. The latch here is real, persists, and
- // is what `GetGuildRecruitmentMode` answers; what it does not yet do is act.
+ // The verb itself fires `UPDATE_CHAT_WINDOWS` once (`0x4a00a9`), unconditionally; the
+ // cascade's two acting arms fire it again, so an acting `Set(1)` fires it twice.
//
- // The same note's other half, also not built: `JoinChannelByName("GuildRecruitment")`
- // (`0x49ed3d`) and `LeaveChannelByName` (`0x49ef8f`) each force the latch back to 0 —
- // the join's reset is conditional on the caller's `flag` argument, which the Lua
- // binding passes as 1 and `0x49ea90`'s own internal join passes as 0 (so the cascade
- // does not undo the mode it is acting on).
+ // The mirror: `JoinChannelByName("GuildRecruitment")` (`0x49ed3d`) and
+ // `LeaveChannelByName` (`0x49ef8f`) each force the latch back to 0 — the join's reset is
+ // conditional on the caller's `flag` argument, which the Lua binding passes as 1 and
+ // `0x49ea90`'s own internal join passes as 0 (so the cascade does not undo the mode it
+ // is acting on). That is `reset_guild_recruitment_mode`, called from the app's drain of
+ // both verbs.
g.set(
"SetGuildRecruitmentMode",
// `[0x4a0060, 0x4a00c4)`. One argument at stack index 1, and **two ways to raise** — this
@@ -290,6 +298,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
model.guild_recruitment_mode = mode;
model.guild_recruitment_changed = true;
}
+ // `0x49ea79 jne` / `0x49ea7b jmp 0x49ea90`: the cascade, on the new value alone.
+ if mode == 1 {
+ model.guild_recruitment_cascade = true;
+ }
+ // `0x4a00a4`/`0x4a00a9`: `UPDATE_CHAT_WINDOWS`, on every successful call.
+ model
+ .pending_events
+ .push(("UPDATE_CHAT_WINDOWS".to_string(), Vec::new()));
Ok(())
})?,
)?;
@@ -405,13 +421,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// a space in the name, or a matched row → nil — no send
// whose zone substitution is empty
//
- // `CMSG_JOIN_CHANNEL` goes out on both non-nil legs. The third argument is the window the
- // stock handler wants the channel in; that bookkeeping is `ChatFrame_AddChannel`'s (Lua), so
- // the verb ignores it — exactly as the reference does.
+ // `CMSG_JOIN_CHANNEL` goes out on both non-nil legs. **The third argument registers the
+ // channel in that window's own list** (contract §5, `0x49ec24`–`0x49ede7`, VERIFIED):
+ // `frameId` is `lua_tonumber`d, truncated and decremented, and anything outside `0..10`
+ // after that — a missing or non-numeric argument yields `-1` — silently skips the
+ // registration; otherwise `(Shortcut, ChannelID)` for a matched row and `(name, 0)` for a
+ // custom one is appended to window `frameId - 1`'s parallel arrays, deduplicated by name
+ // (`SStrCmpI`), on both sending legs. It is what puts a `/join`ed channel back into the
+ // window after a `/leave` stripped it — the window's `ZONECHANNELS` word is the character's
+ // registration for that channel's lines, and without this a leave-then-join-then-relog left
+ // General joined and its every line dropped (decision 2144, live run F).
g.set(
"JoinChannelByName",
lua.create_function(
- |lua, (name, password, _frame): (Option, Option, Value)| {
+ |lua, (name, password, frame): (Option, Option, Value)| {
let Some(name) = non_empty(name) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
@@ -426,13 +449,32 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.find(|r| r.shortcut.eq_ignore_ascii_case(&name))
.cloned()
};
- let (id, resolved) = match row {
- None => (0, None),
+ let (id, resolved, listed_as) = match row {
+ None => (0, None, name.clone()),
Some(ZoneChannelRow { resolved: None, .. }) => {
return Ok(MultiValue::from_vec(vec![Value::Nil]))
}
- Some(ZoneChannelRow { id, resolved, .. }) => (id, resolved),
+ Some(ZoneChannelRow {
+ id,
+ resolved,
+ shortcut,
+ ..
+ }) => (id, resolved, shortcut),
};
+ // `0x49ff6b edi = -1` / `0x49ff8a dec edi`; `0x49ec27 cmp esi,0xa; jae` skips.
+ let window = match frame {
+ Value::Integer(n) => Some(n as f64),
+ Value::Number(n) => Some(n),
+ _ => None,
+ }
+ .map(|n| n.trunc() as i64 - 1)
+ .and_then(|i| usize::try_from(i).ok())
+ .filter(|i| *i < 10);
+ if let Some(i) = window {
+ lua.app_data_mut::()
+ .expect("model app_data")
+ .register_window_channel(i, listed_as, id);
+ }
push(
lua,
ChannelCommand::Join {
@@ -467,13 +509,72 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
+ // LeaveChannelByName(name) — `0x4a0000` → `0x49ee70`, the contract wow-re
+ // `leavechannelbyname-contract.md` §11 carved (decision 2144). The only stock caller is
+ // `SlashCmdList["LEAVE"]`, which passes the first whitespace token of the slash text.
+ //
+ // 1. a string or number, else it RAISES `Usage: LeaveChannelByName("name")`; 0 returns.
+ // 2. `SStrToInt(name) != 0` → the key names joined slot n — resolved on the app side, which
+ // holds the slot states (a suspended or absent slot is a complete no-op there); no
+ // window strip, because a slot name never equals a window entry.
+ // 3. else a case-insensitive Shortcut match → the composed name for this zone, or a
+ // complete no-op with no zone text; the window strip is keyed on the DBC's Shortcut.
+ // 4. else a custom channel: the argument verbatim, stripped verbatim.
+ //
+ // **One deliberate divergence, recorded in 2144.** The reference composes the shortcut leg
+ // with `GetRealZoneText` ALONE (`0x49efe8`), never the `"City"` row — so in a capital
+ // `/leave Trade` sends `"Trade - Stormwind City"`, a channel the player is not in, and
+ // leaves nothing. Ours composes with the join's own city-aware substitution (the catalog's
+ // `resolved`), so `/leave Trade` leaves `Trade - City`. The mechanism, done right, not the
+ // reference's broken form of it.
+ //
+ // Nothing local is freed here and no event fires (§6, §10): the slot goes when the server's
+ // `YOU_LEFT` matches its stored name, and the mask bit with it — both the app's.
+ g.set(
+ "LeaveChannelByName",
+ lua.create_function(|lua, name: Option| {
+ let key = match name {
+ Some(Value::String(s)) => s.to_str().map(|s| s.to_string())?,
+ Some(Value::Integer(i)) => i.to_string(),
+ Some(Value::Number(n)) => format!("{n:.14}")
+ .trim_end_matches('0')
+ .trim_end_matches('.')
+ .to_string(),
+ _ => return Err(mlua::Error::runtime(r#"Usage: LeaveChannelByName("name")"#)),
+ };
+ if super::chat_window::leading_int(&key) != 0 {
+ push(lua, ChannelCommand::Leave { name: key });
+ return Ok(());
+ }
+ let mut model = lua.app_data_mut::().expect("model app_data");
+ let row = model
+ .zone_channel_catalog
+ .iter()
+ .find(|r| r.shortcut.eq_ignore_ascii_case(&key))
+ .cloned();
+ let (wire, strip_key) = match row {
+ Some(ZoneChannelRow { resolved: None, .. }) => return Ok(()),
+ Some(ZoneChannelRow {
+ resolved: Some(r),
+ shortcut,
+ ..
+ }) => (r, shortcut),
+ None => (key.clone(), key),
+ };
+ model.strip_window_channel(&strip_key);
+ model
+ .channel_commands
+ .push(ChannelCommand::Leave { name: wire });
+ Ok(())
+ })?,
+ )?;
+
// The one-name verbs: each queues its `CMSG_*` and returns nothing.
for (verb, make) in [
(
- "LeaveChannelByName",
- (|name| ChannelCommand::Leave { name }) as fn(String) -> ChannelCommand,
+ "ListChannelByName",
+ (|name| ChannelCommand::List { name }) as fn(String) -> ChannelCommand,
),
- ("ListChannelByName", |name| ChannelCommand::List { name }),
("DisplayChannelOwner", |name| ChannelCommand::DisplayOwner {
name,
}),
@@ -693,4 +794,123 @@ mod command_tests {
]
);
}
+
+ /// **`JoinChannelByName`'s third argument registers the channel in that window** (contract §5,
+ /// decision 2144): `frameId - 1` indexes the ten window records, `(Shortcut, ChannelID)` for a
+ /// DBC row and `(name, 0)` for a custom channel, deduplicated by name; a missing, non-numeric or
+ /// out-of-range frame skips the registration and nothing else. It is what puts a `/join`ed
+ /// channel back into a window a `/leave` had stripped it from.
+ #[test]
+ fn join_channel_by_name_registers_the_channel_in_the_named_window() {
+ let mut s = UiScript::new().unwrap();
+ s.set_zone_channel_catalog(catalog());
+ s.run(
+ "JoinChannelByName('General', nil, 1) \
+ JoinChannelByName('general', nil, 1) \
+ JoinChannelByName('MyChan', nil, 2) \
+ JoinChannelByName('Trade', nil, nil) \
+ JoinChannelByName('LocalDefense', nil, 11) \
+ JoinChannelByName('WorldDefense', nil, 0)",
+ )
+ .unwrap();
+ let looks = s.chat_window_looks();
+ assert_eq!(
+ looks[0].channels,
+ vec![("General".to_string(), 1)],
+ "the DBC Shortcut and its id, once — the second join deduplicated by name"
+ );
+ assert_eq!(
+ looks[1].channels,
+ vec![("MyChan".to_string(), 0)],
+ "a custom channel: the name and id 0"
+ );
+ assert!(
+ looks[2..].iter().all(|l| l.channels.is_empty()),
+ "nil, 11 and 0 register nowhere"
+ );
+ assert_eq!(
+ s.take_chat_window_changes(),
+ vec![0, 1],
+ "…and both windows are owed a save"
+ );
+ // The joins themselves went out regardless of the frame argument — five, because this
+ // catalog's Trade is unresolvable (the nil leg, asserted above), and that leg registers
+ // nothing either.
+ assert_eq!(s.take_channel_commands().len(), 5);
+ }
+
+ /// `LeaveChannelByName`'s legs (contract §11; decision 2144): a number passes through for the
+ /// app's slot lookup and strips nothing; a shortcut composes for the zone and strips the
+ /// window entry it was registered under, in every window; an unresolvable shortcut is a
+ /// complete no-op; a custom name goes verbatim and strips verbatim. Nil raises. Zero returns.
+ #[test]
+ fn leave_channel_by_name_composes_strips_and_raises_the_way_the_reference_does() {
+ let mut s = UiScript::new().unwrap();
+ s.set_zone_channel_catalog(catalog());
+ s.run(
+ "JoinChannelByName('General', nil, 1) JoinChannelByName('MyChan', nil, 1) \
+ JoinChannelByName('General', nil, 2)",
+ )
+ .unwrap();
+ s.take_channel_commands();
+ s.take_chat_window_changes();
+
+ s.run("LeaveChannelByName('2')").unwrap();
+ assert_eq!(
+ s.take_channel_commands(),
+ vec![ChannelCommand::Leave { name: "2".into() }],
+ "a number goes through as typed — the app holds the slot states"
+ );
+ assert!(
+ s.take_chat_window_changes().is_empty(),
+ "…and strips no window: a slot name never equals a window entry"
+ );
+
+ s.run("LeaveChannelByName('general')").unwrap();
+ assert_eq!(
+ s.take_channel_commands(),
+ vec![ChannelCommand::Leave {
+ name: "General - Elwynn Forest".into()
+ }],
+ "a shortcut composes for the zone"
+ );
+ let looks = s.chat_window_looks();
+ assert_eq!(
+ looks[0].channels,
+ vec![("MyChan".to_string(), 0)],
+ "General stripped from window 1, MyChan kept"
+ );
+ assert!(
+ looks[1].channels.is_empty(),
+ "…and from window 2 — all ten are walked"
+ );
+ assert_eq!(s.take_chat_window_changes(), vec![0, 1]);
+
+ s.run("LeaveChannelByName('Trade')").unwrap();
+ assert!(
+ s.take_channel_commands().is_empty(),
+ "unresolvable here (no city word in this catalog): a complete no-op"
+ );
+
+ s.run("LeaveChannelByName('mychan')").unwrap();
+ assert_eq!(
+ s.take_channel_commands(),
+ vec![ChannelCommand::Leave {
+ name: "mychan".into()
+ }],
+ "custom: verbatim"
+ );
+ assert!(
+ s.chat_window_looks()[0].channels.is_empty(),
+ "…and stripped verbatim, case-folded"
+ );
+
+ let e = s.run("LeaveChannelByName()").unwrap_err();
+ assert!(e.to_string().contains("Usage: LeaveChannelByName"), "{e}");
+ assert_eq!(
+ s.eval::("return select('#', LeaveChannelByName('x'))")
+ .unwrap(),
+ 0
+ );
+ }
}
diff --git a/crates/benilla-ui/src/script/chat_window.rs b/crates/benilla-ui/src/script/chat_window.rs
index dd0aec87..56846666 100644
--- a/crates/benilla-ui/src/script/chat_window.rs
+++ b/crates/benilla-ui/src/script/chat_window.rs
@@ -353,7 +353,7 @@ impl ChatWindowLook {
/// (see the module docs' divergence note). `NaN` lands on `0` rather than propagating.
/// `SStrToInt` — the leading decimal integer of a string (an optional sign, then digits), 0 when
/// there is none.
-fn leading_int(s: &str) -> i64 {
+pub(super) fn leading_int(s: &str) -> i64 {
let s = s.trim_start();
let (neg, digits) = match s.strip_prefix('-') {
Some(rest) => (true, rest),
@@ -398,7 +398,63 @@ fn window_index(id: i64) -> mlua::Result {
Ok(id as usize - 1)
}
+impl super::Model {
+ /// **Register a channel in window `window`'s list** — the shared tail of `JoinChannelByName`
+ /// (`0x49ec24`–`0x49ede7`) and `AddChatWindowChannel` (`0x4a1000`): `(name, id)` appended
+ /// unless an entry of that name is already there (`SStrCmpI`), and the window marked for the
+ /// persist pass. `name` is the DBC's own Shortcut for a matched row, the argument verbatim for
+ /// a custom channel. Answers whether anything was added.
+ pub(super) fn register_window_channel(&mut self, window: usize, name: String, id: u32) -> bool {
+ let Some(look) = self.chat_window_looks.get_mut(window) else {
+ return false;
+ };
+ if look
+ .channels
+ .iter()
+ .any(|(c, _)| c.eq_ignore_ascii_case(&name))
+ {
+ return false;
+ }
+ look.channels.push((name, id));
+ self.chat_window_changes.insert(window);
+ true
+ }
+
+ /// **Strip `key` from every window's list** — leave-by-name `0x49ee70`'s `0x49f001`–`0x49f085`
+ /// (wow-re `leavechannelbyname-contract.md` §6): all ten windows, the first entry whose name
+ /// equals `key` case-insensitively, the cell blanked. `key` is the DBC Shortcut when the
+ /// argument matched a row, else the argument verbatim; the numeric form strips nothing,
+ /// because a slot name never equals a window entry. Answers whether anything was stripped.
+ pub(super) fn strip_window_channel(&mut self, key: &str) -> bool {
+ let mut stripped = false;
+ for (i, look) in self.chat_window_looks.iter_mut().enumerate() {
+ if let Some(at) = look
+ .channels
+ .iter()
+ .position(|(c, _)| c.eq_ignore_ascii_case(key))
+ {
+ look.channels.remove(at);
+ self.chat_window_changes.insert(i);
+ stripped = true;
+ }
+ }
+ stripped
+ }
+}
+
impl super::UiScript {
+ /// Host-side [`Model::register_window_channel`] — the guild-recruitment cascade's join arm,
+ /// which registers `GuildRecruitment` in window 1 the way `0x49eb70(frameIdx = 0)` does.
+ pub fn register_chat_window_channel(&mut self, window: usize, name: &str, id: u32) -> bool {
+ self.model_mut()
+ .register_window_channel(window, name.to_string(), id)
+ }
+
+ /// Host-side [`Model::strip_window_channel`] — the cascade's leave arm.
+ pub fn strip_chat_window_channel(&mut self, key: &str) -> bool {
+ self.model_mut().strip_window_channel(key)
+ }
+
/// Seed the per-window looks from the host's persisted store — the load path, so it queues no
/// change (an echo would re-dirty the file it was just read from; [`Self::set_cvar_host`]'s
/// reason, one store over).
diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs
index 90ac8f14..bc027f82 100644
--- a/crates/benilla-ui/src/script/model.rs
+++ b/crates/benilla-ui/src/script/model.rs
@@ -628,6 +628,10 @@ pub(crate) struct Model {
/// the reference client itself wrote in this repo's install says `AUTO`, on characters that
/// never opened the option.
pub(crate) guild_recruitment_mode: u8,
+ /// A `SetGuildRecruitmentMode(1)` since the last drain — `0x49ea70`'s tail-jump into the
+ /// cascade `0x49ea90`, which the app runs (decision 2144). Keyed on the *new value alone*, not
+ /// on a change: the reference's store is unconditional and the jump reads only `ecx == 1`.
+ pub(crate) guild_recruitment_cascade: bool,
/// Whether Lua has moved [`Self::guild_recruitment_mode`] since the last drain — the chat
/// cache's dirty signal, the peer of `chat_window_changes`.
pub(crate) guild_recruitment_changed: bool,
@@ -1896,6 +1900,7 @@ impl Model {
zone_channel_catalog: Vec::new(),
channel_commands: Vec::new(),
guild_recruitment_mode: 1,
+ guild_recruitment_cascade: false,
guild_recruitment_changed: false,
emote_requests: Vec::new(),
roll_requests: Vec::new(),
diff --git a/crates/benilla-ui/src/script/stdlib.rs b/crates/benilla-ui/src/script/stdlib.rs
index de9b79a4..ded7b9c2 100644
--- a/crates/benilla-ui/src/script/stdlib.rs
+++ b/crates/benilla-ui/src/script/stdlib.rs
@@ -7,8 +7,8 @@
//! not anti-automation, but running untrusted addon Lua still means no filesystem/OS/native reach.
//! - **Stdlib** ([`install`]): the nine bare-global aliases probe A found in the real corpus
//! (`format`/`strlen`/`gsub`/`strsub`/`strupper`/`tinsert`/`getn`/`tremove`/`strfind`), plus
-//! `strlower`/`strrep`, `getglobal`/`setglobal`, the `strsplit`/`strjoin`/`strtrim`/`strconcat`
-//! family, `wipe`, `tostringall`, and — the one non-trivial piece — a `string.format` replacement
+//! `strlower`/`strrep`, `getglobal`/`setglobal`, and — the one non-trivial piece — a
+//! `string.format` replacement
//! that supports Blizzard's positional `%N$` extension (probe A confirmed stock 5.1 rejects it).
//!
//! ## The positional `format` (`%N$`)
@@ -157,7 +157,7 @@ pub(super) fn sandbox(lua: &Lua) -> mlua::Result<()> {
Ok(())
}
-/// Install the WoW stdlib layer (aliases, `strsplit` family, positional `format`, …).
+/// Install the WoW stdlib layer (the bare-global aliases, positional `format`, …).
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// The default `geterrorhandler()` reports into the same channel a failed handler uses, so an
// addon's `geterrorhandler()(msg)` surfaces where every other script error does rather than
@@ -193,7 +193,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
}
/// The stdlib layer, in Lua. One place, so each alias is the one-liner the task calls for and the
-/// `format`/`strsplit` logic is readable. Runs once at construction.
+/// `format` logic is readable. Runs once at construction.
const WOW_STDLIB: &str = r#"
-- ── the bare string family ─────────────────────────────────────────────────────────────────────
-- Every name here is present in the REAL 1.12 client's global table — the in-world `_G` captured
@@ -359,61 +359,14 @@ __benilla_game_hour = 0
__benilla_game_minute = 0
function GetGameTime() return __benilla_game_hour, __benilla_game_minute end
--- ── wipe / tostringall ─────────────────────────────────────────────────────────────────────────
-function wipe(t)
- for k in pairs(t) do t[k] = nil end
- return t
-end
-
--- 5.0 spelling throughout this file: a vararg function reads `arg`/`arg.n`, never `...` as a
--- value, because 1.12's Lua has no grammar for that (decision 2101) and neither does ours.
-function tostringall(...)
- local n = arg.n
- local t = {}
- for i = 1, n do t[i] = tostring(arg[i]) end
- return unpack(t, 1, n)
-end
-
--- ── the strsplit / strjoin / strtrim / strconcat family ────────────────────────────────────────
--- strsplit(delimiters, str [, limit]): each char of `delimiters` is a single-char delimiter;
--- returns the pieces as multiple values (empty fields preserved), matching WoW's behavior.
-function strsplit(delim, str, limit)
- local set = "[" .. delim:gsub("(.)", "%%%1") .. "]"
- local result = {}
- local n = 0
- local start = 1
- while true do
- if limit and n >= limit - 1 then
- n = n + 1; result[n] = str:sub(start)
- break
- end
- local s, e = str:find(set, start)
- if not s then
- n = n + 1; result[n] = str:sub(start)
- break
- end
- n = n + 1; result[n] = str:sub(start, s - 1)
- start = e + 1
- end
- return unpack(result, 1, n)
-end
-
--- strjoin(sep, ...): join the varargs with `sep`.
-function strjoin(sep, ...)
- return table.concat(arg, sep, 1, arg.n)
-end
-
--- strconcat(...): concatenate all args (WoW's join with no separator).
-function strconcat(...)
- return table.concat(arg, "", 1, arg.n)
-end
-
--- strtrim(s [, chars]): trim leading/trailing chars (default whitespace).
-function strtrim(s, chars)
- chars = chars or " \t\r\n"
- local set = chars:gsub("(.)", "%%%1")
- return (s:gsub("^[" .. set .. "]*(.-)[" .. set .. "]*$", "%1"))
-end
+-- ── NOT here: wipe / tostringall / strsplit / strjoin / strconcat / strtrim ────────────────────
+-- Six 2.0+ names benilla used to define. 1.12 has none of them (absent from
+-- `reference/1.12-globals.tsv`, from the stock chain, and from every registrar table), and the
+-- reason they were kept — that the vanilla addon ecosystem assumes them — did not survive being
+-- checked: the corpus callers are multi-client addons whose vanilla paths raise on a real 1.12
+-- client too, or that define the name themselves under their own namespace (decision 2146).
+-- An addon reaching for one of these is reaching for a later client's API, and it should find
+-- what it would find there: nothing.
-- ── Blizzard's positional string.format (%N$) ──────────────────────────────────────────────────
do
diff --git a/crates/benilla-ui/src/script/tests/channel.rs b/crates/benilla-ui/src/script/tests/channel.rs
index fe3faac9..262fc801 100644
--- a/crates/benilla-ui/src/script/tests/channel.rs
+++ b/crates/benilla-ui/src/script/tests/channel.rs
@@ -243,3 +243,71 @@ fn the_guild_recruitment_setter_gates_its_argument_the_way_the_reference_does()
"0x4a0060 returns `xor eax,eax` — zero values, not a nil"
);
}
+
+/// **The setter is not inert** (decision 2144). `0x49ea70` stores the latch and tail-jumps into
+/// the cascade `0x49ea90` on the new value alone — `Set(1)` raises the app's cue whether or not
+/// the value moved; `Set(0)` never does — and `0x4a00a4`/`0x4a00a9` fire `UPDATE_CHAT_WINDOWS`
+/// on every successful call, before any cascade fires it again.
+#[test]
+fn the_setter_asks_for_the_cascade_on_one_and_fires_update_chat_windows() {
+ let mut s = script();
+ s.run(
+ r#"
+ n = 0
+ local f = CreateFrame("Frame", "GRF")
+ f:RegisterEvent("UPDATE_CHAT_WINDOWS")
+ f:SetScript("OnEvent", function() n = n + 1 end)
+ "#,
+ )
+ .unwrap();
+ assert!(!s.take_guild_recruitment_cascade(), "nothing asked yet");
+
+ // Boots at 1; a Set(1) that moves nothing still asks — the reference's store is
+ // unconditional and the jump reads only `ecx == 1`.
+ s.run("SetGuildRecruitmentMode(1)").unwrap();
+ assert!(s.take_guild_recruitment_cascade());
+ assert!(!s.take_guild_recruitment_cascade(), "drained");
+ assert!(
+ !s.take_guild_recruitment_change(),
+ "…and the file is not dirtied by a no-move"
+ );
+
+ s.run("SetGuildRecruitmentMode(0)").unwrap();
+ assert!(
+ !s.take_guild_recruitment_cascade(),
+ "mode 0 is the latch alone"
+ );
+ assert!(
+ s.take_guild_recruitment_change(),
+ "the file is dirtied by the move"
+ );
+
+ // A refused call fires nothing.
+ s.run("SetGuildRecruitmentMode(2)").unwrap_err();
+ s.tick(0.016);
+ assert_eq!(
+ s.eval::("return n").unwrap(),
+ 2,
+ "one UPDATE_CHAT_WINDOWS per successful call: Set(1), Set(0); the raise fired none"
+ );
+}
+
+/// **A manual join or leave of `GuildRecruitment` forces the latch to 0** — `0x49ed3d`/
+/// `0x49ef8f`, `call 0x49ea70(0)`. A player gesture, so it dirties the file; and mode 0 is the
+/// latch alone, so it asks for no cascade.
+#[test]
+fn a_manual_guild_recruitment_verb_resets_the_latch() {
+ let mut s = script();
+ assert!(s.reset_guild_recruitment_mode(), "1 → 0 moved");
+ assert_eq!(
+ s.eval::("return GetGuildRecruitmentMode()").unwrap(),
+ 0.0
+ );
+ assert!(s.take_guild_recruitment_change());
+ assert!(!s.take_guild_recruitment_cascade());
+ assert!(
+ !s.reset_guild_recruitment_mode(),
+ "already 0: nothing moved"
+ );
+ assert!(!s.take_guild_recruitment_change());
+}
diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs
index f8752833..9ad85de8 100644
--- a/crates/benilla-ui/src/script/tests/mod.rs
+++ b/crates/benilla-ui/src/script/tests/mod.rs
@@ -1,5 +1,5 @@
//! Rust-driven tests of the Lua host: the object model, layout+size reads, show/hide + event + tick
-//! firing (both RF-0025 conventions), the WoW stdlib (positional `format`, `strsplit`, `wipe`,
+//! firing (both RF-0025 conventions), the WoW stdlib (positional `format`, the aliases,
//! `getglobal`), the sandbox holes, and an end-to-end extract in ZKey order.
//!
//! Split by subject; the shared `script()` fixture lives in [`common`].
diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs
index 3040fc92..533da8cc 100644
--- a/crates/benilla-ui/src/script/tests/reference_surface.rs
+++ b/crates/benilla-ui/src/script/tests/reference_surface.rs
@@ -119,12 +119,6 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
"UnitAura",
"UnitIsAFK",
"UnitIsDND",
- "strconcat",
- "strjoin",
- "strsplit",
- "strtrim",
- "tostringall",
- "wipe",
]
.into_iter()
.collect()
diff --git a/crates/benilla-ui/src/script/tests/stdlib.rs b/crates/benilla-ui/src/script/tests/stdlib.rs
index 9c10bfa3..ccebbd2f 100644
--- a/crates/benilla-ui/src/script/tests/stdlib.rs
+++ b/crates/benilla-ui/src/script/tests/stdlib.rs
@@ -1,4 +1,4 @@
-//! The WoW stdlib: positional `format`, the `getglobal`/`strsplit`/`wipe` alias layer, and the
+//! The WoW stdlib: positional `format`, the `getglobal` alias layer, and the
//! sandbox holes (`loadstring` text-only, dangerous globals removed).
use super::common::script;
@@ -36,7 +36,7 @@ fn positional_format_reorders_and_mix_is_an_error() {
assert!(!mixed_ok, "mixed positional+sequential must error");
}
-// ── getglobal / strsplit / wipe / the alias layer ───────────────────────────────────────────────
+// ── getglobal / the alias layer ───────────────────────────────────────────────
#[test]
fn stdlib_aliases_and_helpers() {
@@ -47,17 +47,6 @@ fn stdlib_aliases_and_helpers() {
local f = CreateFrame("Frame", "GG")
assert(getglobal("GG") == f)
- -- strsplit returns pieces (empty fields preserved)
- local a, b, c = strsplit(",", "x,y,z")
- assert(a == "x" and b == "y" and c == "z")
- local e1, e2 = strsplit(",", ",tail")
- assert(e1 == "" and e2 == "tail")
-
- -- strjoin / strconcat / strtrim
- assert(strjoin("-", "a", "b", "c") == "a-b-c")
- assert(strconcat("a", "b", "c") == "abc")
- assert(strtrim(" hi \t") == "hi")
-
-- the bare-global aliases
assert(strupper("ab") == "AB" and strlower("AB") == "ab")
assert(strsub("hello", 2, 3) == "el")
@@ -68,13 +57,9 @@ fn stdlib_aliases_and_helpers() {
tremove(t, 1)
assert(t[1] == 20)
- -- wipe empties a table in place
- local w = { 1, 2, x = 3 }
- assert(wipe(w) == w and next(w) == nil)
-
- -- tostringall
- local s1, s2 = tostringall(1, true)
- assert(s1 == "1" and s2 == "true")
+ -- and the six 2.0 names that are NOT here (decision 2146)
+ assert(wipe == nil and tostringall == nil)
+ assert(strsplit == nil and strjoin == nil and strconcat == nil and strtrim == nil)
"#,
)
.unwrap();
diff --git a/crates/benilla-ui/src/script/tests/widget_surface.rs b/crates/benilla-ui/src/script/tests/widget_surface.rs
index e20985c3..83daa23f 100644
--- a/crates/benilla-ui/src/script/tests/widget_surface.rs
+++ b/crates/benilla-ui/src/script/tests/widget_surface.rs
@@ -17,6 +17,11 @@
//! measure demand. It measures the SURFACE, and every entry in the two declared lists below is
//! where the reading of the demand got written down.
//!
+//! When re-reading that demand: those corpora hold addons that *run on* 1.12, not addons written
+//! only for it — most of the big ones are multi-client and pick a code path at load, and they come
+//! in several versions of themselves. `scripts/api-coverage.sh`'s header has the detail; decision
+//! 2146 is the case that made it matter.
+//!
//! ## The reference side is a CHAIN, not 23 flat surfaces
//!
//! The reference's 23 widget registrar tables are mutually **disjoint** — a class's real surface is