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:
co-authored by
Claude Opus 5.5
parent
78862ff291
commit
36b359f007
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)])
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user