fourteen settings go live, /leave holds, and /assist can swing

- mouse-look speed is cameraYawMoveSpeed and cameraPitchMoveSpeed, so pfUI's afkcam does what it asks and an addon driving the stock Interface Options window runs its load and Defaults walks to the end; the shipped feel does not move (2147)
- the eight combat-log range sliders are real settings, so BigWigs' Range plugin and DPSMate read and write them, and the Show Pet Melee Damage and periodic-spell settings genuinely silence what they gate (2147)
- the stock Sound Options window loads hidden beside ours for addons to find, and its three check-box settings are real: the listener sits at your character, emote voices can be muted, zone music can loop without its silence gap (2147)
- /assist with assistAttack set selects and starts swinging; /leave General survives a relog, a /join puts the channel back in its chat window's list, and SetGuildRecruitmentMode joins and leaves GuildRecruitment - City (2144)
- wipe, strsplit, strjoin, strtrim, strconcat and tostringall are gone: no 1.12 client has them, and the addons that call them are multi-client builds whose vanilla path raises on the real client too (2146)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-11 20:53:36 +07:00
co-authored by Claude Fable 5.1
parent 31371641eb
commit 26fb54b402
40 changed files with 2912 additions and 631 deletions
+26 -19
View File
@@ -4025,28 +4025,36 @@
</OnHide>
</Scripts>
</Frame>
<!-- ══ THE TWO SURVIVING ALIASES, AND THE ONE THAT HAD TO GO (decision 2115) ══════════════
<!-- ══ THE ONE SURVIVING ALIAS (decisions 2115, and the Sound window's row) ═══════════════
The reference has THREE options windows — `OptionsFrame` (video), `SoundOptionsFrame` and
`UIOptionsFrame` (interface) — and benilla's one window answers to the first name outright,
because that is what this frame is called. What happens to the other two is not one
question but two:
`UIOptionsFrame` (interface). benilla's one window answers to the first name outright,
because that is what this frame is called; the other two are **the reference's own frames,
loaded hidden off the chain**, and neither may ever be aliased onto this one again.
**`UIOptionsFrame` is NO LONGER ALIASED, and must never be.** The reference's own
`UIOptionsFrame.xml` is loaded, hidden, at the manifest's l.21 seat, so that name is a real
frame with real children — which is the whole point of 2115: pfUI's
**`UIOptionsFrame`** is loaded at the manifest's l.21 seat, so that name is a real frame
with real children — the whole point of 2115: pfUI's
`skins/blizzard/options-interface.lua` does `UIOptionsFrame:SetWidth(1024)` and then walks
`UIOptionsFrameCheckButton1..69`. Under the old alias that `SetWidth` landed on THIS window,
the one the player opens. Nothing of ours may `Show()` the stock frame either; it is
`hidden="true"` in its own file and that is what keeps it off the screen.
`UIOptionsFrameCheckButton1..69`. Under the old alias that `SetWidth` landed on THIS
window, the one the player opens.
**`SoundOptionsFrame` stays aliased**, because we do not load the reference's
`SoundOptionsFrame.xml` at all (2115 says why: nothing in the addon corpus needs it beyond
one optional pfUI skin). It answers exactly two stock consumers, both of which read all
three window names UNGUARDED and both of which are asking "is an options window open":
`MainMenuBarMicroButtons.lua` l.47-52 (`UpdateMicroButtons`, deciding whether the Main Menu
micro button is pushed — decision 1987) and `UIParent.lua:996` (`IsOptionFrameOpen`, on the
path of every window close). While ours is up the honest answer to both is yes, and the
alias is how they get it. `OptionsFrame`, being this frame's own name, answers them too.
**`SoundOptionsFrame` is no longer aliased either**, and the reason it had to go is
sharper than the reason it existed. Our file loads AFTER the stock one, so the alias would
not have sat harmlessly beside the real frame — it would have **clobbered it**, and pfUI's
`skins/blizzard/options-sound.lua` would then `StripTextures`, `CreateBackdrop`,
`EnableMovable` and re-anchor THIS window. That is 2115 §2's harm restored, one window
over.
**Losing it costs nothing.** Its two stock consumers —
`MainMenuBarMicroButtons.lua` l.47-52 (`UpdateMicroButtons`) and `UIParent.lua:997`
(`IsOptionFrameOpen`) — read all three names UNGUARDED in one `or` chain, and that chain
already contains `OptionsFrame:IsVisible()`, which is this frame's own name. The
`SoundOptionsFrame` term goes from true-via-alias to false-via-real-hidden-frame, and in
both consumers it was redundant with a term that is already true. So while ours is up both
still answer yes, and while it is down both still answer no. 2115 §1 read that alias as
load-bearing for those two; walking them says it never was.
Nothing of ours may `Show()` either stock frame; both are `hidden="true"` in their own
files and that is what keeps them off the screen.
**`OptionsFrameCancel` stays**, and it is the ESC ladder's: the stock `ToggleGameMenu`
(UIParent.lua l.1465-1497, off the chain since 1988) closes an open options window with
@@ -4062,7 +4070,6 @@
panel that does not. No adapter is added for it (2115).
BENILLA_ALLOW_OWN_UI=1 -->
<Script><![CDATA[
SoundOptionsFrame = OptionsFrame
OptionsFrameCancel = OptionsFrameCloseButton
]]></Script>
</Ui>
+29 -2
View File
@@ -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 `<Include>`s
# `CharacterFrameTemplates.xml`, this file's only dependency line is its own
# `<Script file="SoundOptionsFrame.lua"/>`. 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) ══
+103 -14
View File
@@ -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<Option<u32>> {
if !COMBAT_DAMAGE {
pub(crate) fn damage_color(
gates: DamageTextGates,
source: DamageSource,
melee: bool,
) -> Option<Option<u32>> {
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))
);
}
}
+4 -2
View File
@@ -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<crate::net::SelfPlayer>>,
self_guid: Res<crate::net::SelfGuid>,
stores: Query<&crate::net::ObjectStore>,
gates: Res<DamageTextGates>,
mut text: MessageWriter<CombatTextSpawn>,
) {
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::<WorldTexts>()
.init_resource::<DamageTextGates>()
.add_message::<CombatTextSpawn>()
.add_systems(
Update,
+202 -2
View File
@@ -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,
} = &params;
// 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::<LookConfig>()
.init_resource::<crate::ui_chat::combat::CombatLogRanges>()
.init_resource::<crate::combat_text::DamageTextGates>()
.init_resource::<crate::ui_chat::combat::LogPeriodicSpells>()
.init_resource::<ClickConfig>()
.init_resource::<crate::target::AssistAttack>()
.init_resource::<LootConfig>()
.init_resource::<NameConfig>()
.init_resource::<VPlateMode>()
+21
View File
@@ -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<crate::ui_action::PetTameFailures>,
// 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<crate::ui_items::EquipErrors>,
ResMut<crate::ui_merchant::MerchantErrors>,
@@ -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,
@@ -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 {
+27 -6
View File
@@ -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<CombatTextSpawn>,
feedback: &mut MessageWriter<UnitCombatFeedback>,
center: &mut MessageWriter<CombatTextEvent>,
@@ -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<CombatTextSpawn>,
feedback: &mut MessageWriter<UnitCombatFeedback>,
center: &mut MessageWriter<CombatTextEvent>,
@@ -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<CombatTextSpawn>,
feedback: &mut MessageWriter<UnitCombatFeedback>,
) {
@@ -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<CombatTextSpawn>,
feedback: &mut MessageWriter<UnitCombatFeedback>,
center: &mut MessageWriter<CombatTextEvent>,
@@ -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))
+124 -10
View File
@@ -175,6 +175,49 @@ const LOOK_SENSITIVITY: f32 = 0.003;
/// reproduces the shipped feel exactly.
pub(crate) const MOUSE_SPEED_RANGE: std::ops::RangeInclusive<f32> = 0.5..=1.5;
/// **The 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<f32> = 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<f32> = 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
+7
View File
@@ -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;
};
+62 -6
View File
@@ -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<AudioListener>,
mut out: NonSendMut<SoundOutput>,
config: Res<SoundConfig>,
player: Res<Player>,
cinematic: Option<Res<crate::cinematic::Cinematic>>,
self_av: Query<(&Transform, Option<&CameraPivot>), With<Embodied>>,
@@ -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;
+20 -5
View File
@@ -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<f64> {
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]));
+26 -2
View File
@@ -607,6 +607,8 @@ pub(crate) struct SelectCommit<'w, 's> {
index: Res<'w, GuidIndex>,
factions: Option<Res<'w, super::Factions>>,
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.
+1
View File
@@ -2165,6 +2165,7 @@ mod tests {
world.init_resource::<crate::ui_party::GroupState>();
world.init_resource::<crate::net::GuidIndex>();
world.init_resource::<crate::net::Reputations>();
world.init_resource::<super::AssistAttack>();
world.init_resource::<Selection>();
world.init_resource::<scan::LastEnemy>();
world.init_resource::<Messages<super::by_name::AssistRequest>>();
+19
View File
@@ -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::<Selection>()
.init_resource::<ClickConfig>()
.init_resource::<AssistAttack>()
.init_resource::<Hovered>()
.init_resource::<HoveredObject>()
.init_resource::<PickOcclusion>()
+18
View File
@@ -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::<frames::ChatWindows>()
.init_resource::<edit::ChannelState>()
.init_resource::<channels::ZoneChannelWalk>()
.init_resource::<recruitment::GuildRecruitmentCascade>()
.init_resource::<language::ChatLanguages>()
.init_resource::<combat::CombatLogRanges>()
// `CombatLogPeriodicSpells`' knob, beside its sibling range set — both are
// `KnobParams` members, so a missing one is not a dormant default but a
// STARTUP PANIC in `cvars::load_config` (which takes them all as `ResMut`).
// 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::<combat::LogPeriodicSpells>()
// `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<Res<_>>` `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),
@@ -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![
File diff suppressed because it is too large Load Diff
+160 -16
View File
@@ -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,
@@ -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);
}
+18 -5
View File
@@ -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<Res<'w, Factions>>,
pub reputations: Res<'w, Reputations>,
pub spells: Option<Res<'w, crate::ui_action::Spells>>,
/// 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()
+82 -9
View File
@@ -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<u32>,
}
/// 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<String> {
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::<usize>()
.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)
}
+10
View File
@@ -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
+50 -10
View File
@@ -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<NonSendMut<benilla_ui::script::UiScript>>,
@@ -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 }
}
@@ -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<u32>,
}
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<Cascade> {
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<NonSendMut<UiScript>>,
mut state: ResMut<GuildRecruitmentCascade>,
mut channels: ResMut<ChannelState>,
walk: Res<ZoneChannelWalk>,
areas: Option<Res<AreaTableRes>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
commands: Res<NetCommands>,
) {
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");
}
}
+94 -25
View File
@@ -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::<super::edit::ChannelState>() {
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<NonSendMut<UiScript>>, mut file: ResMut<ChatW
}
fn write(script: &UiScript, channels: &super::edit::ChannelState, path: &std::path::Path) {
// Never compose a file from an unseated mask: `ZONECHANNELS 0` in every block is exactly the
// damage 2120 repaired, and a `None` here means the character's file was never read.
let Some(zone_mask) = channels.zone_mask else {
warn!(
"chat cache: refusing to write {} — the zone mask has not been seated",
path.display()
);
return;
};
let body = render(
&script.chat_window_looks(),
&script.chat_colors(),
&roster(channels),
channels.zone_mask,
zone_mask,
script.guild_recruitment_mode() != 0,
);
if let Err(e) = crate::local_state::write_atomic(path, &body) {
@@ -689,6 +700,49 @@ fn write(script: &UiScript, channels: &super::edit::ChannelState, path: &std::pa
}
}
/// Is a write owed **now**? Two reasons, and they gate differently (decision 2144):
///
/// - **A flush** (`exiting`) — the session end or the window close — writes **unconditionally**,
/// provided this VM restored the character's file (`restored`): the reference rewrites the
/// whole file at chat teardown (`0x499a80` from `0x490c55`) with no dirty flag in the way
/// (`[0xb6e5c4]` has three writers and no reader; wow-re `guild-recruitment-mode.md` §6). The
/// `ZONECHANNELS` word and the guild-recruitment latch are host state that no Lua write ever
/// dirties — gating the flush on `dirty` is how a `/leave General` was persisted only when the
/// player also happened to drag a window, and 2120's "durable state" was durable on paper.
/// - **The debounce** writes only what Lua moved (`dirty`) once the quiet time has passed — our
/// own improvement over the reference's teardown-only write, so a crash loses at most a second
/// of drags; it never fires for host-only changes, which the flush carries.
///
/// `restored` is the guard that keeps a fresh VM — whose look table is back at the stock row —
/// from composing the player's file out of nothing: it is exactly "the file was read into this
/// VM", the `identity` memo.
fn owes_write(exiting: bool, restored: bool, dirty: bool, quiet: bool) -> 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<NonSendMut<UiScript>>,
@@ -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<NonSendMut<UiScript>>,
channels: Res<super::edit::ChannelState>,
mut file: ResMut<ChatWindowFile>,
) {
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
@@ -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
@@ -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::<bool>(&format!("return getglobal({name:?}) ~= nil"))
.unwrap(),
"{name} — pfUI's options-sound skin walks every one of these"
);
}
assert!(
s.eval::<bool>("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::<bool>("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::<bool>("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<String> = 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::<bool>("return OptionsFrame:IsShown()").unwrap(),
"the player's Options button opens OUR window"
);
assert!(
!s.eval::<bool>("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::<bool>("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<String> = 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 `<OnClick>` (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::<bool>(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}"
);
}
+4 -2
View File
@@ -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
+4 -2
View File
@@ -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
@@ -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
+256 -36
View File
@@ -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<String>, Option<String>, Value)| {
|lua, (name, password, frame): (Option<String>, Option<String>, 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::<Model>()
.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<Value>| {
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::<Model>().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::<i64>("return select('#', LeaveChannelByName('x'))")
.unwrap(),
0
);
}
}
+57 -1
View File
@@ -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<usize> {
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).
+5
View File
@@ -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(),
+12 -59
View File
@@ -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
@@ -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::<i64>("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::<f64>("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());
}
+1 -1
View File
@@ -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`].
@@ -119,12 +119,6 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
"UnitAura",
"UnitIsAFK",
"UnitIsDND",
"strconcat",
"strjoin",
"strsplit",
"strtrim",
"tostringall",
"wipe",
]
.into_iter()
.collect()
+5 -20
View File
@@ -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();
@@ -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