Files
benilla-octo/crates/benilla-ui/src/script/addon_gate.rs
T
SamandClaude Fable 5 047dd3444e the wheel binds, one AddOns screen, text at the true scale
- SetBinding accepts the mouse wheel — the refusal was ours, not the client's — so wheel-bound action buttons press and release like the real thing
- one addon load law: a single arbiter for "can this addon load, and why not", with the out-of-date version gate live
- one AddOns screen — the character-select list, wearing the authored reference layout: roster dropdown, per-character tri-state checkboxes, force-load; the ESC-menu panel is retired, since 1.12's own menu has no AddOns row
- the glyph atlas follows the window scale, so maximised text stops washing out and a title stops measuring wider than it draws; an unpositioned frame positions nothing, instead of landing at the screen origin

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 19:31:56 +07:00

276 lines
12 KiB
Rust

//! **The one load law — `AddOn_CanLoad 0x51e780`, as a pure function** (decision 1292).
//!
//! Every question of the form "can this addon load, and if not, why" has exactly one answer in
//! the reference: a single arbiter whose checks run in a fixed order, whose version gate and
//! CVar read re-run on every query, and whose two out-params (`reasonOut`, `depReasonOut`)
//! render through one formatter. benilla had three partial copies of it — the API verbs'
//! `not_loadable`, `LoadAddOn`'s inline checks, and the startup walk's disabled set — and the
//! screens were about to become a fourth. This module is the one copy they all consult.
//!
//! The check order, byte-verified (wow-re `system/ui/scratch/addon-version-gate.md` §2, a §5
//! pair + arbitration): **missing → visiting-guard (returns loadable — a cycle resolves
//! optimistically) → enabled → banned → corrupt → the version gate → required-deps recursion →
//! demand gate**. The first check that fires decides.
//!
//! ## The version gate (§2.1, byte for byte)
//!
//! `cmp [rec+0x1c], 11200` — an **exact `==` against a hard-coded immediate** (`0x51d7d0` is
//! `mov eax,0x2bc0; ret`, one caller image-wide). `## Interface: 11201` is as out of date as
//! `10000`, and a manifest with **no** `## Interface` line compares as `0` — out of date, not
//! "unknown". On mismatch the reason `7` is written and then, when the check is OFF
//! (`checkAddonVersion`, the *Load out of date AddOns* checkbox inverted), **actively reset to
//! `0`** — an out-of-date addon under force-load is byte-indistinguishable from an up-to-date
//! one; there is no "loadable but flagged" state. Force-load is a fall-through, not a success:
//! the dependency and demand checks still run.
//!
//! ## The reason rendering (§2.3)
//!
//! `reason != 0` → its token; `reason == 0 && depReason != 0` → `DEP_<token>`, applied
//! **exactly once at any nesting depth** (the recursion passes the caller's `depReasonOut` as
//! the callee's *both* out-params, so the deepest failure's raw token lands in the parent's dep
//! slot); both zero → nil (all three callers guard it — `LOADABLE` is unreachable as a reason).
//! `BANNED`/`CORRUPT`/`INSECURE` gate on the server's `SMSG_ADDON_INFO` signature state, which
//! benilla does not model (nothing a player installs is Blizzard-signed) — the arms exist in
//! the table for honesty and are never produced.
/// The `## Interface` this client implements — the reference's own hard-coded `0x2bc0`.
pub const CLIENT_INTERFACE: u32 = 11200;
/// One addon as the gate reads it — the adapter shape every registry (the VM's `AddOnInfo`
/// rows, the app's discovered manifests, the glue screen's folder view) lowers into.
pub struct GateRow<'a> {
pub name: &'a str,
/// This character's enable state (`AddOns.txt`; an addon nobody disabled is enabled).
pub enabled: bool,
/// `## Interface` as the client parses it ([`crate::toc::Toc::interface_version`]):
/// the leading integer, `0` when absent.
pub interface: u32,
pub load_on_demand: bool,
/// Loaded this session. A loaded dependency satisfies the recursion outright — VERIFIED at
/// the bytes (wow-re `addon-enable-store.md`, the follow-up carve): the short-circuit lives
/// inside `AddOn_CanLoad` itself (`0x51e8ba call AddOn_IsLoaded; jne` skips the recursion),
/// and the recursion propagates the caller's own `demandOnly` (`0x51e790` spill →
/// `0x51e8c9` reload). The concrete verdicts match this implementation exactly: a
/// LoadOnDemand addon over a loaded ordinary dep is loadable/nil; over an
/// enabled-but-UNLOADED ordinary dep it reports `DEP_NOT_DEMAND_LOADED`.
pub loaded: bool,
/// `## Dependencies` / `## RequiredDeps` / any `## Dep*` (one list in the reference).
pub dependencies: Vec<&'a str>,
}
/// The arbiter's answer: loadable, or the two out-params.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Loadable,
Refused {
/// The addon's own reason token (`DISABLED`, `INTERFACE_VERSION`, …), when the failure
/// is its own.
reason: Option<&'static str>,
/// The dependency's raw token when the failure is a dep's — rendered as `DEP_<token>`.
dep: Option<&'static str>,
},
}
impl Verdict {
pub fn loadable(self) -> bool {
self == Verdict::Loadable
}
/// The formatter (`0x51e930`) plus its callers' nil-guard: the reason token, else
/// `DEP_<dep>`, else `None` (loadable — `LOADABLE` is unreachable as a reason).
pub fn token(self) -> Option<String> {
match self {
Verdict::Loadable => None,
Verdict::Refused { reason, dep } => reason
.map(str::to_owned)
.or_else(|| dep.map(|d| format!("DEP_{d}"))),
}
}
}
/// `AddOn_CanLoad` over a registry: can `rows[index]` load right now?
///
/// `demand_only` is the in-game flavour (`dl=1`): an enabled, up-to-date, **non**-LoadOnDemand
/// addon that has not loaded reports `NOT_DEMAND_LOADED` — a state the glue (`dl=0`) never
/// produces. `version_check` is the live `checkAddonVersion` read; both re-run per query, which
/// is why toggling the checkbox needs no rescan (§2.2).
pub fn can_load(rows: &[GateRow], index: usize, demand_only: bool, version_check: bool) -> Verdict {
let mut visiting = vec![false; rows.len()];
walk(rows, index, demand_only, version_check, &mut visiting)
}
/// One level of the arbiter — the checks in the reference's evaluation order.
fn walk(
rows: &[GateRow],
i: usize,
demand_only: bool,
version_check: bool,
visiting: &mut [bool],
) -> Verdict {
// Check 2: the in-progress guard — a cycle resolves optimistically (`[rec+0x2d]` → TRUE).
if visiting[i] {
return Verdict::Loadable;
}
let row = &rows[i];
// Check 3: this character's enable state.
if !row.enabled {
return Verdict::Refused {
reason: Some("DISABLED"),
dep: None,
};
}
// Checks 4/5 (banned / corrupt): no server signature state to read — never produced.
// Check 6: the version gate — exact ==, then refuse or actively fall through (§2.1).
if row.interface != CLIENT_INTERFACE && version_check {
return Verdict::Refused {
reason: Some("INTERFACE_VERSION"),
dep: None,
};
}
// Check 7: required dependencies, recursively; the first failure decides, and the deepest
// failure's raw token is the one `DEP_` wraps (§2.3's shared out-param).
visiting[i] = true;
for dep in &row.dependencies {
let found = rows.iter().position(|r| r.name.eq_ignore_ascii_case(dep));
let verdict = match found {
// The recursion's check 1: a name not in the registry is MISSING.
None => Verdict::Refused {
reason: Some("MISSING"),
dep: None,
},
// A loaded dependency satisfies the walk outright — `0x51e8ba`, before the
// recursion (the row doc has the carve).
Some(d) if rows[d].loaded => Verdict::Loadable,
Some(d) => walk(rows, d, demand_only, version_check, visiting),
};
if let Verdict::Refused { reason, dep } = verdict {
visiting[i] = false;
return Verdict::Refused {
reason: None,
dep: reason.or(dep),
};
}
}
visiting[i] = false;
// Check 8: the demand gate — reachable only from the in-game surface (`dl=1`).
if demand_only && !row.load_on_demand && !row.loaded {
return Verdict::Refused {
reason: Some("NOT_DEMAND_LOADED"),
dep: None,
};
}
Verdict::Loadable
}
#[cfg(test)]
mod tests {
use super::*;
fn row<'a>(name: &'a str, deps: Vec<&'a str>) -> GateRow<'a> {
GateRow {
name,
enabled: true,
interface: CLIENT_INTERFACE,
load_on_demand: false,
loaded: false,
dependencies: deps,
}
}
/// The check ORDER is the law: a disabled addon reports only DISABLED, whatever else is
/// wrong with it — the glue colours that row grey, never red (1197's precedence).
#[test]
fn a_disabled_addon_reports_only_disabled() {
let mut a = row("A", vec!["Ghost"]);
a.enabled = false;
a.interface = 0; // out of date too — and still invisible behind DISABLED
let rows = vec![a];
assert_eq!(
can_load(&rows, 0, false, true).token().as_deref(),
Some("DISABLED")
);
}
/// §2.1 byte for byte: exact `==` (11201 is out of date), missing `## Interface` is 0 and
/// out of date, and force-load RESETS the reason — indistinguishable from up to date.
#[test]
fn the_version_gate_is_exact_and_force_load_erases_it() {
let mut a = row("A", vec![]);
a.interface = 11201;
let rows = vec![a];
assert_eq!(
can_load(&rows, 0, false, true).token().as_deref(),
Some("INTERFACE_VERSION")
);
assert_eq!(can_load(&rows, 0, false, false), Verdict::Loadable);
let mut b = row("B", vec![]);
b.interface = 0; // no ## Interface line
let rows = vec![b];
assert_eq!(
can_load(&rows, 0, false, true).token().as_deref(),
Some("INTERFACE_VERSION")
);
}
/// §2.3: `DEP_` is applied exactly once at any depth — the deepest failure's raw token is
/// what the top level wraps.
#[test]
fn dep_prefix_applies_once_at_any_depth() {
let a = row("A", vec!["B"]);
let b = row("B", vec!["C"]);
let mut c = row("C", vec![]);
c.enabled = false;
let rows = vec![a, b, c];
assert_eq!(
can_load(&rows, 0, false, true).token().as_deref(),
Some("DEP_DISABLED"),
"not DEP_DEP_DISABLED — the shared out-param collapses the nesting"
);
// And a dep that is not installed at all is the recursion's own check 1.
let rows = vec![row("A", vec!["Ghost"])];
assert_eq!(
can_load(&rows, 0, false, true).token().as_deref(),
Some("DEP_MISSING")
);
}
/// Check 2: a dependency cycle resolves optimistically (the visiting guard returns TRUE),
/// so neither side reports a failure from the query side.
#[test]
fn a_cycle_is_loadable_to_the_query() {
let a = row("Ping", vec!["Pong"]);
let b = row("Pong", vec!["Ping"]);
let rows = vec![a, b];
assert_eq!(can_load(&rows, 0, false, true), Verdict::Loadable);
}
/// Check 8 is in-game only (`dl=1`), runs AFTER the dep loop, and force-load does not skip
/// it — an out-of-date addon under force-load can still be NOT_DEMAND_LOADED.
#[test]
fn the_demand_gate_is_in_game_only_and_survives_force_load() {
let mut a = row("A", vec![]);
a.interface = 11507;
let rows = vec![a];
assert_eq!(can_load(&rows, 0, false, false), Verdict::Loadable);
assert_eq!(
can_load(&rows, 0, true, false).token().as_deref(),
Some("NOT_DEMAND_LOADED"),
"force-load falls THROUGH, it does not succeed (§2.1 fact 2)"
);
}
/// A LoadOnDemand addon whose required dep already loaded at startup is loadable from the
/// in-game surface — the loaded short-circuit, VERIFIED at `0x51e8ba` (inside `AddOn_CanLoad`,
/// before the recursion; the carve's own concrete A/B verdict is this assertion).
#[test]
fn a_loaded_dependency_satisfies_the_demand_query() {
let mut a = row("A", vec!["B"]);
a.load_on_demand = true;
let mut b = row("B", vec![]);
b.loaded = true;
let rows = vec![a, b];
assert_eq!(can_load(&rows, 0, true, true), Verdict::Loadable);
}
}