Logout and the AddOns screen's Okay write only a changed character's own enable rows, so a character with no row keeps following the realm's others (#418) (#490)

A character's AddOns file now holds only the rows the character chose, as in 1.12.1, so a character that never chose an addon keeps following what the realm's other characters agree on (`0x51e470`) instead of freezing its inherited values at the first logout.

- Each character has an enable hash and a dirty flag: the loader `0x51ebe0` fills the hash from the file, clean; the setter `0x51ea20` inserts on a miss and dirties the hash only on a change (`0x51ebab`-`0x51ebbc`); the writer `0x51ef20` writes a dirty character's hash, a line per entry, and nothing else (`0x51ef59`).
- Logout and exit (`0x490c88`) and the character-select AddOns screen's Okay (`SaveAddOns` `0x46d990`) both write through it, so a session that toggles nothing writes nothing, and no `Blizzard_*` rows appear.
- `ResetDisabledAddOns` restores the hash as loaded, clean (`0x48e830`).
- On the AddOns screen a character with no row now follows another character's toggle at once, as `AddonList_Update` re-reads each column after every `EnableAddOn`.

Fixes #418

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-28 10:11:40 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 78862ff291
commit 36b359f007
7 changed files with 494 additions and 202 deletions
+19 -10
View File
@@ -142,9 +142,12 @@ fn row_of(model: &Model, key: &AddonKey) -> Option<usize> {
}
/// The enable setter (`0x51ea20`), the one write every enable verb makes, for the current
/// character's row.
/// character's row: the registry bit the gate reads, and the character's enable hash, which the
/// setter dirties only when the row changes or is new.
fn set_enabled(model: &mut Model, i: usize, on: bool) {
model.addons[i].enabled = on;
let name = model.addons[i].name.clone();
model.addon_enable.set(&name, on);
}
/// The registry as [`super::addon_gate`] rows, the one input every verb's verdict reads.
@@ -353,8 +356,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
}
// `0x48e830` reloads the enable state from `AddOns.txt`, which is unchanged until the shutdown
// write, so reverting to the state as registered is the same thing.
// `0x48e830` reloads the enable hash from `AddOns.txt`, clean (`0x51ec59`), and the file is
// unchanged until the shutdown write, so reverting to the state as registered is the same.
g.set(
"ResetDisabledAddOns",
lua.create_function(|lua, ()| {
@@ -362,6 +365,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
for a in &mut model.addons {
a.enabled = a.saved_enabled;
}
model.addon_enable = model.addon_enable_saved.clone();
Ok(())
})?,
)?;
@@ -722,12 +726,17 @@ impl super::UiScript {
}
}
/// `(name, enabled)` per registered addon, in order: what the host writes to `AddOns.txt`.
pub fn addon_enable_states(&self) -> Vec<(String, bool)> {
self.model_ref()
.addons
.iter()
.map(|a| (a.name.clone(), a.enabled))
.collect()
/// Seat the current character's enable hash as the loader read it from `AddOns.txt`
/// (`0x51ebe0`, re-read at every UI boot, `0x48ff2a`).
pub fn set_addon_enable_hash(&mut self, hash: super::EnableHash) {
let mut model = self.model_mut();
model.addon_enable_saved = hash.clone();
model.addon_enable = hash;
}
/// The shutdown writer's rows (`0x490c88` into `0x51ef20`): the current character's whole
/// enable hash when a verb changed it this session, else `None`, and nothing is written.
pub fn take_addon_enable_rows(&self) -> Option<Vec<(String, bool)>> {
self.model_mut().addon_enable.take_dirty()
}
}
@@ -0,0 +1,135 @@
//! One character's addon enable hash, the table an `ADDONSTATELIST` node (`0xbe1bd0`) holds at
//! `+0x10`, with the node's dirty byte (`+0xc`). Both the glue AddOns screen and the in-game verbs
//! write through its setter, and the writer `0x51ef20` saves a node only while it is dirty, one
//! line per entry, so a character that toggled nothing writes nothing and one that never set an
//! addon keeps inheriting what the realm's other characters chose (`0x51e470`).
use std::collections::HashMap;
/// A character's explicit enable rows: those `AddOnList_LoadCharacter 0x51ebe0` read from its
/// `AddOns.txt`, whether or not that addon is installed now, plus those the setter `0x51ea20` put
/// there since. An addon with no row here is not the character's choice, and the query answers it
/// from the other characters.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EnableHash {
/// In insertion order, each name in the spelling that first put it here.
rows: Vec<(String, bool)>,
/// Lowercased name to its row: every compare in the reference is `SStrCmpI` (`0x64a4c0`).
index: HashMap<String, usize>,
/// The node's `+0xc`: set when a setter call changes the hash (`0x51ebbc`), cleared by a
/// reload (`0x51ec59`) and by the writer as it saves (`0x51ef68`).
dirty: bool,
}
impl EnableHash {
/// The loader's hash, clean. A name on two lines keeps its first spelling and takes the last
/// value, as the reload looks each one up and overwrites its byte (`0x51ee31`, `0x51eeef`).
pub fn from_rows(rows: impl IntoIterator<Item = (String, bool)>) -> Self {
let mut hash = Self::default();
for (name, on) in rows {
hash.put(name, on);
}
hash
}
/// The row for `name`, if this character has one.
pub fn get(&self, name: &str) -> Option<bool> {
self.index
.get(&name.to_ascii_lowercase())
.map(|&i| self.rows[i].1)
}
/// The setter `0x51ea20` on this node: a row with the same value is left alone
/// (`0x51ebab`-`0x51ebb1`); a changed row is stored and a missing one inserted, and either
/// dirties the node (`0x51ebb3`-`0x51ebbc`, `0x51eb62`).
pub fn set(&mut self, name: &str, on: bool) {
if self.get(name) == Some(on) {
return;
}
self.put(name.to_string(), on);
self.dirty = true;
}
/// Whether the writer would save this node.
pub fn is_dirty(&self) -> bool {
self.dirty
}
/// Every row, in order.
pub fn rows(&self) -> &[(String, bool)] {
&self.rows
}
/// The writer's view of the node (`0x51ef59`-`0x51ef68`): every row when dirty, clearing the
/// flag; `None` when clean, which writes nothing.
pub fn take_dirty(&mut self) -> Option<Vec<(String, bool)>> {
std::mem::take(&mut self.dirty).then(|| self.rows.clone())
}
fn put(&mut self, name: String, on: bool) {
match self.index.get(&name.to_ascii_lowercase()) {
Some(&i) => self.rows[i].1 = on,
None => {
self.index
.insert(name.to_ascii_lowercase(), self.rows.len());
self.rows.push((name, on));
}
}
}
}
#[cfg(test)]
mod tests {
use super::EnableHash;
fn loaded() -> EnableHash {
EnableHash::from_rows([("Kept".to_string(), true), ("Off".to_string(), false)])
}
#[test]
fn a_loaded_hash_is_clean_and_keeps_the_first_spelling_with_the_last_value() {
let h = EnableHash::from_rows([
("MyAddon".to_string(), true),
("myaddon".to_string(), false),
]);
assert!(!h.is_dirty());
assert_eq!(h.rows(), &[("MyAddon".to_string(), false)]);
assert_eq!(h.get("MYADDON"), Some(false));
}
#[test]
fn setting_a_row_to_its_own_value_does_not_dirty() {
let mut h = loaded();
h.set("kept", true);
h.set("OFF", false);
assert!(!h.is_dirty());
assert_eq!(h.clone().take_dirty(), None, "a clean node writes nothing");
}
#[test]
fn a_changed_row_dirties_and_the_writer_takes_every_row_once() {
let mut h = loaded();
h.set("Kept", false);
assert!(h.is_dirty());
assert_eq!(
h.take_dirty(),
Some(vec![
("Kept".to_string(), false),
("Off".to_string(), false)
])
);
assert!(!h.is_dirty(), "the writer clears the flag (`0x51ef68`)");
assert_eq!(h.take_dirty(), None);
}
/// A miss inserts even when the value matches what the character inherited: the setter has
/// no view of the aggregate, only of this node's table.
#[test]
fn a_missing_row_is_inserted_and_dirties() {
let mut h = loaded();
h.set("New", true);
assert!(h.is_dirty());
assert_eq!(h.get("new"), Some(true));
assert_eq!(h.rows().len(), 3);
}
}
+2
View File
@@ -49,6 +49,7 @@ mod editbox;
pub mod instance;
pub(crate) use editbox::{adopt_text_region, editbox_text_region_wrapper};
pub(crate) mod addon;
mod addon_enable;
pub mod addon_gate;
mod client;
mod cvars;
@@ -140,6 +141,7 @@ mod worn_display;
pub use action::{ActionSlot, ActionState, ActionUse};
pub use addon::AddOnInfo;
pub use addon_enable::EnableHash;
pub use addon_message::{AddonDistribution, AddonSend};
pub use auction::{
AuctionBid, AuctionCategory, AuctionItemRow, AuctionListState, AuctionQuery,
+7
View File
@@ -62,6 +62,11 @@ pub(crate) struct Model {
/// Lowercased names `SMSG_ADDON_INFO` marked `status = 2`; `None` until the reply arrives, and
/// until then `GetNumAddOns()` is 0, as in the reference (`[0xbe1b90]`, reset at `0x51fad1`).
pub(crate) addon_info_hidden: Option<Vec<String>>,
/// The current character's enable hash, which every enable verb writes through and the
/// shutdown writer (`0x490c88` into `0x51ef20`) saves only while dirty.
pub(crate) addon_enable: super::EnableHash,
/// The hash as the host read it from the file, what `ResetDisabledAddOns` reloads (`0x48e830`).
pub(crate) addon_enable_saved: super::EnableHash,
/// The AddOns folder, so `LoadAddOn` can read an addon's files from inside a Lua binding.
pub(crate) addons_root: Option<std::path::PathBuf>,
/// The host's reader for chain-sourced addon files; without one such an addon is `MISSING`.
@@ -967,6 +972,8 @@ impl Model {
addons: Vec::new(),
addon_index: Vec::new(),
addon_info_hidden: None,
addon_enable: Default::default(),
addon_enable_saved: Default::default(),
addons_root: None,
addons_chain_reader: None,
measurer: None,
@@ -205,3 +205,20 @@ fn the_all_verbs_do_nothing_before_the_server_answers() {
s.run("DisableAllAddOns()").unwrap();
assert!(enabled(&s, "Alpha") && enabled(&s, "Mike") && enabled(&s, "Zulu"));
}
/// What a disable-all leaves for the shutdown writer (`0x51ef20`): the setter's one new row per
/// visible addon, and none for the hidden one, whose row it never set.
#[test]
fn disable_all_writes_rows_for_the_visible_addons_only() {
let s = with_hidden_blizzard(true);
assert_eq!(
s.take_addon_enable_rows(),
None,
"nothing set, nothing to write"
);
s.run("DisableAllAddOns()").unwrap();
assert_eq!(
s.take_addon_enable_rows(),
Some(vec![("Probe".to_string(), false)])
);
}