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:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user