the create screen marks your pick, and a new character inherits addons
- the character-create screen marks the chosen race, gender and class again — a lit square and its name on the icon, as the reference's LockHighlight draws it; a query term had filtered all eighteen icons out since 2072 - a new character inherits the addon enables the realm's other characters agree on, as the reference computes them: a Disable All saved just before no longer comes back all-checked (2311) - an auction list-update event names one list: a first search on Browse no longer throws the stock Auctions tab's page error, and a sale or outbid notification never fetches a list the window never asked for (2308) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -161,11 +161,20 @@ struct AuctionProbe {
|
||||
baseline_money: u32,
|
||||
/// `AUCTION_OWNED_LIST_UPDATE`'s count immediately before `StartAuction` went in.
|
||||
///
|
||||
/// Taken THERE and not when step (6) starts, because the STARTED result queues an owner
|
||||
/// re-query of its own: by the time (6) is reached the event may already have fired, and a
|
||||
/// baseline taken then would be waiting for a second one that a repeated identical page will
|
||||
/// never produce.
|
||||
/// Taken THERE and not when step (6) starts, so that an owner re-query queued by the STARTED
|
||||
/// result cannot fire the event before the baseline is read — a baseline taken after it would
|
||||
/// wait for a second event that a repeated identical page will never produce. (Since 2308 that
|
||||
/// re-query only happens for a list we already hold, and step 6 is what first asks for this
|
||||
/// one, so the ordering is belt and braces rather than load-bearing.)
|
||||
owned_event_baseline: i64,
|
||||
/// How many red `UI_ERROR_MESSAGE` lines the run had already raised when the auction window
|
||||
/// opened — the login environment's own, not the arc's.
|
||||
///
|
||||
/// Step (6d) asserts the arc raised **no** red line, and it used to read the whole tally: on a
|
||||
/// `probeN` account that is never zero, because the preflight's `GM mode is ON` and
|
||||
/// `GOD mode is ON` banners (0679) are red lines too, and they land before the greeting. The
|
||||
/// assertion was therefore FAILING on every clean run, which is worse than not making it.
|
||||
red_baseline: i64,
|
||||
passes: u32,
|
||||
fails: u32,
|
||||
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
|
||||
@@ -287,6 +296,13 @@ fn last_error(script: &UiScript) -> String {
|
||||
///
|
||||
/// Compared against `getglobal("ERR_AUCTION_STARTED")` rather than against English: the string is
|
||||
/// the player's own, and this file must not carry a copy of it.
|
||||
/// Red `UI_ERROR_MESSAGE` lines raised so far, as the hook's own array counts them.
|
||||
fn red_count(script: &UiScript) -> i64 {
|
||||
script
|
||||
.eval::<i64>("return table.getn(ProbeAuctionErrors)")
|
||||
.unwrap_or(-1)
|
||||
}
|
||||
|
||||
fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) {
|
||||
let said = script
|
||||
.eval::<i64>(
|
||||
@@ -298,9 +314,7 @@ fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) {
|
||||
return 0",
|
||||
)
|
||||
.unwrap_or(-2);
|
||||
let reds = script
|
||||
.eval::<i64>("return table.getn(ProbeAuctionErrors)")
|
||||
.unwrap_or(-1);
|
||||
let reds = red_count(script) - probe.red_baseline;
|
||||
match (said, reds) {
|
||||
(1, 0) => {
|
||||
info!(
|
||||
@@ -317,13 +331,16 @@ fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) {
|
||||
probe.fails += 1;
|
||||
}
|
||||
(said, reds) => {
|
||||
// The reds are listed from the baseline on, for the same reason `reds` counts from
|
||||
// there: the login environment's own banners are not this arc's output.
|
||||
let lines = script
|
||||
.eval::<String>(
|
||||
"local t = {} \
|
||||
.eval::<String>(&format!(
|
||||
"local t = {{}} \
|
||||
for i = 1, table.getn(ProbeAuctionChat) do table.insert(t, ProbeAuctionChat[i]) end \
|
||||
for i = 1, table.getn(ProbeAuctionErrors) do table.insert(t, \"RED:\" .. ProbeAuctionErrors[i]) end \
|
||||
for i = {} + 1, table.getn(ProbeAuctionErrors) do table.insert(t, \"RED:\" .. ProbeAuctionErrors[i]) end \
|
||||
return table.concat(t, \" | \")",
|
||||
)
|
||||
probe.red_baseline.max(0)
|
||||
))
|
||||
.unwrap_or_default();
|
||||
error!(
|
||||
"PROBE_AUCTION: FAIL (6d chat) — wanted the STARTED line in chat and zero red \
|
||||
@@ -342,8 +359,15 @@ fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) {
|
||||
/// coin. Derived from `probe.min_bid` rather than hardcoded, because the listing price is computed
|
||||
/// from the item's own vendor value and a fixed expectation would be a lie the day the item changes.
|
||||
///
|
||||
/// `IsShown`, not `IsVisible`: the Auctions pane is behind the Browse tab at this point in the run
|
||||
/// and the rows are painted either way — visibility would test the tab, not the coins.
|
||||
/// `IsShown`, not `IsVisible`: the coin buttons' own shown-ness is the subject, not whatever the
|
||||
/// window is doing around them.
|
||||
///
|
||||
/// The pane is the *front* tab by the time this runs, and that is load-bearing rather than
|
||||
/// incidental: the row is painted by `AuctionFrameAuctions_Update`, which cannot complete before
|
||||
/// the tab's `OnShow` has given it a `page` (decision 2308). This check read the player's purse
|
||||
/// out of the row's money frame for as long as the probe asked for the owned list without opening
|
||||
/// the tab — the frame was still on its `MoneyTypeInfo["PLAYER"]` default because nothing had ever
|
||||
/// repainted it.
|
||||
fn owner_money_check(script: &UiScript, probe: &mut AuctionProbe) {
|
||||
let (gold, silver, copper) = (
|
||||
probe.min_bid / 10_000,
|
||||
@@ -601,6 +625,9 @@ fn auction_probe(
|
||||
auction.auctioneer.unwrap_or(0)
|
||||
);
|
||||
probe.passes += 1;
|
||||
// Everything red before this instant is the login environment (the GM/GOD
|
||||
// banners); the arc's own tally starts here.
|
||||
probe.red_baseline = red_count(&script);
|
||||
} else {
|
||||
error!(
|
||||
"PROBE_AUCTION: FAIL (2 greet) — the session opened, but on {:?} (we \
|
||||
@@ -1151,10 +1178,27 @@ fn auction_probe(
|
||||
probe.phase = cancel_at(now);
|
||||
return;
|
||||
}
|
||||
// Re-ask on a cadence: the server drops a second in-flight list request silently, and
|
||||
// the STARTED result has already queued a refresh of its own.
|
||||
// Re-ask on a cadence: the server drops a second in-flight list request silently.
|
||||
//
|
||||
// **The FIRST ask is the tab click, not the raw binding** (decision 2308). Every
|
||||
// caller of `GetOwnerAuctionItems` in the stock files lives inside the Auctions pane —
|
||||
// its `OnShow` and its two page turners — and the `OnShow` is also the one place that
|
||||
// assigns `AuctionFrameAuctions.page`. Calling the binding cold puts the window in a
|
||||
// state no click can reach, and the reply's `AUCTION_OWNED_LIST_UPDATE` then repaints
|
||||
// a tab whose `page` is still nil: this probe raised
|
||||
// `Blizzard_AuctionUI.lua:837: attempt to perform arithmetic on field 'page'` twice a
|
||||
// run doing exactly that, at WARN, where nothing was looking. Clicking the tab is both
|
||||
// the faithful drive ("exactly as a click would", this file's header) and what makes
|
||||
// step (6c) a real assertion instead of a permanently red one.
|
||||
if last_ask == 0.0 || now - last_ask > OWNER_REASK_SECS {
|
||||
crate::ui_script::run_or_warn(&script, "GetOwnerAuctionItems()");
|
||||
let ask = if last_ask == 0.0 {
|
||||
// The tab's own OnShow issues the query, once per window session
|
||||
// (`AuctionFrame.gotAuctions`), and sets the page the repaint needs.
|
||||
"AuctionFrameTab_OnClick(3)"
|
||||
} else {
|
||||
"GetOwnerAuctionItems()"
|
||||
};
|
||||
crate::ui_script::run_or_warn(&script, ask);
|
||||
probe.phase = Phase::OwnerList {
|
||||
since,
|
||||
last_ask: now,
|
||||
|
||||
@@ -326,7 +326,7 @@ impl CreateSelection {
|
||||
}
|
||||
|
||||
/// One clickable control on the screen — a single component so one query dispatches every button.
|
||||
#[derive(Component, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum CreateAction {
|
||||
Race(u8),
|
||||
Gender(u8),
|
||||
@@ -808,4 +808,118 @@ mod tests {
|
||||
"the booth look must differ by class, or the preview cannot re-dress"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The chosen race / gender / class icon is LOCK-HIGHLIGHTED** — the reference's own verb:
|
||||
/// `SetCharacterRace`, `SetCharacterClass` and `SetCharacterGender` each call
|
||||
/// `button:LockHighlight()` on the one that is chosen and `UnlockHighlight()` on the rest
|
||||
/// (`CharacterCreate.lua` l.171/254/326). In 1.12 that lock *is* the whole selected visual:
|
||||
/// `CharacterCreateIconButtonTemplate` has its `<CheckedTexture>` commented out, leaving the
|
||||
/// ADD `ButtonHilight-Square` and the HIGHLIGHT-layer `$parentHighlightText` — both of which
|
||||
/// the lock is what lights.
|
||||
///
|
||||
/// **The regression this exists for** (director, 2026-09-17: *"it doesn't show that mage is
|
||||
/// selected"*): 2072 moved the sheen to one owner and gave this screen's visuals query a
|
||||
/// `&mut LockHighlight` term — but [`crate::glue::widgets::icon_button`], the only spawn site
|
||||
/// for all eighteen of these icons, inserted no such component. A `&mut T` term is a filter,
|
||||
/// so the query matched **nothing**: no selected sheen and no icon name on race, gender or
|
||||
/// class, for ten days. Every existing sheen test hand-spawned the flag, so the consumer was
|
||||
/// covered and the producer was not — hence this one builds the icons with the real
|
||||
/// `icon_button` and runs the real system.
|
||||
#[test]
|
||||
fn the_chosen_icons_are_lock_highlighted() {
|
||||
use crate::glue::art::GlueArt;
|
||||
use crate::glue::widgets::{icon_button, LockHighlight};
|
||||
|
||||
fn spawn_icons(mut commands: Commands, art: Res<GlueArt>) {
|
||||
let font = Handle::<Font>::default();
|
||||
commands.spawn(Node::default()).with_children(|p| {
|
||||
for race in ALLIANCE {
|
||||
icon_button(
|
||||
p,
|
||||
&font,
|
||||
CreateAction::Race(race),
|
||||
None::<parts::DynIcon>,
|
||||
None,
|
||||
None::<parts::DynText>,
|
||||
"",
|
||||
&art,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
for sex in 0..2u8 {
|
||||
icon_button(
|
||||
p,
|
||||
&font,
|
||||
CreateAction::Gender(sex),
|
||||
None::<parts::DynIcon>,
|
||||
None,
|
||||
None::<parts::DynText>,
|
||||
"",
|
||||
&art,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
for slot in 0..8u8 {
|
||||
icon_button(
|
||||
p,
|
||||
&font,
|
||||
CreateAction::ClassSlot(slot),
|
||||
None::<parts::DynIcon>,
|
||||
None,
|
||||
None::<parts::DynText>,
|
||||
"",
|
||||
&art,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut app = App::new();
|
||||
app.init_resource::<GlueArt>()
|
||||
// Human, female, MAGE — the director's own case. With no catalog loaded
|
||||
// `race_classes` is the full list, so mage (8) is slot 6.
|
||||
.insert_resource(CreateSelection {
|
||||
race: 1,
|
||||
sex: 1,
|
||||
class: 8,
|
||||
..default()
|
||||
})
|
||||
.add_systems(Startup, spawn_icons)
|
||||
.add_systems(Update, refresh::refresh_hover);
|
||||
app.update();
|
||||
|
||||
let locked: Vec<CreateAction> = app
|
||||
.world_mut()
|
||||
.query::<(&CreateAction, &LockHighlight)>()
|
||||
.iter(app.world())
|
||||
.filter(|(_, l)| l.0)
|
||||
.map(|(a, _)| *a)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
locked.len(),
|
||||
3,
|
||||
"exactly one race, one gender and one class icon is locked — got {locked:?}"
|
||||
);
|
||||
assert!(locked.contains(&CreateAction::Race(1)), "Human");
|
||||
assert!(locked.contains(&CreateAction::Gender(1)), "female");
|
||||
assert!(
|
||||
locked.contains(&CreateAction::ClassSlot(6)),
|
||||
"mage is slot 6 of [1,2,3,4,5,7,8,9,11] — and it is the mage icon the director saw \
|
||||
unmarked"
|
||||
);
|
||||
|
||||
// …and the selection moving takes the lock with it, rather than lighting a second icon.
|
||||
app.world_mut().resource_mut::<CreateSelection>().class = 1; // warrior, slot 0
|
||||
app.update();
|
||||
let locked: Vec<CreateAction> = app
|
||||
.world_mut()
|
||||
.query::<(&CreateAction, &LockHighlight)>()
|
||||
.iter(app.world())
|
||||
.filter(|(_, l)| l.0)
|
||||
.map(|(a, _)| *a)
|
||||
.collect();
|
||||
assert!(locked.contains(&CreateAction::ClassSlot(0)));
|
||||
assert!(!locked.contains(&CreateAction::ClassSlot(6)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,11 +219,17 @@ pub(super) fn refresh_dynamic(
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame interaction visuals the CREATE screen owns: highlight overlays (hover — and held
|
||||
/// while selected, the ref's `LockHighlight`), hover labels, and the Create button's disabled
|
||||
/// latch while a create is in flight. The screen-agnostic passes — up/down art swaps, the glue
|
||||
/// buttons' art + caption color, outline mirroring — are [`crate::glue`]'s, registered beside
|
||||
/// this in the plugin chain.
|
||||
/// Per-frame interaction visuals the CREATE screen owns: which icon is *chosen* (the ref's
|
||||
/// `LockHighlight`, which [`crate::glue::glue_hilights`] then renders), hover labels, the no-art
|
||||
/// fallback shade, and the Create button's disabled latch while a create is in flight. The
|
||||
/// screen-agnostic passes — up/down art swaps, the glue buttons' art + caption color, outline
|
||||
/// mirroring — are [`crate::glue`]'s, registered beside this in the plugin chain.
|
||||
///
|
||||
/// **The chosen-icon write is its own query, deliberately.** It used to be a sixth term on the
|
||||
/// hover query below, which meant a button without a [`LockHighlight`] dropped out of the hover
|
||||
/// visuals too — and since no spawn site on this screen had one, that query matched *nothing*:
|
||||
/// no selected sheen and no icon name anywhere on the create screen. Two jobs, two queries, so a
|
||||
/// missing component can only ever cost its own job.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn refresh_hover(
|
||||
sel: Res<CreateSelection>,
|
||||
@@ -238,10 +244,10 @@ pub(super) fn refresh_hover(
|
||||
&Children,
|
||||
&mut BackgroundColor,
|
||||
Has<FallbackFace>,
|
||||
&mut LockHighlight,
|
||||
),
|
||||
(With<Button>, Without<crate::glue::widgets::GlueBtn>),
|
||||
>,
|
||||
mut locks: Query<(&CreateAction, &mut LockHighlight)>,
|
||||
mut labels: Query<&mut Visibility, (With<HoverLabel>, Without<Hilight>)>,
|
||||
mut disables: Query<(&CreateAction, &mut GlueDisabled)>,
|
||||
) {
|
||||
@@ -254,14 +260,20 @@ pub(super) fn refresh_hover(
|
||||
_ => false,
|
||||
};
|
||||
|
||||
for (action, interaction, children, mut bg, fallback, mut locked) in &mut buttons {
|
||||
// `SetCharacterRace`/`SetCharacterClass`/`SetCharacterGender`: `LockHighlight()` on the one
|
||||
// that is chosen, `UnlockHighlight()` on the rest. Nothing about visibility — the sheen is
|
||||
// `crate::glue::glue_hilights`' alone.
|
||||
for (action, mut locked) in &mut locks {
|
||||
let is_sel = selected(action);
|
||||
let hovered = *interaction != Interaction::None;
|
||||
let lit = is_sel || hovered;
|
||||
// The sheen itself is `crate::glue::glue_hilights`' — this says only which row is chosen.
|
||||
if locked.0 != is_sel {
|
||||
locked.0 = is_sel;
|
||||
}
|
||||
}
|
||||
|
||||
for (action, interaction, children, mut bg, fallback) in &mut buttons {
|
||||
let is_sel = selected(action);
|
||||
let hovered = *interaction != Interaction::None;
|
||||
let lit = is_sel || hovered;
|
||||
// No-art fallback only: buttons spawned with a plain face get a hover shade. (Every node
|
||||
// *has* a `BackgroundColor` — only a `FallbackFace`'s belongs to us.)
|
||||
if fallback {
|
||||
|
||||
@@ -38,9 +38,12 @@
|
||||
//!
|
||||
//! At character select no addon has been *loaded* — `load_third_party` is a world-entry step
|
||||
//! (1191 §2) — so `GetNumAddOns()` would answer 0. The screen asks
|
||||
//! [`crate::ui_script::addons::installed`] instead: the same discovery, the same manifest parser,
|
||||
//! the same enable files. Two views of one folder, never two folders. With the dropdown that read
|
||||
//! happens **once per roster character at open** ([`AddonsPanel::open_for`]).
|
||||
//! [`crate::ui_script::addons::installed_rows`] instead, and resolves the enable bits through
|
||||
//! [`crate::ui_script::addons::EnableStore`] exactly as the world-entry walk does: the same
|
||||
//! discovery, the same manifest parser, the same store. Two views of one folder, never two
|
||||
//! folders. Both reads happen **once at open** ([`AddonsPanel::open_for`]) — the folder walk used
|
||||
//! to run once per roster character, which decision 2311 retired along with the per-character
|
||||
//! `installed()` it was there to feed.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::ui_render::ui_material::MaterialNode;
|
||||
@@ -224,41 +227,34 @@ impl Default for AddonsPanel {
|
||||
}
|
||||
|
||||
impl AddonsPanel {
|
||||
/// Open for this realm's roster, reading the folder fresh — once per character, so each
|
||||
/// staged column is that character's own enable file applied to one shared list. The
|
||||
/// reference's `AddonList_OnShow` also re-reads (`AddonList_Update`), so a folder that
|
||||
/// changed under us is picked up.
|
||||
/// Open for this realm's roster: the folder read **once** into one metadata list, and one
|
||||
/// staged column per character off the enable store. The reference's `AddonList_OnShow` also
|
||||
/// re-reads (`AddonList_Update`), so a folder that changed under us is picked up.
|
||||
///
|
||||
/// **The store is loaded for the whole roster, not per character** (decision 2311). A column
|
||||
/// is not "that character's file, everything else enabled": an addon the character has no row
|
||||
/// for takes what the realm's *other* characters agree on, and only falls to the manifest's
|
||||
/// `## DefaultState` when they disagree or nobody has said anything. Reading it the other way
|
||||
/// is what made a newly created character re-enable every addon in the All view — its
|
||||
/// all-enabled column turned every disabled row Mixed, and Mixed reads as on.
|
||||
pub(super) fn open_for(&mut self, realm: String, chars: Vec<String>) {
|
||||
self.list.clear();
|
||||
self.list = addons::installed_rows();
|
||||
self.staged.clear();
|
||||
if chars.is_empty() {
|
||||
// A fresh account: no character means no enable file to key. `None` is the "nobody's
|
||||
// file" read (everything enabled), staged into one anonymous column so the boxes
|
||||
// still toggle; Okay has no file to write ([`Self::save_staged`] walks `chars`).
|
||||
self.list = addons::installed(None);
|
||||
self.staged
|
||||
.push(self.list.iter().map(|a| a.enabled).collect());
|
||||
let store = addons::EnableStore::load(&realm, &chars);
|
||||
// A fresh account stages one anonymous column: no character means no file to key, and
|
||||
// Okay has none to write ([`Self::save_staged`] walks `chars`).
|
||||
let columns: Vec<Option<&str>> = if chars.is_empty() {
|
||||
vec![None]
|
||||
} else {
|
||||
for (c, name) in chars.iter().enumerate() {
|
||||
let id = (realm.clone(), name.clone());
|
||||
let rows = addons::installed(Some(&id));
|
||||
if c == 0 {
|
||||
self.staged.push(rows.iter().map(|a| a.enabled).collect());
|
||||
self.list = rows;
|
||||
} else {
|
||||
// Same folder, same deterministic discovery order, read back-to-back:
|
||||
// every call answers the same rows in the same order, only the `enabled` bits
|
||||
// differ. That is what lets ONE metadata list carry N columns. The order is
|
||||
// `addons::sort_by_directory_order` — NTFS's, the reference's own listing
|
||||
// order — and the point here is only that it is a pure function of the names.
|
||||
debug_assert_eq!(
|
||||
rows.len(),
|
||||
self.list.len(),
|
||||
"installed() must be deterministic across per-character reads"
|
||||
);
|
||||
self.staged.push(rows.iter().map(|a| a.enabled).collect());
|
||||
}
|
||||
}
|
||||
chars.iter().map(|c| Some(c.as_str())).collect()
|
||||
};
|
||||
for character in columns {
|
||||
self.staged.push(
|
||||
self.list
|
||||
.iter()
|
||||
.map(|a| store.enabled_for(&a.name, a.default_state, character))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
self.baseline = self.staged.clone();
|
||||
self.realm = realm;
|
||||
@@ -316,7 +312,7 @@ impl AddonsPanel {
|
||||
/// actually matters.
|
||||
pub(super) fn any_installed() -> bool {
|
||||
static ANY: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ANY.get_or_init(|| !addons::installed(None).is_empty())
|
||||
*ANY.get_or_init(|| !addons::installed_rows().is_empty())
|
||||
}
|
||||
|
||||
/// The rows currently visible, as `(index, addon)` — enable state is the view's to answer
|
||||
@@ -392,10 +388,12 @@ impl AddonsPanel {
|
||||
}
|
||||
|
||||
/// Is this addon enabled *for the current view* — the bit the gate is fed? A single character
|
||||
/// reads their own column. The All view answers **"any staged character has it on"** — OURS
|
||||
/// (1293): the reference feeds `AddOn_CanLoad` a NULL character there and that read's exact
|
||||
/// semantics are un-carved, so we state the natural reading rather than guess at bytes nobody
|
||||
/// has verified.
|
||||
/// reads their own column. The All view answers **"any staged character has it on"**, which
|
||||
/// 1293 stated as the natural reading of an un-carved mechanism and which the bytes have
|
||||
/// since confirmed: `AddonList_Update` computes `enabled = (checkboxState > 0)` off
|
||||
/// `GetAddOnEnableState(nil, i)`, whose `1` (enabled for some) therefore counts as on
|
||||
/// (wow-5875-re `addon-enable-store.md` §4). What 1293 got wrong was not this fold but what
|
||||
/// each column holds — see [`Self::open_for`] and decision 2311.
|
||||
fn effective_enabled(&self, i: usize) -> bool {
|
||||
self.box_state(i) != BoxState::Off
|
||||
}
|
||||
@@ -1712,7 +1710,7 @@ mod tests {
|
||||
dependencies: deps.iter().map(|d| (*d).to_string()).collect(),
|
||||
interface: iface,
|
||||
load_on_demand: false,
|
||||
enabled: true,
|
||||
default_state: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1721,7 +1719,7 @@ mod tests {
|
||||
/// `p.view = Some(c)` themselves.
|
||||
fn panel_for(chars: usize, list: Vec<InstalledAddOn>) -> AddonsPanel {
|
||||
let staged: Vec<Vec<bool>> = (0..chars.max(1))
|
||||
.map(|_| list.iter().map(|a| a.enabled).collect())
|
||||
.map(|_| list.iter().map(|a| a.default_state).collect())
|
||||
.collect();
|
||||
AddonsPanel {
|
||||
open: true,
|
||||
@@ -1934,7 +1932,7 @@ mod tests {
|
||||
assert_eq!(p.staged, vec![vec![true, true]]);
|
||||
p.staged[0][0] = false;
|
||||
assert!(
|
||||
p.list[0].enabled,
|
||||
p.list[0].default_state,
|
||||
"the read-in list is never mutated — only `staged` is, which is what makes Cancel free"
|
||||
);
|
||||
p.close();
|
||||
@@ -1982,27 +1980,152 @@ mod tests {
|
||||
p.staged[1][1] = false;
|
||||
p.save_staged();
|
||||
|
||||
// Read back through the same folder view the world-entry walk uses — the two views of
|
||||
// one folder that 1197 §3 demands can never disagree.
|
||||
let alice = ("TestRealm".to_string(), "Alice".to_string());
|
||||
let bob = ("TestRealm".to_string(), "Bob".to_string());
|
||||
let carol = ("TestRealm".to_string(), "Carol".to_string());
|
||||
let read = |id: &(String, String)| -> Vec<bool> {
|
||||
addons::installed(Some(id))
|
||||
.iter()
|
||||
.map(|a| a.enabled)
|
||||
// Read back through the same store the world-entry walk resolves against — the two views
|
||||
// of one folder that 1197 §3 demands can never disagree.
|
||||
let roster = ["Alice".to_string(), "Bob".to_string(), "Carol".to_string()];
|
||||
let store = addons::EnableStore::load("TestRealm", &roster);
|
||||
let rows = addons::installed_rows();
|
||||
let read = |who: &str| -> Vec<bool> {
|
||||
rows.iter()
|
||||
.map(|a| store.enabled_for(&a.name, a.default_state, Some(who)))
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
read(&alice),
|
||||
read("Alice"),
|
||||
vec![false, true],
|
||||
"Alice's file carries HER edit"
|
||||
);
|
||||
assert_eq!(read(&bob), vec![true, false], "Bob's carries HIS");
|
||||
assert_eq!(read("Bob"), vec![true, false], "Bob's carries HIS");
|
||||
let carol = ("TestRealm".to_string(), "Carol".to_string());
|
||||
assert!(
|
||||
!addons::enable_state_path(Some(&carol)).unwrap().exists(),
|
||||
"an unchanged column writes no file — only the diffs against the open-time baseline"
|
||||
);
|
||||
// And Carol, who has no file at all, is NOT "everything enabled": she takes what the
|
||||
// others agree on, which here is nothing — Alpha and Beta are each on for one and off
|
||||
// for the other — so both fall to their manifests' `## DefaultState` (2311).
|
||||
assert_eq!(read("Carol"), vec![true, true]);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// **Creating a character does not re-enable everything** — the director's report,
|
||||
/// 2026-09-17: *"after creating a new char all addons are enabled again, even after disabling
|
||||
/// all of them just before"*.
|
||||
///
|
||||
/// The walk is theirs, verbatim: Disable All in the All view, Okay, create a character, open
|
||||
/// the list again. What used to happen is the whole bug in one line — the new character had
|
||||
/// no `AddOns.txt`, benilla read an absent row as **enabled**, and that all-true column turned
|
||||
/// every row in the All view from Off to Mixed, which paints a check and reads as on
|
||||
/// (`effective_enabled`). One click there would then have written the all-enabled state back
|
||||
/// onto every other character's file: a one-way ratchet.
|
||||
///
|
||||
/// The reference does not do this and the mechanism is carved (wow-5875-re
|
||||
/// `addon-enable-store.md` §4): the enable query counts only nodes with an **explicit** entry,
|
||||
/// so a character with no file contributes no opinion and inherits the aggregate — here, the
|
||||
/// unanimous `disabled` the player just saved. Decision 2311.
|
||||
#[test]
|
||||
fn a_new_character_inherits_the_disable_it_did_not_ask_for() {
|
||||
let _l = crate::local_state::test_env::ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let tmp =
|
||||
std::env::temp_dir().join(format!("benilla-charsel-newchar-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let home = tmp.join("benilla-config");
|
||||
for name in ["Alpha", "Beta"] {
|
||||
let dir = home.join("AddOns").join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.toc")), "## Interface: 11200\n").unwrap();
|
||||
}
|
||||
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
|
||||
let _h =
|
||||
crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().unwrap());
|
||||
|
||||
// The roster before the create, and the player's Disable All + Okay over it.
|
||||
let mut p = AddonsPanel::default();
|
||||
p.open_for(
|
||||
"TestRealm".into(),
|
||||
vec!["Onemage".into(), "Onerogue".into()],
|
||||
);
|
||||
p.set_all(false);
|
||||
p.save_staged();
|
||||
p.close();
|
||||
|
||||
// …then a character is created, so the roster the panel opens over has one more name and
|
||||
// that name has no enable file at all.
|
||||
p.open_for(
|
||||
"TestRealm".into(),
|
||||
vec!["Onemage".into(), "Onerogue".into(), "Freshling".into()],
|
||||
);
|
||||
assert!(p.view.is_none(), "reopens on All, the reference's default");
|
||||
assert_eq!(p.staged.len(), 3);
|
||||
assert_eq!(
|
||||
p.staged[2],
|
||||
vec![false, false],
|
||||
"the new character inherits the unanimous disable, not a blank slate"
|
||||
);
|
||||
for i in 0..p.list.len() {
|
||||
assert_eq!(
|
||||
p.box_state(i),
|
||||
BoxState::Off,
|
||||
"row {i} reads OFF in the All view — Mixed here is the bug, and it paints a check"
|
||||
);
|
||||
assert!(!p.effective_enabled(i), "…so nothing is loadable either");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// The other side of the same law: a new character does **not** inherit a disable the roster
|
||||
/// never agreed on. One character off, one on, and the newcomer takes the manifest's
|
||||
/// `## DefaultState` — so this is not "new characters copy whoever is strictest".
|
||||
#[test]
|
||||
fn a_new_character_inherits_nothing_when_the_roster_disagrees() {
|
||||
let _l = crate::local_state::test_env::ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let tmp =
|
||||
std::env::temp_dir().join(format!("benilla-charsel-split-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let home = tmp.join("benilla-config");
|
||||
for name in ["Alpha", "Beta"] {
|
||||
let dir = home.join("AddOns").join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join(format!("{name}.toc")),
|
||||
// Beta ships `## DefaultState: disabled`, so the tie-break is visible.
|
||||
if name == "Beta" {
|
||||
"## Interface: 11200\n## DefaultState: disabled\n"
|
||||
} else {
|
||||
"## Interface: 11200\n"
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
|
||||
let _h =
|
||||
crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().unwrap());
|
||||
|
||||
let mut p = AddonsPanel::default();
|
||||
p.open_for(
|
||||
"TestRealm".into(),
|
||||
vec!["Onemage".into(), "Onerogue".into()],
|
||||
);
|
||||
p.view = Some(0);
|
||||
p.set_all(false); // the mage turns both off; the rogue keeps the defaults
|
||||
p.view = Some(1);
|
||||
p.set_all(true);
|
||||
p.save_staged();
|
||||
p.close();
|
||||
|
||||
p.open_for(
|
||||
"TestRealm".into(),
|
||||
vec!["Onemage".into(), "Onerogue".into(), "Freshling".into()],
|
||||
);
|
||||
assert_eq!(
|
||||
p.staged[2],
|
||||
vec![true, false],
|
||||
"split roster → each row falls to its own `## DefaultState`"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
|
||||
@@ -353,6 +353,13 @@ fn outlined_spans<W: Bundle, T: Bundle>(
|
||||
/// visual), and the name label along the bottom (the ref's `HighlightText`, `GlueFontNormalSmall`,
|
||||
/// anchored BOTTOM +1 — over the icon's bottom edge). `dyn_icon`/`label_dyn` are the screen's
|
||||
/// refresh markers, spawned onto the face / real label text.
|
||||
///
|
||||
/// **It carries its own [`LockHighlight`]**, because the reference does: these are the
|
||||
/// `CheckButton`s that `SetCharacterRace`/`SetCharacterClass`/`SetCharacterGender` lock and
|
||||
/// unlock by hand (`CharacterCreate.lua` l.171/254/326). Leaving the flag to the screen is how
|
||||
/// the create screen lost every selected sheen *and* every icon name for ten days — 2072 added a
|
||||
/// `&mut LockHighlight` term to the screen's own visuals query and this spawn site had none, so
|
||||
/// the query matched nothing at all.
|
||||
pub(crate) fn icon_button<A: Component, I: Bundle, L: Bundle>(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
font: &Handle<Font>,
|
||||
@@ -368,6 +375,7 @@ pub(crate) fn icon_button<A: Component, I: Bundle, L: Bundle>(
|
||||
let mut b = parent.spawn((
|
||||
action,
|
||||
Button,
|
||||
LockHighlight::default(),
|
||||
Node {
|
||||
width: px(48.0),
|
||||
height: px(48.0),
|
||||
|
||||
@@ -10,9 +10,15 @@
|
||||
//! decorative: it keys the deposit rate the sell pane displays, and the six faction houses charge
|
||||
//! 5% where the neutral goblin house charges 25%.
|
||||
//!
|
||||
//! **Three lists, one session.** Browse (`"list"`), Bids (`"bidder"`) and Auctions (`"owner"`) are
|
||||
//! three independent server queries into one window. Each holds at most one 50-row page — the
|
||||
//! server's own cap — plus the pre-cap match count the pager needs, plus its own sort stack.
|
||||
//! **Three lists, one session — and three separate events.** Browse (`"list"`), Bids (`"bidder"`)
|
||||
//! and Auctions (`"owner"`) are three independent server queries into one window. Each holds at
|
||||
//! most one 50-row page — the server's own cap — plus the pre-cap match count the pager needs,
|
||||
//! plus its own sort stack, plus its own update event. The reference keeps them just as far apart
|
||||
//! (wow-re's auction node §1: three arrays, three counts, three sort stacks, and a fire-site
|
||||
//! census in which every `AUCTION_*_LIST_UPDATE` is fired by whatever just touched *its* array).
|
||||
//! So a list-update event here is a statement about **one** list; firing the other two runs the
|
||||
//! stock addon's other tabs over state they were never given, and the Auctions tab answers that
|
||||
//! by crashing (decision 2308).
|
||||
//!
|
||||
//! **Sorting is ours and paging is the server's**, which is the split that shapes this module: a
|
||||
//! header click re-orders rows we already hold and sends nothing ([`sort`]), while a page turn
|
||||
@@ -164,6 +170,11 @@ pub(crate) struct AuctionListSlot {
|
||||
pub(crate) entries: Vec<AuctionListEntry>,
|
||||
/// `totalCount` — the pre-cap match count, which is what tells the pager there is a page 2.
|
||||
pub(crate) total: u32,
|
||||
/// A result for **this** list has landed since the window opened — the interface asked for it
|
||||
/// and the server answered. `false` is "we hold no such list", which is *not* "the list is
|
||||
/// empty": an empty page still arrives, and still counts. It is what the server-driven
|
||||
/// refreshes gate on (decision 2308).
|
||||
received: bool,
|
||||
sort: SortStack,
|
||||
}
|
||||
|
||||
@@ -209,15 +220,23 @@ pub(crate) struct AuctionOpen {
|
||||
show_requested: bool,
|
||||
/// Fire `NEW_AUCTION_UPDATE` next feed — the sell slot changed.
|
||||
sell_slot_dirty: bool,
|
||||
/// A list result landed, so the list events are owed **whether or not the snapshot changed**.
|
||||
/// A list result landed **for that list**, so *its* event is owed whether or not the snapshot
|
||||
/// changed. Indexed [`LIST`]/[`BIDDER`]/[`OWNER`].
|
||||
///
|
||||
/// Diffing the snapshot is not enough and the live probe is what proved it: an empty auction
|
||||
/// house, or re-running the same search, produces a result identical to what we already hold.
|
||||
/// The window's Browse pane clears its "Searching…" state only on `AUCTION_ITEM_LIST_UPDATE`,
|
||||
/// so on an empty server a search animated its dots forever and never reported a result. The
|
||||
/// reference has no diff here at all — one routine invalidates all three lists and fires all
|
||||
/// three events.
|
||||
list_result_landed: bool,
|
||||
/// so on an empty server a search animated its dots forever and never reported a result.
|
||||
///
|
||||
/// **Per list — that is the whole point** (decision 2308). This was one flag driving all three
|
||||
/// fires, on 1511's reading that "one routine invalidates all three lists". The reference has
|
||||
/// no such routine: three arrays, three counts, three sort stacks, **three separate events**,
|
||||
/// each fired by whatever just touched *its* array. Firing the other two crashed the stock
|
||||
/// `Blizzard_AuctionUI` outright — `AuctionFrameAuctions_Update` multiplies by
|
||||
/// `AuctionFrameAuctions.page`, which the addon initialises only in that tab's `OnShow`, so a
|
||||
/// browse result arriving while the player had never opened the Auctions tab raised
|
||||
/// `attempt to perform arithmetic on field 'page' (a nil value)`.
|
||||
list_result_landed: [bool; 3],
|
||||
/// Empty the sell slot next feed — a listing was accepted, so the staged item is *gone*.
|
||||
///
|
||||
/// Also from the probe: without this the pane kept painting a phantom stack after a successful
|
||||
@@ -304,10 +323,11 @@ impl AuctionOpen {
|
||||
self.show_requested = true;
|
||||
}
|
||||
|
||||
/// Replace one list's page. Always owes the list events, even for an identical page.
|
||||
/// Replace one list's page. Always owes **that list's** event, even for an identical page.
|
||||
pub(crate) fn set_list(&mut self, which: usize, entries: Vec<AuctionListEntry>, total: u32) {
|
||||
self.list_result_landed = true;
|
||||
self.list_result_landed[which] = true;
|
||||
let slot = &mut self.lists[which];
|
||||
slot.received = true;
|
||||
slot.entries = entries;
|
||||
// A server that reports fewer total matches than it just sent us is not a case worth
|
||||
// trusting over our own eyes; the pager reads the larger of the two.
|
||||
@@ -329,13 +349,20 @@ impl AuctionOpen {
|
||||
}
|
||||
|
||||
/// Mark our own listings stale — the drain re-queries next frame.
|
||||
///
|
||||
/// **Only a list we hold can be stale** (decision 2308). A notification says "the page you are
|
||||
/// showing is now wrong"; if the interface has never asked for the owned list, there is no
|
||||
/// such page, and re-asking would *introduce* one — landing an `AUCTION_OWNED_LIST_UPDATE` on
|
||||
/// a tab whose `page` field the stock addon has not initialised yet. The reference cannot
|
||||
/// reach that state at all: its notification handlers patch the cached row in place, so an
|
||||
/// array it never fetched has nothing to patch and announces nothing.
|
||||
pub(crate) fn refresh_owner(&mut self) {
|
||||
self.pending_owner_refresh = true;
|
||||
self.pending_owner_refresh |= self.lists[OWNER].received;
|
||||
}
|
||||
|
||||
/// Mark the bids we hold stale.
|
||||
/// Mark the bids we hold stale — under the same rule as [`Self::refresh_owner`].
|
||||
pub(crate) fn refresh_bidder(&mut self) {
|
||||
self.pending_bidder_refresh = true;
|
||||
self.pending_bidder_refresh |= self.lists[BIDDER].received;
|
||||
}
|
||||
|
||||
/// Close the window (a client-side close — vanilla sends nothing).
|
||||
@@ -348,7 +375,7 @@ impl AuctionOpen {
|
||||
self.query_gate = None;
|
||||
self.pending_owner_refresh = false;
|
||||
self.pending_bidder_refresh = false;
|
||||
self.list_result_landed = false;
|
||||
self.list_result_landed = [false; 3];
|
||||
self.sell_slot_taken = false;
|
||||
}
|
||||
|
||||
@@ -680,8 +707,16 @@ fn feed_auction(
|
||||
let opened = last_open.is_none() && auction.auctioneer.is_some();
|
||||
let closed = last_open.is_some() && auction.auctioneer.is_none();
|
||||
let show_requested = std::mem::take(&mut auction.show_requested);
|
||||
let result_landed = std::mem::take(&mut auction.list_result_landed);
|
||||
let landed = std::mem::take(&mut auction.list_result_landed);
|
||||
let changed = fresh != *last;
|
||||
// **Which events are owed, per list.** A landed result owes its own list's event outright; a
|
||||
// changed list owes it because an async name or template just filled one of its rows in. The
|
||||
// diff is per list rather than over the whole snapshot for the same reason the fires are
|
||||
// (2308): an event names one list, and firing the other two runs the stock Lua's other tabs
|
||||
// over state they were never given.
|
||||
let owed: [bool; 3] = std::array::from_fn(|i| {
|
||||
landed[i] || fresh.as_ref().map(|s| &s.lists[i]) != last.as_ref().map(|s| &s.lists[i])
|
||||
});
|
||||
if changed {
|
||||
script.set_auction(fresh.clone());
|
||||
}
|
||||
@@ -694,19 +729,18 @@ fn feed_auction(
|
||||
if show_requested {
|
||||
script.fire_event("AUCTION_HOUSE_SHOW", vec![]);
|
||||
}
|
||||
// A landed result owes the events outright; a change owes them because an async name or
|
||||
// template just filled a row in. Diffing ALONE was the bug: an empty house and a repeated
|
||||
// search both produce a result identical to what we hold, and the Browse pane waits on
|
||||
// this event to stop saying "Searching…".
|
||||
if changed || result_landed {
|
||||
// One routine invalidates all three lists in the reference too — the three events fire
|
||||
// together rather than being diffed apart, and each tab's handler repaints only if it
|
||||
// is the visible one.
|
||||
// Three literal fires, not a loop over names: the producer tripwire
|
||||
// (`reference_ui::every_event_a_chain_file_registers_has_a_producer`) reads fire
|
||||
// sites by their literal, and the stock addon registers all three (1971).
|
||||
// Three literal fires, not a loop over names: the producer tripwire
|
||||
// (`reference_ui::every_event_a_chain_file_registers_has_a_producer`) reads fire sites by
|
||||
// their literal, and the stock addon registers all three (1971). Browse first, then Bids,
|
||||
// then Auctions — the array's own order; the reference never fires two in one pass either,
|
||||
// so nothing rests on it.
|
||||
if owed[LIST] {
|
||||
script.fire_event("AUCTION_ITEM_LIST_UPDATE", vec![]);
|
||||
}
|
||||
if owed[BIDDER] {
|
||||
script.fire_event("AUCTION_BIDDER_LIST_UPDATE", vec![]);
|
||||
}
|
||||
if owed[OWNER] {
|
||||
script.fire_event("AUCTION_OWNED_LIST_UPDATE", vec![]);
|
||||
}
|
||||
}
|
||||
@@ -926,7 +960,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// An identical list result still owes its events. Diffing the snapshot was the bug: an empty
|
||||
/// An identical list result still owes its event. Diffing the snapshot was the bug: an empty
|
||||
/// auction house and a repeated search both produce a page identical to the one we hold, and
|
||||
/// the Browse pane clears its "Searching…" state only when the event arrives — so on an empty
|
||||
/// server a search animated forever and never reported a result. Found by the live probe,
|
||||
@@ -937,19 +971,84 @@ mod tests {
|
||||
open.open(0x1234, 1);
|
||||
|
||||
open.set_list(LIST, Vec::new(), 0);
|
||||
assert!(open.list_result_landed, "the first empty page");
|
||||
assert!(open.list_result_landed[LIST], "the first empty page");
|
||||
|
||||
// The feed consumes the flag when it fires.
|
||||
open.list_result_landed = false;
|
||||
open.list_result_landed = [false; 3];
|
||||
|
||||
// The very same empty page again — nothing to diff, and still owed.
|
||||
open.set_list(LIST, Vec::new(), 0);
|
||||
assert!(
|
||||
open.list_result_landed,
|
||||
open.list_result_landed[LIST],
|
||||
"an identical page owes the event too — this is the whole bug"
|
||||
);
|
||||
}
|
||||
|
||||
/// **A result owes its OWN list's event and no other** (decision 2308).
|
||||
///
|
||||
/// The regression this guards is a crash, not a repaint: the stock `Blizzard_AuctionUI`'s
|
||||
/// `AuctionFrameAuctions_Update` multiplies by `AuctionFrameAuctions.page`, and that field is
|
||||
/// initialised only in the Auctions tab's `OnShow`. A browse result that also fired
|
||||
/// `AUCTION_OWNED_LIST_UPDATE` therefore ran that tab's repaint before it had ever been
|
||||
/// shown — `attempt to perform arithmetic on field 'page' (a nil value)`, on screen, on the
|
||||
/// first search of the session. `auction_frame.rs` holds the Lua half of this.
|
||||
#[test]
|
||||
fn a_result_owes_only_its_own_lists_event() {
|
||||
let mut open = AuctionOpen::default();
|
||||
open.open(0x1234, 1);
|
||||
|
||||
open.set_list(LIST, Vec::new(), 0);
|
||||
assert_eq!(
|
||||
open.list_result_landed,
|
||||
[true, false, false],
|
||||
"a browse result is not news about the player's bids or listings"
|
||||
);
|
||||
|
||||
open.list_result_landed = [false; 3];
|
||||
open.set_list(OWNER, Vec::new(), 0);
|
||||
assert_eq!(open.list_result_landed, [false, false, true]);
|
||||
|
||||
open.list_result_landed = [false; 3];
|
||||
open.set_list(BIDDER, Vec::new(), 0);
|
||||
assert_eq!(open.list_result_landed, [false, true, false]);
|
||||
}
|
||||
|
||||
/// **A refresh can only re-ask for a list we hold** (decision 2308) — the other half of the
|
||||
/// same crash.
|
||||
///
|
||||
/// "Your auction sold" arrives whenever the server feels like it, including while the player
|
||||
/// is standing on the Browse tab having never opened the Auctions one. Turning that into a
|
||||
/// fresh owner query would land an `AUCTION_OWNED_LIST_UPDATE` on that never-shown tab — the
|
||||
/// nil `page` again, by a slower road. The reference cannot get there: its notification
|
||||
/// handlers patch the cached row, and an array it never fetched has no row to patch.
|
||||
#[test]
|
||||
fn a_refresh_never_introduces_a_list_the_interface_never_asked_for() {
|
||||
let mut open = AuctionOpen::default();
|
||||
open.open(0x1234, 1);
|
||||
|
||||
open.refresh_owner();
|
||||
open.refresh_bidder();
|
||||
assert!(
|
||||
!open.pending_owner_refresh && !open.pending_bidder_refresh,
|
||||
"we hold neither list, so neither can have gone stale"
|
||||
);
|
||||
|
||||
// The Auctions tab has now been shown once and the server answered — even with nothing in
|
||||
// it. From here a sale really does invalidate what the player is looking at.
|
||||
open.set_list(OWNER, Vec::new(), 0);
|
||||
open.refresh_owner();
|
||||
assert!(open.pending_owner_refresh, "a list we hold can go stale");
|
||||
assert!(
|
||||
!open.pending_bidder_refresh,
|
||||
"and the other one still cannot"
|
||||
);
|
||||
|
||||
// Closing forgets all of it — a notice landing after the window is gone re-asks nothing.
|
||||
open.clear();
|
||||
open.refresh_owner();
|
||||
assert!(!open.pending_owner_refresh);
|
||||
}
|
||||
|
||||
/// An accepted listing releases the sell slot. Without it the pane kept painting a phantom
|
||||
/// stack, Create Auction stayed enabled, and a second click re-resolved the remembered
|
||||
/// (bag, slot) at send time — auctioning whatever had since landed in that slot.
|
||||
|
||||
@@ -183,11 +183,33 @@ fn auction_command_result(
|
||||
// tell "the server said STARTED/OK" from "the server said nothing at all".
|
||||
auction.wire.last_command = Some((auction_id, action, error));
|
||||
if error == auction_error::OK {
|
||||
// A successful sell/cancel/bid changes a list we are showing. The reference re-queries
|
||||
// rather than patching its local copy, and so do we — the server is the only thing that
|
||||
// knows what the page looks like now. Each also says so, in **chat** — the success arm
|
||||
// shows `0x178`/`0x179`/`0x17f` keyed on the action field, with zero varargs
|
||||
// (wow-re §11.2/§11.3).
|
||||
// A successful sell/cancel/bid changes a list we are showing, so we re-ask: the server is
|
||||
// the only thing that knows what the page looks like now, and the result carries an
|
||||
// auction id, not a row.
|
||||
//
|
||||
// **The reference re-asks on two of these three, and neither ask is page 0** — VERIFIED
|
||||
// (wow-re auction node §12: a closed caller census of both list senders, two callers each
|
||||
// and zero address-takes; decision 2308):
|
||||
//
|
||||
// - `STARTED` — `[0xb7263c] = 1`, then `0x4cc4e0 call 0x4cd680` = `CMSG 0x259` at the
|
||||
// **saved page offset** `[0xb72650]`. No row patch, no event of its own.
|
||||
// - `BID_PLACED` — drops the id from the outbid list, `[0xb72640] = 1`, then
|
||||
// `0x4cc528 call 0x4cd720` = `CMSG 0x264` at `[0xb72654]` — and *then* patches the
|
||||
// browse row optimistically (bid ← the amount we sent, high bidder ← our own guid,
|
||||
// deadline ← `max(deadline, now + 90 s)`) and fires 424.
|
||||
// - `REMOVED` — **no query at all**: `0x4cc658 call 0x4cdfe0` deletes the id from all
|
||||
// three arrays locally, each list firing only if it actually lost a row.
|
||||
//
|
||||
// We re-ask on all three, always at page 0, and patch nothing. Two live differences fall
|
||||
// out: a player on page 2 of their own auctions is thrown back to page 1 by a sale, and
|
||||
// their own bid does not appear on the row until the next result lands. Both belong to the
|
||||
// freshness-model slice 2308 leaves open, not to this arm. What 2308 *does* enforce here
|
||||
// is that a re-ask may never **introduce** a list the interface never asked for — the
|
||||
// reference cannot, because its notification handlers patch instead of asking
|
||||
// (`AuctionOpen::refresh_owner`).
|
||||
//
|
||||
// Each success also says so, in **chat** — the success arm shows `0x178`/`0x179`/`0x17f`
|
||||
// keyed on the action field, with zero varargs (wow-re §11.2/§11.3).
|
||||
match action {
|
||||
auction_action::STARTED => {
|
||||
// The item is gone from the bag; the sell slot must stop claiming to hold it.
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
//! key-binding table the Key Bindings window edits, so an addon's binding is a row like any
|
||||
//! other; the app's dispatch runs its Lua body ([`crate::bindings`]).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bevy::prelude::*;
|
||||
@@ -781,8 +781,14 @@ pub(crate) struct InstalledAddOn {
|
||||
/// LoadOnDemand addon is not "off", it is waiting for a `LoadAddOn` call (1191 §6).
|
||||
/// Read by the char-select AddOns screen's rows (`char_select::addons`).
|
||||
pub(crate) load_on_demand: bool,
|
||||
/// From this character's enable file; an addon the file never mentions is **enabled** (1191 §7).
|
||||
pub(crate) enabled: bool,
|
||||
/// `## DefaultState:` — what this addon is for a character with no opinion, and the tie-break
|
||||
/// when the realm's characters disagree.
|
||||
///
|
||||
/// **This row carries no enable bit**, deliberately (decision 2311). It used to, read out of
|
||||
/// one character's file with "absent means enabled" baked in — which is the bug: an absent
|
||||
/// row is not enabled, it is [`EnableStore::enabled_for`]'s question, and the store needs the
|
||||
/// realm's whole character list to answer it. Metadata here, state there.
|
||||
pub(crate) default_state: bool,
|
||||
}
|
||||
|
||||
impl InstalledAddOn {
|
||||
@@ -814,14 +820,13 @@ impl InstalledAddOn {
|
||||
/// The array's *membership* needs nothing here: it excludes the addons the server hid, which on a
|
||||
/// stock install is Blizzard's twelve, and this walk is the player's own folder only — they were
|
||||
/// never in it.
|
||||
pub(crate) fn installed(identity: Option<&(String, String)>) -> Vec<InstalledAddOn> {
|
||||
let disabled = disabled_set(identity);
|
||||
pub(crate) fn installed_rows() -> Vec<InstalledAddOn> {
|
||||
// The player's own folder only: the glue's list is what a player can toggle, and the
|
||||
// reference's Blizzard addons are not in it.
|
||||
let mut rows: Vec<InstalledAddOn> = discover_folder()
|
||||
.into_iter()
|
||||
.map(|addon| InstalledAddOn {
|
||||
enabled: !disabled.contains(&addon.name.to_ascii_lowercase()),
|
||||
default_state: addon.toc.default_state(),
|
||||
title: addon.toc.directive("Title").map(str::to_owned),
|
||||
notes: addon.toc.directive("Notes").map(str::to_owned),
|
||||
url: addon.toc.directive("URL").map(str::to_owned),
|
||||
@@ -843,18 +848,115 @@ pub(crate) fn installed(identity: Option<&(String, String)>) -> Vec<InstalledAdd
|
||||
rows
|
||||
}
|
||||
|
||||
/// The lowercased names this character has turned off.
|
||||
fn disabled_set(identity: Option<&(String, String)>) -> HashSet<String> {
|
||||
enable_state_path(identity)
|
||||
.and_then(|p| std::fs::read(p).ok())
|
||||
.map(|b| {
|
||||
parse_enable_state(&benilla_ui::source::decode(&b))
|
||||
.into_iter()
|
||||
.filter(|(_, on)| !on)
|
||||
.map(|(n, _)| n.to_ascii_lowercase())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
// ── The enable store — the reference's `ADDONSTATELIST` ──────────────────────────────────────────
|
||||
|
||||
/// **One node per character on the realm's character list, each holding the explicit rows that
|
||||
/// character's `AddOns.txt` carried** — the reference's `ADDONSTATELIST` (anchor `0xbe1bd0`, head
|
||||
/// `ds:0xbe1bd8`), which `AddOnList_LoadCharacter 0x51ebe0` fills one node per character at
|
||||
/// char-list population (wow-5875-re `system/ui/scratch/addon-enable-store.md` §1/§5).
|
||||
///
|
||||
/// **A node with no file is EMPTY, not absent**, and the difference is the whole point: an empty
|
||||
/// node contributes no opinion to the aggregate, but it is still a character whose enable bit has
|
||||
/// to be answered — and the reference answers it from the *other* characters, never with a bare
|
||||
/// "enabled". That was the bug this type was written for (decision 2311): benilla resolved an
|
||||
/// absent row as enabled, so creating a character re-enabled every addon the player had just
|
||||
/// turned off, on every character at once.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct EnableStore {
|
||||
/// `(character, explicit rows)` in character-list order; the map is the node's enable hash,
|
||||
/// keyed lowercased because every compare in the reference is `SStrCmpI`.
|
||||
nodes: Vec<(String, HashMap<String, bool>)>,
|
||||
}
|
||||
|
||||
impl EnableStore {
|
||||
/// Load a node per character, each from its own `AddOns.txt`
|
||||
/// (`benilla-config/addons/<Realm>-<Char>.txt`, decision 1191 §7). A character with no file
|
||||
/// still gets its node.
|
||||
pub(crate) fn load(realm: &str, characters: &[String]) -> Self {
|
||||
let nodes = characters
|
||||
.iter()
|
||||
.map(|character| {
|
||||
let id = (realm.to_string(), character.clone());
|
||||
let rows = enable_state_path(Some(&id))
|
||||
.and_then(|p| std::fs::read(p).ok())
|
||||
.map(|b| parse_enable_state(&benilla_ui::source::decode(&b)))
|
||||
.unwrap_or_default();
|
||||
let hash = rows
|
||||
.into_iter()
|
||||
// Last line wins, like the reference's hash insert (§5's duplicate law).
|
||||
.map(|(name, on)| (name.to_ascii_lowercase(), on))
|
||||
.collect();
|
||||
(character.clone(), hash)
|
||||
})
|
||||
.collect();
|
||||
Self { nodes }
|
||||
}
|
||||
|
||||
/// `0x51e470(addon, NULL, useDefault = 0)` — the **explicit-only** aggregate. A node with no
|
||||
/// entry for this addon is not counted at all (`0x51e5df je 0x51e60a`).
|
||||
///
|
||||
/// `Some(v)` is the reference's 2/0 (every counted node agrees on `v`); `None` folds its two
|
||||
/// *undecided* returns together — the mixed `1` and the `total == 0` epilogue — because both
|
||||
/// resolve the same way one level up, through `DefaultState`.
|
||||
fn aggregate(&self, addon: &str) -> Option<bool> {
|
||||
let key = addon.to_ascii_lowercase();
|
||||
let mut total = 0usize;
|
||||
let mut on = 0usize;
|
||||
for (_, hash) in &self.nodes {
|
||||
if let Some(&v) = hash.get(&key) {
|
||||
total += 1;
|
||||
on += usize::from(v);
|
||||
}
|
||||
}
|
||||
match (total, on) {
|
||||
(0, _) => None,
|
||||
(_, 0) => Some(false),
|
||||
(t, o) if o == t => Some(true),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// **The bit a character actually gets** — `0x51e470(addon, character, useDefault = 1)`
|
||||
/// lowered to a bool (a single-character query returns only 0 or 2, §4).
|
||||
///
|
||||
/// Their own explicit row when their file has one; otherwise the aggregate above, and where
|
||||
/// that is undecided, the addon's `## DefaultState`. `None` is the no-character case (nobody
|
||||
/// picked yet), which is the aggregate alone.
|
||||
pub(crate) fn enabled_for(
|
||||
&self,
|
||||
addon: &str,
|
||||
default_state: bool,
|
||||
character: Option<&str>,
|
||||
) -> bool {
|
||||
let key = addon.to_ascii_lowercase();
|
||||
if let Some(explicit) = character
|
||||
.and_then(|c| self.node(c))
|
||||
.and_then(|h| h.get(&key))
|
||||
{
|
||||
return *explicit;
|
||||
}
|
||||
self.aggregate(addon).unwrap_or(default_state)
|
||||
}
|
||||
|
||||
fn node(&self, character: &str) -> Option<&HashMap<String, bool>> {
|
||||
self.nodes
|
||||
.iter()
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case(character))
|
||||
.map(|(_, hash)| hash)
|
||||
}
|
||||
}
|
||||
|
||||
/// The store's node set for a load: the realm's character list, with the loading character
|
||||
/// appended when the caller had no list to give (a test, a capture, a `.go`-style entry) — so a
|
||||
/// one-identity caller still gets its own file honoured, and nothing else.
|
||||
fn store_nodes(identity: Option<&(String, String)>, roster: &[String]) -> Vec<String> {
|
||||
let mut names = roster.to_vec();
|
||||
if let Some((_, character)) = identity {
|
||||
if !names.iter().any(|n| n.eq_ignore_ascii_case(character)) {
|
||||
names.push(character.clone());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Write a character's enable state — the AddOns screen's write (decision 1197) and, since 2139,
|
||||
@@ -967,6 +1069,7 @@ const SAVED_HEADER: &str = "\
|
||||
pub(super) fn load_third_party(
|
||||
script: &mut UiScript,
|
||||
identity: Option<&(String, String)>,
|
||||
roster: &[String],
|
||||
version_check: bool,
|
||||
) -> Vec<String> {
|
||||
let addons = discover();
|
||||
@@ -976,15 +1079,19 @@ pub(super) fn load_third_party(
|
||||
let mut infos: Vec<_> = addons.iter().map(info_for).collect();
|
||||
// The chain-sourced rows read through the reference's own store (1957).
|
||||
script.set_addon_chain_reader(Box::new(super::reference_ui::read));
|
||||
if let Some(text) = enable_state_path(identity).and_then(|p| std::fs::read_to_string(p).ok()) {
|
||||
for (name, enabled) in parse_enable_state(&text) {
|
||||
if let Some(i) = infos
|
||||
.iter_mut()
|
||||
.find(|i| i.name.eq_ignore_ascii_case(&name))
|
||||
{
|
||||
i.enabled = enabled;
|
||||
}
|
||||
}
|
||||
// Every addon's enable bit, through the reference's own query (decision 2311): this
|
||||
// character's explicit `AddOns.txt` row when they have one, else what the realm's other
|
||||
// characters agree on, else the manifest's `## DefaultState`. Reading an absent row as a
|
||||
// bare "enabled" is what re-enabled every addon the moment a character was created.
|
||||
let store = EnableStore::load(
|
||||
identity
|
||||
.map(|(realm, _)| realm.as_str())
|
||||
.unwrap_or_default(),
|
||||
&store_nodes(identity, roster),
|
||||
);
|
||||
let character = identity.map(|(_, c)| c.as_str());
|
||||
for (info, addon) in infos.iter_mut().zip(addons.iter()) {
|
||||
info.enabled = store.enabled_for(&info.name, addon.toc.default_state(), character);
|
||||
}
|
||||
let disabled_names: HashSet<String> = infos
|
||||
.iter()
|
||||
@@ -1328,7 +1435,7 @@ mod tests {
|
||||
// No `## Title` at all — sorts under its folder name, the comparator's own fallback.
|
||||
write_addon(&home, "bravo", "## Interface: 11200\n", &[]);
|
||||
|
||||
let rows = installed(None);
|
||||
let rows = installed_rows();
|
||||
assert_eq!(
|
||||
rows.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
|
||||
vec!["Zulu", "bravo", "Mike", "Alpha"],
|
||||
@@ -1454,7 +1561,7 @@ mod tests {
|
||||
"benilla.toc lists a Blizzard addon eagerly; the reference loads every one on demand"
|
||||
);
|
||||
// The glue's list is the player's folder alone.
|
||||
assert!(installed(None)
|
||||
assert!(installed_rows()
|
||||
.iter()
|
||||
.all(|a| !a.name.starts_with("Blizzard_")));
|
||||
}
|
||||
@@ -2102,7 +2209,7 @@ mod tests {
|
||||
let mut script = UiScript::new().unwrap();
|
||||
script.set_screen_size(1024.0, 768.0);
|
||||
script.run("EventLog = {}").unwrap();
|
||||
let failures = load_third_party(&mut script, None, true);
|
||||
let failures = load_third_party(&mut script, None, &[], true);
|
||||
assert!(failures.is_empty(), "load errors: {failures:?}");
|
||||
crate::ui_script::finish_ui_load(&mut script);
|
||||
|
||||
@@ -2175,7 +2282,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let mut script = UiScript::new().unwrap();
|
||||
let failures = load_third_party(&mut script, None, true);
|
||||
let failures = load_third_party(&mut script, None, &[], true);
|
||||
assert!(
|
||||
failures.iter().any(|f| f.contains("NeedsOld")),
|
||||
"the gated dependency is the dependent's failure: {failures:?}"
|
||||
@@ -2199,7 +2306,7 @@ mod tests {
|
||||
|
||||
// Force-load: the same folder, the checkbox's other state — everything loads.
|
||||
let mut open = UiScript::new().unwrap();
|
||||
let failures = load_third_party(&mut open, None, false);
|
||||
let failures = load_third_party(&mut open, None, &[], false);
|
||||
assert!(failures.is_empty(), "force-load load errors: {failures:?}");
|
||||
assert_eq!(
|
||||
open.eval::<bool>(
|
||||
@@ -2273,7 +2380,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let mut script = UiScript::new().unwrap();
|
||||
let failures = load_third_party(&mut script, None, true);
|
||||
let failures = load_third_party(&mut script, None, &[], true);
|
||||
assert!(failures.is_empty(), "load errors: {failures:?}");
|
||||
|
||||
// Discovery saw it at all — the `.toc` decoded rather than read as absent.
|
||||
@@ -2394,7 +2501,7 @@ mod tests {
|
||||
&[],
|
||||
);
|
||||
let mut script = UiScript::new().unwrap();
|
||||
let _ = load_third_party(&mut script, None, true);
|
||||
let _ = load_third_party(&mut script, None, &[], true);
|
||||
|
||||
// **One**, not one-plus-the-chain (decision 2175). The registry holds the chain's twelve
|
||||
// Blizzard rows too, but the Lua index space is a different set: `SMSG_ADDON_INFO` answers
|
||||
@@ -2496,7 +2603,7 @@ mod tests {
|
||||
|
||||
let mut script = UiScript::new().unwrap();
|
||||
let id = ("Realm".to_string(), "Char".to_string());
|
||||
let failures = load_third_party(&mut script, Some(&id), true);
|
||||
let failures = load_third_party(&mut script, Some(&id), &[], true);
|
||||
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
@@ -2554,7 +2661,7 @@ mod tests {
|
||||
);
|
||||
let mut script = UiScript::new().unwrap();
|
||||
script.set_screen_size(1024.0, 768.0);
|
||||
let _ = load_third_party(&mut script, None, true);
|
||||
let _ = load_third_party(&mut script, None, &[], true);
|
||||
|
||||
// Discovered and described, but NOT run.
|
||||
// **One**, not one-plus-the-chain (decision 2175). The registry holds the chain's twelve
|
||||
@@ -2650,7 +2757,7 @@ mod tests {
|
||||
);
|
||||
let mut script = UiScript::new().unwrap();
|
||||
script.set_screen_size(1024.0, 768.0);
|
||||
let _ = load_third_party(&mut script, None, true);
|
||||
let _ = load_third_party(&mut script, None, &[], true);
|
||||
|
||||
// The dependency loaded at startup and the registry knows it — the stamp this rests on.
|
||||
assert_eq!(
|
||||
@@ -2704,7 +2811,7 @@ mod tests {
|
||||
);
|
||||
let mut script = UiScript::new().unwrap();
|
||||
script.set_screen_size(1024.0, 768.0);
|
||||
let _ = load_third_party(&mut script, None, true);
|
||||
let _ = load_third_party(&mut script, None, &[], true);
|
||||
|
||||
assert_eq!(
|
||||
script
|
||||
@@ -2766,7 +2873,7 @@ mod tests {
|
||||
|
||||
let mut script = UiScript::new().unwrap();
|
||||
let id = ("Realm".to_string(), "Char".to_string());
|
||||
let _ = load_third_party(&mut script, Some(&id), true);
|
||||
let _ = load_third_party(&mut script, Some(&id), &[], true);
|
||||
|
||||
for (call, want) in [
|
||||
("LoadAddOn('NoSuchAddon')", "MISSING"),
|
||||
@@ -2788,6 +2895,75 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(home.parent().unwrap());
|
||||
}
|
||||
|
||||
/// **A character with no enable file does not load what the realm has unanimously turned
|
||||
/// off** — the world-entry half of the director's 2026-09-17 report (decision 2311).
|
||||
///
|
||||
/// The glue list showing a new character's addons as off would be worth nothing if the world
|
||||
/// then loaded them anyway, so the load walk resolves through the same [`EnableStore`]: the
|
||||
/// reference's `AddOn_CanLoad` check 3 is `0x51e470(name, character, useDefault = 1)`, which
|
||||
/// for a character with no explicit entry takes the explicit-only aggregate over the realm's
|
||||
/// other characters (wow-5875-re `addon-enable-store.md` §4).
|
||||
///
|
||||
/// The control is the second addon: one character left it on, so the roster does **not** agree
|
||||
/// about it, and the newcomer gets its manifest's `## DefaultState` instead — enabled. Without
|
||||
/// that half the test would pass on a client that simply disabled everything for new
|
||||
/// characters.
|
||||
#[test]
|
||||
fn a_character_with_no_file_inherits_the_rosters_unanimous_disable() {
|
||||
let _l = crate::local_state::test_env::ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
|
||||
let (home, _h) = hermetic_root("inherit");
|
||||
write_addon(
|
||||
&home,
|
||||
"Shunned",
|
||||
"## Interface: 11200\nran.lua\n",
|
||||
&[("ran.lua", "ShunnedRan = true")],
|
||||
);
|
||||
write_addon(
|
||||
&home,
|
||||
"Contested",
|
||||
"## Interface: 11200\nran.lua\n",
|
||||
&[("ran.lua", "ContestedRan = true")],
|
||||
);
|
||||
// Two characters who have played and saved: both turned `Shunned` off, they disagree
|
||||
// about `Contested`.
|
||||
for (who, contested) in [("Onemage", false), ("Onerogue", true)] {
|
||||
let id = ("Realm".to_string(), who.to_string());
|
||||
write_enable_state(
|
||||
Some(&id),
|
||||
&[("Shunned".into(), false), ("Contested".into(), contested)],
|
||||
);
|
||||
}
|
||||
let roster = [
|
||||
"Onemage".to_string(),
|
||||
"Onerogue".to_string(),
|
||||
"Freshling".to_string(),
|
||||
];
|
||||
|
||||
// …and the character just created, who has no file of their own.
|
||||
let fresh = ("Realm".to_string(), "Freshling".to_string());
|
||||
assert!(
|
||||
!enable_state_path(Some(&fresh)).unwrap().exists(),
|
||||
"the premise: a newly created character has written nothing"
|
||||
);
|
||||
let mut script = UiScript::new().unwrap();
|
||||
let _ = load_third_party(&mut script, Some(&fresh), &roster, true);
|
||||
|
||||
assert_eq!(
|
||||
script.eval::<bool>("return ShunnedRan == nil").ok(),
|
||||
Some(true),
|
||||
"the unanimous disable reaches the character who never expressed one"
|
||||
);
|
||||
assert_eq!(
|
||||
script.eval::<bool>("return ContestedRan == true").ok(),
|
||||
Some(true),
|
||||
"…and a contested addon falls to its `## DefaultState`, which is enabled"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(home.parent().unwrap());
|
||||
}
|
||||
|
||||
/// Enable state survives the session: `DisableAddOn` from Lua is written back in the
|
||||
/// reference's own `AddOns.txt` format, and read back as a disable next time.
|
||||
#[test]
|
||||
@@ -2807,7 +2983,7 @@ mod tests {
|
||||
let id = ("Realm".to_string(), "Char".to_string());
|
||||
|
||||
let mut script = UiScript::new().unwrap();
|
||||
let _ = load_third_party(&mut script, Some(&id), true);
|
||||
let _ = load_third_party(&mut script, Some(&id), &[], true);
|
||||
script.run("DisableAddOn('Drop')").unwrap();
|
||||
save_enable_state(&script, Some(&id));
|
||||
|
||||
@@ -2828,7 +3004,7 @@ mod tests {
|
||||
|
||||
// A fresh session reads it back and the addon stays off.
|
||||
let mut next = UiScript::new().unwrap();
|
||||
let _ = load_third_party(&mut next, Some(&id), true);
|
||||
let _ = load_third_party(&mut next, Some(&id), &[], true);
|
||||
assert_eq!(
|
||||
next.eval::<bool>("return DropRan == nil").ok(),
|
||||
Some(true),
|
||||
@@ -2879,7 +3055,7 @@ mod tests {
|
||||
// ── session one: defaults, then the addon changes them ──
|
||||
let mut script = UiScript::new().unwrap();
|
||||
script.set_screen_size(1024.0, 768.0);
|
||||
let failures = load_third_party(&mut script, Some(&id), true);
|
||||
let failures = load_third_party(&mut script, Some(&id), &[], true);
|
||||
assert!(failures.is_empty(), "{failures:?}");
|
||||
assert_eq!(
|
||||
script.eval::<i64>("return KeeperSawAtEvent").ok(),
|
||||
@@ -2894,7 +3070,7 @@ mod tests {
|
||||
// ── session two: a fresh VM reads them back ──
|
||||
let mut next = UiScript::new().unwrap();
|
||||
next.set_screen_size(1024.0, 768.0);
|
||||
let failures = load_third_party(&mut next, Some(&id), true);
|
||||
let failures = load_third_party(&mut next, Some(&id), &[], true);
|
||||
assert!(failures.is_empty(), "{failures:?}");
|
||||
assert_eq!(
|
||||
next.eval::<i64>("return KeeperDB.count").ok(),
|
||||
@@ -2945,7 +3121,7 @@ mod tests {
|
||||
);
|
||||
let id = ("Realm".to_string(), "Char".to_string());
|
||||
let mut script = UiScript::new().unwrap();
|
||||
let _ = load_third_party(&mut script, Some(&id), true);
|
||||
let _ = load_third_party(&mut script, Some(&id), &[], true);
|
||||
script
|
||||
.run("GramDB = { ['on'] = true, ['n'] = 2, ['s'] = 'a\\\"b', ['t'] = { 1 } } GramChar = 5")
|
||||
.unwrap();
|
||||
@@ -3007,7 +3183,7 @@ mod tests {
|
||||
let id = ("Realm".to_string(), "Char".to_string());
|
||||
let mut script = UiScript::new().unwrap();
|
||||
script.set_screen_size(1024.0, 768.0);
|
||||
let _ = load_third_party(&mut script, Some(&id), true);
|
||||
let _ = load_third_party(&mut script, Some(&id), &[], true);
|
||||
crate::ui_script::shutdown_ui_state(&mut script, Some(&id), true);
|
||||
|
||||
let written = std::fs::read_to_string(home.join("saved/Last.lua")).unwrap();
|
||||
@@ -3048,7 +3224,7 @@ mod tests {
|
||||
|
||||
let mut script = UiScript::new().unwrap();
|
||||
script.set_screen_size(1024.0, 768.0);
|
||||
let _ = load_third_party(&mut script, Some(&id), true);
|
||||
let _ = load_third_party(&mut script, Some(&id), &[], true);
|
||||
script.eval::<()>("DisableAddOn('MyAddon')").unwrap();
|
||||
save_enable_state(&script, Some(&id));
|
||||
|
||||
@@ -3132,7 +3308,7 @@ mod tests {
|
||||
AddonOrder = AddonOrder .. arg1 .. ',' end)",
|
||||
)
|
||||
.unwrap();
|
||||
let _ = load_third_party(&mut script, None, true);
|
||||
let _ = load_third_party(&mut script, None, &[], true);
|
||||
|
||||
// Neither ran at startup — the walk skips LoadOnDemand, which is why a cycle among them
|
||||
// is never observed until something demand-loads one.
|
||||
|
||||
@@ -457,6 +457,14 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
let identity = world
|
||||
.get_resource::<crate::char_select::Roster>()
|
||||
.and_then(crate::ui_macro::identity);
|
||||
// …and the realm's whole character list, which is the enable store's node set: the reference
|
||||
// populates one `ADDONSTATELIST` node per character at char-list time, and an addon this
|
||||
// character has never had an opinion about is resolved from what the others said
|
||||
// (decision 2311).
|
||||
let roster: Vec<String> = world
|
||||
.get_resource::<crate::char_select::Roster>()
|
||||
.map(|r| r.chars.iter().map(|c| c.name.clone()).collect())
|
||||
.unwrap_or_default();
|
||||
// **The Lua index space, before a single addon file runs** (decision 2175). The reference has
|
||||
// the array in hand well before `UI_Init 0x48fbf0` reaches the addon walk — `SMSG_ADDON_INFO`
|
||||
// lands during the handshake — so an addon reading `GetNumAddOns()` at file scope sees a
|
||||
@@ -576,7 +584,7 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
// through its whole load edge and only converged a frame later off whatever poll the caller
|
||||
// had written to survive it.
|
||||
seat_raster_seam_for_load(world, &mut script);
|
||||
let _ = load_ingame_ui(&mut script, identity.as_ref(), version_check);
|
||||
let _ = load_ingame_ui(&mut script, identity.as_ref(), &roster, version_check);
|
||||
// The Minimap widget was born a moment ago with `MinimapState::default()`; seed its two live
|
||||
// zoom indices from the persisted CVars now, before anything reads them — the reference's own
|
||||
// minimap reset path copying each CVar object's int into its live index (decision 1131). Once
|
||||
|
||||
@@ -303,8 +303,10 @@ pub(crate) fn load_font_registry(script: &UiScript) -> Vec<String> {
|
||||
/// an addon.
|
||||
///
|
||||
/// `identity` is `(realm, character)`, which names this character's AddOn enable-state file — the
|
||||
/// reference keys `AddOns.txt` per character too. `None` (no pick yet, a capture) means every
|
||||
/// discovered addon is enabled, the same answer an absent file gives.
|
||||
/// reference keys `AddOns.txt` per character too — and `roster` is every character on that realm's
|
||||
/// list, which is the enable store's node set (decision 2311: an addon this character has no row
|
||||
/// for is resolved from what the *other* characters said, never from a bare "enabled"). `None`
|
||||
/// with an empty roster is the no-pick case: every addon falls to its own `## DefaultState`.
|
||||
///
|
||||
/// `version_check` is the persisted `checkAddonVersion` — the *Load out of date AddOns* toggle,
|
||||
/// inverted — resolved by the caller because at load time this VM's own CVar table does not
|
||||
@@ -314,6 +316,7 @@ pub(crate) fn load_font_registry(script: &UiScript) -> Vec<String> {
|
||||
pub(crate) fn load_ingame_ui(
|
||||
script: &mut UiScript,
|
||||
identity: Option<&(String, String)>,
|
||||
roster: &[String],
|
||||
version_check: bool,
|
||||
) -> Vec<String> {
|
||||
// The whole load edge runs bounded (decision 1306): the reference files sourced off the
|
||||
@@ -332,6 +335,7 @@ pub(crate) fn load_ingame_ui(
|
||||
failures.extend(super::addons::load_third_party(
|
||||
script,
|
||||
identity,
|
||||
roster,
|
||||
version_check,
|
||||
));
|
||||
failures
|
||||
|
||||
@@ -256,6 +256,125 @@ fn auction_house_show_opens_the_window_on_the_browse_tab() {
|
||||
assert!(s.errors().is_empty(), "clean open: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The bug the per-list fires were made for** (decision 2308), in the stock addon's own Lua.
|
||||
///
|
||||
/// `AuctionFrameAuctions_Update` computes `offset + i + (NUM_AUCTION_ITEMS_PER_PAGE *
|
||||
/// AuctionFrameAuctions.page)` on its very first line of loop body, and `AuctionFrameAuctions.page`
|
||||
/// is assigned in exactly one place in the whole addon: that tab's `OnShow`, three lines below its
|
||||
/// `GetOwnerAuctionItems()`. So the event is only safe to fire *after* the Auctions tab has been
|
||||
/// shown at least once — which the reference guarantees structurally, because every caller of
|
||||
/// `GetOwnerAuctionItems` lives *inside* that pane (the `OnShow`, and the two page-turner buttons,
|
||||
/// which pass `AuctionFrameAuctions.page` as their argument).
|
||||
///
|
||||
/// The feed used to fire all three list events on any change, so the first browse result of the
|
||||
/// session ran this repaint on a tab nobody had opened and put
|
||||
/// `attempt to perform arithmetic on field 'page' (a nil value)` on the player's screen. This test
|
||||
/// is the hazard itself, pinned: it fails the day the stock Lua stops caring, and `ui_auction`'s
|
||||
/// own `a_result_owes_only_its_own_lists_event` is the guarantee that we never hand it that event.
|
||||
#[test]
|
||||
fn the_auctions_tab_cannot_repaint_before_it_has_been_shown() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().unwrap();
|
||||
load_ui_with_classes(&mut s);
|
||||
seat_player(&mut s, 500_000);
|
||||
s.set_auction(Some(state(vec![row(
|
||||
"Linen Cloth",
|
||||
1000,
|
||||
5000,
|
||||
0,
|
||||
"Seller",
|
||||
)])));
|
||||
|
||||
// Open, and land a browse result — the whole of what a search does. Clean.
|
||||
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
|
||||
s.fire_event("AUCTION_ITEM_LIST_UPDATE", vec![]);
|
||||
assert!(
|
||||
s.take_errors().is_empty(),
|
||||
"a browse result on the Browse tab is clean"
|
||||
);
|
||||
|
||||
// The same result also announcing the *owned* list is what crashed: the Auctions tab has never
|
||||
// been shown, so its `page` is nil and the repaint does arithmetic on it.
|
||||
s.fire_event("AUCTION_OWNED_LIST_UPDATE", vec![]);
|
||||
let errors = s.take_errors();
|
||||
assert!(
|
||||
errors.iter().any(|e| e.contains("page")),
|
||||
"the never-shown Auctions tab raises the nil-page error: {errors:?}"
|
||||
);
|
||||
|
||||
// And the same event is harmless the moment the tab has been shown once — which is the only
|
||||
// state the app can now produce it in, since the owned list is only ever asked for from there.
|
||||
s.run("AuctionFrameTab_OnClick(3)").unwrap();
|
||||
assert!(s.take_errors().is_empty(), "opening the tab is clean");
|
||||
s.fire_event("AUCTION_OWNED_LIST_UPDATE", vec![]);
|
||||
assert!(
|
||||
s.take_errors().is_empty(),
|
||||
"and now the owned list may announce itself"
|
||||
);
|
||||
}
|
||||
|
||||
/// Why only the Auctions tab was in the screenshot — and why that is an accident of the XML.
|
||||
///
|
||||
/// All three panes read their `page` field in `_Update`, and each assigns it in exactly one place:
|
||||
/// its own `OnShow`. What differs is *when* that `OnShow` first runs, and it is decided by one
|
||||
/// attribute in the stock file — `AuctionFrameBrowse` and `AuctionFrameAuctions` are declared
|
||||
/// `hidden="true"`, `AuctionFrameBid` is declared **`hidden="false"`**. So the Bids pane becomes
|
||||
/// visible with the window itself, its `OnShow` runs on the very first `AUCTION_HOUSE_SHOW` —
|
||||
/// before `AuctionFrameTab_OnClick(1)` hides it again — and `AuctionFrameBid.page` is `0` while
|
||||
/// the player is still looking at Browse. The Auctions pane gets nothing at all until tab 3.
|
||||
///
|
||||
/// That is the whole reason a mis-aimed `AUCTION_OWNED_LIST_UPDATE` crashed and a mis-aimed
|
||||
/// `AUCTION_BIDDER_LIST_UPDATE` did not — and the reason the fix is the rule (an event names one
|
||||
/// list) rather than a guard on the one tab that happened to be reported.
|
||||
#[test]
|
||||
fn only_the_bids_pane_gets_its_page_without_being_opened() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().unwrap();
|
||||
load_ui_with_classes(&mut s);
|
||||
|
||||
let page = |s: &UiScript, pane: &str| {
|
||||
s.eval::<String>(&format!("return tostring({pane}.page)"))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// At load, with no session: no pane has been shown, so none has a page.
|
||||
for pane in [
|
||||
"AuctionFrameBrowse",
|
||||
"AuctionFrameBid",
|
||||
"AuctionFrameAuctions",
|
||||
] {
|
||||
assert_eq!(page(&s, pane), "nil", "{pane} before the window opens");
|
||||
}
|
||||
|
||||
// The window opens on tab 1. Browse is shown by the tab click; Bid is shown by the window
|
||||
// itself and hidden again a moment later, keeping the page its OnShow assigned.
|
||||
seat_player(&mut s, 500_000);
|
||||
s.set_auction(Some(state(vec![row(
|
||||
"Linen Cloth",
|
||||
1000,
|
||||
5000,
|
||||
0,
|
||||
"Seller",
|
||||
)])));
|
||||
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
|
||||
assert_eq!(page(&s, "AuctionFrameBrowse"), "0");
|
||||
assert_eq!(
|
||||
page(&s, "AuctionFrameBid"),
|
||||
"0",
|
||||
"hidden=\"false\": the Bids pane rode the window up and back down"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("return AuctionFrameBid:IsShown()").unwrap(),
|
||||
"and it is not the visible tab"
|
||||
);
|
||||
assert_eq!(
|
||||
page(&s, "AuctionFrameAuctions"),
|
||||
"nil",
|
||||
"the Auctions pane is the one an unowed event can still kill"
|
||||
);
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The part that proves it works: a fed snapshot paints the Browse rows.
|
||||
#[test]
|
||||
fn the_browse_list_populates_from_the_fed_snapshot() {
|
||||
|
||||
@@ -171,6 +171,25 @@ impl Toc {
|
||||
pub fn load_on_demand(&self) -> bool {
|
||||
self.directive("LoadOnDemand").map(str::trim) == Some("1")
|
||||
}
|
||||
|
||||
/// `## DefaultState:` — what this addon's enable state is for a character who has never
|
||||
/// expressed one. The reference's `[rec+0x2b]`, stored by `Toc_Parse` at `0x51d204` from the
|
||||
/// two literals `"enabled"` (`0x853764`) → `1` and `"disabled"` (`0x853758`) → `0`
|
||||
/// (wow-5875-re `savedvariables-protocol.md`, the directive table).
|
||||
///
|
||||
/// It is load-bearing well beyond a manifest that writes it: the enable query `0x51e470`
|
||||
/// falls back to this byte whenever the characters disagree, and whenever *none* of them has
|
||||
/// an opinion at all — which is every addon on a fresh install.
|
||||
///
|
||||
/// **A manifest that does not write the line is enabled**, which is what makes a folder
|
||||
/// dropped into `AddOns/` just work. That is the two literals' fall-through, not a guess at
|
||||
/// the record's initial byte — a value that is neither `enabled` nor `disabled` reads the
|
||||
/// same way, because neither compare matches it and the field keeps whatever it had.
|
||||
pub fn default_state(&self) -> bool {
|
||||
!self
|
||||
.directive("DefaultState")
|
||||
.is_some_and(|v| v.trim().eq_ignore_ascii_case("disabled"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user