merge: absorb samwhosung/main on top of the upstream merge

Same fork policy: upstream structure wins, local-unique re-instated.

Cargo.toml unions both measured dev-profile philosophies (codegen-units=1 +
lto=off from samwhosung, debug=line-tables-only + split-debuginfo from the
fork's disk measurements).

glue_booth.rs takes samwhosung's typed GlueScene (token/of_race/from_model_path)
and re-adds the fork's TurtleWoW races (Goblin=9, BloodElf=10) as variants;
their glue fog stays on Human's row, exactly as the fork's scene_fog fallback did.

ui script takes samwhosung's mouseover/hyperlink rework (world_mouseover,
minimap_blip, hyperlink.rs) over the fork's inline hover_link/span_at/pointer
path — the rework covers the same OnHyperlinkEnter/Leave behaviour.

game_plugins.rs keeps the fork's re-measured ceilings (466 / 5,509); mail.rs
keeps the explicit 13-tuple with a float daysLeft; MAP.md takes the superset.
This commit is contained in:
chairmanpow
2026-10-12 17:24:31 +01:00
403 changed files with 52938 additions and 9951 deletions
+26 -27
View File
@@ -7,8 +7,9 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::flag;
use super::binding_abi::{bool_or_default, flag};
use super::Model;
use crate::script::binding_abi::Idx32;
/// The kind byte, bits 24-31 of the packed slot word (vmangos `Player.h:133`). Must match
/// `benilla_protocol`'s `ACTION_KIND_*`; this crate has no protocol dependency.
@@ -137,24 +138,13 @@ impl super::UiScript {
}
}
/// `UseAction`'s flag test: `nil`, `false` and `0` are false, unlike Lua, where `0` is true.
pub(super) fn truthy_nonzero(v: &Value) -> bool {
match v {
Value::Nil => false,
Value::Boolean(b) => *b,
Value::Integer(i) => *i != 0,
Value::Number(n) => *n != 0.0,
_ => true,
}
}
/// Register the action globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set(
"HasAction",
lua.create_function(|lua, action: u32| {
lua.create_function(|lua, Idx32(action): Idx32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(flag(model.actions.contains_key(&action)))
})?,
@@ -204,7 +194,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"GetActionTexture",
lua.create_function(|lua, action: u32| {
lua.create_function(|lua, Idx32(action): Idx32| {
let tex = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.actions.get(&action).and_then(|s| s.texture.clone())
@@ -252,12 +242,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UseAction",
lua.create_function(|lua, (action, rest): (u32, MultiValue)| {
// Both flags go through `GetBoolOrDefault` (`0x4e717e`, `0x4e716f`), default 0.
let check_cursor = bool_or_default(rest.front(), false);
let on_self = bool_or_default(rest.get(1), false);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let mut rest = rest.iter();
let check_cursor = rest.next().is_some_and(truthy_nonzero);
let on_self = rest.next().is_some_and(truthy_nonzero);
if check_cursor && model.cursor.is_some() {
super::cursor::place_action(&mut model, action);
drop(model);
super::cursor::place_action(lua, action);
} else {
model
.script_calls
@@ -269,7 +260,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"GetActionCount",
lua.create_function(|lua, action: u32| {
lua.create_function(|lua, Idx32(action): Idx32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model
.actions
@@ -310,7 +301,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"IsUsableAction",
lua.create_function(|lua, action: u32| {
lua.create_function(|lua, Idx32(action): Idx32| {
Ok((
state_flag(lua, action, |s| s.usable),
state_flag(lua, action, |s| s.not_enough_mana),
@@ -320,7 +311,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"IsActionInRange",
lua.create_function(|lua, action: u32| {
lua.create_function(|lua, Idx32(action): Idx32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(
match model
@@ -342,20 +333,26 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
g.set(
"IsCurrentAction",
lua.create_function(|lua, action: u32| Ok(state_flag(lua, action, |s| s.current)))?,
lua.create_function(|lua, Idx32(action): Idx32| {
Ok(state_flag(lua, action, |s| s.current))
})?,
)?;
g.set(
"IsAutoRepeatAction",
lua.create_function(|lua, action: u32| Ok(state_flag(lua, action, |s| s.auto_repeat)))?,
lua.create_function(|lua, Idx32(action): Idx32| {
Ok(state_flag(lua, action, |s| s.auto_repeat))
})?,
)?;
g.set(
"IsAttackAction",
lua.create_function(|lua, action: u32| Ok(state_flag(lua, action, |s| s.is_attack)))?,
lua.create_function(|lua, Idx32(action): Idx32| {
Ok(state_flag(lua, action, |s| s.is_attack))
})?,
)?;
// From the slot, not the state map: `0x4e5250` depends only on the item template.
g.set(
"IsConsumableAction",
lua.create_function(|lua, action: u32| {
lua.create_function(|lua, Idx32(action): Idx32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match model.actions.get(&action) {
Some(s) if s.consumable => Ok(Value::Integer(1)),
@@ -365,7 +362,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
g.set(
"IsEquippedAction",
lua.create_function(|lua, action: u32| Ok(state_flag(lua, action, |s| s.equipped)))?,
lua.create_function(|lua, Idx32(action): Idx32| {
Ok(state_flag(lua, action, |s| s.equipped))
})?,
)?;
// GetActionCooldown(action) → start, duration, enable, in `GetTime` seconds. An absent or
@@ -373,7 +372,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// replay the sweep and its finish flash.
g.set(
"GetActionCooldown",
lua.create_function(|lua, action: u32| {
lua.create_function(|lua, Idx32(action): Idx32| {
let now = crate::script::clock::now(lua);
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(
+304 -127
View File
@@ -28,12 +28,23 @@ pub struct AddOnInfo {
pub title: Option<String>,
pub notes: Option<String>,
pub url: Option<String>,
/// `## Secure: 1`, which Blizzard's own addons carry; drives the `security` token.
/// The security state SECURE (`[rec+0x24] == 0`), which the reference sets only for a valid
/// `%s.sig` (`0x6f10f0`, `0x51dbfb`); the host stands in for that check. Drives the `security`
/// token and the context the addon loads under ([`addon_load_context`]).
pub secure: bool,
pub load_on_demand: bool,
/// `## Dependencies` / `## RequiredDeps`.
pub dependencies: Vec<String>,
/// Every `## Key: Value` in manifest order, for `GetAddOnMetadata`.
/// `## OptionalDeps`: loaded first by `AddOn_Load`, a failure ignored (`0x51f320`).
pub optional_dependencies: Vec<String>,
/// `## LoadWith`: the addons whose load loads this one (`[rec+0x58]`, `0x51cf0a`-`0x51cf8c`).
pub load_with: Vec<String>,
/// The registry rows whose `## LoadWith` names this addon, in registry order (`[rec+0x88]`):
/// filled by [`super::UiScript::register_addons`], as the reference builds it once the scan
/// ends (`0x51c7c0`-`0x51c8c9`). `AddOn_Load` loads each after this one's `ADDON_LOADED`.
pub dependents: Vec<usize>,
/// The lines that reach the reference's metadata map in manifest order ([`crate::toc::Toc::metadata`]):
/// all `GetAddOnMetadata` answers from.
pub directives: Vec<(String, String)>,
/// The `.toc`'s file list in order: what `LoadAddOn` runs.
pub files: Vec<String>,
@@ -143,12 +154,14 @@ fn row_of(model: &Model, key: &AddonKey) -> Option<usize> {
}
/// The enable setter (`0x51ea20`), the one write every enable verb makes, for the current
/// character's row: the registry bit the gate reads, and the character's enable hash, which the
/// setter dirties only when the row changes or is new.
fn set_enabled(model: &mut Model, i: usize, on: bool) {
model.addons[i].enabled = on;
let name = model.addons[i].name.clone();
model.addon_enable.set(&name, on);
/// character's row: the character's enable hash, which the setter dirties only when the row
/// changes or is new, and, for an installed addon (`row`), the registry bit the gate reads. A
/// name nothing installed answers to still gets its hash row, which the logout writer saves.
fn set_enabled(model: &mut Model, row: Option<usize>, name: &str, on: bool) {
if let Some(i) = row {
model.addons[i].enabled = on;
}
model.addon_enable.set(name, on);
}
/// The registry as [`super::addon_gate`] rows, the one input every verb's verdict reads.
@@ -213,7 +226,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// In-game `0x48e390` returns seven: `name, title, notes, enabled, loadable, reason,
// security`, the flags the number 1 or nil (`lua_pushnumber` via `0x6f3810`). The glue's
// `0x46d460` returns eight, `url` fourth; in-game the URL is `GetAddOnMetadata(name, "URL")`.
// `0x46d460` returns eight, `url` fourth, from `0x51e180`: the server's, never the `.toc`'s.
// In-game no verb returns it.
g.set(
"GetAddOnInfo",
lua.create_function(|lua, key: Value| {
@@ -307,9 +321,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let Some(i) = row_of(&model, &key) else {
return Ok(Value::Nil);
};
// The map's insert replaces (`0x51d77b`): a key a host lists twice answers its last.
match model.addons[i]
.directives
.iter()
.rev()
.find(|(k, _)| k.eq_ignore_ascii_case(&field))
{
Some((_, v)) => lua_str(lua, v),
@@ -319,9 +335,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// One argument, keyed on the logged-in character (`0x48e690`, `0x48e760`, via `0x5abdc0`);
// the `(character, index)` form is the glue's (`0x46d7b0`, `0x46d8a0`). Deviation: an unknown
// name is a no-op, where the reference adds an enable-hash entry for it, so that a typo leaves
// no row behind.
// the `(character, index)` form is the glue's (`0x46d7b0`, `0x46d8a0`). A name goes to the
// setter as the caller spelled it (`0x6f3690`), installed or not: the setter never checks the
// installed list, and a name its hash lacks gets a new node (`0x51eaf2`, allocated `0x51eb62`,
// appended at the tail with the state at `0x51eb6f`).
for (name, on, usage) in [
("EnableAddOn", true, USAGE_ENABLE),
("DisableAddOn", false, USAGE_DISABLE),
@@ -330,10 +347,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
name,
lua.create_function(move |lua, key: Value| {
let mut model = lua.app_data_mut::<Model>().expect("model");
let key = addon_key(lua, &model, &key, usage)?;
if let Some(i) = row_of(&model, &key) {
set_enabled(&mut model, i, on);
}
let (row, name) = match addon_key(lua, &model, &key, usage)? {
AddonKey::Index(i) => (Some(i), model.addons[i].name.clone()),
AddonKey::Name(n) => (by_name(&model, &n), n),
};
set_enabled(&mut model, row, &name, on);
Ok(())
})?,
)?;
@@ -350,7 +368,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let mut model = lua.app_data_mut::<Model>().expect("model");
for k in 0..model.addon_index.len() {
let i = model.addon_index[k];
set_enabled(&mut model, i, on);
let name = model.addons[i].name.clone();
set_enabled(&mut model, Some(i), &name, on);
}
Ok(())
})?,
@@ -391,7 +410,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.expect("model")
.load_log
.demand_depth += 1;
let result = load_addon(lua, i);
let mut host = DemandLoad { lua };
let version_check = version_check(&lua.app_data_ref::<Model>().expect("model"));
let loaded = load_row(lua, &mut host, i, true, version_check);
{
let mut model = lua.app_data_mut::<Model>().expect("model");
let log = &mut model.load_log;
@@ -401,117 +422,200 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
log.writes.push(crate::status::LogWrite::Append(lines));
}
}
match result {
Ok(()) => Ok(MultiValue::from_vec(vec![Value::Integer(1), Value::Nil])),
Err(reason) => Ok(MultiValue::from_vec(vec![
Value::Nil,
lua_str(lua, &reason)?,
])),
if loaded {
return Ok(MultiValue::from_vec(vec![Value::Integer(1), Value::Nil]));
}
// `0x48eae7`: a refusal is explained by asking the gate again; a gate that now says yes
// leaves `UNKNOWN_ERROR` (`0x842ebc`), and an addon with no files to read is `MISSING`.
let reason = {
let model = lua.app_data_ref::<Model>().expect("model");
match verdict(&model, i).token() {
Some(reason) => reason,
None if !host.available(i) => "MISSING".to_owned(),
None => "UNKNOWN_ERROR".to_owned(),
}
};
Ok(MultiValue::from_vec(vec![
Value::Nil,
lua_str(lua, &reason)?,
]))
})?,
)?;
Ok(())
}
/// `AddOn_Load` (`0x51f240`), dependencies first; `Err` is the reason token. Synchronous inside
/// the Lua call, as stock callers use the addon on the next line (`UIParent.lua:577`).
fn load_addon(lua: &Lua, i: usize) -> Result<(), String> {
let (name, root, files, deps, already, chain, chain_reader) = {
/// The part of `AddOn_Load` (`0x51f240`) that differs by caller: the startup walk (`0x51f600`)
/// and `LoadAddOn` (`0x48e980`) share [`load_row`] and bring their own way to run an addon's files
/// and to report what failed. Rows are registry indices.
pub trait AddonLoad {
/// Whether row `i` has anything to read from: a capture has no AddOns folder. Asked after the
/// gate; an addon that answers no does not load.
fn available(&self, _i: usize) -> bool {
true
}
/// Row `i`'s own load, between its dependencies and `ADDON_LOADED`: its `.toc` files,
/// `Bindings.xml` and saved variables, under its load context (`0x51f3fa`-`0x51f53b`).
fn run(&mut self, i: usize);
/// The gate refused row `i` for a reason of its own (`DISABLED`, `INTERFACE_VERSION`, ...).
fn refused(&mut self, _i: usize, _reason: &str) {}
/// A required dependency of row `i` is missing or failed, whether the gate saw it before the
/// load began or the load found it (`0x51f357`).
fn dep_failed(&mut self, _i: usize, _dep: &str) {}
}
/// `AddOn_Load` (`0x51f240`) on registry row `i`, the one loader every surface runs; `demand` is
/// its `dl` (`LoadAddOn` 1, the startup walk 0) and `version_check` the live `checkAddonVersion`.
/// Returns the reference's bool. In its order:
/// - a loaded addon answers 1 at once (`0x51f2d6`);
/// - `AddOn_CanLoad` decides next (`0x51f2fa`), which already answers "can load" for an addon it is
/// checking, so a cycle of required dependencies passes;
/// - the addon is marked loaded (`0x51f313`) before any dependency, its files or `ADDON_LOADED`,
/// which is what ends a cycle: a re-entry takes the first line. Inside a cycle `IsAddOnLoaded`
/// is 1 from the addon's own files, and the second addon's `ADDON_LOADED` fires first;
/// - optional dependencies load, failures ignored (`0x51f320`); required ones load, and one that
/// fails clears the mark and fails this addon (`0x51f343`-`0x51f357`, `0x51f5ee`);
/// - the addon's files run, `ADDON_LOADED` fires (`0x51f5ad`), and each addon whose `## LoadWith`
/// names this one loads (`0x51f5b2`-`0x51f5e1`), failures ignored.
pub(super) fn load_row(
lua: &Lua,
host: &mut dyn AddonLoad,
i: usize,
demand: bool,
version_check: bool,
) -> bool {
let (name, optional, required, dependents) = {
let model = lua.app_data_ref::<Model>().expect("model");
let a = &model.addons[i];
(
a.name.clone(),
model.addons_root.clone(),
a.files.clone(),
a.dependencies.clone(),
a.loaded,
a.chain,
model.addons_chain_reader.clone(),
)
};
if already {
return Ok(()); // a redundant load succeeds, as in the reference
}
// `LoadAddOn` (`0x48e980`) refuses through `AddOn_CanLoad` and takes the reason from the same
// gate; an out-of-date addon loads only with the version check off (`0x48ea8c`).
{
let model = lua.app_data_ref::<Model>().expect("model");
if let refused @ Verdict::Refused { .. } = verdict(&model, i) {
return Err(refused.token().expect("a refusal always carries a token"));
if a.loaded {
return true;
}
}
// An absent source (a capture has no folder, a bare VM no chain) is `MISSING`.
let read: Reader = if chain {
let Some(reader) = chain_reader else {
return Err("MISSING".into());
};
Box::new(move |req: &str| reader(&format!("Interface/AddOns/{req}")))
} else {
let Some(root) = root else {
return Err("MISSING".into());
};
Box::new(move |req: &str| read_under(&root, req))
};
// Loaded is stamped after the gate (`0x51f2fa`), so a refusal stays retryable, and before the
// dependencies, which is what ends a cycle: `AddOn_Load` has no re-entrancy guard, but its
// store `0x51f313` precedes both dependency loops (`0x51f320`, `0x51f343`), so a re-entry
// takes the loaded early-out (`0x51f2d6`). Inside a cycle, then, `IsAddOnLoaded("A")` is 1
// while B runs and `ADDON_LOADED` fires B before A, as in the reference.
{
let mut model = lua.app_data_mut::<Model>().expect("model");
model.addons[i].loaded = true;
}
// Past the gate, a dependency that fails to load maps to its `DEP_` reason.
for dep in &deps {
let d = {
let model = lua.app_data_ref::<Model>().expect("model");
model
.addons
.iter()
.position(|a| a.name.eq_ignore_ascii_case(dep))
};
match d {
None => return Err("DEP_MISSING".into()),
Some(d) => {
let loaded = {
let model = lua.app_data_ref::<Model>().expect("model");
model.addons[d].loaded
};
if !loaded {
load_addon(lua, d).map_err(|r| {
if r.starts_with("DEP_") {
// `DEP_` applies once at any depth (`0x51e8ce`, `0x51e8cf`).
r
} else {
format!("DEP_{r}")
}
})?;
}
match can_load(&gate_rows(&model), i, demand, version_check) {
Verdict::Loadable => {}
Verdict::Refused {
reason: Some(reason),
..
} => {
drop(model);
host.refused(i, reason);
return false;
}
Verdict::Refused { reason: None, .. } => {
let dep = failing_dependency(&model, i, demand, version_check);
drop(model);
host.dep_failed(i, &dep);
return false;
}
}
(
a.name.clone(),
a.optional_dependencies.clone(),
a.dependencies.clone(),
a.dependents.clone(),
)
};
if !host.available(i) {
return false;
}
lua.app_data_mut::<Model>().expect("model").addons[i].loaded = true;
for dep in &optional {
let row = by_name(&lua.app_data_ref::<Model>().expect("model"), dep);
if let Some(d) = row {
load_row(lua, host, d, demand, version_check);
}
}
for dep in &required {
let row = by_name(&lua.app_data_ref::<Model>().expect("model"), dep);
if !row.is_some_and(|d| load_row(lua, host, d, demand, version_check)) {
lua.app_data_mut::<Model>().expect("model").addons[i].loaded = false;
host.dep_failed(i, dep);
return false;
}
}
host.run(i);
super::event::fire_global(lua, "ADDON_LOADED", &[super::ScriptValue::Str(name)]);
for d in dependents {
load_row(lua, host, d, demand, version_check);
}
true
}
/// The first required dependency of row `i` that cannot load, for the gate's `DEP_` refusal: a
/// name no addon answers to, or an unloaded one the gate refuses.
fn failing_dependency(model: &Model, i: usize, demand: bool, version_check: bool) -> String {
let rows = gate_rows(model);
let a = &model.addons[i];
a.dependencies
.iter()
.find(|dep| match by_name(model, dep) {
None => true,
Some(d) => {
!model.addons[d].loaded && !can_load(&rows, d, demand, version_check).loadable()
}
})
.or(a.dependencies.first())
.cloned()
.unwrap_or_default()
}
/// `LoadAddOn`'s way to run an addon: from the AddOns folder, or the patch chain for Blizzard's
/// own, into the demand-load record.
struct DemandLoad<'a> {
lua: &'a Lua,
}
impl DemandLoad<'_> {
/// The reader for row `i`'s files; `None` when its source is absent (a capture has no folder,
/// a bare VM no chain).
fn reader(&self, i: usize) -> Option<Reader> {
let model = self.lua.app_data_ref::<Model>().expect("model");
if model.addons[i].chain {
let reader = model.addons_chain_reader.clone()?;
Some(Box::new(move |req: &str| {
reader(&format!("Interface/AddOns/{req}"))
}))
} else {
let root = model.addons_root.clone()?;
Some(Box::new(move |req: &str| read_under(&root, req)))
}
}
}
impl AddonLoad for DemandLoad<'_> {
fn available(&self, i: usize) -> bool {
self.reader(i).is_some()
}
let mut toc = crate::status::Status::default();
run_files(lua, &name, &read, &files, &mut toc);
// The toc's record under its banner, then the add-on's (`0x6eddc8`, `0x51f464`).
let debug = framexml_debug(lua);
let mut own = crate::status::Status::default();
let toc_path = crate::status::addon_toc_path(&name);
toc.close_into(&mut own, debug, crate::status::toc_banner(&toc_path));
{
let mut model = lua.app_data_mut::<Model>().expect("model");
let banner = crate::status::addon_banner(&name);
own.close_into(&mut model.load_log.demand, debug, banner);
fn run(&mut self, i: usize) {
let lua = self.lua;
let Some(read) = self.reader(i) else { return };
let (name, files, secure) = {
let model = lua.app_data_ref::<Model>().expect("model");
let a = &model.addons[i];
(a.name.clone(), a.files.clone(), a.secure)
};
// The files, `Bindings.xml` and the saved variables run under the addon's context.
let outer = swap_exec_context(lua, addon_load_context(&name, secure));
let mut toc = crate::status::Status::default();
run_files(lua, &name, &read, &files, &mut toc);
// The toc's record under its banner, then the add-on's (`0x6eddc8`, `0x51f464`).
let debug = framexml_debug(lua);
let mut own = crate::status::Status::default();
let toc_path = crate::status::addon_toc_path(&name);
toc.close_into(&mut own, debug, crate::status::toc_banner(&toc_path));
{
let mut model = lua.app_data_mut::<Model>().expect("model");
let banner = crate::status::addon_banner(&name);
own.close_into(&mut model.load_log.demand, debug, banner);
}
// `Bindings.xml` loads between the files and the saved variables.
load_bindings(lua, &name, &read);
load_saved_variables(lua, i);
swap_exec_context(lua, outer);
}
// `Bindings.xml` loads between the files and the saved variables.
load_bindings(lua, &name, &read);
load_saved_variables(lua, i);
// `ADDON_LOADED` closes the addon's load (`0x51f5ad`).
super::event::fire_global(lua, "ADDON_LOADED", &[super::ScriptValue::Str(name)]);
Ok(())
}
/// Register the addon's `Bindings.xml` where the reference loads it (`0x51f400`): after the
@@ -525,7 +629,10 @@ fn load_bindings(lua: &Lua, name: &str, read: &Reader) {
match crate::bindings_xml::parse(&crate::source::decode(&bytes)) {
Ok(bindings) => super::keybind::register_addon_bindings(lua, name, &bindings),
// Reported, not swallowed: otherwise a broken binding file leaves keys dead silently.
Err(e) => log_error(lua, &format!("{name}/Bindings.xml: {e}")),
Err(e) => {
let msg = format!("{name}/Bindings.xml: {e}");
log_error(lua, &msg, &msg);
}
}
}
@@ -555,7 +662,8 @@ fn load_saved_variables(lua: &Lua, i: usize) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, // the first-run case
Err(e) => {
// Present but unreadable is held, like a file that will not run.
log_error(lua, &format!("{}: {e}", path.display()));
let msg = format!("{}: {e}", path.display());
log_error(lua, &msg, &msg);
let mut model = lua.app_data_mut::<Model>().expect("model");
if !model.held_saved_files.contains(&path) {
model.held_saved_files.push(path);
@@ -570,7 +678,11 @@ fn load_saved_variables(lua: &Lua, i: usize) {
) {
// Deviation: the reference fails silently; we report it, as a player needs to know,
// and hold the file so the shutdown write keeps it.
log_error(lua, &format!("{}: {e}", path.display()));
log_error(
lua,
&format!("{}: {e}", path.display()),
&super::lua_message(&e),
);
let mut model = lua.app_data_mut::<Model>().expect("model");
if !model.held_saved_files.contains(&path) {
model.held_saved_files.push(path);
@@ -616,7 +728,7 @@ fn run_files(lua: &Lua, name: &str, read: &Reader, files: &[String], toc: &mut S
};
if lua_file {
if let Err(e) = run_chunk(lua, &bytes, &crate::script::addon_chunk_name(name, file)) {
log_error(lua, &format!("{name}/{file}: {e}"));
log_error(lua, &format!("{name}/{file}: {e}"), &super::lua_message(&e));
}
continue;
}
@@ -630,15 +742,15 @@ fn run_files(lua: &Lua, name: &str, read: &Reader, files: &[String], toc: &mut S
continue;
}
};
let report = crate::loader::load_into_under(lua, &doc, &path, &provider, ADDONS_ROOT);
let mut report = crate::loader::load_into_under(lua, &doc, &path, &provider, ADDONS_ROOT);
let banner = crate::status::file_banner(&install);
report.status.close_into(toc, framexml_debug(lua), banner);
std::mem::take(&mut report.status).close_into(toc, framexml_debug(lua), banner);
// Warnings take the `<Addon>/<file>` prefix; a bare loader warning names no document.
for w in report.warnings {
for w in &report.warnings {
crate::script::diagnostics::record_warning(lua, &format!("{name}/{file}: {w}"));
}
for e in report.errors {
log_error(lua, &format!("{name}/{file}: {e}"));
for (e, heard) in report.raised() {
log_error(lua, &format!("{name}/{file}: {e}"), heard);
}
}
}
@@ -683,17 +795,40 @@ fn run_chunk(lua: &Lua, bytes: &[u8], name: &str) -> mlua::Result<()> {
}
/// Record a demand-load error as the startup walk does ([`super::UiScript::report_script_error`]):
/// a `Load` row, dispatched to the player's error handler and put on the host drain.
fn log_error(lua: &Lua, msg: &str) {
/// a `Load` row, dispatched to the player's error handler and put on the host drain. `handler` is
/// the bare Lua message of a raise; a fault that is no raise passes its own text.
fn log_error(lua: &Lua, msg: &str, handler: &str) {
let msg = format!("LoadAddOn: {msg}");
super::diagnostics::record_load_failure(lua, &msg);
let mut model = lua.app_data_mut::<Model>().expect("model");
model.pending_error_dispatch.push(msg.clone());
model.pending_error_dispatch.push(handler.to_string());
model.errors.push(msg);
}
/// The script-execution context an addon's load runs under: `AddOn_Load` sets `[0xceeac0]` to the
/// record's name (`[rec+0x14]`) when its security state `[rec+0x24]` is non-zero, else to 0
/// (`0x51f37e`-`0x51f387`), so only a secure addon loads under the UI's own context. `None` is 0.
pub fn addon_load_context(name: &str, secure: bool) -> Option<String> {
(!secure).then(|| name.to_owned())
}
/// Set the context word, returning the one it replaces for the caller to restore, as `AddOn_Load`
/// saves it (`0x51f36d`) and restores it before `ADDON_LOADED` (`0x51f584`).
fn swap_exec_context(lua: &Lua, context: Option<String>) -> Option<String> {
lua.app_data_ref::<Model>()
.expect("model")
.exec_context
.replace(context)
}
/// The host's side: the registry, where its files live, and what to write back.
impl super::UiScript {
/// Set the script-execution context for the startup walk's addon loads
/// ([`addon_load_context`]), returning the previous one to restore after.
pub fn swap_exec_context(&self, context: Option<String>) -> Option<String> {
swap_exec_context(self.lua(), context)
}
/// Seat the reader chain-sourced addons (`AddOnInfo::chain`) are read through.
pub fn set_addon_chain_reader(&self, reader: AddonChainReader) {
self.model_mut().addons_chain_reader = Some(std::rc::Rc::from(reader));
@@ -710,6 +845,21 @@ impl super::UiScript {
for a in &mut addons {
a.saved_enabled = a.enabled;
}
// The reverse of `## LoadWith`, once the whole list is known (`0x51c7c0`-`0x51c8c9`): each
// addon is appended to every addon it names, in list order, a repeat included.
for a in &mut addons {
a.dependents.clear();
}
for r in 0..addons.len() {
for t in 0..addons[r].load_with.len() {
let target = addons
.iter()
.position(|a| a.name.eq_ignore_ascii_case(&addons[r].load_with[t]));
if let Some(target) = target {
addons[target].dependents.push(r);
}
}
}
let mut model = self.model_mut();
model.addons = addons;
model.addons_root = root;
@@ -729,8 +879,35 @@ impl super::UiScript {
rebuild_index(&mut model);
}
/// The registry row a folder name answers to, case-insensitively.
pub fn addon_row(&self, name: &str) -> Option<usize> {
by_name(&self.model_ref(), name)
}
/// The registry rows whose `## LoadWith` names row `i`, in the order `AddOn_Load` loads them
/// after its `ADDON_LOADED` (`0x51f5b2`-`0x51f5e1`).
pub fn addon_dependents(&self, i: usize) -> Vec<usize> {
self.model_ref()
.addons
.get(i)
.map(|a| a.dependents.clone())
.unwrap_or_default()
}
/// `AddOn_Load` (`0x51f240`) on registry row `i` ([`load_row`]) for a host that runs the
/// addon's files itself, as the startup walk does (`demand` false).
pub fn load_addon_row(
&self,
host: &mut dyn AddonLoad,
i: usize,
demand: bool,
version_check: bool,
) -> bool {
load_row(self.lua(), host, i, demand, version_check)
}
/// Run one addon's saved-variables files at the startup walk's position, as `LoadAddOn` does.
pub fn load_addon_saved_variables(&mut self, name: &str) {
pub fn load_addon_saved_variables(&self, name: &str) {
let i = self
.model_ref()
.addons
+127 -40
View File
@@ -8,8 +8,9 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::flag;
use super::cursor::{self, CursorPayload};
use super::cursor;
use super::Model;
use crate::script::binding_abi::{Idx, Idx32};
/// The three list types, in the order the app pushes them.
pub const LIST: usize = 0;
@@ -247,9 +248,13 @@ impl super::UiScript {
.map(|it| (it.bag, it.slot))
}
/// Empty the sell slot, once the auction is sent and on session close.
/// Empty the sell slot, once the auction is sent and on session close: the sell-slot clear
/// (`0x4cd640`), which lifts the attached item's lock with its `ITEM_LOCK_CHANGED`
/// (`0x495420` at `0x4cd653`).
pub fn clear_auction_sell_item(&mut self) {
self.model_mut().auction_sell_item = None;
if self.model_mut().auction_sell_item.take().is_some() {
self.fire_event("ITEM_LOCK_CHANGED", Vec::new());
}
}
}
@@ -337,7 +342,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// GetAuctionItemInfo(type, index) → twelve values; `highBidder` is a name, `1` or nil.
g.set(
"GetAuctionItemInfo",
lua.create_function(|lua, (kind, index): (String, usize)| {
lua.create_function(|lua, (kind, index): (String, Value)| {
let index = crate::script::binding_abi::index_arg(
lua,
index,
"Usage: GetAuctionItemInfo(\"type\", index)",
)?;
let row = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
row_at(&model, &kind, index)
@@ -399,7 +409,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// live countdown: it moves only when a new list lands.
g.set(
"GetAuctionItemTimeLeft",
lua.create_function(|lua, (kind, index): (String, usize)| {
lua.create_function(|lua, (kind, Idx(index)): (String, Idx)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(row_at(&model, &kind, index).map_or(0, |r| i64::from(r.time_left)))
})?,
@@ -407,7 +417,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"GetAuctionItemLink",
lua.create_function(|lua, (kind, index): (String, usize)| {
lua.create_function(|lua, (kind, index): (String, Value)| {
let index = crate::script::binding_abi::index_arg(
lua,
index,
"Usage: GetAuctionItemLink(\"type\", index)",
)?;
let link = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
row_at(&model, &kind, index).and_then(|r| r.link)
@@ -446,7 +461,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// SetSelectedAuctionItem(type, index): remembers the auction at that row; out of range clears.
g.set(
"SetSelectedAuctionItem",
lua.create_function(|lua, (kind, index): (String, usize)| {
lua.create_function(|lua, (kind, Idx(index)): (String, Idx)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(i) = list_index(&kind) else {
return Ok(());
@@ -644,17 +659,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// GetOwnerAuctionItems([page]) / GetBidderAuctionItems([page]): unthrottled, page 0 by default.
g.set(
"GetOwnerAuctionItems",
lua.create_function(|lua, page: Option<u32>| {
lua.create_function(|lua, page: Option<Value>| {
// A page that is not a number is page 0 (`0x4cecaa`, `0x4cecfa`): the query never
// raises, and `trunc(n)` is the signed page (`0x40a2b0`).
let page = crate::script::binding_abi::coerced_number(lua, page);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.auction_owner_query = Some(page.unwrap_or(0));
model.auction_owner_query = Some(crate::script::binding_abi::ftol(page) as u32);
Ok(())
})?,
)?;
g.set(
"GetBidderAuctionItems",
lua.create_function(|lua, page: Option<u32>| {
lua.create_function(|lua, page: Option<Value>| {
// A page that is not a number is page 0 (`0x4cecaa`, `0x4cecfa`): the query never
// raises, and `trunc(n)` is the signed page (`0x40a2b0`).
let page = crate::script::binding_abi::coerced_number(lua, page);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.auction_bidder_query = Some(page.unwrap_or(0));
model.auction_bidder_query = Some(crate::script::binding_abi::ftol(page) as u32);
Ok(())
})?,
)?;
@@ -693,7 +714,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// CancelAuction(index), always an "owner" row.
g.set(
"CancelAuction",
lua.create_function(|lua, index: u32| {
lua.create_function(|lua, Idx32(index): Idx32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.auction_cancels.push(index);
Ok(())
@@ -770,13 +791,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// ClickAuctionSellItemButton(): attach the cursor's item, or pick the attached one back up;
// every cursor write queues both CURSOR_UPDATE and ITEM_LOCK_CHANGED.
// ClickAuctionSellItemButton() (`0x4ce310`): attach the cursor's item, putting an earlier
// attachment back on the cursor ([`cursor::click_attach_slot`]). The app signals
// `NEW_AUCTION_UPDATE` when it sees the sell slot change.
g.set(
"ClickAuctionSellItemButton",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
click_auction_sell_item(&mut model);
cursor::click_attach_slot(lua, |model| &mut model.auction_sell_item);
Ok(())
})?,
)?;
@@ -813,32 +834,31 @@ const AUCTION_INV_TYPES: [(u32, &str); 14] = [
(23, "INVTYPE_HOLDABLE"),
];
/// Attach the cursor's item to the sell slot, or with an empty cursor pick the attached one back
/// up; any other payload stays on the cursor.
fn click_auction_sell_item(model: &mut Model) {
match model.cursor.take() {
Some(CursorPayload::Item(item)) => {
let (bag, slot) = (item.bag, item.slot);
model.auction_sell_item = Some(item);
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, bag, slot);
}
None => {
if let Some(item) = model.auction_sell_item.take() {
let (bag, slot) = (item.bag, item.slot);
model.cursor = Some(CursorPayload::Item(item));
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, bag, slot);
}
}
Some(other) => {
model.cursor = Some(other);
}
}
}
#[cfg(test)]
mod tests {
/// The owner and bidder queries (`0x4cec80`, `0x4cecd0`) read `trunc(n)` as the page and take
/// anything else for page 0; they never raise.
#[test]
fn owner_and_bidder_queries_default_to_page_zero_and_never_raise() {
let mut s = crate::script::UiScript::new().unwrap();
for (call, page) in [
("()", 0),
("(2)", 2),
("(2.9)", 2),
("('x')", 0),
("({})", 0),
("(nil)", 0),
("(-1)", u32::MAX),
] {
s.run(&format!(
"GetOwnerAuctionItems{call} GetBidderAuctionItems{call}"
))
.unwrap_or_else(|e| panic!("{call} raised: {e}"));
assert_eq!(s.take_auction_owner_query(), Some(page), "owner {call}");
assert_eq!(s.take_auction_bidder_query(), Some(page), "bidder {call}");
}
}
use super::*;
use crate::script::UiScript;
@@ -1107,4 +1127,71 @@ mod tests {
assert!(!list.reversed("level"));
assert!(!list.reversed("seller"), "never sorted by: not reversed");
}
/// The attach clears with `SetCursorItem(0, …)` (`0x4ce56a`): no unlock and no
/// `ITEM_LOCK_CHANGED`, so the item stays locked in its bag and cannot be picked up again,
/// until the sell-slot clear lifts the lock with its event (`0x4cd653`).
#[test]
fn an_item_in_the_sell_slot_keeps_its_lock_until_it_leaves() {
use crate::script::cursor::{bag_lock_probe, two_item_bag};
use crate::script::tick::heard;
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(two_item_bag()));
s.run("PickupContainerItem(0, 1)").unwrap();
heard::listen(
&s,
&["ITEM_LOCK_CHANGED", "CURSOR_UPDATE"],
Some(&bag_lock_probe(1)),
);
s.run("ClickAuctionSellItemButton()").unwrap();
assert_eq!(heard::take(&s), ["CURSOR_UPDATE 1"]);
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(
s.cursor_payload().is_none(),
"a locked item is not picked up"
);
s.clear_auction_sell_item();
assert_eq!(heard::take(&s), ["ITEM_LOCK_CHANGED nil"]);
s.clear_auction_sell_item();
assert!(
heard::take(&s).is_empty(),
"an empty slot's clear signals nothing"
);
}
/// An occupied sell slot puts the old item back on the cursor through `SetCursorItem(0, …)`
/// (`0x4ce4f2`-`0x4ce56c`), both items staying locked; with nothing held and nothing attached
/// the click is `ClearCursor(1,1)` (`0x4ce523`).
#[test]
fn an_attach_onto_an_occupied_sell_slot_puts_the_old_item_on_the_cursor() {
use crate::script::cursor::{bag_lock_probe, install_kind_probe, two_item_bag};
use crate::script::tick::heard;
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(two_item_bag()));
install_kind_probe(&s);
s.run("PickupContainerItem(0, 1) ClickAuctionSellItemButton() PickupContainerItem(0, 2)")
.unwrap();
heard::listen(
&s,
&["CURSOR_UPDATE", "ITEM_LOCK_CHANGED"],
Some("CursorKind()"),
);
s.run("ClickAuctionSellItemButton()").unwrap();
assert_eq!(
heard::take(&s),
["CURSOR_UPDATE none", "CURSOR_UPDATE item"]
);
assert_eq!(s.cursor_item().map(|i| i.slot), Some(1));
assert_eq!(s.auction_sell_item(), Some((0, 2)));
for slot in [1, 2] {
let probe = bag_lock_probe(slot);
assert!(s.eval::<bool>(&format!("return {probe} == 1")).unwrap());
}
s.run("ClearCursor() ClickAuctionSellItemButton() ClearCursor()")
.unwrap();
assert_eq!(s.auction_sell_item(), None);
heard::take(&s);
s.run("ClickAuctionSellItemButton()").unwrap();
assert_eq!(heard::take(&s), ["CURSOR_UPDATE none"]);
}
}
+13 -7
View File
@@ -103,7 +103,7 @@ pub(crate) fn auras_of<'m>(model: &'m Model, token: &str) -> mlua::Result<Option
}
/// The `index`-th (1-based) buff (`helpful`) or debuff of `token`, in pushed order; out of range
/// answers nothing, the loop terminator.
/// answers one nil on every miss leg (`0x5195fe`, `0x5197d4`, `0x519a51`), the loop terminator.
fn nth_aura(
lua: &Lua,
token: &Option<String>,
@@ -128,7 +128,7 @@ fn nth_aura(
};
match hit {
Some(a) => returns(lua, &a, !helpful),
None => Ok(MultiValue::new()),
None => Ok(MultiValue::from_vec(vec![Value::Nil])),
}
}
@@ -319,11 +319,17 @@ mod tests {
assert!(s
.eval::<bool>(r#"return UnitBuff("target", 1) == nil"#)
.unwrap());
assert_eq!(
s.arity(r#"UnitBuff("player", 0)"#).unwrap(),
0,
"a miss answers nothing"
);
// One nil on every miss leg (`0x5195fe`, `0x5197d4`, `0x519a51`): an index out of range,
// zero or negative, and a unit with no list.
for call in [
r#"UnitBuff("player", 0)"#,
r#"UnitBuff("player", 9)"#,
r#"UnitDebuff("player", 9)"#,
r#"UnitBuff("target", 1)"#,
r#"UnitDebuff("target", 1)"#,
] {
assert_eq!(s.arity(call).unwrap(), 1, "{call} answers one nil");
}
}
#[test]
+457 -200
View File
@@ -1,8 +1,17 @@
//! The frame backdrop as the 1.12 client builds it: the tiled background and 8-piece border that a
//! `<Backdrop>` element or `SetBackdrop` installs. This module owns the data ([`Backdrop`]) and the
//! geometry ([`pieces`], screen-space quads with per-corner UVs); the app renders them.
//! The frame backdrop as the 1.12 client builds it: a `<Backdrop>` element or `SetBackdrop`
//! installs a spec ([`Backdrop`], the 0x68-byte struct at `frame+0x1ac`) and builds up to nine
//! Texture regions on the frame from it (`0x77e8d0`), the background on BACKGROUND and the eight
//! border pieces on BORDER. They are ordinary regions: `GetRegions` returns them, the region draw
//! path paints them, and every Texture verb works on them. The spec drives them: the colour
//! setters tint them and a resize re-runs their texcoords (`0x77f0c0`).
use crate::layout::Rect;
use std::collections::HashMap;
use crate::layout::{self, Anchor, Anchors, Point, Rect};
use crate::order::DrawLayer;
use crate::widget::{FrameHandle, RegionHandle, RegionKind};
use super::{Model, RegionData, TexCoords};
/// The default `edgeSize`, logical 32: the constructor writes device 0.025 (`DAT_0081c9b0`).
pub const DEFAULT_EDGE_SIZE: f32 = 32.0;
@@ -21,12 +30,12 @@ pub struct Insets {
/// parser defaults a present but partial `<Color>` or `<BorderColor>` to black.
#[derive(Clone, Debug, PartialEq)]
pub struct Backdrop {
/// The background texture (`+0x24`); none or empty draws no background.
/// The background texture (`+0x24`); none or empty builds no background.
pub bg_file: Option<String>,
/// The 8-slice border texture (`+0x30`); none or empty draws no border.
/// The 8-slice border texture (`+0x30`); none or empty builds no border.
pub edge_file: Option<String>,
/// Tile the background, else stretch it (`+0x40`).
pub tile: bool,
pub(crate) tile: bool,
/// `tileSize` in logical px (`+0x4c`); 0 tiles at `edge_size` (`0x77f0c0`).
pub tile_size: f32,
/// `edgeSize` in logical px (`+0x48`): the border piece size and the edge tiling period.
@@ -64,54 +73,147 @@ impl Backdrop {
}
}
/// One drawable piece, the background or one of the 8 border pieces: explicit corners, not a rect,
/// so the bottom-right inset quirk can slant it, and per-corner UVs, since the top and bottom edges
/// are rotated 90° (`0x77f0c0`). Both arrays run `[TL, TR, BR, BL]`, y-up.
/// One drawable piece, the background or one of the 8 border pieces: its corners, axis-aligned,
/// and per-corner UVs, since the top and bottom edges are rotated 90° (`0x77f0c0`). Both arrays
/// run `[TL, TR, BR, BL]`, y-up.
#[cfg(test)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BackdropPiece {
pub(crate) struct BackdropPiece {
/// Corner positions in screen px.
pub corners: [[f32; 2]; 4],
pub(crate) corners: [[f32; 2]; 4],
/// Corner UVs in texture space.
pub uvs: [[f32; 2]; 4],
pub(crate) uvs: [[f32; 2]; 4],
/// The texture needs repeat addressing. The corners stay in `[0,1]` but share the edge texture,
/// so they carry `true` too and the border batches as one texture (`0x77e8d0`).
pub tile: bool,
/// Drawn with `bg_file` and `bg_color`, else with `edge_file` and `border_color`.
pub is_bg: bool,
pub(crate) is_bg: bool,
}
const SLICE: f32 = 0.125;
/// Pull a piece's UVs half a texel inward on every axis that does not tile, so bilinear
/// magnification cannot sample the neighbouring slice of the shared border atlas (in
/// `ChatBubble-Backdrop`, a white alpha-0 column: a pale line through the speech bubble). A tiling
/// axis, a strip's `v` past 1, is left exact, or each period would land short and walk the art.
#[must_use]
pub fn inset_atlas_bleed(uvs: [[f32; 2]; 4], tex_w: f32, tex_h: f32) -> [[f32; 2]; 4] {
let mut out = uvs;
for (axis, tex) in [(0usize, tex_w), (1usize, tex_h)] {
if tex < 2.0 {
continue; // a 1-texel axis has no interior
}
let (lo, hi) = uvs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), c| {
(lo.min(c[axis]), hi.max(c[axis]))
});
// A range of exactly 1, the corners' `v`, is bounded and gets the inset.
if hi - lo > 1.0 + f32::EPSILON {
continue;
}
let half = 0.5 / tex;
if hi - lo <= 2.0 * half {
continue; // degenerate: the inset would invert the range
}
for c in &mut out {
c[axis] += if c[axis] <= lo { half } else { -half };
}
}
out
/// One piece's texcoords, `[TL, TR, BR, BL]`.
type Uvs = [[f32; 2]; 4];
/// The eight border pieces in the struct's order, `+0x04` to `+0x20`, which is their creation
/// order and their atlas slice (`0x77e8d0`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Edge {
Left,
Right,
Top,
Bottom,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
const EDGES: [Edge; 8] = [
Edge::Left,
Edge::Right,
Edge::Top,
Edge::Bottom,
Edge::TopLeft,
Edge::TopRight,
Edge::BottomLeft,
Edge::BottomRight,
];
/// A piece's anchors on its frame, `(point, relativePoint, x, y)` in the frame's units, and its
/// `(width, height)`, 0 for an axis the anchors span (`0x77e8d0`'s `SetPoint 0x767c70` and
/// layout-vtable `+0x14`/`+0x18` calls).
type PieceLayout = ([Option<(Point, f32, f32)>; 4], (f32, f32));
/// The background's four anchors. Its BOTTOMRIGHT takes the TOP inset for its y, a bug in the
/// reference (`0x77e9ae` loads `+0x50`, top, not `+0x54`), kept: the resolver reads the bottom
/// edge from BOTTOMLEFT and the right edge from TOPRIGHT first (`0x7671a0`, `0x7671f0`), so the
/// bug shows only to `GetPoint`, never on screen.
fn bg_layout(bd: &Backdrop) -> PieceLayout {
let i = bd.insets;
(
[
Some((Point::TopLeft, i.left, -i.top)),
Some((Point::TopRight, -i.right, -i.top)),
Some((Point::BottomLeft, i.left, i.bottom)),
Some((Point::BottomRight, -i.right, i.top)),
],
(0.0, 0.0),
)
}
/// A border piece's anchors: corners are `edgeSize` squares on their frame corner, edge strips run
/// between them, all flush inside the frame rect.
fn edge_layout(edge: Edge, e: f32) -> PieceLayout {
use Point::*;
let (a, b, size) = match edge {
Edge::Left => ((TopLeft, 0.0, -e), Some((BottomLeft, 0.0, e)), (e, 0.0)),
Edge::Right => ((TopRight, 0.0, -e), Some((BottomRight, 0.0, e)), (e, 0.0)),
Edge::Top => ((TopLeft, e, 0.0), Some((TopRight, -e, 0.0)), (0.0, e)),
Edge::Bottom => ((BottomLeft, e, 0.0), Some((BottomRight, -e, 0.0)), (0.0, e)),
Edge::TopLeft => ((TopLeft, 0.0, 0.0), None, (e, e)),
Edge::TopRight => ((TopRight, 0.0, 0.0), None, (e, e)),
Edge::BottomLeft => ((BottomLeft, 0.0, 0.0), None, (e, e)),
Edge::BottomRight => ((BottomRight, 0.0, 0.0), None, (e, e)),
};
([Some(a), b, None, None], size)
}
/// A corner's texcoords, set once at creation (`0x77e8d0`): its slice, upright.
fn corner_uvs(edge: Edge) -> Uvs {
let i = edge as u8 as f32;
let (u0, u1) = (i * SLICE, (i + 1.0) * SLICE);
[[u0, 0.0], [u1, 0.0], [u1, 1.0], [u0, 1.0]]
}
/// The texcoords `0x77f0c0` runs for a frame `rect` on screen: the tiled background's, `None`
/// when it stretches (no `SetTexCoord` call), and the four edge strips', which tile `run` periods
/// between the corners. The divisors are the stored, unscaled sizes (`0x77f101 fdiv [esi+0x48]`,
/// `0x77f195`), so a scaled frame repeats its art more or less often than at scale 1. All four
/// arrays run `[TL, TR, BR, BL]`.
fn run_uvs(bd: &Backdrop, rect: Rect, scale: f32) -> (Option<Uvs>, [Uvs; 4]) {
let (l, r, b, t) = (rect.left, rect.right, rect.bottom, rect.top);
let bg = (bd.has_bg() && bd.tile).then(|| {
let period = if bd.tile_size != 0.0 {
bd.tile_size
} else {
bd.edge_size
};
let i = bd.insets;
let bg_w = (r - i.right * scale) - (l + i.left * scale);
let bg_h = (t - i.top * scale) - (b + i.bottom * scale);
let (wt, ht) = (bg_w / period, bg_h / period);
[[0.0, 0.0], [wt, 0.0], [wt, ht], [0.0, ht]]
});
// Run lengths clamp at 0 below two edges and never above.
let w_run = ((r - l) / bd.edge_size - 2.0).max(0.0);
let h_run = ((t - b) / bd.edge_size - 2.0).max(0.0);
let s = SLICE;
let edges = [
// LEFT, slice 0, upright.
[[0.0, 0.0], [s, 0.0], [s, h_run], [0.0, h_run]],
// RIGHT, slice 1, upright.
[[s, 0.0], [2.0 * s, 0.0], [2.0 * s, h_run], [s, h_run]],
// TOP, slice 2, rotated: atlas `u` runs down the screen, atlas `v` right to left.
[
[2.0 * s, w_run],
[2.0 * s, 0.0],
[3.0 * s, 0.0],
[3.0 * s, w_run],
],
// BOTTOM, slice 3, rotated as TOP.
[
[3.0 * s, w_run],
[3.0 * s, 0.0],
[4.0 * s, 0.0],
[4.0 * s, w_run],
],
];
(bg, edges)
}
/// Axis-aligned corners `[TL, TR, BR, BL]`, y-up.
#[cfg(test)]
fn aa_corners(x0: f32, x1: f32, y_bottom: f32, y_top: f32) -> [[f32; 2]; 4] {
[
[x0, y_top], // TL
@@ -122,184 +224,331 @@ fn aa_corners(x0: f32, x1: f32, y_bottom: f32, y_top: f32) -> [[f32; 2]; 4] {
}
/// The drawable pieces for a frame's resolved rect (y-up), in paint order: the background (layer
/// `BACKGROUND`), then the 8 border pieces (layer `BORDER`). Geometry `0x77e8d0`, UVs `0x77f0c0`.
/// The pieces are textures anchored and sized in the frame's own units, so the layout multiplies
/// their geometry by the frame's effective `scale` (`0x76ac90`), as it does any region's. The
/// texcoord repeats are not: `0x77f0c0` divides the scaled rect by the stored, unscaled
/// `edgeSize` and `tileSize` (`0x77f101 fdiv [esi+0x48]`, `0x77f195`), so a scaled frame's edge
/// and background repeat the art more or less often than at scale 1.
pub fn pieces(frame: Rect, bd: &Backdrop, scale: f32) -> Vec<BackdropPiece> {
/// `BACKGROUND`), then the 8 border pieces (layer `BORDER`), as the regions `install` builds
/// would resolve and draw, the oracle the region tests check them against. Their geometry is in
/// the frame's own units, so it is multiplied by the frame's effective `scale` (`0x76ac90`), as
/// any region's is.
#[cfg(test)]
pub(crate) fn pieces(frame: Rect, bd: &Backdrop, scale: f32) -> Vec<BackdropPiece> {
let mut out = Vec::with_capacity(9);
let e = bd.edge_size * scale;
// The texcoord divisor, unscaled.
let e_uv = bd.edge_size;
let (l, r, b, t) = (frame.left, frame.right, frame.bottom, frame.top);
let (bg_uvs, edge_uvs) = run_uvs(bd, frame, scale);
// ── The background piece ──
if bd.has_bg() {
let (il, ir, it, ib) = (
bd.insets.left * scale,
bd.insets.right * scale,
bd.insets.top * scale,
bd.insets.bottom * scale,
);
// The four anchors, y-up. Bottom-right takes the top inset, as the reference does
// (`0x77e9ae` loads `+0x50`, top, not `+0x54`); invisible when the two are equal.
let corners = [
[l + il, t - it],
[r - ir, t - it],
[r - ir, b + it], // BR: the top inset
[l + il, b + ib],
];
// Untiled, no `SetTexCoord` runs and the background stretches over `[0,1]` (`0x77f0c0`).
let uvs = if bd.tile {
let period = if bd.tile_size != 0.0 {
bd.tile_size
} else {
e_uv
};
let bg_w = (r - ir) - (l + il);
let bg_h = (t - it) - (b + ib);
let (wt, ht) = (bg_w / period, bg_h / period);
[[0.0, 0.0], [wt, 0.0], [wt, ht], [0.0, ht]]
} else {
[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]
};
// TOPLEFT, TOPRIGHT and BOTTOMLEFT place it; the BOTTOMRIGHT anchor is never read.
let i = bd.insets;
out.push(BackdropPiece {
corners,
uvs,
corners: aa_corners(
l + i.left * scale,
r - i.right * scale,
b + i.bottom * scale,
t - i.top * scale,
),
// Untiled, no `SetTexCoord` runs and the background stretches over `[0,1]`.
uvs: bg_uvs.unwrap_or([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]),
tile: bd.tile,
is_bg: true,
});
}
// ── The 8 border pieces ──
if bd.has_border() {
// Edge strips tile `run` periods between the corners, 0 below two edges (`0x77f0c0`).
let w_run = ((r - l) / e_uv - 2.0).max(0.0);
let h_run = ((t - b) / e_uv - 2.0).max(0.0);
let mut edge = |x0: f32, x1: f32, y0: f32, y1: f32, uvs: [[f32; 2]; 4]| {
for (k, edge) in EDGES.into_iter().enumerate() {
let corners = match edge {
Edge::Left => aa_corners(l, l + e, b + e, t - e),
Edge::Right => aa_corners(r - e, r, b + e, t - e),
Edge::Top => aa_corners(l + e, r - e, t - e, t),
Edge::Bottom => aa_corners(l + e, r - e, b, b + e),
Edge::TopLeft => aa_corners(l, l + e, t - e, t),
Edge::TopRight => aa_corners(r - e, r, t - e, t),
Edge::BottomLeft => aa_corners(l, l + e, b, b + e),
Edge::BottomRight => aa_corners(r - e, r, b, b + e),
};
out.push(BackdropPiece {
corners: aa_corners(x0, x1, y0, y1),
uvs,
corners,
uvs: edge_uvs.get(k).copied().unwrap_or_else(|| corner_uvs(edge)),
tile: true,
is_bg: false,
});
};
// LEFT, slice 0, upright.
edge(
l,
l + e,
b + e,
t - e,
[[0.0, 0.0], [SLICE, 0.0], [SLICE, h_run], [0.0, h_run]],
);
// RIGHT, slice 1, upright.
edge(
r - e,
r,
b + e,
t - e,
[
[SLICE, 0.0],
[2.0 * SLICE, 0.0],
[2.0 * SLICE, h_run],
[SLICE, h_run],
],
);
// TOP, slice 2, rotated: atlas `u` runs down the screen, atlas `v` right to left.
edge(
l + e,
r - e,
t - e,
t,
[
[2.0 * SLICE, w_run],
[2.0 * SLICE, 0.0],
[3.0 * SLICE, 0.0],
[3.0 * SLICE, w_run],
],
);
// BOTTOM, slice 3, rotated as TOP.
edge(
l + e,
r - e,
b,
b + e,
[
[3.0 * SLICE, w_run],
[3.0 * SLICE, 0.0],
[4.0 * SLICE, 0.0],
[4.0 * SLICE, w_run],
],
);
// The corners, slices 4 to 7, upright.
let corner_uv = |i: f32| {
let u0 = i * SLICE;
let u1 = (i + 1.0) * SLICE;
[[u0, 0.0], [u1, 0.0], [u1, 1.0], [u0, 1.0]]
};
edge(l, l + e, t - e, t, corner_uv(4.0)); // TOPLEFT
edge(r - e, r, t - e, t, corner_uv(5.0)); // TOPRIGHT
edge(l, l + e, b, b + e, corner_uv(6.0)); // BOTTOMLEFT
edge(r - e, r, b, b + e, corner_uv(7.0)); // BOTTOMRIGHT
}
}
out
}
// ── The installed backdrop: the spec and its nine regions ───────────────────────────────────
/// A frame's installed backdrop, the struct's spec and its nine piece slots (`+0x00` the
/// background, `+0x04..+0x20` the edges in `EDGES` order), each `None` where no piece was built.
#[derive(Clone, Debug)]
pub(crate) struct Installed {
pub(crate) spec: Backdrop,
pieces: [Option<RegionHandle>; 9],
/// The frame rect the texcoords last ran for; `ApplyRect 0x76b580` re-runs them only when the
/// size moves past [`layout::size_changed`].
ran_for: Option<Rect>,
}
impl Installed {
fn bg(&self) -> Option<RegionHandle> {
self.pieces[0]
}
fn edges(&self) -> impl Iterator<Item = RegionHandle> + '_ {
self.pieces[1..].iter().flatten().copied()
}
}
/// `CSimpleFrame::SetBackdrop 0x76a5d0`: release the frame's nine pieces and drop its spec
/// (`0x77f4d0`), then, for a spec, build the pieces (`0x77e8d0`) and run their texcoords against
/// the frame's rect if it has one, as Lua `SetBackdrop` does at its end (`0x7776e0`); otherwise
/// the next resolve runs them. Each piece is a fresh Texture linked at the tail of the region
/// list, so a second backdrop's pieces come after any region made in between.
pub(crate) fn install(model: &mut Model, fh: FrameHandle, spec: Option<Backdrop>) {
// Each freed piece names its node, and each built one below, so the next resolve takes the
// swap through the cached graph.
let old: Vec<RegionHandle> = model
.backdrops
.remove(&fh)
.iter()
.flat_map(|o| o.pieces)
.flatten()
.collect();
super::region::free_regions(model, &old);
let Some(spec) = spec.filter(|_| model.arena.frame(fh).is_some()) else {
return;
};
let owner = model.frame_id(fh);
// `SetTexture 0x770200`: a path that does not load leaves the piece without art.
let loads = |model: &Model, path: &Option<String>| -> Option<String> {
let path = path.as_deref().filter(|p| !p.is_empty())?;
let ok = model.texture_probe.as_ref().is_none_or(|probe| probe(path));
ok.then(|| path.to_string())
};
let bg_art = loads(model, &spec.bg_file);
let edge_art = loads(model, &spec.edge_file);
let mut pieces = [None; 9];
let build = |model: &mut Model,
layer: DrawLayer,
(anchors, size): PieceLayout,
texture: Option<String>,
tex_coords: Option<TexCoords>|
-> Option<RegionHandle> {
// `0x76fc40(frame, layer, 1)`: a Texture on the frame, linked at the tail (`0x76a750`).
let rh = model
.arena
.create_region(fh, RegionKind::Texture, layer, 0)?;
let (anchors, size) = piece_geometry(owner, (anchors, size));
model.region_data.insert(
rh,
RegionData {
texture,
anchors,
size,
tex_coords,
..RegionData::default()
},
);
Some(rh)
};
if spec.has_bg() {
pieces[0] = build(model, DrawLayer::Background, bg_layout(&spec), bg_art, None);
}
if spec.has_border() {
for (k, edge) in EDGES.into_iter().enumerate() {
let corner = matches!(
edge,
Edge::TopLeft | Edge::TopRight | Edge::BottomLeft | Edge::BottomRight
);
pieces[k + 1] = build(
model,
DrawLayer::Border,
edge_layout(edge, spec.edge_size),
edge_art.clone(),
corner.then(|| TexCoords::Corners(corner_uvs(edge))),
);
}
}
// Each piece is invalidated after its points are set (`0x77e9ca`..`0x77ec77`, `0x77ed05`..).
for rh in pieces.iter().flatten() {
model.invalidate_region(*rh, true);
}
model.backdrops.insert(
fh,
Installed {
spec,
pieces,
ran_for: None,
},
);
if let Some(rect) = model.resolved.get(&fh).copied() {
run_texcoords(model, fh, rect);
}
}
/// A piece's anchors on frame `owner` and its explicit size, `None` where the anchors span both
/// axes.
fn piece_geometry(owner: u32, (anchors, size): PieceLayout) -> (Anchors, Option<(f32, f32)>) {
let anchors = anchors
.into_iter()
.flatten()
.map(|(p, x, y)| Anchor::new(p, owner, p, x, y))
.collect();
(anchors, (size != (0.0, 0.0)).then_some(size))
}
/// Re-size an installed backdrop in place, its nine regions kept: the edge size, the tile period
/// and the insets, each piece re-anchored as `0x77e8d0` would build it, then the texcoords re-run.
/// No 1.12 call does this; the engine's chat bubbles take it on a window resize, where the
/// reference's sizes are window-relative and never need it (`0x4b0940`).
pub(crate) fn set_metrics(
model: &mut Model,
fh: FrameHandle,
edge_size: f32,
tile_size: f32,
insets: Insets,
) {
let Some(inst) = model.backdrops.get_mut(&fh) else {
return;
};
if inst.spec.edge_size == edge_size
&& inst.spec.tile_size == tile_size
&& inst.spec.insets == insets
{
return;
}
inst.spec.edge_size = edge_size;
inst.spec.tile_size = tile_size;
inst.spec.insets = insets;
inst.ran_for = None;
let (spec, pieces) = (inst.spec.clone(), inst.pieces);
let owner = model.frame_id(fh);
let layouts = std::iter::once(bg_layout(&spec)).chain(EDGES.map(|e| edge_layout(e, edge_size)));
for (slot, layout) in pieces.into_iter().zip(layouts) {
let Some(d) = slot.and_then(|rh| model.region_data.get_mut(&rh)) else {
continue;
};
(d.anchors, d.size) = piece_geometry(owner, layout);
}
// Set as `0x77e8d0` sets them, so invalidated as there.
for rh in pieces.iter().flatten() {
model.invalidate_region(*rh, true);
}
if let Some(rect) = model.resolved.get(&fh).copied() {
run_texcoords(model, fh, rect);
}
}
/// `0x77f0c0` for frame `fh` at `rect`, from `install`.
fn run_texcoords(model: &mut Model, fh: FrameHandle, rect: Rect) {
let scale = super::object::eff_scale(model, fh);
let Model {
backdrops,
region_data,
..
} = model;
if let Some(inst) = backdrops.get_mut(&fh) {
rerun(inst, region_data, rect, scale);
}
}
/// `0x77f0c0`: the tiled background's and the edge strips' texcoords for a frame at `rect`, the
/// corners' left as created, then both colours re-applied (`0x77f410`, `0x77f440`).
fn rerun(
inst: &mut Installed,
region_data: &mut HashMap<RegionHandle, RegionData>,
rect: Rect,
scale: f32,
) {
inst.ran_for = Some(rect);
let (bg, edges) = run_uvs(&inst.spec, rect, scale);
if let (Some(rh), Some(uvs)) = (inst.bg(), bg) {
if let Some(d) = region_data.get_mut(&rh) {
d.tex_coords = Some(TexCoords::Corners(uvs));
}
}
for (slot, uvs) in inst.pieces[1..5].iter().zip(edges) {
if let Some(d) = slot.and_then(|rh| region_data.get_mut(&rh)) {
d.tex_coords = Some(TexCoords::Corners(uvs));
}
}
paint(inst, region_data);
}
/// Each piece's single vertex colour (`0x77f750`): the backdrop colour on the background only
/// (`0x77f410`), the border colour on the eight edges only (`0x77f440`).
fn paint(inst: &Installed, region_data: &mut HashMap<RegionHandle, RegionData>) {
if let Some(d) = inst.bg().and_then(|rh| region_data.get_mut(&rh)) {
d.vertex_color = Some(inst.spec.bg_color);
}
for rh in inst.edges() {
if let Some(d) = region_data.get_mut(&rh) {
d.vertex_color = Some(inst.spec.border_color);
}
}
}
/// `SetBackdropColor` (`0x77f410`) or, with `border`, `SetBackdropBorderColor` (`0x77f440`): store
/// the colour in the spec and tint that colour's pieces.
pub(crate) fn set_color(model: &mut Model, fh: FrameHandle, border: bool, color: [f32; 4]) {
let Model {
backdrops,
region_data,
..
} = model;
let Some(inst) = backdrops.get_mut(&fh) else {
return;
};
if border {
inst.spec.border_color = color;
for rh in inst.edges() {
if let Some(d) = region_data.get_mut(&rh) {
d.vertex_color = Some(color);
}
}
} else {
inst.spec.bg_color = color;
if let Some(d) = inst.bg().and_then(|rh| region_data.get_mut(&rh)) {
d.vertex_color = Some(color);
}
}
}
/// After a resolve: re-run the texcoords of every backdrop whose frame rect changed size, as the
/// rect application `0x76b580` calls `0x77f0c0` (at `0x76b61e`); a move alone re-runs nothing.
pub(crate) fn after_resolve(model: &mut Model) {
let eps = model.size_eps();
let Model {
backdrops,
resolved,
arena,
region_data,
..
} = model;
for (fh, inst) in backdrops.iter_mut() {
let Some(&rect) = resolved.get(fh) else {
continue;
};
if inst
.ran_for
.is_some_and(|prev| !layout::size_changed(prev, rect, eps))
{
continue;
}
// `eff_scale`'s guard: a zero scale lays out at 1.
let scale = arena
.frame(*fh)
.map(|f| f.effective_scale)
.filter(|s| s.abs() >= 1e-6)
.unwrap_or(1.0);
rerun(inst, region_data, rect, scale);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// On the `ChatBubble-Backdrop` geometry: 256×32, eight 32-texel slices.
#[test]
fn the_bleed_inset_lands_on_texel_centres_and_spares_tiling() {
const W: f32 = 256.0;
const H: f32 = 32.0;
let half_u = 0.5 / W;
let corner = [[0.5, 0.0], [0.625, 0.0], [0.625, 1.0], [0.5, 1.0]];
let got = inset_atlas_bleed(corner, W, H);
// 128.5 and 159.5 are the centres of the slice's first and last texels.
let us: Vec<f32> = got.iter().map(|c| c[0] * W).collect();
assert!((us[0] - 128.5).abs() < 1e-3, "left u at {} texels", us[0]);
assert!((us[1] - 159.5).abs() < 1e-3, "right u at {} texels", us[1]);
let vs: Vec<f32> = got.iter().map(|c| c[1] * H).collect();
assert!((vs[0] - 0.5).abs() < 1e-3, "top v at {} texels", vs[0]);
assert!((vs[2] - 31.5).abs() < 1e-3, "bottom v at {} texels", vs[2]);
let strip = [[0.25, 8.0], [0.25, 0.0], [0.375, 0.0], [0.375, 8.0]];
let got = inset_atlas_bleed(strip, W, H);
for (g, s) in got.iter().zip(strip.iter()) {
assert_eq!(g[1], s[1], "the tiling axis must be bit-exact");
}
assert!((got[0][0] - (0.25 + half_u)).abs() < 1e-6);
assert!((got[2][0] - (0.375 - half_u)).abs() < 1e-6);
for i in 0u8..8 {
let (u0, u1) = (f32::from(i) * SLICE, f32::from(i + 1) * SLICE);
let piece = [[u0, 0.0], [u1, 0.0], [u1, 1.0], [u0, 1.0]];
for c in inset_atlas_bleed(piece, W, H) {
assert!(
c[0] > u0 && c[0] < u1,
"slice {i}: u {} escaped ({u0},{u1})",
c[0]
);
}
}
assert_eq!(inset_atlas_bleed(corner, 0.0, 0.0), corner);
let sliver = [[0.0, 0.0], [0.001, 0.0], [0.001, 1.0], [0.0, 1.0]];
assert_eq!(
inset_atlas_bleed(sliver, W, H)[0][0],
0.0,
"too thin to inset"
);
}
fn tooltip_backdrop(edge_size: f32, inset: f32) -> Backdrop {
Backdrop {
bg_file: Some("Interface\\Tooltips\\UI-Tooltip-Background".into()),
@@ -364,8 +613,11 @@ mod tests {
assert_eq!(br.uvs[2], [1.0, 1.0]);
}
/// The background's BOTTOMRIGHT anchor takes the top inset (`0x77e9ae`), but the resolver
/// reads the bottom edge from BOTTOMLEFT and the right from TOPRIGHT, so the rect is the
/// insets' own, top 7 and bottom 9.
#[test]
fn bg_bottomright_rides_top_inset_bug() {
fn the_bg_bottomright_quirk_never_reaches_the_rect() {
let frame = Rect::new(0.0, 0.0, 100.0, 200.0);
let bd = Backdrop {
insets: Insets {
@@ -377,11 +629,16 @@ mod tests {
..tooltip_backdrop(16.0, 0.0)
};
let bg = pieces(frame, &bd, 1.0)[0];
assert_eq!(bg.corners[3], [3.0, 9.0]);
// BR takes the top inset, 7, not the bottom's 9.
assert_eq!(bg.corners[2], [196.0, 7.0]);
assert_eq!(bg.corners[0], [3.0, 93.0]); // TL
assert_eq!(bg.corners[1], [196.0, 93.0]); // TR
assert_eq!(
bg.corners,
[[3.0, 93.0], [196.0, 93.0], [196.0, 9.0], [3.0, 9.0]]
);
let (anchors, _) = bg_layout(&bd);
assert_eq!(
anchors[3],
Some((Point::BottomRight, -4.0, 7.0)),
"the quirk is kept"
);
}
#[test]
+10 -1
View File
@@ -67,7 +67,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// GetBankSlotCost(numSlots): the argument is unread; the pushed state names the next slot.
g.set(
"GetBankSlotCost",
lua.create_function(|lua, _n: Option<u32>| {
// Reads no argument at all (`0x4f8630`): any call shape answers.
lua.create_function(|lua, _args: mlua::MultiValue| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(model.bank.as_ref().map_or(0, |b| b.next_cost)))
})?,
@@ -138,6 +139,14 @@ mod tests {
.eval::<bool>("local n, full = GetNumBankSlots()\nreturn n == 2 and full == nil")
.unwrap());
assert_eq!(s.eval::<i64>("return GetBankSlotCost(2)").unwrap(), 100_000);
// The argument is never read (`0x4f8630`): none, a string or a table answer all the same.
for call in [
"GetBankSlotCost()",
"GetBankSlotCost('x')",
"GetBankSlotCost({})",
] {
assert_eq!(s.eval::<i64>(&format!("return {call}")).unwrap(), 100_000);
}
// Six purchased: full = 1, and 999999999 is the DBC's sentinel row.
s.set_bank(Some(BankState {
@@ -204,8 +204,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// Never raises; a stored filter fires `UPDATE_BATTLEFIELD_SCORE` (`0x4abc90`), here on the
// next dispatch.
// Never raises; a stored filter rebuilds the board and fires `UPDATE_BATTLEFIELD_SCORE` in
// place (`0x4abc90` → `0x4aa200`, the signal at `0x4aa308`).
g.set(
"SetBattlefieldScoreFaction",
lua.create_function(|lua, faction: Option<Value>| {
@@ -213,12 +213,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
if !(-1..=1).contains(&f) {
return Ok(());
}
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.battlefield_board.filter = f;
model.battlefield_board.rebuild();
model
.pending_events
.push(("UPDATE_BATTLEFIELD_SCORE".to_string(), Vec::new()));
super::tick::verb(lua, |model| {
model.battlefield_board.filter = f;
model.battlefield_board.rebuild();
model.raise("UPDATE_BATTLEFIELD_SCORE", Vec::new());
});
Ok(())
})?,
)?;
+2 -1
View File
@@ -6,6 +6,7 @@
use mlua::Lua;
use super::Model;
use crate::script::binding_abi::Idx32;
/// One answer to a pending-equip question.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -58,7 +59,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// standing dialog (`exclusive = 1`), and that cancel releases the superseded item's locks.
g.set(
"CancelPendingEquip",
lua.create_function(|lua, index: u32| {
lua.create_function(|lua, Idx32(index): Idx32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.pending_equip_answers.push(PendingEquipAnswer {
index,
+3 -3
View File
@@ -4,6 +4,7 @@
use mlua::Lua;
use super::binding_abi::flag;
use super::Model;
impl super::UiScript {
@@ -36,13 +37,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// `CheckBinderDist()`, polled by the dialog's OnUpdate. This answers a boolean; the
// reference (`0x48d210`) answers 1 or nil.
// `CheckBinderDist()`, polled by the dialog's OnUpdate: 1 or nil (`0x48d210`).
g.set(
"CheckBinderDist",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.binder_pending)
Ok(flag(model.binder_pending))
})?,
)?;
+281 -29
View File
@@ -14,22 +14,68 @@
//! `xor eax,eax; ret` that skips `0x6f4940` (`GetDefaultLanguage`'s failure edges);
//! 3. raise.
use mlua::{Lua, Value};
use mlua::{Lua, MultiValue, Value};
/// Shape A: `is-number` (`0x6f34d0`), `tonumber` (`0x6f3620`), then the truncating `0x40a2b0`,
/// raising the binding's `Usage:` on failure. A missing argument fails like a wrong-typed one
/// (`0x6f3410` returns NULL past `L->top`); a numeric string passes. `0x40a2b0` stores a qword but
/// the bindings consume only the low dword, hence `i32`.
///
/// Deviation: past ±2^63 Rust saturates where the x87 stores its integer-indefinite pattern,
/// because no real argument reaches that range.
/// `_ftol` (`0x40a2b0`) as the bindings consume it: the x87 rounding control set to chop, a
/// `fistp` to a qword, and its low dword. What the qword cannot hold (an infinity, NaN, ±2^63 and
/// beyond) stores the integer-indefinite pattern `0x8000_0000_0000_0000`, whose low dword is 0.
pub(crate) fn ftol(n: f64) -> i32 {
const TWO_63: f64 = 9_223_372_036_854_775_808.0;
let t = n.trunc();
if (-TWO_63..TWO_63).contains(&t) {
t as i64 as i32
} else {
0
}
}
/// Shape A: `is-number` (`0x6f34d0`), `tonumber` (`0x6f3620`), then the truncating `0x40a2b0`
/// ([`ftol`]), raising the binding's `Usage:` on failure. A missing argument fails like a
/// wrong-typed one (`0x6f3410` returns NULL past `L->top`); a numeric string passes.
pub(crate) fn number_arg(lua: &Lua, v: Value, usage: &'static str) -> mlua::Result<i32> {
match lua.coerce_number(v)? {
Some(n) => Ok(n as i64 as i32),
Some(n) => Ok(ftol(n)),
None => Err(mlua::Error::RuntimeError(usage.into())),
}
}
/// An index argument read as the reference reads one: a number or numeric string (`0x6f34d0`)
/// truncated by `_ftol` ([`ftol`]) and then decremented and compared unsigned, so a zero or
/// negative index is a huge one and falls out of range where a Rust integer parameter would raise
/// in the conversion (`GetCraftInfo` `0x4f6ed8`). The inner value is the 32-bit pattern, the
/// negative a value no table holds. A non-number raises, as the reference's usage error does.
pub(crate) struct Idx(pub usize);
/// [`Idx`] as a function, for a binding whose reference raises its own usage text for a
/// non-number (`usage`); the value is the same 32-bit pattern, widened.
pub(crate) fn index_arg(lua: &Lua, v: Value, usage: &'static str) -> mlua::Result<usize> {
number_arg(lua, v, usage).map(|n| n as u32 as usize)
}
/// [`Idx`] for the parameters the bindings keep as `u32`.
pub(crate) struct Idx32(pub u32);
fn index_bits(lua: &Lua, v: Value) -> mlua::Result<u32> {
match lua.coerce_number(v)? {
Some(n) => Ok(ftol(n) as u32),
None => Err(mlua::Error::RuntimeError(
"bad argument: number expected".into(),
)),
}
}
impl mlua::FromLua for Idx {
fn from_lua(v: Value, lua: &Lua) -> mlua::Result<Self> {
index_bits(lua, v).map(|n| Idx(n as usize))
}
}
impl mlua::FromLua for Idx32 {
fn from_lua(v: Value, lua: &Lua) -> mlua::Result<Self> {
index_bits(lua, v).map(Idx32)
}
}
/// Shape D: an `is-number`-gated flag whose failure keeps its zero instead of raising. The value
/// must be strictly `> 0.0` (`0x53303f`), so 0, a negative and NaN are false and `0.5` or `"1"`
/// true; `SetInventoryItem`'s `nameOnly` is one.
@@ -146,6 +192,118 @@ fn trim_g(s: &str) -> &str {
s.trim_end_matches('0').trim_end_matches('.')
}
/// Storm's `SStrToInt` (`0x64ac60`): an optional leading `-` (and no `+`), then the decimal digits
/// up to the first other byte, with no whitespace skip; none is 0. The accumulator is a 32-bit
/// register that wraps (`0x64ac98`-`0x64aca5`) and `neg` wraps too, so `"4294967297"` is 1.
/// `RemoveChatWindowChannel` and `LeaveChannelByName` key on it (`0x49e880`), and the `item:` link
/// fields and `SStrToFloat`'s exponent read it.
pub(crate) fn s_str_to_int(s: &str) -> i64 {
let b = s.as_bytes();
let (neg, mut i) = match b.first() {
Some(b'-') => (true, 1),
_ => (false, 0),
};
let mut n: i32 = 0;
while let Some(&c) = b.get(i) {
if !c.is_ascii_digit() {
break;
}
n = n.wrapping_mul(10).wrapping_add(i32::from(c - b'0'));
i += 1;
}
i64::from(if neg { n.wrapping_neg() } else { n })
}
/// Storm's `SStrToFloat` (`0x64aaa0`), which `EditBox:GetNumber` (`0x798790`) pushes: a prefix
/// parse that stops at the first byte outside its grammar and skips no whitespace, evaluated with
/// the x87 at 53-bit precision (`_PC_53`), so every operation below rounds as an `f64` does.
///
/// - an optional `-` (no `+`)
/// - the integer digits, which may be none (`".5"`, `"-"`), summed in chunks: a 32-bit register
/// takes digits until it reaches `0x19999999` (`0x64ab19`), then `value = value * 10^n + chunk`
/// (`0x64ab21`-`0x64ab48`) and a new chunk starts
/// - a `.` is consumed whether or not a digit follows it (`0x64ab9f`: `esi` is advanced before the
/// test), so `"5.e3"` is 5000; each fraction digit `d` at place `k` (0 for the first) then adds
/// the table term `d * 10^-(k+1)` (`0x64abe4`, table `0xc52560` built at `0x64a680`; past place
/// 19 the same product is formed on the spot, `0x64abbb`-`0x64abda`), so `"0.12"` is
/// `0.1 + 0.02` = 0.12000000000000001
/// - `e` or `E`, an optional `+`, then [`s_str_to_int`] of what follows scales the value by
/// `pow(10, exp)` (`0x64ac13`-`0x64ac40`): `"1e"` is 1, `"1e+-2"` is 0.01, and the wrapped
/// exponent means `"1e4294967297"` is 10
///
/// Deviation: `pow(10, n)` is the platform's, where the reference's (`0x73f940`, `fyl2x`) rounds
/// its extended-precision steps at 53 bits, so a power or a fraction term can differ in the last
/// place. The reference also keeps the product `value * 10^exp` in the x87's wider exponent range
/// until it is stored, so an exponent past +-308 against a value that pulls it back in range
/// (`"0.000...1e320"`) is reached here only by scaling in steps of 300 digits, which rounds more
/// often; `"0e999"` is 0 as there, and only an exponent past +-4932 makes 0 * inf, NaN.
pub(crate) fn s_str_to_float(s: &str) -> f64 {
let b = s.as_bytes();
let at = |i: usize| b.get(i).copied().unwrap_or(0);
let neg = at(0) == b'-';
let mut i = usize::from(neg);
let mut value = 0.0f64;
let (mut chunk, mut count) = (0u32, 0i32);
while at(i).is_ascii_digit() {
chunk = chunk * 10 + u32::from(at(i) - b'0');
count += 1;
i += 1;
if chunk >= 0x1999_9999 {
value = value * 10f64.powf(f64::from(count)) + f64::from(chunk);
(chunk, count) = (0, 0);
}
}
value = value * 10f64.powf(f64::from(count)) + f64::from(chunk);
if at(i) == b'.' {
i += 1;
let mut place = 1.0;
while at(i).is_ascii_digit() {
value += f64::from(at(i) - b'0') * 10f64.powf(-place);
place += 1.0;
i += 1;
}
}
if matches!(at(i), b'e' | b'E') {
i += 1;
if at(i) == b'+' {
i += 1;
}
value = scale_by_pow10(value, s_str_to_int(&s[i..]) as i32);
}
if neg {
-value
} else {
value
}
}
/// `value * pow(10, exp)` as the x87 forms it: the power keeps the extended exponent range until
/// the product is stored as a double (see [`s_str_to_float`]).
fn scale_by_pow10(value: f64, exp: i32) -> f64 {
let p = 10f64.powf(f64::from(exp));
if p.is_finite() && p != 0.0 {
return value * p;
}
if value == 0.0 || !value.is_finite() {
// 0 * a finite extended power is 0; the power overflows the extended range only past 4932.
return if exp > 4932 || !value.is_finite() {
value * p
} else {
value * 0.0
};
}
let mut v = value;
let mut left = exp;
while left.abs() > 300 {
v *= 10f64.powf(f64::from(300 * left.signum()));
left -= 300 * left.signum();
if v == 0.0 || v.is_infinite() {
return v;
}
}
v * 10f64.powf(f64::from(left))
}
/// `GetBoolOrDefault` (`0x6f1c10`, jump table `0x6f1ce8`), the reference's boolean argument
/// coercion, which is not Lua truthiness:
///
@@ -167,29 +325,15 @@ pub(crate) fn bool_or_default(v: Option<&Value>, default: bool) -> bool {
Value::Nil => false,
Value::Boolean(b) => *b,
Value::LightUserData(_) => default,
// Truncated like `0x40a2b0`, testing only the low dword.
// Truncated like `0x40a2b0` ([`ftol`]), which keeps only the low dword: 2^32 reads 0, and
// an infinity or NaN too.
Value::Integer(i) => (*i as i32) != 0,
Value::Number(n) => (*n as i64 as i32) != 0,
Value::Number(n) => ftol(*n) != 0,
Value::String(s) => {
// The keyword arm is also reached by every byte the remap table cannot index (and
// by an empty string); a string it does not know takes `default`.
let bytes = s.as_bytes();
match bytes.first() {
Some(b'0' | b'F' | b'N' | b'f' | b'n') => false,
Some(b'1'..=b'9' | b'T' | b'Y' | b't' | b'y') => true,
// The keyword arm, also reached by every byte the remap table cannot index (and
// by an empty string).
_ => {
if bytes.eq_ignore_ascii_case(b"off") || bytes.eq_ignore_ascii_case(b"disabled")
{
false
} else if bytes.eq_ignore_ascii_case(b"on")
|| bytes.eq_ignore_ascii_case(b"enabled")
{
true
} else {
default
}
}
}
crate::strings::keyword_bool(&bytes).unwrap_or(default)
}
_ => default,
}
@@ -210,6 +354,18 @@ pub(crate) fn flag(b: bool) -> Value {
}
}
/// A tuple the reference pushes by fixed slots, as an indexed getter's miss does: `None` is a nil
/// (`0x6f37f0`), `Some(n)` the number `n` (`0x6f3810`). The count is the handler's `mov eax,N`, so
/// a caller counting the returns (`arg.n`) reads N, never one nil.
pub(crate) fn tuple(slots: &[Option<i64>]) -> MultiValue {
MultiValue::from_vec(
slots
.iter()
.map(|s| s.map_or(Value::Nil, Value::Integer))
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -218,6 +374,89 @@ mod tests {
Lua::new()
}
/// `SStrToInt` (`0x64ac60`): an optional `-`, digits, nothing skipped, a 32-bit wrap.
#[test]
fn s_str_to_int_is_the_references_atoi() {
assert_eq!(s_str_to_int("2589"), 2589);
assert_eq!(s_str_to_int("-5"), -5);
assert_eq!(s_str_to_int(""), 0);
assert_eq!(s_str_to_int("abc"), 0);
assert_eq!(s_str_to_int(" 12"), 0, "no leading-whitespace skip");
assert_eq!(s_str_to_int("+12"), 0, "no unary plus");
assert_eq!(s_str_to_int("12abc"), 12, "stops at the first non-digit");
assert_eq!(s_str_to_int("-"), 0);
assert_eq!(
s_str_to_int("2147483648"),
-2_147_483_648,
"the register wraps"
);
assert_eq!(s_str_to_int("4294967297"), 1);
assert_eq!(s_str_to_int("-4294967297"), -1);
}
/// `SStrToFloat` (`0x64aaa0`): the grammar's quirks, each read off the bytes.
#[test]
fn s_str_to_float_is_the_references_prefix_parse() {
for (text, want) in [
("12", 12.0),
("12abc", 12.0),
("-12.5x", -12.5),
(" 12", 0.0),
("+12", 0.0),
("inf", 0.0),
("1e", 1.0),
("1e3", 1000.0),
("1E+3", 1000.0),
("1e-2", 0.01),
("1e+-2", 0.01),
("1e3.5", 1000.0),
(".5", 0.5),
("-.5", -0.5),
("5.", 5.0),
("5.e2", 500.0),
("5.5.5", 5.5),
("e5", 0.0),
("0x1f", 0.0),
("1234567890123", 1_234_567_890_123.0),
] {
assert_eq!(s_str_to_float(text), want, "{text:?}");
}
// The fraction is a sum of per-digit table terms, not one correctly rounded parse.
assert_eq!(s_str_to_float("0.12"), 0.1 + 0.02);
assert_ne!(s_str_to_float("0.12"), 0.12);
assert_eq!(s_str_to_float("1.5"), 1.5);
// The exponent is `SStrToInt`'s 32-bit wrap.
assert_eq!(s_str_to_float("1e4294967297"), 10.0);
// The integer part sums in chunks of up to ten digits.
assert_eq!(
s_str_to_float("12345678901234567890"),
(1_234_567_890.0 * 1e10) + 1_234_567_890.0
);
// The product keeps the x87's wide exponent until it is stored.
assert_eq!(s_str_to_float("0e999"), 0.0);
assert!(
s_str_to_float("0e5000").is_nan(),
"0 * inf past the extended range"
);
assert_eq!(s_str_to_float("1e-999"), 0.0);
assert_eq!(s_str_to_float("1e999"), f64::INFINITY);
assert!(s_str_to_float("-").is_sign_negative(), "'-' alone is -0");
}
/// `_ftol`'s low dword: chopped toward zero, wrapped to 32 bits, and 0 for whatever the qword
/// `fistp` cannot hold.
#[test]
fn ftol_keeps_the_low_dword_of_the_x87_qword() {
assert_eq!(ftol(-2.9), -2);
assert_eq!(ftol(2.9), 2);
assert_eq!(ftol(3e9), 3_000_000_000_i64 as i32);
assert_eq!(ftol(4_294_967_301.0), 5);
assert_eq!(ftol(-9_223_372_036_854_775_808.0), 0);
for none in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, 9.3e18, -9.3e18] {
assert_eq!(ftol(none), 0, "{none}");
}
}
#[test]
fn bool_or_default_is_not_lua_truthiness() {
let lua = lua();
@@ -237,6 +476,19 @@ mod tests {
assert!(!bool_or_default(Some(&Value::Number(-0.999)), true));
assert!(bool_or_default(Some(&Value::Number(1.5)), false));
assert!(bool_or_default(Some(&Value::Number(-1.5)), false));
// Only the low dword of the truncation counts (`0x40a2b0`): 2^32 reads 0, and so does an
// infinity or NaN, which the `fistp` stores as the integer indefinite.
assert!(!bool_or_default(
Some(&Value::Number(4_294_967_296.0)),
true
));
assert!(bool_or_default(
Some(&Value::Number(4_294_967_297.0)),
false
));
for n in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, 9.3e18] {
assert!(!bool_or_default(Some(&Value::Number(n)), true), "{n}");
}
assert!(!bool_or_default(Some(&Value::Integer(0)), true));
assert!(bool_or_default(Some(&Value::Integer(7)), false));
+246 -182
View File
@@ -6,7 +6,7 @@
use mlua::{Lua, MultiValue, ObjectLike, Table, Value};
use super::object::{as_f32, frame_handle_of};
use super::object::frame_handle_of;
use super::region::region_wrapper;
use super::{event, JustifyH, Model, RegionData};
use crate::justify::Justify;
@@ -45,17 +45,37 @@ impl Slot {
}
/// The three state slots go through [`ButtonState::set_state_slot`] (`0x778fd0`), which also
/// shows the texture when it is the current state's; the others are plain fields.
fn set(self, bs: &mut ButtonState, rh: Option<crate::widget::RegionHandle>) {
/// shows the texture when it is the current state's, and the two checked slots refresh the
/// pair (`0x7854c0` from `0x785330`/`0x785440`); the others are plain fields. The bits the
/// store writes come back for the caller to apply.
fn set(
self,
bs: &mut ButtonState,
rh: Option<crate::widget::RegionHandle>,
) -> crate::widget::ShownWrites {
match self {
Slot::Normal => bs.set_state_slot(ButtonVisualState::Normal, rh),
Slot::Pushed => bs.set_state_slot(ButtonVisualState::Pushed, rh),
Slot::Disabled => bs.set_state_slot(ButtonVisualState::Disabled, rh),
Slot::Normal => return bs.set_state_slot(ButtonVisualState::Normal, rh),
Slot::Pushed => return bs.set_state_slot(ButtonVisualState::Pushed, rh),
Slot::Disabled => return bs.set_state_slot(ButtonVisualState::Disabled, rh),
Slot::Highlight => bs.highlight = rh,
Slot::Checked => bs.checked_tex = rh,
Slot::DisabledChecked => bs.disabled_checked = rh,
Slot::Checked => {
bs.checked_tex = rh;
return bs.refresh_checked(bs.checked, true);
}
Slot::DisabledChecked => {
bs.disabled_checked = rh;
return bs.refresh_checked(bs.checked, true);
}
Slot::Text => bs.text = rh,
}
crate::widget::ShownWrites::default()
}
/// The link's third argument (`0x77fd10`): every slot links its texture shown (the highlight
/// `0x779139`, the label `0x778d49`) but the state and checked ones, which pass 0 and are
/// shown by the switch that follows.
fn links_shown(self) -> bool {
matches!(self, Slot::Highlight | Slot::Text)
}
/// The region kind and default layer: state textures under the label, the highlight in its own
@@ -192,6 +212,19 @@ fn with_button<T>(
}
}
/// [`with_button`] for a state machine call that writes shown bits: the writes land in the regions
/// once the button's borrow is released.
fn with_button_shown(
lua: &Lua,
this: &Table,
f: impl FnOnce(&mut ButtonState) -> crate::widget::ShownWrites,
) -> mlua::Result<()> {
let writes = with_button(lua, this, f)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
super::region::apply_shown_writes(&mut model, writes);
Ok(())
}
/// `Enable()`/`Disable()`. `Disable` (`0x77ffd0`) reaches `SetEnabled` (`0x779160`), which also
/// turns draw layer 4, HIGHLIGHT, off (`0x7791bb`) in the array `DisableDrawLayer` writes, so every
/// region in that layer goes dark, not only the HighlightTexture. `Enable()` here turns it back
@@ -204,7 +237,7 @@ fn set_enabled(lua: &Lua, this: &Table, on: bool) -> mlua::Result<()> {
.arena
.frame_mut(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
match &mut frame.kind_state {
let writes = match &mut frame.kind_state {
KindState::Button(bs) => bs.set_enabled(on),
_ => return Err(mlua::Error::runtime("not a Button")),
};
@@ -214,18 +247,23 @@ fn set_enabled(lua: &Lua, this: &Table, on: bool) -> mlua::Result<()> {
} else {
frame.disabled_layers |= bit;
}
super::region::apply_shown_writes(&mut model, writes);
Ok(())
}
/// Run an engine state edge over frame `h` if it is a live Button. The reference has six, all of
/// `0x779790`'s call sites but Lua's: enable, disable, hide, press, release and drag start; the
/// cursor entering or leaving a button is not one.
pub(super) fn edge(model: &mut Model, h: FrameHandle, f: impl FnOnce(&mut ButtonState)) {
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::Button(bs) = &mut frame.kind_state {
f(bs);
}
}
pub(super) fn edge(
model: &mut Model,
h: FrameHandle,
f: impl FnOnce(&mut ButtonState) -> crate::widget::ShownWrites,
) {
let writes = match model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
Some(KindState::Button(bs)) => f(bs),
_ => return,
};
super::region::apply_shown_writes(model, writes);
}
/// Whether a hover edge on `h` reaches the base notify, the HIGHLIGHT layer and the Lua
@@ -283,27 +321,21 @@ fn ensure_slot(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result<u32> {
.arena
.create_region(h, kind, layer, 0)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
// A new highlight blends ADD, `SetHighlightTexture`'s default (`0x781d92`). The
// reference applies that mode, or a third argument's, on every call and to a handed-in
// Texture too (`0x7703f0` from `0x779050` and `0x779110`); here only a new region
// takes it.
// Born with its shown bit clear, as `0x76fc40` zeroes it; the link writes the slot's
// own (`0x77fd10`), and the store shows a current state's texture (`0x779037`).
model.region_data.insert(
rh,
RegionData {
blend: if slot == Slot::Highlight {
crate::script::BlendMode::Add
} else {
crate::script::BlendMode::default()
},
..Default::default()
hidden: !slot.links_shown(),
..RegionData::default()
},
);
model.touch_layout(); // a region entered the layout gate's read set
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::Button(bs) = &mut frame.kind_state {
slot.set(bs, Some(rh));
}
}
model.touch_layout_region(rh);
let writes = match model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
Some(KindState::Button(bs)) => slot.set(bs, Some(rh)),
_ => Default::default(),
};
super::region::apply_shown_writes(&mut model, writes);
// Only a new region gets an implicit anchor: a state texture fills the button
// (`0x778f9d`/`0x7790db`), a label goes to the adopter (`0x778dc0` → `0x778d20`).
match slot {
@@ -316,35 +348,78 @@ fn ensure_slot(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result<u32> {
Ok(model.region_id(rh))
}
/// Point `slot` at `rh` or at nothing, returning the different region it held for the caller to
/// free, as the slot store `0x778fd0` destroys the outgoing object.
fn swap_slot(
/// Install `incoming` (a Texture object) into `slot`, or clear it, as the slot store `0x778fd0`
/// does: the same texture is a no-op (`0x778fe7`), the displaced one is destroyed (`0x77900a`), and
/// the incoming one moves into the button on the slot's own draw layer (`0x779017`, `0x779139`
/// for the highlight) before it is stored.
fn install_slot(
lua: &Lua,
this: &Table,
slot: Slot,
incoming: Option<RegionHandle>,
) -> mlua::Result<bool> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
install_slot_model(&mut model, h, slot, incoming)
}
/// The loader's install of the texture a state-texture element built through the `<Layers>`
/// adder (`0x6f26f0`), never through a Lua setter: `Button::LoadXML` hands each new region to the
/// slot store (`0x77890e`, `0x778940`, `0x778972`; the highlight's `0x7789a9`; the checked pair's
/// `0x7851dd`, `0x78520e`), which destroys whatever the slot held, an inherited template's
/// included, so a handle still held on it goes dead (`0x701cd0` clears its `[0]`). The highlight's
/// store sets the region's own blend (`0x7789a4`), so it keeps it.
pub(super) fn install_loaded(
model: &mut Model,
h: FrameHandle,
tag: &str,
rh: RegionHandle,
) -> mlua::Result<()> {
let slot = match tag {
"NormalTexture" => Slot::Normal,
"PushedTexture" => Slot::Pushed,
"DisabledTexture" => Slot::Disabled,
"HighlightTexture" => Slot::Highlight,
"CheckedTexture" => Slot::Checked,
"DisabledCheckedTexture" => Slot::DisabledChecked,
_ => return Err(mlua::Error::runtime(format!("not a Button texture: {tag}"))),
};
install_slot_model(model, h, slot, Some(rh)).map(|_| ())
}
/// [`install_slot`] on the model.
fn install_slot_model(
model: &mut Model,
h: FrameHandle,
slot: Slot,
rh: Option<RegionHandle>,
) -> mlua::Result<Option<RegionHandle>> {
let frame = model
.arena
.frame_mut(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
let KindState::Button(bs) = &mut frame.kind_state else {
incoming: Option<RegionHandle>,
) -> mlua::Result<bool> {
if !matches!(
model.arena.frame(h).map(|f| &f.kind_state),
Some(KindState::Button(_))
) {
return Err(mlua::Error::runtime("not a Button"));
};
let outgoing = slot.get(bs).filter(|old| Some(*old) != rh);
slot.set(bs, rh);
model.touch_layout();
Ok(outgoing)
}
/// Free the region a slot gave up, never the one that moved in.
fn free_outgoing(lua: &Lua, outgoing: Option<RegionHandle>, incoming: RegionHandle) {
if let Some(old) = outgoing.filter(|old| *old != incoming) {
super::region::free_region(
&mut lua.app_data_mut::<Model>().expect("model app_data"),
old,
);
}
let writes = std::cell::RefCell::new(crate::widget::ShownWrites::default());
let changed = super::region::install_into_slot(
model,
h,
incoming,
slot.shape().1,
slot.links_shown(),
false,
|ks| match ks {
KindState::Button(bs) => slot.get(bs),
_ => None,
},
|ks, rh| {
if let KindState::Button(bs) = ks {
*writes.borrow_mut() = slot.set(bs, rh);
}
},
)?;
super::region::apply_shown_writes(model, writes.into_inner());
Ok(changed)
}
/// `Set<State>Texture(texture | "path" | nil)`, forked on the argument's type (`0x781970`). The
@@ -359,58 +434,40 @@ fn set_slot_texture(
usage: &str,
args: &MultiValue,
) -> mlua::Result<()> {
match args.front() {
// A Texture object is itself installed into the slot (`0x781b0b` → `0x778fd0`).
Some(Value::Table(t)) => {
let rh = super::region::region_handle_of(lua, t)?;
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// Any other widget raises (`0x781ab1`, message `0x87a1e0`).
if model.arena.region(rh).map(|r| r.kind) != Some(RegionKind::Texture) {
return Err(mlua::Error::runtime("Wrong object type, expected texture"));
}
let outgoing = swap_slot(&mut model, h, slot, Some(rh))?;
drop(model);
free_outgoing(lua, outgoing, rh);
return Ok(());
}
// nil clears the slot and destroys the old region (`0x781b5a` → `0x778fd0`): unhooked but
// alive, it would draw in every state. No argument is not nil (`0x781b4f`) and clears
// nothing.
Some(Value::Nil) => {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let outgoing = swap_slot(&mut model, h, slot, None)?;
drop(model);
if let Some(old) = outgoing {
super::region::free_region(
&mut lua.app_data_mut::<Model>().expect("model app_data"),
old,
);
// The highlight's third argument names its blend, ADD without one or on a name that does not
// parse (`0x781d92`, `0x781db3` → `0x6f1920`); every other state has none.
let blend = (slot == Slot::Highlight).then(|| match args.get(1) {
Some(Value::String(n)) => n
.to_str()
.ok()
.and_then(|n| crate::script::BlendMode::parse(&n))
.unwrap_or(crate::script::BlendMode::Add),
_ => crate::script::BlendMode::Add,
});
// A Texture object is itself installed into the slot (`0x781b0b` → `0x778fd0`; the highlight's
// `0x781eb7` → `0x779110`, which also sets its blend, `0x779144`, unless it is the same
// texture, `0x779122`). Any other widget raises (`0x781ab1`, message `0x87a1e0`; the
// highlight's `0x87a28c`). nil clears the slot and destroys the old region (`0x781b5a` →
// `0x778fd0`): unhooked but alive, it would draw in every state. A string or a number is a
// path (`0x781b33`, `lua_tostring` converting a number as `%.14g`); no argument is not nil
// (`0x781b4f`) and raises the usage.
let method = usage.split('(').next().unwrap_or(usage);
let path = match super::region::slot_arg(lua, this, args, usage, method, true)? {
super::region::SlotArg::Object(rh) => {
if install_slot(lua, this, slot, Some(rh))? {
if let Some(blend) = blend {
lua.app_data_mut::<Model>()
.expect("model app_data")
.region_data
.entry(rh)
.or_default()
.blend = blend;
}
}
return Ok(());
}
_ => {}
}
// `lua_tostring` (`0x781b33`): a number converts as `%.14g`, as the stack's coercion does.
let path = match args.front() {
Some(v @ (Value::String(_) | Value::Number(_) | Value::Integer(_))) => {
lua.coerce_string(v.clone())?.map(|s| s.to_string_lossy())
}
_ => None,
};
let Some(path) = path else {
let who = {
let h = frame_handle_of(lua, this)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.arena
.frame(h)
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "<unnamed>".to_string())
};
return Err(mlua::Error::runtime(format!("Usage: {who}:{usage}")));
super::region::SlotArg::Clear => return install_slot(lua, this, slot, None).map(|_| ()),
super::region::SlotArg::Path(path) => path,
};
let id = ensure_slot(lua, this, slot)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
@@ -419,6 +476,12 @@ fn set_slot_texture(
// `""` clears the texture, which `QuestLogFrame.lua:165` relies on.
data.texture = (!path.is_empty()).then_some(path);
data.fill = None;
// The highlight applies its blend on every path call, to the old region as to a new one
// (`0x779082`, `0x7790e6`).
if let Some(blend) = blend {
data.blend = blend;
}
model.note_art(rh);
Ok(())
}
@@ -477,8 +540,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let id = ensure_slot(lua, &this, Slot::Text)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let rh = *model.id_to_region.get(&id).expect("text region id");
model.region_data.entry(rh).or_default().text = Some(text);
let data = model.region_data.entry(rh).or_default();
let was_shown = data.text.as_deref().is_some_and(|t| !t.is_empty());
data.text = Some(text);
model.touch_measure(rh);
model.touch_layout_text(rh, was_shown);
Ok(())
})?,
)?;
@@ -570,13 +636,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// Destroyed, not orphaned (`0x778d3c`).
super::region::free_region(&mut model, dead);
}
model.arena.set_region_owner(rh, Some(owner));
if let Some(r) = model.arena.region_mut(rh) {
r.draw_layer = DrawLayer::Artwork;
}
super::region::link_region(&mut model, rh, owner, DrawLayer::Artwork, true);
adopt_label(&mut model, owner, rh);
// The layout's read set moved, and `GetTextWidth` reads the new label's extents.
model.touch_layout();
// A new owner, so a new scale for its node, and `GetTextWidth` reads its extents.
model.touch_layout_region(rh);
model.touch_measure(rh);
Ok(())
})?,
@@ -634,30 +697,22 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// SetFont(file, height [, flags]) (`0x780880`) sets all three embedded fonts and returns
// nothing, discarding the shared `0x79f210`'s load result. It never touches the label
// (`+0x338`), so creates none: extract applies the stored font to whatever label exists. Its
// checks are 5.0's coercing `lua_isstring`/`lua_isnumber`, else a usage error (`0x87c69c`).
// (`+0x338`), so creates none: extract applies the stored font to whatever label exists. The
// arguments pass the shared gate, which names the button in its usage line.
m.set(
"SetFont",
lua.create_function(
|lua, (this, file, height, flags): (Table, Value, Value, Option<String>)| {
let usage = || {
mlua::Error::runtime(
"Usage: <Button>:SetFont(\"font\", fontHeight [, flags])".to_string(),
)
};
let path = match &file {
Value::String(s) => s.to_str()?.to_string(),
Value::Number(_) | Value::Integer(_) => as_f32(&file).to_string(),
_ => return Err(usage()),
};
let height = match &height {
Value::Number(_) | Value::Integer(_) => as_f32(&height),
Value::String(s) => s.to_str()?.parse::<f32>().map_err(|_| usage())?,
_ => return Err(usage()),
};
let flags = super::Outline::flags(flags.as_deref().unwrap_or(""))
.as_str()
.to_string();
|lua, (this, file, height, flags): (Table, Value, Value, Value)| {
let super::font_block::FontArgs {
path,
height,
flags,
} = super::font_block::set_font_args(lua, &this, &file, &height, &flags)?;
// Each embedded font's load fails alike, writing nothing (`0x79f30f` → `0x79f361`).
if !super::font_block::instance_loads(lua, &path, height) {
return Ok(());
}
let flags = flags.as_str().to_string();
with_button(lua, &this, |bs| {
bs.font = Some(ButtonFont {
path,
@@ -702,16 +757,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"SetTextColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = super::object::alpha_f32(lua, &a);
// Shape C on the channels (`Button:SetTextColor 0x780ee0`, `2=C 3=C 4=C 5=B`).
let (r, g, b) = (
super::object::as_f32(&r),
super::object::as_f32(&g),
super::object::as_f32(&b),
super::object::tonumber_f32(lua, &r),
super::object::tonumber_f32(lua, &g),
super::object::tonumber_f32(lua, &b),
);
with_button(lua, &this, |bs| {
bs.normal_color = Some([r, g, b, a.unwrap_or(1.0)])
})
with_button(lua, &this, |bs| bs.normal_color = Some([r, g, b, a]))
},
)?,
)?;
@@ -735,32 +789,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"SetHighlightTextColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = super::object::alpha_f32(lua, &a);
// Shape C on the channels (`Button:SetTextColor 0x780ee0`, `2=C 3=C 4=C 5=B`).
let (r, g, b) = (
super::object::as_f32(&r),
super::object::as_f32(&g),
super::object::as_f32(&b),
super::object::tonumber_f32(lua, &r),
super::object::tonumber_f32(lua, &g),
super::object::tonumber_f32(lua, &b),
);
with_button(lua, &this, |bs| {
bs.highlight_color = Some([r, g, b, a.unwrap_or(1.0)])
})
with_button(lua, &this, |bs| bs.highlight_color = Some([r, g, b, a]))
},
)?,
)?;
m.set(
"SetDisabledTextColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = super::object::alpha_f32(lua, &a);
// Shape C on the channels (`Button:SetTextColor 0x780ee0`, `2=C 3=C 4=C 5=B`).
let (r, g, b) = (
super::object::as_f32(&r),
super::object::as_f32(&g),
super::object::as_f32(&b),
super::object::tonumber_f32(lua, &r),
super::object::tonumber_f32(lua, &g),
super::object::tonumber_f32(lua, &b),
);
with_button(lua, &this, |bs| {
bs.disabled_color = Some([r, g, b, a.unwrap_or(1.0)])
})
with_button(lua, &this, |bs| bs.disabled_color = Some([r, g, b, a]))
},
)?,
)?;
@@ -833,7 +885,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
};
let first = lock.into_iter().next();
let locked = super::binding_abi::bool_or_default(first.as_ref(), false);
with_button(lua, &this, |bs| bs.set_button_state(new, locked))
with_button_shown(lua, &this, |bs| bs.set_button_state(new, locked))
})?,
)?;
m.set(
@@ -873,25 +925,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
texture_pair(lua, &c, "DisabledChecked", Slot::DisabledChecked)?;
c.set(
"SetChecked",
lua.create_function(|lua, (this, v): (Table, Value)| {
// `SetChecked` (`0x799bf0`) reads its argument through `0x6f1c10` with default 1
// (`0x799c77`), not Lua truthiness: a number truncates toward zero (`0x40a2b0`), so
// `SetChecked(0)` unchecks, and the stock UI passes `"true"`/`"false"`
// (`SpellBookFrame.lua:296-303`). Here a missing argument unchecks and a string checks
// only as `"true"` or a nonzero number, where the reference checks for a missing one
// and reads a string by its first byte, so `"yes"` checks there.
let checked = match v {
Value::Boolean(b) => b,
Value::Integer(i) => i != 0,
Value::Number(n) => n.trunc() != 0.0,
Value::String(s) => s.to_str().ok().is_some_and(|s| {
let t = s.trim();
t.eq_ignore_ascii_case("true")
|| t.parse::<f64>().is_ok_and(|n| n.trunc() != 0.0)
}),
_ => false,
};
with_button(lua, &this, |bs| bs.checked = checked)
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
// `SetChecked` (`0x799bf0`) reads its argument through `GetBoolOrDefault` (`0x6f1c10`)
// with default 1 (`0x799c77`), not Lua truthiness: no argument, `""`, a table or
// `"yes"` check, `nil`, `0` and `"0"` uncheck. The stock UI passes `"true"`/`"false"`
// (`SpellBookFrame.lua:296-303`).
let checked = super::binding_abi::bool_or_default(args.front(), true);
with_button_shown(lua, &this, |bs| bs.refresh_checked(checked, false))
})?,
)?;
c.set(
@@ -920,6 +960,15 @@ pub(super) fn wants_click(model: &Model, h: FrameHandle, name: &str) -> bool {
}
}
/// The state half of the release click gate (`0x7792fc`): a Button clicks only while PUSHED, which
/// the press art sets and a drag start, a hide or a lost press clears; any other kind has no state.
pub(super) fn release_may_click(model: &Model, h: FrameHandle) -> bool {
match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Button(bs)) => bs.button_state() == ButtonVisualState::Pushed,
_ => true,
}
}
/// Whether `h` is registered for `button` (`"LeftButton"`, …) as Up or Down, the press-art gate.
/// `OnMouseDown` (`0x779210`) tests `m | m << 8` against `[this+0x330]` (Down in byte 0, Up in
/// byte 1, `0x77924b`) before the Down-only `OnClick` test (`0x77926b`), so any registration for
@@ -942,6 +991,17 @@ pub(super) fn wants_press_visual(model: &Model, h: FrameHandle, button: &str) ->
/// (`0x7826c0` passes 1, the mouse `0x779280`/`0x7793a4` pass 0): a LootButton then does nothing at
/// all, not even its `OnClick` (`0x4c182b`); every other kind ignores it.
pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool, scripted: bool) {
// A hyperlink button's click slot (`+0x94`, `0x7a3510`) is its text frame's
// `OnHyperlinkClick`, in place of the Button's `OnClick` firer.
let target = lua
.app_data_ref::<Model>()
.expect("model app_data")
.id_to_frame
.get(&id)
.copied();
if target.is_some_and(|h| super::hyperlink::click(lua, h, button)) {
return;
}
let mut take_loot = None;
let fire = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
@@ -972,21 +1032,25 @@ pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool, scripte
return;
};
let is_check = frame.kind == FrameKind::CheckButton;
match &mut frame.kind_state {
let (fire, writes) = match &mut frame.kind_state {
// A Button fires only while enabled;
KindState::Button(bs) => {
if bs.enabled() {
if is_check {
bs.checked = !bs.checked;
}
true
let writes = if is_check {
bs.refresh_checked(!bs.checked, false)
} else {
Default::default()
};
(true, writes)
} else {
false
(false, Default::default())
}
}
// any other kind with an `OnClick` just fires.
_ => true,
}
_ => (true, Default::default()),
};
super::region::apply_shown_writes(&mut model, writes);
fire
};
if !fire {
return;
@@ -998,7 +1062,7 @@ pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool, scripte
if let Err(e) = event::fire_widget_handler(lua, id, "OnClick", vec![btn]) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
// A nameplate click selects its unit, from the pointer or `Click()` alike (the plate's
// override `0x7cb910` chains the base), after the handler so an erroring hook cannot eat it. A
+54 -33
View File
@@ -209,20 +209,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"SetGuildRecruitmentMode: invalid mode",
));
}
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let mode = n as u8;
if model.guild_recruitment_mode != mode {
model.guild_recruitment_mode = mode;
model.guild_recruitment_changed = true;
}
// `0x49ea79 jne` / `0x49ea7b jmp 0x49ea90`: the cascade, on the new value alone.
if mode == 1 {
model.guild_recruitment_cascade = true;
}
// `0x4a00a4`/`0x4a00a9`: `UPDATE_CHAT_WINDOWS`, on every successful call.
model
.pending_events
.push(("UPDATE_CHAT_WINDOWS".to_string(), Vec::new()));
super::tick::verb(lua, |model| {
let mode = n as u8;
if model.guild_recruitment_mode != mode {
model.guild_recruitment_mode = mode;
model.guild_recruitment_changed = true;
}
// `0x49ea79 jne` / `0x49ea7b jmp 0x49ea90`: the cascade, on the new value alone.
if mode == 1 {
model.guild_recruitment_cascade = true;
}
// `0x4a00a4`/`0x4a00a9`: `UPDATE_CHAT_WINDOWS`, on every successful call, in place.
model.raise("UPDATE_CHAT_WINDOWS", Vec::new());
});
Ok(())
})?,
)?;
@@ -296,20 +295,29 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// JoinChannelByName(name [, password [, frameId]]) (`0x49ff00` → `0x49eb70`): a DBC row
// answers `(ChannelID, resolvedName)` and a custom channel `(0, nil)`, both sending; a name
// with a space, or a row whose zone text is empty, answers nil and sends nothing. A `frameId`
// that truncates to 1..=10 registers the channel in that window's list, deduplicated by name
// JoinChannelByName(name [, password [, frameId]]) (`0x49ff00` → `0x49eb70`): `name` is a
// string or number (anything else raises `Usage: JoinChannelByName("name")`, `0x49ffe7`), the
// password a string or number, else empty. A DBC row answers `(ChannelID, resolvedName)` and a
// custom channel `(ChannelID, nil)`, two values either way (`0x49ffcc`, `0x49ffdd`), both
// sending; a name with a space (`0x49ff5e`), a row whose zone text is empty, or a failed join
// (`0x49ffaa`) pushes nothing and sends nothing. A `frameId` (a number or numeric string) that
// truncates to 1..=10 registers the channel in that window's list, deduplicated by name
// (`0x49ec24`), which is what brings a `/leave`-stripped channel's lines back to the window.
g.set(
"JoinChannelByName",
lua.create_function(
|lua, (name, password, frame): (Option<String>, Option<String>, Value)| {
let Some(name) = non_empty(name) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
if name.contains(' ') {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|lua, (name, password, frame): (Option<Value>, Option<Value>, Option<Value>)| {
let name = super::binding_abi::string_arg(
lua,
name.unwrap_or(Value::Nil),
r#"Usage: JoinChannelByName("name")"#,
)?;
let password = password
.as_ref()
.and_then(|v| super::binding_abi::optional_string(lua, v))
.unwrap_or_default();
if name.is_empty() || name.contains(' ') {
return Ok(MultiValue::new());
}
let row = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
@@ -321,9 +329,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
};
let (id, resolved, listed_as) = match row {
None => (0, None, name.clone()),
Some(ZoneChannelRow { resolved: None, .. }) => {
return Ok(MultiValue::from_vec(vec![Value::Nil]))
}
Some(ZoneChannelRow { resolved: None, .. }) => return Ok(MultiValue::new()),
Some(ZoneChannelRow {
id,
resolved,
@@ -333,11 +339,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
};
// `0x49ff6b edi = -1` / `0x49ff8a dec edi`; `0x49ec27 cmp esi,0xa; jae` skips.
let window = match frame {
Value::Integer(n) => Some(n as f64),
Value::Number(n) => Some(n),
_ => None,
Some(v) => lua.coerce_number(v)?,
None => None,
}
.map(|n| n.trunc() as i64 - 1)
.map(|n| super::binding_abi::ftol(n).wrapping_sub(1))
.and_then(|i| usize::try_from(i).ok())
.filter(|i| *i < 10);
if let Some(i) = window {
@@ -349,7 +354,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua,
ChannelCommand::Join {
name: resolved.clone().unwrap_or_else(|| name.clone()),
password: password.unwrap_or_default(),
password,
},
);
Ok(MultiValue::from_vec(vec![
@@ -398,7 +403,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.to_string(),
_ => return Err(mlua::Error::runtime(r#"Usage: LeaveChannelByName("name")"#)),
};
if super::chat_window::leading_int(&key) != 0 {
if super::binding_abi::s_str_to_int(&key) != 0 {
push(lua, ChannelCommand::Leave { name: key });
return Ok(());
}
@@ -711,6 +716,22 @@ mod command_tests {
"…and strips no window: a slot name never equals a window entry"
);
// `SStrToInt` takes no `+`: "+2" is a channel name, so its window entry is stripped, which
// the numeric leg (a slot, never a window entry) does not do.
s.run("AddChatWindowChannel(1, '+2')").unwrap();
s.take_channel_commands();
s.take_chat_window_changes();
s.run("LeaveChannelByName('+2')").unwrap();
assert!(
!s.chat_window_looks()[0]
.channels
.iter()
.any(|(c, _)| c == "+2"),
"a '+'-prefixed key is a name"
);
s.take_channel_commands();
s.take_chat_window_changes();
s.run("LeaveChannelByName('general')").unwrap();
assert_eq!(
s.take_channel_commands(),
+6 -2
View File
@@ -17,6 +17,7 @@ use mlua::{Lua, Value};
use super::binding_abi::flag;
use super::{binding_abi, Model};
use crate::script::binding_abi::Idx;
/// The `SkillLine.dbc` id behind weapon subclass `0..=20` (vmangos `Item.cpp:700-707`), `None`
/// for the obsolete, exotic and misc rows; with no weapon the skill is [`SKILL_UNARMED`]
@@ -406,7 +407,7 @@ pub(super) const KEYRING_INV_SLOTS: std::ops::RangeInclusive<usize> = 82..=113;
/// The keyring's container id (`KEYRING_CONTAINER`, `MainMenuBarBagButtons.lua:1`); must match
/// `ui_items::KEYRING_CONTAINER` in the app, which feeds the keys as that container.
const KEYRING_CONTAINER: i64 = -2;
pub(super) const KEYRING_CONTAINER: i64 = -2;
impl Model {
/// The item `token` exposes at live-API id `slot`, the one routing the `GetInventoryItem*`
@@ -501,6 +502,9 @@ impl InvSlotView {
locked: slot.locked,
creator: slot.creator.clone(),
enchants: slot.enchants.clone(),
// The item's own flags (UNLOCKED, WRAPPED) and expiry, which the tooltip builder reads.
flags: slot.flags,
duration_ms: slot.duration_ms,
// Slot 20 (`Bag0Slot`) among `equip_slots` marks a container (`INVTYPE_BAG`). The bank
// vault is past the `0x16` short-circuit, so only `Some` versus `None` matters.
contents_count: slot.equip_slots.contains(&20).then_some(0),
@@ -859,7 +863,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// 4 broken.
g.set(
"GetInventoryAlertStatus",
lua.create_function(|lua, index: usize| {
lua.create_function(|lua, Idx(index): Idx| {
let model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(if (1..=12).contains(&index) {
i64::from(model.inventory_alerts[index - 1])
+468
View File
@@ -0,0 +1,468 @@
//! The chat bubble widgets: the reference's `CGChatBubbleFrame` (ctor `0x4b0940`), an anonymous
//! `Frame` the pool `0x4b1870` builds with the `WorldFrame` as its parent (the spawn passes
//! `[0xb4b2bc]`, `0x608bb9`), so it takes the `WorldFrame`'s stratum (`SetParent 0x76ab10`).
//! Bubble addons (pfUI's `bubbles.lua`) find it in `WorldFrame:GetChildren()` by its first
//! region's texture and read it positionally: eleven regions in creation order, the backdrop's
//! background and eight border pieces (`0x4b0a35` → `0x77e8d0`), the tail (`0x4b0a57`) and the
//! text (`0x4b0b14`).
//!
//! The app owns each line's lifetime, fade and seat ([`BubbleState`]); this layer owns the frames:
//! it hands one out per line, takes it back when the line ends, and writes what moved.
use std::collections::{HashMap, VecDeque};
use crate::layout::{Anchor, Anchors, Point};
use crate::order::DrawLayer;
use crate::widget::{FrameHandle, FrameKind, RegionHandle, RegionKind};
use super::backdrop::{self, Backdrop, Insets};
use super::model::Model;
use super::UiScript;
/// Region 1's art, the backdrop's background, and the path bubble addons identify a bubble by.
pub const BG_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Background";
/// Regions 2 to 9, the backdrop's eight border pieces.
pub const EDGE_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Backdrop";
/// Region 10, the tail.
pub const TAIL_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Tail";
/// The first frame level the per-frame pass stamps, farthest bubble first (`0x4b12de`).
const FIRST_LEVEL: u16 = 2;
/// The bubble geometry that follows from the window rather than the line, in the `WorldFrame`'s
/// units. Written into a bubble when it changes, so an addon's own anchors survive between
/// resizes. A resize re-sizes the live bubbles as part of the live re-layout's deviation, where
/// the reference can reload the UI on an aspect change (`0x492f13` → `0x491380`); the `Deviation:`
/// line is at the resize in `benilla_app`'s `extract::tick_script`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BubbleGeometry {
/// The border unit (`0x4b09d2`-`0x4b0a2f`): the backdrop's edge size, tile size and four
/// insets, the tail's side, and half the text's width floor.
pub border: f32,
/// The frame's margin around the text on all four sides (`0x4b0b6b`, `0x4b0b94`).
pub margin: f32,
/// The text's width cap, past which it wraps (`0x4b175d`).
pub wrap_cap: f32,
/// The text's font height (`0x4b0b21`).
pub text_height: f32,
/// Device pixels per unit, the grid the seat snaps to.
pub device_px: f32,
}
/// One live bubble's state for this frame, in the active list's order: farthest from the camera
/// first, which is the frame level order.
#[derive(Clone, Debug, PartialEq)]
pub struct BubbleState {
/// The speaker's GUID: a speaker has at most one bubble (`[unit+0xe64]`).
pub key: u64,
/// The chat line, unique per spawn: a new value for a live key is a new line, which replaces
/// the bubble (`0x608bb4`, `0x608bbf`).
pub line: u64,
/// The display text, already through the bubble's escape strip.
pub text: String,
/// The chat type's colour (`0x49e990`).
pub colour: [f32; 3],
/// The own-shown bit: a line is shown when it spawns and while it fades, hidden when an
/// out-of-range fade ends (`0x4b0d9f`), shown again on the way back (`0x4b0ea0`).
pub shown: bool,
/// The fade's frame alpha (`0x4b0d4f`, `0x4b0dc3`).
pub alpha: f32,
/// The projected speaker point the text's BOTTOM sits on, from the `WorldFrame`'s
/// BOTTOMLEFT (`0x4b157a`).
pub seat: (f32, f32),
}
/// One pooled bubble's widgets, in the reference's creation order after the backdrop's nine.
#[derive(Debug)]
struct Bubble {
frame: FrameHandle,
tail: RegionHandle,
text: RegionHandle,
/// The `(key, line)` it shows; `None` in the free list.
line: Option<(u64, u64)>,
/// The own-shown bit as this layer last set it (`[frame+0xd0]`); a new frame is born shown by
/// `CSimpleFrame`'s ctor (`0x76934a`).
shown: bool,
/// The anchor offset last written, the change gate's cache (`[bubble+0x324]`, `+0x328`).
seat: Option<(f32, f32)>,
/// The geometry its sizes were written under.
laid_out: Option<BubbleGeometry>,
}
/// The bubble pool: grown on demand, never shrunk or reordered, so every bubble keeps its place in
/// the `WorldFrame`'s child list for the session.
#[derive(Debug, Default)]
pub(crate) struct ChatBubbles {
bubbles: Vec<Bubble>,
/// The free list (`[0xb6f064]`): a finished bubble joins the tail (`0x4b15e5`-`0x4b15f7`) and a
/// new line takes the head (`0x4b1870`), so the bubble retired first is reused first.
free: VecDeque<usize>,
/// Each live speaker's pool slot, by [`BubbleState::key`].
assigned: HashMap<u64, usize>,
/// Frame to pool slot, never removed.
by_frame: HashMap<FrameHandle, usize>,
/// The active list (`[0xb6f038]`) as the last sync left it: the live slots, farthest first.
active: Vec<usize>,
}
impl UiScript {
/// Drive the pool from the app's live bubbles: end the lines that are gone, hand a frame to
/// each new one, then write each live bubble's level, alpha, visibility and seat.
pub fn sync_chat_bubbles(&mut self, geometry: BubbleGeometry, states: &[BubbleState]) {
let lua = self.lua();
// The font a new bubble takes: `FrameScript_GetText` on `NAMEPLATE_FONT` (`0x4b0b36` →
// `0x703bf0`), whose `_G[key]` arm (`0x703c12`) answers the global's string, else the
// shared "" a missing or non-string global gives, which sets no font.
let font = || match lua.globals().get::<mlua::Value>("NAMEPLATE_FONT") {
Ok(mlua::Value::String(s)) => Some(s.to_string_lossy()).filter(|s| !s.is_empty()),
_ => None,
};
let visibility = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// No WorldFrame (no interface loaded), no bubbles: no addon walks an invented root.
let Some(world) = model.arena.lookup("WorldFrame") else {
return;
};
sync(&mut model, world, geometry, states, &font)
};
// Each a full transition with its handlers, in the order the sync issued them.
for (frame, shown) in visibility {
super::visibility::set_shown(lua, frame, shown);
}
}
/// How many bubble widgets the pool holds.
pub fn chat_bubble_pool_len(&self) -> usize {
let lua = self.lua();
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.chat_bubbles.bubbles.len()
}
}
/// Whether `frame` is a pooled bubble.
pub(super) fn is_bubble(model: &Model, frame: FrameHandle) -> bool {
model.chat_bubbles.by_frame.contains_key(&frame)
}
/// The per-frame drive; returns the `Show`/`Hide` calls to run, in order.
fn sync(
model: &mut Model,
world: FrameHandle,
geometry: BubbleGeometry,
states: &[BubbleState],
font: &dyn Fn() -> Option<String>,
) -> Vec<(FrameHandle, bool)> {
let mut visibility = Vec::new();
// Lines that ended, in the active list's order, farthest first, as the reference's walk
// (`0x4b1138`) meets and destroys them (`0x4b1186`, `0x4b11c0`), so they join the free list in
// that order, before any new line this frame looks for a frame.
let live = |key: u64, line: u64| states.iter().any(|s| s.key == key && s.line == line);
for i in std::mem::take(&mut model.chat_bubbles.active) {
if let Some((key, line)) = model.chat_bubbles.bubbles[i].line {
if !live(key, line) {
retire(model, i, &mut visibility);
}
}
}
let world_id = model.frame_id(world);
for (rank, state) in states.iter().enumerate() {
let slot = match model.chat_bubbles.assigned.get(&state.key) {
Some(&i) => i,
None => {
// A new line: the spawn's acquire (`0x4b1870`), then its text (`0x4b1600`).
let i = acquire(model, world, geometry, font);
model.chat_bubbles.assigned.insert(state.key, i);
if model.chat_bubbles.bubbles[i].laid_out != Some(geometry) {
lay_out(model, i, geometry);
}
set_line(model, i, state, geometry, &mut visibility);
i
}
};
if model.chat_bubbles.bubbles[slot].laid_out != Some(geometry) {
lay_out(model, slot, geometry);
fit_width(model, slot, geometry);
}
drive(
model,
slot,
world_id,
rank,
state,
geometry,
&mut visibility,
);
model.chat_bubbles.active.push(slot);
}
visibility
}
/// Take a frame for a new line: the free list's head, else a new one at the pool's tail.
fn acquire(
model: &mut Model,
world: FrameHandle,
geometry: BubbleGeometry,
font: &dyn Fn() -> Option<String>,
) -> usize {
if let Some(i) = model.chat_bubbles.free.pop_front() {
return i;
}
let bubble = create(model, world, geometry, font());
let i = model.chat_bubbles.bubbles.len();
model.chat_bubbles.by_frame.insert(bubble.frame, i);
model.chat_bubbles.bubbles.push(bubble);
i
}
/// End a bubble's line (`0x4b1590`): clear its shown bit and hide it, then put it on the free
/// list's tail. It keeps its parent and its place in the child list.
fn retire(model: &mut Model, i: usize, visibility: &mut Vec<(FrameHandle, bool)>) {
let b = &mut model.chat_bubbles.bubbles[i];
let Some((key, _)) = b.line.take() else {
return;
};
if b.shown {
b.shown = false;
visibility.push((b.frame, false));
}
model.chat_bubbles.assigned.remove(&key);
model.chat_bubbles.free.push_back(i);
}
/// Build one bubble (`0x4b0940`): the Frame under the `WorldFrame`, the backdrop's nine regions,
/// the tail, the text, and the frame's two anchors on the text.
fn create(
model: &mut Model,
world: FrameHandle,
g: BubbleGeometry,
font: Option<String>,
) -> Bubble {
// Anonymous: the pool passes no name.
let frame = model.arena.create(FrameKind::Frame, None, Some(world));
// `SetParent 0x76ab10`: the parent's stratum and its level plus one, until the first level
// stamp.
let (strata, level) = model
.arena
.frame(world)
.map(|f| (f.strata, f.level))
.unwrap_or_default();
model.arena.set_frame_strata(frame, strata);
model
.arena
.set_frame_level(frame, level.saturating_add(1), false);
// The backdrop first (`0x4b0a35` → `0x76a5d0`): edge mask 0xff and tile on (`0x4b09bf`,
// `0x4b09c6`), one border unit for the four insets (`0x4b09ec`-`0x4b09f5`), the edge size
// (`0x4b0a12`, `+0x48`) and the tile size (`0x4b0a2f`, `+0x4c`).
backdrop::install(
model,
frame,
Some(Backdrop {
bg_file: Some(BG_TEXTURE.to_string()),
edge_file: Some(EDGE_TEXTURE.to_string()),
tile: true,
tile_size: g.border,
edge_size: g.border,
insets: uniform(g.border),
..Backdrop::default()
}),
);
// Then the tail on ARTWORK (`0x4b0a57`, layer 2) and the text on ARTWORK (`0x4b0b14`, layer 2).
let tail = model
.arena
.create_region(frame, RegionKind::Texture, DrawLayer::Artwork, 0)
.expect("live bubble");
model.region_data.entry(tail).or_default().texture = Some(TAIL_TEXTURE.to_string());
let text = model
.arena
.create_region(frame, RegionKind::FontString, DrawLayer::Artwork, 0)
.expect("live bubble");
// The `NAMEPLATE_FONT` global's font with no flags (`0x4b0b2b`-`0x4b0b42`): no outline, no
// shadow.
let data = model.region_data.entry(text).or_default();
data.font_path = font;
data.font_explicit.face = true;
model.touch_layout_frame(frame);
model.touch_layout_region(tail);
model.touch_layout_region(text);
Bubble {
frame,
tail,
text,
line: None,
shown: true,
seat: None,
laid_out: None,
}
}
fn uniform(v: f32) -> Insets {
Insets {
left: v,
right: v,
top: v,
bottom: v,
}
}
/// Write the window-derived sizes and anchors: the backdrop's unit, the tail (`0x4b0a7d`-
/// `0x4b0af1`), the font height and the frame's margin anchors on the text.
fn lay_out(model: &mut Model, i: usize, g: BubbleGeometry) {
let b = &model.chat_bubbles.bubbles[i];
let (frame, tail, text) = (b.frame, b.tail, b.text);
backdrop::set_metrics(model, frame, g.border, g.border, uniform(g.border));
let frame_id = model.frame_id(frame);
let text_id = model.region_id(text);
// A border-unit square, its TOPRIGHT on the frame's BOTTOM lifted a quarter unit.
let tail_data = model.region_data.entry(tail).or_default();
tail_data.size = Some((g.border, g.border));
tail_data.anchors = Anchors::from([Anchor::new(
Point::TopRight,
frame_id,
Point::Bottom,
0.0,
g.border / 4.0,
)]);
model.region_data.entry(text).or_default().font_height = Some(g.text_height);
model.touch_measure(text);
// The frame hugs the text: TOPLEFT and BOTTOMRIGHT on the text's, out by the margin.
let input = model.layout_inputs.entry(frame).or_default();
input.anchors = Anchors::from([
Anchor::new(Point::TopLeft, text_id, Point::TopLeft, -g.margin, g.margin),
Anchor::new(
Point::BottomRight,
text_id,
Point::BottomRight,
g.margin,
-g.margin,
),
]);
// Once per geometry change: each node it wrote is invalidated, as the bubble build's
// `SetPoint`s with the invalidate flag do (`0x4b0b6b`, `0x4b0b94`).
model.invalidate_frame(frame, true);
model.invalidate_region(tail, true);
model.invalidate_region(text, true);
model.chat_bubbles.bubbles[i].laid_out = Some(g);
}
/// Show a new line (`0x4b1600`): its text and colour, its width fitted, then `Show` through the
/// fade-in's start (`0x4b17f7` → `0x4b0ea0` → vtable `+0x88`), which fires `OnShow` on a reused,
/// hidden frame.
fn set_line(
model: &mut Model,
i: usize,
state: &BubbleState,
g: BubbleGeometry,
visibility: &mut Vec<(FrameHandle, bool)>,
) {
let b = &mut model.chat_bubbles.bubbles[i];
b.line = Some((state.key, state.line));
let (frame, text, was_shown) = (b.frame, b.text, b.shown);
b.shown = true;
let c = state.colour;
let data = model.region_data.entry(text).or_default();
data.text = Some(state.text.clone());
data.vertex_color = Some([c[0], c[1], c[2], 1.0]);
fit_width(model, i, g);
if !was_shown {
visibility.push((frame, true));
}
}
/// The width law (`0x4b16c5`-`0x4b1796`): with width and height cleared, the text's own width;
/// past the cap the cap, so it wraps, else at least two border units. The height stays cleared,
/// so it is the wrapped text's.
fn fit_width(model: &mut Model, i: usize, g: BubbleGeometry) {
let text = model.chat_bubbles.bubbles[i].text;
if let Some(d) = model.region_data.get_mut(&text) {
d.size = None;
}
model.touch_measure(text);
super::measure::ensure_measured_in(model, text);
let natural = super::region::virtual_span(model, text).0;
let floor = 2.0 * g.border;
let width = if natural > g.wrap_cap {
g.wrap_cap
} else {
natural.max(floor)
};
if let Some(d) = model.region_data.get_mut(&text) {
d.size = Some((width, 0.0));
}
model.touch_measure(text);
// `SetWidth`, which invalidates (`0x7683d0`).
model.invalidate_region(text, true);
}
/// The per-frame write, each property only when it moved.
fn drive(
model: &mut Model,
i: usize,
world_id: u32,
rank: usize,
state: &BubbleState,
g: BubbleGeometry,
visibility: &mut Vec<(FrameHandle, bool)>,
) {
let b = &model.chat_bubbles.bubbles[i];
let (frame, text, shown, last_seat) = (b.frame, b.text, b.shown, b.seat);
// `0x76a4f0(2 + i, 0)` down the list: no carry to children (`0x4b1306`).
let level = u16::try_from(rank)
.unwrap_or(u16::MAX)
.saturating_add(FIRST_LEVEL);
model.arena.set_frame_level(frame, level, false);
if model
.arena
.frame(frame)
.is_some_and(|f| f.alpha != state.alpha)
{
model.arena.set_alpha(frame, state.alpha);
}
if shown != state.shown {
model.chat_bubbles.bubbles[i].shown = state.shown;
visibility.push((frame, state.shown));
}
// The seat (`0x4b14a0`): the text's BOTTOM on the `WorldFrame`'s BOTTOMLEFT, re-anchored only
// when the snapped point moved. Deviation: the frame's bottom-left origin is snapped onto the
// device grid so the border art blits 1:1; the reference ceils the anchor point itself onto
// it (`0x7666a0`, `0x766750`).
let width = model
.region_data
.get(&text)
.and_then(|d| d.size)
.map_or(0.0, |s| s.0)
+ 2.0 * g.margin;
let snap = |v: f32| {
if g.device_px > 0.0 {
(v * g.device_px).round() / g.device_px
} else {
v
}
};
let seat = (
snap(state.seat.0 - width * 0.5) + width * 0.5,
snap(state.seat.1 - g.margin) + g.margin,
);
if last_seat != Some(seat) {
let data = model.region_data.entry(text).or_default();
data.anchors = Anchors::from([Anchor::new(
Point::Bottom,
world_id,
Point::BottomLeft,
seat.0,
seat.1,
)]);
// `SetPoint` with the invalidate flag (`0x4b157a`).
model.invalidate_region(text, true);
model.chat_bubbles.bubbles[i].seat = Some(seat);
}
}
+56 -53
View File
@@ -5,6 +5,7 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::bool_or_default;
use super::Model;
/// `DoEmote(token [, target])`: the `EmotesText.dbc` name token (`"WAVE"`, not `/wave`) and an
@@ -31,17 +32,6 @@ fn to_u32(v: &Value) -> u32 {
}
}
fn flag_arg(v: Option<Value>) -> Option<bool> {
match v {
None | Some(Value::Nil) => None,
Some(Value::Boolean(b)) => Some(b),
Some(Value::Integer(n)) => Some(n != 0),
Some(Value::Number(n)) => Some(n != 0.0),
Some(Value::String(s)) => Some(s.to_str().is_ok_and(|s| s != "0" && !s.is_empty())),
Some(_) => Some(true),
}
}
impl super::UiScript {
/// `DoEmote` calls since the last drain.
pub fn take_emote_requests(&mut self) -> Vec<EmoteRequest> {
@@ -75,6 +65,35 @@ impl super::UiScript {
}
}
/// `ConsoleExec` (`0x515f10`) is in the registrar both states call (`0x51bb20`, from the glue
/// registrar `0x46abb0` and the in-game `0x51bb60`), so it installs apart from this file's in-game
/// verbs.
pub(super) fn install_console_exec(lua: &Lua) -> mlua::Result<()> {
// `ConsoleExec("name value")`: a registered CVar with a value is written at once, as `SetCVar`
// writes it; anything else, a bare CVar name included (the reference answers it with
// `CVar "%s" is "%s"`, `0x63dde0`), goes to the app's command registry.
lua.globals().set(
"ConsoleExec",
lua.create_function(|lua, line: Option<String>| {
let Some(line) = line.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) else {
return Ok(());
};
let (name, value) = line
.split_once(char::is_whitespace)
.map_or((line.as_str(), ""), |(n, v)| (n, v.trim()));
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let is_cvar = model.cvars.contains_key(&name.to_ascii_lowercase());
if is_cvar && !value.is_empty() {
super::cvars::write_cvar(&mut model, name, value.to_string());
} else {
model.console_lines.push(line.clone());
}
Ok(())
})?,
)?;
Ok(())
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
@@ -135,53 +154,33 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// `ConsoleExec("name value")`: a registered CVar with a value is written at once, as `SetCVar`
// writes it; anything else, a bare CVar name included (the reference answers it with
// `CVar "%s" is "%s"`, `0x63dde0`), goes to the app's command registry.
g.set(
"ConsoleExec",
lua.create_function(|lua, line: Option<String>| {
let Some(line) = line.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) else {
return Ok(());
};
let (name, value) = line
.split_once(char::is_whitespace)
.map_or((line.as_str(), ""), |(n, v)| (n, v.trim()));
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let is_cvar = model.cvars.contains_key(&name.to_ascii_lowercase());
if is_cvar && !value.is_empty() {
super::cvars::write_cvar(&mut model, name, value.to_string());
} else {
model.console_lines.push(line.clone());
}
Ok(())
})?,
)?;
for (name, chat) in [("LoggingChat", true), ("LoggingCombat", false)] {
g.set(
name,
lua.create_function(move |lua, flag: Option<Value>| {
lua.create_function(move |lua, args: MultiValue| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let slot = if chat {
&mut model.logging_chat
} else {
&mut model.logging_combat
};
match flag_arg(flag) {
None => Ok(MultiValue::from_vec(vec![if *slot {
Value::Integer(1)
// With an argument (`lua_gettop > 0`, `0x49fe57`) the flag is set from
// `GetBoolOrDefault` (`0x49fe69`, default 1), so an explicit nil clears it. Both
// paths then answer the flag as it stands, 1 or nil, one value (`0x49feb2`-`0x49fee1`):
// the reference re-reads it after the log-open attempt, which can clear it
// (`0x49feab`); the app owns that attempt here.
if let Some(on) = args.front().map(|v| bool_or_default(Some(v), true)) {
let slot = if chat {
&mut model.logging_chat
} else {
Value::Nil
}])),
Some(on) => {
if *slot != on {
*slot = on;
model.logging_changed = true;
}
Ok(MultiValue::new())
&mut model.logging_combat
};
if *slot != on {
*slot = on;
model.logging_changed = true;
}
}
let now = if chat {
model.logging_chat
} else {
model.logging_combat
};
Ok(if now { Value::Integer(1) } else { Value::Nil })
})?,
)?;
}
@@ -283,8 +282,8 @@ mod tests {
assert!(s.eval::<bool>("return LoggingCombat() == nil").unwrap());
assert_eq!(
s.arity("LoggingChat(true)").unwrap(),
0,
"the setter returns nothing"
1,
"the setter answers the flag too (`0x49fed3`)"
);
assert_eq!(s.eval::<i64>("return LoggingChat()").unwrap(), 1);
assert_eq!(s.logging_flags(), (true, false));
@@ -297,5 +296,9 @@ mod tests {
!s.take_logging_changes(),
"a write that moves nothing cues nothing"
);
// The setter answers the flag it leaves: 1 or nil, one value either way.
assert_eq!(s.eval::<i64>("return LoggingChat(true)").unwrap(), 1);
assert_eq!(s.arity("LoggingChat(nil)").unwrap(), 1);
assert!(s.eval::<bool>("return LoggingChat(nil) == nil").unwrap());
}
}
+3 -2
View File
@@ -2,7 +2,7 @@
//! `ResetChatColors`: the reference's runtime table (`0xb4e518`), which the `COLORS` block of
//! `chat-cache.txt` overwrites in place at load; the app owns that file.
use mlua::{Lua, MultiValue, Value};
use mlua::{Lua, MultiValue};
use super::Model;
@@ -247,7 +247,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
reset(&mut model.chat_colors);
model.chat_colors_changed = true;
Ok(Value::Nil)
// `0x4a09e0` ends `xor eax,eax; ret`: no value.
Ok(())
})?,
)?;
+64 -51
View File
@@ -205,28 +205,6 @@ impl ChatWindowLook {
}
}
/// The leading decimal integer after optional whitespace and a sign, 0 when there is none. The
/// reference's `SStrToInt` (`0x64ac60`) takes neither the whitespace nor a `+`.
pub(super) fn leading_int(s: &str) -> i64 {
let s = s.trim_start();
let (neg, digits) = match s.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, s.strip_prefix('+').unwrap_or(s)),
};
let n: i64 = digits
.chars()
.take_while(|c| c.is_ascii_digit())
.fold(0i64, |acc, c| {
acc.saturating_mul(10)
.saturating_add(i64::from(c as u8 - b'0'))
});
if neg {
-n
} else {
n
}
}
/// The byte `__ftol(x · 255.0)` stores, clamped (the module's deviation); non-finite stores 0.
fn to_byte(x: f64) -> u8 {
if !x.is_finite() {
@@ -407,11 +385,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// SetChatWindowLocked(id, isLocked) (`0x4a1650`): the record's `+0x8c` (`0x4a16a2`), 1 at
// boot (`0x4984e4`). A `bool` holds it, as the cache writer stores it through `setne`
// (`0x499e8b`). The argument is Lua truthiness, where the reference coerces through `0x6f1c10`
// (default 0): `0` and "off" unlock there and lock here. FrameXML passes only 1 and nil.
// (`0x499e8b`). The argument goes through `GetBoolOrDefault` (`0x4a1697`, default 0), so `0`
// and "off" unlock and "yes" locks. FrameXML passes only 1 and nil.
lua.globals().set(
"SetChatWindowLocked",
lua.create_function(|lua, (id, locked): (i64, bool)| {
lua.create_function(|lua, (id, locked): (i64, Option<Value>)| {
let locked = crate::script::binding_abi::bool_or_default(locked.as_ref(), false);
let i = window_index(id)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let look = &mut model.chat_window_looks[i];
@@ -513,31 +492,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// SetChatWindowShown(id [, shown]): `+0x94` through the flag coercion `0x6f1c10`, default 1.
// No argument stores 1; nil, false, 0, "0", "off" and "disabled" store 0. The reference also
// stores 0 for a string opening F or N ("false", "no"), which stores 1 here.
// SetChatWindowShown(id [, shown]): `+0x94` (`0x4a1782`) through `GetBoolOrDefault`
// (`0x4a1777`) with default 1: no argument stores 1; nil, false, `0`, "0", "off", "disabled"
// and anything opening F or N ("false", "no") store 0.
lua.globals().set(
"SetChatWindowShown",
lua.create_function(|lua, (id, rest): (i64, MultiValue)| {
let shown = match rest.front() {
None => true,
Some(Value::Nil) | Some(Value::Boolean(false)) => false,
Some(Value::Boolean(true)) => true,
Some(Value::Integer(n)) => *n != 0,
Some(Value::Number(n)) => n.is_finite() && n.trunc() != 0.0,
Some(Value::String(s)) => {
let s = s
.to_str()
.map(|s| s.trim().to_ascii_lowercase())
.unwrap_or_default();
if s.starts_with(|c: char| c.is_ascii_digit() || c == '-') {
leading_int(&s) != 0
} else {
!(s == "off" || s == "disabled")
}
}
Some(_) => true,
};
let shown = crate::script::binding_abi::bool_or_default(rest.front(), true);
with_look(lua, id, |look| look.shown = shown)
})?,
)?;
@@ -620,7 +581,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// RemoveChatWindowChannel(id, name | n) (`0x4a1260`): a numeric key names the joined channel
// in slot n, and an empty slot removes nothing; the key then resolves to its DBC Shortcut as
// in slot n, and an empty slot or a negative number removes nothing; the key then resolves to its DBC Shortcut as
// above, and the entry of that name is freed.
lua.globals().set(
"RemoveChatWindowChannel",
@@ -630,8 +591,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
};
let key = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let n = leading_int(&key);
let key = if n > 0 {
let n = crate::script::binding_abi::s_str_to_int(&key);
// `0x49e880`: a non-zero `SStrToInt` names a joined slot, and `0x49be50` answers
// NULL below slot 1, so `"-1"` removes nothing (`0x4a12c9`); a zero keeps the
// string raw, untrimmed, for the case-insensitive compares (`0x4a1315`,
// `0x4a1359`).
let key = if n != 0 {
match usize::try_from(n)
.ok()
.and_then(|n| model.joined_channels.get(n - 1))
@@ -641,7 +606,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
None => return Ok(()),
}
} else {
key.trim().to_string()
key
};
match model
.zone_channel_catalog
@@ -1062,6 +1027,54 @@ mod record_tests {
assert_eq!(s.take_chat_window_changes(), vec![0]);
}
/// A channel key is `SStrToInt` (`0x64ac60`, through `0x49e880`): an optional `-` and digits,
/// with no whitespace skip and no `+`, so only a plain number names a joined slot.
#[test]
fn a_channel_key_is_a_slot_only_as_sstrtoint_reads_it() {
let mut s = UiScript::new().unwrap();
s.set_joined_channels(vec![Some("MyChan".into())]);
s.run("AddChatWindowChannel(1, 'MyChan')").unwrap();
for key in ["+1", " 1", "x1"] {
s.run(&format!("RemoveChatWindowChannel(1, {key:?})"))
.unwrap();
assert_eq!(
s.chat_window_looks()[0].channels,
vec![("MyChan".to_string(), 0)],
"{key:?} is a name, not slot 1"
);
}
// A negative number is a slot too (non-zero, `0x49e889`) and no slot is below 1.
s.run("AddChatWindowChannel(1, '-1')").unwrap();
s.run("RemoveChatWindowChannel(1, '-1')").unwrap();
assert_eq!(
s.chat_window_looks()[0].channels,
vec![("MyChan".to_string(), 0), ("-1".to_string(), 0)],
"'-1' removes nothing"
);
// A name is raw: the blanks around it are part of it.
s.set_chat_window_looks([(
4,
ChatWindowLook {
channels: vec![(" pad ".into(), 0)],
..Default::default()
},
)]);
s.run("RemoveChatWindowChannel(5, 'pad')").unwrap();
assert_eq!(
s.chat_window_looks()[4].channels.len(),
1,
"'pad' is not ' pad '"
);
s.run("RemoveChatWindowChannel(5, ' pad ')").unwrap();
assert!(s.chat_window_looks()[4].channels.is_empty());
s.run("RemoveChatWindowChannel(1, '1abc')").unwrap();
assert_eq!(
s.chat_window_looks()[0].channels,
vec![("-1".to_string(), 0)],
"'1abc' reads 1 and frees the channel joined in slot 1"
);
}
#[test]
fn a_host_loaded_record_is_what_the_verbs_read() {
let mut s = UiScript::new().unwrap();
+26 -12
View File
@@ -7,9 +7,9 @@ use mlua::{Lua, MultiValue, Value};
use super::Model;
/// The 1.12.1 client's build identity, as the strings in its binary spell it.
const VERSION: &str = "1.12.1";
const BUILD: &str = "5875";
const BUILD_DATE: &str = "Sep 19 2006";
pub(super) const VERSION: &str = "1.12.1";
pub(super) const BUILD: &str = "5875";
pub(super) const BUILD_DATE: &str = "Sep 19 2006";
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
@@ -53,17 +53,29 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// Three setters whose state has no reader here, so each takes its argument as the reference
// does, never raising, and stores nothing; all return zero values.
// `SetLayoutMode([mode])` (`0x488540`): a number or numeric string (`0x6f34d0`) truncates
// (`0x40a2b0`, [`super::binding_abi::ftol`]) into the UI root's `+0xcf8`, anything else
// stores 1 (`0x488573`); never raises, returns zero values. The press handler reads it
// (`0x766301`).
g.set(
"SetLayoutMode",
lua.create_function(|lua, v: Value| {
let mode = lua.coerce_number(v)?.map_or(1, super::binding_abi::ftol);
lua.app_data_mut::<Model>()
.expect("model app_data")
.layout_mode = mode;
Ok(())
})?,
)?;
// Two setters whose state has no reader here, so each takes its argument as the reference
// does, never raising, and stores nothing; both return zero values.
// - `SetEuropeanNumbers(flag)` (`0x48dec0`): `GetBoolOrDefault(1, true)` into `[0xcf4d24]`,
// which turns on `FontString:SetText`'s digit-group pass (`0x771daa` into `0x771c50`).
// benilla's `SetText` has no such pass.
// - `SetLayoutMode(mode)` (`0x488540`): a number truncates into the UI root's `+0xcf8`, anything
// else stores 1; read by the root's mouse handler (`0x766301`) for a frame-dragging mode
// benilla does not build.
// - `SetConsoleKey(key)` (`0x488640`): a one-character or named key becomes the console's
// toggle key (`0x63cb00`), a non-string the default `0x30e`. benilla has no console screen.
for name in ["SetEuropeanNumbers", "SetLayoutMode", "SetConsoleKey"] {
for name in ["SetEuropeanNumbers", "SetConsoleKey"] {
g.set(
name,
lua.create_function(|_, _: MultiValue| Ok(MultiValue::new()))?,
@@ -132,9 +144,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.set_mode(mlua::ChunkMode::Text)
.exec()
{
let bare = super::stdlib::lua_message(&e);
lua.app_data_mut::<crate::script::Model>()
.expect("model")
.record_script_error(super::stdlib::lua_message(&e));
.record_script_error(bare.clone(), bare);
}
Ok(())
})?,
@@ -360,9 +373,10 @@ mod tests {
);
}
/// The three setters never raise, whatever they are handed, and return zero values.
/// The two stateless setters and `SetLayoutMode` never raise, whatever they are handed, and
/// return zero values.
#[test]
fn the_stateless_setters_take_any_argument() {
fn the_setters_take_any_argument() {
let s = UiScript::new().unwrap();
for name in ["SetEuropeanNumbers", "SetLayoutMode", "SetConsoleKey"] {
for arg in ["", "nil", "1", "\"x\"", "{}", "\"ESCAPE\""] {
+125 -86
View File
@@ -8,13 +8,15 @@
use mlua::{Lua, Table, Value};
use super::object::{draw_layer_from_str, frame_handle_of};
use super::object::frame_handle_of;
use super::pointer::point_in_rect;
use super::region::region_wrapper;
use super::{event, Model, RegionData};
use super::region::{
install_into_slot, new_texture_region, region_wrapper, set_texture_path, slot_arg, SlotArg,
};
use super::{event, Model};
use crate::layout::Rect;
use crate::order::DrawLayer;
use crate::widget::{ColorSelectState, FrameHandle, KindState, RegionHandle, RegionKind};
use crate::widget::{ColorSelectState, FrameHandle, KindState, RegionHandle};
/// Registry key of the ColorSelect method table (the MAXCSTACK discipline: a Lua-side root).
pub(super) const REG_COLORSELECT_METHODS: &str = "__benilla_colorselect_methods";
@@ -58,14 +60,13 @@ impl Slot {
}
}
fn set(self, s: &mut ColorSelectState, rh: RegionHandle) {
let slot = match self {
Slot::Wheel => &mut s.wheel,
Slot::WheelThumb => &mut s.wheel_thumb,
Slot::ValueStrip => &mut s.value_strip,
Slot::ValueThumb => &mut s.value_thumb,
};
*slot = Some(rh);
fn set_opt(self, s: &mut ColorSelectState, rh: Option<RegionHandle>) {
match self {
Slot::Wheel => s.wheel = rh,
Slot::WheelThumb => s.wheel_thumb = rh,
Slot::ValueStrip => s.value_strip = rh,
Slot::ValueThumb => s.value_thumb = rh,
}
}
/// The layer a slot's region is born on: ARTWORK for the art (the wheel's layer 2, through
@@ -78,11 +79,84 @@ impl Slot {
}
}
/// Get-or-create one slot's texture region, `layer` re-layering an existing one; returns its id.
fn ensure_slot(lua: &Lua, this: &Table, slot: Slot, layer: Option<DrawLayer>) -> mlua::Result<u32> {
impl Slot {
/// Whether the slot has a path leg: the markers do (`0x78b7b0`), the wheel and the strip take
/// an object or nil alone (`0x78de90`, `0x78e450`).
fn has_path_leg(self) -> bool {
matches!(self, Slot::WheelThumb | Slot::ValueThumb)
}
fn of_tag(tag: &str) -> Option<Slot> {
Some(match tag {
"ColorWheelTexture" => Slot::Wheel,
"ColorWheelThumbTexture" => Slot::WheelThumb,
"ColorValueTexture" => Slot::ValueStrip,
"ColorValueThumbTexture" => Slot::ValueThumb,
_ => return None,
})
}
}
/// Install `incoming` into `slot`, or clear it, through the slot hooks: the wheel `0x78b580` and
/// the strip `0x78b8a0` link on ARTWORK and replace the texture's image with the generated one
/// (`0x770300`), so it ends file-less here, where the renderer paints it; the markers
/// `0x78b850`/`0x78ba90` link on OVERLAY and clear their anchors (`0x767ed0`).
fn install_slot_model(
model: &mut Model,
h: FrameHandle,
slot: Slot,
incoming: Option<RegionHandle>,
) -> mlua::Result<()> {
if !matches!(
model.arena.frame(h).map(|f| &f.kind_state),
Some(KindState::ColorSelect(_))
) {
return Err(mlua::Error::runtime("not a ColorSelect"));
}
let thumb = matches!(slot, Slot::WheelThumb | Slot::ValueThumb);
let changed = install_into_slot(
model,
h,
incoming,
slot.layer(),
true,
thumb,
|ks| match ks {
KindState::ColorSelect(s) => slot.get(s),
_ => None,
},
|ks, rh| {
if let KindState::ColorSelect(s) = ks {
slot.set_opt(s, rh);
}
},
)?;
// The same texture returned at the first compare (`0x78b598`) and keeps what it was set to.
if let (true, Some(rh), false) = (changed, incoming, thumb) {
super::region::clear_texture(model, rh);
}
Ok(())
}
/// A ColorSelect element's texture child (`0x78b3f0`): the texture the texture adder built,
/// installed as the slot's object. The wheel and the strip are painted by the renderer whatever
/// file or colour the element names.
pub(super) fn install_loaded(
model: &mut Model,
h: FrameHandle,
tag: &str,
rh: RegionHandle,
) -> mlua::Result<()> {
let slot = Slot::of_tag(tag)
.ok_or_else(|| mlua::Error::runtime(format!("not a ColorSelect texture: {tag}")))?;
install_slot_model(model, h, slot, Some(rh))
}
/// A marker's path leg (`0x78b7b0`): a marker that exists has its file swapped and nothing else;
/// with none, a texture is built, given the file and installed.
fn set_marker_path(lua: &Lua, this: &Table, slot: Slot, path: &str) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let existing = match &model
.arena
.frame(h)
@@ -92,35 +166,23 @@ fn ensure_slot(lua: &Lua, this: &Table, slot: Slot, layer: Option<DrawLayer>) ->
KindState::ColorSelect(s) => slot.get(s),
_ => return Err(mlua::Error::runtime("not a ColorSelect")),
};
let rh = match existing {
Some(rh) => {
if let (Some(l), Some(region)) = (layer, model.arena.region_mut(rh)) {
region.draw_layer = l;
}
rh
}
match existing {
Some(rh) => set_texture_path(&mut model, rh, path),
None => {
let rh = model
.arena
.create_region(h, RegionKind::Texture, layer.unwrap_or(slot.layer()), 0)
let rh = new_texture_region(&mut model, h, slot.layer())
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
model.region_data.insert(rh, RegionData::default());
model.touch_layout(); // a region entered the layout gate's read set
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::ColorSelect(s) = &mut frame.kind_state {
slot.set(s, rh);
}
}
rh
set_texture_path(&mut model, rh, path);
install_slot_model(&mut model, h, slot, Some(rh))?;
}
};
Ok(model.region_id(rh))
}
Ok(())
}
/// `Set<Slot>Texture([path [, drawLayer]] | r, g, b [, a])`, plus an empty form that only creates
/// the region: what `<ColorWheelTexture/>` means. A file-less slot is where the app renderer paints
/// the disc and strip the reference computes (`0x78b580`, `0x78b8a0`).
/// `Set<Slot>Texture(texture or "texture" or nil)` for the markers (`0x78e160`, `0x78e720`), a
/// path leg beside the object and nil ones; the wheel and the strip (`0x78de90`, `0x78e450`) have
/// `(texture or nil)` alone. No argument raises the usage, and there is no colour form. A file-less
/// wheel or strip is where the app renderer paints the disc and strip the reference computes
/// (`0x78b580`, `0x78b8a0`).
fn install_slot_texture(
lua: &Lua,
m: &Table,
@@ -128,44 +190,29 @@ fn install_slot_texture(
setter: &str,
getter: &str,
) -> mlua::Result<()> {
let method = setter.to_string();
let usage = if slot.has_path_leg() {
format!(r#"{setter}(texture or "texture" or nil)"#)
} else {
format!("{setter}(texture or nil)")
};
m.set(
setter,
lua.create_function(
move |lua, (this, a1, a2, a3, a4): (Table, Value, Value, Value, Value)| {
let layer = match &a2 {
Value::String(s) => s.to_str().ok().and_then(|l| draw_layer_from_str(&l)),
_ => None,
};
let id = ensure_slot(lua, &this, slot, layer)?;
let rh = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
*model.id_to_region.get(&id).expect("colorselect region id")
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let data = model.region_data.entry(rh).or_default();
match &a1 {
Value::String(s) => {
data.texture = Some(s.to_str()?.to_string());
data.fill = None;
}
Value::Number(_) | Value::Integer(_) => {
data.texture = None;
data.fill = Some([
num_f32(&a1),
num_f32(&a2),
num_f32(&a3),
match &a4 {
Value::Nil => 1.0,
v => num_f32(v),
},
]);
}
// The empty form: the region now exists and stays file-less.
_ => {}
lua.create_function(move |lua, (this, args): (Table, mlua::MultiValue)| {
let arg = slot_arg(lua, &this, &args, &usage, &method, slot.has_path_leg())?;
let h = frame_handle_of(lua, &this)?;
match arg {
SlotArg::Object(rh) => {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
install_slot_model(&mut model, h, slot, Some(rh))
}
Ok(())
},
)?,
SlotArg::Clear => {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
install_slot_model(&mut model, h, slot, None)
}
SlotArg::Path(path) => set_marker_path(lua, &this, slot, &path),
}
})?,
)?;
m.set(
getter,
@@ -184,14 +231,6 @@ fn install_slot_texture(
Ok(())
}
fn num_f32(v: &Value) -> f32 {
match v {
Value::Number(n) => *n as f32,
Value::Integer(i) => *i as f32,
_ => 0.0,
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
@@ -200,9 +239,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// Shape C on r, g, b (`ColorSelect:SetColorRGB 0x78eae0`, `2=C 3=C 4=C 5=B`).
lua.create_function(|lua, (this, r, g, b): (Table, Value, Value, Value)| {
let (r, g, b) = (
crate::script::object::as_f64(&r),
crate::script::object::as_f64(&g),
crate::script::object::as_f64(&b),
crate::script::object::tonumber_f64(lua, &r),
crate::script::object::tonumber_f64(lua, &g),
crate::script::object::tonumber_f64(lua, &b),
);
// Store through the client's quantizer, then fire with what the widget now holds: the
// round-tripped values, identical to the next `GetColorRGB`, not the raw arguments.
@@ -268,7 +307,7 @@ fn fire_color_select(lua: &Lua, this: &Table, r: f64, g: f64, b: f64) -> mlua::R
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
Ok(())
}
+280 -99
View File
@@ -3,8 +3,9 @@
//! (0 the backpack, 1-4 the worn bags, -1 the bank's own slots, 5-10 the bank bags, -2 the
//! keyring); slots are 1-based.
use mlua::{Lua, Table, Value};
use mlua::{Lua, MultiValue, Table, Value};
use super::binding_abi::{flag, number_arg};
use super::cursor::{self, CursorItem, CursorPayload};
use super::Model;
@@ -27,8 +28,11 @@ pub struct ContainerSlot {
/// Icon texture path (`Interface\Icons\…`); `None` while the template answer is in flight.
pub texture: Option<String>,
pub count: u32,
/// Item quality 0..6; `None` while unresolved (the API reports `nil`).
/// Item quality 0..6; `None` while unresolved. `GetContainerItemInfo` reports it only when
/// `inventory_type` is nonzero (`0x4f9794`), else -1.
pub quality: Option<u32>,
/// The template's InventoryType; 0 while unresolved and for an item that cannot be equipped.
pub inventory_type: u32,
/// The item's template entry; 0 while unresolved.
pub item_id: u32,
/// An `|Hitem:…|h[Name]|h` link once the name is known.
@@ -199,7 +203,7 @@ impl super::UiScript {
model.pending_wrap = Some(PendingWrap { bag, slot });
model.ui_cursor = Some(UiCursorMode::Cast);
model.ui_cursor_dirty = true;
cursor::queue_lock_changed(&mut model, bag, slot);
cursor::raise_lock_changed(&mut model);
}
/// The armed gift wrap, if any; the app reads it to hold the cursor's base at Cast.
@@ -264,7 +268,10 @@ pub enum UiCursorMode {
/// displaced item in the source slot, never on the cursor (no `SetCursorItem`, `0x5e0c40`);
/// - a split carry places onto an empty or same-item slot, and stays held over another item;
/// - a spell or action payload stays held.
fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
///
/// Returns the item a pickup puts on the cursor, which the binding sets past this segment
/// ([`cursor::pick_up_item`]), so the setter's inner clear signals on its own.
fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> Option<CursorItem> {
// An armed gift wrap completes here, before the cursor is read. `PickupContainerItem` is the
// only caller of the wrap sender `0x5edfc0`, so an equipped item cannot be wrapped.
if let Some(wrap) = model.pending_wrap {
@@ -275,7 +282,7 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
.and_then(|c| c.slots.get(&slot))
.is_some_and(|s| s.item_id != 0)
{
return;
return None;
}
// A vanished paper also bails, the wrap armed and the paper locked (`0x5edfef`).
if !model
@@ -284,7 +291,7 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
.and_then(|c| c.slots.get(&wrap.slot))
.is_some_and(|s| s.item_id != 0)
{
return;
return None;
}
// No eligibility test: the server refuses with an `ERR_CANT_WRAP_*` reason
// (`SMSG_INVENTORY_CHANGE_FAILURE` 43-48), and a local gate would eat that error line.
@@ -292,10 +299,10 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
model.container_wraps.push((wrap.bag, wrap.slot, bag, slot));
// The paper unlocks and the cursor resets right after the send (`0x5eded0` at
// `0x5ee0aa`), not on the answer; the target never locks.
cursor::queue_lock_changed(model, wrap.bag, wrap.slot);
cursor::raise_lock_changed(model);
model.ui_cursor = None;
model.ui_cursor_dirty = true;
return;
return None;
}
// Only a held item (`0x4f9c38`) or vendor row (`0x4f9c43`) takes the click first. Past them
// an empty slot does nothing (`0x4f9c4e`), an armed item-targeting spell binds the item
@@ -316,35 +323,31 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
} else if occupied {
model.container_repairs.push((bag, slot));
}
return;
return None;
}
match model.cursor.take() {
None => {
let picked = model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.filter(|s| s.item_id != 0 && !s.locked)
.map(|s| CursorItem {
bag,
slot,
item_id: s.item_id,
texture: s.texture.clone(),
link: s.link.clone(),
quality: s.quality,
count: None,
bar_placeable: s.bar_placeable,
equip_slots: s.equip_slots.clone(),
});
if let Some(item) = picked {
model.cursor = Some(CursorPayload::Item(item));
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, bag, slot);
}
}
match model.cursor.clone() {
// The lock gate reads the item's own bit (`0x4f9dc4`), the app's or the client's.
None => model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.filter(|s| s.item_id != 0 && !s.locked)
.filter(|_| !cursor::client_locked(model, bag, slot))
.map(|s| CursorItem {
bag,
slot,
item_id: s.item_id,
texture: s.texture.clone(),
link: s.link.clone(),
quality: s.quality,
count: None,
bar_placeable: s.bar_placeable,
equip_slots: s.equip_slots.clone(),
}),
// The cancel is `ClearCursor(1,1)` (`0x4f9e0e`): the unlock, then `CURSOR_UPDATE`.
Some(CursorPayload::Item(held)) if held.bag == bag && held.slot == slot => {
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, held.bag, held.slot);
cursor::clear_cursor(model);
None
}
Some(CursorPayload::Item(held)) => {
// Cloned so the moves below can borrow `model`; an unresolved slot reads as empty.
@@ -356,17 +359,28 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
.filter(|d| d.item_id != 0);
match (held.count, dest) {
// A split carry cannot swap with a different item: it stays held.
(Some(_), Some(d)) if d.item_id != held.item_id => {
model.cursor = Some(CursorPayload::Item(held));
(Some(_), Some(d)) if d.item_id != held.item_id => {}
// A split places and clears (`0x5e1210`, then `ClearCursor(0,1)` at `0x4f9e4f`):
// the grid hide and `CURSOR_UPDATE`, and no lock event.
(Some(n), _) => {
queue_move(model, &held, bag, slot, Some(n));
cursor::clear(model, cursor::Clear::KEEP_LOCK);
}
// Anything else queues the move and clears: a split, a merge the server tops up,
// or a swap that lands the displaced item in the source slot.
(count, _) => {
queue_move(model, &held, bag, slot, count);
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, held.bag, held.slot);
// A whole stack locks the destination's item first (`0x4f9e85`; an empty slot's
// zero guid signals nothing, `0x4953ed`), then the swap send clears
// (`ClearCursor(0,1)` at `0x5e0fe7`: the grid hide, `CURSOR_UPDATE`) and the
// binding clears again (`0x4f9eb8`), a second `CURSOR_UPDATE` on the empty cursor.
// The source keeps its lock in both: the clears pass no unlock.
(None, dest) => {
queue_move(model, &held, bag, slot, None);
if dest.is_some() {
cursor::raise_lock_changed(model);
}
cursor::clear(model, cursor::Clear::KEEP_LOCK);
cursor::clear(model, cursor::Clear::KEEP_LOCK);
}
}
None
}
// Mode 5, a held vendor row, buys into this slot whatever it holds (`0x4f9efa` tests
// `[0xb4d900] == 5`): `CMSG_BUY_ITEM_IN_SLOT` (0x1a3) with count 1, and the cursor clears
@@ -382,20 +396,19 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
if let Some(entry) = entry {
model.merchant_slot_buys.push((bag, slot, entry));
}
cursor::queue_cursor_update(model);
cursor::clear_cursor(model);
None
}
Some(
other @ (CursorPayload::Spell(_)
CursorPayload::Spell(_)
| CursorPayload::Action(_)
| CursorPayload::Macro(_)
| CursorPayload::PetAction(_)
// Mode 10, a stabled pet, stays held for the stable window.
| CursorPayload::StablePet(_)
// Mode 2, money, stays held for a money frame's DropFunc.
| CursorPayload::Money(_)),
) => {
model.cursor = Some(other);
}
| CursorPayload::Money(_),
) => None,
}
}
@@ -416,6 +429,21 @@ fn queue_move(
});
}
/// The `(bag, slot)` the three container getters resolve (`0x4f9820`): both are numbers (else the
/// binding's usage error), the slot is `trunc(n) - 1` and fails the resolver when negative
/// (`0x4f990d`), so a zero or negative slot is no slot at all. `None` is that failure. The slot
/// is returned 1-based, as the snapshot keys it.
fn container_key(
lua: &Lua,
bag: Value,
slot: Value,
usage: &'static str,
) -> mlua::Result<Option<(i64, u32)>> {
let bag = number_arg(lua, bag, usage)?;
let slot = number_arg(lua, slot, usage)?;
Ok((slot >= 1).then_some((i64::from(bag), slot as u32)))
}
/// Register the container verbs as bare globals, as `reference/1.12-globals.tsv` lists them;
/// `C_Container` is a later client's namespace, and an addon that finds it assumes that client.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
@@ -474,6 +502,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
data.texture = path;
data.circular = true;
data.portrait_unit = None;
model.note_art(rh);
Ok(())
})?,
)?;
@@ -505,10 +534,25 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// re-feed cannot replay the finish flash.
lua.globals().set(
"GetContainerItemCooldown",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
lua.create_function(|lua, (bag, slot): (Value, Value)| {
let now = crate::script::clock::now(lua);
let key = container_key(
lua,
bag,
slot,
"Usage: GetContainerItemCooldown(index, slot)",
)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match model.container_cooldowns.get(&(bag, slot)) {
// No resolved slot or no item in it pushes three zeros, the enable included
// (`0x4f9ae9`); an item with no running cooldown reads (0, 0, 1).
let held = key.is_some_and(|k| {
model
.containers
.get(&k.0)
.is_some_and(|c| c.slots.contains_key(&k.1))
});
Ok(match key.and_then(|k| model.container_cooldowns.get(&k)) {
_ if !held => (0.0, 0.0, 0),
Some(&(start, duration, enabled)) if start + duration > now || !enabled => {
(start, duration, i32::from(enabled))
}
@@ -519,39 +563,45 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"GetContainerItemLink",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
lua.create_function(|lua, (bag, slot): (Value, Value)| {
let key = container_key(lua, bag, slot, "Usage: GetContainerItemLink(index, slot)")?;
let link = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.and_then(|s| s.link.clone())
key.and_then(|(bag, slot)| {
model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.and_then(|s| s.link.clone())
})
};
// Nothing at all on every miss: the resolver failing, no item object or no template
// name (`0x4f996b`, `0x4f9979`, `0x4f99a5`).
match link {
Some(l) => Ok(Value::String(lua.create_string(&l)?)),
None => Ok(Value::Nil),
Some(l) => Ok(MultiValue::from_vec(vec![Value::String(
lua.create_string(&l)?,
)])),
None => Ok(MultiValue::new()),
}
})?,
)?;
// GetContainerItemInfo(bag, slot) (`0x4f9670`) → texture, itemCount, locked, quality,
// readable, the five `ContainerFrame.lua:241` reads; no values for an empty or unknown slot.
// The texture is the question mark while the template is unknown.
// `locked` and `readable` are 1 or nil (`0x4f975d`/`0x4f976b`, `0x4f97e6`/`0x4f97d3`).
lua.globals().set(
"GetContainerItemInfo",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
lua.create_function(|lua, (bag, slot): (Value, Value)| {
let Some((bag, slot)) =
container_key(lua, bag, slot, "Usage: GetContainerItemInfo(index, slot)")?
else {
// The resolver failed (`0x4f990d`): nothing is pushed (`0x4f9810`).
return Ok(mlua::MultiValue::new());
};
let (info, held_here) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let held = matches!(
&model.cursor,
Some(CursorPayload::Item(c)) if c.bag == bag && c.slot == slot
) ||
// An armed wrap's paper reads locked until the send or a cancel (`0x4953e0` at
// `0x5edeb5`).
matches!(
&model.pending_wrap,
Some(w) if w.bag == bag && w.slot == slot
);
let held = cursor::client_locked(&model, bag, slot);
(
model
.containers
@@ -565,19 +615,26 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
return Ok(mlua::MultiValue::new());
};
Ok(mlua::MultiValue::from_vec(vec![
match &s.texture {
Some(p) => Value::String(lua.create_string(p.as_str())?),
None => Value::Nil,
},
// A string on every path: the question mark while the template is unknown
// (`0x5d8af0`).
Value::String(
lua.create_string(
s.texture
.as_deref()
.unwrap_or(super::item_stats::MISSING_ITEM_ICON),
)?,
),
Value::Integer(i64::from(s.count)),
// A held item's source slot reads locked, derived from the cursor so the app's
// re-push cannot wipe it.
Value::Boolean(s.locked || held_here),
match s.quality {
Some(q) => Value::Integer(i64::from(q)),
None => Value::Nil,
},
Value::Boolean(s.readable),
// The client's own lock ([`cursor::client_locked`]) is kept apart from the app's
// push, so a re-push cannot wipe it.
flag(s.locked || held_here),
// A number on every path: -1 unless the template is cached and equippable
// (`0x4f9794`).
Value::Integer(super::item_stats::gated_quality(
s.quality,
s.inventory_type,
)),
flag(s.readable),
]))
})?,
)?;
@@ -585,10 +642,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"UseContainerItem",
lua.create_function(|lua, (bag, slot, _rest): (i64, u32, mlua::MultiValue)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// Every right-click clears the cursor before it reads the slot (`0x4fa198`): a held
// payload goes back, never placed, and an armed gift wrap is cancelled.
cursor::clear_cursor(&mut model);
// payload goes back, never placed, and an armed gift wrap is cancelled. The clear's
// events run first, as the reference's do before it reads the slot.
super::tick::verb(lua, cursor::clear_cursor);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.repair_mode {
// Then the item in the slot is repaired (`0x4fa1a6`, `0x4fa1da`).
let occupied = model
@@ -613,8 +671,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"PickupContainerItem",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_container_item(&mut model, bag, slot);
if let Some(item) =
super::tick::verb(lua, |model| pickup_container_item(model, bag, slot))
{
cursor::pick_up_item(lua, item);
}
Ok(())
})?,
)?;
@@ -701,6 +762,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
#[cfg(test)]
mod tests {
use super::{ContainerMove, ContainerSlot, ContainerState, PendingWrap, UiCursorMode};
use crate::script::cursor::split_container_item;
use crate::script::{MerchantItem, MerchantState, UiScript};
fn backpack() -> ContainerState {
@@ -716,6 +778,7 @@ mod tests {
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(1),
inventory_type: 0,
item_id: 117,
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
locked: false,
@@ -824,7 +887,8 @@ mod tests {
)
.unwrap();
assert_eq!(icon, "Interface\\Icons\\INV_Misc_Food_16");
assert_eq!((count, quality), (5, 1));
// Food has InventoryType 0, so its quality is the -1 sentinel (`0x4f9794`).
assert_eq!((count, quality), (5, -1));
assert_eq!(
s.eval::<i64>(
"local _, _, id = string.find(GetContainerItemLink(0, 1), 'item:(%d+)') \
@@ -841,11 +905,12 @@ mod tests {
.eval::<bool>("local _, _, _, _, readable = GetContainerItemInfo(0, 4) return readable")
.unwrap());
// An in-flight slot: texture and quality nil, itemCount real.
// An in-flight slot: the question mark, the -1 quality sentinel, itemCount real.
assert!(s
.eval::<bool>(
"local texture, itemCount, locked, quality = GetContainerItemInfo(0, 3)\n\
return texture == nil and quality == nil and itemCount ~= nil",
return texture == 'Interface\\\\Icons\\\\INV_Misc_QuestionMark' \
and quality == -1 and itemCount ~= nil",
)
.unwrap());
assert!(s
@@ -856,6 +921,127 @@ mod tests {
.unwrap());
}
/// The fourth value is the template's quality only when the template is cached and its
/// InventoryType is nonzero (`0x4f9794`, `0x4f979b`), else the number -1 (`0x4f97a6`): a potion,
/// a reagent or conjured food answers -1 with its template in hand, as does an item whose
/// template has not arrived. Never nil.
#[test]
fn container_item_info_quality_is_gated_on_the_inventory_type() {
let mut s = UiScript::new().unwrap();
let slot = |quality: Option<u32>, inventory_type: u32| ContainerSlot {
count: 1,
quality,
inventory_type,
item_id: 25,
..Default::default()
};
let mut slots = std::collections::HashMap::new();
// An equippable epic, two InventoryType 0 items of quality 1 and 3, and an item whose
// template is still in flight.
slots.insert(1, slot(Some(4), 13));
slots.insert(2, slot(Some(1), 0));
slots.insert(3, slot(Some(3), 0));
slots.insert(4, slot(None, 0));
s.set_container(
0,
Some(ContainerState {
slots,
..Default::default()
}),
);
for (index, want) in [
(1, "number:4"),
(2, "number:-1"),
(3, "number:-1"),
(4, "number:-1"),
] {
assert_eq!(
s.eval::<String>(&format!(
"local c = function(...) return arg.n .. ':' .. type(arg[4]) .. ':' .. \
tostring(arg[4]) end return c(GetContainerItemInfo(0, {index}))"
))
.unwrap(),
format!("5:{want}"),
"slot {index}"
);
}
}
/// The texture is a string on every path: while the template is unknown, `0x5d8af0` falls back
/// to `Interface\Icons\INV_Misc_QuestionMark` (`0x5d88b0`, `0x847fe4`), not nil.
#[test]
fn container_item_info_texture_falls_back_to_the_question_mark() {
let mut s = UiScript::new().unwrap();
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
count: 1,
item_id: 25,
..Default::default()
},
);
slots.insert(
2,
ContainerSlot {
count: 1,
item_id: 26,
texture: Some("Interface\\Icons\\INV_Sword_01".into()),
..Default::default()
},
);
s.set_container(
0,
Some(ContainerState {
slots,
..Default::default()
}),
);
for (index, want) in [
(1, "string:Interface\\Icons\\INV_Misc_QuestionMark"),
(2, "string:Interface\\Icons\\INV_Sword_01"),
] {
assert_eq!(
s.eval::<String>(&format!(
"local c = function(...) return type(arg[1]) .. ':' .. tostring(arg[1]) end \
return c(GetContainerItemInfo(0, {index}))"
))
.unwrap(),
want,
"slot {index}"
);
}
}
/// `GetContainerItemInfo` (`0x4f9670`) pushes `locked` and `readable` as the number 1
/// (`0x4f975d`, `0x4f97e6`) or nil (`0x4f976b`, `0x4f97d3`), five values in all.
#[test]
fn container_item_info_flags_are_one_or_nil() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
// `arg.n` counts the returns (`select` is 5.1's, not a 1.12 global).
let shape = |s: &mut UiScript, slot: u32| {
s.eval::<String>(&format!(
"local function c(...) return arg.n .. ':' .. type(arg[3]) .. ':' .. tostring(arg[3]) \
.. ':' .. type(arg[5]) .. ':' .. tostring(arg[5]) end \
return c(GetContainerItemInfo(0, {slot}))"
))
.unwrap()
};
assert_eq!(shape(&mut s, 1), "5:nil:nil:nil:nil", "a plain item");
assert_eq!(
shape(&mut s, 4),
"5:nil:nil:number:1",
"the readable letter"
);
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(
shape(&mut s, 1),
"5:number:1:nil:nil",
"the held item reads locked"
);
}
#[test]
fn use_container_item_queues_intents() {
let mut s = UiScript::new().unwrap();
@@ -955,6 +1141,7 @@ mod tests {
texture: Some("Interface\\Icons\\INV_Misc_Gem_01".into()),
count: 1,
quality: Some(2),
inventory_type: 0,
item_id: 200, // item B, a different id
link: Some("|Hitem:200|h[Shiny Gem]|h".into()),
locked: false,
@@ -1060,9 +1247,7 @@ mod tests {
);
s.set_container(0, Some(state));
assert!(s
.eval::<bool>("return SplitContainerItem(0, 1, 3)")
.unwrap());
assert!(split_container_item(&s.lua, 0, 1, 3));
let held = s.cursor_item().expect("the split carry");
assert_eq!(
(held.bag, held.slot, held.item_id, held.count),
@@ -1106,15 +1291,11 @@ mod tests {
fn split_of_the_whole_stack_is_a_plain_pickup() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack())); // slot 1: a 5-stack
assert!(s
.eval::<bool>("return SplitContainerItem(0, 1, 5)")
.unwrap());
assert!(split_container_item(&s.lua, 0, 1, 5));
assert_eq!(s.cursor_item().unwrap().count, None);
// A split of more than the stack is a plain pickup too.
s.run("ClearCursor()").unwrap();
assert!(s
.eval::<bool>("return SplitContainerItem(0, 1, 99)")
.unwrap());
assert!(split_container_item(&s.lua, 0, 1, 99));
assert_eq!(s.cursor_item().unwrap().count, None);
}
+20 -9
View File
@@ -12,6 +12,7 @@ use mlua::{Lua, MultiValue, Value, Variadic};
use super::binding_abi::{self, number_arg};
use super::item_stats::item_link;
use super::Model;
use crate::script::binding_abi::Idx;
/// One reagent a recipe consumes (`GetCraftReagentInfo`); name and icon are `None` until the item
/// template answer lands.
@@ -272,14 +273,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// GetCraftInfo(index) → craftName, craftSubSpellName, craftType, numAvailable, isExpanded,
// trainingPointCost, requiredLevel (`Blizzard_CraftUI.lua:168`); one nil past the end.
// trainingPointCost, requiredLevel (`Blizzard_CraftUI.lua:168`); a miss pushes the seven too.
// `isExpanded` is always nil; Beast Training's cost and level answer 0, not built.
g.set(
"GetCraftInfo",
lua.create_function(|lua, index: usize| {
lua.create_function(|lua, Idx(index): Idx| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(r) = recipe(&model, index) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
// `0x4f70a4`: nil, nil, nil, 0, nil, 0, 0.
return Ok(binding_abi::tuple(&[
None,
None,
None,
Some(0),
None,
Some(0),
Some(0),
]));
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&r.name)?),
@@ -296,7 +306,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// GetCraftIcon(index) → the icon path, nil while in flight or past the end.
g.set(
"GetCraftIcon",
lua.create_function(|lua, index: usize| {
lua.create_function(|lua, Idx(index): Idx| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
opt_str(lua, recipe(&model, index).and_then(|r| r.icon.as_ref()))
})?,
@@ -306,7 +316,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// painting (`Blizzard_CraftUI.lua:312`).
g.set(
"GetCraftDescription",
lua.create_function(|lua, index: usize| {
lua.create_function(|lua, Idx(index): Idx| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
opt_str(
lua,
@@ -318,21 +328,22 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// GetCraftNumReagents(index) → the reagent count, 0 past the end or when closed.
g.set(
"GetCraftNumReagents",
lua.create_function(|lua, index: usize| {
lua.create_function(|lua, Idx(index): Idx| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(recipe(&model, index).map_or(0, |r| r.reagents.len()) as i64)
})?,
)?;
// GetCraftReagentInfo(index, reagentIndex) → name, icon, need, have; one nil past the end.
// GetCraftReagentInfo(index, reagentIndex) → name, icon, need, have; a miss is four nils
// (`0x4f759b`).
g.set(
"GetCraftReagentInfo",
lua.create_function(|lua, (index, reagent_index): (usize, usize)| {
lua.create_function(|lua, (Idx(index), Idx(reagent_index)): (Idx, Idx)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(reagent) = recipe(&model, index)
.and_then(|r| reagent_index.checked_sub(1).and_then(|n| r.reagents.get(n)))
else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
return Ok(binding_abi::tuple(&[None; 4]));
};
Ok(MultiValue::from_vec(vec![
opt_str(lua, reagent.name.as_ref())?,
+578 -138
View File
@@ -1,10 +1,14 @@
//! The cursor payload: the client's payload-mode global `[0xb4d900]` as a typed enum, and the drag
//! gestures ([`drag`]) that change it. Bags, the paper doll ([`doll`]), the action bars ([`bar`])
//! and the spellbook all transition it through [`queue_cursor_update`] and [`queue_lock_changed`],
//! so sounds, `CURSOR_UPDATE`, grid events and lock display fire from one place.
//! gestures ([`drag`]) that change it. The payload changes through two primitives, as in the
//! reference: [`clear`], `ClearCursor(unlock, signal)` with its arm's events and its shared tail,
//! and [`put`], a setter's install and its own tail. Every setter runs its inner clear first
//! ([`set_cursor`] from a Lua verb, one segment each, so the clear's handlers read the emptied
//! cursor), and every verb that drops a payload clears through [`clear`].
use mlua::Lua;
use super::binding_abi::flag;
use super::tick::verb;
use super::{Model, ScriptValue};
mod bag_verbs;
@@ -16,7 +20,7 @@ mod pet;
pub(crate) use bar::place_action;
pub(crate) use drag::{
abandon_drag, arm_drag, maybe_start_drag, take_drag, DragGesture, DragRelease,
abandon_drag, maybe_start_drag, press_drag, take_drag, DragGesture, DragRelease,
};
/// Sentinel bag id for the player's equipped slots, whose `slot` is a 1-based inventory slot id
@@ -157,62 +161,201 @@ pub struct CursorMacro {
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The transition events
// The two primitives: the clear and the set
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// Queue `CURSOR_UPDATE`, once per payload transition, plus a grid event when what the bars can
/// take changes. Call it after `model.cursor` holds the new payload; an action hop keeps the grid
/// up rather than hiding and reshowing it.
pub(crate) fn queue_cursor_update(model: &mut Model) {
model
.pending_events
.push(("CURSOR_UPDATE".to_string(), Vec::new()));
// The action bar's grid shows for any payload but a pet action, which `PlaceAction` refuses,
// or a vendor row, whose grab `0x4950f0` fires `ACTIONBAR_SHOWGRID` for mode 7 only
// (`0x495106`, `0x49513a`). The reference fires it (`0x4e58c0`) only from the item, spell and
// macro setters and that mode-7 grab, so coins and a stabled pet show it here and not there.
// The pet bar's follows the pet action alone (`0x494f28`).
let pet_held = matches!(model.cursor, Some(CursorPayload::PetAction(_)));
let bar_held = model.cursor.is_some()
&& !pet_held
&& !matches!(model.cursor, Some(CursorPayload::Merchant(_)));
if bar_held != model.cursor_grid_shown {
model.cursor_grid_shown = bar_held;
let event = if bar_held {
"ACTIONBAR_SHOWGRID"
} else {
"ACTIONBAR_HIDEGRID"
};
model.pending_events.push((event.to_string(), Vec::new()));
/// `ClearCursor`'s two arguments (`0x495190`): `ecx`, whether a held item's lock lifts in the
/// item arm (`0x4951c2`), and `edx`, whether held coins signal `PLAYER_MONEY` (`0x49523f`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Clear {
pub(crate) unlock: bool,
pub(crate) signal_money: bool,
}
impl Clear {
/// `ClearCursor(1,1)`: Lua's `ClearCursor()` (`0x4895b0`), every cancel, and the inner clear
/// of each setter but `SetCursorItem`, which passes its own `edx` on (`0x494bb4`).
pub(crate) const BOTH: Clear = Clear {
unlock: true,
signal_money: true,
};
/// `ClearCursor(0,1)`: an item send's clear, the item keeping its lock for the server's answer.
pub(crate) const KEEP_LOCK: Clear = Clear {
unlock: false,
signal_money: true,
};
/// `ClearCursor(1,0)`: the trade window's money fold, which repaints by its own events.
pub(crate) const NO_MONEY_EVENT: Clear = Clear {
unlock: true,
signal_money: false,
};
}
/// `ClearCursor(unlock, signal)` (`0x495190`), the one way a payload leaves the cursor. An armed
/// gift wrap is cancelled first (`0x495197`). Then the payload's arm: an item's unlock signals
/// `ITEM_LOCK_CHANGED` when `unlock` (`0x4951d9`, `0x495455`), coins `PLAYER_MONEY` when `signal`
/// (`0x495248`). Then the shared tail, on every call, an empty cursor included: a grid a setter
/// showed hides, the pet bar's for a held pet action and the action bar's otherwise (`0x4952ac`,
/// `0x4952b3`), and `CURSOR_UPDATE` fires on the emptied cursor (`0x4952cc`).
pub(crate) fn clear(model: &mut Model, how: Clear) {
// `0x5edf10`: the wrapping paper unlocks and the cursor mode returns to the base.
if model.pending_wrap.take().is_some() {
raise_lock_changed(model);
model.ui_cursor = None;
model.ui_cursor_dirty = true;
}
if pet_held != model.pet_grid_shown {
model.pet_grid_shown = pet_held;
let event = if pet_held {
"PET_BAR_SHOWGRID"
// Coins go back to nothing, as the purse was never debited; a vendor row clears with no
// packet (`0x49525f`).
let held = model.cursor.take();
match &held {
Some(CursorPayload::Item(_)) if how.unlock => raise_lock_changed(model),
Some(CursorPayload::Money(_)) if how.signal_money => money::raise_player_money(model),
_ => {}
}
if std::mem::take(&mut model.cursor_grid) {
if matches!(held, Some(CursorPayload::PetAction(_))) {
model.raise("PET_BAR_HIDEGRID", Vec::new());
} else {
"PET_BAR_HIDEGRID"
};
model.pending_events.push((event.to_string(), Vec::new()));
model.raise("ACTIONBAR_HIDEGRID", Vec::new());
}
}
model.raise("CURSOR_UPDATE", Vec::new());
}
/// `ClearCursor(1,1)`, Lua's `ClearCursor()` (`0x4895b0`).
pub(crate) fn clear_cursor(model: &mut Model) {
clear(model, Clear::BOTH);
}
/// A setter past its inner clear: `payload` goes on the cursor, then that setter's own tail.
/// `SetCursorItem` (`0x494b60`) shows the action bar's grid for an item the bar takes
/// (`0x494c3b`) and fires `CURSOR_UPDATE` (`0x494c54`); the row grab (`0x4950f0`) shows it for a
/// bar item, mode 7 (`0x49513a`), and fires `CURSOR_UPDATE` (`0x49515e`). The spell (`0x494dd1`)
/// and macro (`0x494fd7`) setters end on the action bar's grid, the pet-action setter on the pet
/// bar's (`0x494f28`), the money setter on `PLAYER_MONEY` (`0x494d0a`) and the stable grab on
/// nothing (`0x4950c5`), so no `CURSOR_UPDATE` follows theirs. A bar pickup takes the setter of
/// its kind (`0x4e6130`): the grab for an item, the spell or macro setter otherwise.
pub(crate) fn put(model: &mut Model, payload: CursorPayload) {
use super::action::ACTION_KIND_ITEM;
/// What a setter fires past its set.
enum Tail {
/// `SetCursorItem` and the row grab: the action bar's grid if asked, then `CURSOR_UPDATE`.
Update { grid: bool },
/// The spell and macro setters.
BarGrid,
/// The pet-action setter.
PetGrid,
/// The money setter.
Money,
/// The stable grab.
Nothing,
}
let tail = match &payload {
CursorPayload::Item(item) => Tail::Update {
grid: item.bar_placeable,
},
CursorPayload::Action(a) if a.kind == ACTION_KIND_ITEM => Tail::Update { grid: true },
CursorPayload::Merchant(_) => Tail::Update { grid: false },
CursorPayload::Action(_) | CursorPayload::Spell(_) | CursorPayload::Macro(_) => {
Tail::BarGrid
}
CursorPayload::PetAction(_) => Tail::PetGrid,
CursorPayload::Money(_) => Tail::Money,
CursorPayload::StablePet(_) => Tail::Nothing,
};
model.cursor = Some(payload);
match tail {
Tail::Update { grid } => {
if grid {
model.raise("ACTIONBAR_SHOWGRID", Vec::new());
model.cursor_grid = true;
}
model.raise("CURSOR_UPDATE", Vec::new());
}
Tail::BarGrid => {
model.raise("ACTIONBAR_SHOWGRID", Vec::new());
model.cursor_grid = true;
}
Tail::PetGrid => {
model.raise("PET_BAR_SHOWGRID", Vec::new());
model.cursor_grid = true;
}
Tail::Money => money::raise_player_money(model),
Tail::Nothing => {}
}
}
/// Queue `ITEM_LOCK_CHANGED(bag, slot)` for a source slot that a pickup locks or a put-down frees.
pub(crate) fn queue_lock_changed(model: &mut Model, bag: i64, slot: u32) {
model.pending_events.push((
"ITEM_LOCK_CHANGED".to_string(),
vec![ScriptValue::Int(bag), ScriptValue::Int(i64::from(slot))],
));
/// A cursor setter from a Lua verb. Its inner `ClearCursor(unlock, 1)` runs as one segment, so
/// that clear's handlers read the emptied cursor as the reference's do; then `payload`, read
/// past the clear, goes on with its tail as a second ([`put`]). `None` leaves the cursor clear,
/// as `SetCursorItem` with a zero guid (`0x494bc5`) or a stable grab whose slot fails its check
/// past the clear (`0x49505e`). Only `SetCursorItem` passes `unlock` through; every other setter
/// clears with `ClearCursor(1,1)`. Returns whether a payload went on.
pub(crate) fn set_cursor(
lua: &Lua,
unlock: bool,
payload: impl FnOnce(&Model) -> Option<CursorPayload>,
) -> bool {
verb(lua, |model| {
clear(
model,
Clear {
unlock,
signal_money: true,
},
);
});
verb(lua, |model| match payload(model) {
Some(payload) => {
put(model, payload);
true
}
None => false,
})
}
/// Queue `DELETE_ITEM_CONFIRM(name, quality)` for an item dropped on the world. The payload stays
/// An item pickup: `SetCursorItem(1, …)`, then `LockItem` (`0x4953e0`), whose
/// `ITEM_LOCK_CHANGED` (`0x495415`) follows the set's `CURSOR_UPDATE`, as the bag (`0x4f9de9`,
/// `0x4f9df0`), doll (`0x4c7838`) and bag-slot (`0x4c7be2`) pickups and the split (`0x4fa0aa`) do.
pub(crate) fn pick_up_item(lua: &Lua, item: CursorItem) {
set_cursor(lua, true, |_| Some(CursorPayload::Item(item)));
verb(lua, raise_lock_changed);
}
/// Raise `ITEM_LOCK_CHANGED` for a slot that a pickup locks or a put-down frees. It carries no
/// arguments: the lock (`0x495415`) and the unlock (`0x495455`) are plain `SignalEvent`s, and the
/// stock buttons re-read every slot's lock.
pub(crate) fn raise_lock_changed(model: &mut Model) {
model.raise("ITEM_LOCK_CHANGED", Vec::new());
}
/// Whether the client itself holds the item at `(bag, slot)` locked, the reference's `item+0x314`
/// bit 0 that `LockItem` (`0x4953e0`) sets: while the item is on the cursor, is an armed gift
/// wrap's paper (`0x5edeb5`), or sits in the mail or auction attach slot, whose attach clears the
/// cursor with `SetCursorItem(0, …)` (`0x4ae0d2`, `0x4ce56a`) and so keeps the pickup's lock until
/// the item leaves the slot (`0x495420` at `0x4acdd6`, `0x4cd653`). The engine has no item object
/// to carry the bit, so it is read from the state that holds the item and lifts when the item
/// leaves it. The app's pending operations are the slot's `locked`.
pub(crate) fn client_locked(model: &Model, bag: i64, slot: u32) -> bool {
let at = |item: &CursorItem| item.bag == bag && item.slot == slot;
matches!(&model.cursor, Some(CursorPayload::Item(item)) if at(item))
|| model
.pending_wrap
.as_ref()
.is_some_and(|w| w.bag == bag && w.slot == slot)
|| model.mail_send_item.as_ref().is_some_and(at)
|| model.auction_sell_item.as_ref().is_some_and(at)
}
/// Raise `DELETE_ITEM_CONFIRM(name, quality)` for an item dropped on the world. The payload stays
/// held: the popup's `DeleteCursorItem` or `ClearCursor` clears it.
fn queue_delete_item_confirm(model: &mut Model, item: &CursorItem) {
fn raise_delete_item_confirm(model: &mut Model, item: &CursorItem) {
let name = item_link_name(item.link.as_deref());
let quality = item.quality.unwrap_or(0);
model.pending_events.push((
"DELETE_ITEM_CONFIRM".to_string(),
model.raise(
"DELETE_ITEM_CONFIRM",
vec![ScriptValue::Str(name), ScriptValue::Int(i64::from(quality))],
));
);
}
/// An item link's name, from its `|h[Name]|h` segment; empty for no link or another shape.
@@ -223,38 +366,17 @@ pub(super) fn item_link_name(link: Option<&str>) -> String {
.unwrap_or_default()
}
/// `ClearCursor()` (`0x495190`): drops any payload and frees an item's source slot, then signals
/// `CURSOR_UPDATE` from its shared tail (`0x49529b`) on every call, an empty cursor included.
pub(crate) fn clear_cursor(model: &mut Model) {
// Any clear first cancels an armed gift wrap, whatever its parameters (`0x495190` opens with
// `0x5edf10`): the wrapping paper unlocks and the cursor mode returns to the base.
if let Some(wrap) = model.pending_wrap.take() {
queue_lock_changed(model, wrap.bag, wrap.slot);
model.ui_cursor = None;
model.ui_cursor_dirty = true;
}
// Coins go back to nothing, as the purse was never debited; a vendor row clears with no
// packet (`0x49525f`).
let taken = model.cursor.take();
queue_cursor_update(model);
if let Some(CursorPayload::Item(item)) = taken {
queue_lock_changed(model, item.bag, item.slot);
}
}
/// The sell fork's clear, `SetCursorItem(0, …)` (`0x494b60`), shared by `PickupMerchantItem`
/// (`0x4fb760`) and the interact ladder's vendor arm (`0x5df5d0`): unlike [`clear_cursor`] it
/// leaves the source slot locked, grey until the server removes the item (the mode-1 arm skips
/// `0x495420`). Any other payload is no sale and stays held (`GetCursorItem`, `0x494c60`).
/// The sell fork's clear, `SetCursorItem(0, …)` with a zero guid, so its inner `ClearCursor(0,1)`
/// alone (`0x4fb7ac`, `0x5df5fc`): `PickupMerchantItem` (`0x4fb760`) and the interact ladder's
/// vendor arm (`0x5df5d0`). Unlike [`clear_cursor`] it lifts no lock, so the source slot stays
/// grey until the server removes the item. Any other payload is no sale and stays held
/// (`GetCursorItem`, `0x494c60`).
pub(crate) fn take_cursor_item_for_sale(model: &mut Model) -> Option<CursorItem> {
let item = match model.cursor.take() {
Some(CursorPayload::Item(item)) => item,
other => {
model.cursor = other;
return None;
}
let Some(CursorPayload::Item(item)) = &model.cursor else {
return None;
};
queue_cursor_update(model);
let item = item.clone();
clear(model, Clear::KEEP_LOCK);
Some(item)
}
@@ -285,7 +407,7 @@ pub(crate) fn world_drop_click(model: &mut Model) -> bool {
match (&model.cursor, model.world_pick) {
(Some(CursorPayload::Item(item)), WorldPick::Terrain | WorldPick::Nothing) => {
let item = item.clone();
queue_delete_item_confirm(model, &item);
raise_delete_item_confirm(model, &item);
true
}
(
@@ -304,51 +426,84 @@ pub(crate) fn world_drop_click(model: &mut Model) -> bool {
}
}
/// `SplitContainerItem(bag, slot, count)`: picks up `count` of an unlocked stack onto an empty
/// cursor. A count of the whole stack or more is a plain pickup, since vmangos refuses to split a
/// `SplitContainerItem(bag, slot, count)` (`0x4f9f70`): an unlocked stack's `count` goes on the
/// cursor. Past its gates the binding clears with `ClearCursor(1,1)` (`0x4fa078`), whatever is
/// held, then picks up ([`pick_up_item`], `0x4fa0aa`), so its handlers see two clears and the
/// set. A count of the whole stack or more is a plain pickup, since vmangos refuses to split a
/// whole stack or more (`Player.cpp:11139`, `:11147`). Returns whether anything was picked up.
pub(crate) fn split_container_item(model: &mut Model, bag: i64, slot: u32, count: i64) -> bool {
if model.cursor.is_some() || count < 1 {
return false;
}
let Some(s) = model.containers.get(&bag).and_then(|c| c.slots.get(&slot)) else {
return false;
pub(crate) fn split_container_item(lua: &Lua, bag: i64, slot: u32, count: i64) -> bool {
let item = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(s) = model.containers.get(&bag).and_then(|c| c.slots.get(&slot)) else {
return false;
};
if count < 1 || s.item_id == 0 || s.locked || client_locked(&model, bag, slot) {
return false;
}
let n = count.min(i64::from(s.count)) as u32;
let whole = n >= s.count;
CursorItem {
bag,
slot,
item_id: s.item_id,
texture: s.texture.clone(),
link: s.link.clone(),
quality: s.quality,
count: if whole { None } else { Some(n) },
bar_placeable: s.bar_placeable,
equip_slots: s.equip_slots.clone(),
}
};
if s.item_id == 0 || s.locked {
return false;
}
let n = count.min(i64::from(s.count)) as u32;
let whole = n >= s.count;
let item = CursorItem {
bag,
slot,
item_id: s.item_id,
texture: s.texture.clone(),
link: s.link.clone(),
quality: s.quality,
count: if whole { None } else { Some(n) },
bar_placeable: s.bar_placeable,
equip_slots: s.equip_slots.clone(),
};
model.cursor = Some(CursorPayload::Item(item));
queue_cursor_update(model);
queue_lock_changed(model, bag, slot);
verb(lua, clear_cursor);
pick_up_item(lua, item);
true
}
/// `DeleteCursorItem()`, the delete popup's accept: a held item queues its `(bag, slot, count)`
/// destroy, count 0 for the whole stack, and clears; anything else is a no-op.
/// `DeleteCursorItem()` (`0x4897b0`), the delete popup's accept: a held item queues its
/// `(bag, slot, count)` destroy, count 0 for the whole stack, then `ClearCursor(1,1)`
/// (`0x4898af`) and `LockItem` on it again (`0x4898bc`) for the server's answer; anything else is
/// a no-op.
pub(crate) fn delete_cursor_item(model: &mut Model) {
let Some(CursorPayload::Item(item)) = &model.cursor else {
return;
};
let item = item.clone();
model.cursor = None;
model
.container_destroys
.push((item.bag, item.slot, item.count.unwrap_or(0)));
queue_cursor_update(model);
queue_lock_changed(model, item.bag, item.slot);
clear_cursor(model);
raise_lock_changed(model);
}
/// An attach slot's click, `ClickSendMailItemButton` (`0x4adef0`) and
/// `ClickAuctionSellItemButton` (`0x4ce310`) alike: `slot` names the attachment. The held item,
/// or none, is compared with the attachment (`0x4ae07a`, `0x4ce512`): the same, nothing on both
/// sides included, is a plain `ClearCursor(1,1)` (`0x4ae08b`, `0x4ce523`), which drops any other
/// payload. Otherwise the held item becomes the attachment and `SetCursorItem(0, …)` puts the old
/// one back on the cursor (`0x4ae0d4`, `0x4ce56c`): its inner `ClearCursor(0,1)` lifts no lock,
/// so the newly attached item stays locked ([`client_locked`]), and an empty old attachment
/// leaves the cursor clear. Returns whether the attachment changed, after which the caller's
/// slot signals its own update.
pub(crate) fn click_attach_slot(
lua: &Lua,
slot: impl Fn(&mut Model) -> &mut Option<CursorItem>,
) -> bool {
let (held, old) = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let held = match &model.cursor {
Some(CursorPayload::Item(item)) => Some(item.clone()),
_ => None,
};
(held, slot(&mut model).clone())
};
let key = |item: &Option<CursorItem>| item.as_ref().map(|i| (i.bag, i.slot));
if key(&held) == key(&old) {
verb(lua, clear_cursor);
return false;
}
verb(lua, |model| *slot(model) = held);
set_cursor(lua, false, |_| old.map(CursorPayload::Item));
true
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -460,21 +615,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"CursorHasItem",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(matches!(model.cursor, Some(CursorPayload::Item(_))))
Ok(flag(matches!(model.cursor, Some(CursorPayload::Item(_)))))
})?,
)?;
g.set(
"CursorHasSpell",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(matches!(model.cursor, Some(CursorPayload::Spell(_))))
Ok(flag(matches!(model.cursor, Some(CursorPayload::Spell(_)))))
})?,
)?;
// Deviation: returns nothing. The handler ends `mov eax,1` with nothing pushed (`0x4895bc`), so
// Lua 5.0 hands back the stack top, the last argument or the function itself: an accident of
// the handler, not an API, and copying a stack-top leak would copy a bug.
g.set(
"ClearCursor",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_cursor(&mut model);
super::tick::verb(lua, clear_cursor);
Ok(())
})?,
)?;
@@ -482,15 +639,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"SplitContainerItem",
lua.create_function(|lua, (bag, slot, count): (i64, u32, i64)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(split_container_item(&mut model, bag, slot, count))
split_container_item(lua, bag, slot, count);
Ok(())
})?,
)?;
g.set(
"DeleteCursorItem",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
delete_cursor_item(&mut model);
super::tick::verb(lua, delete_cursor_item);
Ok(())
})?,
)?;
@@ -504,9 +660,65 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
}
/// Test-only: a `CursorKind()` global naming the payload a handler reads (`none`, `item`,
/// `spell`, `action`, `macro`, `pet`, `row`, `stable`, `money`), since 1.12's Lua has no getter for
/// most modes.
#[cfg(test)]
pub(crate) fn install_kind_probe(s: &super::UiScript) {
let kind = s
.lua
.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match &model.cursor {
None => "none",
Some(CursorPayload::Item(_)) => "item",
Some(CursorPayload::Spell(_)) => "spell",
Some(CursorPayload::Action(_)) => "action",
Some(CursorPayload::Macro(_)) => "macro",
Some(CursorPayload::PetAction(_)) => "pet",
Some(CursorPayload::Merchant(_)) => "row",
Some(CursorPayload::StablePet(_)) => "stable",
Some(CursorPayload::Money(_)) => "money",
})
})
.unwrap();
s.lua.globals().set("CursorKind", kind).unwrap();
}
/// Test-only: a backpack with two items, 117 in slot 1 and 118 in slot 2, both bar-placeable.
#[cfg(test)]
pub(crate) fn two_item_bag() -> super::container::ContainerState {
let item = |item_id: u32, name: &str| super::container::ContainerSlot {
item_id,
count: 1,
quality: Some(1),
bar_placeable: true,
texture: Some(format!("Interface\\Icons\\INV_{item_id}")),
link: Some(format!("|cffffffff|Hitem:{item_id}|h[{name}]|h|r")),
..Default::default()
};
super::container::ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: [
(1, item(117, "Tough Jerky")),
(2, item(118, "Minor Healing Potion")),
]
.into_iter()
.collect(),
}
}
/// Test-only: a Lua expression reading `GetContainerItemInfo(0, slot)`'s `locked`.
#[cfg(test)]
pub(crate) fn bag_lock_probe(slot: u32) -> String {
format!("(function() local _, _, l = GetContainerItemInfo(0, {slot}) return l end)()")
}
#[cfg(test)]
mod tests {
use super::{CursorAction, CursorItem, CursorPayload, CursorSpell};
use super::{split_container_item, CursorAction, CursorItem, CursorPayload, CursorSpell};
use crate::script::tick::heard;
use crate::script::UiScript;
/// A backpack holding a five-stack in slot 1.
@@ -523,6 +735,7 @@ mod tests {
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(3),
inventory_type: 0,
item_id: 117,
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
locked: false,
@@ -548,10 +761,10 @@ mod tests {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(one_item_backpack()));
// Drain the pickup's events so only the sell clear's remain.
// The pickup's events fire inside it, so the queue holds only the host's sell clear.
s.eval::<()>("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some(), "the pickup did not take");
s.model_mut().pending_events.clear();
assert!(s.model_ref().pending_events.is_empty());
let sold = s.take_cursor_item_for_sale().expect("mode 1 is a sale");
assert_eq!((sold.bag, sold.slot), (0, 1));
@@ -594,12 +807,24 @@ mod tests {
);
}
/// `CursorHasItem` (`0x4895d0`) and `CursorHasSpell` (`0x489600`) push the number 1
/// (`0x4895ea`, `0x489609`) or nil (`0x4895df`, `0x48961b`) and return one value.
#[test]
fn the_has_checks_answer_per_arm() {
let mut s = UiScript::new().unwrap();
// `arg.n` counts the returns (`select` is 5.1's, not a 1.12 global).
let ask = |s: &mut UiScript, f: &str| {
s.eval::<String>(&format!(
"local function c(...) return arg.n .. ':' .. type(arg[1]) .. ':' .. tostring(arg[1]) end \
return c({f}())"
))
.unwrap()
};
const YES: &str = "1:number:1";
const NO: &str = "1:nil:nil";
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
assert!(!s.eval::<bool>("return CursorHasSpell()").unwrap());
assert_eq!(ask(&mut s, "CursorHasItem"), NO);
assert_eq!(ask(&mut s, "CursorHasSpell"), NO);
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: true,
@@ -612,8 +837,8 @@ mod tests {
quality: Some(3),
equip_slots: Vec::new(),
}));
assert!(s.eval::<bool>("return CursorHasItem()").unwrap());
assert!(!s.eval::<bool>("return CursorHasSpell()").unwrap());
assert_eq!(ask(&mut s, "CursorHasItem"), YES);
assert_eq!(ask(&mut s, "CursorHasSpell"), NO);
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
passive: false,
@@ -622,8 +847,8 @@ mod tests {
spell_id: 133,
texture: None,
}));
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
assert!(s.eval::<bool>("return CursorHasSpell()").unwrap());
assert_eq!(ask(&mut s, "CursorHasItem"), NO);
assert_eq!(ask(&mut s, "CursorHasSpell"), YES);
s.set_cursor_for_test(CursorPayload::Action(CursorAction {
src_slot: 12,
@@ -631,8 +856,26 @@ mod tests {
action: 133,
texture: None,
}));
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
assert!(!s.eval::<bool>("return CursorHasSpell()").unwrap());
assert_eq!(ask(&mut s, "CursorHasItem"), NO);
assert_eq!(ask(&mut s, "CursorHasSpell"), NO);
}
/// Deviation pin: the reference hands back its stack top (`0x4895bc`); `ClearCursor` returns
/// nothing, with an empty cursor and a held payload alike.
#[test]
fn clear_cursor_returns_no_values() {
let mut s = UiScript::new().unwrap();
let count = "return (function(...) return arg.n end)(ClearCursor())";
assert_eq!(s.eval::<i64>(count).unwrap(), 0);
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
passive: false,
book_slot: 1,
book_type: "spell".into(),
spell_id: 1,
texture: None,
}));
assert_eq!(s.eval::<i64>(count).unwrap(), 0);
assert!(s.cursor_payload().is_none());
}
#[test]
@@ -650,18 +893,111 @@ mod tests {
assert!(s.cursor_payload().is_none());
}
/// A pickup's events have run by the verb's next Lua line, each handler reading the cursor as
/// the reference's does: `SetCursorItem`'s inner clear on the empty cursor (`0x4952cc`), then
/// the grid before `CURSOR_UPDATE` (`0x494c3b`, `0x494c54`), then the lock (`0x495415`). The
/// same-slot click that puts it back is `ClearCursor(1,1)` (`0x4f9e0e`): the unlock
/// (`0x495455`), the grid hide (`0x4952b3`), then `CURSOR_UPDATE` (`0x4952cc`).
#[test]
fn a_pickup_fires_its_events_before_it_returns() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(one_item_backpack()));
heard::listen(
&s,
&[
"ACTIONBAR_SHOWGRID",
"ACTIONBAR_HIDEGRID",
"CURSOR_UPDATE",
"ITEM_LOCK_CHANGED",
],
Some("CursorHasItem()"),
);
let seen: Vec<String> = s
.eval("PickupContainerItem(0, 1) local h = HEARD HEARD = {} return h")
.unwrap();
assert_eq!(
seen,
[
"CURSOR_UPDATE nil",
"ACTIONBAR_SHOWGRID 1",
"CURSOR_UPDATE 1",
"ITEM_LOCK_CHANGED 1"
]
);
let seen: Vec<String> = s
.eval("PickupContainerItem(0, 1) local h = HEARD HEARD = {} return h")
.unwrap();
assert_eq!(
seen,
[
"ITEM_LOCK_CHANGED nil",
"ACTIONBAR_HIDEGRID nil",
"CURSOR_UPDATE nil"
]
);
assert!(s.model_ref().pending_events.is_empty());
}
/// The bag place arm (`0x4f9e1c`-`0x4f9eb8`): a whole stack locks an occupied destination
/// first (`0x4f9e85`), then the swap's clear (`0x5e0fe7`) and the binding's own (`0x4f9eb8`)
/// each fire `CURSOR_UPDATE`; an empty destination signals no lock (`0x4953ed`); a split's
/// one clear (`0x4f9e4f`) signals no lock either.
#[test]
fn the_bag_place_arm_locks_the_destination_before_its_two_clears() {
let mut s = UiScript::new().unwrap();
let mut bag = one_item_backpack();
let mut other = bag.slots[&1].clone();
other.item_id = 118;
bag.slots.insert(2, other);
s.set_container(0, Some(bag));
heard::listen(
&s,
&["ITEM_LOCK_CHANGED", "ACTIONBAR_HIDEGRID", "CURSOR_UPDATE"],
None,
);
let place = |s: &UiScript, from: u32, to: u32| {
s.run(&format!("PickupContainerItem(0, {from})")).unwrap();
heard::take(s);
s.run(&format!("PickupContainerItem(0, {to})")).unwrap();
heard::take(s)
};
assert_eq!(
place(&s, 1, 2),
[
"ITEM_LOCK_CHANGED",
"ACTIONBAR_HIDEGRID",
"CURSOR_UPDATE",
"CURSOR_UPDATE"
],
"a swap onto an occupied slot"
);
assert_eq!(
place(&s, 1, 3),
["ACTIONBAR_HIDEGRID", "CURSOR_UPDATE", "CURSOR_UPDATE"],
"a move onto an empty slot"
);
s.run("SplitContainerItem(0, 1, 2)").unwrap();
heard::take(&s);
s.run("PickupContainerItem(0, 4)").unwrap();
assert_eq!(
heard::take(&s),
["ACTIONBAR_HIDEGRID", "CURSOR_UPDATE"],
"a split"
);
}
/// The clear's shared tail (`0x49529b`) signals on every call: with nothing held, one
/// `CURSOR_UPDATE` and no grid event.
#[test]
fn clear_cursor_with_nothing_held_still_signals() {
let s = UiScript::new().unwrap();
let before = s.model_ref().pending_events.len();
heard::listen(
&s,
&["CURSOR_UPDATE", "ACTIONBAR_SHOWGRID", "ACTIONBAR_HIDEGRID"],
None,
);
s.run("ClearCursor()").unwrap();
let fired: Vec<String> = s.model_ref().pending_events[before..]
.iter()
.map(|(e, _)| e.clone())
.collect();
assert_eq!(fired, vec!["CURSOR_UPDATE"]);
assert_eq!(heard::take(&s), ["CURSOR_UPDATE"]);
}
/// Clearing a spell on terrain is the deviation documented on `world_drop_click`.
@@ -748,10 +1084,114 @@ mod tests {
fn split_of_the_whole_stack_degrades_to_a_plain_pickup() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(one_item_backpack())); // slot 1: a 5-stack
assert!(s
.eval::<bool>("return SplitContainerItem(0, 1, 5)")
.unwrap());
assert!(split_container_item(&s.lua, 0, 1, 5));
let held = s.cursor_item().expect("picked up");
assert_eq!(held.count, None, "count>=stack ⇒ whole-stack pickup");
}
/// `ClearCursor(1,1)`'s coin arm signals `PLAYER_MONEY` (`0x495248`) before the tail's
/// `CURSOR_UPDATE` (`0x4952cc`), both handlers reading the coins gone, so a money frame
/// repaints the purse whole: Lua's `ClearCursor()` (`0x4895b0`) and a bag right-click's clear
/// (`0x4fa198`) alike.
#[test]
fn a_clear_with_coins_held_signals_player_money() {
let mut s = UiScript::new().unwrap();
s.set_money(1_000);
s.set_container(0, Some(one_item_backpack()));
heard::listen(
&s,
&["PLAYER_MONEY", "CURSOR_UPDATE"],
Some("GetCursorMoney()"),
);
for clear in ["ClearCursor()", "UseContainerItem(0, 1)"] {
s.run("PickupPlayerMoney(10)").unwrap();
heard::take(&s);
s.run(clear).unwrap();
assert_eq!(
heard::take(&s),
["PLAYER_MONEY 0", "CURSOR_UPDATE 0"],
"{clear}"
);
}
}
/// `PickupPlayerMoney`'s setter clears with `ClearCursor(1,1)` (`0x494ce0`): a held item
/// unlocks (`0x4951d9`) and an armed gift wrap is cancelled (`0x495197`) before the coins go
/// on and signal `PLAYER_MONEY` (`0x494d0a`).
#[test]
fn a_money_pickup_clears_a_held_item_and_an_armed_wrap() {
let mut s = UiScript::new().unwrap();
s.set_money(1_000);
s.set_container(0, Some(one_item_backpack()));
super::install_kind_probe(&s);
s.run("PickupContainerItem(0, 1)").unwrap();
heard::listen(
&s,
&[
"ITEM_LOCK_CHANGED",
"ACTIONBAR_SHOWGRID",
"ACTIONBAR_HIDEGRID",
"CURSOR_UPDATE",
"PLAYER_MONEY",
],
Some("CursorKind()"),
);
s.run("PickupPlayerMoney(10)").unwrap();
assert_eq!(
heard::take(&s),
[
"ITEM_LOCK_CHANGED none",
"ACTIONBAR_HIDEGRID none",
"CURSOR_UPDATE none",
"PLAYER_MONEY money"
]
);
s.run("ClearCursor()").unwrap();
s.model_mut().pending_wrap =
Some(crate::script::container::PendingWrap { bag: 0, slot: 1 });
heard::take(&s);
s.run("PickupPlayerMoney(10)").unwrap();
assert!(
s.model_ref().pending_wrap.is_none(),
"the wrap is cancelled"
);
assert_eq!(
heard::take(&s),
[
"ITEM_LOCK_CHANGED none",
"CURSOR_UPDATE none",
"PLAYER_MONEY money"
]
);
}
/// The split clears with `ClearCursor(1,1)` (`0x4fa078`) whatever is held, then picks up
/// through `SetCursorItem`, whose inner clear signals again (`0x494bb6`), and `LockItem`.
#[test]
fn a_split_clears_what_is_held_then_picks_up() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(one_item_backpack()));
super::install_kind_probe(&s);
s.set_cursor_for_test(CursorPayload::Macro(super::CursorMacro {
index: 1,
texture: None,
}));
heard::listen(
&s,
&["CURSOR_UPDATE", "ITEM_LOCK_CHANGED"],
Some("CursorKind()"),
);
s.run("SplitContainerItem(0, 1, 2)").unwrap();
assert_eq!(
heard::take(&s),
[
"CURSOR_UPDATE none",
"CURSOR_UPDATE none",
"CURSOR_UPDATE item",
"ITEM_LOCK_CHANGED item"
]
);
assert_eq!(s.cursor_item().and_then(|i| i.count), Some(2));
}
}
@@ -3,10 +3,14 @@
use mlua::{Lua, MultiValue, Value};
use crate::script::container::ContainerMove;
use crate::script::tick::verb;
use crate::script::Model;
use super::{clear_cursor, queue_cursor_update, queue_lock_changed, CursorItem, CursorPayload};
use super::{doll::pickup_inventory_item, EQUIPMENT_BAG};
use super::doll::{doll_click, DollClick};
use super::{
clear, clear_cursor, client_locked, pick_up_item, raise_lock_changed, Clear, CursorItem,
CursorPayload, EQUIPMENT_BAG,
};
/// The ids `PutItemInBag` and `PickupBagFromSlot` take: what their argument reader (`0x4c8520`)
/// accepts above each floor gate (`0x4c8f1f`, `0x4c8fbf`), so the equipped bags 20-23, the
@@ -73,16 +77,17 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
if !occupied {
// An empty slot: the doll handler equips the bag (`0x4c7300`), and the answer is nil
// whether or not it went in (`0x4c7cae xor eax,eax`).
pickup_inventory_item(model, live);
// whether or not it went in (`0x4c7cae xor eax,eax`). With an item held the doll click
// only places, so it owes no pickup.
let click = doll_click(model, live);
debug_assert!(matches!(click, DollClick::Done(_)));
return false;
}
// The held bag is this slot's own: a cancel that sends nothing and answers 1.
if held.bag == EQUIPMENT_BAG && held.slot == live {
model.cursor = None;
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
// `ClearCursor(1,1)` (`0x4c7cca`): the unlock, then `CURSOR_UPDATE`.
clear_cursor(model);
return true;
}
@@ -90,7 +95,6 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
// A swap (`0x5e0c40`); an item from a bag equipment slot enters this fork too, hence the
// `||`. A split stack cannot swap and stays held: the reference splits only when storing.
if held.count.is_none() {
model.cursor = None;
model.container_moves.push(ContainerMove {
src_bag: held.bag,
src_slot: held.slot,
@@ -98,11 +102,10 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
dst_slot: live,
count: None,
});
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
return true;
// The swap send's `ClearCursor(0,1)` (`0x5e0fe7`).
clear(model, Clear::KEEP_LOCK);
raise_lock_changed(model);
}
model.cursor = Some(CursorPayload::Item(held));
return true;
}
@@ -111,15 +114,15 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
let Some(dst_bag) = autostore_container(live) else {
return false;
};
model.cursor = None;
model.bag_autostores.push(crate::script::BagAutoStore {
src_bag: held.bag,
src_slot: held.slot,
dst_bag,
count: held.count,
});
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
// `ClearCursor(0,1)` past the send (`0x4c7e55`).
clear(model, Clear::KEEP_LOCK);
raise_lock_changed(model);
true
}
@@ -130,34 +133,48 @@ pub(super) fn put_item_in_backpack(model: &mut Model) -> bool {
let Some(CursorPayload::Item(held)) = model.cursor.clone() else {
return false;
};
model.cursor = None;
model.bag_autostores.push(crate::script::BagAutoStore {
src_bag: held.bag,
src_slot: held.slot,
dst_bag: 0,
count: held.count,
});
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
clear(model, Clear::KEEP_LOCK);
raise_lock_changed(model);
true
}
/// `PickupBagFromSlot(inventorySlot)` (`0x4c7b00`): clears a held payload before it looks at the
/// slot, where the reference clears only a held item (`0x4c7b70`, `0x4c7b79`); with nothing held it
/// skips the clear and its `CURSOR_UPDATE`, as the reference does. Takes only a container
/// skips the clear and its `CURSOR_UPDATE`, as the reference does. The clear's events run before
/// the slot is read, so its `CURSOR_UPDATE` handler sees the empty cursor. Takes only a container
/// (`0x4c7bb3`), and never places or swaps. The client has no empty-bag rule: the server refuses
/// with `EQUIP_ERR_CAN_ONLY_DO_WITH_EMPTY_BAGS`.
pub(super) fn pickup_bag_from_slot(model: &mut Model, live: u32) {
pub(super) fn pickup_bag_from_slot(lua: &Lua, live: u32) {
if !accepts(live) {
return;
}
if model.cursor.is_some() {
clear_cursor(model);
verb(lua, |model| {
if model.cursor.is_some() {
clear_cursor(model);
}
});
let picked = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
grab_bag(&model, live)
};
if let Some(item) = picked {
pick_up_item(lua, item);
}
let picked = usize::try_from(live)
}
/// `PickupBagFromSlot`'s grab: an unlocked container in `live` (`0x4c7bc1`), which it picks up
/// with `SetCursorItem(1, …)` and `LockItem` (`0x4c7be2`, `0x4c7be9`).
fn grab_bag(model: &Model, live: u32) -> Option<CursorItem> {
usize::try_from(live)
.ok()
.and_then(|slot| model.inv_slot("player", slot))
.filter(|s| s.item_id != 0 && !s.locked)
.filter(|s| s.item_id != 0 && !s.locked && !client_locked(model, EQUIPMENT_BAG, live))
.map(|s| CursorItem {
bag: EQUIPMENT_BAG,
slot: live,
@@ -169,12 +186,7 @@ pub(super) fn pickup_bag_from_slot(model: &mut Model, live: u32) {
bar_placeable: s.bar_placeable,
equip_slots: s.equip_slots.clone(),
})
.filter(is_container);
if let Some(item) = picked {
model.cursor = Some(CursorPayload::Item(item));
queue_cursor_update(model);
queue_lock_changed(model, EQUIPMENT_BAG, live);
}
.filter(is_container)
}
/// Register the three verbs as globals.
@@ -193,28 +205,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"PutItemInBag",
lua.create_function(|lua, live: u32| {
let yes = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
put_item_in_bag(&mut model, live)
};
consumed(yes)
consumed(verb(lua, |model| put_item_in_bag(model, live)))
})?,
)?;
g.set(
"PutItemInBackpack",
lua.create_function(|lua, ()| {
let yes = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
put_item_in_backpack(&mut model)
};
consumed(yes)
})?,
lua.create_function(|lua, ()| consumed(verb(lua, put_item_in_backpack)))?,
)?;
g.set(
"PickupBagFromSlot",
lua.create_function(|lua, live: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_bag_from_slot(&mut model, live);
pickup_bag_from_slot(lua, live);
Ok(())
})?,
)?;
@@ -225,6 +226,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
#[cfg(test)]
mod tests {
use crate::script::cursor::EQUIPMENT_BAG;
use crate::script::tick::heard;
use crate::script::{
BagAutoStore, BankBagSlots, ContainerMove, ContainerSlot, ContainerState, InvSlotView,
InventorySlots, UiScript,
@@ -235,10 +237,10 @@ mod tests {
#[test]
fn an_empty_cursor_skips_the_clear_and_its_signal() {
let s = UiScript::new().unwrap();
let before = s.model_ref().pending_events.len();
heard::listen(&s, &["CURSOR_UPDATE"], None);
s.run("PickupBagFromSlot(20)").unwrap();
assert!(s.cursor_payload().is_none());
assert_eq!(s.model_ref().pending_events.len(), before);
assert!(heard::take(&s).is_empty());
}
/// An item that is a bag: `equip_slots` is `find_equip_slot(INVTYPE_BAG)`.
@@ -454,6 +456,34 @@ mod tests {
assert!(s.take_container_moves().is_empty(), "no place path exists");
}
/// The clear's events (the unlock `0x495455`, then `0x4952cc`) run before the grab reads the
/// slot (`0x4c7be2`), so its handlers see the bag still in place; the grab's own inner clear
/// signals once more on the empty cursor (`0x494bb6`), and only its set's events (`0x494c54`,
/// then `0x495415`) see the bag held and locked.
#[test]
fn pickup_bag_from_slot_fires_the_clear_before_the_grab() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
s.set_bank_bag_slots(one_bank_bag());
s.run("PickupContainerItem(0, 2)").unwrap();
heard::listen(
&s,
&["CURSOR_UPDATE", "ITEM_LOCK_CHANGED"],
Some("IsInventoryItemLocked(64)"),
);
s.run("PickupBagFromSlot(64)").unwrap();
assert_eq!(
heard::take(&s),
[
"ITEM_LOCK_CHANGED nil",
"CURSOR_UPDATE nil",
"CURSOR_UPDATE nil",
"CURSOR_UPDATE 1",
"ITEM_LOCK_CHANGED 1"
]
);
}
#[test]
fn pickup_bag_from_slot_only_takes_containers_and_never_a_locked_one() {
let mut s = UiScript::new().unwrap();
+185 -83
View File
@@ -6,9 +6,10 @@
use mlua::Lua;
use crate::script::action::{ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL};
use crate::script::tick::verb;
use crate::script::{ActionSlot, Model};
use super::{queue_cursor_update, CursorAction, CursorPayload};
use super::{clear_cursor, set_cursor, CursorAction, CursorPayload};
/// The passive-spell refusal's error, errorId `0x9e`: entry `0xb4c0f0` of the table at `0xb4b498`
/// (stride `0x14`) names this key (`0x84133c`).
@@ -20,86 +21,113 @@ fn pack(kind: u8, action: u32) -> u32 {
}
/// `PickupAction(id)`, the slot's shift-click and drag start (`ActionBarFrame.xml:12-38`). With a
/// payload held it places instead; a pickup also clears the slot on the wire at once. Returns
/// whether to repaint.
pub(super) fn pickup_action(model: &mut Model, id: u32) -> bool {
if model.cursor.is_some() {
return place_action(model, id);
}
let Some(slot) = model.actions.get(&id) else {
/// payload held it places instead; a pickup also clears the slot on the wire at once. The cursor
/// goes first and the slot after, as `0x4e6130` runs its setter before the slot clear `0x4e5db0`,
/// so the pickup's events still read the action in its slot. The setter is the one for the
/// slot's kind ([`super::put`]): its inner `ClearCursor(1,1)` signals on the empty cursor, then
/// the grid shows. Returns whether to repaint.
pub(super) fn pickup_action(lua: &Lua, id: u32) -> bool {
let picked = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
if model.cursor.is_some() {
None
} else {
// An empty slot returns before any setter (`0x4e61a3`).
Some(model.actions.get(&id).map(|slot| CursorAction {
src_slot: id,
kind: slot.kind,
action: slot.action,
texture: slot.texture.clone(),
}))
}
};
let Some(picked) = picked else {
return place_action(lua, id);
};
let Some(payload) = picked else {
return false;
};
let payload = CursorAction {
src_slot: id,
kind: slot.kind,
action: slot.action,
texture: slot.texture.clone(),
};
model.actions.remove(&id);
model.cursor = Some(CursorPayload::Action(payload));
model.action_sets.push((id, 0));
queue_cursor_update(model);
set_cursor(lua, true, |_| Some(CursorPayload::Action(payload)));
verb(lua, |model| {
model.actions.remove(&id);
model.action_sets.push((id, 0));
});
true
}
/// `PlaceAction(id)`, the slot's `OnReceiveDrag` and `UseAction(id, 1)`'s place fork. An occupied
/// slot's action hops onto the cursor with `id` as its source; an item stays in its bag, since a
/// bar item is a reference, not a move. Returns whether to repaint.
pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
/// slot's action hops onto the cursor with `id` as its source, through its setter, whose inner
/// `ClearCursor(1,1)` drops the held payload, hiding the grid, before the setter shows it again
/// (`0x4e6450`, `0x4e6494`, `0x4e64e2`); an empty slot's place is that clear alone (`0x4e650f`).
/// An item stays in its bag, since a bar item is a reference, not a move. The cursor goes first
/// and the slot after, as `0x4e62e0` writes the slot past the setter, so the cursor's events read
/// the slot as it was. Returns whether to repaint.
pub(crate) fn place_action(lua: &Lua, id: u32) -> bool {
let Some((placed, displaced)) = verb(lua, |model| plan_place(model, id)) else {
return false;
};
let (kind, action, texture) = placed;
match displaced {
Some(displaced) => {
set_cursor(lua, true, |_| Some(CursorPayload::Action(displaced)));
}
None => verb(lua, clear_cursor),
}
verb(lua, |model| {
model.actions.insert(
id,
ActionSlot {
texture,
kind,
action,
// Count and consumable wait for the app's next-frame re-feed, which knows the item.
count: 0,
consumable: false,
},
);
model.action_sets.push((id, pack(kind, action)));
});
true
}
/// What the slot takes and the action it displaces.
type PlacePlan = ((u8, u32, Option<String>), Option<CursorAction>);
/// `PlaceAction`'s decision: the accept filters, then what the slot takes from the held payload
/// and the occupant that hops. `None` is a refusal that leaves the payload held.
fn plan_place(model: &mut Model, id: u32) -> Option<PlacePlan> {
// The two accept filters (`0x4e62e0`) refuse with a bare return: nothing stored or sent, and
// the payload stays held. An item needs an on-use spell or an equip slot (`0x4e6571`), so a
// bag is placeable; a passive spell (`Attributes & 0x40`) also raises errorId `0x9e` through
// `DisplayError` (`0x4e63ad`, `0x496720`), where the item refusal is mute.
match &model.cursor {
Some(CursorPayload::Item(i)) if !i.bar_placeable => return false,
Some(CursorPayload::Item(i)) if !i.bar_placeable => return None,
Some(CursorPayload::Spell(s)) if s.passive => {
model.ui_errors.push(PASSIVE_ON_BAR_ERROR);
return false;
return None;
}
_ => {}
}
let Some(held) = model.cursor.take() else {
return false;
};
let placeable = match &held {
CursorPayload::Action(a) => Some((a.kind, a.action, a.texture.clone())),
CursorPayload::Item(i) => Some((ACTION_KIND_ITEM, i.item_id, i.texture.clone())),
CursorPayload::Spell(s) => Some((ACTION_KIND_SPELL, s.spell_id, s.texture.clone())),
let placed = match model.cursor.as_ref()? {
CursorPayload::Action(a) => (a.kind, a.action, a.texture.clone()),
CursorPayload::Item(i) => (ACTION_KIND_ITEM, i.item_id, i.texture.clone()),
CursorPayload::Spell(s) => (ACTION_KIND_SPELL, s.spell_id, s.texture.clone()),
// `PlaceAction` accepts modes 1, 3, 7 and 8 (`0x4e62e0`): a macro, mode 8, packs its id
// under the macro tag. Pet actions (4), coins (2), stabled pets (10) and vendor rows (5)
// are refused and stay held.
CursorPayload::Macro(m) => Some((ACTION_KIND_MACRO, m.index, m.texture.clone())),
CursorPayload::PetAction(_) => None,
CursorPayload::StablePet(_) => None,
CursorPayload::Money(_) => None,
CursorPayload::Merchant(_) => None,
CursorPayload::Macro(m) => (ACTION_KIND_MACRO, m.index, m.texture.clone()),
CursorPayload::PetAction(_)
| CursorPayload::StablePet(_)
| CursorPayload::Money(_)
| CursorPayload::Merchant(_) => return None,
};
let Some((kind, action, texture)) = placeable else {
model.cursor = Some(held);
return false;
};
let displaced = model.actions.insert(
id,
ActionSlot {
texture,
kind,
action,
// Count and consumable wait for the app's next-frame re-feed, which knows the item.
count: 0,
consumable: false,
},
);
model.action_sets.push((id, pack(kind, action)));
model.cursor = displaced.map(|d| {
CursorPayload::Action(CursorAction {
src_slot: id,
kind: d.kind,
action: d.action,
texture: d.texture,
})
let displaced = model.actions.get(&id).map(|d| CursorAction {
src_slot: id,
kind: d.kind,
action: d.action,
texture: d.texture.clone(),
});
queue_cursor_update(model);
true
Some((placed, displaced))
}
/// Register `PickupAction` and `PlaceAction` as globals.
@@ -109,15 +137,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"PickupAction",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(pickup_action(&mut model, id))
pickup_action(lua, id);
Ok(())
})?,
)?;
g.set(
"PlaceAction",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(place_action(&mut model, id))
place_action(lua, id);
Ok(())
})?,
)?;
@@ -126,6 +154,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
#[cfg(test)]
mod tests {
use super::{pickup_action, place_action};
use crate::script::cursor::{CursorAction, CursorItem, CursorPayload, CursorSpell};
use crate::script::{ActionSlot, UiScript};
@@ -144,7 +173,7 @@ mod tests {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 133)));
assert!(s.eval::<bool>("return PickupAction(1)").unwrap());
assert!(pickup_action(&s.lua, 1));
assert!(
!s.eval::<bool>("return HasAction(1)").unwrap(),
"removed from the engine's optimistic mirror"
@@ -157,10 +186,40 @@ mod tests {
assert_eq!(s.take_action_sets(), vec![(1, 0)]);
}
/// The cursor's events run before the slot write, as `0x4e6130` runs its setter before the
/// slot clear `0x4e5db0` and `0x4e62e0` hops the displaced action before it writes: a handler
/// reads each slot as it was.
#[test]
fn the_cursor_events_fire_before_the_slot_write() {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
s.set_action(2, Some(action_slot("Interface\\Icons\\Spell_B", 0x00, 222)));
crate::script::tick::heard::listen(
&s,
&["CURSOR_UPDATE"],
Some("tostring(GetActionTexture(1)) .. ' ' .. tostring(GetActionTexture(2))"),
);
s.run("PickupAction(1)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
["CURSOR_UPDATE Interface\\Icons\\Spell_A Interface\\Icons\\Spell_B"]
);
assert!(!s.eval::<bool>("return HasAction(1)").unwrap());
s.run("PlaceAction(2)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
["CURSOR_UPDATE nil Interface\\Icons\\Spell_B"]
);
assert_eq!(
s.eval::<String>("return GetActionTexture(2)").unwrap(),
"Interface\\Icons\\Spell_A"
);
}
#[test]
fn pickup_action_on_an_empty_slot_is_a_no_op() {
let mut s = UiScript::new().unwrap();
assert!(!s.eval::<bool>("return PickupAction(5)").unwrap());
assert!(!pickup_action(&s.lua, 5));
assert!(s.cursor_payload().is_none());
assert!(s.take_action_sets().is_empty());
}
@@ -175,7 +234,7 @@ mod tests {
texture: Some("Interface\\Icons\\Spell_A".into()),
}));
assert!(s.eval::<bool>("return PlaceAction(7)").unwrap());
assert!(place_action(&s.lua, 7));
assert!(s.cursor_payload().is_none(), "empty destination clears");
assert!(s.eval::<bool>("return HasAction(7)").unwrap());
assert_eq!(
@@ -192,10 +251,10 @@ mod tests {
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
s.set_action(2, Some(action_slot("Interface\\Icons\\Spell_B", 0x00, 222)));
assert!(s.eval::<bool>("return PickupAction(1)").unwrap());
assert!(pickup_action(&s.lua, 1));
assert_eq!(s.take_action_sets(), vec![(1, 0)]);
assert!(s.eval::<bool>("return PlaceAction(2)").unwrap());
assert!(place_action(&s.lua, 2));
assert_eq!(
s.eval::<String>("return GetActionTexture(2)").unwrap(),
"Interface\\Icons\\Spell_A"
@@ -227,7 +286,7 @@ mod tests {
equip_slots: Vec::new(),
}));
assert!(s.eval::<bool>("return PlaceAction(4)").unwrap());
assert!(place_action(&s.lua, 4));
assert!(s.cursor_payload().is_none());
// 0x80<<24 | 117.
assert_eq!(s.take_action_sets(), vec![(4, 0x8000_0000 | 117)]);
@@ -248,7 +307,7 @@ mod tests {
texture: Some("Interface\\Icons\\Spell_A".into()),
}));
assert!(s.eval::<bool>("return PlaceAction(9)").unwrap());
assert!(place_action(&s.lua, 9));
assert_eq!(s.take_action_sets(), vec![(9, 133)]); // 0x00<<24 | 133
}
@@ -256,7 +315,7 @@ mod tests {
fn place_action_with_an_empty_cursor_is_a_no_op() {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
assert!(!s.eval::<bool>("return PlaceAction(1)").unwrap());
assert!(!place_action(&s.lua, 1));
assert!(s.eval::<bool>("return HasAction(1)").unwrap(), "untouched");
assert!(s.take_action_sets().is_empty());
}
@@ -270,14 +329,16 @@ mod tests {
action: 111,
texture: Some("Interface\\Icons\\Spell_A".into()),
}));
assert!(s.eval::<bool>("return PickupAction(5)").unwrap());
assert!(pickup_action(&s.lua, 5));
assert!(s.cursor_payload().is_none(), "placed onto the empty slot 5");
assert!(s.eval::<bool>("return HasAction(5)").unwrap());
assert_eq!(s.take_action_sets(), vec![(5, 111)]);
}
#[test]
fn showgrid_hidegrid_fire_on_gain_and_loss_not_on_a_hop() {
/// The setters show the grid (`0x494dd1`) and every clear's tail hides it (`0x4952b3`), so a
/// hop's inner clear hides it before the displaced action's setter shows it again.
fn showgrid_hidegrid_fire_on_gain_and_loss_and_twice_on_a_hop() {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
s.set_action(2, Some(action_slot("Interface\\Icons\\Spell_B", 0x00, 222)));
@@ -300,15 +361,15 @@ mod tests {
assert_eq!(s.eval::<i64>("return shows").unwrap(), 1);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 0);
s.run("PlaceAction(2)").unwrap(); // Some -> Some (hop): neither
s.run("PlaceAction(2)").unwrap(); // Some -> Some (hop): HIDE, then SHOW
s.tick(0.01);
assert_eq!(s.eval::<i64>("return shows").unwrap(), 1);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 0);
assert_eq!(s.eval::<i64>("return shows").unwrap(), 2);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 1);
s.run("ClearCursor()").unwrap(); // Some -> None: HIDE
s.tick(0.01);
assert_eq!(s.eval::<i64>("return shows").unwrap(), 1);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 1);
assert_eq!(s.eval::<i64>("return shows").unwrap(), 2);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 2);
}
#[test]
@@ -326,7 +387,7 @@ mod tests {
equip_slots: Vec::new(),
}));
assert!(!s.eval::<bool>("return PlaceAction(4)").unwrap());
assert!(!place_action(&s.lua, 4));
assert!(!s.eval::<bool>("return HasAction(4)").unwrap(), "no store");
assert!(s.take_action_sets().is_empty(), "no packet");
assert!(
@@ -351,7 +412,7 @@ mod tests {
passive: true,
}));
assert!(!s.eval::<bool>("return PlaceAction(4)").unwrap());
assert!(!place_action(&s.lua, 4));
assert!(!s.eval::<bool>("return HasAction(4)").unwrap());
assert!(s.take_action_sets().is_empty());
assert!(matches!(s.cursor_payload(), Some(CursorPayload::Spell(_))));
@@ -377,7 +438,7 @@ mod tests {
passive: false,
}));
assert!(s.eval::<bool>("return PlaceAction(4)").unwrap());
assert!(place_action(&s.lua, 4));
assert!(s.eval::<bool>("return HasAction(4)").unwrap());
assert!(s.take_ui_errors().is_empty());
}
@@ -397,7 +458,48 @@ mod tests {
equip_slots: vec![20, 21, 22, 23],
}));
assert!(s.eval::<bool>("return PlaceAction(4)").unwrap());
assert!(place_action(&s.lua, 4));
assert_eq!(s.take_action_sets(), vec![(4, 0x8000_0000u32 | 4496)]);
}
/// A bar pickup runs the setter of its slot's kind (`0x4e6130`): a spell's shows the grid and
/// signals nothing after (`0x494dd1`); an item's row grab shows it and signals after its set
/// (`0x49513a`, `0x49515e`). Each inner clear signals first, so a hop hides the grid and
/// `CURSOR_UPDATE` reads the emptied cursor before the displaced action goes on.
#[test]
fn a_bar_pickup_and_a_hop_signal_as_their_setters() {
let s = {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
s.set_action(2, Some(action_slot("Interface\\Icons\\INV_A", 0x80, 4540)));
s
};
crate::script::cursor::install_kind_probe(&s);
crate::script::tick::heard::listen(
&s,
&["CURSOR_UPDATE", "ACTIONBAR_SHOWGRID", "ACTIONBAR_HIDEGRID"],
Some("CursorKind()"),
);
s.run("PickupAction(1)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
["CURSOR_UPDATE none", "ACTIONBAR_SHOWGRID action"]
);
s.run("PlaceAction(2)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
[
"ACTIONBAR_HIDEGRID none",
"CURSOR_UPDATE none",
"ACTIONBAR_SHOWGRID action",
"CURSOR_UPDATE action"
]
);
// Onto an empty slot the place is the clear alone (`0x4e650f`).
s.run("PlaceAction(3)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
["ACTIONBAR_HIDEGRID none", "CURSOR_UPDATE none"]
);
}
}
+142 -78
View File
@@ -1,22 +1,46 @@
//! The paper-doll cursor verbs: an equipped slot is an item source under
//! [`super::EQUIPMENT_BAG`], so the shared cursor code handles its cancel, clear and world drop.
use mlua::Lua;
use mlua::{Lua, Value};
use crate::script::binding_abi::flag;
use crate::script::container::ContainerMove;
use crate::script::tick::verb;
use crate::script::Model;
use super::{queue_cursor_update, queue_lock_changed, CursorItem, CursorPayload, EQUIPMENT_BAG};
use super::{
clear, clear_cursor, client_locked, pick_up_item, raise_lock_changed, Clear, CursorItem,
CursorPayload, EQUIPMENT_BAG,
};
/// `PickupInventoryItem(id)` (`0x4c7300`), the doll slot's click, on the bag click's model: a
/// fitting whole item moves in and the cursor clears, with no hop; a split or a misfit stays
/// held. Returns whether to repaint.
pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
pub(super) fn pickup_inventory_item(lua: &Lua, id: u32) -> bool {
match verb(lua, |model| doll_click(model, id)) {
DollClick::Done(repaint) => repaint,
DollClick::PickUp(item) => {
pick_up_item(lua, item);
true
}
}
}
/// What a doll click did inside its segment, or the pickup it owes past it.
pub(super) enum DollClick {
/// Handled; whether to repaint.
Done(bool),
/// The item to pick up: `SetCursorItem(1, …)` and `LockItem` (`0x4c7838`, `0x4c783f`), whose
/// inner clear signals in a segment of its own ([`pick_up_item`]).
PickUp(CursorItem),
}
/// [`pickup_inventory_item`]'s decision and every arm but the pickup.
pub(super) fn doll_click(model: &mut Model, id: u32) -> DollClick {
// Head 1 to Tabard 19, the bag slots 20-23, and the bank bag slots, which only `PutItemInBag`
// reaches (`0x4c7c00` calls `0x4c7300`). Ammo (0) is refused; its leg is not built.
if !(1..=23).contains(&id) && !super::bag_verbs::BANK_BAG_INV_SLOTS.contains(&id) {
return false;
return DollClick::Done(false);
}
// Only a held item (`0x4c769a`) or vendor row (`0x4c76a5`) takes the click first. Past them
// an empty slot or a locked item does nothing (`0x4c76af`, `0x4c76d2`), an item-targeting
@@ -30,19 +54,21 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
if !placing && (model.item_pick_armed || model.repair_mode) {
let usable = model
.inv_slot("player", id as usize)
.is_some_and(|s| s.item_id != 0 && !s.locked);
.is_some_and(|s| s.item_id != 0 && !s.locked)
&& !client_locked(model, EQUIPMENT_BAG, id);
if usable && model.item_pick_armed {
model.item_picks.push((EQUIPMENT_BAG, id));
} else if usable && (1..=19).contains(&id) {
model.inventory_repairs.push(id);
}
return false;
return DollClick::Done(false);
}
match model.cursor.take() {
match model.cursor.clone() {
None => {
let picked = model
.inv_slot("player", id as usize)
.filter(|s| s.item_id != 0 && !s.locked)
.filter(|_| !client_locked(model, EQUIPMENT_BAG, id))
.map(|s| CursorItem {
bag: EQUIPMENT_BAG,
slot: id,
@@ -55,25 +81,19 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
equip_slots: s.equip_slots.clone(),
});
match picked {
Some(item) => {
model.cursor = Some(CursorPayload::Item(item));
queue_cursor_update(model);
queue_lock_changed(model, EQUIPMENT_BAG, id);
true
}
None => false,
Some(item) => DollClick::PickUp(item),
None => DollClick::Done(false),
}
}
// The cancel is `ClearCursor(1,1)` (`0x4c794d`): the unlock, then `CURSOR_UPDATE`.
Some(CursorPayload::Item(held)) if held.bag == EQUIPMENT_BAG && held.slot == id => {
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
true
clear_cursor(model);
DollClick::Done(true)
}
Some(CursorPayload::Item(held)) => {
// A split or a misfit stays held, with no event: nothing changed.
if held.count.is_some() || !super::bag_verbs::fits_slot(&held, id) {
model.cursor = Some(CursorPayload::Item(held));
return false;
return DollClick::Done(false);
}
model.container_moves.push(ContainerMove {
src_bag: held.bag,
@@ -82,9 +102,11 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
dst_slot: id,
count: None,
});
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
true
// The swap send clears with `ClearCursor(0,1)` (`0x5e0fe7`), then `LockItem`
// (`0x4c78c8`).
clear(model, Clear::KEEP_LOCK);
raise_lock_changed(model);
DollClick::Done(true)
}
// Mode 5, a buy into this slot: `0x4c78fd` sends `CMSG_BUY_ITEM_IN_SLOT` (`0x1a3`) and the
// server equips or refuses. A stale row buys nothing: of the reference's three drop sites
@@ -99,11 +121,12 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
if let Some(entry) = entry {
model.merchant_slot_buys.push((EQUIPMENT_BAG, id, entry));
}
queue_cursor_update(model);
true
// `ClearCursor(1,1)` (`0x4c7954`).
clear_cursor(model);
DollClick::Done(true)
}
Some(
other @ (CursorPayload::Spell(_)
CursorPayload::Spell(_)
| CursorPayload::Action(_)
| CursorPayload::Macro(_)
| CursorPayload::PetAction(_)
@@ -112,17 +135,14 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
// Mode 2: coins stay held for a money frame's drop. On an occupied slot the reference
// picks the item up over either, as it tests only for a held item (`0x4c769a`) or a
// preview row (`0x4c76a5`) before the pickup (`0x4c7838`).
| CursorPayload::Money(_)),
) => {
model.cursor = Some(other);
false
}
| CursorPayload::Money(_),
) => DollClick::Done(false),
}
}
/// `EquipCursorItem(id)`: placing the held item on doll slot `id`.
pub(super) fn equip_cursor_item(model: &mut Model, id: u32) -> bool {
pickup_inventory_item(model, id)
pub(super) fn equip_cursor_item(lua: &Lua, id: u32) -> bool {
pickup_inventory_item(lua, id)
}
/// `CursorCanGoInSlot(id)`, the doll slot's highlight on `CURSOR_UPDATE`
@@ -141,26 +161,29 @@ pub(super) fn cursor_can_go_in_slot(model: &Model, id: u32) -> bool {
/// the displaced piece back (`ItemHandler.cpp:138-228`); an equipped item or a split stays held.
/// Returns whether to repaint.
pub(super) fn auto_equip_cursor_item(model: &mut Model) -> bool {
match model.cursor.take() {
match &model.cursor {
Some(CursorPayload::Item(item)) if item.bag >= 0 && item.count.is_none() => {
model.container_autoequips.push((item.bag, item.slot));
queue_cursor_update(model);
queue_lock_changed(model, item.bag, item.slot);
clear(model, Clear::KEEP_LOCK);
raise_lock_changed(model);
true
}
other => {
model.cursor = other;
false
}
_ => false,
}
}
/// `UseInventoryItem(id)`, the doll slot's right-click (`PaperDollFrame.lua:658-659`): the cursor
/// clears before the slot is read (`0x4c79a9`), so a held payload goes back, never placed; then
/// the app sends `CMSG_USE_ITEM` with bag 255 and the 0-based slot, dropping an empty slot unsent,
/// or in repair mode a worn item is repaired, locked or not (`0x4c79c4`).
pub(super) fn use_inventory_item(model: &mut Model, id: u32) {
super::clear_cursor(model);
/// clears before the slot is read (`0x4c79a9`), so a held payload goes back, never placed, its
/// events running first; then the app sends `CMSG_USE_ITEM` with bag 255 and the 0-based slot,
/// dropping an empty slot unsent, or in repair mode a worn item is repaired, locked or not
/// (`0x4c79c4`).
pub(super) fn use_inventory_item(lua: &Lua, id: u32) {
verb(lua, clear_cursor);
verb(lua, |model| use_cleared(model, id));
}
/// `UseInventoryItem` past its clear.
fn use_cleared(model: &mut Model, id: u32) {
if !model.repair_mode {
model
.script_calls
@@ -176,11 +199,11 @@ pub(super) fn use_inventory_item(model: &mut Model, id: u32) {
}
}
/// `IsInventoryItemLocked(id)`: true while `id` is the held item's source or the app reports a
/// pending operation on it. Reads [`Model::inv_slot`], since `BankFrameItemButton_UpdateLock`
/// asks about bank slots too.
/// `IsInventoryItemLocked(id)`: true while the client holds `id` locked ([`client_locked`]) or
/// the app reports a pending operation on it. Reads [`Model::inv_slot`], since
/// `BankFrameItemButton_UpdateLock` asks about bank slots too.
pub(super) fn is_inventory_item_locked(model: &Model, id: u32) -> bool {
let held_here = matches!(&model.cursor, Some(CursorPayload::Item(c)) if c.bag == EQUIPMENT_BAG && c.slot == id);
let held_here = client_locked(model, EQUIPMENT_BAG, id);
let fed = usize::try_from(id)
.ok()
.and_then(|i| model.inv_slot("player", i))
@@ -195,44 +218,53 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"PickupInventoryItem",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(pickup_inventory_item(&mut model, id))
pickup_inventory_item(lua, id);
Ok(())
})?,
)?;
g.set(
"EquipCursorItem",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(equip_cursor_item(&mut model, id))
equip_cursor_item(lua, id);
Ok(())
})?,
)?;
g.set(
"CursorCanGoInSlot",
lua.create_function(|lua, id: u32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(cursor_can_go_in_slot(&model, id))
Ok(flag(cursor_can_go_in_slot(&model, id)))
})?,
)?;
g.set(
"AutoEquipCursorItem",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(auto_equip_cursor_item(&mut model))
verb(lua, auto_equip_cursor_item);
Ok(())
})?,
)?;
g.set(
"UseInventoryItem",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
use_inventory_item(&mut model, id);
use_inventory_item(lua, id);
Ok(())
})?,
)?;
g.set(
"IsInventoryItemLocked",
lua.create_function(|lua, id: u32| {
lua.create_function(|lua, slot: Value| {
// The slot reader raises for any id outside its whitelist, a negative included
// (`0x4c8520`, `0x4c8e7c`), and the ammo leg (Lua 0) answers nil (`0x4c8e98`).
let slot0 = crate::script::inventory_verbs::inventory_slot_arg(
lua,
slot,
"Invalid inventory slot in IsInventoryItemLocked",
)?;
if slot0 < 0 {
return Ok(Value::Nil);
}
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(flag(is_inventory_item_locked(&model, id)))
Ok(flag(is_inventory_item_locked(&model, slot0 as u32 + 1)))
})?,
)?;
@@ -241,9 +273,39 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
#[cfg(test)]
mod tests {
use super::{auto_equip_cursor_item, equip_cursor_item, pickup_inventory_item};
use crate::script::cursor::{CursorAction, CursorPayload, CursorSpell, EQUIPMENT_BAG};
use crate::script::{ContainerMove, ContainerSlot, ContainerState, InvSlotView, UiScript};
/// `IsInventoryItemLocked`'s slot reader (`0x4c8520`) raises for any id outside {0, 1-23,
/// 40-69, 82-113}, a negative included (`0x4c8e7c`); the ammo leg (0) answers nil; 99 is the
/// keyring's slot 98 and answers.
#[test]
fn is_inventory_item_locked_raises_outside_the_readers_whitelist() {
let s = UiScript::new().unwrap();
for id in ["-1", "24", "39", "70", "81", "114", "'x'", "nil"] {
let err = s
.eval::<String>(&format!(
"local ok, e = pcall(IsInventoryItemLocked, {id}) return ok and 'ok' or tostring(e)"
))
.unwrap();
assert!(
err.contains("Invalid inventory slot in IsInventoryItemLocked"),
"{id}: {err}"
);
}
for id in ["0", "1", "23", "40", "69", "82", "99", "113", "1.9"] {
assert_eq!(
s.eval::<i64>(&format!(
"return (function(...) return arg.n end)(IsInventoryItemLocked({id}))"
))
.unwrap(),
1,
"{id} answers one value"
);
}
}
/// Head (1), a ring in finger slot 11 that also fits 12, and Tabard (19).
fn doll_slots() -> crate::script::InventorySlots {
let mut slots: crate::script::InventorySlots = Default::default();
@@ -317,6 +379,7 @@ mod tests {
texture: Some("Interface\\Icons\\INV_Helmet_02".into()),
count: 1,
quality: Some(3),
inventory_type: 0,
item_id: 2000,
link: Some("|cff0070dd|Hitem:2000:0:0:0|h[Another Helm]|h|r".into()),
locked: false,
@@ -341,7 +404,7 @@ mod tests {
s.set_inventory_slots(doll_slots());
assert!(!s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap());
assert!(s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
assert!(pickup_inventory_item(&s.lua, 1));
let held = s.cursor_item().expect("picked up");
assert_eq!(
(held.bag, held.slot, held.item_id),
@@ -356,8 +419,8 @@ mod tests {
fn pickup_same_doll_slot_cancels_no_move() {
let mut s = UiScript::new().unwrap();
s.set_inventory_slots(doll_slots());
assert!(s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
assert!(s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
assert!(pickup_inventory_item(&s.lua, 1));
assert!(pickup_inventory_item(&s.lua, 1));
assert!(s.cursor_item().is_none());
assert!(s.take_container_moves().is_empty());
assert!(!s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap());
@@ -372,7 +435,7 @@ mod tests {
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(s.cursor_item().unwrap().equip_slots, vec![1]);
assert!(s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
assert!(pickup_inventory_item(&s.lua, 1));
assert!(s.cursor_item().is_none(), "a plain clear, no hop");
assert_eq!(
s.take_container_moves(),
@@ -394,7 +457,7 @@ mod tests {
s.set_container(0, Some(one_fitting_bag_item()));
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(!s.eval::<bool>("return PickupInventoryItem(2)").unwrap());
assert!(!pickup_inventory_item(&s.lua, 2));
let held = s.cursor_item().expect("kept — doesn't fit slot 2");
assert_eq!(held.item_id, 2000);
assert!(s.take_container_moves().is_empty());
@@ -405,10 +468,10 @@ mod tests {
let mut s = UiScript::new().unwrap();
s.set_inventory_slots(doll_slots());
assert!(s.eval::<bool>("return PickupInventoryItem(11)").unwrap());
assert!(pickup_inventory_item(&s.lua, 11));
assert_eq!(s.cursor_item().unwrap().equip_slots, vec![11, 12]);
assert!(s.eval::<bool>("return PickupInventoryItem(12)").unwrap());
assert!(pickup_inventory_item(&s.lua, 12));
assert!(s.cursor_item().is_none());
assert_eq!(
s.take_container_moves(),
@@ -434,7 +497,7 @@ mod tests {
let held = s.cursor_item().expect("a split carry");
assert_eq!(held.count, Some(2));
assert!(!s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
assert!(!pickup_inventory_item(&s.lua, 1));
let held = s.cursor_item().expect("kept — can't equip a partial stack");
assert_eq!(held.count, Some(2));
assert!(s.take_container_moves().is_empty());
@@ -473,8 +536,8 @@ mod tests {
s.set_inventory_slots(slots);
// Ammo (0) is refused; Bag0Slot (20) picks up the equipped bag.
assert!(!s.eval::<bool>("return PickupInventoryItem(0)").unwrap());
assert!(s.eval::<bool>("return PickupInventoryItem(20)").unwrap());
assert!(!pickup_inventory_item(&s.lua, 0));
assert!(pickup_inventory_item(&s.lua, 20));
let held = s.cursor_item().expect("bag picked up from the bar");
assert_eq!(
(held.bag, held.slot, held.item_id),
@@ -498,6 +561,7 @@ mod tests {
texture: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
count: 1,
quality: Some(1),
inventory_type: 0,
item_id: 4496,
link: Some("|cffffffff|Hitem:4496:0:0:0|h[Small Brown Pouch]|h|r".into()),
locked: false,
@@ -520,7 +584,7 @@ mod tests {
s.run("PickupContainerItem(0, 3)").unwrap();
assert!(s.eval::<bool>("return CursorCanGoInSlot(21)").unwrap());
assert!(s.eval::<bool>("return PickupInventoryItem(21)").unwrap());
assert!(pickup_inventory_item(&s.lua, 21));
assert!(s.cursor_item().is_none(), "a plain clear on place");
assert_eq!(
s.take_container_moves(),
@@ -570,7 +634,7 @@ mod tests {
s.set_container(0, Some(one_fitting_bag_item()));
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.eval::<bool>("return AutoEquipCursorItem()").unwrap());
assert!(auto_equip_cursor_item(&mut s.model_mut()));
assert!(s.cursor_item().is_none());
assert_eq!(s.take_container_autoequips(), vec![(0, 1)]);
}
@@ -581,7 +645,7 @@ mod tests {
s.set_inventory_slots(doll_slots());
s.run("PickupInventoryItem(1)").unwrap();
assert!(!s.eval::<bool>("return AutoEquipCursorItem()").unwrap());
assert!(!auto_equip_cursor_item(&mut s.model_mut()));
assert!(s.cursor_item().is_some(), "kept");
assert!(s.take_container_autoequips().is_empty());
s.run("ClearCursor()").unwrap();
@@ -590,7 +654,7 @@ mod tests {
state.slots.get_mut(&1).unwrap().count = 5;
s.set_container(0, Some(state));
s.run("SplitContainerItem(0, 1, 2)").unwrap();
assert!(!s.eval::<bool>("return AutoEquipCursorItem()").unwrap());
assert!(!auto_equip_cursor_item(&mut s.model_mut()));
assert!(s.cursor_item().is_some(), "kept");
assert!(s.take_container_autoequips().is_empty());
}
@@ -633,8 +697,8 @@ mod tests {
}));
s.run("ShowRepairCursor()").unwrap();
assert!(!s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
assert!(!s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
assert!(!pickup_inventory_item(&s.lua, 1));
assert!(!pickup_inventory_item(&s.lua, 1));
s.run("UseInventoryItem(1)").unwrap();
assert!(s.cursor_item().is_none());
assert_eq!(s.take_inventory_repairs(), vec![1, 1, 1]);
@@ -723,7 +787,7 @@ mod tests {
s.set_container(0, Some(one_fitting_bag_item()));
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.eval::<bool>("return EquipCursorItem(1)").unwrap());
assert!(equip_cursor_item(&s.lua, 1));
assert!(s.cursor_item().is_none());
assert_eq!(
s.take_container_moves(),
@@ -746,7 +810,7 @@ mod tests {
s.set_container(0, Some(one_fitting_bag_item()));
// Unarmed, both seams do their ordinary gesture and queue nothing.
assert!(s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
assert!(pickup_inventory_item(&s.lua, 1));
assert!(s.cursor_item().is_some());
assert!(s.take_item_picks().is_empty());
@@ -761,7 +825,7 @@ mod tests {
// Armed with an empty cursor: both seams bind and leave the cursor empty.
s.run("ClearCursor()").unwrap();
assert!(s.cursor_item().is_none());
assert!(!s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
assert!(!pickup_inventory_item(&s.lua, 1));
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(
s.take_item_picks(),
+414 -36
View File
@@ -3,9 +3,32 @@
use crate::script::Model;
use crate::widget::FrameHandle;
/// The drag-start distance in pixels, exceeded strictly; the reference starts at 0.01 frame units
/// or more from the press (`0x81c468`, squared by `0x76b300`).
pub(crate) const DRAG_START_THRESHOLD: f32 = 4.0;
/// The drag-start threshold, 0.01 layout units squared: `0x76b300` stores `0.01f × 0.01f`
/// (`0x81c468` = `0.01f`) in `[0xcf0c14]` as an f32.
const DRAG_START_DISTANCE_SQ: f32 = 0.01 * 0.01;
/// Whether `pos` is far enough from the press point `start` to start the drag (`0x76b34c`–`0x76b372`).
/// The reference measures in layout units, where the screen diagonal is 1 (`0x41ad80`, from the
/// press and move converters `0x425666`/`0x425766`), so the pointer, in root units, is divided by
/// [`Model::layout_unit`]: 1% of the diagonal at every screen size and UI scale. The squared
/// distance is taken in `f64` against the f32 threshold (x87 at 53 bits: `WinMain`'s
/// `_controlfp(0x9001f)`, `D3DCREATE_FPU_PRESERVE`), and only a smaller one skips (`0x76b372`
/// `jne`), so a NaN, which also sets the carry flag, skips too.
fn past_start_distance(model: &Model, start: (f32, f32), pos: (f32, f32)) -> bool {
let unit = model.layout_unit();
let axis = |a: f32, b: f32| f64::from(a / unit) - f64::from(b / unit);
let (dx, dy) = (axis(pos.0, start.0), axis(pos.1, start.1));
dx * dx + dy * dy >= f64::from(DRAG_START_DISTANCE_SQ)
}
/// The event button bits in order, `1`..`0x10` (`0x76bf70`'s name table).
const HELD_ORDER: [&str; 5] = [
"LeftButton",
"MiddleButton",
"RightButton",
"Button4",
"Button5",
];
/// A drag armed at a press on a [`Model::drag_registered`] frame, `started` past the threshold.
#[derive(Clone, Debug, PartialEq)]
@@ -16,29 +39,57 @@ pub(crate) struct DragGesture {
pub(crate) started: bool,
}
/// A gesture whose button matched the release; [`Model::drag`] is cleared either way.
/// A gesture whose frame's drag mask held the released button; [`Model::drag`] is cleared either way.
pub(crate) struct DragRelease {
/// `OnDragStart` fired; an unstarted release fires nothing and the click proceeds.
pub(crate) started: bool,
pub(crate) source: FrameHandle,
}
/// Arm or clear the gesture on a press, replacing any leftover one; the caller runs
/// [`abandon_drag`] first so a started gesture still gets its `OnDragStop`.
pub(crate) fn arm_drag(model: &mut Model, hit: Option<FrameHandle>, button: &str, pos: (f32, f32)) {
model.drag = hit
.filter(|&h| {
model
.drag_registered
.get(&h)
.is_some_and(|set| set.iter().any(|b| b.eq_ignore_ascii_case(button)))
})
.map(|h| DragGesture {
/// The press arm `0x76bf70`, run on the frame the press lands on (the capture, else the hover). A
/// button in the frame's drag mask (`0x76bf7d`) re-arms: armed, not started, this button, this
/// point (`0x76bf82`..`0x76bfab`), with no `OnDragStop` for a started drag it replaces. Any other
/// button skips the arm (`0x76bf80` je `0x76bfb1`) and leaves the armed or started drag alone.
///
/// The reference keeps this state per frame and benilla has the one [`Model::drag`], which holds
/// the same thing because a press always lands on the capture, the frame of any live gesture. A
/// gesture on another frame, or one whose button's release was lost (`release_lost`; the
/// reference cannot lose one), can no longer be moved or released there, so it is abandoned and
/// the returned source of a started one must get `OnDragStop`, or a frame it is moving follows the
/// cursor.
pub(crate) fn press_drag(
model: &mut Model,
target: Option<FrameHandle>,
button: &str,
pos: (f32, f32),
release_lost: bool,
) -> Option<FrameHandle> {
let elsewhere = model
.drag
.as_ref()
.is_some_and(|g| Some(g.source) != target);
let displaced = if release_lost || elsewhere {
abandon_drag(model)
} else {
None
};
if let Some(h) = target.filter(|h| is_drag_button(model, *h, button)) {
model.drag = Some(DragGesture {
button: button.to_string(),
source: h,
start: pos,
started: false,
});
}
displaced
}
/// Whether `button` is in the frame's `RegisterForDrag` mask (`[+0xfc]`).
fn is_drag_button(model: &Model, frame: FrameHandle, button: &str) -> bool {
model
.drag_registered
.get(&frame)
.is_some_and(|set| set.iter().any(|b| b.eq_ignore_ascii_case(button)))
}
/// Start the armed gesture once the cursor passes the threshold, returning the `OnDragStart`
@@ -49,12 +100,22 @@ pub(crate) fn maybe_start_drag(model: &mut Model, pos: (f32, f32)) -> Option<(u3
if g.started {
return None;
}
let (sx, sy) = g.start;
let dist = ((pos.0 - sx).powi(2) + (pos.1 - sy).powi(2)).sqrt();
if dist <= DRAG_START_THRESHOLD {
if !past_start_distance(model, g.start, pos) {
return None;
}
(g.source, g.button.clone())
// `OnDragStart`'s argument is the lowest button of the move event's held mask
// (`0x76c1e0` over `[evt+0x18]`), not the armed one: a chord re-armed by the later button
// starts as the earlier. The held set is the buttons whose press took a frame.
let held = HELD_ORDER.iter().find(|name| {
model
.mouse_down_on
.keys()
.any(|k| k.eq_ignore_ascii_case(name))
});
(
g.source,
held.map_or_else(|| g.button.clone(), |name| (*name).to_string()),
)
};
model.drag.as_mut().expect("checked Some above").started = true;
// A dragged button un-presses at the threshold, not on leaving its rect: `CSimpleButton`'s
@@ -68,18 +129,22 @@ pub(crate) fn maybe_start_drag(model: &mut Model, pos: (f32, f32)) -> Option<(u3
Some((id, button))
}
/// Drop a gesture whose release will never come, because the pointer left the window or a second
/// button was pressed; the reference cannot lose one, as the OS captures the pointer. Returns a
/// started one's source, which must get `OnDragStop` or a frame it is moving follows the cursor.
/// Drop a gesture whose release will never come, because the pointer left the window; the
/// reference cannot lose one, as the OS captures the pointer. Returns a started one's source,
/// which must get `OnDragStop` or a frame it is moving follows the cursor.
pub(crate) fn abandon_drag(model: &mut Model) -> Option<FrameHandle> {
model.drag.take().filter(|g| g.started).map(|g| g.source)
}
/// Take the gesture whose button matches the release.
/// Take the gesture a release ends. `0x76c050` tests the released button against the frame's
/// drag mask, not the armed button `[+0x108]`, so any mask button's release clears the arm
/// (`0x76c05d`) and, with the drag started, is the drag release (`0x76c06e`).
pub(crate) fn take_drag(model: &mut Model, button: &str) -> Option<DragRelease> {
let g = model
.drag
.take_if(|g| g.button.eq_ignore_ascii_case(button))?;
let source = model.drag.as_ref()?.source;
if !is_drag_button(model, source, button) {
return None;
}
let g = model.drag.take()?;
Some(DragRelease {
started: g.started,
source: g.source,
@@ -121,17 +186,17 @@ mod tests {
s.resolve();
s.mouse_button(100.0, 300.0, "LeftButton", true); // press inside A
s.mouse_move(102.0, 300.0); // 2px: under the threshold
s.mouse_move(102.0, 300.0); // 2 units: under the threshold
assert_eq!(s.eval::<i64>("return drag_starts").unwrap(), 0);
s.mouse_move(110.0, 300.0); // 10px from the press: starts
s.mouse_move(120.0, 300.0); // 20 units from the press: starts
assert_eq!(s.eval::<i64>("return drag_starts").unwrap(), 1);
assert_eq!(
s.eval::<String>("return drag_button").unwrap(),
"LeftButton"
);
s.mouse_move(120.0, 300.0); // further movement doesn't re-fire
s.mouse_move(140.0, 300.0); // further movement doesn't re-fire
assert_eq!(s.eval::<i64>("return drag_starts").unwrap(), 1);
s.mouse_move(600.0, 300.0); // over B now
@@ -177,12 +242,107 @@ mod tests {
s.resolve();
s.mouse_button(100.0, 300.0, "LeftButton", true);
s.mouse_move(110.0, 300.0); // starts the drag
s.mouse_move(120.0, 300.0); // starts the drag
s.mouse_button(100.0, 300.0, "LeftButton", false); // released back on A
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 0);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A frame whose every mouse script appends to `log`, registered for a LeftButton drag.
fn logged_drag_frame() -> UiScript {
let mut s = drag_script();
s.run(
r#"
log = {}
local function note(name) return function() table.insert(log, name) end end
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true)
a:RegisterForDrag("LeftButton")
a:SetScript("OnMouseDown", note("down"))
a:SetScript("OnMouseUp", note("up"))
a:SetScript("OnDragStart", note("dragstart"))
a:SetScript("OnDragStop", note("dragstop"))
a:SetScript("OnReceiveDrag", note("receive"))
"#,
)
.unwrap();
s.resolve();
s
}
fn log(s: &UiScript) -> String {
s.eval::<String>("return table.concat(log, ' ')").unwrap()
}
/// `0x76c040` on a release for a drag-registered button whose drag has started clears the arm,
/// fires `OnDragStop` (`0x76c06e`) and the hover frame's drop (`0x76c081`) and returns 1
/// (`0x76c08f`), never reaching its `OnMouseUp` (`0x76c099`).
#[test]
fn a_drag_release_fires_dragstop_and_no_mouseup() {
let mut s = logged_drag_frame();
s.mouse_button(100.0, 300.0, "LeftButton", true);
s.mouse_move(130.0, 300.0); // 30 units: past the threshold
s.mouse_button(130.0, 300.0, "LeftButton", false);
assert_eq!(log(&s), "down dragstart dragstop receive");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A press that never crossed the threshold falls through to `OnMouseUp` (`0x76c067` je).
#[test]
fn a_plain_click_on_a_drag_frame_fires_mouseup() {
let mut s = logged_drag_frame();
s.mouse_button(100.0, 300.0, "LeftButton", true);
s.mouse_button(100.0, 300.0, "LeftButton", false);
assert_eq!(log(&s), "down up");
}
/// The gate `0x76c050` tests the released button against the frame's registered buttons: a
/// frame registered for LeftButton drag still gets `OnMouseUp` for a RightButton release.
#[test]
fn a_button_not_registered_for_drag_still_fires_mouseup() {
let mut s = logged_drag_frame();
s.mouse_button(100.0, 300.0, "RightButton", true);
s.mouse_move(130.0, 300.0);
s.mouse_button(130.0, 300.0, "RightButton", false);
assert_eq!(log(&s), "down up");
}
/// A stock resize grip starts sizing in `OnMouseDown` and is not drag-registered, so its
/// `OnMouseUp` (which stops the sizing) fires even after the cursor left it.
#[test]
fn a_resize_grip_still_gets_mouseup() {
let mut s = drag_script();
s.run(
r#"
ups = 0
local f = CreateFrame("Frame", "Sized", UIParent)
f:SetResizable(true)
f:SetPoint("BOTTOMLEFT", 100, 100); f:SetWidth(200); f:SetHeight(100)
local g = CreateFrame("Button", "Grip", f)
g:SetPoint("BOTTOMRIGHT", 0, 0); g:SetWidth(16); g:SetHeight(16)
g:EnableMouse(true)
g:SetScript("OnMouseDown", function() Sized:StartSizing("BOTTOMRIGHT") end)
g:SetScript("OnMouseUp", function() ups = ups + 1; Sized:StopMovingOrSizing() end)
"#,
)
.unwrap();
s.resolve();
s.mouse_button(295.0, 105.0, "LeftButton", true);
s.mouse_move(395.0, 55.0); // dragged 100 right, 50 down, off the grip
s.mouse_button(395.0, 55.0, "LeftButton", false);
s.resolve();
assert_eq!(s.eval::<i64>("return ups").unwrap(), 1);
assert_eq!(s.eval::<f32>("return Sized:GetWidth()").unwrap(), 300.0);
s.mouse_move(500.0, 300.0);
s.resolve();
assert_eq!(
s.eval::<f32>("return Sized:GetWidth()").unwrap(),
300.0,
"the sizing ended on the release"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn drag_not_started_leaves_the_ordinary_click_path_untouched() {
let mut s = drag_script();
@@ -473,6 +633,45 @@ mod tests {
assert!(s.cursor_item().is_some());
}
/// A press whose frame hid (the removal cleared the capture, `0x764cad`) reaches no one on the
/// release: `0x766420` dispatches to the capture alone, and the world hook `0x492b50` runs
/// only in freelook, so the world click `0x495300` never runs.
#[test]
fn a_press_whose_frame_hid_is_not_a_world_drop() {
let mut s = drag_script();
s.run(
r#"
WorldFrame = CreateFrame("WorldFrame", "WorldFrame") WorldFrame:SetAllPoints()
heard = 0
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true)
a:SetScript("OnMouseDown", function() A:Hide() end)
local f = CreateFrame("Frame", "Listener")
f:RegisterEvent("DELETE_ITEM_CONFIRM")
f:SetScript("OnEvent", function() heard = heard + 1 end)
"#,
)
.unwrap();
s.resolve();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 1,
item_id: 117,
texture: None,
link: None,
count: None,
quality: None,
equip_slots: Vec::new(),
}));
s.mouse_button(100.0, 300.0, "LeftButton", true); // press on A, which hides itself
s.mouse_button(600.0, 300.0, "LeftButton", false); // release over the world
s.tick(0.01);
assert_eq!(s.eval::<i64>("return heard").unwrap(), 0, "no popup");
assert!(s.cursor_item().is_some());
}
/// A non-item payload's world click clears it silently (`0x495300`); a drag release keeps it.
#[test]
fn world_click_with_a_spell_payload_clears_silently() {
@@ -559,18 +758,22 @@ mod tests {
assert_eq!(s.eval::<i64>("return stops").unwrap(), 1);
}
/// Each press runs the arm `0x76bf70` on the capture: a button outside the frame's drag mask
/// (`0x76bf7d` je `0x76bfb1`) skips the arm and touches neither `[+0x100]` nor `[+0x104]`, so
/// the started left drag runs on and only its own release (`0x76c040`) fires `OnDragStop`.
#[test]
fn a_press_that_replaces_an_in_flight_gesture_stops_it_first() {
fn a_press_of_a_button_the_frame_does_not_drag_leaves_the_drag_running() {
let mut s = drag_script();
s.run(
r#"
stops, starts = 0, 0
stops, starts, ups = 0, 0, 0
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 100, 100); a:SetWidth(200); a:SetHeight(100)
a:EnableMouse(true); a:SetMovable(true)
a:RegisterForDrag("LeftButton")
a:SetScript("OnDragStart", function() starts = starts + 1; this:StartMoving() end)
a:SetScript("OnDragStop", function() stops = stops + 1; this:StopMovingOrSizing() end)
a:SetScript("OnMouseUp", function() ups = ups + 1 end)
"#,
)
.unwrap();
@@ -583,13 +786,188 @@ mod tests {
s.mouse_button(200.0, 200.0, "RightButton", true);
assert_eq!(
s.eval::<i64>("return stops").unwrap(),
0,
"the right press does not touch the left drag"
);
s.mouse_move(250.0, 250.0);
s.mouse_move(300.0, 300.0);
s.resolve();
assert_eq!(
s.eval::<f32>("return A:GetLeft()").unwrap(),
200.0,
"the frame still follows the cursor"
);
assert_eq!(
s.eval::<i64>("return starts").unwrap(),
1,
"the left drag the right press displaced was stopped, not forgotten"
"no second start"
);
// Replacing an unstarted press fires nothing, as releasing it would not.
s.mouse_button(150.0, 150.0, "LeftButton", true);
s.mouse_button(150.0, 150.0, "MiddleButton", true);
// The right release is no drag release (`0x76c050` je `0x76c099`): its `OnMouseUp` fires.
s.mouse_button(300.0, 300.0, "RightButton", false);
assert_eq!(s.eval::<i64>("return stops").unwrap(), 0);
assert_eq!(s.eval::<i64>("return ups").unwrap(), 1);
s.mouse_button(300.0, 300.0, "LeftButton", false);
assert_eq!(
s.eval::<i64>("return stops").unwrap(),
1,
"the left release ends the drag"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A frame registered for both buttons.
fn logged_two_button_drag_frame() -> UiScript {
let mut s = drag_script();
s.run(
r#"
log = {}
local function note(name) return function() table.insert(log, name) end end
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true)
a:RegisterForDrag("LeftButton", "RightButton")
a:SetScript("OnMouseDown", note("down"))
a:SetScript("OnMouseUp", note("up"))
a:SetScript("OnDragStart", note("dragstart"))
a:SetScript("OnDragStop", note("dragstop"))
a:SetScript("OnReceiveDrag", note("receive"))
"#,
)
.unwrap();
s.resolve();
s
}
/// A press of a registered button re-arms (`0x76bf82`..`0x76bfab`: armed 1, started 0, the new
/// button and start point) and fires no `OnDragStop`. `0x76b320` then starts again from the new
/// point (`0x76b34c`), a second `OnDragStart`; the release of a mask button ends a started
/// drag whichever button armed it (`0x76c050`..`0x76c06e` read the released button only).
#[test]
fn a_press_of_a_registered_button_re_arms_without_a_stop() {
let mut s = logged_two_button_drag_frame();
s.mouse_button(100.0, 300.0, "LeftButton", true);
s.mouse_move(130.0, 300.0);
assert_eq!(log(&s), "down dragstart");
s.mouse_button(130.0, 300.0, "RightButton", true);
assert_eq!(log(&s), "down dragstart down", "no OnDragStop");
s.mouse_move(132.0, 300.0); // 2px from the new arm point: under the threshold
assert_eq!(log(&s), "down dragstart down");
s.mouse_move(160.0, 300.0); // past it: started again
assert_eq!(log(&s), "down dragstart down dragstart");
s.mouse_button(160.0, 300.0, "RightButton", false);
assert_eq!(
log(&s),
"down dragstart down dragstart dragstop receive",
"the release ends the started drag, no OnMouseUp"
);
// The drag is over: the left release finds it unarmed and is a plain release.
s.mouse_button(160.0, 300.0, "LeftButton", false);
assert_eq!(log(&s), "down dragstart down dragstart dragstop receive up");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `0x76c050` tests the released button against the mask, not `[+0x108]`: the first button's
/// release after the re-arm clears the arm (`0x76c05d`), and with the drag not restarted
/// (`[+0x104]` 0, `0x76c067` je `0x76c099`) is a plain `OnMouseUp`. Nothing starts afterwards.
#[test]
fn the_first_buttons_release_after_a_re_arm_disarms_it() {
let mut s = logged_two_button_drag_frame();
s.mouse_button(100.0, 300.0, "LeftButton", true);
s.mouse_move(130.0, 300.0);
s.mouse_button(130.0, 300.0, "RightButton", true);
s.mouse_button(130.0, 300.0, "LeftButton", false);
assert_eq!(log(&s), "down dragstart down up", "no OnDragStop");
s.mouse_move(200.0, 300.0); // the arm is cleared: the right button drags nothing
assert_eq!(log(&s), "down dragstart down up");
s.mouse_button(200.0, 300.0, "RightButton", false);
assert_eq!(log(&s), "down dragstart down up up");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `OnDragStart`'s button is not the armed one: `0x76c1e0` names the lowest bit of the move
/// event's held mask (`[evt+0x18]`, the producer `0x423fe0` stores `ds:0x883f40` there), so
/// with Left still held a drag re-armed by Right starts as `LeftButton`.
#[test]
fn on_drag_start_names_the_lowest_held_button() {
let mut s = drag_script();
s.run(
r#"
names = {}
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true)
a:RegisterForDrag("LeftButton", "RightButton")
a:SetScript("OnDragStart", function() table.insert(names, arg1) end)
"#,
)
.unwrap();
s.resolve();
s.mouse_button(100.0, 300.0, "LeftButton", true);
s.mouse_move(130.0, 300.0);
s.mouse_button(130.0, 300.0, "RightButton", true); // re-arms for Right, Left still held
s.mouse_move(160.0, 300.0);
s.mouse_button(160.0, 300.0, "RightButton", false);
s.mouse_button(160.0, 300.0, "LeftButton", false);
s.mouse_button(160.0, 300.0, "RightButton", true); // Right alone
s.mouse_move(200.0, 300.0);
assert_eq!(
s.eval::<String>("return table.concat(names, ' ')").unwrap(),
"LeftButton LeftButton RightButton"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The cursor payload is no part of the arm: a right click during an item drag leaves the
/// carried item and the drag, and the left release still drops on the frame under the cursor.
#[test]
fn an_item_drag_survives_a_right_click() {
let mut s = drag_script();
s.run(
r#"
stops, receives = 0, 0
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true)
a:RegisterForDrag("LeftButton")
a:SetScript("OnDragStop", function() stops = stops + 1 end)
local b = CreateFrame("Frame", "B")
b:SetPoint("BOTTOMLEFT", 400, 0); b:SetWidth(400); b:SetHeight(600); b:EnableMouse(true)
b:SetScript("OnReceiveDrag", function() receives = receives + 1 end)
"#,
)
.unwrap();
s.resolve();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 1,
item_id: 117,
texture: None,
link: None,
count: None,
quality: None,
equip_slots: Vec::new(),
}));
s.mouse_button(100.0, 300.0, "LeftButton", true);
s.mouse_move(110.0, 300.0); // the drag starts
s.mouse_button(110.0, 300.0, "RightButton", true);
s.mouse_button(110.0, 300.0, "RightButton", false);
assert_eq!(s.eval::<i64>("return stops").unwrap(), 0);
assert!(s.cursor_item().is_some(), "the item is still carried");
s.mouse_move(600.0, 300.0);
s.mouse_button(600.0, 300.0, "LeftButton", false);
assert_eq!(s.eval::<i64>("return stops").unwrap(), 1);
assert_eq!(
s.eval::<i64>("return receives").unwrap(),
1,
"the drop reaches the frame under the cursor"
);
assert!(s.cursor_item().is_some(), "a drag release never drops");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
}
+106 -67
View File
@@ -1,11 +1,10 @@
//! The money cursor, payload mode 2: coins taken off a money frame; the purse is never debited.
//! Deviation: a `CURSOR_UPDATE` handler already sees the coins, because events are queued; the
//! reference runs it inside the clear, before they land.
use mlua::{Lua, Value};
use super::{queue_cursor_update, CursorMoney, CursorPayload};
use super::{clear, clear_cursor, set_cursor, Clear, CursorMoney, CursorPayload};
use crate::script::binding_abi::{flag, number_arg};
use crate::script::tick::verb;
use crate::script::Model;
/// The coin icon for an amount of copper: `GetCoinIcon`'s table (`0x48d4e0`), thresholds signed.
@@ -26,6 +25,12 @@ pub fn coin_icon(copper: i64) -> &'static str {
}
}
/// Raise `PLAYER_MONEY`, which repaints the money frames less the held coins: the coin arm of
/// `ClearCursor` (`0x495248`) and the money setter's tail (`0x494d0a`).
pub(super) fn raise_player_money(model: &mut Model) {
model.raise("PLAYER_MONEY", Vec::new());
}
pub(crate) fn cursor_money(model: &Model) -> u32 {
match &model.cursor {
Some(CursorPayload::Money(m)) => m.copper,
@@ -33,42 +38,35 @@ pub(crate) fn cursor_money(model: &Model) -> u32 {
}
}
/// Queue `PLAYER_MONEY`, which repaints the money frames less the held coins.
fn queue_player_money(model: &mut Model) {
model
.pending_events
.push(("PLAYER_MONEY".to_string(), Vec::new()));
}
/// Put coins on the cursor as `0x494cc0` does: the inner clear's `CURSOR_UPDATE`, after its own
/// `PLAYER_MONEY` if coins were already held, then the payload and `PLAYER_MONEY`.
fn install_money(model: &mut Model, copper: u32) {
if matches!(model.cursor, Some(CursorPayload::Money(_))) {
queue_player_money(model);
}
model.cursor = None;
queue_cursor_update(model);
model.cursor = Some(CursorPayload::Money(CursorMoney { copper }));
queue_player_money(model);
/// Put coins on the cursor as `0x494cc0` does. Its inner `ClearCursor(1,1)` (`0x494cd9`) drops
/// whatever is held with that arm's events, an item's unlock or coins' `PLAYER_MONEY`, and fires
/// `CURSOR_UPDATE` (`0x4952cc`) on the empty cursor, so that handler sees no coins; then the coins
/// land and `PLAYER_MONEY` fires (`0x494d0a`), with no second `CURSOR_UPDATE`.
fn install_money(lua: &Lua, copper: u32) {
set_cursor(lua, true, |_| {
Some(CursorPayload::Money(CursorMoney { copper }))
});
}
/// `PickupPlayerMoney` after the argument gate; each refusal is silent.
pub(crate) fn pickup_player_money(model: &mut Model, copper: u32) -> bool {
if copper == 0 || !model.player_control || u64::from(copper) > model.money {
return false;
pub(crate) fn pickup_player_money(lua: &Lua, copper: u32) -> bool {
let allowed = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
copper != 0 && model.player_control && u64::from(copper) <= model.money
};
if allowed {
install_money(lua, copper);
}
install_money(model, copper);
true
allowed
}
/// `DropCursorMoney`: `PLAYER_MONEY`, then the clear's `CURSOR_UPDATE`, and no packet.
/// `DropCursorMoney` (`0x48aba0`), `SetCursorMoney(0)`: its `ClearCursor(1,1)` fires the coin
/// arm's `PLAYER_MONEY`, then `CURSOR_UPDATE`, and no packet.
pub(crate) fn drop_cursor_money(model: &mut Model) -> bool {
if !matches!(model.cursor, Some(CursorPayload::Money(_))) || !model.player_control {
return false;
}
model.cursor = None;
queue_player_money(model);
queue_cursor_update(model);
clear_cursor(model);
true
}
@@ -89,26 +87,30 @@ pub(crate) fn add_trade_money(model: &mut Model) -> bool {
if let Some(t) = model.trade.as_mut() {
t.player.gold = total;
}
// `ClearCursor(1, 0)`: no PLAYER_MONEY, the clear's own CURSOR_UPDATE.
model.cursor = None;
queue_cursor_update(model);
// `ClearCursor(1,0)` (`0x4bfe4b`): no PLAYER_MONEY, the clear's own CURSOR_UPDATE.
clear(model, Clear::NO_MONEY_EVENT);
true
}
/// `PickupTradeMoney` after the argument gate: signed gates, then `CMSG_SET_TRADE_GOLD` (`0x11F`)
/// with the trimmed offer, then the coins onto the cursor.
pub(crate) fn pickup_trade_money(model: &mut Model, amount: i32) -> bool {
let offer = model.trade.as_ref().map_or(0, |t| t.player.gold) as i32;
if amount <= 0 || amount > offer {
return false;
pub(crate) fn pickup_trade_money(lua: &Lua, amount: i32) -> bool {
let trimmed = verb(lua, |model| {
let offer = model.trade.as_ref().map_or(0, |t| t.player.gold) as i32;
if amount <= 0 || amount > offer {
return false;
}
let left = (offer - amount) as u32;
model.trade_set_money = Some(left);
if let Some(t) = model.trade.as_mut() {
t.player.gold = left;
}
true
});
if trimmed {
install_money(lua, amount as u32);
}
let left = (offer - amount) as u32;
model.trade_set_money = Some(left);
if let Some(t) = model.trade.as_mut() {
t.player.gold = left;
}
install_money(model, amount as u32);
true
trimmed
}
impl crate::script::UiScript {
@@ -139,24 +141,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"PickupPlayerMoney",
lua.create_function(|lua, amount: Value| {
let n = number_arg(lua, amount, "Usage: PickupPlayerMoney(amount)")? as u32;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_player_money(&mut model, n);
pickup_player_money(lua, n);
Ok(())
})?,
)?;
g.set(
"DropCursorMoney",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
drop_cursor_money(&mut model);
verb(lua, drop_cursor_money);
Ok(())
})?,
)?;
g.set(
"AddTradeMoney",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
add_trade_money(&mut model);
verb(lua, add_trade_money);
Ok(())
})?,
)?;
@@ -164,8 +163,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"PickupTradeMoney",
lua.create_function(|lua, amount: Value| {
let n = number_arg(lua, amount, "Usage: PickupTradeMoney(amount)")?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_trade_money(&mut model, n);
pickup_trade_money(lua, n);
Ok(())
})?,
)?;
@@ -183,18 +181,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
mod tests {
use super::coin_icon;
use crate::script::cursor::CursorPayload;
use crate::script::tick::heard;
use crate::script::UiScript;
fn events(s: &mut UiScript) -> Vec<String> {
s.run(
r#"EV = {} F = F or CreateFrame("Frame") F:RegisterEvent("PLAYER_MONEY")
F:RegisterEvent("CURSOR_UPDATE") F:SetScript("OnEvent", function() table.insert(EV, event) end)"#,
)
.unwrap();
s.tick(0.0);
s.eval::<Vec<String>>("return EV").unwrap()
}
#[test]
fn coins_ride_the_cursor_without_leaving_the_purse() {
let mut s = UiScript::new().unwrap();
@@ -251,25 +240,75 @@ mod tests {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The events fire inside each verb, in `0x494cc0`'s order: the inner clear's `CURSOR_UPDATE`
/// reads an empty cursor, and only the install's `PLAYER_MONEY` reads the new coins.
#[test]
fn the_pickup_and_drop_fire_player_money_around_the_clear() {
let mut s = UiScript::new().unwrap();
s.set_money(1_000);
let _ = events(&mut s);
heard::listen(
&s,
&["PLAYER_MONEY", "CURSOR_UPDATE"],
Some("GetCursorMoney()"),
);
s.run("PickupPlayerMoney(10)").unwrap();
assert_eq!(
events(&mut s),
vec!["CURSOR_UPDATE", "PLAYER_MONEY"],
heard::take(&s),
["CURSOR_UPDATE 0", "PLAYER_MONEY 10"],
"the inner clear's CURSOR_UPDATE, then the money's PLAYER_MONEY"
);
s.run("PickupPlayerMoney(20)").unwrap();
assert_eq!(
events(&mut s),
vec!["PLAYER_MONEY", "CURSOR_UPDATE", "PLAYER_MONEY"],
heard::take(&s),
["PLAYER_MONEY 0", "CURSOR_UPDATE 0", "PLAYER_MONEY 20"],
"coins already held: the mode-2 arm's PLAYER_MONEY first"
);
s.run("DropCursorMoney()").unwrap();
assert_eq!(events(&mut s), vec!["PLAYER_MONEY", "CURSOR_UPDATE"]);
assert_eq!(heard::take(&s), ["PLAYER_MONEY 0", "CURSOR_UPDATE 0"]);
assert!(s.model_ref().pending_events.is_empty());
}
/// A handler that calls another verb runs it to completion inside the dispatch, as the
/// reference's synchronous `SignalEvent` nests: the mail verb's event lands between the
/// pickup's two steps, and a pickup called from the install's `PLAYER_MONEY` runs both of its
/// own steps before the outer verb returns. No step waits on a held model borrow.
#[test]
fn a_verb_called_from_a_handler_nests_inside_the_dispatch() {
let mut s = UiScript::new().unwrap();
s.set_money(1_000);
let log: Vec<String> = s
.eval(
r#"LOG = {} NEST = 0
local f = CreateFrame("Frame")
f:RegisterEvent("CURSOR_UPDATE")
f:RegisterEvent("PLAYER_MONEY")
f:RegisterEvent("SEND_MAIL_MONEY_CHANGED")
f:SetScript("OnEvent", function()
table.insert(LOG, event .. " " .. GetCursorMoney() .. " " .. GetSendMailMoney())
if event == "CURSOR_UPDATE" and NEST == 0 then
NEST = 1 SetSendMailMoney(5)
elseif event == "PLAYER_MONEY" and NEST == 1 then
NEST = 2 PickupPlayerMoney(20)
end
end)
PickupPlayerMoney(10)
return LOG"#,
)
.unwrap();
assert_eq!(
log,
[
"CURSOR_UPDATE 0 0",
"SEND_MAIL_MONEY_CHANGED 0 5",
"PLAYER_MONEY 10 5",
"PLAYER_MONEY 0 5",
"CURSOR_UPDATE 0 5",
"PLAYER_MONEY 20 5",
]
);
assert_eq!(s.eval::<i64>("return GetCursorMoney()").unwrap(), 20);
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(s.model_ref().pending_events.is_empty());
}
#[test]
+165 -81
View File
@@ -1,5 +1,5 @@
//! The pet bar's one drag verb, `PickupPetAction`: a pickup with an empty cursor, a drop with a
//! pet payload held (`PetActionBarFrame.lua:252-283`). Only a pet slot makes or takes the
//! The pet bar's one drag verb, `PickupPetAction`: a drop with a pet payload held, a pickup
//! otherwise (`PetActionBarFrame.lua:252-283`). Only a pet slot makes or takes the
//! payload, a word carried verbatim (`0x4bce00`), so a drag only rearranges the bar the server
//! sent. A drop clears the cursor before deciding (`0x495190`) and only a write that relocated
//! nothing refills it (`0x4bce38`), so a refused drop loses the payload, as in the reference.
@@ -9,9 +9,10 @@
use mlua::Lua;
use crate::script::tick::verb;
use crate::script::Model;
use super::{queue_cursor_update, CursorPayload, CursorPetAction};
use super::{clear_cursor, set_cursor, CursorPayload, CursorPetAction};
/// Pet bar slots (`NUM_PET_ACTION_SLOTS`, vmangos `MAX_UNIT_ACTION_BAR_INDEX`). Deviation: slot
/// 11 is refused, because the reference's gate (`cmp esi,0xa; jbe`) admits it and neither
@@ -94,70 +95,102 @@ fn assign(slots: &mut [u32], target: usize, source: u32, passive: bool) -> Optio
/// `PickupPetAction(slot)` (`0x4be180`), 1-based. A pet whose object does not resolve
/// (`0x4be1f7`) or is `UNIT_FLAG_POSSESSED` (`0x4be20a`) blocks both ends, the gate sitting above
/// the cursor fork, so the cursor is left as it was. Another payload stays held here, where the
/// reference clears it (`0x4be220`) and picks the slot up. A pickup blanks only a spell slot
/// (`0x4be268`), so a dragged token is copied, not moved.
pub(super) fn pickup_pet_action(model: &mut Model, slot: u32) -> bool {
if !model.pet_bar.pickup_allowed {
return false;
}
match &model.cursor {
Some(CursorPayload::PetAction(_)) => return place_pet_action(model, slot),
Some(_) => return false,
None => {}
}
let Some(index) = slot_index(model, slot) else {
return false;
};
let view = model.pet_bar.slots[index].view.clone();
let Some(packed) = payload_word(view.packed) else {
return false;
/// the cursor fork, so the cursor is left as it was. Past it the binding clears with
/// `ClearCursor(1,1)` (`0x4be220`), whatever is held, before anything is decided: a held pet
/// action drops, anything else is dropped and the slot picked up. The pickup is the pet-action
/// setter (`0x494e20`), whose own inner clear signals again on the empty cursor before the set
/// and its `PET_BAR_SHOWGRID`. A pickup blanks only a spell slot (`0x4be268`), so a dragged token
/// is copied, not moved; the blank comes after the setter's events, so they read the slot as it
/// was. Returns whether to repaint.
pub(super) fn pickup_pet_action(lua: &Lua, slot: u32) -> bool {
let held = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
if !model.pet_bar.pickup_allowed {
return false;
}
match &model.cursor {
Some(CursorPayload::PetAction(held)) => Some(held.clone()),
_ => None,
}
};
verb(lua, clear_cursor);
match held {
Some(held) => {
let (wrote, displaced) = verb(lua, |model| place_pet_action(model, slot, &held));
if let Some(displaced) = displaced {
set_pet_action(lua, displaced);
}
wrote
}
None => {
let picked = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
pick_pet_action(&model, slot)
};
let Some((payload, blank)) = picked else {
return false;
};
set_pet_action(lua, payload);
if let Some((index, word)) = blank {
verb(lua, |model| {
write_slot(model, index, word, false);
});
}
true
}
}
}
model.cursor = Some(CursorPayload::PetAction(CursorPetAction {
/// The pet-action setter (`0x494e20`): a token's icon is resolved before its inner clear, then
/// the clear and the set run as their own segments ([`set_cursor`]).
fn set_pet_action(lua: &Lua, mut payload: CursorPetAction) {
resolve_token_icon(lua, &mut payload);
set_cursor(lua, true, |_| Some(CursorPayload::PetAction(payload)));
}
/// The slot's word as a payload past the binding's clear (`0x4be23e`): the bar must exist and the
/// word be one the setter takes. A spell slot also owes its blank, `(index, word)`: it keeps its
/// type and autocast bits with id 0, and the server is told. `false` is right for that write: the
/// passive gate reads the source's spell record, and id 0 has none.
fn pick_pet_action(model: &Model, slot: u32) -> Option<(CursorPetAction, Option<(usize, u32)>)> {
let index = slot_index(model, slot)?;
let view = &model.pet_bar.slots[index].view;
let packed = payload_word(view.packed)?;
let payload = CursorPetAction {
src_slot: slot,
packed,
passive: view.passive,
texture: view.texture.clone(),
}));
// A spell slot keeps its type and autocast bits with id 0, and the server is told. `false` is
// right: the passive gate reads the source's spell record, and id 0 has none.
if kind(packed) == 1 {
write_slot(model, index, packed & 0xFFFF_0000, false);
}
queue_cursor_update(model);
true
};
Some((
payload,
(kind(packed) == 1).then_some((index, packed & 0xFFFF_0000)),
))
}
/// The drop: the cursor always empties, and only a homeless occupant refills it (`0x4bce3d`).
/// Returns whether anything was written; the cursor empties on `false` too.
fn place_pet_action(model: &mut Model, slot: u32) -> bool {
let Some(CursorPayload::PetAction(held)) = model.cursor.clone() else {
return false;
};
// `ClearCursor(1,1)`, unconditionally, before anything is decided.
model.cursor = None;
/// The drop past its clear: the bar write (`0x4bce00`). Only a homeless occupant goes back on the
/// cursor (`0x4bce3d`), through the setter as a pickup does. Returns whether anything was
/// written, and that occupant.
fn place_pet_action(
model: &mut Model,
slot: u32,
held: &CursorPetAction,
) -> (bool, Option<CursorPetAction>) {
let Some(index) = slot_index(model, slot) else {
queue_cursor_update(model);
return false;
return (false, None);
};
// Read before the write: a displaced word's passive bit and icon are its slot's.
let occupant_view = model.pet_bar.slots[index].view.clone();
let Some(assigned) = write_slot(model, index, held.packed, held.passive) else {
queue_cursor_update(model);
return false;
return (false, None);
};
model.cursor = assigned.displaced.map(|packed| {
CursorPayload::PetAction(CursorPetAction {
src_slot: slot,
packed,
passive: occupant_view.passive,
texture: occupant_view.texture.clone(),
})
let displaced = assigned.displaced.map(|packed| CursorPetAction {
src_slot: slot,
packed,
passive: occupant_view.passive,
texture: occupant_view.texture.clone(),
});
queue_cursor_update(model);
true
(true, displaced)
}
/// The 0-based index of a 1-based slot on a bar that exists.
@@ -197,23 +230,16 @@ fn write_slot(model: &mut Model, target: usize, source: u32, passive: bool) -> O
}
/// A token's texture is a global's name (`GetPetActionInfo`, `0x4bdc50`), which the bar resolves
/// with `getglobal` (`PetActionBarFrame.lua:102`); the cursor needs the path, resolved here.
fn resolve_token_icon(lua: &Lua) {
let name = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match &model.cursor {
Some(CursorPayload::PetAction(p)) if is_token(p.packed) => p.texture.clone(),
_ => None,
}
};
let Some(name) = name else {
/// with `getglobal` (`PetActionBarFrame.lua:102`); the cursor needs the path, resolved here, as
/// the setter resolves the token's string before its clear (`0x494ede`).
fn resolve_token_icon(lua: &Lua, payload: &mut CursorPetAction) {
if !is_token(payload.packed) {
return;
}
let Some(name) = payload.texture.clone() else {
return;
};
let path: Option<String> = lua.globals().get(name.as_str()).unwrap_or_default();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(CursorPayload::PetAction(p)) = &mut model.cursor {
p.texture = path;
}
payload.texture = lua.globals().raw_get(name.as_str()).unwrap_or_default();
}
/// Register the pet bar's one cursor global.
@@ -221,13 +247,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"PickupPetAction",
lua.create_function(|lua, slot: u32| {
let took = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_pet_action(&mut model, slot)
};
// Both the pickup and a drop's displaced occupant can put a token on the cursor.
resolve_token_icon(lua);
Ok(took)
pickup_pet_action(lua, slot);
Ok(())
})?,
)?;
Ok(())
@@ -236,6 +257,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use crate::script::cursor::bar::place_action;
use crate::script::{PetActionView, UiScript};
/// A hunter's bar words. `FILLER` is vmangos's unused slot, `ACT_DISABLED` with spell 0: type
@@ -337,10 +359,45 @@ mod tests {
s
}
/// A drop clears first (`0x4be220`): its grid hide and `CURSOR_UPDATE` read the bar before the
/// write (`0x4bce00`). The displaced occupant goes through the setter after it (`0x4bce40`):
/// its inner clear's `CURSOR_UPDATE`, then the set's `PET_BAR_SHOWGRID` (`0x494f28`), and no
/// `CURSOR_UPDATE` after. The written word is engine state, read here through a test global.
#[test]
fn a_drop_signals_its_clear_before_the_bar_write() {
let s = bar_script();
let word = s
.lua
.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.pet_bar.slots[4].view.packed == CLAW)
})
.unwrap();
s.lua.globals().set("SlotFiveIsClaw", word).unwrap();
s.run("PickupPetAction(4)").unwrap();
crate::script::tick::heard::listen(
&s,
&["PET_BAR_HIDEGRID", "CURSOR_UPDATE", "PET_BAR_SHOWGRID"],
Some("SlotFiveIsClaw()"),
);
// Growl is displaced and rides the cursor out, signalling after the write.
s.run("PickupPetAction(5)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
[
"PET_BAR_HIDEGRID false",
"CURSOR_UPDATE false",
"CURSOR_UPDATE true",
"PET_BAR_SHOWGRID true"
]
);
assert!(s.eval::<bool>("return SlotFiveIsClaw()").unwrap());
}
#[test]
fn picking_up_a_spell_blanks_its_slot_and_sends() {
let mut s = bar_script();
assert!(s.eval::<bool>("return PickupPetAction(4)").unwrap());
assert!(pickup_pet_action(&s.lua, 4));
assert!(
matches!(s.cursor_payload(), Some(crate::script::CursorPayload::PetAction(c)) if c.src_slot == 4),
"{:?}",
@@ -388,7 +445,7 @@ mod tests {
let mut s = UiScript::new().unwrap();
s.set_pet_actions(true, false, true, hunter_bar());
assert!(s.eval::<bool>("return PickupPetAction(4)").unwrap());
assert!(pickup_pet_action(&s.lua, 4));
assert!(
matches!(s.cursor_payload(), Some(crate::script::CursorPayload::PetAction(c))
if c.src_slot == 4 && c.packed == CLAW),
@@ -440,7 +497,7 @@ mod tests {
#[test]
fn picking_up_a_token_leaves_the_slot_alone() {
let mut s = bar_script();
assert!(s.eval::<bool>("return PickupPetAction(1)").unwrap());
assert!(pickup_pet_action(&s.lua, 1));
assert!(
s.take_pet_set_actions().is_empty(),
"nothing was written, so nothing is sent"
@@ -506,8 +563,8 @@ mod tests {
#[test]
fn slot_eleven_is_refused_rather_than_written_past_the_array() {
let mut s = bar_script();
assert!(!s.eval::<bool>("return PickupPetAction(11)").unwrap());
assert!(!s.eval::<bool>("return PickupPetAction(0)").unwrap());
assert!(!pickup_pet_action(&s.lua, 11));
assert!(!pickup_pet_action(&s.lua, 0));
assert!(s.take_pet_set_actions().is_empty());
assert!(s.cursor_payload().is_none());
}
@@ -517,7 +574,7 @@ mod tests {
fn a_possessed_bar_takes_no_drag_at_all() {
let mut s = UiScript::new().unwrap();
s.set_pet_actions(true, true, false, hunter_bar());
assert!(!s.eval::<bool>("return PickupPetAction(4)").unwrap());
assert!(!pickup_pet_action(&s.lua, 4));
assert!(s.take_pet_set_actions().is_empty());
}
@@ -558,7 +615,7 @@ mod tests {
"the action bar's grid stays down — nothing can land there"
);
// PlaceAction refuses it outright and leaves it held.
assert!(!s.eval::<bool>("return PlaceAction(1)").unwrap());
assert!(!place_action(&s.lua, 1));
assert!(matches!(
s.cursor_payload(),
Some(crate::script::CursorPayload::PetAction(_))
@@ -568,4 +625,31 @@ mod tests {
"and writes nothing to the action bar"
);
}
/// The binding's `ClearCursor(1,1)` (`0x4be220`) drops whatever is held, then the pet-action
/// setter clears again (`0x494f00`) before the set and its `PET_BAR_SHOWGRID` (`0x494f28`):
/// two `CURSOR_UPDATE`s on the emptied cursor and none after.
#[test]
fn a_pickup_clears_twice_then_shows_the_pet_grid() {
let mut s = bar_script();
s.set_cursor_for_test(CursorPayload::Macro(crate::script::cursor::CursorMacro {
index: 1,
texture: None,
}));
crate::script::cursor::install_kind_probe(&s);
crate::script::tick::heard::listen(
&s,
&["CURSOR_UPDATE", "PET_BAR_SHOWGRID", "PET_BAR_HIDEGRID"],
Some("CursorKind()"),
);
assert!(pickup_pet_action(&s.lua, 4));
assert_eq!(
crate::script::tick::heard::take(&s),
[
"CURSOR_UPDATE none",
"CURSOR_UPDATE none",
"PET_BAR_SHOWGRID pet"
]
);
}
}
+6 -6
View File
@@ -1,11 +1,11 @@
//! The release, reclaim, resurrect and self-res verbs and the getters the `DEATH`,
//! `RECOVER_CORPSE`, `RESURRECT` and `XP_LOSS` dialogs call, over a snapshot the app pushes.
//! The three predicates answer Lua booleans where the reference answers `1` or nil
//! (`ResurrectHasSickness` `0x48aa00`, `ResurrectHasTimer` `0x48aa30`, `CheckSpiritHealerDist`
//! `0x48d120`).
//! The three predicates answer `1` or nil, as the reference does (`ResurrectHasSickness`
//! `0x48aa00`, `ResurrectHasTimer` `0x48aa30`, `CheckSpiritHealerDist` `0x48d120`).
use mlua::{Lua, Value};
use super::binding_abi::flag;
use super::Model;
/// The death snapshot the app pushes each frame.
@@ -106,21 +106,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"ResurrectHasSickness",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.death.resurrect_sickness)
Ok(flag(model.death.resurrect_sickness))
})?,
)?;
g.set(
"ResurrectHasTimer",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.death.resurrect_has_timer)
Ok(flag(model.death.resurrect_has_timer))
})?,
)?;
g.set(
"CheckSpiritHealerDist",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.death.spirit_healer_in_range)
Ok(flag(model.death.spirit_healer_in_range))
})?,
)?;
+1 -1
View File
@@ -110,7 +110,7 @@ impl super::UiScript {
}
/// Retain an addon load failure that never raised (a missing file, an unparseable document, a
/// dependency cycle or missing dependency, a broken `Bindings.xml`). It is not dispatched to
/// missing or failed dependency, a broken `Bindings.xml`). It is not dispatched to
/// `geterrorhandler()`: the reference logs such a failure and shows nothing. The caller still
/// writes its own log line.
pub fn report_load_failure(&self, msg: &str) {
+8 -9
View File
@@ -80,18 +80,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
})?,
)?;
// `0x48df30` runs the generic cancel-aura (`0x6e7040`): the event, then `0x136` with
// `u32 2584`, no guid. Its refusal gate (`AttributesEx` bit 13 set, bit 2 clear, then
// `0x5ee290`) never trips for 2584 in the shipped `Spell.dbc` (the app's
// `spell_2584_never_trips_the_cancel_gate`), so the send is unconditional.
// `0x48df30` runs the generic cancel-aura (`0x4923a0` → `0x6e7040`): the event in place
// (`0x6e70c3`), then `0x136` with `u32 2584`, no guid. Its refusal gate (`AttributesEx` bit 13
// set, bit 2 clear, then `0x5ee290`) never trips for 2584 in the shipped `Spell.dbc` (the
// app's `spell_2584_never_trips_the_cancel_gate`), so the send is unconditional.
g.set(
"CancelAreaSpiritHeal",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.pending_events
.push(("AREA_SPIRIT_HEALER_OUT_OF_RANGE".to_string(), Vec::new()));
model.cancel_aura_requests.push(AREA_SPIRIT_HEALER_SPELL);
super::tick::verb(lua, |model| {
model.raise("AREA_SPIRIT_HEALER_OUT_OF_RANGE", Vec::new());
model.cancel_aura_requests.push(AREA_SPIRIT_HEALER_SPELL);
});
Ok(())
})?,
)?;
+48 -34
View File
@@ -4,10 +4,11 @@
//! - Focus: one owner ([`Model::focused_editbox`], the client's `DAT_00cf4dc8`). A left click
//! focuses and collapses the selection to the clicked byte (`0x77b86f call 0x77ccf0`, before
//! `SetFocus` at `0x77b881`), so it never selects all. An `autoFocus` box takes the focus on show
//! when none is held and on the first key, and a hidden box drops it ([`visibility_focus`]).
//! - Routing: a focused box consumes every key and char and fires only its specialized scripts
//! (Enter, Escape, Space, Tab, TextChanged, TextSet, focus), never a generic `OnKeyDown`; an
//! unfocused box that is not `autoFocus` ignores input.
//! when none is held and on the first key the walk offers it, and a hidden box drops it ([`visibility_focus`]).
//! - Routing: the key and char walks reach a box at its own place ([`super::keyboard`]); the focused
//! one consumes every key and char and fires only its specialized scripts (Enter, Escape, Space,
//! Tab, TextChanged, TextSet, focus), never a generic `OnKeyDown`; an unfocused box that is not
//! `autoFocus` ignores input.
//! - Editing: an insert deletes the selection, then `numeric` aborts a whole insert on any
//! non-digit; caps trim from the end (`maxBytes`, then `maxLetters`).
//! - Events: an edit, a caret move, a focus change or a re-seat of the text only raises the box's
@@ -27,7 +28,7 @@ use mlua::{Lua, Table};
use super::object::frame_handle_of;
use super::types::{EditAction, EditUnit};
use super::{event, Model, RegionData};
use crate::order::{self, DrawLayer, ZTarget};
use crate::order::DrawLayer;
use crate::widget::{EditBoxState, FrameHandle, FrameKind, KindState, RegionHandle, RegionKind};
/// Registry key of the EditBox method table (the MAXCSTACK discipline: Lua-side root, named key).
@@ -153,39 +154,49 @@ pub(in crate::script) use relayout::reseat_resized;
// Focus model
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// The box that processes a key or char. A hidden focused box blocks self-acquire (the client's
/// guard returns 0 with the focus still set); with no focus, the topmost visible `autoFocus` box
/// takes it and processes this same event.
/// The box that processes a key or char: the focused one, taken at the walk (`takes_event`). A
/// hidden focused box blocks (the client's guard returns 0 with the focus still set); with none
/// focused nothing processes, since an `autoFocus` box takes the focus only where the walk
/// reaches it.
fn route(lua: &Lua) -> Option<FrameHandle> {
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(h) = model.focused_editbox {
match model.arena.frame(h) {
Some(f) if f.effective_visible && f.kind == FrameKind::EditBox => return Some(h),
Some(_) => return None, // alive but hidden: block self-acquire, don't consume
None => model.focused_editbox = None, // stale: drop and self-acquire below
}
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let h = model.focused_editbox?;
match model.arena.frame(h) {
Some(f) if f.effective_visible && f.kind == FrameKind::EditBox => Some(h),
Some(_) => None, // alive but hidden: don't consume
None => {
model.focused_editbox = None; // stale
None
}
}
let h = topmost_autofocus(lua)?;
set_focus_handle(lua, h);
Some(h)
}
/// The topmost-drawn visible `autoFocus` EditBox, the keyboard self-acquire target.
fn topmost_autofocus(lua: &Lua) -> Option<FrameHandle> {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let sorted = order::traversal(&model.arena);
for (target, _) in sorted.iter().rev() {
if let ZTarget::Frame(fh) = *target {
if let Some(KindState::EditBox(eb)) = model.arena.frame(fh).map(|f| &f.kind_state) {
if eb.auto_focus {
return Some(fh);
}
/// Whether the EditBox `h`, reached by the key or char walk, takes the event (its vtable's
/// `+0x5c`/`+0x60`/`+0x64`, `0x77a900`/`0x77b160`/`0x77b7c0`): with a box focused (`[0xcf4dc8]`,
/// even a hidden one) only that box does; with none, an `autoFocus` box takes the focus through
/// `SetFocus` at its own walk position and the event with it; any other box declines.
pub(super) fn takes_event(lua: &Lua, h: FrameHandle) -> bool {
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(f) = model.focused_editbox {
if model.arena.frame(f).is_some() {
return f == h;
}
model.focused_editbox = None; // stale: no box holds the focus
}
let auto = matches!(
model.arena.frame(h).map(|f| &f.kind_state),
Some(KindState::EditBox(eb)) if eb.auto_focus
);
if !auto {
return false;
}
}
None
set_focus_handle(lua, h);
lua.app_data_ref::<Model>()
.expect("model app_data")
.focused_editbox
== Some(h)
}
/// `SetFocus` (`0x77e3d0`): needs effective visibility, a no-op when already focused; fires
@@ -294,7 +305,7 @@ fn insert(lua: &Lua, h: FrameHandle, ins: &str, fire_space: bool) {
if let Err(e) = on_char {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
if fire_space {
for _ in 0..out.spaces {
@@ -376,8 +387,11 @@ fn sync_text_region(lua: &Lua, h: FrameHandle) {
});
if let Some(display) = display {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.region_data.entry(rh).or_default().text = Some(display);
let data = model.region_data.entry(rh).or_default();
let was_shown = data.text.as_deref().is_some_and(|t| !t.is_empty());
data.text = Some(display);
model.touch_measure(rh);
model.touch_layout_text(rh, was_shown);
}
}
@@ -461,7 +475,7 @@ fn ensure_text_region_in(model: &mut Model, h: FrameHandle) -> Option<RegionHand
};
apply_text_region_justify(&mut data, multi_line);
slot.insert(data);
model.touch_layout(); // a region entered the layout gate's read set
model.touch_layout_region(rh);
}
if let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
eb.text_region = Some(rh);
@@ -519,7 +533,7 @@ fn fire_script(lua: &Lua, id: u32, name: &str) {
if let Err(e) = event::fire_widget_handler(lua, id, name, Vec::new()) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
@@ -124,7 +124,7 @@ fn cursor_changed(lua: &Lua, h: FrameHandle) -> bool {
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
true
}
@@ -93,6 +93,19 @@ fn the_flush_follows_the_boxs_own_on_update_cursor_then_text() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The box's place in the walk is its stratum's, not its creation's: a BACKGROUND box updates and
/// flushes before the MEDIUM frames made around it (`0x7656c8`).
#[test]
fn a_box_flushes_after_its_on_update_in_its_strata_place() {
let mut s = script();
logged_box(&mut s);
s.run(r#"E:SetFrameStrata("BACKGROUND") E:SetText("abc")"#)
.unwrap();
s.tick(0.016);
assert_eq!(take_log(&s), "update cursor@21 text:abc early late");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// An edit a box makes in its own `OnUpdate` is flushed by the same update, so its events fire in
/// the same tick.
#[test]
@@ -458,6 +471,9 @@ fn up_and_down_move_a_multi_line_boxs_caret_by_rows() {
"#,
)
.unwrap();
// The flush sizes the box to its two rows; the drain after the walk resizes it, and the
// re-seat that runs fires once more at the next flush (`0x77a8d0` → `0x77b8c0`).
s.tick(0.016);
s.tick(0.016);
take_log(&s);
let row = |back| EditAction::Move {
@@ -555,3 +571,30 @@ fn on_cursor_changed_answers_in_the_boxs_own_units() {
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A box created and anchored between frames, as an event handler does: the pump's drain before
/// the walk (`0x7656ba`) gives it its first rect, whose resize runs the box's `OnSizeChanged`
/// override (`0x76b63e` → `0x77a8d0`); the re-seat ORs its bits into the ctor's (`0x77ba7f`), so the
/// walk's flush (`0x77a7a1`) fires `OnTextChanged` once, and the frames after it fire nothing.
#[test]
fn a_box_anchored_between_frames_fires_on_text_changed_once() {
let mut s = script();
s.run(
r#"
N = 0
local e = CreateFrame("EditBox", "Fresh")
e:SetAutoFocus(false)
e:SetPoint("BOTTOMLEFT", 100, 50)
e:SetWidth(200); e:SetHeight(32)
e:SetScript("OnTextChanged", function() N = N + 1 end)
"#,
)
.unwrap();
// The host's frame: the tick, then its own resolve for the draw.
for _ in 0..3 {
s.tick(0.016);
s.resolve();
}
assert_eq!(s.eval::<i64>("return N").unwrap(), 1);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
+12 -11
View File
@@ -57,13 +57,13 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
})?,
)?;
// GetNumber: the real text as a number, else 0. The reference (`0x798790`) is atof, which also
// reads the leading number of a text like "12abc".
// GetNumber (`0x798790`): the real text through Storm's `SStrToFloat` (`0x64aaa0`), a prefix
// parse, so "12abc" is 12 and " 12", "+12" and "inf" are 0.
m.set(
"GetNumber",
lua.create_function(|lua, this: Table| {
let text = with_editbox(lua, &this, |eb| eb.text.clone())?;
Ok(text.trim().parse::<f64>().unwrap_or(0.0))
Ok(crate::script::binding_abi::s_str_to_float(&text))
})?,
)?;
// Insert: a nil is a no-op, not an error, since the reference reads the argument through
@@ -293,8 +293,10 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
let refresh_text = name == "SetMultiLine";
m.set(
name,
lua.create_function(move |lua, (this, v): (Table, Value)| {
let on = !matches!(v, Value::Nil | Value::Boolean(false));
lua.create_function(move |lua, (this, args): (Table, mlua::MultiValue)| {
// `GetBoolOrDefault` (`0x6f1c10`) with default 1 (`0x797d1e`, `0x797e8e`,
// `0x797ffe`, `0x79816e`): a bare call enables, `"0"` and `nil` disable.
let on = crate::script::binding_abi::bool_or_default(args.front(), true);
with_editbox(lua, &this, |eb| set(eb, on))?;
if refresh_text {
// A multi-line box seats its text TOP and as tall as the text, a single-line
@@ -314,10 +316,9 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
}
/// The font block, entries #0-#15 of the EditBox's own 48-entry method table (`0x87bb68`, count
/// at `0x799ab5`); 1.12 has no `FontInstance` class, so each text-bearing kind declares it. Ten
/// come from [`super::super::font_block`]. `SetSpacing`/`GetSpacing` (#10-#11) are not installed:
/// nothing renders line spacing, and a missing method raises by name. The justify four (#12-#15)
/// act on the box's own [`EditBoxState::justify`] word, not its text region.
/// at `0x799ab5`); 1.12 has no `FontInstance` class, so each text-bearing kind declares it. Twelve
/// come from [`super::super::font_block`], `SetSpacing`/`GetSpacing` (#10-#11) among them. The
/// justify four (#12-#15) act on the box's own [`EditBoxState::justify`] word, not its text region.
fn install_font_block(lua: &Lua, m: &Table) -> mlua::Result<()> {
// Every font method acts on the box's implicit FontString (`[this+0x324]`, our
// `text_region`), created on demand.
@@ -328,7 +329,7 @@ fn install_font_block(lua: &Lua, m: &Table) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
super::ensure_text_region(lua, h).ok_or_else(|| mlua::Error::runtime("not an EditBox"))
},
"EditBox",
crate::script::font_block::FontKind::Instance,
// The box listens on its font (vtable `0x81c8dc` slot 0, `0x77e2a0`): a change of face or
// spacing (mask `0x11`) re-seats its text region (`0x77e2b5` → `0x77b8c0`).
Some(|lua, this| {
@@ -373,7 +374,7 @@ fn install_font_block(lua: &Lua, m: &Table) -> mlua::Result<()> {
/// A `Set<Flag>` method name and the field write it drives.
type FlagSetter = (&'static str, fn(&mut EditBoxState, bool));
/// The four config-flag setters, each taking Lua truthiness.
/// The four config-flag setters, each reading its flag with `GetBoolOrDefault`.
fn flag_setters() -> [FlagSetter; 4] {
[
("SetAutoFocus", |eb, on| eb.auto_focus = on),
@@ -3,7 +3,7 @@
use mlua::Lua;
use crate::layout::{Anchor, Point, Rect};
use crate::layout::{Anchor, Anchors, Point, Rect};
use crate::script::{MeasureRequest, Model};
use crate::widget::{EditBoxState, FrameHandle, KindState, RegionHandle};
@@ -39,18 +39,13 @@ pub(super) fn seat_text_region(model: &mut Model, h: FrameHandle) {
let top_left = Anchor::new(Point::TopLeft, owner, Point::TopLeft, l, -t);
let (anchors, size) = if multi_line {
let width = multi_line_text_width(model, h, [l, r]);
(vec![top_left], width.map(|w| Some((w, 0.0))))
(Anchors::from([top_left]), width.map(|w| Some((w, 0.0))))
} else {
let bottom_right = Anchor::new(Point::BottomRight, owner, Point::BottomRight, -r, b);
(vec![top_left, bottom_right], Some(None))
(Anchors::from([top_left, bottom_right]), Some(None))
};
let data = model.region_data.entry(rh).or_default();
let same_anchors = data.anchors.len() == anchors.len()
&& data
.anchors
.iter()
.zip(&anchors)
.all(|(a, b)| crate::script::object::anchor_bits_eq(a, b));
let same_anchors = data.anchors.bits_eq(&anchors);
// `None` keeps the size: a multi-line box with no rect yet.
let size = size.unwrap_or(data.size);
let size_moved = !crate::script::region::size_bits_eq(data.size, size);
@@ -58,11 +53,9 @@ pub(super) fn seat_text_region(model: &mut Model, h: FrameHandle) {
data.anchors = anchors;
}
data.size = size;
if !same_anchors {
model.touch_layout();
} else if size_moved {
model.touch_layout_region(rh);
}
// The seat's points and size, each set with an invalidate (`0x77b905`, `0x77b91e`/`0x77b940`,
// `0x77b96b`).
model.invalidate_region(rh, !same_anchors || size_moved);
if size_moved {
// The width is the wrap width, which keys the measure.
model.touch_measure(rh);
@@ -76,6 +69,7 @@ pub(super) fn seat_text_region(model: &mut Model, h: FrameHandle) {
/// a layout input, for the caller to resolve it in the same pass, as the reference's drain does.
pub(in crate::script) fn reseat_resized(model: &mut Model) -> bool {
let epoch = model.layout_epoch;
let eps = model.size_eps();
for i in 0..model.arena.editbox_kinds().len() {
let h = model.arena.editbox_kinds()[i];
let Some(&now) = model.resolved.get(&h) else {
@@ -89,7 +83,7 @@ pub(in crate::script) fn reseat_resized(model: &mut Model) -> bool {
.notified_rect
.replace(now)
.unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0));
if !crate::layout::size_changed(before, now) {
if !crate::layout::size_changed(before, now, eps) {
continue;
}
// The ctor built the text region; its data is seeded here if nothing has touched it.
@@ -143,10 +137,10 @@ pub(super) fn relayout(lua: &Lua, h: FrameHandle) -> bool {
// `fld top; fadd bottom; fadd st, st(1)`.
let height = (top + bottom) + text_h;
let input = model.layout_inputs.entry(h).or_default();
if input.height.to_bits() != height.to_bits() {
input.height = height;
model.touch_layout_frame(h);
}
let moved = input.height.to_bits() != height.to_bits();
input.height = height;
// `SetHeight`, which invalidates (`0x7683f0`).
model.invalidate_frame(h, moved);
true
}
@@ -197,6 +191,9 @@ fn line_height(lua: &Lua, h: FrameHandle, rh: RegionHandle) -> Option<f32> {
text_height: d.text_height,
wrap_width: None,
outline: d.outline,
// One line has no gap: `0x5c2070` charges `(N−1)·gap`.
spacing: d.line_gap(),
shadow_x: d.shadow_x(),
scale,
text: ONE_LINE.to_string(),
key,
@@ -165,6 +165,9 @@ fn stale_advances(
let font = d.and_then(|d| d.font_path.clone());
let height = d.and_then(|d| d.font_height);
let outline = d.map(|d| d.outline).unwrap_or_default();
// A multi-line box steps its rows by the font height plus the gap (`0x77d133`-`0x77d161`).
let spacing = d.map_or(0.0, super::super::RegionData::line_gap);
let shadow_x = d.map_or(0.0, |d| d.shadow_x());
// The host measures at the drawn raster size, so the advances land on the drawn glyphs.
let scale = model
.arena
@@ -187,6 +190,8 @@ fn stale_advances(
font.hash(&mut hasher);
height.map(f32::to_bits).hash(&mut hasher);
(outline as u8).hash(&mut hasher);
spacing.to_bits().hash(&mut hasher);
shadow_x.to_bits().hash(&mut hasher);
wrap_width.map(f32::to_bits).hash(&mut hasher);
scale.to_bits().hash(&mut hasher);
let key = hasher.finish();
@@ -207,6 +212,8 @@ fn stale_advances(
font,
height,
outline,
spacing,
shadow_x,
scale,
text: display,
wrap_width,
@@ -656,6 +656,85 @@ fn get_number_parses_the_text() {
assert_eq!(s.eval::<f64>("return E:GetNumber()").unwrap(), 0.0);
}
/// `GetNumber` pushes `SStrToFloat` of the text (`0x798790` → `0x64aaa0`): a prefix parse with no
/// whitespace skip and no `+`.
#[test]
fn get_number_is_the_references_prefix_parse() {
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E")"#).unwrap();
for (text, want) in [
("12abc", 12.0),
(" 12", 0.0),
("+12", 0.0),
("inf", 0.0),
("nan", 0.0),
("-12.5x", -12.5),
(".5", 0.5),
("5.", 5.0),
("1e3", 1000.0),
("1E+2", 100.0),
("0x10", 0.0),
("", 0.0),
] {
s.run(&format!("E:SetText({text:?})")).unwrap();
assert_eq!(
s.eval::<f64>("return E:GetNumber()").unwrap(),
want,
"{text:?}"
);
}
}
/// The four config flags are `GetBoolOrDefault` reads with default 1 (`0x797d1e`, `0x797e8e`,
/// `0x797ffe`, `0x79816e`): a bare call enables, nil, `"0"` and `"no"` disable.
#[test]
fn editbox_flag_setters_read_get_bool_or_default_with_default_one() {
use crate::script::object::frame_handle_of;
use crate::widget::EditBoxState;
type Pick = fn(&EditBoxState) -> bool;
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E")"#).unwrap();
let state = |pick: Pick| {
let t: mlua::Table = s.lua().globals().get("E").unwrap();
let h = frame_handle_of(s.lua(), &t).unwrap();
super::with_eb(s.lua(), h, |eb| pick(eb)).unwrap()
};
for (set, pick) in [
("SetAutoFocus", (|eb| eb.auto_focus) as Pick),
("SetNumeric", |eb| eb.numeric),
("SetPassword", |eb| eb.password),
("SetMultiLine", |eb| eb.multi_line),
] {
let put = |arg: &str| s.run(&format!("E:{set}({arg})")).unwrap();
put("true");
put("false");
put("");
assert!(state(pick), "{set}: no argument takes the default 1");
for (arg, want) in [
("nil", false),
("true", true),
("false", false),
("0", false),
("0.5", false),
("1", true),
("2^32", false),
(r#""0""#, false),
(r#""1""#, true),
(r#""no""#, false),
(r#""yes""#, true),
(r#""off""#, false),
(r#""on""#, true),
(r#""""#, true),
("{}", true),
] {
put(if want { "false" } else { "true" });
assert_eq!(state(pick), !want, "{set}: setup");
put(arg);
assert_eq!(state(pick), want, "{set}({arg})");
}
}
}
// ── the generic OnChar and OnKeyDown ─────────────────────────────────────────────────────────
#[test]
+19 -14
View File
@@ -1,7 +1,7 @@
//! Handler firing: the FrameScript calling convention for events, ticks and show/hide. The
//! reference sets `this` (`0x872e64`), `event` (`0x84b648`) and `arg1..argN` (`0x8722dc`) around
//! the call, restoring each after, and `pcall`s with no arguments; the handler here also gets the
//! same values as `(self, event, ...)` arguments, which 1.12 does not pass.
//! the call, restoring each after, and `pcall`s with no arguments; so does [`invoke_with_globals`],
//! which sets only the args it is given, so a script fired with none leaves `arg1..argN` as found.
//! Handler errors return to the caller, which records them in [`super::Model::errors`].
use std::borrow::Cow;
@@ -38,7 +38,7 @@ pub(super) fn fire_all_event_listeners(lua: &Lua, event: &str, args: &[ScriptVal
if let Err(e) = fire_event_handler(lua, id, event, args) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
at = next;
@@ -93,17 +93,22 @@ pub(super) fn fire_size_changes(lua: &Lua) {
.pending_size_changed,
);
for (id, w, h) in pending {
if let Err(e) = fire(
lua,
id,
"OnSizeChanged",
None,
vec![Value::Number(f64::from(w)), Value::Number(f64::from(h))],
) {
lua.app_data_mut::<Model>()
.expect("model")
.record_script_error(e.to_string());
}
fire_size_changed(lua, id, w, h);
}
}
/// `OnSizeChanged(width, height)` on frame `id`, its error recorded.
pub(super) fn fire_size_changed(lua: &Lua, id: u32, w: f32, h: f32) {
if let Err(e) = fire(
lua,
id,
"OnSizeChanged",
None,
vec![Value::Number(f64::from(w)), Value::Number(f64::from(h))],
) {
lua.app_data_mut::<Model>()
.expect("model")
.record_lua_error(&e);
}
}
+21 -13
View File
@@ -89,7 +89,8 @@ impl UiScript {
icon: m.icon.clone(),
camera: m.camera,
light: m.light,
fog: m.armed_fog(),
fog: m.scene_fog(),
ffx: m.ffx,
},
_ => QuadContent::Frame,
};
@@ -179,7 +180,7 @@ impl UiScript {
});
}
}
// A Button's non-current state textures emit no quad, and its ButtonText takes
// A Button's state textures draw by their shown bit like any region, and its ButtonText takes
// the current state's font instance (disabled > highlighted > normal).
let mut state_font: Option<&FontObject> = None;
let mut state_color: Option<[f32; 4]> = None;
@@ -191,7 +192,7 @@ impl UiScript {
let hovered = owner.is_some() && model.mouseover == owner;
// The press is not read here: the state texture latches on the transition
// (`ButtonState::set_state`). Only the Highlight, unlatched, reads hover.
if !bs.region_visible(rh, hovered) {
if !bs.highlight_draws(rh, hovered) {
continue;
}
if bs.text == Some(rh) {
@@ -302,26 +303,33 @@ impl UiScript {
if let Some(c) = state_color {
data.vertex_color = Some(c);
}
// A region draws at its resolved rect only: drawable regions carry anchors
// (authored, or `region::implicit_creation_anchor`) and a failed resolve
// latches unresolvable (`0x768d55`), so an unanchored Lua region draws nowhere,
// as in the reference. The bar fill and thumbs keep their fraction geometry.
// A region draws at its resolved rect: drawable regions carry anchors
// (authored, or `region::implicit_creation_anchor`), so a region that never
// resolved draws nowhere, as in the reference. After a failed resolve
// (`0x768d55`) a FontString stops (`0x7730b7` needs the valid bit) and a
// Texture keeps the quad its last good resolve wrote (`0x7706e0`). The bar
// fill and thumbs keep their fraction geometry.
if let Some(sb) = bar_fill {
data.tex_coords = Some(bar_fill_uv(data.tex_coords, sb));
}
if bar_fill.is_none() && !thumb_fill && !color_thumb {
rect = model.region_resolved.get(&rh).copied();
}
let is_text = matches!(
region.map(|r| r.kind),
Some(crate::widget::RegionKind::FontString)
);
if bar_fill.is_none() && !thumb_fill && !color_thumb {
rect = model.region_resolved.get(&rh).copied().or_else(|| {
(!is_text)
.then(|| model.region_kept.get(&rh).copied())
.flatten()
});
}
// Generated art fills only an empty slot (`ColorPickerFrame.xml` sets none).
let content = if let Some(art) =
color_art.filter(|_| data.texture.is_none() && data.fill.is_none())
{
art
} else if is_text {
let spacing = data.line_gap();
QuadContent::Text {
text: data.text,
color: data.vertex_color,
@@ -334,6 +342,7 @@ impl UiScript {
text_height: data.text_height,
shadow: data.font_shadow,
outline: data.outline,
spacing,
alpha_gradient: data.alpha_gradient,
// The owner's property: an addon's strings on a plate slide with it.
world_seat: owner
@@ -389,10 +398,9 @@ impl UiScript {
// A ScrollingMessageFrame adds one Text quad per visible ring line, stacked bottom-up
// with each line's fade alpha; the ring lives in its kind state, not in FontStrings.
if let (ZTarget::Frame(fh), Some(fr)) = (target, rect) {
// The Backdrop draws at the frame slot; the lines take the ARTWORK content key,
// where the client's message strings live, over the BACKGROUND (hover box) art.
// The lines take the ARTWORK content key, where the client's message strings
// live, over the BACKGROUND (hover box) art.
let paint = super::layout::FramePaint { alpha, scale };
Self::emit_backdrop(&model, fh, fr, zkey.raw(), paint, clip, &mut out);
Self::emit_message_lines(
&model,
fh,
+2 -8
View File
@@ -43,14 +43,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"FollowByName",
lua.create_function(|lua, (name, exact): (String, Option<mlua::Value>)| {
// The reference reads this with `GetBoolOrDefault` (`0x6f1c10`), default false; here
// nil, false and 0 are false and any other value is true.
let exact = match exact {
None | Some(mlua::Value::Nil) | Some(mlua::Value::Boolean(false)) => false,
Some(mlua::Value::Integer(n)) => n != 0,
Some(mlua::Value::Number(n)) => n != 0.0,
Some(_) => true,
};
// `GetBoolOrDefault` (`0x6f1c10`, `0x489f14`) with default false.
let exact = super::binding_abi::bool_or_default(exact.as_ref(), false);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.follow_requests
+56 -39
View File
@@ -3,8 +3,7 @@
//!
//! The reference's class is `CSimpleFont` (`0x87a454`). Its method table (`0x87c7c8`) has 22
//! entries (`mov edx,0x16` at `0x7a10d5`), and its lookup `0x7a1100` has no base-class fallback.
//! All are here but the `SetSpacing` pair ([`install`]); `CopyFontObject` is the one a FontString
//! lacks. A named font is published by `0x783870` through `SetName 0x784150` and `CreateLuaHandle
//! All are here; `CopyFontObject` is the one a FontString lacks. A named font is published by `0x783870` through `SetName 0x784150` and `CreateLuaHandle
//! 0x701bd0`, which leaves a non-nil `_G[name]` alone (`0x701cb8`).
//!
//! A setter repaints every FontString that inherits the font. The reference links dependents into
@@ -27,7 +26,7 @@ use super::binding_abi;
use super::object::publish_global;
use crate::justify;
use super::{FontObject, FontShadow, Model, Outline, RegionData};
use super::{FontObject, FontShadow, Model, RegionData};
/// The shared metatable of every font-object handle.
const REG_FONT_META: &str = "__benilla_font_meta";
@@ -108,8 +107,8 @@ fn resolve_required(verb: &str, v: &Value) -> mlua::Result<String> {
/// on the source's held-value bits (`+0x38`, the merge `0x770910`), so an empty `CreateFont` font
/// changes nothing.
///
/// The outline is always copied, as [`Outline`] has no unset state; only an empty `CreateFont` font
/// on an outlined FontString differs from the reference, clearing the outline.
/// The outline is always copied, as [`Outline`](super::Outline) has no unset state; only an empty
/// `CreateFont` font on an outlined FontString differs from the reference, clearing the outline.
pub(crate) fn repaint(d: &mut RegionData, fo: &FontObject) {
let ex = d.font_explicit;
if !ex.face {
@@ -145,6 +144,13 @@ pub(crate) fn repaint(d: &mut RegionData, fo: &FontObject) {
d.justify.set_v(j);
}
}
// Copied as given: a FontString's reader clamps it, as its setter `0x772240` does at the merge
// (`0x7708fd`).
if !ex.spacing {
if let Some(sp) = fo.spacing {
d.spacing = sp;
}
}
}
/// Repaint every region that inherits `name`, as the reference's live parent link does. Linear in
@@ -185,10 +191,6 @@ fn read(lua: &Lua, this: &Table) -> mlua::Result<FontObject> {
// ── install: the method table, the metatable and CreateFont ──────────────────────────────────
/// Build the font-object method table and metatable, and register `CreateFont`.
///
/// Deviation: `SetSpacing`/`GetSpacing` are absent, because nothing renders line spacing (the line
/// pitch is the font height): a missing method raises and names itself, where a stored, undrawn
/// value would fail silently.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.set_named_registry_value(REG_FONT_WRAPPERS, lua.create_table()?)?;
@@ -239,29 +241,28 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// ── face ────────────────────────────────────────────────────────────────────────────────
// SetFont(path, height [, flags]) → 1, or nil when the font file fails to load, which does not
// raise. Deviation: any non-empty path answers 1, because the atlas falls back per face and no
// load fails; an empty one answers nil.
// SetFont(path, height [, flags]) → 1, or nil when the font fails to load, which does not raise
// and writes nothing: an empty path, one the host's probe refuses, or a height of 0 or less.
m.set(
"SetFont",
lua.create_function(
|lua, (this, file, height, flags): (Table, Value, Value, Option<String>)| {
// The argument gate the FontString and EditBox tables share (`0x79f210`); a
// missing argument raises (`0x87c69c`).
let (path, height) = super::font_block::set_font_args(&file, &height, "Font")?;
let ok = !path.is_empty();
|lua, (this, file, height, flags): (Table, Value, Value, Value)| {
// The argument gate every kind shares (`0x79f210`); a bad path or height raises
// (`0x87c69c`), and anything but a string or a number is no flags.
let super::font_block::FontArgs {
path,
height,
flags,
} = super::font_block::set_font_args(lua, &this, &file, &height, &flags)?;
if !super::font_block::instance_loads(lua, &path, height) {
return Ok(Value::Nil);
}
edit(lua, &this, |fo| {
if ok {
fo.font = Some(path);
}
fo.font = Some(path);
fo.outline = flags;
fo.height = Some(height);
if let Some(f) = flags {
// The Lua flag spelling (`OUTLINE`, `THICKOUTLINE`), not the XML
// attribute's (`NORMAL`, `THICK`).
fo.outline = Outline::flags(&f);
}
})?;
Ok(if ok { Value::Number(1.0) } else { Value::Nil })
Ok(Value::Number(1.0))
},
)?,
)?;
@@ -284,16 +285,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"SetTextColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = super::object::alpha_f32(lua, &a);
let (r, g, b) = (
super::object::as_f32(&r),
super::object::as_f32(&g),
super::object::as_f32(&b),
super::object::tonumber_f32(lua, &r),
super::object::tonumber_f32(lua, &g),
super::object::tonumber_f32(lua, &b),
);
edit(lua, &this, |fo| {
let keep = fo.color.map_or(1.0, |c| c[3]);
fo.color = Some([r, g, b, a.unwrap_or(keep)]);
})
// Alpha is staged 1.0 (`0x79f59b`) and never read back, so a bare colour call
// resets it.
edit(lua, &this, |fo| fo.color = Some([r, g, b, a]))
},
)?,
)?;
@@ -325,17 +326,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"SetShadowColor",
lua.create_function(
// Shape C on r, g, b (`Font:SetShadowColor 0x79f730`): never raises.
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = super::object::alpha_f32(lua, &a);
let (r, g, b) = (
crate::script::object::as_f32(&r),
crate::script::object::as_f32(&g),
crate::script::object::as_f32(&b),
crate::script::object::tonumber_f32(lua, &r),
crate::script::object::tonumber_f32(lua, &g),
crate::script::object::tonumber_f32(lua, &b),
);
edit(lua, &this, |fo| {
let offset = fo.shadow.map_or([0.0, 0.0], |s| s.offset);
fo.shadow = Some(FontShadow {
offset,
color: [r, g, b, a.unwrap_or(1.0)],
color: [r, g, b, a],
});
})
},
@@ -370,6 +372,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// ── line spacing ────────────────────────────────────────────────────────────────────────
// The shared pair (`0x7a09f0` → `0x79fb40`, `0x7a0ab0` → `0x79fbe0`): `+0x50` as given, a
// negative included; a FontString that inherits it clamps it.
m.set(
"SetSpacing",
lua.create_function(|lua, (this, v): (Table, Value)| {
let sp = super::font_block::spacing_arg(lua, &this, &v)?;
edit(lua, &this, |fo| fo.spacing = Some(sp))
})?,
)?;
m.set(
"GetSpacing",
lua.create_function(|lua, this: Table| Ok(read(lua, &this)?.spacing.unwrap_or(0.0)))?,
)?;
// ── justification ───────────────────────────────────────────────────────────────────────
// Parsing is [`crate::justify`]'s, shared with the FontString pair: `SStrCmpI` over the whole
// string, no trim. `None` means the font does not specify the axis (inheritance), not a
+228 -68
View File
@@ -1,22 +1,21 @@
//! The font block: the ten font methods a text-bearing widget's own method table declares,
//! The font block: the twelve font methods a text-bearing widget's own method table declares,
//! implemented once over the [`RegionData`](super::RegionData) its glyphs paint from.
//!
//! The 1.12 client has no `FontInstance` class: each text type re-declares the font names in its
//! own flat method table, and each binding is a shim that tail-calls one shared implementation.
//! This module is that shared layer, installed by the FontString, EditBox, MessageFrame and
//! ScrollingMessageFrame tables; the `<Font>` object and `SimpleHTML` write their own records and
//! share only [`set_font_args`].
//! share only the argument gates ([`set_font_args`], [`spacing_arg`]).
//!
//! `EditBox`'s table (`0x87bb68`, 48 entries) opens with these ten, then `Set/GetSpacing` and the
//! four justify methods. Its shims (`0x797090` on) leave `eax` alone, so each returns what the
//! shared implementation pushes, and act on `[this+0x324]`, the box's implicit FontString
//! ([`EditBoxState::text_region`](crate::widget::EditBoxState::text_region)). The justify four are
//! installed by `script::editbox::methods`. `SetSpacing`/`GetSpacing` (`0x79fb40`/`0x79fbe0`) are
//! not installed: no line spacing is modelled, so a call raises.
//! `EditBox`'s table (`0x87bb68`, 48 entries) opens with these twelve, then the four justify
//! methods. Its shims (`0x797090` on) leave `eax` alone, so each returns what the shared
//! implementation pushes, and act on `[this+0x324]`, the box's font, which here is its implicit
//! FontString ([`EditBoxState::text_region`](crate::widget::EditBoxState::text_region)). The
//! justify four are installed by `script::editbox::methods`.
use mlua::{Lua, Table, Value};
use super::object::as_f32;
use super::binding_abi;
use super::{FontShadow, Model, Outline};
use crate::widget::RegionHandle;
@@ -29,40 +28,127 @@ pub(super) type ResolveRegion = fn(&Lua, &Table) -> mlua::Result<RegionHandle>;
/// `0x77e4d7` call, which the EditBox answers (`0x77e2a0`).
pub(super) type FontChanged = fn(&Lua, &Table) -> mlua::Result<()>;
/// `SetFont`'s shared argument gate (`0x79f210`, reached from Font `0x7a0270`, FontString
/// `0x79d4f0` and EditBox `0x797210`): arg 2 must pass `lua_isstring` and arg 3 `lua_isnumber`,
/// both coercing, else it raises `Usage: %s:SetFont("font", fontHeight [, flags])` (`0x87c69c`).
/// An empty path is not an argument error but a failed load, which the caller answers with nil.
pub(super) fn set_font_args(
file: &Value,
height: &Value,
widget: &str,
) -> mlua::Result<(String, f32)> {
let usage = || {
mlua::Error::runtime(format!(
"Usage: <{widget}>:SetFont(\"font\", fontHeight [, flags])"
))
};
let path = match file {
Value::String(s) => s.to_str()?.to_string(),
Value::Number(_) | Value::Integer(_) => as_f32(file).to_string(),
_ => return Err(usage()),
};
let height = match height {
Value::Number(_) | Value::Integer(_) => as_f32(height),
Value::String(s) => s.to_str()?.parse::<f32>().map_err(|_| usage())?,
_ => return Err(usage()),
};
Ok((path, height))
/// The `%s` of a font verb's `Usage:` line: every shim passes the receiver's `GetName()`, or
/// `<unnamed>` (`0x84c7f0`) when it has none (`0x79d652`, `0x7a0315`, `0x780919`). Runs Lua, so a
/// caller asks before it borrows the model.
pub(super) fn usage_name(this: &Table) -> String {
this.get::<mlua::Function>("GetName")
.and_then(|f| f.call::<Option<String>>(this.clone()))
.ok()
.flatten()
.unwrap_or_else(|| "<unnamed>".to_string())
}
/// Install the ten font-block methods onto `m`, reading `this` through `resolve`; only for a
/// widget whose reference method table carries all ten.
/// `SetFont`'s three arguments as the shared implementation reads them.
pub(super) struct FontArgs {
pub(super) path: String,
pub(super) height: f32,
/// The flags word written on every load (`0x79f334`): zero, no outline, when the gate fails.
pub(super) flags: Outline,
}
/// `SetFont`'s argument gate: `0x79f210`, behind the Font (`0x7a0270`), EditBox (`0x797210`),
/// message-frame (`0x794450`, `0x791e80`), SimpleHTML (`0x795f30`) and Button (`0x780880`) shims,
/// and inlined in the FontString's (`0x79d582`-`0x79d5fb`). The path must pass `lua_isstring`
/// (`0x79f225`) and the height `lua_isnumber` (`0x79f239`), both coercing, else `Usage:
/// %s:SetFont("font", fontHeight [, flags])` (`0x87c69c`). The flags are read only when
/// `lua_isstring` passes (`0x79f287`), so a string or a number; `false`, nil or anything else is no
/// flags. An empty path is not an argument error but a failed load, which the caller answers with
/// nil.
pub(super) fn set_font_args(
lua: &Lua,
this: &Table,
file: &Value,
height: &Value,
flags: &Value,
) -> mlua::Result<FontArgs> {
let usage = || {
mlua::Error::runtime(format!(
"Usage: {}:SetFont(\"font\", fontHeight [, flags])",
usage_name(this)
))
};
let Some(path) = binding_abi::optional_string(lua, file) else {
return Err(usage());
};
let Some(height) = lua.coerce_number(height.clone())? else {
return Err(usage());
};
// The Lua spelling (`OUTLINE`, `THICKOUTLINE`), not the XML one, parsed like `0x6f1a90`: a
// case-insensitive substring scan, so `THICKOUTLINE` sets both.
let flags =
binding_abi::optional_string(lua, flags).map_or(Outline::None, |f| Outline::flags(&f));
Ok(FontArgs {
path,
height: height as f32,
flags,
})
}
/// `SetSpacing`'s argument gate, the same on all six tables (`0x79fb54`, FontString `0x79e340`):
/// `lua_isnumber`, coercing, else `Usage: %s:SetSpacing(spacing)` (`0x87c708`).
pub(super) fn spacing_arg(lua: &Lua, this: &Table, v: &Value) -> mlua::Result<f32> {
match lua.coerce_number(v.clone())? {
Some(n) => Ok(n as f32),
None => Err(mlua::Error::runtime(format!(
"Usage: {}:SetSpacing(spacing)",
usage_name(this)
))),
}
}
/// Which implementations a table's font verbs reach: the FontString has its own `SetFont` load
/// (`0x79d4f0` → `0x7714f0`) and spacing pair (`0x79e2b0`, `0x79e1d0`, through the clamping
/// `0x772240`); the message frames' and the EditBox's font reach the shared ones (`0x79f210`,
/// `0x79fb40`, `0x79fbe0`), which keep a spacing as given.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum FontKind {
/// A FontString: its `GetSpacing` reads [`RegionData::line_gap`](super::RegionData::line_gap).
FontString,
/// A font instance: its `GetSpacing` reads the number as set.
Instance,
}
/// A `SetFont` load's outcome.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Load {
Loaded,
/// A FontString's height of 0: no load, its font freed, still a success.
Freed,
Failed,
}
impl Load {
fn verdict(ok: bool) -> Load {
if ok {
Load::Loaded
} else {
Load::Failed
}
}
}
/// Whether the font load (`0x44d040`, the factory `0x5c1ae0`) finds `path`: never for an empty one
/// (`0x44d059`), else the host's probe, as only the store knows the files; with no probe, any
/// non-empty path loads.
pub(super) fn face_loads(model: &Model, path: &str) -> bool {
!path.is_empty() && model.font_probe.as_ref().is_none_or(|probe| probe(path))
}
/// The shared path's verdict (`0x79f210`): no load for a height of 0 or less
/// (`0x79f2f1`-`0x79f2ff`), else [`face_loads`]. A failure writes nothing (`0x79f30f` →
/// `0x79f361`). Borrows the model, so a caller asks before it holds it.
pub(super) fn instance_loads(lua: &Lua, path: &str, height: f32) -> bool {
height > 0.0 && face_loads(&lua.app_data_ref::<Model>().expect("model"), path)
}
/// Install the twelve font-block methods onto `m`, reading `this` through `resolve`; only for a
/// widget whose reference method table carries all twelve.
pub(super) fn install(
lua: &Lua,
m: &Table,
resolve: ResolveRegion,
widget: &'static str,
kind: FontKind,
on_font_change: Option<FontChanged>,
) -> mlua::Result<()> {
// ── the font object ──
@@ -130,48 +216,72 @@ pub(super) fn install(
// ── the face ──
// SetFont(path, height [, flags]) → the number 1 (`0x79f345`), or nil on a failed load
// (`0x79f361`), never a boolean. The EditBox shim passes it through (`0x7972b2` leaves `eax`
// alone), unlike `Button:SetFont` (`0x780880`), which returns nothing.
// alone), unlike `Button:SetFont` (`0x780880`), which returns nothing. A failed load writes
// nothing: the shared path jumps past its stores (`0x79f30f` → `0x79f361`), the FontString's
// returns before its own (`0x771571`-`0x77157b`).
m.set(
"SetFont",
lua.create_function(
move |lua, (this, file, height, flags): (Table, Value, Value, Option<String>)| {
let (path, height) = set_font_args(&file, &height, widget)?;
move |lua, (this, file, height, flags): (Table, Value, Value, Value)| {
let FontArgs {
path,
height,
flags,
} = set_font_args(lua, &this, &file, &height, &flags)?;
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let before = model
.region_data
.get(&rh)
.map(|d| (d.font_path.clone(), d.font_height, d.outline));
// A failed load (`0x5c1ae0` under the `0x44d040` font cache) is the host's probe to
// judge, as only the store knows the files; with no probe, a non-empty path loads.
let ok =
!path.is_empty() && model.font_probe.as_ref().is_none_or(|probe| probe(&path));
let load = match kind {
// A FontString loads for any height but 0 (`0x77154d`-`0x77155b`); 0 skips the
// load and frees its font (`0x7715b3`), which still answers 1. What a negative
// height does inside the load (`0x44d040`) is untraced: the probe judges it.
FontKind::FontString if height == 0.0 => Load::Freed,
FontKind::FontString => Load::verdict(face_loads(&model, &path)),
// The shared path answers nil for a height of 0 or less without loading
// (`0x79f2f1`-`0x79f2ff`).
FontKind::Instance => Load::verdict(height > 0.0 && face_loads(&model, &path)),
};
if load == Load::Failed {
return Ok(Value::Nil);
}
let d = model.region_data.entry(rh).or_default();
// Each supplied argument is an explicit set, kept through a later font-object
// change; the height applies even when the face fails to load.
if ok {
// Each argument is an explicit set, kept through a later font-object change
// (`0x79f33a`, `0x79d625`). The flags are part of the font the load picks
// (`0x44d040`), none included, and a freed font has none.
if load == Load::Loaded {
d.font_path = Some(path);
d.font_explicit.face = true;
d.outline = flags;
} else {
d.font_path = None;
d.outline = Outline::None;
}
d.font_height = Some(height);
d.font_explicit.face = true;
d.font_explicit.outline = true;
d.font_explicit.height = true;
if let Some(f) = flags {
// The Lua spelling (`OUTLINE`, `THICKOUTLINE`), not the XML one, parsed like
// `0x6f1a90`: a case-insensitive substring scan, so `THICKOUTLINE` sets both.
d.outline = Outline::flags(&f);
d.font_explicit.outline = true;
}
let after = (d.font_path.clone(), d.font_height, d.outline);
model.touch_measure(rh);
// A FontString's load of another face, height or flags clears its valid bit and
// invalidates (`0x7715e0`); the same font returns first (`0x771541`), and a freed
// one skips it (`0x7715b9`).
if kind == FontKind::FontString
&& load == Load::Loaded
&& before.as_ref() != Some(&after)
{
model.clear_region_valid(rh);
}
drop(model);
// A loaded face that changed the path, height or flags notifies (`0x79f32f` sets
// the change bits, `0x79f342` calls the notify); a failed load does not.
if ok && before.as_ref() != Some(&after) {
// A load that changed the path, height or flags notifies (`0x79f32f` sets the
// change bits, `0x79f342` calls the notify).
if before.as_ref() != Some(&after) {
if let Some(changed) = on_font_change {
changed(lua, &this)?;
}
}
Ok(if ok { Value::Number(1.0) } else { Value::Nil })
Ok(Value::Number(1.0))
},
)?,
)?;
@@ -204,16 +314,17 @@ pub(super) fn install(
m.set(
"SetTextColor",
lua.create_function(
move |lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
move |lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = super::object::alpha_f32(lua, &a);
let (r, g, b) = (
super::object::as_f32(&r),
super::object::as_f32(&g),
super::object::as_f32(&b),
super::object::tonumber_f32(lua, &r),
super::object::tonumber_f32(lua, &g),
super::object::tonumber_f32(lua, &b),
);
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
d.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]);
d.vertex_color = Some([r, g, b, a]);
// Clears the colour inherit bit (`0x79dbd0`): it survives a font-object repaint.
d.font_explicit.color = true;
Ok(())
@@ -243,11 +354,12 @@ pub(super) fn install(
"SetShadowColor",
lua.create_function(
// r, g and b are a bare `lua_tonumber` (`0x79dd40`), as in `SetTextColor`.
move |lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
move |lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = super::object::alpha_f32(lua, &a);
let (r, g, b) = (
super::object::as_f32(&r),
super::object::as_f32(&g),
super::object::as_f32(&b),
super::object::tonumber_f32(lua, &r),
super::object::tonumber_f32(lua, &g),
super::object::tonumber_f32(lua, &b),
);
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
@@ -255,7 +367,7 @@ pub(super) fn install(
let offset = d.font_shadow.map_or([0.0, 0.0], |s| s.offset);
d.font_shadow = Some(FontShadow {
offset,
color: [r, g, b, a.unwrap_or(1.0)],
color: [r, g, b, a],
});
d.font_explicit.shadow = true;
Ok(())
@@ -288,6 +400,8 @@ pub(super) fn install(
color,
});
d.font_explicit.shadow = true;
// The offset seeds `GetStringWidth`'s line, so it is in the measure key.
model.touch_measure(rh);
Ok(())
})?,
)?;
@@ -305,5 +419,51 @@ pub(super) fn install(
})?,
)?;
// ── the line spacing ──
// SetSpacing(spacing) → nothing (`0x79fbbe`). The reference notifies on every call
// (`0x79fbb1`-`0x79fbb9`) and the EditBox acts only on a set change bit (mask `0x11`,
// `0x77e2b5`); here the notify runs only when the value changed.
m.set(
"SetSpacing",
lua.create_function(move |lua, (this, v): (Table, Value)| {
let spacing = spacing_arg(lua, &this, &v)?;
let rh = resolve(lua, &this)?;
let changed = {
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let changed = d.spacing.to_bits() != spacing.to_bits();
d.spacing = spacing;
// Clears inherit bit `0x10` (`0x79e37b`, `0x79fbb1`).
d.font_explicit.spacing = true;
model.touch_measure(rh);
// A FontString's new spacing clears its valid bit and invalidates (`0x771666`);
// the same one returns first (`0x77161c`).
if changed && kind == FontKind::FontString {
model.clear_region_valid(rh);
}
changed
};
if changed {
if let Some(notify) = on_font_change {
notify(lua, &this)?;
}
}
Ok(())
})?,
)?;
// GetSpacing() → one number (`0x79fbe0`, FontString `0x79e1d0`).
m.set(
"GetSpacing",
lua.create_function(move |lua, this: Table| {
let rh = resolve(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let d = model.region_data.get(&rh);
Ok(match kind {
FontKind::FontString => d.map_or(0.0, super::RegionData::line_gap),
FontKind::Instance => d.map_or(0.0, |d| d.spacing),
})
})?,
)?;
Ok(())
}
+145
View File
@@ -0,0 +1,145 @@
//! The glue screens' FrameScript state, which [`UiScript::new_glue`] builds: what the glue builder
//! (`0x46a7b0`) gives Lua where `UI_Init` (`0x48fbf0`) gives it the world's surface.
//!
//! The reference keeps one Lua state (`0xceef74`). The glue builder starts from the bare base set
//! (`0x7039e0`): on a rebuild it resets the state (`0x703b80` at `0x46a87b`, re-running the base
//! init), while the first build (`0x46a505`, passing 0) skips the reset (`je` at `0x46a879`) and
//! takes the state the startup init left (`0x402aee`). Either way it then runs the glue registrar
//! `0x46abb0` and loads the 26-event table below in place of the in-game one
//! (`0x703d90` at `0x46a88f`). The frame types are a process-wide registry instead
//! ([`super::object::registered_frame_kind`]).
//!
//! [`UiScript::new_glue`]: super::UiScript::new_glue
use mlua::Lua;
use super::Model;
/// Which of the reference's two FrameScript states a VM is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VmKind {
/// `UI_Init`'s: the in-game surface over FrameXML.
InGame,
/// The glue builder's: the base set, the three shared registrars and the glue verbs, over
/// GlueXML.
Glue,
}
/// The glue event table by id (`0xb41e70`, written by `0x46cc60`), `reference/1.12-glue-events.tsv`.
/// While the glue state runs these are the only events: `RegisterEvent` matches a name against
/// them case-insensitively (`0x702140` with `0x64a4c0`) and drops one they lack without an error.
pub const GLUE_EVENTS: [&str; 26] = [
"SET_GLUE_SCREEN",
"START_GLUE_MUSIC",
"DISCONNECTED_FROM_SERVER",
"OPEN_STATUS_DIALOG",
"UPDATE_STATUS_DIALOG",
"CLOSE_STATUS_DIALOG",
"ADDON_LIST_UPDATE",
"CHARACTER_LIST_UPDATE",
"UPDATE_SELECTED_CHARACTER",
"OPEN_REALM_LIST",
"GET_PREFERRED_REALM_INFO",
"UPDATE_SELECTED_RACE",
"SELECT_LAST_CHARACTER",
"SELECT_FIRST_CHARACTER",
"GLUE_SCREENSHOT_SUCCEEDED",
"GLUE_SCREENSHOT_FAILED",
"PATCH_UPDATE_PROGRESS",
"PATCH_DOWNLOADED",
"SUGGEST_REALM",
"SUGGEST_REALM_WRONG_PVP",
"SUGGEST_REALM_WRONG_CATEGORY",
"SHOW_SERVER_ALERT",
"FRAMES_LOADED",
"FORCE_RENAME_CHARACTER",
"SHOW_SURVEY_NOTIFICATION",
"PLAYER_ENTER_PIN",
];
/// The in-game event table: the 549-slot name array `UI_Init` loads (`0x703d90` at `0x48feab`),
/// `reference/1.12-event-catalog.tsv`. The 130 slots the client never writes stay nameless, and
/// `0x703d90` skips a null name (`0x703e0c`), so the 419 names here are all it holds.
const IN_GAME_EVENT_TABLE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-event-catalog.tsv"
));
/// The in-game table's distinct names, in id order.
pub(crate) fn in_game_events() -> &'static [&'static str] {
static NAMES: std::sync::LazyLock<Vec<&'static str>> = std::sync::LazyLock::new(|| {
let mut names: Vec<&'static str> = Vec::new();
for line in IN_GAME_EVENT_TABLE.lines() {
// `id<TAB>NAME`; a name two slots share (`UNIT_PET`) is one event to a name match.
let Some((_, name)) = line.split_once('\t') else {
continue;
};
if !line.starts_with('#') && !names.contains(&name) {
names.push(name);
}
}
names
});
&NAMES
}
impl VmKind {
/// The event table's own spelling of `name` for `RegisterEvent`/`UnregisterEvent` (`0x702140`,
/// `0x702280`), or `None` for a name the table lacks, which registers nothing.
///
/// The reference compares the name with every table entry's, ASCII case folded (`0x64a4c0` →
/// `0x414310`), and a miss returns without an error. A name two slots share registers on both,
/// which a name-keyed listener list is one entry for.
pub(crate) fn event_key(self, name: &str) -> Option<&'static str> {
let table: &'static [&'static str] = match self {
VmKind::InGame => in_game_events(),
VmKind::Glue => &GLUE_EVENTS,
};
table.iter().copied().find(|e| e.eq_ignore_ascii_case(name))
}
/// Whether the host may fire `event` into this VM. The reference fires by id
/// (`0x703e50`), so the glue state can only ever see one of its 26; a host's in-game event sent
/// to the glue VM reaches no frame, `RegisterAllEvents` ones included.
pub(crate) fn fires(self, event: &str) -> bool {
match self {
VmKind::InGame => true,
VmKind::Glue => GLUE_EVENTS.contains(&event),
}
}
}
/// The glue core's screen verbs (registrar `0x46ddf0`, table `0x8373b8`): the screen root's size
/// and the cursor in it. The glue and in-game states share the root, `0x41ad10`'s, 768 units tall
/// and `768·a` wide for the aspect `a` (the glue builder sets it at `0x46a828`, from `0x46ab40`:
/// `gxResolution`'s width over height with `widescreen` on, its default, else 4:3). The glue has
/// no `UIParent` and so no UI scale: `GetScreenWidth` (`0x46dd30`) and `GetScreenHeight`
/// (`0x46dd80`) are the in-game pair (`0x48b480`, `0x48b4d0`) without its `0x494590` division,
/// and `GetCursorPosition` (`0x46dad0`) is byte for byte the in-game `0x48b820`.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set(
"GetScreenWidth",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.screen.width())
})?,
)?;
g.set(
"GetScreenHeight",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.screen.height())
})?,
)?;
g.set(
"GetCursorPosition",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.cursor_pos)
})?,
)?;
super::glue_core::install(lua)?;
super::glue_realm::install(lua)?;
super::glue_select::install(lua)
}
+804
View File
@@ -0,0 +1,804 @@
//! The glue core's login-screen verbs (registrar `0x46ddf0`, table `0x8373b8`): the login, the
//! status dialog's button, the saved account, the four user agreements, the build, the locale,
//! the server, the screen name, the glue music, the movie frame's settings and the quit. Each reads a slot the host seeds or
//! queues a [`GlueRequest`] the host's glue manager drains; the screen state machine they drive
//! (`CGlueMgr::Update`, `0x46b930`) is the host's.
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::{flag, string_arg};
use super::Model;
/// One of the four user agreements the login screen walks before it shows the login boxes
/// (`AccountLogin_ShowUserAgreements`), each a CVar the glue manager reads once at startup
/// (`0x46a400`): `readTOS`, `readEULA`, `readScanning`, `readContest`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GlueNotice {
Tos,
Eula,
Scanning,
Contest,
}
impl GlueNotice {
/// All four, in the glue manager's seeding order (`0x46a406`-`0x46a48e`).
pub const ALL: [GlueNotice; 4] = [
GlueNotice::Tos,
GlueNotice::Eula,
GlueNotice::Scanning,
GlueNotice::Contest,
];
fn index(self) -> usize {
self as usize
}
}
/// One agreement's two flags, as the glue manager seeds them from its CVar's integer value
/// (`0x46a41a`-`0x46a42e` for `readTOS`): accepted is the value being 1, the notice (the agreement
/// changed since it was read) is the value being negative.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct NoticeState {
pub accepted: bool,
pub show_notice: bool,
}
impl NoticeState {
/// The flags for a CVar value, read as `atoi` reads it: anything unparseable is 0.
pub fn from_cvar(value: &str) -> Self {
let n = leading_int(value);
NoticeState {
accepted: n == 1,
show_notice: n < 0,
}
}
}
/// `atoi`: an optional sign and the leading digits, 0 when there are none.
pub(super) fn leading_int(s: &str) -> i64 {
let s = s.trim_start();
let (sign, digits) = match s.as_bytes().first() {
Some(b'-') => (-1, &s[1..]),
Some(b'+') => (1, &s[1..]),
_ => (1, s),
};
let end = digits
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(digits.len());
sign * digits[..end].parse::<i64>().unwrap_or(0)
}
/// `DefaultServerLogin`'s two strings. Never logged: the `Debug` form names the account only.
pub struct GlueCredentials {
pub account: String,
pub password: String,
}
impl std::fmt::Debug for GlueCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GlueCredentials")
.field("account", &self.account)
.field("password", &"<redacted>")
.finish()
}
}
/// A glue verb's effect on the host, drained by its glue manager.
#[derive(Debug)]
pub enum GlueRequest {
/// `DefaultServerLogin(account, password)` (`0x46d160`), handed to `CGlueMgr::Login`
/// (`0x46afb0`), whose guards and dialogs are the host's.
Login(GlueCredentials),
/// `StatusDialogClick()` (`0x46d270` → `0x46b720`): the status dialog's button, per screen
/// state.
StatusDialogClick,
/// `SetSavedAccountName(name)` (`0x46cdf0`): the `accountName` CVar's new value.
SetSavedAccountName(String),
/// `AcceptTOS` and its three siblings (`0x46d000`, `0x46d070`, `0x46d0e0`, `0x46d150`): the
/// agreement's CVar set to `"1"` (`0x46aef0` and its siblings).
Accept(GlueNotice),
/// `PlayGlueMusic(file)` (`0x46ceb0` → `0x45aeb0`).
PlayGlueMusic(String),
/// `StopGlueMusic()` (`0x46cf00`): `0x45b050(1.0)`, a one-second fade.
StopGlueMusic,
/// `DisconnectFromServer()` (`0x46d340`): the world connection dropped if up, then the logon
/// cancelled.
DisconnectFromServer,
/// `QuitGame()` (`0x46cea0` → `0x46b870` → `0x401560(0)`).
QuitGame,
/// `RequestRealmList([dialog])` (`0x46ecf0` → `0x46b8d0`): screen state 4, the status dialog
/// when `dialog`, and a realm list query (`0x5ab840`).
RequestRealmList { dialog: bool },
/// `CancelRealmListQuery()` (`0x46ed10` → `0x46b7e0`): in screen state 4 only, the query
/// completes cancelled and the state goes to 0; nothing is sent.
CancelRealmListQuery,
/// `RealmListDialogCancelled()` on the login screen (`0x46b810` → `CLoginMgr::Cancel`
/// `0x5b3320`): the realmd link closes, with no dialog and no screen change.
CancelLogon,
/// `ChangeRealm` reaching `ConnectToRealm` (`0x46b210`) with this realm, `realmName` already
/// set to it (`0x5ab7b0`).
ChangeRealm(String),
/// `GetCharacterListUpdate()` (`0x473270` → `0x4723d0` → `0x46b880`): the roster is already
/// cleared in the VM; the host takes screen state 3, the `CHAR_LIST_RETRIEVING` dialog and
/// the `CMSG_CHAR_ENUM`.
GetCharacterList,
/// `LaunchURL(url)` (`0x46cf60` → `0x5abd00`) for a URL that passed `launch_url_allowed`
/// (`0x5abd00` tests `0x5abc10` first): the player's browser opens it (`0x429ff0`).
LaunchUrl(String),
}
/// The hosts `LaunchURL` opens, the 10-entry table at `0x85cc34`: a leading `*` stands for one
/// label, so `*.blizzard.com` takes `forums.blizzard.com` and not `a.b.blizzard.com`.
const LAUNCH_HOSTS: [&str; 10] = [
"*.blizzard.com",
"*.blizzcon.com",
"www.battle.net",
"*.worldofwarcraft.com",
"www.battlenet.co.kr",
"*.worldofwarcraft.co.kr",
"*.blizzard-europe.com",
"*.wow-europe.com",
"*.wowchina.com",
"*.wowtaiwan.com.tw",
];
/// The gate in front of the browser: `0x5abd00` tests `0x5abc10` and `0x429ff0` takes the URL.
/// The URL begins `http://` (`0x85d6bc`, compared case-folded), every byte after it is a letter,
/// a digit, `.`, `-` or `/` (so no query, port, escape or `_`), and its host, the text up to the
/// first `/`, is one of [`LAUNCH_HOSTS`], compared case-folded. `0x429ff0` refuses a URL of 0x400
/// bytes or more (`0x42a01c`). The reference works on a C string, so the verb cuts its argument
/// at a NUL before asking; a URL with a NUL in it is refused here.
///
/// Deviation: a host of 0x80 bytes or more is refused. The reference copies the host into a 0x80-byte
/// buffer (`0x5abc7c`-`0x5abc9c`) and writes its terminator one byte past the end, and the cut
/// host could end in a whitelisted suffix the real one does not; this takes the safe side.
pub fn launch_url_allowed(url: &str) -> bool {
if url.len() >= 0x400 || url.contains('\0') {
return false;
}
let url = url.as_bytes();
const SCHEME: &[u8] = b"http://";
let Some(rest) = url
.get(..SCHEME.len())
.filter(|head| head.eq_ignore_ascii_case(SCHEME))
.map(|_| &url[SCHEME.len()..])
else {
return false;
};
if !rest
.iter()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'/'))
{
return false;
}
let host = rest.split(|&b| b == b'/').next().unwrap_or_default();
if host.len() >= 0x80 {
return false;
}
LAUNCH_HOSTS
.iter()
.any(|entry| match entry.strip_prefix('*') {
// `SStrChr(host, '.')`, then the rest against the entry past its `*`.
Some(suffix) => host
.iter()
.position(|&b| b == b'.')
.is_some_and(|dot| host[dot..].eq_ignore_ascii_case(suffix.as_bytes())),
None => host.eq_ignore_ascii_case(entry.as_bytes()),
})
}
/// The realm `GetServerName` (`0x46d280`) answers: the `realmName` CVar's text, and the
/// selected realm's PvP and RP flags off its `Cfg_Configs` row, nil without a selected realm.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GlueServer {
pub name: String,
pub pvp: bool,
pub rp: bool,
}
/// The glue core's slots and queue in the VM's model.
#[derive(Default)]
pub(crate) struct GlueCore {
requests: Vec<GlueRequest>,
/// The `accountName` CVar (`[0x8826c8]`), which `GetSavedAccountName` (`0x46cdd0`) reads.
saved_account: String,
notices: [NoticeState; 4],
/// ClientServices' world-connection flag (`[svc+0x1b00]`), which `IsConnectedToServer`
/// (`0x46d380`) reads.
connected: bool,
server: GlueServer,
/// The current glue screen's name (`[0xb41478]`), which `SetCurrentScreen` (`0x46ce60` →
/// `0x46b860`) copies into a 0x40-byte buffer.
pub(super) screen: String,
/// `HideCursor` / `ShowCursor` (`0x46db70`/`0x46db60`, the device's `0x58b590`): whether the
/// glue has the pointer hidden. Nothing in the host reads it yet: the one caller, the movie
/// frame, hides the pointer for a movie that plays here for no frame at all.
cursor_hidden: bool,
/// The realm list the realm verbs read ([`super::glue_realm`]).
pub(super) realms: super::glue_realm::GlueRealmList,
/// The character roster and the select verbs' cells ([`super::glue_select`]).
pub(super) roster: super::glue_select::GlueRoster,
}
/// `SetCurrentScreen`'s buffer, 0x40 bytes with its terminator (`0x46b860`, `SStrCopy`).
const SCREEN_NAME_MAX: usize = 0x3f;
impl super::UiScript {
/// Drain the glue verbs' requests since the last call.
pub fn take_glue_requests(&mut self) -> Vec<GlueRequest> {
std::mem::take(&mut self.model_mut().glue.requests)
}
/// Seed the saved account name `GetSavedAccountName` answers.
pub fn set_glue_saved_account(&mut self, name: &str) {
self.model_mut().glue.saved_account = name.to_string();
}
/// Seed one agreement's flags.
pub fn set_glue_notice(&mut self, notice: GlueNotice, state: NoticeState) {
self.model_mut().glue.notices[notice.index()] = state;
}
/// One agreement's flags as the VM holds them.
pub fn glue_notice(&self, notice: GlueNotice) -> NoticeState {
self.model_ref().glue.notices[notice.index()]
}
/// Seed whether the world connection is up.
pub fn set_glue_connected(&mut self, connected: bool) {
self.model_mut().glue.connected = connected;
}
/// Seed the realm `GetServerName` answers.
pub fn set_glue_server(&mut self, server: GlueServer) {
self.model_mut().glue.server = server;
}
/// Whether the glue has hidden the pointer (`HideCursor`) and not shown it again.
pub fn glue_cursor_hidden(&self) -> bool {
self.model_ref().glue.cursor_hidden
}
/// The current glue screen's name, as `SetCurrentScreen` last set it.
pub fn glue_screen(&self) -> String {
self.model_ref().glue.screen.clone()
}
/// A string global's text, `GetGlobalString`'s answer (`0x703bf0` with no gender and no
/// ordinal takes `0x704350`): the global's string (a number stringified), else the empty
/// string (`0x882748`).
pub fn global_text(&self, key: &str) -> String {
global_text(&self.lua, key)
}
}
pub(super) fn queue(lua: &Lua, request: GlueRequest) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.glue
.requests
.push(request);
}
/// The text of a string global, as [`super::UiScript::global_text`] reads it.
fn global_text(lua: &Lua, key: &str) -> String {
match lua.globals().get::<Value>(key) {
Ok(v @ (Value::String(_) | Value::Integer(_) | Value::Number(_))) => lua
.coerce_string(v)
.ok()
.flatten()
.map(|s| s.to_string_lossy())
.unwrap_or_default(),
_ => String::new(),
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// Both arguments `is-number-or-string` (`0x46d168`, `0x46d178`), else the usage error
// (`0x837cbc`); then `CGlueMgr::Login(account, password)` (`0x46d1a0`).
g.set(
"DefaultServerLogin",
lua.create_function(|lua, (account, password): (Value, Value)| {
const USAGE: &str = "Usage: DefaultServerLogin(\"accountName\", \"password\")";
let ok =
|v: &Value| matches!(v, Value::String(_) | Value::Integer(_) | Value::Number(_));
if !ok(&account) || !ok(&password) {
return Err(mlua::Error::RuntimeError(USAGE.into()));
}
let account = string_arg(lua, account, USAGE)?;
let password = string_arg(lua, password, USAGE)?;
queue(
lua,
GlueRequest::Login(GlueCredentials { account, password }),
);
Ok(())
})?,
)?;
g.set(
"StatusDialogClick",
lua.create_function(|lua, _: MultiValue| {
queue(lua, GlueRequest::StatusDialogClick);
Ok(())
})?,
)?;
// The `accountName` CVar's string (`0x46cdd5`, `[cvar+0x20]`).
g.set(
"GetSavedAccountName",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
lua.create_string(&model.glue.saved_account)
})?,
)?;
// A string argument (`0x46cdf8`) or `Usage: SetSavedAccountName("accountName")` (`0x837c28`),
// then `CVar::Set` (`0x46ce2e`), so the next `GetSavedAccountName` reads it back at once.
g.set(
"SetSavedAccountName",
lua.create_function(|lua, name: Value| {
let name = string_arg(lua, name, "Usage: SetSavedAccountName(\"accountName\")")?;
lua.app_data_mut::<Model>()
.expect("model app_data")
.glue
.saved_account = name.clone();
queue(lua, GlueRequest::SetSavedAccountName(name));
Ok(())
})?,
)?;
// Each agreement: `Show*Notice` and `*Accepted` push 1 or nil off the flag pair the glue
// manager seeded (`[0xb41df8]`/`[0xb41dfc]` for the TOS, `[0xb41e00]`/`[0xb41e04]` the EULA,
// `[0xb41e08]`/`[0xb41e0c]` scanning, `[0xb41e10]`/`[0xb41e14]` the contest); `Accept*` sets
// accepted, clears the notice and sets the CVar to "1" (`0x46aef0`, `0x46af20`, `0x46af50`,
// `0x46af80`).
for (notice, show, accepted, accept) in [
(GlueNotice::Tos, "ShowTOSNotice", "TOSAccepted", "AcceptTOS"),
(
GlueNotice::Eula,
"ShowEULANotice",
"EULAAccepted",
"AcceptEULA",
),
(
GlueNotice::Scanning,
"ShowScanningNotice",
"ScanningAccepted",
"AcceptScanning",
),
(
GlueNotice::Contest,
"ShowContestNotice",
"ContestAccepted",
"AcceptContest",
),
] {
let i = notice.index();
g.set(
show,
lua.create_function(move |lua, _: MultiValue| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(flag(model.glue.notices[i].show_notice))
})?,
)?;
g.set(
accepted,
lua.create_function(move |lua, _: MultiValue| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(flag(model.glue.notices[i].accepted))
})?,
)?;
g.set(
accept,
lua.create_function(move |lua, _: MultiValue| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.glue
.notices[i] = NoticeState {
accepted: true,
show_notice: false,
};
queue(lua, GlueRequest::Accept(notice));
Ok(())
})?,
)?;
}
// Five strings (`0x46cd70`): the version type and build type through `GetGlobalString`
// (`BETA_BUILD` `0x837c1c`, `RELEASE_BUILD` `0x837c0c`), then the version, the build and the
// date as the binary spells them. The in-game binding (`0x4884a0`) has the last three only.
g.set(
"GetBuildInfo",
lua.create_function(|lua, _: MultiValue| {
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(global_text(lua, "BETA_BUILD"))?),
Value::String(lua.create_string(global_text(lua, "RELEASE_BUILD"))?),
Value::String(lua.create_string(super::client::VERSION)?),
Value::String(lua.create_string(super::client::BUILD)?),
Value::String(lua.create_string(super::client::BUILD_DATE)?),
]))
})?,
)?;
// The locale name table `0x8558a4` at the locale index `[0xc0e080]` (`0x46ce40`), the same
// answer as the in-game binding: benilla is enUS-only.
g.set(
"GetLocale",
lua.create_function(|lua, _: MultiValue| lua.create_string("enUS"))?,
)?;
// A string argument (`0x46ce68`) or `Usage: SetCurrentScreen("screen")` (`0x837c54`), copied
// into the 0x40-byte screen buffer (`0x46b860`).
g.set(
"SetCurrentScreen",
lua.create_function(|lua, name: Value| {
let mut name = string_arg(lua, name, "Usage: SetCurrentScreen(\"screen\")")?;
if name.len() > SCREEN_NAME_MAX {
let mut end = SCREEN_NAME_MAX;
while !name.is_char_boundary(end) {
end -= 1;
}
name.truncate(end);
}
lua.app_data_mut::<Model>()
.expect("model app_data")
.glue
.screen = name;
Ok(())
})?,
)?;
// Three values (`0x46d280`): the `realmName` CVar's string, always a string, then PvP and RP
// as 1 or nil.
g.set(
"GetServerName",
lua.create_function(|lua, _: MultiValue| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let server = &model.glue.server;
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&server.name)?),
flag(server.pvp),
flag(server.rp),
]))
})?,
)?;
g.set(
"IsConnectedToServer",
lua.create_function(|lua, _: MultiValue| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(flag(model.glue.connected))
})?,
)?;
g.set(
"DisconnectFromServer",
lua.create_function(|lua, _: MultiValue| {
queue(lua, GlueRequest::DisconnectFromServer);
Ok(())
})?,
)?;
g.set(
"QuitGame",
lua.create_function(|lua, _: MultiValue| {
queue(lua, GlueRequest::QuitGame);
Ok(())
})?,
)?;
// A string argument (`0x46ceb8`) or `Usage: PlayGlueMusic("filename")` (`0x837c78`).
g.set(
"PlayGlueMusic",
lua.create_function(|lua, file: Value| {
let file = string_arg(lua, file, "Usage: PlayGlueMusic(\"filename\")")?;
queue(lua, GlueRequest::PlayGlueMusic(file));
Ok(())
})?,
)?;
// A string argument (`0x46cf68`) or `Usage: LaunchURL("URL")` (`0x837ca4`), then `0x5abd00`,
// which opens the browser for a URL `0x5abc10` passes and does nothing for any other. The
// in-game state has no such verb.
g.set(
"LaunchURL",
lua.create_function(|lua, url: Value| {
let url = string_arg(lua, url, "Usage: LaunchURL(\"URL\")")?;
// The C string ends at a NUL (`0x6f3690`), for the gate and for the shell alike.
let url = url.split('\0').next().unwrap_or_default();
if launch_url_allowed(url) {
queue(lua, GlueRequest::LaunchUrl(url.to_string()));
}
Ok(())
})?,
)?;
g.set(
"StopGlueMusic",
lua.create_function(|lua, _: MultiValue| {
queue(lua, GlueRequest::StopGlueMusic);
Ok(())
})?,
)?;
// One value (`0x46dbc0`): 1 when the `movieSubtitle` CVar's integer (`[0x882640]+0x28`) is
// nonzero, else nil.
g.set(
"GetMovieSubtitles",
lua.create_function(|lua, _: MultiValue| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let on = model
.cvars
.get("moviesubtitle")
.is_some_and(|c| leading_int(&c.value) != 0);
Ok(flag(on))
})?,
)?;
// One number (`0x46cf10`): the width of the `gxResolution` CVar's string, scanned as `"%dx%d"`
// (`0x837c9c`). A string with no leading integer leaves the scan's stack slot unwritten in the
// reference; 0 here.
g.set(
"GetMovieResolution",
lua.create_function(|lua, _: MultiValue| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let width = model
.cvars
.get("gxresolution")
.map_or(0, |c| leading_int(&c.value));
Ok(width as f64)
})?,
)?;
// The device's pointer off (`0x46db70` → `0x58b590(0)`) and on (`0x46db60` → `0x58b590(1)`).
for (name, hidden) in [("HideCursor", true), ("ShowCursor", false)] {
g.set(
name,
lua.create_function(move |lua, _: MultiValue| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.glue
.cursor_hidden = hidden;
Ok(())
})?,
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::UiScript;
#[test]
fn a_notice_cvar_reads_as_atoi() {
let s = |v| NoticeState::from_cvar(v);
assert_eq!(s("0"), NoticeState::default());
assert!(s("1").accepted && !s("1").show_notice);
assert!(s("-1").show_notice && !s("-1").accepted);
assert!(!s("2").accepted && !s("2").show_notice);
assert!(s("1x").accepted, "atoi stops at the first non-digit");
assert_eq!(s("x"), NoticeState::default());
}
#[test]
fn default_server_login_queues_the_strings_or_raises_its_usage() {
let mut s = UiScript::new_glue().unwrap();
s.run("DefaultServerLogin('probe', 1234)").unwrap();
let reqs = s.take_glue_requests();
match reqs.as_slice() {
[GlueRequest::Login(c)] => {
assert_eq!((c.account.as_str(), c.password.as_str()), ("probe", "1234"));
assert!(
!format!("{c:?}").contains("1234"),
"the password is never printed"
);
}
other => panic!("{other:?}"),
}
let err = s
.run("DefaultServerLogin('probe')")
.unwrap_err()
.to_string();
assert!(
err.contains("Usage: DefaultServerLogin(\"accountName\", \"password\")"),
"{err}"
);
assert!(s.take_glue_requests().is_empty());
}
#[test]
fn the_agreements_answer_their_seeded_flags_and_accept_sets_them() {
let mut s = UiScript::new_glue().unwrap();
s.set_glue_notice(
GlueNotice::Eula,
NoticeState {
accepted: false,
show_notice: true,
},
);
s.set_glue_notice(GlueNotice::Tos, NoticeState::from_cvar("1"));
let read = |s: &UiScript| {
s.eval::<(Option<i64>, Option<i64>, Option<i64>, Option<i64>)>(
"return EULAAccepted(), ShowEULANotice(), TOSAccepted(), ScanningAccepted()",
)
.unwrap()
};
assert_eq!(read(&s), (None, Some(1), Some(1), None));
s.run("AcceptEULA()").unwrap();
assert_eq!(read(&s), (Some(1), None, Some(1), None));
assert!(matches!(
s.take_glue_requests().as_slice(),
[GlueRequest::Accept(GlueNotice::Eula)]
));
}
#[test]
fn the_saved_account_reads_back_what_was_set() {
let mut s = UiScript::new_glue().unwrap();
s.set_glue_saved_account("PROBE");
assert_eq!(
s.eval::<String>("return GetSavedAccountName()").unwrap(),
"PROBE"
);
s.run("SetSavedAccountName('')").unwrap();
assert_eq!(
s.eval::<String>("return GetSavedAccountName()").unwrap(),
""
);
assert!(matches!(
s.take_glue_requests().as_slice(),
[GlueRequest::SetSavedAccountName(n)] if n.is_empty()
));
let err = s.run("SetSavedAccountName()").unwrap_err().to_string();
assert!(
err.contains("Usage: SetSavedAccountName(\"accountName\")"),
"{err}"
);
}
/// `0x5abc10`: the scheme, the character set past it and the host table.
#[test]
fn launch_url_opens_only_the_whitelisted_hosts() {
let ok = [
"http://www.worldofwarcraft.com/",
"HTTP://WWW.WorldOfWarcraft.com/index.html",
"http://forums.blizzard.com",
"http://us.blizzard.com/en-us/alert",
"http://www.battle.net/x",
"http://www.wow-europe.com/en/info/theburningcrusade.html",
"http://status.wow-europe.com/en/alert",
];
for url in ok {
assert!(launch_url_allowed(url), "{url}");
}
let refused = [
"https://www.blizzard.com/",
"ftp://www.blizzard.com/",
"www.blizzard.com",
"http://blizzard.com/",
"http://a.b.blizzard.com/",
"http://example.com/",
"http://example.com/www.blizzard.com",
"http://www.blizzard.com.evil.net/",
"http://battle.net/",
"http://www.blizzard.com/?q=1",
"http://www.blizzard.com:80/",
"http://www.blizzard.com/a_b",
"http://www.blizzard.com/a b",
"http://www.blizzard.com\n/",
"",
];
for url in refused {
assert!(!launch_url_allowed(url), "{url:?}");
}
// The gate takes a C string: a NUL inside one is refused outright.
assert!(!launch_url_allowed("http://www.blizzard.com/\0?q=1"));
// 0x400 bytes or more is refused (`0x42a01c`); one byte under is not.
let path = |n: usize| format!("http://www.blizzard.com/{}", "a".repeat(n - 24));
assert!(launch_url_allowed(&path(0x3ff)));
assert!(!launch_url_allowed(&path(0x400)));
// A host of 0x80 bytes or more is refused rather than cut to a whitelisted tail.
let host = |n: usize| format!("http://{}.blizzard.com/", "a".repeat(n - 13));
assert!(launch_url_allowed(&host(0x7f)));
assert!(!launch_url_allowed(&host(0x80)));
let cut = format!("http://{}.blizzard.com.evil.net/", "a".repeat(0x80 - 13));
assert!(!launch_url_allowed(&cut));
}
#[test]
fn launch_url_queues_a_passing_url_and_raises_its_usage() {
let mut s = UiScript::new_glue().unwrap();
s.run("LaunchURL('http://www.worldofwarcraft.com/')")
.unwrap();
s.run("LaunchURL('http://example.com/')").unwrap();
// The text before a NUL is the argument, for the gate and for the opener.
s.run("LaunchURL('http://www.blizzard.com/a\\0?q=1')")
.unwrap();
s.run("LaunchURL('http://example.com/\\0http://www.blizzard.com/')")
.unwrap();
let queued = s.take_glue_requests();
assert!(
matches!(
queued.as_slice(),
[GlueRequest::LaunchUrl(a), GlueRequest::LaunchUrl(b)]
if a == "http://www.worldofwarcraft.com/" && b == "http://www.blizzard.com/a"
),
"{queued:?}"
);
let err = s.run("LaunchURL()").unwrap_err().to_string();
assert!(err.contains("Usage: LaunchURL(\"URL\")"), "{err}");
assert!(s.take_glue_requests().is_empty());
}
#[test]
fn the_glue_build_info_is_five_strings() {
let s = UiScript::new_glue().unwrap();
s.run("BETA_BUILD = 'Version' RELEASE_BUILD = 'Release'")
.unwrap();
assert_eq!(
s.eval::<Vec<String>>("return { GetBuildInfo() }").unwrap(),
["Version", "Release", "1.12.1", "5875", "Sep 19 2006"]
);
}
#[test]
fn the_server_name_is_always_a_string_with_two_flags() {
let mut s = UiScript::new_glue().unwrap();
assert_eq!(
s.eval::<(String, Option<i64>, Option<i64>)>("return GetServerName()")
.unwrap(),
(String::new(), None, None)
);
s.set_glue_server(GlueServer {
name: "Nostalrius".into(),
pvp: true,
rp: false,
});
assert_eq!(
s.eval::<(String, Option<i64>, Option<i64>)>("return GetServerName()")
.unwrap(),
("Nostalrius".to_string(), Some(1), None)
);
}
#[test]
fn set_current_screen_keeps_the_name_in_its_buffer() {
let s = UiScript::new_glue().unwrap();
s.run("SetCurrentScreen('login')").unwrap();
assert_eq!(s.glue_screen(), "login");
s.run(&format!("SetCurrentScreen('{}')", "x".repeat(100)))
.unwrap();
assert_eq!(s.glue_screen().len(), 0x3f);
assert!(s.run("SetCurrentScreen()").is_err());
}
/// `GetMovieSubtitles` is 1 or nil off the `movieSubtitle` CVar's integer (`0x46dbc0`),
/// `GetMovieResolution` the width of `gxResolution` (`0x46cf10`), and the cursor pair moves
/// the hidden flag (`0x46db70`, `0x46db60`).
#[test]
fn the_movie_verbs_read_their_cvars_and_move_the_pointer_flag() {
let mut s = UiScript::new_glue().unwrap();
s.register_cvars([("movieSubtitle", "0"), ("gxResolution", "1024x768")]);
assert!(s.eval::<bool>("return GetMovieSubtitles() == nil").unwrap());
s.set_cvar_host("movieSubtitle", "1");
assert_eq!(
s.eval::<Option<i64>>("return GetMovieSubtitles()").unwrap(),
Some(1)
);
s.set_cvar_host("movieSubtitle", "0");
assert!(s.eval::<bool>("return GetMovieSubtitles() == nil").unwrap());
assert_eq!(
s.eval::<f64>("return GetMovieResolution()").unwrap(),
1024.0
);
s.set_cvar_host("gxResolution", "800x600");
assert_eq!(s.eval::<f64>("return GetMovieResolution()").unwrap(), 800.0);
assert!(s
.eval::<bool>("return table.getn({GetMovieResolution()}) == 1")
.unwrap());
assert!(!s.glue_cursor_hidden());
s.run("HideCursor()").unwrap();
assert!(s.glue_cursor_hidden());
s.run("ShowCursor()").unwrap();
assert!(!s.glue_cursor_hidden());
assert!(s.take_errors().is_empty());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15 -22
View File
@@ -247,18 +247,6 @@ fn member_at(roster: &[GuildMemberInfo], index: i64) -> Option<&GuildMemberInfo>
.and_then(|i| roster.get(i))
}
/// A flag argument: `nil`, `false` and `0` are off, anything else on. The reference reads both
/// flags with `GetBoolOrDefault` (`0x6f1c10`, `binding_abi::bool_or_default`), which differs on
/// strings and fractions; the FrameXML's checkbox states, `1` or `nil`, read the same in both.
fn truthy(value: &Value) -> bool {
match value {
Value::Nil | Value::Boolean(false) => false,
Value::Integer(0) => false,
Value::Number(n) => *n != 0.0,
_ => true,
}
}
/// Whether the player's own rank holds right `index`, 1-based into [`RANK_RIGHT_BITS`].
fn has_right(model: &Model, index: usize) -> bool {
model.guild.in_guild && model.guild.rights & RANK_RIGHT_BITS[index - 1] != 0
@@ -335,12 +323,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// ── The roster ───────────────────────────────────────────────────────────────────────────
// GetNumGuildMembers() (`0x4d1190`): the loop bound (`FriendsFrame.lua:446`), which respects
// show-offline, not the addressable range (`GuildState::num_members`).
// GetNumGuildMembers([includeOffline]) (`0x4d1190`): the loop bound (`FriendsFrame.lua:446`),
// which respects show-offline, not the addressable range (`GuildState::num_members`).
g.set(
"GetNumGuildMembers",
lua.create_function(|lua, ()| {
lua.create_function(|lua, args: MultiValue| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
// The flag is `GetBoolOrDefault` (`0x4d11a0`, default 0): a set one counts the whole
// roster (`[0xb73118]`) whatever show-offline says, else the loop bound.
if binding_abi::bool_or_default(args.front(), false) {
return Ok(model.guild.roster.len() as i64);
}
Ok(model.guild.num_members as i64)
})?,
)?;
@@ -438,14 +431,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(binding_abi::flag(model.guild.show_offline))
})?,
)?;
// With no argument it turns show-offline on, unlike an explicit `nil`, hence `MultiValue`.
// `GetBoolOrDefault` (`0x4d1e17`) with default 1: no argument turns show-offline on, unlike an
// explicit `nil`, hence `MultiValue`.
g.set(
"SetGuildRosterShowOffline",
lua.create_function(|lua, args: MultiValue| {
let on = match args.into_iter().next() {
None => true,
Some(flag) => truthy(&flag),
};
let on = binding_abi::bool_or_default(args.front(), true);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.guild.show_offline = on;
model.guild_requests.push(GuildRequest::SetShowOffline(on));
@@ -653,7 +644,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// GuildControlSetRankFlag(index, enabled) (`0x4d2070`): stage one checkbox, 1 to 13.
g.set(
"GuildControlSetRankFlag",
lua.create_function(|lua, (index, enabled): (i64, Value)| {
lua.create_function(|lua, (index, enabled): (i64, Option<Value>)| {
let Some(slot) = usize::try_from(index)
.ok()
.and_then(|i| i.checked_sub(1))
@@ -662,7 +653,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
return Ok(());
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.guild_control.staged[slot] = truthy(&enabled);
// `GetBoolOrDefault` (`0x4d20b7`), default 0.
model.guild_control.staged[slot] =
binding_abi::bool_or_default(enabled.as_ref(), false);
Ok(())
})?,
)?;
+198
View File
@@ -0,0 +1,198 @@
//! A text frame's hyperlink spans as the reference builds them: each span is a frame of its own, a
//! `CSimpleHyperlinkButton` (ctor `0x7a3240`) on the Button constructor (`0x7786a0`), so a
//! mouse-enabled child of the text frame with no name and no scripts. It takes the hover, the
//! press, the capture and the click like any Button, and its vtable (`0x81d5b0`) differs from the
//! Button's (`0x81c7f8`) in three code slots, each handing its link (`+0x4dc`) and text (`+0x4e0`)
//! to the text frame: the enter (`+0x4c`, `0x7a34b0`) to `OnHyperlinkEnter`, the leave (`+0x50`,
//! `0x7a34e0`) to `OnHyperlinkLeave`, and the click (`+0x94`, `0x7a3510`) to `OnHyperlinkClick`
//! (the parent's firers `0x7a3780`/`0x7a37b0`/`0x7a37e0`). The enter and the leave pass the link
//! alone (`"%s"`, `0x82e280`); the click passes the link, the text and the button name. Its click
//! mask is `LeftButtonUp` and `RightButtonUp` (`0x500`, `0x7a327d`).
//!
//! The text frame keeps a pool (`[+0x314]`): a span takes a button from it (`0x7a3880`), which is
//! placed over the span and shown (`0x7a3350`), and a span gone hides its button again (`0x7a3920`),
//! freeing nothing. The app lays the text out, so its spans arrive here as screen rects.
use mlua::{Lua, Value};
use crate::layout::{Anchor, Anchors, Point, Rect};
use crate::widget::{FrameHandle, FrameKind, KindState};
use super::{event, visibility, Model, UiScript, SCREEN};
/// The text frame and the `(link, text)` a hyperlink button carries, if `h` is one.
pub(super) fn owner_link(model: &Model, h: FrameHandle) -> Option<(FrameHandle, String, String)> {
let f = model.arena.frame(h)?;
match &f.kind_state {
KindState::Button(bs) => {
let (link, text) = bs.hyperlink.clone()?;
Some((f.parent?, link, text))
}
_ => None,
}
}
/// Fire the text frame's `OnHyperlinkEnter` or `OnHyperlinkLeave` for the button `h`, with the link
/// alone; false when `h` is not a hyperlink button, whose own enter or leave then runs.
pub(super) fn hover(lua: &Lua, h: FrameHandle, enter: bool) -> bool {
let Some((owner, link, _)) = owner_link(&lua.app_data_ref::<Model>().expect("model"), h) else {
return false;
};
let script = if enter {
"OnHyperlinkEnter"
} else {
"OnHyperlinkLeave"
};
fire(lua, owner, script, &[&link]);
true
}
/// Fire the text frame's `OnHyperlinkClick(link, text, button)` for the button `h`; false when `h`
/// is not a hyperlink button, whose own `OnClick` then runs.
pub(super) fn click(lua: &Lua, h: FrameHandle, button: &str) -> bool {
let Some((owner, link, text)) = owner_link(&lua.app_data_ref::<Model>().expect("model"), h)
else {
return false;
};
fire(lua, owner, "OnHyperlinkClick", &[&link, &text, button]);
true
}
fn fire(lua: &Lua, owner: FrameHandle, script: &str, args: &[&str]) {
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model");
if model.arena.frame(owner).is_none() {
return;
}
model.frame_id(owner)
};
let args: mlua::Result<Vec<Value>> = args
.iter()
.map(|a| lua.create_string(a).map(Value::String))
.collect();
let result = args.and_then(|args| event::fire_widget_handler(lua, id, script, args));
if let Err(e) = result {
lua.app_data_mut::<Model>()
.expect("model")
.record_lua_error(&e);
}
}
/// Replace the spans on screen, `(text frame, y-up rect, link, text)`: each text frame's spans take
/// its pooled buttons in order, a new one built when the pool runs out, and the buttons left over,
/// or of a text frame with no span now, hide.
pub(super) fn set_spans(lua: &Lua, spans: Vec<(FrameHandle, Rect, String, String)>) {
// One span: its rect, link and text.
type Span = (Rect, String, String);
let mut by_owner: Vec<(FrameHandle, Vec<Span>)> = Vec::new();
for (owner, rect, link, text) in spans {
match by_owner.iter_mut().find(|(o, _)| *o == owner) {
Some((_, list)) => list.push((rect, link, text)),
None => by_owner.push((owner, vec![(rect, link, text)])),
}
}
let (mut show, mut hide) = (Vec::new(), Vec::new());
let mut moved = false;
{
let mut model = lua.app_data_mut::<Model>().expect("model");
let model = &mut *model;
model
.link_buttons
.retain(|owner, _| model.arena.frame(*owner).is_some());
for (owner, list) in &by_owner {
if model.arena.frame(*owner).is_none() {
continue;
}
for (i, (rect, link, text)) in list.iter().enumerate() {
let pooled = model
.link_buttons
.get(owner)
.and_then(|pool| pool.get(i))
.copied();
let h = pooled.unwrap_or_else(|| build(model, *owner));
moved |= place(model, h, *rect, link, text);
if !model.arena.frame(h).is_some_and(|f| f.shown) {
show.push(h);
}
}
}
for (owner, pool) in &model.link_buttons {
let used = by_owner
.iter()
.find(|(o, _)| o == owner)
.map_or(0, |(_, list)| list.len());
hide.extend(
pool.iter()
.skip(used)
.filter(|&&h| model.arena.frame(h).is_some_and(|f| f.shown)),
);
}
}
for h in hide {
visibility::set_shown(lua, h, false);
}
for h in show {
visibility::set_shown(lua, h, true);
}
// The pointer hit-tests resolved rects, so a moved button is solved before the next input.
if moved {
UiScript::resolve_layout(&mut lua.app_data_mut::<Model>().expect("model"));
}
}
/// `0x7a3880`'s miss: a new button, a Button child of the text frame in its stratum one level up,
/// as a child is made, registered for `LeftButtonUp` and `RightButtonUp`. It is born hidden, so the
/// caller's show is its one transition, as `0x7a3350`'s `Show` is.
fn build(model: &mut Model, owner: FrameHandle) -> FrameHandle {
let h = model.arena.create(FrameKind::Button, None, Some(owner));
if let Some((strata, level)) = model.arena.frame(owner).map(|f| (f.strata, f.level)) {
model.arena.set_frame_strata(h, strata);
model.arena.set_frame_level(h, level + 1, false);
}
model.set_shown_silent(h, false);
if let Some(KindState::Button(bs)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
bs.registered_clicks = ["LeftButtonUp", "RightButtonUp"]
.into_iter()
.map(String::from)
.collect();
}
model.frame_id(h);
model.layout_inputs.entry(h).or_default();
model.link_buttons.entry(owner).or_default().push(h);
h
}
/// `0x7a3350`: store the link and text, and seat the button over the span, one TOPLEFT on the
/// screen with an explicit size, in its own units. True when its anchors or size changed.
fn place(model: &mut Model, h: FrameHandle, rect: Rect, link: &str, text: &str) -> bool {
if let Some(KindState::Button(bs)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
if bs
.hyperlink
.as_ref()
.is_none_or(|(l, t)| l != link || t != text)
{
bs.hyperlink = Some((link.to_string(), text.to_string()));
}
}
let scale = f64::from(super::object::eff_scale(model, h));
let root = model.screen;
let x = ((f64::from(rect.left) - f64::from(root.left)) / scale) as f32;
let y = ((f64::from(rect.top) - f64::from(root.top)) / scale) as f32;
let w = ((f64::from(rect.right) - f64::from(rect.left)) / scale) as f32;
let ht = ((f64::from(rect.top) - f64::from(rect.bottom)) / scale) as f32;
let pin = Anchors::from([Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, x, y)]);
let input = model.layout_inputs.entry(h).or_default();
let changed = !input.anchors.bits_eq(&pin)
|| input.width.to_bits() != w.to_bits()
|| input.height.to_bits() != ht.to_bits();
if changed {
input.anchors = pin;
input.width = w;
input.height = ht;
model.note_authored_size(h);
}
// The points are cleared (`0x7a3415`), one set with the invalidate flag (`0x7a3441`), and the
// size set through the setters, which invalidate (`0x7a3457`, `0x7a346b`).
model.invalidate_frame(h, changed);
changed
}
@@ -13,7 +13,7 @@ use super::Model;
/// `0x4c8520`, the registrar's shared slot reader: `is-number`, `tonumber`, the truncating cast,
/// then `dec` into the 0-based slot and the whitelist; a failure of either raises `message`. The
/// 0-based slot, `-1` for Lua 0, the ammo leg.
fn inventory_slot_arg(lua: &Lua, v: Value, message: &'static str) -> mlua::Result<i32> {
pub(super) fn inventory_slot_arg(lua: &Lua, v: Value, message: &'static str) -> mlua::Result<i32> {
let slot0 = number_arg(lua, v, message)?.wrapping_sub(1);
if !inventory_slot_reader_accepts(slot0) {
return Err(mlua::Error::RuntimeError(message.into()));
@@ -102,16 +102,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// UpdateInventoryAlertStatus() (`0x4c9560`): the alert recompute (`0x4c7ee0`), which fires
// UPDATE_INVENTORY_ALERTS unconditionally; zero returns. Stock calls it when the durability
// UPDATE_INVENTORY_ALERTS (`0x4c8085`) in place, after every write; zero returns. Stock calls it when the durability
// figure's enchant timer runs out (`DurabilityFrame.lua:81`).
g.set(
"UpdateInventoryAlertStatus",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
recompute_inventory_alerts(&mut model);
model
.pending_events
.push(("UPDATE_INVENTORY_ALERTS".to_string(), Vec::new()));
super::tick::verb(lua, |model| {
recompute_inventory_alerts(model);
model.raise("UPDATE_INVENTORY_ALERTS", Vec::new());
});
Ok(MultiValue::new())
})?,
)?;
+23 -40
View File
@@ -115,6 +115,25 @@ pub struct PlayerReqState {
pub honor_rank: u8,
}
/// The icon an item-texture getter answers while the item's template is unknown or its display
/// has no icon: `0x5d8af0` looks the template up, finds none and passes display id 0 to `0x5d88b0`,
/// which falls back to `INV_Misc_QuestionMark` (`0x847fe4`, `0x5d8927`) under the icon directory.
/// `GetContainerItemInfo` (`0x4f9708`) and `GetTradePlayerItemInfo` (`0x4c0529`) push it, so their
/// texture is a string on every path.
pub(super) const MISSING_ITEM_ICON: &str = "Interface\\Icons\\INV_Misc_QuestionMark";
/// The quality an item-info getter answers: the template's quality only when the template is
/// cached and its InventoryType is nonzero, else -1 (`GetContainerItemInfo` `0x4f9794`..`0x4f97a6`,
/// `GetTradePlayerItemInfo` `0x4c059c`..`0x4c05af`, `GetTradeTargetItemInfo` `0x4c0241`). A potion,
/// a reagent or a trade good (InventoryType 0) answers -1 with its template in hand, and the
/// number is never nil. `inventory_type` is 0 while the template is unresolved.
pub(super) fn gated_quality(quality: Option<u32>, inventory_type: u32) -> i64 {
match quality {
Some(q) if inventory_type != 0 => i64::from(q),
_ => -1,
}
}
/// [`item_usable`] by item entry: entry 0 and a template not yet answered are usable, as the
/// merchant getter skips an uncached record (`0x4fb298`).
pub(super) fn item_usable_by_id(model: &super::Model, item_id: u32) -> bool {
@@ -315,33 +334,6 @@ fn equip_loc_token(inventory_type: u32) -> &'static str {
TOKENS.get(inventory_type as usize).copied().unwrap_or("")
}
/// The reference's `SStrToInt` (`0x64ac60`) over an `item:` field: an optional `-`, then digits up
/// to the first non-digit, with no whitespace skip and no `+`. Deviation: an overflow saturates at
/// `u32::MAX` where the reference wraps, because only a script can overflow it.
fn reference_atoi(s: &str) -> i64 {
let b = s.as_bytes();
let (neg, mut i) = match b.first() {
Some(b'-') => (true, 1),
_ => (false, 0),
};
let mut n: i64 = 0;
while let Some(&c) = b.get(i) {
if !c.is_ascii_digit() {
break;
}
n = n
.saturating_mul(10)
.saturating_add(i64::from(c - b'0'))
.min(i64::from(u32::MAX));
i += 1;
}
if neg {
-n
} else {
n
}
}
/// `GetItemInfo`'s argument as the reference parses it (`0x48e0a3`-`0x48e16d`): a number, or a
/// string `lua_isnumber` (`0x6f34d0`) coerces, is the id alone; another string (`0x6f3510`) must
/// pass `strnicmp(s, "item:", 5)` (`0x64a4c0`) for its four fields, else it is id 0, so a name or
@@ -372,7 +364,9 @@ fn parse_item_arg(v: &Value) -> mlua::Result<(i64, u32, u32, u32)> {
else {
return Ok((0, 0, 0, 0));
};
let mut fields = rest.splitn(4, ':').map(reference_atoi);
let mut fields = rest
.splitn(4, ':')
.map(crate::script::binding_abi::s_str_to_int);
let id = fields.next().unwrap_or(0);
let mut next = || u32::try_from(fields.next().unwrap_or(0)).unwrap_or(0);
Ok((id, next(), next(), next()))
@@ -577,7 +571,7 @@ mod tests {
#[cfg(test)]
mod get_item_info_tests {
use super::{equip_loc_token, reference_atoi, ItemTemplateView};
use super::{equip_loc_token, ItemTemplateView};
use crate::script::UiScript;
/// Worn Shortsword, item 25, as vmangos `item_template` holds it (item level 2).
@@ -806,17 +800,6 @@ mod get_item_info_tests {
assert_eq!(equip_loc_token(29), "", "the array's trailing \"\"");
assert_eq!(equip_loc_token(9999), "", "past the array");
}
#[test]
fn the_field_parser_is_the_references_atoi() {
assert_eq!(reference_atoi("2589"), 2589);
assert_eq!(reference_atoi("-5"), -5);
assert_eq!(reference_atoi(""), 0);
assert_eq!(reference_atoi("abc"), 0);
assert_eq!(reference_atoi(" 12"), 0, "no leading-whitespace skip");
assert_eq!(reference_atoi("+12"), 0, "no unary plus");
assert_eq!(reference_atoi("12abc"), 12, "stops at the first non-digit");
}
}
#[cfg(test)]
+3 -3
View File
@@ -321,7 +321,7 @@ impl super::UiScript {
/// Register one addon's parsed `Bindings.xml`, at the reference's point in the addon load:
/// after its `.toc` files, before its saved variables (`0x51f443`).
pub fn register_addon_bindings(&mut self, addon: &str, bindings: &[Binding]) {
pub fn register_addon_bindings(&self, addon: &str, bindings: &[Binding]) {
self.model_mut().keybinds.register(Some(addon), bindings);
}
@@ -760,7 +760,7 @@ mod tests {
#[test]
fn an_addons_bindings_xml_registers_as_ordinary_rows() {
let mut s = script();
let s = script();
let parsed = parse(
r#"<Bindings>
<Binding name="PROBEHOLD" runOnUp="true" header="PROBE">
@@ -849,7 +849,7 @@ mod tests {
/// stock `platform="mac"` iTunes rows are never commands.
#[test]
fn a_binding_for_another_platform_does_not_register() {
let mut s = script();
let s = script();
let parsed = parse(
r#"<Bindings>
<Binding name="PROBEHERE" platform="Windows">Here();</Binding>
+274 -108
View File
@@ -1,23 +1,41 @@
//! Frame keyboard delivery. One dispatcher per [`Channel`] calls the keyboard-enabled frames in
//! [`walk_order`] until one consumes. Consumption is the existence of a script slot, not what the
//! handler does: a key-down consumes on `OnKeyDown` or `OnKeyUp` and fires only `OnKeyDown`, so a
//! frame with only `OnKeyUp` swallows key-downs and runs nothing; a char consumes on `OnChar`.
//! Membership is the keyboard-enabled flag (`0x76af00`), never a script: XML `enableKeyboard` and
//! an XML `<Scripts>` handler set it, Lua `SetScript` does not.
//! Frame keyboard delivery. One dispatcher per [`Channel`] calls the frames in that channel's
//! bucket in [`walk_order`] until one consumes. Consumption is the existence of a script slot, not
//! what the handler does: a key-down consumes on `OnKeyDown` or `OnKeyUp` and fires only
//! `OnKeyDown`, so a frame with only `OnKeyUp` swallows key-downs and runs nothing; a char
//! consumes on `OnChar`. Membership is the frame's char or key bit (`0x76af00`), never a script:
//! the EditBox ctor, XML `enableKeyboard` and an XML `<Scripts>` handler set it, Lua `SetScript`
//! does not.
//!
//! The focused EditBox is a frame in the same walk, consuming through its own vtable, so a
//! keyboard frame in a higher stratum pre-empts it and one in a lower stratum does not. A key no
//! frame consumes falls through to the editbox routing, focus acquisition included.
//! An EditBox is a frame in the same walk, answering through its own vtable (`0x77a900`,
//! `0x77b160`): the focused box consumes, an unfocused one declines, and with no box focused an
//! `autoFocus` one takes the focus and the event at its own position. So a keyboard frame in a
//! higher stratum or level pre-empts the box, and one below it never sees the key.
//!
//! `OnKeyUp` is stored and gates key-downs but never fires: the host feeds no key-up, so the
//! reference's key-up gate (`0x76bba0`, `OnKeyUp` alone) and its sticky per-code target
//! (`0x765fd0`, `[root+code*4+0x84]`, the last frame to consume that code's down) are not built.
//! The key-up (`0x765fd0`) does not walk. It goes to the frame that consumed that key's last
//! down (`[root+code*4+0x84]`, written at `0x765fa2`), through its `OnKeyUp` slot (`0x76bba0`),
//! and the auto-repeat (`0x766070`, category `0xa`) to the same frame's `+0x58`: a stub on a plain
//! frame (`0x46ff90`), the key-down again on an EditBox (`0x77b770`). A delivered up never clears
//! the record.
use mlua::Lua;
use super::{event, Model};
use crate::widget::{FrameHandle, FrameKind};
/// What the key-down walk (`0x765f10`) came to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KeyWalk {
/// No frame took the key: the world frame's binding is next.
Declined,
/// A frame took it (`0x76b7d0`'s existence gate): nothing below it, the focused box and the
/// keybinding included, sees it.
Frame,
/// The walk reached an EditBox that took it: focused already or, with none focused, an
/// `autoFocus` box that just took the focus (`0x77b1c7`-`0x77b1db`). The key's editing action
/// is that box's.
Box,
}
/// The two walks the reference registers separately.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Channel {
@@ -27,15 +45,28 @@ enum Channel {
KeyDown,
}
/// The walk order (`0x765f10`): keyboard-enabled, effectively-visible frames, strata TOOLTIP down
/// to WORLD, then level descending, ties oldest registration first as the inserter keeps them
/// (`0x764aa0`).
fn walk_order(model: &Model) -> Vec<FrameHandle> {
let mut candidates: Vec<(FrameHandle, u8, u16, u32)> = model
/// The walk order (`0x765f10`): the channel's enabled, effectively-visible frames, strata TOOLTIP
/// down to WORLD, then level descending, ties oldest first by when each joined the bucket
/// (`0x764aa0` inserts after the equal levels; a show, a level or strata change and a live enable
/// each insert again).
fn walk_order(model: &Model, channel: Channel) -> Vec<FrameHandle> {
let mut candidates: Vec<(FrameHandle, u8, u16, u64)> = model
.arena
.iter_frames()
.filter(|(_, f)| f.effective_visible && f.keyboard_enabled)
.map(|(h, f)| (h, f.strata as u8, f.level, f.insertion_seq))
.filter(|(_, f)| {
f.effective_visible
&& match channel {
Channel::Char => f.char_enabled,
Channel::KeyDown => f.key_enabled,
}
})
.map(|(h, f)| {
let stamp = match channel {
Channel::Char => f.char_stamp,
Channel::KeyDown => f.key_stamp,
};
(h, f.strata as u8, f.level, stamp)
})
.collect();
candidates.sort_by(|a, b| b.1.cmp(&a.1).then(b.2.cmp(&a.2)).then(a.3.cmp(&b.3)));
candidates.into_iter().map(|(h, ..)| h).collect()
@@ -46,11 +77,9 @@ fn has_script(lua: &Lua, id: u32, name: &str) -> bool {
event::has_widget_handler(lua, id, name)
}
/// Whether `h` is a `CSimpleEditBox`, which the walk asks about focus, never a script slot: its
/// ctor (`0x779ce8`) puts it in both buckets, and its vtable `0x81c910` replaces the input slots
/// (`+0x5c` with `0x77a900`, `+0x60` with `0x77b160`) without chaining to the base, consuming
/// when focused and otherwise declining unless it takes a free focus as `autoFocus`
/// (`0x77a952`/`0x77a956`, `0x77b1c7`/`0x77b21e`). So an unfocused box's `OnChar` (`+0x180`)
/// Whether `h` is a `CSimpleEditBox`, which the walk asks [`super::editbox::takes_event`] about,
/// never a script slot: its ctor (`0x779ce8`) puts it in both buckets, and its vtable `0x81c910`
/// replaces the input slots without chaining to the base. So an unfocused box's `OnChar` (`+0x180`)
/// never runs and never eats a neighbour's keys.
fn is_editbox(lua: &Lua, h: FrameHandle) -> bool {
let model = lua.app_data_ref::<Model>().expect("model app_data");
@@ -60,62 +89,233 @@ fn is_editbox(lua: &Lua, h: FrameHandle) -> bool {
.is_some_and(|f| f.kind == FrameKind::EditBox)
}
/// One channel's walk, gate and fire. Returns whether a frame consumed the event, which is what
/// suppresses the keybinding; a handler's own return is discarded (`0x7026f0`).
fn walk(lua: &Lua, channel: Channel, arg: &str) -> bool {
let order = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
walk_order(&model)
/// Whether the EditBox `h` declines `key` before it looks at focus (`0x77b160`): PRINTSCREEN
/// (`0x77b17f`), and the four arrows on a box in alt-arrow mode (`ignoreArrows`) with ALT up
/// (`0x77b18e`-`0x77b1c4`), so the walk goes on below it and the world frame's binding can run.
fn box_declines(lua: &Lua, h: FrameHandle, key: &str) -> bool {
if key.eq_ignore_ascii_case("PRINTSCREEN") {
return true;
}
let arrow = ["LEFT", "UP", "RIGHT", "DOWN"]
.iter()
.any(|a| key.eq_ignore_ascii_case(a));
let model = lua.app_data_ref::<Model>().expect("model app_data");
arrow
&& !model.alt_down
&& model.arena.frame(h).is_some_and(|f| {
matches!(&f.kind_state,
crate::widget::KindState::EditBox(eb) if eb.alt_arrow_key_mode)
})
}
/// Whether the frame's own key or char slot takes the event, firing it: the base gates
/// `0x76b760` (`OnChar` alone) and `0x76b7d0` (either key slot, `OnKeyDown` fired).
fn frame_takes(lua: &Lua, h: FrameHandle, channel: Channel, arg: &str) -> bool {
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.frame_id(h)
};
for h in order {
// The focused box answers through its own vtable, at its walk position.
let is_focused_box = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.focused_editbox == Some(h)
};
if is_focused_box {
let consumed = match channel {
Channel::Char => super::editbox::char_input(lua, arg),
Channel::KeyDown => super::editbox::key_input(lua, arg),
};
if consumed {
match channel {
Channel::Char => {
if has_script(lua, id, "OnChar") {
fire(lua, id, "OnChar", arg);
return true;
}
continue;
}
// An unfocused box declines here; the topmost `autoFocus` box takes a free focus in
// `super::editbox::route` once the whole walk declines, where the reference's takes it
// at its own walk position (`0x77a917`, `0x77b1c7`).
if is_editbox(lua, h) {
continue;
}
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.frame_id(h)
};
match channel {
// `0x76b760`: gates on the OnChar slot alone.
Channel::Char => {
if has_script(lua, id, "OnChar") {
fire(lua, id, "OnChar", arg);
return true;
}
}
// `0x76b7d0`: either key slot consumes, only `OnKeyDown` fires.
Channel::KeyDown => {
let down = has_script(lua, id, "OnKeyDown");
if down || has_script(lua, id, "OnKeyUp") {
if down {
fire(lua, id, "OnKeyDown", arg);
}
return true;
Channel::KeyDown => {
let down = has_script(lua, id, "OnKeyDown");
if down || has_script(lua, id, "OnKeyUp") {
remember(lua, arg, h);
if down {
fire(lua, id, "OnKeyDown", arg);
}
return true;
}
}
}
false
}
/// The char walk (`0x765df0`): whether a frame or a box took the character.
fn walk_char(lua: &Lua, text: &str) -> bool {
let order = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
walk_order(&model, Channel::Char)
};
for h in order {
if is_editbox(lua, h) {
if super::editbox::takes_event(lua, h) && super::editbox::char_input(lua, text) {
return true;
}
} else if frame_takes(lua, h, Channel::Char, text) {
return true;
}
}
false
}
/// The key-down walk (`0x765f10`). A handler's own return is discarded (`0x7026f0`).
fn walk_key(lua: &Lua, key: &str) -> KeyWalk {
let order = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
walk_order(&model, Channel::KeyDown)
};
for h in order {
if is_editbox(lua, h) {
if box_declines(lua, h, key) || !super::editbox::takes_event(lua, h) {
continue;
}
if super::editbox::key_input(lua, key) {
remember(lua, key, h);
return KeyWalk::Box;
}
} else if frame_takes(lua, h, Channel::KeyDown, key) {
return KeyWalk::Frame;
}
}
KeyWalk::Declined
}
/// The XML walker's auto-enable after a `SetScript` (`0x769ef0`): `OnChar` sets the char bit
/// (`0x769f6b`), `OnKeyDown` and `OnKeyUp` the key bit (`0x769fa4`), each alone. Lua's `SetScript`
/// never does (`0x7748d0`).
pub(crate) fn enable_for_script(lua: &Lua, this: &mlua::Table, script: &str) -> mlua::Result<()> {
let char_bit = script.eq_ignore_ascii_case("OnChar");
let key_bit =
script.eq_ignore_ascii_case("OnKeyDown") || script.eq_ignore_ascii_case("OnKeyUp");
if !(char_bit || key_bit) {
return Ok(());
}
let h = super::object::frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if char_bit {
model.arena.set_char_enabled(h, true);
} else {
model.arena.set_key_enabled(h, true);
}
Ok(())
}
/// The consumer of a key-down, which that key's up and repeats go to (`0x765fa2`).
fn remember(lua: &Lua, key: &str, h: FrameHandle) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.key_consumers
.insert(key.to_string(), h);
}
/// A binding took the key's press: the world frame's key-down (`0x483b70`) ran it and returned
/// that, so the world frame is the consumer, and the up and repeats go to it
/// (`0x483bd0`), not to whichever frame took the key before.
pub(super) fn note_world_key(lua: &Lua, key: &str) {
let world = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let found = model
.arena
.iter_frames()
.find(|(_, f)| f.kind == FrameKind::WorldFrame)
.map(|(h, _)| h);
found
};
if let Some(h) = world {
remember(lua, key, h);
}
}
/// Whether `h` is linked, the base handlers' first test (`[+0xd4]`, `0x76bba7`, `0x77b7c0`).
fn linked(lua: &Lua, h: FrameHandle) -> bool {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.arena.frame(h).is_some_and(|f| f.effective_visible)
}
/// What the key-up dispatcher (`0x765fd0`) did with a release.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KeyUp {
/// A consumer was on record and got the up (or was gone or hidden, which ends it the same).
Delivered,
/// No consumer on record, and the release was PRINTSCREEN, which the client runs through the
/// key-down walk (`0x76603c`-`0x76604d`): Windows sends that key as an up alone. `Declined`
/// leaves the world frame's binding to the host.
Walked(KeyWalk),
/// No consumer on record: nothing.
Unclaimed,
}
impl KeyUp {
/// Whether the release was taken, by a recorded consumer or the PRINTSCREEN walk.
pub fn taken(self) -> bool {
!matches!(self, KeyUp::Unclaimed | KeyUp::Walked(KeyWalk::Declined))
}
}
/// The key-up dispatcher (`0x765fd0`): the key's last consumer gets `OnKeyUp` with the key's name
/// (`0x76bba0`, gated on that slot alone, and on being linked), however the key stands now. An
/// EditBox consumer runs no script: its override (`0x77b7c0`) only grants an `autoFocus` box the
/// free focus. A delivered up leaves the record. With none on record the release is nothing,
/// except PRINTSCREEN's, which runs the key-down walk and then clears the record that walk wrote
/// (`0x76603c`-`0x76605b`).
pub(super) fn key_up(lua: &Lua, key: &str) -> KeyUp {
let Some(h) = lua
.app_data_ref::<Model>()
.expect("model app_data")
.key_consumers
.get(key)
.copied()
else {
if !key.eq_ignore_ascii_case("PRINTSCREEN") {
return KeyUp::Unclaimed;
}
let walked = walk_key(lua, key);
lua.app_data_mut::<Model>()
.expect("model app_data")
.key_consumers
.remove(key);
return KeyUp::Walked(walked);
};
if !linked(lua, h) {
return KeyUp::Delivered;
}
if is_editbox(lua, h) {
super::editbox::takes_event(lua, h);
return KeyUp::Delivered;
}
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.frame_id(h)
};
if has_script(lua, id, "OnKeyUp") {
fire(lua, id, "OnKeyUp", key);
}
KeyUp::Delivered
}
/// The auto-repeat dispatcher (`0x766070`): the key's consumer's `+0x58`, and any consumer on
/// record makes the repeat consumed whatever that returns (`0x7660bb`). A plain frame's is a stub
/// (`0x46ff90`): [`KeyWalk::Frame`], nothing runs. An EditBox's (`0x77b770`) grants an `autoFocus`
/// box the free focus first (`0x77b799`) and then runs its key-down, which may decline: the
/// repeat is [`KeyWalk::Box`] only when the box acted, else `Frame`, consumed and inert. No
/// consumer on record: `Declined`, and nothing happens.
pub(super) fn key_repeat(lua: &Lua, key: &str) -> KeyWalk {
let Some(h) = lua
.app_data_ref::<Model>()
.expect("model app_data")
.key_consumers
.get(key)
.copied()
else {
return KeyWalk::Declined;
};
if is_editbox(lua, h)
&& linked(lua, h)
&& super::editbox::takes_event(lua, h)
&& !box_declines(lua, h, key)
&& super::editbox::key_input(lua, key)
{
return KeyWalk::Box;
}
KeyWalk::Frame
}
/// Fire one key handler with its one argument, a string on all three channels (`0x7026f0`);
/// errors are recorded in [`Model::errors`], never raised into the dispatch.
fn fire(lua: &Lua, id: u32, script: &str, arg: &str) {
@@ -124,59 +324,25 @@ fn fire(lua: &Lua, id: u32, script: &str, arg: &str) {
Err(e) => {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
return;
}
});
if let Err(e) = event::fire_widget_handler(lua, id, script, vec![val]) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
}
/// The char channel (`0x765df0`): `arg1` is the character in UTF-8, a multi-byte codepoint whole
/// (`0x41abb0` encodes with no range guard).
pub(super) fn char_input(lua: &Lua, text: &str) -> bool {
walk(lua, Channel::Char, text)
walk_char(lua, text)
}
/// The key-down channel (`0x765f10`): `arg1` is the key name without modifiers, from the same
/// table as the keybinding chord (`0x76b7d0`, `0x4b66b0`); the host already speaks those names.
pub(super) fn key_input(lua: &Lua, key: &str) -> bool {
walk(lua, Channel::KeyDown, key)
}
/// The key-down walk for the editing keys a focused box takes as a [`crate::script::EditAction`]
/// (BACKSPACE, DELETE, the arrows, HOME, END): a frame above the box consumes them, and the walk
/// stops at the box with `false` for its chord path, so no frame below it takes them.
pub(super) fn frame_key_input(lua: &Lua, key: &str) -> bool {
let order = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
walk_order(&model)
};
for h in order {
{
let model = lua.app_data_ref::<Model>().expect("model app_data");
if model.focused_editbox == Some(h) {
return false; // the box's chord path owns this key
}
}
// An unfocused box declines rather than gating on `+0x188`/`+0x190`.
if is_editbox(lua, h) {
continue;
}
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.frame_id(h)
};
let down = has_script(lua, id, "OnKeyDown");
if down || has_script(lua, id, "OnKeyUp") {
if down {
fire(lua, id, "OnKeyDown", key);
}
return true;
}
}
false
pub(super) fn key_down(lua: &Lua, key: &str) -> KeyWalk {
walk_key(lua, key)
}
File diff suppressed because it is too large Load Diff
+199 -69
View File
@@ -3,17 +3,39 @@
//! engine half, a snapshot out, a restore in and a dirty bit; the app owns the file
//! (`benilla_app::ui_layout`).
//!
//! A saved frame carries its name, size and whole anchor list. An anchor's target is the screen
//! root (saved as no target, the `nil` `GetPoint` answers there) or a named frame; a frame anchored
//! to anything else is skipped whole, never half-restored.
//! A saved frame is its rect, as the reference's writer (`0x490e60`) takes it: the frame level,
//! the top-left against the screen's top-left and the extent, in the frame's own units and rounded
//! to integers. The apply (`0x4905e0`) pins TOPLEFT on the screen there and sets the size, so the
//! frame's authored anchors do not survive it.
use crate::layout::Anchor;
use crate::layout::{Anchor, Anchors, Point};
use crate::widget::FrameHandle;
use super::object::{point_from_str, point_name};
use super::{Model, SCREEN};
use super::object::point_from_str;
use super::object::{eff_scale, settle_frame};
use super::SCREEN;
/// One anchor of a saved frame, in the spelling `GetPoint`/`SetPoint` use.
/// One user-placed frame's persisted geometry, one field per line of the reference's file. A
/// field is `None` where its line is absent.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FrameLayout {
/// The frame's global name, which addresses the row across sessions (`Frame:`).
pub name: String,
/// The frame level (`FrameLevel:`, `frame+0xc4`), written for every row (`0x490f55`).
pub level: Option<u16>,
/// The rect's left, against the screen's left (`X:`), written for a movable frame
/// (`0x490f99`).
pub x: Option<i32>,
/// The rect's top, against the screen's top (`Y:`), so zero or below.
pub y: Option<i32>,
/// The rect's width (`W:`), written for a resizable frame (`0x49109f`).
pub width: Option<i32>,
/// The rect's height (`H:`).
pub height: Option<i32>,
}
/// One anchor of a frame saved in benilla's earlier anchor form, in the spelling
/// `GetPoint`/`SetPoint` use.
#[derive(Clone, Debug, PartialEq)]
pub struct LayoutPoint {
/// The point on the saved frame (`"BOTTOMLEFT"`, …).
@@ -28,64 +50,172 @@ pub struct LayoutPoint {
pub y: f32,
}
/// One user-placed frame's persisted geometry.
/// A row of the anchor form benilla wrote before it saved the reference's rect: the authored
/// size and the whole anchor list. Read once, to carry a player's windows into the rect form.
#[derive(Clone, Debug, PartialEq)]
pub struct FrameLayout {
/// The frame's global name, which addresses the row across sessions.
pub struct AnchorLayout {
/// The frame's global name.
pub name: String,
/// The frame's authored width (`LayoutInput::width`), which a resize drag writes.
/// The frame's authored width (`LayoutInput::width`).
pub width: f32,
/// The frame's authored height.
pub height: f32,
/// Every anchor the frame carries, in `GetPoint` order.
/// Every anchor the frame carried, in `GetPoint` order.
pub points: Vec<LayoutPoint>,
}
impl super::UiScript {
/// Snapshot, sorted by name so the file is stable, every frame the reference's writer
/// (`0x490e60`) writes: a non-empty name (`0x490e79`, `0x490e82`), `userPlaced` (`0x490e8e`,
/// `0x1000`) and `movable` or `resizable` (`0x490e97`, `0x100|0x200`), with every anchor
/// nameable. Every drag stamps `userPlaced` (`0x7652b0` at `0x7652e5`), so the last test is
/// what drops the row of a stock frame an addon once moved, once that addon is gone.
/// Snapshot, in creation order as the writer walks the list of every frame (`0x490e24`,
/// `[root+0xcc4]`, appended by the constructor at `0x76930d`), every frame it writes: a
/// non-empty name (`0x490e79`, `0x490e82`), `userPlaced` (`0x490e8e`, `0x1000`) and `movable`
/// or `resizable` (`0x490e97`, `0x100|0x200`), with a rect to read. Every drag stamps
/// `userPlaced` (`0x7652b0` at `0x7652e5`), so the last test is what drops the row of a stock
/// frame an addon once moved, once that addon is gone.
///
/// The rect is the one the frame holds, stale or not (`0x768320`, its valid bit): only a frame
/// holding none is resolved now (`0x490eed`, `0x7680e0(1)`) and read again, and still none
/// writes no row (`0x490f00`). The root's rect is the screen, which is always held, so the
/// second bail (`0x490f17`) never takes here. The level and the two flags are read after the
/// rect (`0x490f55`, `0x490f93`, `0x491099`), since a resolve can run script.
///
/// `X:`/`Y:` round half away from zero (`0x490fc4`–`0x490fdf`, then `0x40a2b0`'s truncating
/// `fistp`); `W:`/`H:` add a half and truncate (`0x4910cd`–`0x4910d3`).
pub fn user_placed_layouts(&self) -> Vec<FrameLayout> {
let model = self.model_ref();
let mut out: Vec<FrameLayout> = model
let mut candidates: Vec<(u64, FrameHandle, String)> = self
.model_ref()
.arena
.iter_frames()
.filter(|(_, f)| f.user_placed && (f.movable || f.resizable))
.filter_map(|(h, f)| {
let name = f.name.clone()?;
let input = model.layout_inputs.get(&h)?;
let points = input
.anchors
.iter()
.map(|a| saved_point(&model, a))
.collect::<Option<Vec<_>>>()?;
Some(FrameLayout {
name,
width: input.width,
height: input.height,
points,
})
let name = f.name.clone().filter(|n| !n.is_empty())?;
Some((f.created(), h, name))
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
candidates.sort_unstable_by_key(|c| c.0);
let mut out = Vec::with_capacity(candidates.len());
for (_, h, name) in candidates {
if !self.model_ref().resolved.contains_key(&h) {
settle_frame(self.lua(), h);
}
let model = self.model_ref();
let Some(r) = model.resolved.get(&h).copied() else {
continue;
};
let Some((movable, resizable, level)) = model
.arena
.frame(h)
.map(|f| (f.movable, f.resizable, f.level))
else {
continue; // the resolve's script destroyed it
};
let root = model.screen;
let scale = f64::from(eff_scale(&model, h));
let own = |v: f32| f64::from(v) / scale;
let (x, y) = if movable {
(
Some(round_offset(own(r.left - root.left))),
Some(round_offset(own(r.top - root.top))),
)
} else {
(None, None)
};
let (width, height) = if resizable {
(
Some(round_extent(own(r.right - r.left))),
Some(round_extent(own(r.top - r.bottom))),
)
} else {
(None, None)
};
out.push(FrameLayout {
name,
level: Some(level),
x,
y,
width,
height,
});
}
out
}
/// Seat saved geometry back onto the frames that exist, each row whole or not at all. The
/// apply is per arm (`0x4905e0`): the position needs `movable` (`0x490600`) and anchors (the
/// no-position bail, `0x490613`), the size needs `resizable` (`0x490689`) and a non-zero size
/// (`0x49069a`), and each arm that applies stamps `userPlaced` (`0x49067e`, `0x490706`), which
/// keeps `UIParent_ManageFramePositions` off the frame. Marks nothing dirty: the values came
/// Seat saved rows back onto the frames that exist, as the apply (`0x4905e0`) does. A level of
/// zero or more is set, carried to same-strata children (`0x4905ee`–`0x4905f5`). The position
/// needs `movable` (`0x490600`) and a non-zero `X` or `Y` (`0x490607`–`0x490613`): it clears
/// every anchor (`0x49061a`) and pins TOPLEFT on the screen's TOPLEFT there (`0x490670`). The
/// size needs `resizable` (`0x490689`) and a non-zero `W` or `H` (`0x49068e`–`0x49069a`), and
/// sets each non-zero one (`0x4906c7`, `0x4906fa`). Each arm that applies stamps `userPlaced`
/// (`0x49067e`, `0x490706`), which keeps `UIParent_ManageFramePositions` off the frame. Each
/// row ends in a forced resolve of its frame, shown or hidden (`0x490710`, `0x7680e0(1)`), so
/// its `OnSizeChanged` fires before the next row applies. Marks nothing dirty: the values came
/// from the file.
pub fn restore_user_placed_layouts(&mut self, layouts: impl IntoIterator<Item = FrameLayout>) {
for l in layouts {
if let Some(h) = self.apply_layout_row(l) {
settle_frame(self.lua(), h);
}
}
}
/// One row of [`Self::restore_user_placed_layouts`], up to its invalidate; the frame it
/// applied to.
fn apply_layout_row(&mut self, l: FrameLayout) -> Option<FrameHandle> {
let mut model = self.model_mut();
// A window this session does not have applies nothing (`0x4905e6`).
let h = model.arena.lookup(&l.name)?;
if let Some(level) = l.level {
model.arena.set_frame_level(h, level, true);
}
let (movable, resizable) = model.arena.frame(h).map(|f| (f.movable, f.resizable))?;
// An absent line reads as the reference's cleared accumulator, zero.
let (x, y) = (l.x.unwrap_or(0), l.y.unwrap_or(0));
let (w, ht) = (l.width.unwrap_or(0), l.height.unwrap_or(0));
let seat_position = movable && (x != 0 || y != 0);
let seat_size = resizable && (w != 0 || ht != 0);
let input = model.layout_inputs.entry(h).or_default();
let mut changed = false;
if seat_position {
let pin = Anchors::from([Anchor::new(
Point::TopLeft,
SCREEN,
Point::TopLeft,
x as f32,
y as f32,
)]);
changed |= !input.anchors.bits_eq(&pin);
input.anchors = pin;
}
if seat_size && w != 0 {
changed |= input.width.to_bits() != (w as f32).to_bits();
input.width = w as f32;
}
if seat_size && ht != 0 {
changed |= input.height.to_bits() != (ht as f32).to_bits();
input.height = ht as f32;
}
if seat_size {
model.note_authored_size(h);
}
if seat_position || seat_size {
if let Some(f) = model.arena.frame_mut(h) {
f.user_placed = true;
}
}
// Every row that names a frame ends in an invalidate (`0x490710`).
model.invalidate_frame(h, changed);
Some(h)
}
/// Seat rows of benilla's earlier anchor form, each whole or not at all, under the apply's arm
/// gates (`0x490600`, `0x490689`) and stamps (`0x49067e`, `0x490706`). The next save writes
/// the frames back in the rect form.
pub fn restore_anchor_layouts(&mut self, layouts: impl IntoIterator<Item = AnchorLayout>) {
let mut model = self.model_mut();
for l in layouts {
let Some(h) = model.arena.lookup(&l.name) else {
continue; // a window this build does not have
continue;
};
let mut anchors: Vec<Anchor> = Vec::with_capacity(l.points.len());
let mut anchors = Anchors::new();
let mut usable = true;
for p in &l.points {
// `frame_id` mints the id lazily, so a target nothing has anchored to yet resolves.
@@ -99,7 +229,7 @@ impl super::UiScript {
point_from_str(&p.relative_point),
) {
(Some(point), Some(rel), Some(rp)) => {
anchors.push(Anchor::new(point, rel, rp, p.x, p.y));
anchors.insert(Anchor::new(point, rel, rp, p.x, p.y));
}
_ => {
usable = false;
@@ -110,7 +240,6 @@ impl super::UiScript {
if !usable {
continue;
}
// The two arm gates, read before the layout input is borrowed mutably.
let Some((movable, resizable)) = model.arena.frame(h).map(|f| (f.movable, f.resizable))
else {
continue;
@@ -120,9 +249,7 @@ impl super::UiScript {
if !seat_position && !seat_size {
continue;
}
let Some(input) = model.layout_inputs.get_mut(&h) else {
continue;
};
let input = model.layout_inputs.entry(h).or_default();
let mut changed = false;
if seat_position {
changed |= input.anchors != anchors;
@@ -134,16 +261,10 @@ impl super::UiScript {
input.width = l.width;
input.height = l.height;
}
// Only an arm that applied stamps, so a row neither arm took falls out of the file.
if seat_position || seat_size {
if let Some(f) = model.arena.frame_mut(h) {
f.user_placed = true;
}
}
if changed {
// The whole graph, not the frame: a restored row may repoint an anchor.
model.touch_layout();
if let Some(f) = model.arena.frame_mut(h) {
f.user_placed = true;
}
model.invalidate_frame(h, changed);
}
}
@@ -153,19 +274,28 @@ impl super::UiScript {
}
}
/// An anchor's saved form, or `None` when its target is an unnamed frame or a region.
fn saved_point(model: &Model, a: &Anchor) -> Option<LayoutPoint> {
let relative_to = if a.relative_to == SCREEN {
None
} else {
let h: FrameHandle = model.id_to_frame.get(&a.relative_to).copied()?;
Some(model.arena.frame(h)?.name.clone()?)
};
Some(LayoutPoint {
point: point_name(a.point).to_owned(),
relative_to,
relative_point: point_name(a.relative_point).to_owned(),
x: a.x_off,
y: a.y_off,
})
/// `X:`/`Y:`'s rounding: a half added, or taken off below zero (`fcom 0.0; test ah,0x5; jp`),
/// then truncated toward zero (`0x40a2b0`), so half rounds away from zero.
fn round_offset(v: f64) -> i32 {
(if v < 0.0 { v - 0.5 } else { v + 0.5 }).trunc() as i32
}
/// `W:`/`H:`'s rounding: a half added with no sign test, then truncated toward zero.
fn round_extent(v: f64) -> i32 {
(v + 0.5).trunc() as i32
}
#[cfg(test)]
mod tests {
use super::{round_extent, round_offset};
#[test]
fn offsets_round_half_away_from_zero_and_extents_add_a_half() {
assert_eq!(
[2.5, 2.49, -2.5, -2.49, 0.0, -0.4].map(round_offset),
[3, 2, -3, -2, 0, 0]
);
// No sign test on an extent: a negative half truncates toward zero.
assert_eq!([2.5, 2.49, 0.0, -0.7].map(round_extent), [3, 2, 0, 0]);
}
}
+45 -17
View File
@@ -12,6 +12,7 @@ use mlua::{Lua, MultiValue, Table, Value};
use super::binding_abi::flag;
use super::Model;
use crate::script::binding_abi::{Idx, Idx32};
/// `CLootButton`'s own Lua method table (`0x847ce4`): one entry, per the registrar's `mov edx,1`.
pub(super) const REG_LOOTBUTTON_METHODS: &str = "__benilla_lootbutton_methods";
@@ -48,7 +49,8 @@ pub struct LootRow {
pub quality: Option<u32>,
/// The synthesized coin pile (`LootSlotIsCoin` true, `LootSlotIsItem` false).
pub is_coin: bool,
/// The tooltip store's key, 0 for the coin row: `GetLootSlotInfo`'s fifth return, not 1.12's.
/// The tooltip store's key, 0 for the coin row: `SetLootItem` reads it. `GetLootSlotInfo`
/// returns four values and no id (`0x4c2d07`).
pub item_id: u32,
/// `GetLootSlotLink`'s answer; `None` for the coin row and while the template is in flight.
pub link: Option<String>,
@@ -134,6 +136,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
data.texture = None;
data.fill = None;
data.circular = true;
model.note_art(rh);
Ok(Value::Integer(1))
})?,
)?;
@@ -153,7 +156,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// template lands, and the app holds `LOOT_OPENED` back likewise.
g.set(
"GetLootSlotInfo",
lua.create_function(|lua, slot: usize| {
lua.create_function(|lua, Idx(slot): Idx| {
// `Some(Some(row))` live, `Some(None)` cleared, `None` no such slot.
let slot_state = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
@@ -174,8 +177,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
} else {
NO_SLOT_QUALITY
}),
// The trailing item id: none here.
Value::Integer(0),
]));
};
let texture =
@@ -188,8 +189,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
item,
Value::Integer(i64::from(row.quantity)),
quality,
// The item id, a fifth return that is not a 1.12 value.
Value::Integer(i64::from(row.item_id)),
]))
})?,
)?;
@@ -199,7 +198,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// `ChatFrameEditBox:Insert` (`:152`), both of which take a nil.
g.set(
"GetLootSlotLink",
lua.create_function(|lua, slot: usize| {
lua.create_function(|lua, Idx(slot): Idx| {
let link = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
@@ -219,10 +218,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"LootSlotIsItem",
lua.create_function(|lua, slot: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(slot
.checked_sub(1)
.and_then(|n| model.loot.as_ref()?.rows.get(n)?.as_ref())
.is_some_and(|r| !r.is_coin))
Ok(flag(
slot.checked_sub(1)
.and_then(|n| model.loot.as_ref()?.rows.get(n)?.as_ref())
.is_some_and(|r| !r.is_coin),
))
})?,
)?;
@@ -230,10 +230,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"LootSlotIsCoin",
lua.create_function(|lua, slot: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(slot
.checked_sub(1)
.and_then(|n| model.loot.as_ref()?.rows.get(n)?.as_ref())
.is_some_and(|r| r.is_coin))
Ok(flag(
slot.checked_sub(1)
.and_then(|n| model.loot.as_ref()?.rows.get(n)?.as_ref())
.is_some_and(|r| r.is_coin),
))
})?,
)?;
@@ -302,7 +303,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// as nobody (`LootFrame.lua:168-232`).
g.set(
"GetMasterLootCandidate",
lua.create_function(|lua, index: u32| {
lua.create_function(|lua, Idx32(index): Idx32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let name = index.checked_sub(1).and_then(|i| {
model
@@ -339,7 +340,7 @@ mod tests {
/// The region data behind a named texture: its portrait key and file.
fn portrait_of(s: &UiScript, name: &str) -> (Option<String>, Option<String>) {
let model = s.model_ref();
let id = model.region_names[name];
let id = model.region_by_name[name];
let rh = *model.id_to_region.get(&id).expect("a region");
let data = &model.region_data[&rh];
(data.portrait_unit.clone(), data.texture.clone())
@@ -506,6 +507,33 @@ mod tests {
assert!(s.eval::<bool>("return LootSlotIsItem(2)").unwrap());
}
/// `GetLootSlotInfo` pushes four values on every leg (`0x4c2d07 mov eax,4`): texture, name,
/// quantity, quality, with no item id.
#[test]
fn loot_slot_info_returns_four_values_on_every_leg() {
let mut s = UiScript::new().unwrap();
let count = |s: &UiScript, slot: u32| -> i64 {
s.eval::<i64>(&format!(
"return (function(...) return arg.n end)(GetLootSlotInfo({slot}))"
))
.unwrap()
};
// No window.
assert_eq!(count(&s, 1), 4);
let mut state = loot();
state.rows[0].as_mut().unwrap().item_id = 2589;
state.rows[1].as_mut().unwrap().item_id = 2589;
s.set_loot(Some(state.clone()));
// A coin row, an item row with an id, an in-flight row, past the end.
for slot in [1, 2, 3, 9] {
assert_eq!(count(&s, slot), 4, "slot {slot}");
}
// A looted slot.
state.rows[0] = None;
s.set_loot(Some(state));
assert_eq!(count(&s, 1), 4);
}
#[test]
fn master_loot_candidates_read_1_based_and_nil_past_the_end() {
let mut s = UiScript::new().unwrap();
+32 -27
View File
@@ -6,7 +6,9 @@
use mlua::Lua;
use super::binding_abi::flag;
use super::Model;
use crate::script::binding_abi::Idx32;
/// `rollType` 0, the one vote the bind-on-pickup gate never intercepts.
const PASS: u8 = 0;
@@ -26,17 +28,13 @@ pub struct LootRollEntry {
pub name: Option<String>,
/// Icon texture path (`Interface\Icons\…`). `None` only if the display catalog had no icon.
pub texture: Option<String>,
/// The stack size, answered as `GetLootRollItemInfo`'s `count`; the reference answers a literal
/// `1.0` there, a group roll being one stack (`0x4c3160`).
pub quantity: u32,
/// Item quality 0..6; `None` while the template is in flight, answered as `CACHE_MISS_QUALITY`.
pub quality: Option<u32>,
/// Bind on pickup, which swaps in the gold backdrop; `false` while the template is in flight.
pub bind_on_pickup: bool,
/// Milliseconds left, from `SMSG_LOOT_START_ROLL`'s countdown; saturates at 0.
pub time_left_ms: u32,
/// The item id, the key of the hover's item lookup; `GetLootRollItemInfo` also returns it as a
/// sixth value, which 1.12 does not.
/// The item id, the key of the hover's item lookup.
pub item_id: u32,
/// The link `GetLootRollItemLink` answers for the icon's ctrl/shift clicks
/// (`LootFrame.xml:353-361`); `None` until the item template lands, since it embeds the name.
@@ -78,11 +76,13 @@ fn find(model: &Model, roll_id: u32) -> Option<&LootRollEntry> {
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// `0x4c3050`, read at `LootFrame.lua:261`. An unknown id takes the reference's miss tail
// `nil, nil, 1.0, 1.0, nil` (`0x4c31a3`), as a roll with no item template does there.
// `0x4c3050`, read at `LootFrame.lua:261`. `count` is the literal 1.0 (`0x4c3160`) and
// `bindOnPickup` 1.0 when `[template+0x194] == 1` else nil (`0x4c317e`). An unknown id takes
// the reference's miss tail `nil, nil, 1.0, 1.0, nil` (`0x4c31a3`), as a roll with no item
// template does there.
g.set(
"GetLootRollItemInfo",
lua.create_function(|lua, roll_id: u32| {
lua.create_function(|lua, Idx32(roll_id): Idx32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(r) = find(&model, roll_id) else {
return lua.pack_multi((
@@ -91,16 +91,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
1,
CACHE_MISS_QUALITY,
mlua::Value::Nil,
mlua::Value::Nil,
));
};
let vals = (
r.texture.clone(),
r.name.clone(),
r.quantity,
1,
r.quality.map_or(CACHE_MISS_QUALITY, i64::from),
r.bind_on_pickup,
r.item_id,
flag(r.bind_on_pickup),
);
lua.pack_multi(vals)
})?,
@@ -109,7 +107,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// `nil` for an unknown id or while the item template is in flight.
g.set(
"GetLootRollItemLink",
lua.create_function(|lua, roll_id: u32| {
lua.create_function(|lua, Idx32(roll_id): Idx32| {
let link = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
find(&model, roll_id).and_then(|r| r.link.clone())
@@ -125,7 +123,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// (`LootFrame.lua:290-293`).
g.set(
"GetLootRollTimeLeft",
lua.create_function(|lua, roll_id: u32| {
lua.create_function(|lua, Idx32(roll_id): Idx32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(find(&model, roll_id).map_or(0, |r| r.time_left_ms))
})?,
@@ -182,7 +180,6 @@ mod tests {
roll_id: 7,
name: Some("Staff of Jordan".into()),
texture: Some("Interface\\Icons\\INV_Staff_12".into()),
quantity: 1,
quality: Some(4),
bind_on_pickup: true,
time_left_ms: 42_000,
@@ -195,7 +192,6 @@ mod tests {
roll_id: 8,
name: None,
texture: None,
quantity: 2,
quality: None,
bind_on_pickup: false,
time_left_ms: 60_000,
@@ -218,26 +214,35 @@ mod tests {
s.set_loot_rolls(rolls());
let (texture, name, count, quality, bop) = s
.eval::<(String, String, i64, i64, bool)>("return GetLootRollItemInfo(7)")
.eval::<(String, String, i64, i64, i64)>("return GetLootRollItemInfo(7)")
.unwrap();
assert_eq!(texture, "Interface\\Icons\\INV_Staff_12");
assert_eq!(name, "Staff of Jordan");
assert_eq!((count, quality, bop), (1, 4, true));
assert_eq!((count, quality, bop), (1, 4, 1));
assert_eq!(
s.eval::<i64>("return GetLootRollTimeLeft(7)").unwrap(),
42_000
);
assert_eq!(
s.eval::<i64>("local _, _, _, _, _, id = GetLootRollItemInfo(7)\nreturn id")
.unwrap(),
17182
);
// Five values, a hit and the miss tail alike (`0x4c31a3`).
for id in [7, 99] {
let vals = s
.eval::<mlua::MultiValue>(&format!("return GetLootRollItemInfo({id})"))
.unwrap();
assert_eq!(vals.len(), 5, "roll {id}");
}
// In flight: the quality is the miss tail's 1, never nil.
// In flight: the quality is the miss tail's 1, never nil; the count is the literal 1
// whatever the stack, and a roll that does not bind answers nil, not false.
assert!(s
.eval::<bool>(
"local t, n, c, q, b = GetLootRollItemInfo(8)\n\
return t == nil and n == nil and q == 1 and c == 2 and b == false",
return t == nil and n == nil and q == 1 and c == 1 and b == nil",
)
.unwrap());
assert!(s
.eval::<bool>(
"local _, _, _, _, b7 = GetLootRollItemInfo(7)\n\
return type(b7) == 'number' and b7 == 1",
)
.unwrap());
@@ -259,8 +264,8 @@ mod tests {
assert_eq!((count, quality), (1, 1), "the miss tail is 1, 1");
assert_eq!(
s.arity("GetLootRollItemInfo(99)").unwrap(),
6,
"five reference returns plus benilla's trailing item id"
5,
"the reference's five returns (`0x4c31a3`)"
);
assert!(s
.eval::<bool>("return GetLootRollItemLink(99) == nil")
+152 -85
View File
@@ -9,8 +9,9 @@
use mlua::{Lua, MultiValue, Value};
use super::cursor::{queue_cursor_update, CursorMacro, CursorPayload};
use super::cursor::{set_cursor, CursorMacro, CursorPayload};
use super::Model;
use crate::script::binding_abi::Idx;
/// Macros per tab (`Blizzard_MacroUI.lua:1`), and so the character tab's base.
pub const MAX_MACROS: usize = 18;
@@ -169,9 +170,9 @@ impl super::UiScript {
}
}
/// `CreateMacro(name, iconIndex, body, local, perCharacter)` (usage string `0x44cb74`): the new
/// 1-based index, or `None` on the two failures the client logs (`0x44cbb4`, `0x44cbdc`), an empty
/// name and a full tab.
/// `CreateMacro(name, iconIndex, body, local, perCharacter)` (usage string `0x84cb74`): the new
/// 1-based index, or the error the client raises for the two failures (`0x84cbb4`, `0x84cbdc`), an
/// empty name and a full tab.
fn create_macro(
model: &mut Model,
name: &str,
@@ -179,18 +180,19 @@ fn create_macro(
body: &str,
local_only: bool,
per_character: bool,
) -> Option<usize> {
let name = clamp_chars(name.trim(), MAX_MACRO_NAME);
) -> mlua::Result<usize> {
// Tested on the first byte (`*name == 0`): a blank name is a name.
if name.is_empty() {
model.record_warning("CreateMacro() failed, no name specified");
return None;
return Err(mlua::Error::RuntimeError(
"CreateMacro() failed, no name specified".into(),
));
}
let name = clamp_chars(name, MAX_MACRO_NAME);
let list = model.macros.list_mut(per_character);
if list.len() >= MAX_MACROS {
model.record_warning(format!(
return Err(mlua::Error::RuntimeError(format!(
"CreateMacro() failed, already have {MAX_MACROS} macros"
));
return None;
)));
}
list.push(MacroView {
name,
@@ -201,19 +203,20 @@ fn create_macro(
let pos = list.len() - 1;
model.macros_dirty = true;
model.macros_generation += 1;
Some(MacroIndex { per_character, pos }.lua_index())
Ok(MacroIndex { per_character, pos }.lua_index())
}
/// `EditMacro(index, name, icon, body, local)`: an omitted argument leaves its field alone, since
/// the stock UI calls it with disjoint halves, `(sel, name, icon)` from the rename popup and
/// `(sel, nil, nil, text)` from `MacroFrame_SaveMacro`.
/// `EditMacro(index, name, icon, body, local)`: an omitted name, icon or body leaves its field
/// alone, since the stock UI calls it with disjoint halves, `(sel, name, icon)` from the rename
/// popup and `(sel, nil, nil, text)` from `MacroFrame_SaveMacro`. The `local` flag is always
/// written (`0x4f12dc`, `[macro+0x20]`), so an omitted one clears it.
fn edit_macro(
model: &mut Model,
index: usize,
name: Option<String>,
texture: Option<Option<String>>,
body: Option<String>,
local_only: Option<bool>,
local_only: bool,
) -> Option<usize> {
let at = MacroIndex::split(index)?;
let entry = model.macros.list_mut(at.per_character).get_mut(at.pos)?;
@@ -230,9 +233,7 @@ fn edit_macro(
if let Some(body) = body {
entry.body = clamp_chars(&body, MAX_MACRO_BODY);
}
if let Some(local_only) = local_only {
entry.local_only = local_only;
}
entry.local_only = local_only;
model.macros_dirty = true;
model.macros_generation += 1;
Some(index)
@@ -254,23 +255,23 @@ fn delete_macro(model: &mut Model, index: usize) -> bool {
true
}
/// `PickupMacro(index)`: the macro payload (cursor mode 8, `[0xb4e2fc]`), which `PlaceAction`
/// (`0x4e62e0`) packs as `macroId | 0x40000000`. Refused while the cursor holds anything; the
/// reference's setter (`0x494f80`) clears the cursor first instead.
fn pickup_macro(model: &mut Model, index: usize) -> bool {
if model.cursor.is_some() {
return false;
}
let Some(entry) = model.macros.get(index) else {
return false;
/// `PickupMacro(index)` (`0x4f1ae0`): the macro payload (cursor mode 8, `[0xb4e2fc]`), which
/// `PlaceAction` (`0x4e62e0`) packs as `macroId | 0x40000000`. A missing macro does nothing
/// (`0x4f1b1e`); otherwise the setter (`0x494f80`) clears whatever is held with its inner
/// `ClearCursor(1,1)`, then sets the macro and shows the action bar's grid. Returns whether a
/// macro went on.
fn pickup_macro(lua: &Lua, index: usize) -> bool {
let payload = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(entry) = model.macros.get(index) else {
return false;
};
CursorMacro {
index: index as u32,
texture: entry.texture.clone(),
}
};
let payload = CursorMacro {
index: index as u32,
texture: entry.texture.clone(),
};
model.cursor = Some(CursorPayload::Macro(payload));
queue_cursor_update(model);
true
set_cursor(lua, true, |_| Some(CursorPayload::Macro(payload)))
}
fn opt_string(v: Option<&Value>) -> Option<String> {
@@ -280,11 +281,11 @@ fn opt_string(v: Option<&Value>) -> Option<String> {
}
}
/// The `local` and `perCharacter` flags: `nil`, `false` and `0` are false, as in `UseAction`. The
/// reference reads them with `GetBoolOrDefault` (`0x6f1c10`), which also takes `"0"` and any
/// number that truncates to 0 as false; the stock popup passes a Lua boolean and never `local`.
/// The `local` and `perCharacter` flags: `GetBoolOrDefault` (`0x6f1c10`) with default 0
/// (`CreateMacro` `0x4f167b`, `0x4f168a`; `EditMacro` `0x4f19ad`), so `"0"` and `0.5` are false and
/// `"yes"` true. The stock popup passes a Lua boolean and never `local`.
fn flag(v: Option<&Value>) -> bool {
v.is_some_and(super::action::truthy_nonzero)
super::binding_abi::bool_or_default(v, false)
}
/// Register the macro globals.
@@ -304,10 +305,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"GetMacroInfo",
lua.create_function(|lua, index: usize| {
lua.create_function(|lua, Idx(index): Idx| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(m) = model.macros.get(index) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
// `0x4f1829`: four nils.
return Ok(super::binding_abi::tuple(&[None; 4]));
};
let texture = match &m.texture {
Some(t) => Value::String(lua.create_string(t)?),
@@ -344,7 +346,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"GetMacroIconInfo",
lua.create_function(|lua, index: usize| {
lua.create_function(|lua, Idx(index): Idx| {
let path = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
index
@@ -359,22 +361,41 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// CreateMacro stores `iconIndex`, an index into the chooser list, as its path.
// CreateMacro(name, iconIndex, body, local, perCharacter) (`0x4f15b0`): the name is a string
// or number and the icon a number or numeric string, else the usage text (`0x84cb74`) raises.
// `iconIndex` is an index into the chooser list. An empty name raises (`0x84cbb4`, tested on
// the first byte, so a blank one is a name), and so does a full tab (`0x84cbdc`); a made macro
// answers exactly its new 1-based index (`0x4f16ce`). Not modelled: the script-context gate
// `0x494a50(7)` ahead of the body, which has no taint to read here.
g.set(
"CreateMacro",
lua.create_function(|lua, args: MultiValue| {
let a: Vec<Value> = args.into_iter().collect();
let name = opt_string(a.first()).unwrap_or_default();
let usage = || {
mlua::Error::RuntimeError(
"Usage: CreateMacro(name, iconIndex, body, local, perCharacter)".into(),
)
};
let name = super::binding_abi::string_arg(
lua,
a.first().cloned().unwrap_or(Value::Nil),
"Usage: CreateMacro(name, iconIndex, body, local, perCharacter)",
)?;
let icon = lua
.coerce_number(a.get(1).cloned().unwrap_or(Value::Nil))?
.ok_or_else(usage)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let texture = icon_path(&model, a.get(1));
let body = opt_string(a.get(2)).unwrap_or_default();
// `trunc(n) - 1` against the chooser list, unsigned (`0x4f1632`, `0x4f163c`).
let texture = usize::try_from(super::binding_abi::ftol(icon).wrapping_sub(1))
.ok()
.and_then(|i| model.macro_icons.get(i).cloned());
let body = a
.get(2)
.and_then(|v| super::binding_abi::optional_string(lua, v))
.unwrap_or_default();
let (local_only, per_character) = (flag(a.get(3)), flag(a.get(4)));
Ok(
match create_macro(&mut model, &name, texture, &body, local_only, per_character) {
Some(i) => Value::Integer(i as i64),
None => Value::Nil,
},
)
create_macro(&mut model, &name, texture, &body, local_only, per_character)
.map(|i| Value::Integer(i as i64))
})?,
)?;
@@ -393,10 +414,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
};
let name = opt_string(a.get(1));
let body = opt_string(a.get(3));
let local_only = match a.get(4) {
None | Some(Value::Nil) => None,
other => Some(flag(other)),
};
let local_only = flag(a.get(4));
Ok(
match edit_macro(&mut model, index, name, texture, body, local_only) {
Some(i) => Value::Integer(i as i64),
@@ -410,15 +428,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"DeleteMacro",
lua.create_function(|lua, index: usize| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(delete_macro(&mut model, index))
delete_macro(&mut model, index);
Ok(())
})?,
)?;
g.set(
"PickupMacro",
lua.create_function(|lua, index: usize| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(pickup_macro(&mut model, index))
pickup_macro(lua, index);
Ok(())
})?,
)?;
@@ -504,7 +523,7 @@ mod tests {
);
// The character macro keeps its own base after the delete.
assert!(s.eval::<bool>("return DeleteMacro(1)").unwrap());
assert!(delete_macro(&mut s.model_mut(), 1));
assert_eq!(
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
.unwrap(),
@@ -548,12 +567,36 @@ mod tests {
#[test]
fn create_refuses_a_blank_name_and_a_full_tab_and_clamps_the_caps() {
let s = seeded();
assert!(s
.eval::<bool>(r#"return CreateMacro("", 1, "x") == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return CreateMacro(" ", 1, "x") == nil"#)
.unwrap());
let raised = |s: &UiScript, call: &str| s.run(call).expect_err(call).to_string();
assert!(raised(&s, r#"CreateMacro("", 1, "x")"#).contains("failed, no name specified"));
// Tested on the first byte, not trimmed: a blank name is a name (`0x4f1610`).
assert_eq!(
s.eval::<i64>(r#"return CreateMacro(" ", 1, "x")"#)
.unwrap(),
1
);
s.run("DeleteMacro(1)").unwrap();
// A non-string name, a non-numeric icon and a missing icon raise the usage text.
for call in [
"CreateMacro()",
"CreateMacro({}, 1)",
"CreateMacro('a')",
"CreateMacro('a', 'x')",
"CreateMacro('a', nil, 'b')",
] {
assert!(
raised(&s, call)
.contains("Usage: CreateMacro(name, iconIndex, body, local, perCharacter)"),
"{call}"
);
}
// A number is a name, a numeric string an icon, and exactly one value is pushed.
assert_eq!(
s.eval::<i64>("return (function(...) return arg.n end)(CreateMacro(12, '1', 'b'))")
.unwrap(),
1
);
s.run("DeleteMacro(1)").unwrap();
for i in 1..=MAX_MACROS {
let made = s
@@ -562,8 +605,8 @@ mod tests {
assert_eq!(made as usize, i);
}
assert!(
s.eval::<bool>(r#"return CreateMacro("one too many", 1, "") == nil"#)
.unwrap(),
raised(&s, r#"CreateMacro("one too many", 1, "")"#)
.contains("CreateMacro() failed, already have 18 macros"),
"the 19th account macro is refused"
);
// The character tab is a separate 18.
@@ -617,29 +660,24 @@ mod tests {
}
#[test]
fn pickup_macro_loads_the_cursor_and_refuses_while_holding() {
fn pickup_macro_loads_the_cursor_over_whatever_is_held() {
use crate::script::cursor::CursorPayload;
let s = seeded();
s.run(r#"CreateMacro("Ambush", 1, "/cast Ambush")"#)
.unwrap();
assert!(
!s.eval::<bool>("return PickupMacro(5)").unwrap(),
"empty slot"
);
assert!(s.eval::<bool>("return PickupMacro(1)").unwrap());
assert_eq!(
s.cursor_payload(),
Some(CursorPayload::Macro(CursorMacro {
index: 1,
texture: Some("Interface\\Icons\\Ability_Ambush".into()),
}))
);
assert!(
!s.eval::<bool>("return PickupMacro(1)").unwrap(),
"a macro button is a source, never a drop target"
);
assert!(!pickup_macro(&s.lua, 5), "empty slot");
assert!(s.cursor_payload().is_none());
assert!(pickup_macro(&s.lua, 1));
let ambush = Some(CursorPayload::Macro(CursorMacro {
index: 1,
texture: Some("Interface\\Icons\\Ability_Ambush".into()),
}));
assert_eq!(s.cursor_payload(), ambush);
// The setter's inner clear drops what is held, then sets (`0x494fad`).
assert!(pickup_macro(&s.lua, 1));
assert_eq!(s.cursor_payload(), ambush);
}
#[test]
@@ -653,4 +691,33 @@ mod tests {
assert!(s.eval::<bool>("return GetMacroIconInfo(4) == nil").unwrap());
assert!(s.eval::<bool>("return GetMacroIconInfo(0) == nil").unwrap());
}
/// The macro setter (`0x494f80`) signals only through its inner clear (`0x494fad`): that
/// `CURSOR_UPDATE` reads the empty cursor, and the set ends on the grid (`0x494fd7`).
#[test]
fn a_macro_pickup_signals_its_clear_then_shows_the_grid() {
let s = seeded();
s.run(r#"CreateMacro("Ambush", 1, "/cast Ambush")"#)
.unwrap();
crate::script::cursor::install_kind_probe(&s);
crate::script::tick::heard::listen(
&s,
&["CURSOR_UPDATE", "ACTIONBAR_SHOWGRID", "ACTIONBAR_HIDEGRID"],
Some("CursorKind()"),
);
s.run("PickupMacro(1)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
["CURSOR_UPDATE none", "ACTIONBAR_SHOWGRID macro"]
);
s.run("PickupMacro(1)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
[
"ACTIONBAR_HIDEGRID none",
"CURSOR_UPDATE none",
"ACTIONBAR_SHOWGRID macro"
]
);
}
}
+233 -85
View File
@@ -5,8 +5,9 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::flag;
use super::cursor::{self, CursorPayload};
use super::cursor;
use super::Model;
use crate::script::binding_abi::Idx;
/// One inbox row, resolved by the app from a wire `MailListEntry`; its 1-based index is its
/// position in [`MailState::inbox`].
@@ -128,18 +129,11 @@ pub struct MailSendRequest {
pub package: u32,
}
/// `0x4acdc0`'s clears: the attachment, whose lock it lifts (`0x495420`, firing
/// `ITEM_LOCK_CHANGED`), then money, COD, stationery and package. Returns the lock event to fire.
fn clear_compose(model: &mut Model) -> Option<(String, Vec<super::ScriptValue>)> {
let lock = model.mail_send_item.take().map(|item| {
(
"ITEM_LOCK_CHANGED".to_string(),
vec![
super::ScriptValue::Int(item.bag),
super::ScriptValue::Int(i64::from(item.slot)),
],
)
});
/// `0x4acdc0`'s clears: the attachment, whose lock it lifts (`0x495420`, firing an argless
/// `ITEM_LOCK_CHANGED` at `0x495455`), then money, COD, stationery and package. Returns whether
/// an attachment was lifted, so the caller fires that event.
fn clear_compose(model: &mut Model) -> bool {
let lock = model.mail_send_item.take().is_some();
model.mail_send_money = 0;
model.mail_send_cod = 0;
model.mail_stationery = 0;
@@ -220,8 +214,8 @@ impl super::UiScript {
pub fn reset_compose_tab(&mut self) {
let lock = clear_compose(&mut self.model_mut());
// Fired now, not queued: the order against the caller's `MAIL_SHOW`/`MAIL_FAILED` matters.
if let Some((event, args)) = lock {
self.fire_event(&event, args);
if lock {
self.fire_event("ITEM_LOCK_CHANGED", Vec::new());
}
// One literal call each, as the event census (`ui_script::reference_ui`) reads them.
self.fire_event("SEND_MAIL_MONEY_CHANGED", Vec::new()); // 0x4ace14
@@ -232,9 +226,8 @@ impl super::UiScript {
/// The close core's silent reset (`0x4acdc0(0)`, from `0x4acd50`): the same clears, and only
/// the attachment's `ITEM_LOCK_CHANGED`, which `0x495420` fires on either leg.
pub fn reset_compose_tab_silently(&mut self) {
let lock = clear_compose(&mut self.model_mut());
if let Some((event, args)) = lock {
self.fire_event(&event, args);
if clear_compose(&mut self.model_mut()) {
self.fire_event("ITEM_LOCK_CHANGED", Vec::new());
}
}
@@ -294,7 +287,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// `CODAmount > 0` on a nil.
g.set(
"GetInboxHeaderInfo",
lua.create_function(|lua, index: usize| {
lua.create_function(|lua, Idx(index): Idx| {
let row = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
row_at(&model, index)
@@ -355,7 +348,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// (`MailFrame.lua:366`), the pair vmangos requires (`MailHandler.cpp:867`).
g.set(
"GetInboxText",
lua.create_function(|lua, index: usize| {
lua.create_function(|lua, Idx(index): Idx| {
let row = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let row = row_at(&model, index);
@@ -368,8 +361,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
}
row
};
// A miss pushes four nils (`0x4af311`..`0x4af334`).
let Some(r) = row else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
return Ok(MultiValue::from_vec(vec![Value::Nil; 4]));
};
Ok(MultiValue::from_vec(vec![
// An invoice's body is nil (`0x4af1cf`): its text is the auction house's
@@ -390,7 +384,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// being three nils and four zeros (`0x4af559`).
g.set(
"GetInboxInvoiceInfo",
lua.create_function(|lua, index: usize| {
lua.create_function(|lua, Idx(index): Idx| {
let row = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
row_at(&model, index)
@@ -425,7 +419,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// the shared item-usable gate, true while the template is in flight.
g.set(
"GetInboxItem",
lua.create_function(|lua, index: usize| {
lua.create_function(|lua, Idx(index): Idx| {
let (row, usable) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let row = row_at(&model, index);
@@ -555,16 +549,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
copper,
"Usage: SetSendMailMoney(amount)",
)? as u32;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if u64::from(n) > model.money {
model.ui_errors.push("ERR_NOT_ENOUGH_MONEY");
return Ok(Value::Nil);
}
model.mail_send_money = n;
model
.pending_events
.push(("SEND_MAIL_MONEY_CHANGED".to_string(), Vec::new()));
Ok(Value::Integer(1))
Ok(super::tick::verb(lua, |model| {
if u64::from(n) > model.money {
model.ui_errors.push("ERR_NOT_ENOUGH_MONEY");
return Value::Nil;
}
// The write (`0x4adc39`), then the signal (`0x4adc44`).
model.mail_send_money = n;
model.raise("SEND_MAIL_MONEY_CHANGED", Vec::new());
Value::Integer(1)
}))
})?,
)?;
@@ -600,14 +594,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
copper,
"Usage: SetSendMailCOD(amount)",
)? as u32;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.mail_send_item.is_none() {
return Ok(());
}
model.mail_send_cod = n;
model
.pending_events
.push(("SEND_MAIL_COD_CHANGED".to_string(), Vec::new()));
super::tick::verb(lua, |model| {
if model.mail_send_item.is_some() {
// The write (`0x4adc7d`), then the signal (`0x4adc88`).
model.mail_send_cod = n;
model.raise("SEND_MAIL_COD_CHANGED", Vec::new());
}
});
Ok(())
})?,
)?;
@@ -667,8 +660,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"ClickSendMailItemButton",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
click_send_mail_item(&mut model);
click_send_mail_item(lua);
Ok(())
})?,
)?;
@@ -751,21 +743,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// ClearSendMail() (`0x4adee0`): the compose reset with its events (`0x4acdc0(1)`), queued in
// its order; zero returns.
// ClearSendMail() (`0x4adee0`): the compose reset with its events (`0x4acdc0(1)`), fired in
// place in its order: the attachment's unlock (`0x495455`), then `0x4ace19`, `0x4ace23` and
// `0x4ace2d`; zero returns.
g.set(
"ClearSendMail",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let lock = clear_compose(&mut model);
model.pending_events.extend(lock);
for event in [
"SEND_MAIL_MONEY_CHANGED",
"SEND_MAIL_COD_CHANGED",
"MAIL_SEND_SUCCESS",
] {
model.pending_events.push((event.to_string(), Vec::new()));
}
super::tick::verb(lua, |model| {
if clear_compose(model) {
model.raise("ITEM_LOCK_CHANGED", Vec::new());
}
model.raise("SEND_MAIL_MONEY_CHANGED", Vec::new());
model.raise("SEND_MAIL_COD_CHANGED", Vec::new());
model.raise("MAIL_SEND_SUCCESS", Vec::new());
});
Ok(MultiValue::new())
})?,
)?;
@@ -820,36 +811,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
}
/// `ClickSendMailItemButton`: the send slot as a cursor drop target, firing `CURSOR_UPDATE` and
/// `ITEM_LOCK_CHANGED` like a bag slot.
fn click_send_mail_item(model: &mut Model) {
match model.cursor.take() {
// A held bag item attaches; it stays in its bag slot until the send.
Some(CursorPayload::Item(item)) => {
let (bag, slot) = (item.bag, item.slot);
// An earlier attachment is overwritten, as in the reference; its slot has no lock.
model.mail_send_item = Some(item);
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, bag, slot);
// `MAIL_SEND_INFO_UPDATE` (`0x4ae0de`): the stock tab re-reads the item and postage.
model
.pending_events
.push(("MAIL_SEND_INFO_UPDATE".to_string(), Vec::new()));
}
None => {
if let Some(item) = model.mail_send_item.take() {
let (bag, slot) = (item.bag, item.slot);
model.cursor = Some(CursorPayload::Item(item));
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, bag, slot);
model
.pending_events
.push(("MAIL_SEND_INFO_UPDATE".to_string(), Vec::new()));
}
}
Some(other) => {
model.cursor = Some(other);
}
/// `ClickSendMailItemButton` (`0x4adef0`): the send slot as a cursor drop target, attaching the
/// held item and putting an earlier attachment back on the cursor ([`cursor::click_attach_slot`]).
/// A held item stays in its bag slot, locked, until the send. A changed attachment signals
/// `MAIL_SEND_INFO_UPDATE` (`0x4ae0de`), whose stock handler re-reads the item and postage.
fn click_send_mail_item(lua: &Lua) {
if cursor::click_attach_slot(lua, |model| &mut model.mail_send_item) {
super::tick::verb(lua, |model| {
model.raise("MAIL_SEND_INFO_UPDATE", Vec::new());
});
}
}
@@ -858,6 +828,40 @@ mod tests {
use super::*;
use crate::script::UiScript;
/// The compose verbs fire in place, each after its write (`0x4adc39` then `0x4adc44`), and
/// `ClearSendMail` after all its clears (`0x4acddf`-`0x4ace0c`, then `0x4ace19`, `0x4ace23`,
/// `0x4ace2d`): every handler reads the stored amount before the next Lua line runs.
#[test]
fn the_compose_verbs_fire_their_events_in_place() {
let mut s = UiScript::new().unwrap();
s.set_money(1_000);
crate::script::tick::heard::listen(
&s,
&[
"SEND_MAIL_MONEY_CHANGED",
"SEND_MAIL_COD_CHANGED",
"MAIL_SEND_SUCCESS",
],
Some("GetSendMailMoney()"),
);
let seen: Vec<String> = s
.eval("SetSendMailMoney(250) local h = HEARD HEARD = {} return h")
.unwrap();
assert_eq!(seen, ["SEND_MAIL_MONEY_CHANGED 250"]);
let seen: Vec<String> = s
.eval("ClearSendMail() local h = HEARD HEARD = {} return h")
.unwrap();
assert_eq!(
seen,
[
"SEND_MAIL_MONEY_CHANGED 0",
"SEND_MAIL_COD_CHANGED 0",
"MAIL_SEND_SUCCESS 0"
]
);
assert!(s.model_ref().pending_events.is_empty());
}
/// A send needs a stationery: select the default, as the stock `SendMailFrame_Reset` does.
fn select_default_stationery(s: &mut UiScript) {
s.set_mail_stationeries(vec![super::StationeryView {
@@ -1069,6 +1073,52 @@ mod tests {
.unwrap());
}
/// A row that does not exist still answers the full tuple: `GetInboxHeaderInfo` 13 values,
/// four nils, `0` money, COD and days left, six nils (`0x4aeff0`..`0x4af05e`), so a closed
/// letter's `GetInboxHeaderInfo(0)` can be compared with `CODAmount > 0` (`MailFrame.lua:399`);
/// `GetInboxText` four nils (`0x4af311`..`0x4af334`).
#[test]
fn a_missing_inbox_row_answers_the_references_miss_tuples() {
let mut s = UiScript::new().unwrap();
s.set_mail(Some(state()));
for index in [0, 3, 99] {
assert_eq!(
s.arity(&format!("GetInboxHeaderInfo({index})")).unwrap(),
13,
"header {index}"
);
assert_eq!(
s.arity(&format!("GetInboxText({index})")).unwrap(),
4,
"text {index}"
);
assert_eq!(
s.eval::<String>(&format!(
"local t = {{GetInboxHeaderInfo({index})}} \
local k = '' \
for i = 1, 13 do k = k .. type(t[i]) .. ',' end \
local _, _, _, _, m, c, d = GetInboxHeaderInfo({index}) \
return k .. m .. ',' .. c .. ',' .. d .. ',' .. tostring(c > 0)"
))
.unwrap(),
"nil,nil,nil,nil,number,number,number,nil,nil,nil,nil,nil,nil,0,0,0,false",
"header {index}"
);
assert!(
s.eval::<bool>(&format!(
"local a, b, c, d = GetInboxText({index}) \
return a == nil and b == nil and c == nil and d == nil"
))
.unwrap(),
"text {index}"
);
}
assert!(
s.take_mail_opens().is_empty(),
"a miss opens nothing for the app to fetch"
);
}
#[test]
fn get_inbox_text_returns_body_and_queues_open() {
let mut s = UiScript::new().unwrap();
@@ -1340,6 +1390,104 @@ mod tests {
s.set_has_new_mail(false);
assert!(s.eval::<bool>("return HasNewMail() == nil").unwrap());
}
/// The attach clears with `SetCursorItem(0, …)` (`0x4ae0d2`): no unlock and no
/// `ITEM_LOCK_CHANGED`, so the attached item stays locked in its bag and cannot be picked up
/// again, until the compose reset lifts the lock with its event (`0x4acdd6`).
#[test]
fn an_attached_item_keeps_its_lock_until_it_leaves_the_slot() {
use crate::script::cursor::{bag_lock_probe, two_item_bag};
use crate::script::tick::heard;
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(two_item_bag()));
s.run("PickupContainerItem(0, 1)").unwrap();
heard::listen(
&s,
&["ITEM_LOCK_CHANGED", "CURSOR_UPDATE"],
Some(&bag_lock_probe(1)),
);
s.run("ClickSendMailItemButton()").unwrap();
assert_eq!(heard::take(&s), ["CURSOR_UPDATE 1"]);
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(
s.cursor_payload().is_none(),
"a locked item is not picked up"
);
assert!(heard::take(&s).is_empty());
s.reset_compose_tab();
assert_eq!(heard::take(&s), ["ITEM_LOCK_CHANGED nil"]);
}
/// An occupied attach slot puts the old attachment back on the cursor through
/// `SetCursorItem(0, …)` (`0x4ae05a`-`0x4ae0d4`): its inner clear's `CURSOR_UPDATE` reads the
/// emptied cursor, the set's the old item, and both items stay locked. An empty hand takes the
/// attachment back the same way. With nothing held and nothing attached the click is
/// `ClearCursor(1,1)` (`0x4ae08b`), which drops any other payload and signals nothing more.
#[test]
fn an_attach_onto_an_occupied_slot_puts_the_old_item_on_the_cursor() {
use crate::script::cursor::{
bag_lock_probe, install_kind_probe, two_item_bag, CursorMacro, CursorPayload,
};
use crate::script::tick::heard;
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(two_item_bag()));
install_kind_probe(&s);
s.run("PickupContainerItem(0, 1) ClickSendMailItemButton() PickupContainerItem(0, 2)")
.unwrap();
heard::listen(
&s,
&[
"ACTIONBAR_HIDEGRID",
"ACTIONBAR_SHOWGRID",
"CURSOR_UPDATE",
"ITEM_LOCK_CHANGED",
"MAIL_SEND_INFO_UPDATE",
],
Some("CursorKind() .. ' ' .. tostring(GetSendMailItem())"),
);
s.run("ClickSendMailItemButton()").unwrap();
assert_eq!(
heard::take(&s),
[
"ACTIONBAR_HIDEGRID none Minor Healing Potion",
"CURSOR_UPDATE none Minor Healing Potion",
"ACTIONBAR_SHOWGRID item Minor Healing Potion",
"CURSOR_UPDATE item Minor Healing Potion",
"MAIL_SEND_INFO_UPDATE item Minor Healing Potion"
]
);
assert_eq!(s.cursor_item().map(|i| i.slot), Some(1));
for slot in [1, 2] {
let probe = bag_lock_probe(slot);
assert!(s.eval::<bool>(&format!("return {probe} == 1")).unwrap());
}
// An empty hand takes the attachment back.
s.run("ClearCursor()").unwrap();
heard::take(&s);
s.run("ClickSendMailItemButton()").unwrap();
assert_eq!(
heard::take(&s),
[
"CURSOR_UPDATE none nil",
"ACTIONBAR_SHOWGRID item nil",
"CURSOR_UPDATE item nil",
"MAIL_SEND_INFO_UPDATE item nil"
]
);
assert_eq!(s.cursor_item().map(|i| i.slot), Some(2));
// Nothing held and nothing attached: a plain clear, here of a held macro.
s.run("ClearCursor()").unwrap();
s.set_cursor_for_test(CursorPayload::Macro(CursorMacro {
index: 1,
texture: None,
}));
heard::take(&s);
s.run("ClickSendMailItemButton()").unwrap();
assert!(s.cursor_payload().is_none());
assert_eq!(heard::take(&s), ["CURSOR_UPDATE none nil"]);
}
}
#[cfg(test)]
+8 -2
View File
@@ -36,10 +36,15 @@ pub trait TextMeasure {
/// one is left alone, so a per-frame poll does not re-measure.
pub(super) fn ensure_measured(lua: &Lua, rh: RegionHandle) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
ensure_measured_in(&mut model, rh);
}
/// [`ensure_measured`] on a borrowed model, for the engine's own widgets.
pub(super) fn ensure_measured_in(model: &mut Model, rh: RegionHandle) {
if model.measurer.is_none() {
return;
}
let Some(req) = super::layout::measure_request_for(&mut model, rh) else {
let Some(req) = super::layout::measure_request_for(model, rh) else {
return; // not a FontString, no text, or already current
};
// Taken out and put back: the measurer needs `&mut self` while the model is borrowed, and it
@@ -61,8 +66,9 @@ pub(super) fn ensure_measured(lua: &Lua, rh: RegionHandle) {
}
if moved {
// Only a box that moved touches the layout, as in `set_measured_text`: a same-width
// re-measure (a countdown tick) must not cost a whole-roster hash.
// re-measure (a countdown tick) must not cost a whole-roster hash. Stored whole, as there.
model.touch_layout_region(rh);
model.queue_force(req.id);
}
}
+179 -93
View File
@@ -9,10 +9,11 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::number_arg;
use super::binding_abi::{flag, number_arg};
use super::container::UiCursorMode;
use super::cursor::CursorPayload;
use super::Model;
use crate::script::binding_abi::Idx;
/// One vendor row, resolved by the app; its 1-based index is its place in [`MerchantState::items`].
#[derive(Clone, Debug, Default, PartialEq)]
@@ -173,6 +174,37 @@ fn invalid_tuple() -> Vec<Value> {
]
}
/// The row `PickupMerchantItem(index)` grabs, read before its clear: `None` for each silent
/// refusal, a non-number (`0x4fb7cb`; a numeric string passes), `index < 1` (`0x4fb7e1`),
/// `index > count` (`0x4fb7e9`), no merchant (`0x4fb7f7`), a dead row (`0x4fb80b`), or the held
/// row again (`0x4fb818`, the toggle-off). Neither stock nor the item cache is tested: a sold-out
/// row grabs, and the server refuses the buy.
fn merchant_grab(model: &Model, index: Option<f64>) -> Option<CursorPayload> {
let held = match &model.cursor {
Some(CursorPayload::Merchant(m)) => Some(m.row),
_ => None,
};
// `_ftol` truncates toward zero, then the bound applies signed. The upper bound is
// load-bearing: `2^32 + 1` would narrow to row 0, where the reference bounds against the
// vendor count before any narrowing.
let row = index?.trunc();
if row.is_nan() || row < 1.0 || row > f64::from(u32::MAX) {
return None;
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let row0 = row as u32 - 1;
let item = model.merchant.as_ref()?.items.get(row0 as usize)?;
// The toggle-off: naming the held row puts nothing back.
if held == Some(row0) {
return None;
}
Some(CursorPayload::Merchant(super::cursor::CursorMerchantItem {
item_id: item.item_id,
row: row0,
texture: item.texture.clone(),
}))
}
/// Register the merchant globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
@@ -187,7 +219,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"GetMerchantItemInfo",
lua.create_function(|lua, index: usize| {
lua.create_function(|lua, Idx(index): Idx| {
let (item, usable) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let item = model
@@ -207,10 +239,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Some(n) => Value::String(lua.create_string(n)?),
None => Value::Nil,
};
let texture = match &it.texture {
Some(t) => Value::String(lua.create_string(t)?),
None => Value::Nil,
};
// A string on every path: the wire display id has no icon row, or no row, so
// `0x5d88b0` answers the question mark (`0x4fb214`, `0x5d8927`).
let texture = Value::String(
lua.create_string(
it.texture
.as_deref()
.unwrap_or(super::item_stats::MISSING_ITEM_ICON),
)?,
);
Ok(MultiValue::from_vec(vec![
name,
texture,
@@ -226,7 +263,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// arms accept a nil (`MerchantFrame.lua:303`, `:306`).
g.set(
"GetMerchantItemLink",
lua.create_function(|lua, index: usize| {
lua.create_function(|lua, Idx(index): Idx| {
let link = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
@@ -309,62 +346,29 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// `CursorHasItem()` first (`MerchantFrame.lua:329`). A bag item on the cursor is sold, index
// ignored and no merchant-open gate, which stock `MerchantFrame.xml:743`'s
// `PickupMerchantItem(0)` relies on; otherwise row `index - 1` is grabbed as cursor mode 5.
//
// Every refusal is silent and clears the cursor: a non-number (`0x4fb7cb`; a numeric string
// passes), `index < 1` (`0x4fb7e1`), `index > count` (`0x4fb7e9`), no merchant (`0x4fb7f7`), a
// dead row (`0x4fb80b`), or the held row again (`0x4fb818`, the toggle-off). Neither stock nor
// the item cache is tested: a sold-out row grabs, and the server refuses the buy.
// Every refusal ([`merchant_grab`]) is silent and clears the cursor.
g.set(
"PickupMerchantItem",
lua.create_function(|lua, index: Option<f64>| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// The sell fork comes first, before the argument is read. Its clear is the sale's, not
// `ClearCursor`: `CURSOR_UPDATE` fires and the source slot stays greyed until the
// server's inventory update (`0x494b60`).
if let Some(item) = crate::script::cursor::take_cursor_item_for_sale(&mut model) {
model.merchant_cursor_sells.push((item.bag, item.slot));
return Ok(());
}
// server's inventory update (`0x494b60`). Every other leg reads the row, then clears.
let grab = super::tick::verb(lua, |model| {
if let Some(item) = crate::script::cursor::take_cursor_item_for_sale(model) {
model.merchant_cursor_sells.push((item.bag, item.slot));
return None;
}
Some(merchant_grab(model, index))
});
let Some(grab) = grab else { return Ok(()) };
// Every other leg clears with the real `ClearCursor(1,1)` (`0x4fb82d`/`0x4fb83f` on a
// refusal, `0x49510b` in the grab), not a bare drop: a held spell fires `CURSOR_UPDATE`
// and hides the bar grid, and an armed gift wrap is cancelled (`0x5edf10`).
let held = match &model.cursor {
Some(CursorPayload::Merchant(m)) => Some(m.row),
_ => None,
};
crate::script::cursor::clear_cursor(&mut model);
let Some(index) = index else { return Ok(()) };
// `_ftol` truncates toward zero, then the bound applies signed. The upper bound is
// load-bearing: `2^32 + 1` would narrow to row 0, where the reference bounds against
// the vendor count before any narrowing.
let row = index.trunc();
if row.is_nan() || row < 1.0 || row > f64::from(u32::MAX) {
return Ok(());
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let row0 = row as u32 - 1;
let Some(merchant) = model.merchant.as_ref() else {
return Ok(());
};
let Some(item) = merchant.items.get(row0 as usize) else {
return Ok(());
};
// The toggle-off: naming the held row puts nothing back.
if held == Some(row0) {
return Ok(());
}
let payload = CursorPayload::Merchant(super::cursor::CursorMerchantItem {
item_id: item.item_id,
row: row0,
texture: item.texture.clone(),
});
// The grab's `SignalEvent(CURSOR_UPDATE)` (`0x495159`); mode 5 skips the mode-7
// `ACTIONBAR_SHOWGRID` branch.
model.cursor = Some(payload);
crate::script::cursor::queue_cursor_update(&mut model);
// refusal, `0x495112` in the grab), not a bare drop: a held spell fires `CURSOR_UPDATE`
// and hides the bar grid, and an armed gift wrap is cancelled (`0x5edf10`). Its events
// run before the grab sets the row, so that `CURSOR_UPDATE` reads an empty cursor.
// The grab is the setter (`0x4950f0`): its clear, then the row and its
// `CURSOR_UPDATE` (`0x49515e`); mode 5 skips the mode-7 `ACTIONBAR_SHOWGRID` branch.
crate::script::cursor::set_cursor(lua, true, |_| grab);
Ok(())
})?,
)?;
@@ -390,7 +394,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// same `0x5ea930` gate over the sold item (`0x4fb4f7`): one its seller cannot use reads red.
g.set(
"GetBuybackItemInfo",
lua.create_function(|lua, index: usize| {
lua.create_function(|lua, Idx(index): Idx| {
let (item, usable) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let item = model
@@ -406,14 +410,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let Some(it) = item else {
return Ok(MultiValue::from_vec(invalid_tuple()));
};
let name = match &it.name {
Some(n) => Value::String(lua.create_string(n)?),
None => Value::Nil,
};
let texture = match &it.texture {
Some(t) => Value::String(lua.create_string(t)?),
None => Value::Nil,
};
// A template the cache lacks takes the miss (`0x4fb416`, `0x4fb515`); a cached one
// reaches the icon through its display (`0x4fb45e`, `0x5d8af0`), falling to the
// question mark when that row is missing or bare.
if it.name.is_none() {
return Ok(MultiValue::from_vec(invalid_tuple()));
}
let name = Value::String(lua.create_string(it.name.as_deref().unwrap_or_default())?);
let texture = Value::String(
lua.create_string(
it.texture
.as_deref()
.unwrap_or(super::item_stats::MISSING_ITEM_ICON),
)?,
);
Ok(MultiValue::from_vec(vec![
name,
texture,
@@ -446,21 +456,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// GetRepairAllCost() → cost, canRepair: whether there is damage to pay for, which enables the
// repair-all button (`MerchantFrame.lua:38`); 0 away from a vendor that repairs (`0x4fbd60`).
// repair-all button (`MerchantFrame.lua:38`). `canRepair` is 1 or nil (`0x4fbfa8`-`0x4fbfce`).
// With no open merchant that repairs (`0x4fadb0`) it answers the one value 0 (`0x4fbd76`).
g.set(
"GetRepairAllCost",
lua.create_function(|lua, ()| {
let cost = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
if model.merchant.as_ref().is_some_and(|m| m.can_repair) {
model.repair_all_cost
} else {
0
}
};
let model = lua.app_data_ref::<Model>().expect("model app_data");
if !model.merchant.as_ref().is_some_and(|m| m.can_repair) {
return Ok(MultiValue::from_vec(vec![Value::Integer(0)]));
}
let cost = model.repair_all_cost;
Ok(MultiValue::from_vec(vec![
Value::Integer(i64::from(cost)),
Value::Boolean(cost > 0),
flag(cost > 0),
]))
})?,
)?;
@@ -481,13 +489,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"ShowRepairCursor",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// `0x4fbcc0`: a targeting spell or a vendor without repair service leaves the
// cursor untouched. `ClearCursor(1,1)` returns any held item before mode 0x11 arms.
if model.spell_targeting || !model.merchant.as_ref().is_some_and(|m| m.can_repair) {
return Ok(());
// cursor untouched. `ClearCursor(1,1)` returns any held item before mode 0x11 arms,
// its events running first.
{
let model = lua.app_data_ref::<Model>().expect("model app_data");
if model.spell_targeting || !model.merchant.as_ref().is_some_and(|m| m.can_repair) {
return Ok(());
}
}
crate::script::cursor::clear_cursor(&mut model);
super::tick::verb(lua, crate::script::cursor::clear_cursor);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.repair_mode = true;
model.ui_cursor = None;
model.ui_cursor_dirty = true;
@@ -616,11 +628,13 @@ mod tests {
assert_eq!((price, quantity, num), (25, 5, -1));
assert_eq!(usable, 1);
// In flight: name and texture nil, and usable (the null-record skip).
// In flight: name nil, the texture the question mark (the wire display has no icon row,
// `0x4fb214`), and usable (the null-record skip).
assert!(s
.eval::<bool>(
"local n, t, p, q, a, u = GetMerchantItemInfo(2)\n\
return n == nil and t == nil and p == 1500 and u == 1",
return n == nil and t == 'Interface\\\\Icons\\\\INV_Misc_QuestionMark' \
and p == 1500 and u == 1",
)
.unwrap());
assert!(s
@@ -772,6 +786,56 @@ mod tests {
assert!(!s.take_merchant_close(), "drained");
}
/// The question-mark fallback and the buyback miss: a vendor row whose wire display has no
/// icon (or no row) answers the question mark (`0x5d88b0` at `0x4fb214`), a buyback row with a
/// cached template and no icon does too (`0x5d8af0` at `0x4fb45e`), and one whose template is
/// uncached takes the miss tuple nil, nil, 0, 1, 0, 1 (`0x4fb416`, `0x4fb515`).
#[test]
fn merchant_and_buyback_icons_fall_back_and_an_uncached_buyback_misses() {
let mut s = UiScript::new().unwrap();
let row = |name: Option<&str>| MerchantItem {
name: name.map(str::to_string),
texture: None,
price: 10,
quantity: 1,
num_available: -1,
item_id: 25,
..Default::default()
};
s.set_merchant(Some(MerchantState {
items: vec![row(Some("Sword")), row(None)],
buyback: vec![
MerchantItem {
num_available: 0,
..row(Some("Sold Sword"))
},
row(None),
],
..Default::default()
}));
let shape = |call: &str| -> String {
s.eval(&format!(
"local c = function(...) local o = {{}} for i = 1, arg.n do o[i] = tostring(arg[i]) end \
return table.concat(o, ',') end return c({call})"
))
.unwrap()
};
let q = "Interface\\Icons\\INV_Misc_QuestionMark";
assert_eq!(
shape("GetMerchantItemInfo(1)"),
format!("Sword,{q},10,1,-1,1")
);
assert_eq!(
shape("GetMerchantItemInfo(2)"),
format!("nil,{q},10,1,-1,1")
);
assert_eq!(
shape("GetBuybackItemInfo(1)"),
format!("Sold Sword,{q},10,1,0,1")
);
assert_eq!(shape("GetBuybackItemInfo(2)"), "nil,nil,0,1,0,1");
}
#[test]
fn buyback_reads_and_intents() {
let mut s = UiScript::new().unwrap();
@@ -815,11 +879,21 @@ mod tests {
s.eval::<bool>("return GetRepairAllCost() == 0").unwrap(),
"no vendor"
);
assert_eq!(
s.arity("GetRepairAllCost()").unwrap(),
1,
"no vendor: `0x4fbd76`"
);
s.set_merchant(Some(stock()));
assert!(
s.eval::<bool>("return GetRepairAllCost() == 0").unwrap(),
"a vendor that does not repair"
);
assert_eq!(
s.arity("GetRepairAllCost()").unwrap(),
1,
"one value, 0, when `0x4fadb0` fails (`0x4fbd76`)"
);
}
/// `RepairAllItems` returns before anything else unless the open merchant repairs
@@ -846,8 +920,14 @@ mod tests {
s.set_repair_all_cost(1234);
assert!(s.eval::<bool>("return CanMerchantRepair() == 1").unwrap());
assert!(s
.eval::<bool>("local c, can = GetRepairAllCost()\nreturn c == 1234 and can == true",)
.eval::<bool>("local c, can = GetRepairAllCost()\nreturn c == 1234 and can == 1",)
.unwrap());
// No damage: `canRepair` is nil, not false (`0x4fbfce`).
s.set_repair_all_cost(0);
assert!(s
.eval::<bool>("local c, can = GetRepairAllCost()\nreturn c == 0 and can == nil",)
.unwrap());
assert_eq!(s.arity("GetRepairAllCost()").unwrap(), 2);
s.run("RepairAllItems()").unwrap();
assert!(s.take_repair_all());
@@ -910,7 +990,10 @@ mod tests {
assert!(s.cursor_item().is_some());
s.set_spell_targeting(false);
// The clear's `CURSOR_UPDATE` (`0x4fbcd9`) runs before repair mode arms.
crate::script::tick::heard::listen(&s, &["CURSOR_UPDATE"], Some("InRepairMode()"));
s.run("ShowRepairCursor()").unwrap();
assert_eq!(crate::script::tick::heard::take(&s), ["CURSOR_UPDATE nil"]);
assert!(s.repair_mode());
assert!(
s.cursor_item().is_none(),
@@ -1168,21 +1251,24 @@ mod tests {
let mut s = UiScript::new().unwrap();
s.set_merchant(Some(stock()));
listen_cursor_events(&mut s);
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
book_slot: 1,
book_type: "spell".into(),
spell_id: 133,
texture: None,
passive: false,
}));
cursor::queue_cursor_update(&mut s.model_mut()); // the pickup's own transition
assert_eq!(cursor_event_counts(&mut s), (1, 1, 0), "the spell pickup");
// The spell setter's set and tail (`0x494dd1`): the grid, no `CURSOR_UPDATE`.
cursor::put(
&mut s.model_mut(),
CursorPayload::Spell(CursorSpell {
book_slot: 1,
book_type: "spell".into(),
spell_id: 133,
texture: None,
passive: false,
}),
);
assert_eq!(cursor_event_counts(&mut s), (0, 1, 0), "the spell pickup");
s.run("PickupMerchantItem(0)").unwrap();
assert!(s.cursor_payload().is_none());
assert_eq!(
cursor_event_counts(&mut s),
(2, 1, 1),
(1, 1, 1),
"(CURSOR_UPDATE, ACTIONBAR_SHOWGRID, ACTIONBAR_HIDEGRID) after the vendor drop"
);
}
+109 -16
View File
@@ -25,6 +25,35 @@ mod scrolling;
pub(super) use plain::REG_MESSAGEFRAME_METHODS;
pub(super) use scrolling::REG_SCROLLINGMESSAGEFRAME_METHODS;
/// A message frame's update after its `OnUpdate`, in its walk slot, so only while shown: a hidden
/// frame's lines keep their time. The plain class (`0x786200`) first drains its queued messages
/// against the lines that fit (`0x786247`), which [`MessageFrameState::trim_to_viewport`] stands
/// for, then fades (`0x7862b0`); the scrolling class (`0x788460`) only fades.
///
/// [`MessageFrameState::trim_to_viewport`]: crate::widget::MessageFrameState::trim_to_viewport
pub(super) fn update(model: &mut Model, h: FrameHandle, elapsed: f32) {
let rows = UiScript::message_viewport_rows(model, h);
match model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
Some(KindState::Message(mf)) => {
mf.trim_to_viewport(rows);
mf.tick(elapsed);
}
Some(KindState::ScrollingMessage(smf)) => smf.tick(elapsed),
_ => {}
}
}
/// `lua_isnumber` (`0x6f34d0`) then `lua_tonumber` (`0x6f3620`): a number, or a string Lua reads
/// as one. The message-frame verbs gate every colour channel and id on it.
pub(super) fn is_number(lua: &mlua::Lua, v: &mlua::Value) -> Option<f64> {
lua.coerce_number(v.clone()).ok().flatten()
}
/// A channel the verb has already gated with [`is_number`], as the `f32` it stores.
pub(super) fn channel(lua: &mlua::Lua, v: &mlua::Value) -> f32 {
is_number(lua, v).map_or(0.0, |n| n as f32)
}
/// `AddMessage`'s text; `None` means the whole call does nothing, no line and no error. Both
/// bindings (`0x795590`, `0x792900`) jump to their epilogue (`0x79582b`, `0x792b81`) when
/// `lua_isstring` (`0x6f3510`) fails, `lua_tostring` (`0x6f3690`) gives NULL or the string is empty
@@ -142,6 +171,39 @@ pub(super) struct MessageFont {
/// The child's `justifyH`, which centres `UIErrorsFrame`'s lines and keeps chat flush left;
/// LEFT without a declared FontString, not the FontString default of CENTER.
pub(super) justify_h: JustifyH,
/// The font's spacing as set, a negative included (`+0x50`).
pub(super) spacing: f32,
}
/// `0x766750`: a length snapped to whole pixels, here of the 768-tall root grid, the device pixel
/// of a 768-tall window: a positive one rounds up (`+0.99999`, then truncation), a negative one
/// truncates toward zero.
fn snap_px(v: f32) -> f32 {
if v > 0.0 {
(v + 0.999_99).trunc()
} else {
v.trunc()
}
}
impl MessageFont {
/// The font height, the default 14 when none is set.
fn height_or_default(&self) -> f32 {
self.height.unwrap_or(14.0)
}
/// The gap between a line string's rows and, in the scrolling class, between messages: each
/// line string inherits the frame's font (`0x7888fd`) through the clamping setter (`0x7708fd`
/// → `0x772240`), and the stack snaps its field (`0x78899e` → `0x766750`).
fn line_gap(&self, scale: f32) -> f32 {
snap_px(self.spacing.max(0.0) * scale)
}
/// The plain class's pitch, its fit and its slot step (`0x785f03`-`0x785f2a`, `0x786184`): the
/// font's own spacing, unclamped (`0x785f06`), and the height, each snapped.
fn plain_pitch(&self, scale: f32) -> f32 {
snap_px(self.height_or_default() * scale) + snap_px(self.spacing * scale)
}
}
/// The FontString region holding a message frame's font, made on the first font-block call when
@@ -175,7 +237,7 @@ pub(super) fn ensure_font_region(
let mut data = crate::script::RegionData::default();
data.justify.set_h(JustifyH::Left);
model.region_data.insert(rh, data);
model.touch_layout();
model.touch_layout_region(rh);
Some(rh)
}
@@ -201,6 +263,7 @@ impl UiScript {
shadow: d.font_shadow,
outline: d.outline,
justify_h: d.justify.paint_h(),
spacing: d.spacing,
})
})
.unwrap_or(MessageFont {
@@ -209,16 +272,27 @@ impl UiScript {
shadow: None,
outline: Outline::default(),
justify_h: JustifyH::Left,
spacing: 0.0,
})
}
/// How many rows frame `fh`'s resolved rect holds at its font's pitch, the client's
/// `numLinesDisplayed`: the scrolling class's page size and the plain class's capacity.
/// `numLinesDisplayed`: the scrolling class's page size and the plain class's capacity. Both
/// the rect and the pitch are scaled (`0x785f0a`, `0x785f1f`); a pitch of 0 or less, which a
/// negative spacing can make on the plain class, holds nothing (`0x785f8f`).
pub(super) fn message_viewport_rows(model: &Model, fh: FrameHandle) -> usize {
let pitch = Self::message_frame_font(model, fh)
.height
.unwrap_or(14.0)
.max(1.0);
let font = Self::message_frame_font(model, fh);
let Some(frame) = model.arena.frame(fh) else {
return 0;
};
let scale = frame.effective_scale;
let pitch = match frame.kind_state {
KindState::Message(_) => font.plain_pitch(scale),
_ => font.height_or_default() * scale + font.line_gap(scale),
};
if pitch <= 0.0 {
return 0;
}
model.resolved.get(&fh).map_or(0, |r| {
((r.top - r.bottom) / pitch).floor().max(0.0) as usize
})
@@ -272,6 +346,10 @@ impl UiScript {
font.height.map(f32::to_bits).hash(&mut h);
wrap_width.to_bits().hash(&mut h);
(font.outline as u8).hash(&mut h);
font.shadow
.map_or(0.0, |s| s.offset[0])
.to_bits()
.hash(&mut h);
scale.to_bits().hash(&mut h);
h.finish()
};
@@ -287,6 +365,10 @@ impl UiScript {
font.height.map(f32::to_bits).hash(&mut hasher);
wrap_width.to_bits().hash(&mut hasher);
(font.outline as u8).hash(&mut hasher);
font.shadow
.map_or(0.0, |s| s.offset[0])
.to_bits()
.hash(&mut hasher);
scale.to_bits().hash(&mut hasher);
let key = hasher.finish();
if line.rows_key == key {
@@ -299,6 +381,7 @@ impl UiScript {
height: font.height,
wrap_width,
outline: font.outline,
shadow_x: font.shadow.map_or(0.0, |s| s.offset[0]),
scale,
text: line.text.clone(),
key,
@@ -334,11 +417,12 @@ impl UiScript {
}
}
/// Push one [`QuadContent::Text`] per visible message of either class, each `rows × pitch`
/// tall. The pitch is the font height, the client's line step (`LayoutLines` `0x5cdc20`: size
/// plus a spacing of 0); the message frame's own relayout (`0x788750`, `0x788c00`) is not fully
/// traced. A scrolling frame stacks up from the bottom, `scroll_offset` choosing the bottom
/// message. A MessageFrame does too for `insertMode` BOTTOM, and for TOP hangs the newest off
/// Push one [`QuadContent::Text`] per visible message of either class, each `rows·S +
/// (rows−1)·gap` tall, its wrapped block (`0x5c2070`). The scrolling class anchors each line
/// string on the previous one's top at its snapped spacing (`0x788954`-`0x788975`) and charges
/// `spacing + height` per message (`0x78899e`-`0x7889ad`); the plain class steps each row by its
/// own pitch, the raw spacing included ([`MessageFont::plain_pitch`]). A scrolling frame stacks
/// up from the bottom, `scroll_offset` choosing the bottom message. A MessageFrame does too for `insertMode` BOTTOM, and for TOP hangs the newest off
/// the top edge (`UIErrorsFrame.xml:4`); the reference's anchor per mode is untraced, and this
/// reading reproduces both stock uses. A message that partly fits draws clipped; a faded
/// scrolling line keeps its rows, as the reference never re-packs chat.
@@ -378,9 +462,15 @@ impl UiScript {
KindState::Message(s) => s.justify,
_ => None,
};
// The resolved rect is scaled and the font height is frame-local, so the pitch scales.
let pitch = font.height.unwrap_or(14.0) * paint.scale;
if pitch <= 0.0 || fr.top <= fr.bottom {
// The resolved rect is scaled and the font is frame-local, so the height and gap scale.
let line_h = font.height_or_default() * paint.scale;
let gap = font.line_gap(paint.scale);
// A row's advance: the scrolling class's height plus gap, the plain class's own pitch.
let row_step = match &frame.kind_state {
KindState::Message(_) => font.plain_pitch(paint.scale),
_ => line_h + gap,
};
if line_h <= 0.0 || row_step <= 0.0 || fr.top <= fr.bottom {
return;
}
// Clip to the frame, intersected with any ScrollFrame ancestor's clip.
@@ -394,13 +484,14 @@ impl UiScript {
break; // the next band starts outside the frame
}
let line = &lines[idx];
let band_h = f32::from(line.rows.max(1)) * pitch;
let rows = f32::from(line.rows.max(1));
let band_h = rows * line_h + (rows - 1.0) * gap;
let (bottom, top) = if from_top {
(fr.top - used - band_h, fr.top - used)
} else {
(fr.bottom + used, fr.bottom + used + band_h)
};
used += band_h;
used += rows * row_step;
if line.alpha <= 0.0 {
continue; // faded: holds its rows, draws nothing
}
@@ -431,6 +522,8 @@ impl UiScript {
font_height: font.height,
text_height: None, // message lines have no SetTextHeight
outline: font.outline,
// The line string's own field, clamped; the host snaps it.
spacing: font.spacing.max(0.0),
alpha_gradient: None,
world_seat: false, // interface, not world
},
@@ -5,8 +5,9 @@
//! there and forces alpha opaque. `SetInsertMode` and `GetInsertMode` (`0x794ed0`, `0x794ff0`)
//! exist on this class only.
use mlua::{Lua, Table, Value};
use mlua::{Lua, MultiValue, Table, Value};
use super::{channel, is_number};
use crate::script::object::frame_handle_of;
use crate::script::Model;
use crate::widget::{InsertMode, KindState, MessageFrameState};
@@ -33,15 +34,6 @@ fn with_mf<T>(
}
}
/// A colour argument as f32, 0 for anything but a number.
fn num_f32(v: &Value) -> f32 {
match v {
Value::Number(n) => *n as f32,
Value::Integer(i) => *i as f32,
_ => 0.0,
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
@@ -60,18 +52,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let Some(text) = super::message_text(lua, &text) else {
return Ok(());
};
let has_rgb = !matches!(
(&r, &g, &b),
(Value::Nil, _, _) | (_, Value::Nil, _) | (_, _, Value::Nil)
);
// Each of r, g and b is `lua_isnumber`-gated (`0x79565f`-`0x795687`) and the trio is
// all or nothing; a failed gate still adds the line, in white (`0x79581c`).
let has_rgb = [&r, &g, &b].iter().all(|v| is_number(lua, v).is_some());
let (r, g, b) = if has_rgb {
(num_f32(&r), num_f32(&g), num_f32(&b))
(channel(lua, &r), channel(lua, &g), channel(lua, &b))
} else {
(1.0, 1.0, 1.0)
};
// The alpha counts only with the trio, read in the same parse.
let a = match (&a, has_rgb) {
(Value::Number(_) | Value::Integer(_), true) => num_f32(&a),
// The alpha is its own `lua_isnumber` gate (`0x795759`), default 1.0, read with
// the trio.
let a = match (is_number(lua, &a), has_rgb) {
(Some(n), true) => n as f32,
_ => 1.0,
};
with_mf(lua, &this, |mf| mf.add(text, r, g, b, a))
@@ -116,8 +108,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// The fade accessors, as on the scrolling class; XML's `displayDuration` is `TimeVisible`.
m.set(
"SetFading",
lua.create_function(|lua, (this, on): (Table, Value)| {
let on = !matches!(on, Value::Nil | Value::Boolean(false));
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
// `GetBoolOrDefault` (`0x6f1c10`, `0x7950d0`) with default 1: a bare call enables.
let on = crate::script::binding_abi::bool_or_default(args.front(), true);
with_mf(lua, &this, |mf| mf.fading_enabled = on)
})?,
)?;
@@ -163,7 +156,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
super::ensure_font_region(lua, h)
.ok_or_else(|| mlua::Error::runtime("not a MessageFrame"))
},
"MessageFrame",
crate::script::font_block::FontKind::Instance,
None,
)?;
@@ -4,8 +4,9 @@
//! every scroll call re-arming the displayed lines' fade (`0x788b80` when refused at an end,
//! `0x788af0` when it moves), which is what brings a faded chat back.
use mlua::{Lua, Table, Value};
use mlua::{Lua, MultiValue, Table, Value};
use super::{channel, is_number};
use crate::script::object::frame_handle_of;
use crate::script::{Model, UiScript};
use crate::widget::{KindState, ScrollingMessageState};
@@ -33,24 +34,6 @@ fn with_smf<T>(
}
}
/// `lua_isnumber`: a number or a numeric string. The rgb presence test is three of these.
fn is_number(v: &Value) -> bool {
match v {
Value::Integer(_) | Value::Number(_) => true,
Value::String(s) => s.to_str().is_ok_and(|s| s.trim().parse::<f64>().is_ok()),
_ => false,
}
}
/// A colour argument as f32, 0 for anything but a number.
fn num_f32(v: &Value) -> f32 {
match v {
Value::Number(n) => *n as f32,
Value::Integer(i) => *i as f32,
_ => 0.0,
}
}
/// Run a scroll call with the viewport's row count, which every scroll needs: each re-arms the
/// fade of the lines on display ([`ScrollingMessageState::reset_all_fade_times`]).
fn scroll(
@@ -94,15 +77,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let Some(text) = super::message_text(lua, &text) else {
return Ok(());
};
let has_rgb = is_number(&r) && is_number(&g) && is_number(&b);
// The chat-type index (`ChatFrame.lua` passes `info.id`), stack index 6 or 3.
let id = match if has_rgb { &id } else { &r } {
Value::Integer(i) => u32::try_from(*i).unwrap_or(0),
Value::Number(n) if n.is_finite() && *n >= 0.0 => *n as u32,
_ => 0,
};
let has_rgb = [&r, &g, &b].iter().all(|v| is_number(lua, v).is_some());
// The chat-type index (`ChatFrame.lua` passes `info.id`), stack index 6 or 3, a
// `lua_isnumber`-gated `_ftol` (`0x792b1b`, `0x792b4f`); 0 when the gate fails.
let id = is_number(lua, if has_rgb { &id } else { &r })
.map_or(0, |n| crate::script::binding_abi::ftol(n) as u32);
let (r, g, b) = if has_rgb {
(num_f32(&r), num_f32(&g), num_f32(&b))
(channel(lua, &r), channel(lua, &g), channel(lua, &b))
} else {
(1.0, 1.0, 1.0)
};
@@ -117,12 +98,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"UpdateColorByID",
lua.create_function(
|lua, (this, id, r, g, b): (Table, Value, Value, Value, Value)| {
let id = match &id {
Value::Integer(i) => u32::try_from(*i).unwrap_or(0),
Value::Number(n) if n.is_finite() && *n >= 0.0 => *n as u32,
_ => return Ok(()),
};
let (r, g, b) = (num_f32(&r), num_f32(&g), num_f32(&b));
// All four arguments are `lua_isnumber`-gated (`0x793345`-`0x793388`); any failure
// is a silent no-op, and the id is an `_ftol` (`0x7933a1`).
if ![&id, &r, &g, &b]
.iter()
.all(|v| is_number(lua, v).is_some())
{
return Ok(());
}
let id =
crate::script::binding_abi::ftol(is_number(lua, &id).unwrap_or(0.0)) as u32;
let (r, g, b) = (channel(lua, &r), channel(lua, &g), channel(lua, &b));
with_smf(lua, &this, |smf| {
smf.update_color_by_id(id, r, g, b);
})
@@ -168,13 +154,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
scroll(lua, &this, ScrollingMessageState::page_down)
})?,
)?;
// AtTop (`0x793130`) pushes 1 (`0x7931c3`) or nil (`0x7931d6`); AtBottom (`0x7931f0`) pushes 1
// (`0x793283`) or nil (`0x793296`).
m.set(
"AtTop",
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.at_top()))?,
lua.create_function(|lua, this: Table| {
with_smf(lua, &this, |smf| {
crate::script::binding_abi::flag(smf.at_top())
})
})?,
)?;
m.set(
"AtBottom",
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.at_bottom()))?,
lua.create_function(|lua, this: Table| {
with_smf(lua, &this, |smf| {
crate::script::binding_abi::flag(smf.at_bottom())
})
})?,
)?;
// SetMaxLines wipes the ring (`0x7938a0`).
@@ -195,8 +191,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"SetFading",
lua.create_function(|lua, (this, on): (Table, Value)| {
let on = !matches!(on, Value::Nil | Value::Boolean(false));
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
// `GetBoolOrDefault` (`0x6f1c10`, `0x7939a0`) with default 1: a bare call enables.
let on = crate::script::binding_abi::bool_or_default(args.front(), true);
with_smf(lua, &this, |smf| smf.fading_enabled = on)
})?,
)?;
@@ -242,7 +239,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
super::ensure_font_region(lua, h)
.ok_or_else(|| mlua::Error::runtime("not a ScrollingMessageFrame"))
},
"ScrollingMessageFrame",
crate::script::font_block::FontKind::Instance,
None,
)?;
+294 -157
View File
@@ -34,6 +34,8 @@ mod calls;
mod camera_view;
mod channel;
mod char_stats;
pub mod chat_bubble;
pub use chat_bubble::{BubbleGeometry, BubbleState};
mod chat_misc;
mod chat_send;
mod chat_types;
@@ -62,10 +64,24 @@ mod follow;
pub(crate) mod font;
mod font_block;
mod frame_enum;
mod glue;
mod glue_core;
mod glue_realm;
mod glue_select;
pub use glue::{VmKind, GLUE_EVENTS};
pub use glue_core::{
launch_url_allowed, GlueCredentials, GlueNotice, GlueRequest, GlueServer, NoticeState,
};
pub use glue_realm::{
realm_load_classify, realm_load_stats, GlueRealm, GlueRealmCategory, GlueRealmCells,
GlueRealmType, RealmSortKey,
};
pub use glue_select::{GlueCharacter, GlueScenePane, GlueSelectCells};
mod gm_ticket;
mod gossip;
mod guild;
mod handler_prof;
mod hyperlink;
mod screenshot;
mod tabard;
pub use handler_prof::HandlerRow;
@@ -79,6 +95,8 @@ mod item_stats;
mod item_text;
pub mod keybind;
mod keyboard;
pub(crate) use keyboard::enable_for_script;
pub use keyboard::{KeyUp, KeyWalk};
mod layout;
mod layout_cache;
mod loot;
@@ -90,10 +108,12 @@ mod measure;
mod merchant;
mod messageframe;
mod minimap;
mod movieframe;
pub mod nameplate;
pub use nameplate::{PlateBitWrites, PlateGeometry, PlateState, PLATE_BIT_ENEMY, PLATE_BIT_FRIEND};
mod model;
mod modelframe;
mod mouseover;
mod net_stats;
pub(crate) mod object;
pub use object::frame_kind_from_tag;
@@ -121,6 +141,8 @@ mod spellbook;
mod stable;
mod statusbar;
mod stdlib;
/// An mlua error's bare Lua message, the one value the error handler is called with.
pub use stdlib::lua_message;
mod summon;
mod talent;
mod taxi;
@@ -131,6 +153,7 @@ mod tooltip_spell;
mod tooltip_unit;
mod ui_errors;
mod ui_scale;
mod update_walk;
pub use tooltip_unit::TooltipTint;
mod trade;
mod tradeskill;
@@ -147,7 +170,7 @@ mod worn_display;
mod zone_text;
pub use action::{ActionSlot, ActionState, ActionUse};
pub use addon::AddOnInfo;
pub use addon::{addon_load_context, AddOnInfo, AddonLoad};
pub use addon_enable::EnableHash;
pub use addon_message::{AddonDistribution, AddonSend};
pub use auction::{
@@ -155,7 +178,7 @@ pub use auction::{
AuctionStartRequest, AuctionState, AuctionSubCategory, BIDDER, LIST, OWNER, SORT_KEYS,
};
pub use aura::{AuraState, TrackingState};
pub use backdrop::{inset_atlas_bleed, pieces, Backdrop, BackdropPiece, Insets};
pub use backdrop::{Backdrop, Insets};
pub use bank::BankState;
pub use battlefield_positions::{BattlefieldFlagView, BattlefieldPositionView};
pub use battlefield_queue::{BattlefieldListView, BattlefieldMapInfo, BattlefieldQueueSlot};
@@ -215,7 +238,7 @@ pub(crate) use button::{set_label_font_justify_h_lua, LabelFont};
pub use inspect::{InspectView, UnitReach};
pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState};
pub use item_text::ItemTextState;
pub use layout_cache::{FrameLayout, LayoutPoint};
pub use layout_cache::{AnchorLayout, FrameLayout, LayoutPoint};
pub use loot::{LootRow, LootState, LOOT_PORTRAIT_UNIT};
pub use loot_roll::{LootRollEntry, LootRollsState};
pub use macros::{MacroBinding, MacroState, MacroView, MAX_MACROS, MAX_MACRO_BODY, MAX_MACRO_NAME};
@@ -234,7 +257,7 @@ pub use quest::{
QuestAction, QuestItemView, QuestPanel, QuestRewardSpell, QuestSelect, QuestState,
};
pub use quest_log::{QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState};
pub(crate) use region::{apply_font_parts, implicit_creation_anchor_lua};
pub(crate) use region::{apply_font_parts, implicit_creation_anchor_lua, install_loaded_texture};
pub use reputation::{FactionEntry, ReputationSend, ReputationState};
pub use session::SessionRequest;
pub use shapeshift::ShapeshiftFormView;
@@ -337,9 +360,9 @@ pub(crate) const TEXTURE_ONLY_METHODS: [&str; 12] = [
"GetVertexColor",
];
/// FontString-only: the font, text, justify and shadow block and the string metrics.
/// FontString-only: the font, text, justify, shadow and spacing block and the string metrics.
/// `GetStringHeight`, `SetFormattedText` and `SetSize` are not 1.12 FontString methods.
pub(crate) const FONTSTRING_ONLY_METHODS: [&str; 21] = [
pub(crate) const FONTSTRING_ONLY_METHODS: [&str; 23] = [
"SetFont",
"GetFont",
"SetFontObject",
@@ -350,6 +373,8 @@ pub(crate) const FONTSTRING_ONLY_METHODS: [&str; 21] = [
"GetShadowColor",
"SetShadowOffset",
"GetShadowOffset",
"SetSpacing",
"GetSpacing",
"SetJustifyH",
"GetJustifyH",
"SetJustifyV",
@@ -401,12 +426,13 @@ pub const SCREEN: crate::layout::Handle = 0;
/// ScrollFrame's three scroll kinds are `+0x32c`/`+0x334`/`+0x33c` (script-name map `0x786c40`).
/// The EditBox's vtable (`0x81c910`) replaces the key and char slots: an EditBox never fires
/// `OnKeyDown`, and fires `OnChar` only from `Insert`, with the inserted text (`0x77c13c`).
const SCRIPT_KINDS: [&str; 41] = [
const SCRIPT_KINDS: [&str; 44] = [
"OnLoad",
"OnEvent",
"OnUpdate",
// The model pane's two, fired by the tick's model pass (`tick_model_panes`): `OnUpdateModel`
// at the top of a visible pane's paint, `OnAnimFinished` when the armed sequence completes.
// The model pane's two: `OnUpdateModel` from its paint in the draw pass
// (`paint_model_panes`), `OnAnimFinished` from its clock advance in the update walk
// (`advance_pane`) when the armed sequence completes.
"OnUpdateModel",
"OnAnimFinished",
"OnShow",
@@ -436,11 +462,10 @@ const SCRIPT_KINDS: [&str; 41] = [
"OnDragStart",
"OnDragStop",
"OnReceiveDrag",
// A release over a message-frame hyperlink span, `OnHyperlinkClick(link, text, button)`;
// `ChatFrameTemplate` passes it to `SetItemRef` (`ChatFrame.xml:15`, `ChatFrame.lua:1534`).
// A text frame's hyperlink button hands it these ([`hyperlink`]): the click as
// `OnHyperlinkClick(link, text, button)`, which `ChatFrameTemplate` passes to `SetItemRef`
// (`ChatFrame.xml:15`, `ChatFrame.lua:1534`), and the enter and leave with the link alone.
"OnHyperlinkClick",
// The per-span hover pair, fired by [`pointer`]'s move as the cursor crosses a link span of the
// hovered frame (`0x4e2e30`/`0x4e2ee0`, the chat frames' item and ability tooltips).
"OnHyperlinkEnter",
"OnHyperlinkLeave",
// The GameTooltip's engine-fired scripts: money render, money clear and world-hover default
@@ -455,9 +480,15 @@ const SCRIPT_KINDS: [&str; 41] = [
"OnDoubleClick",
// The layout event (base map `0x76a0d0`, `+0x120`), fired by the resolve pass on a size change.
"OnSizeChanged",
// The key channels, fired by [`keyboard`]'s walk (`0x765f10`). `OnKeyUp` never fires, as the
// host feeds no key-up, but a frame carrying only `OnKeyUp` still consumes every key-down and
// runs nothing, the reference's own asymmetry.
// The movie frame's three (script map `0x78c2e0`): fired by its player alone, which does not
// exist here ([`movieframe`]), so they are stored and never run.
"OnMovieFinished",
"OnMovieShowSubtitle",
"OnMovieHideSubtitle",
// The key channels: `OnChar` and `OnKeyDown` fired by [`keyboard`]'s walks (`0x765df0`,
// `0x765f10`), `OnKeyUp` by the key-up dispatcher (`0x765fd0`) on the frame that took the
// key's down. A frame carrying only `OnKeyUp` still consumes every key-down and runs nothing
// on it, the reference's own asymmetry.
"OnChar",
"OnKeyDown",
"OnKeyUp",
@@ -490,117 +521,172 @@ pub fn addon_chunk_name(folder: &str, file: &str) -> String {
format!("@Interface\\AddOns\\{folder}\\{}", file.replace('/', "\\"))
}
/// What both FrameScript states hold: FrameScript's Lua init (`0x7039e0`), the bare state the glue
/// builder starts from (left by startup `0x402aee` on its first build, re-run by its reset
/// `0x703b80` on a later one) and `UI_Init` rebuilds through its own, and the three registrars
/// the glue registrar `0x46abb0` shares with `UI_Init`: `0x51bb20` (the clock, `ConsoleExec` and
/// the file trio), `0x706350` (the frame factory globals and the 17 widget method tables) and
/// `0x4587f0` (sound).
fn install_framescript(vm: VmKind) -> mlua::Result<Lua> {
let lua = Lua::new();
let mut model = Model::new();
model.vm = vm;
lua.set_app_data(model);
// Before anything can fire a handler and while no app-data borrow is held, as the
// profiler's slot requires.
handler_prof::install(&lua);
// `0x7039e0`: the base array `0x811e28`, the four libraries and the compat chunk `0x8722e8`.
stdlib::sandbox(&lua)?;
// Before the stdlib layer, so its aliases bind the 5.0-shaped functions.
lua50::install(&lua)?;
stdlib::install(&lua)?;
// `0x51bb20` (table `0x8503f4`): `GetTime`, `GetGameTime` and `ConsoleExec`. `ReadFile`,
// `DeleteFile` and `AppendToFile` are not built.
clock::install(&lua)?;
chat_misc::install_console_exec(&lua)?;
// `0x706350`: `CreateFrame`, `CreateFont`, `GetNumFrames`, `EnumerateFrames` (table
// `0x872e74`), then the widget method tables `0x7a2e70`..`0x78dc60`.
object::install(&lua)?;
frame_enum::install(&lua)?;
// After `object`, whose `publish_global` it reuses, and before any FrameXML loads:
// `Loader::do_font` publishes into the tables this builds.
font::install(&lua)?;
messageframe::install(&lua)?;
scrollframe::install(&lua)?;
simplehtml::install(&lua)?;
slider::install(&lua)?;
colorselect::install(&lua)?;
movieframe::install(&lua)?;
modelframe::install(&lua)?;
// `0x4587f0` (table `0x835a50`): `PlaySound`, `PlayMusic`, `PlaySoundFile`, `StopMusic`.
sound::install(&lua)?;
Ok(lua)
}
/// The in-game surface: everything `UI_Init` registers past the shared registrars (`0x490250` and
/// the calls around it), the in-game widget types' method tables among it.
fn install_in_game(lua: &Lua) -> mlua::Result<()> {
addon::install(lua)?;
addon_message::install(lua)?;
chat_send::install(lua)?;
chat_types::install(lua)?;
chat_misc::install(lua)?;
channel::install(lua)?;
chat_window::install(lua)?;
client::install(lua)?;
screenshot::install(lua)?;
zone_text::install(lua)?;
object::install_in_game(lua)?;
region::install_in_game(lua)?;
modelframe::install_player_model(lua)?;
unit::install(lua)?;
party::install(lua)?;
social::install(lua)?;
guild::install(lua)?;
petition::install(lua)?;
binder::install(lua)?;
summon::install(lua)?;
bind_confirm::install(lua)?;
instance::install(lua)?;
gm_ticket::install(lua)?;
duel::install(lua)?;
follow::install(lua)?;
camera_view::install(lua)?;
input_verbs::install(lua)?;
session::install(lua)?;
pvp::install(lua)?;
worn_display::install(lua)?;
death::install(lua)?;
aura::install(lua)?;
cvars::install(lua)?;
nameplate::install_verbs(lua)?;
saved::install(lua)?;
keybind::install(lua)?;
ui_errors::install(lua)?;
pointer::install(lua)?;
action::install(lua)?;
action_bar_toggles::install(lua)?;
container::install(lua)?;
cursor::install(lua)?;
spellbook::install(lua)?;
macros::install(lua)?;
talent::install(lua)?;
dialog_verbs::install(lua)?;
battlefield_score::install(lua)?;
battlefield_queue::install(lua)?;
battlefield_positions::install(lua)?;
tutorial::install(lua)?;
worldmap_arrow::install(lua)?;
shapeshift::install(lua)?;
pet::install(lua)?;
gossip::install(lua)?;
merchant::install(lua)?;
bank::install(lua)?;
stable::install(lua)?;
item_text::install(lua)?;
mail::install(lua)?;
auction::install(lua)?;
trainer::install(lua)?;
taxi::install(lua)?;
trade::install(lua)?;
inspect::install(lua)?;
dressup::install(lua)?;
tabard::install(lua)?;
tradeskill::install(lua)?;
craft::install(lua)?;
reputation::install(lua)?;
skills::install(lua)?;
item_stats::install(lua)?;
char_stats::install(lua)?;
inventory_verbs::install(lua)?;
weapon_enchant::install(lua)?;
loot::install(lua)?;
loot_roll::install(lua)?;
quest::install(lua)?;
quest_log::install(lua)?;
minimap::install(lua)?;
tooltip::install(lua)?;
worldmap::install(lua)?;
worldstate::install(lua)?;
net_stats::install(lua)?;
Ok(())
}
impl UiScript {
/// Build a fully sandboxed, stdlib- and object-model-equipped host.
/// The in-game VM: FrameScript's base set and the registrars both states share
/// ([`install_framescript`]), then the in-game surface `UI_Init` (`0x48fbf0`) registers.
pub fn new() -> mlua::Result<UiScript> {
let lua = Lua::new();
lua.set_app_data(Model::new());
// Before anything can fire a handler and while no app-data borrow is held, as the
// profiler's slot requires.
handler_prof::install(&lua);
let lua = install_framescript(VmKind::InGame)?;
install_in_game(&lua)?;
Ok(Self::host(lua))
}
addon::install(&lua)?;
addon_message::install(&lua)?;
chat_send::install(&lua)?;
chat_types::install(&lua)?;
chat_misc::install(&lua)?;
channel::install(&lua)?;
chat_window::install(&lua)?;
client::install(&lua)?;
screenshot::install(&lua)?;
stdlib::sandbox(&lua)?;
// Before the stdlib layer, so its aliases bind the 5.0-shaped functions.
lua50::install(&lua)?;
stdlib::install(&lua)?;
object::install(&lua)?;
frame_enum::install(&lua)?;
// After `object`, whose `publish_global` it reuses, and before any FrameXML loads:
// `Loader::do_font` publishes into the tables this builds.
font::install(&lua)?;
unit::install(&lua)?;
party::install(&lua)?;
social::install(&lua)?;
guild::install(&lua)?;
petition::install(&lua)?;
binder::install(&lua)?;
summon::install(&lua)?;
bind_confirm::install(&lua)?;
instance::install(&lua)?;
gm_ticket::install(&lua)?;
duel::install(&lua)?;
follow::install(&lua)?;
camera_view::install(&lua)?;
input_verbs::install(&lua)?;
session::install(&lua)?;
pvp::install(&lua)?;
worn_display::install(&lua)?;
death::install(&lua)?;
aura::install(&lua)?;
cvars::install(&lua)?;
nameplate::install_verbs(&lua)?;
saved::install(&lua)?;
keybind::install(&lua)?;
sound::install(&lua)?;
ui_errors::install(&lua)?;
pointer::install(&lua)?;
action::install(&lua)?;
action_bar_toggles::install(&lua)?;
container::install(&lua)?;
cursor::install(&lua)?;
spellbook::install(&lua)?;
macros::install(&lua)?;
talent::install(&lua)?;
dialog_verbs::install(&lua)?;
battlefield_score::install(&lua)?;
battlefield_queue::install(&lua)?;
battlefield_positions::install(&lua)?;
tutorial::install(&lua)?;
worldmap_arrow::install(&lua)?;
shapeshift::install(&lua)?;
pet::install(&lua)?;
gossip::install(&lua)?;
merchant::install(&lua)?;
bank::install(&lua)?;
stable::install(&lua)?;
item_text::install(&lua)?;
mail::install(&lua)?;
auction::install(&lua)?;
trainer::install(&lua)?;
taxi::install(&lua)?;
trade::install(&lua)?;
inspect::install(&lua)?;
dressup::install(&lua)?;
tabard::install(&lua)?;
tradeskill::install(&lua)?;
craft::install(&lua)?;
reputation::install(&lua)?;
skills::install(&lua)?;
item_stats::install(&lua)?;
char_stats::install(&lua)?;
inventory_verbs::install(&lua)?;
weapon_enchant::install(&lua)?;
loot::install(&lua)?;
loot_roll::install(&lua)?;
quest::install(&lua)?;
quest_log::install(&lua)?;
messageframe::install(&lua)?;
scrollframe::install(&lua)?;
simplehtml::install(&lua)?;
slider::install(&lua)?;
colorselect::install(&lua)?;
minimap::install(&lua)?;
modelframe::install(&lua)?;
tooltip::install(&lua)?;
worldmap::install(&lua)?;
worldstate::install(&lua)?;
net_stats::install(&lua)?;
/// The glue screens' VM, as the glue builder (`0x46a7b0`) leaves its state before GlueXML
/// loads: the base set and the shared registrars, with the glue event table and the glue frame
/// types ([`glue`]), and none of the in-game surface. Of the glue registrar's own four tables
/// (`0x46ddf0`, `0x4736c0`, `0x471a20`, `0x46f480`) the screen verbs and the realm list are
/// built ([`glue::install`]).
pub fn new_glue() -> mlua::Result<UiScript> {
let lua = install_framescript(VmKind::Glue)?;
glue::install(&lua)?;
Ok(Self::host(lua))
}
let s = UiScript {
fn host(lua: Lua) -> UiScript {
UiScript {
lua,
instructions: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
session: NEXT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
};
Ok(s)
}
}
/// Which FrameScript state this VM is.
pub fn vm_kind(&self) -> VmKind {
self.model_ref().vm
}
/// The embedded VM, for the app and the loader to add their bindings over this object model.
@@ -838,7 +924,7 @@ impl UiScript {
) {
return false;
}
model.arena.set_shown(h, true);
model.set_shown_silent(h, true);
model.focused_editbox = Some(h);
true
}
@@ -855,17 +941,9 @@ impl UiScript {
}
/// Replace the hyperlink spans `(frame, y-up rect, link, markup)` the app rasterized this
/// frame; a release inside one fires `OnHyperlinkClick` on its frame ([`pointer`]).
/// frame: each is a hyperlink button over its span, a child of its frame ([`hyperlink`]).
pub fn set_link_spans(&mut self, spans: Vec<(FrameHandle, Rect, String, String)>) {
let mut model = self.model_mut();
model.link_spans.clear();
for (fh, rect, link, markup) in spans {
model
.link_spans
.entry(fh)
.or_default()
.push((rect, link, markup));
}
hyperlink::set_spans(&self.lua, spans);
}
/// Whether the frame with this global name is shown with every ancestor (`IsVisible()`).
@@ -886,7 +964,8 @@ impl UiScript {
.map(|(_, f)| f.effective_alpha)
}
/// Resolve and cache every frame's rect, which `GetWidth`/`GetHeight`/`extract` read, then fire
/// The layout drain (`0x768ed0`), which [`Self::tick`] runs before and after its walk: resolve
/// and cache every frame's rect, which `GetWidth`/`GetHeight`/`extract` read, then fire
/// `OnSizeChanged` for frames whose size moved ([`event::fire_size_changes`]) and re-seat the
/// text of every resized EditBox.
pub fn resolve(&mut self) {
@@ -936,8 +1015,10 @@ impl UiScript {
d.measured = Some(new);
}
if moved {
// Touched per region, so the layout gate need not re-walk the whole roster.
// Touched per region, so the layout gate need not re-walk the whole roster; stored
// whole, as the reference's `SetText` clears the resolved bit (`0x771ea4`).
model.touch_layout_region(rh);
model.queue_force(id);
}
}
}
@@ -995,6 +1076,33 @@ impl UiScript {
// ── Input: the pointer leaving the window ────────────────────────────────────────────────────
/// The OS pointer left the window: the hovered frame loses the hover, with its leave notify
/// ([`mouseover::leave`]) as a walk that found nothing would run it. It is no cursor event, so
/// it feeds no position: no probe runs and a move or size in flight holds its last sample
/// (`[root+0xd08]`/`[root+0xd0c]`), as the reference's pump only sees real cursor events
/// (`0x7655b0`). The saved cursor position stays too.
pub fn pointer_left_hover(&mut self) {
let leave = {
let mut model = self.model_mut();
// A frame destroyed since it took the hover gets no leave.
let old = model
.mouseover
.take()
.filter(|&h| model.arena.frame(h).is_some());
old.map(|h| {
let script = model
.frame_to_id
.get(&h)
.copied()
.filter(|_| button::hover_notify_runs(&model, Some(h)));
(h, script)
})
};
if let Some((h, script)) = leave {
mouseover::leave(&self.lua, h, script);
}
}
/// The OS pointer left the window, so no release will be fed: clears [`Model::drag`], which
/// would start a drag on re-entry, and [`Model::mouse_down_on`], which would click the frame
/// it left. The app calls it where it fires the synthetic `OnLeave`.
@@ -1009,7 +1117,8 @@ impl UiScript {
self.fire_drag_stop(source);
}
let mut model = self.model_mut();
let held: Vec<crate::widget::FrameHandle> = model.mouse_down_on.values().copied().collect();
let held: Vec<crate::widget::FrameHandle> =
model.mouse_down_on.values().copied().flatten().collect();
model.mouse_down_on.clear();
// Every held button gets the release edge never fed (`0x7793de`), else it stays pushed.
for h in held {
@@ -1025,16 +1134,17 @@ impl UiScript {
// ── Keyboard entry ───────────────────────────────────────────────────────────────────────────
//
// Keys arrive by name; the host maps its keycodes. The box routing: a focused box consumes
// every event; with none focused, the topmost visible `autoFocus` box takes focus and
// Keys arrive by name; the host maps its keycodes. Every EditBox is a frame in the key and
// char walks ([`keyboard`]): the focused box consumes every event at its own strata and level;
// with none focused, the first visible `autoFocus` box in walk order takes focus and
// processes that same event; otherwise nothing is consumed.
/// A typed character (UTF-8) from the host: a focused box inserts it (numeric, cap and
/// password rules apply) or, for Ctrl+A's control code, selects all. `true` if consumed.
pub fn char_input(&mut self, text: &str) -> bool {
// The frame walk first ([`keyboard`]), the focused box a participant at its own strata and
// level, as in the reference's one dispatcher; an unconsumed event goes to the box routing.
keyboard::char_input(&self.lua, text) || editbox::char_input(&self.lua, text)
// The one dispatcher (`0x765df0`): the boxes are frames in the walk, at their own strata
// and level.
keyboard::char_input(&self.lua, text)
}
/// Paste host clipboard text into the focused EditBox as one edit: newlines survive only in a
@@ -1043,18 +1153,45 @@ impl UiScript {
editbox::paste(&self.lua, text)
}
/// A non-character key by name (`"ENTER"`, `"ESCAPE"`, `"TAB"`); editing keys come through
/// [`Self::editbox_action`]. A focused box consumes a key even when it does nothing with it.
pub fn key_input(&mut self, key: &str) -> bool {
// The same two stages as `char_input`.
keyboard::key_input(&self.lua, key) || editbox::key_input(&self.lua, key)
/// A key pressed, by name (`"ENTER"`, `"A"`, `"SHIFT"`, `"UNKNOWN"`): the key-down walk
/// (`0x765f10`), which an EditBox joins at its own place. `Box` means the walk reached a box
/// that took the key, and the host then runs the key's editing action on it
/// ([`Self::editbox_action`], [`Self::paste`]); `Frame`, a frame that took it, and the host
/// runs neither the action nor the key's binding (`0x76b7d0`).
pub fn key_down(&mut self, key: &str) -> KeyWalk {
keyboard::key_down(&self.lua, key)
}
/// An editing key (BACKSPACE, DELETE, the arrows, HOME, END) offered to the keyboard frames
/// before the focused box gets it as an [`EditAction`]. On `true` the caller dispatches neither
/// the action nor the key's binding (consumption suppresses it, `0x76b7d0`).
/// A key held down and repeating, by name: the auto-repeat dispatcher (`0x766070`) hands it to
/// the frame that took the press, a stub on a plain frame and the key-down again on a box.
pub fn key_repeat(&mut self, key: &str) -> KeyWalk {
keyboard::key_repeat(&self.lua, key)
}
/// A key released, by the name its press went by: the key-up dispatcher (`0x765fd0`) runs
/// `OnKeyUp` on the frame that consumed that key's last down.
pub fn key_up(&mut self, key: &str) -> KeyUp {
keyboard::key_up(&self.lua, key)
}
/// A binding took this key's press: the world frame is its consumer (`0x483b70`).
pub fn note_world_key(&mut self, key: &str) {
keyboard::note_world_key(&self.lua, key);
}
/// Whether ALT is held, which an alt-arrow EditBox reads for the arrows (`0x77b1b3`).
pub fn set_alt_down(&mut self, alt: bool) {
self.model_mut().alt_down = alt;
}
/// Whether the key-down walk takes `key`, a frame or a box.
pub fn key_input(&mut self, key: &str) -> bool {
self.key_down(key) != KeyWalk::Declined
}
/// Whether a frame, not a box, takes `key`.
pub fn frame_key_input(&mut self, key: &str) -> bool {
keyboard::frame_key_input(&self.lua, key)
self.key_down(key) == KeyWalk::Frame
}
/// One text-editing operation on the focused EditBox, from the host's per-OS keymap.
@@ -1124,9 +1261,7 @@ impl UiScript {
/// Whether `name` is a registered FrameXML template, case-folded as the loader resolves it, for
/// the corpus harness: an addon naming a missing template gets a bare frame and no load error.
pub fn has_framexml_template(&self, name: &str) -> bool {
let model = self.model_ref();
let templates = model.framexml_templates.borrow();
templates.contains_key(name) || templates.keys().any(|k| k.eq_ignore_ascii_case(name))
self.model_ref().framexml_templates.borrow().contains(name)
}
/// Whether `name` is a registered font object, the other namespace an `inherits=` may name
@@ -1166,7 +1301,7 @@ impl UiScript {
});
}
// Region leaves publish into their own name table, not the arena's.
let id = *model.region_names.get(name)?;
let id = *model.region_by_name.get(name)?;
let h = *model.id_to_region.get(&id)?;
model.arena.region(h).map(|r| match r.kind {
crate::widget::RegionKind::Texture => "Texture",
@@ -1198,14 +1333,16 @@ impl UiScript {
/// Report a script error the host caught outside the VM's dispatch (an addon file that failed
/// to compile or raised at file scope): it joins the handler-dispatch queue and the retained
/// log, not `errors`, since the caller has already logged it.
pub fn report_script_error(&self, msg: &str) {
/// log, not `errors`, since the caller has already logged it. `log` is the decorated text the
/// log keeps; `handler` is the one value the error handler is called with, the bare Lua
/// message as `lua_pcall` leaves it (`0x703b40`).
pub fn report_script_error(&self, log: &str, handler: &str) {
// A Load row, not an Error one: to the player a file scope that raised and a missing file
// are the same fact, the addon is not running.
diagnostics::record_load_failure(&self.lua, msg);
diagnostics::record_load_failure(&self.lua, log);
self.model_mut()
.pending_error_dispatch
.push(msg.to_string());
.push(handler.to_string());
}
/// Hand each queued script error to the Lua error handler, as the reference's catch closure
@@ -1262,7 +1399,7 @@ impl UiScript {
}
fn push_error(&self, e: mlua::Error) {
self.model_mut().record_script_error(e.to_string());
self.model_mut().record_lua_error(&e);
}
}
File diff suppressed because it is too large Load Diff
+69 -21
View File
@@ -35,6 +35,56 @@ impl Model {
768.0 * (a * a + 1.0).sqrt()
}
/// `ApplyRect`'s size threshold, 2⁻²² in the reference's layout units (`0x76b5e7`,
/// `0x76b602`, `[0x8029d4]`), in ours: [`crate::layout::SIZE_EPS`] times the layout unit.
pub(crate) fn size_eps(&self) -> f64 {
crate::layout::SIZE_EPS * f64::from(self.layout_unit())
}
/// The aspect globals `SetPoint`'s unchanged-anchor test quantizes offsets by
/// ([`crate::layout::Anchors::set`]), for the screen aspect [`Self::layout_unit`] reads.
pub(crate) fn layout_units(&self) -> crate::layout::LayoutUnits {
let (w, h) = (self.screen.width(), self.screen.height());
let a = if h > 0.0 && w > 0.0 { w / h } else { 4.0 / 3.0 };
crate::layout::LayoutUnits::from_aspect(a)
}
/// The drain's keep threshold, 1e-5 in the reference's layout units (`[0x80c5c8]`, tested in
/// `0x768d20`), in ours: [`crate::layout::RESOLVE_EPS`] times the layout unit.
pub(crate) fn resolve_eps(&self) -> f64 {
crate::layout::RESOLVE_EPS * f64::from(self.layout_unit())
}
/// `SetModel` on the pane `h` (`0x76cce0`): the file, seeded now if its facts are held (else
/// asked for), and the implicit rect it may give a size-less pane. A frame that is no model
/// pane is left as it is.
pub(crate) fn set_pane_model(&mut self, h: FrameHandle, path: String) {
// The reference's asset-resident test (`0x71d5a3`): the loader's Stand seed runs now, or
// when the file lands.
let facts = self.model_facts_for(&path);
let instance = self.model_instances + 1;
let Some(KindState::Model(m)) = self.arena.frame_mut(h).map(|f| &mut f.kind_state) else {
return;
};
m.set_file(path, facts.as_deref());
m.instance = instance;
self.model_instances = instance;
// A size-less pane takes the file's rect as soon as the file is known.
self.apply_implicit_rect(h);
}
/// `SetCamera(idx)` on the pane `h` (`0x76cec0`), against the facts of the file it holds.
pub(crate) fn set_pane_camera(&mut self, h: FrameHandle, idx: i32) {
let path = match self.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Model(m)) => m.path.clone(),
_ => return,
};
let facts = path.and_then(|p| self.model_facts_for(&p));
if let Some(KindState::Model(m)) = self.arena.frame_mut(h).map(|f| &mut f.kind_state) {
m.install_camera(idx, facts.as_deref());
}
}
/// The implicit rect: a pane with no authored size takes its file's bounding-box extent, in
/// layout units, as the reference's geometry overrides answer it (`0x76d080`, `0x76d0d0`).
pub(crate) fn apply_implicit_rect(&mut self, h: FrameHandle) {
@@ -66,9 +116,8 @@ impl Model {
input.width.to_bits() != w.to_bits() || input.height.to_bits() != ht.to_bits();
input.width = w;
input.height = ht;
if changed {
self.touch_layout_frame(h);
}
// The model-ready size write invalidates the frame (`0x76ce70`).
self.invalidate_frame(h, changed);
if let Some(KindState::Model(m)) = self.arena.frame_mut(h).map(|f| &mut f.kind_state) {
m.implicit_size = true;
}
@@ -282,18 +331,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let path = string_arg(&path)
.ok_or_else(|| usage(lua, &this, "SetModel(\"file\")"))?
.to_string();
// The reference's asset-resident test (`0x71d5a3`): the loader's Stand seed runs now,
// or when the file lands.
let facts = lua
.app_data_mut::<Model>()
.expect("model app_data")
.model_facts_for(&path);
with_model(lua, &this, |m| m.set_file(path, facts.as_deref()))?;
// A size-less pane takes the file's rect as soon as the file is known.
// A pane of this kind, or the error.
with_model(lua, &this, |_| ())?;
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model app_data")
.apply_implicit_rect(h);
.set_pane_model(h, path);
Ok(())
})?,
)?;
@@ -361,8 +404,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"SetCamera",
lua.create_function(|lua, (this, c): (Table, Value)| {
let c = int(&c);
let facts = facts_of_pane(lua, &this)?;
with_model(lua, &this, |m| m.install_camera(c, facts.as_deref()))
with_model(lua, &this, |_| ())?;
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model app_data")
.set_pane_camera(h, c);
Ok(())
})?,
)?;
m.set(
@@ -507,12 +554,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
lua.set_named_registry_value(REG_MODEL_METHODS, m)?;
playermodel_install(lua)
lua.set_named_registry_value(REG_MODEL_METHODS, m)
}
/// `PlayerModel`'s own three methods (table `0x84f1fc`); the rest are `Model`'s, reached on a miss.
fn playermodel_install(lua: &Lua) -> mlua::Result<()> {
/// Only the in-game state registers it (`0x506230`, from `0x490260`), where `Model`'s is in the
/// widget set both states install (`0x76f840`, from `0x706350`).
pub(super) fn install_player_model(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// `SetUnit(unit)` (`0x84f1fc[0]` → `0x505d70`) clears the `SetModel` path; the unit token is
@@ -612,7 +660,7 @@ impl crate::script::UiScript {
let mut model = self.model_mut();
let panes: Vec<FrameHandle> = model
.arena
.ticked_kinds()
.model_kinds()
.iter()
.copied()
.filter(|&h| {
@@ -643,7 +691,7 @@ impl crate::script::UiScript {
let model = self.model_ref();
model
.arena
.ticked_kinds()
.model_kinds()
.iter()
.filter_map(|&h| {
let f = model.arena.frame(h)?;
@@ -655,8 +703,8 @@ impl crate::script::UiScript {
};
let path = m.path.as_deref()?;
let facts = model.model_facts.get(&model_key(path))?;
// A pane whose camera is still pending paints nothing, not even its
// `OnUpdateModel` (`0x76d5f0`).
// A pane whose camera is still pending draws nothing (`0x76d5f0`); its
// `OnUpdateModel` still fires, from the paint hook ahead of that gate (`0x76d5ea`).
if m.camera_pending.is_some() {
return None;
}
+161
View File
@@ -0,0 +1,161 @@
//! The world mouseover's publisher (`0x492890`) and the engine half of a hover leave. The world's
//! pick publishes through the change check (`0x482090`), and a leave publishes inside the hover
//! walk, which runs the old frame's leave before the new frame's enter (`0x766229`, `0x766239`):
//! the WorldFrame's (`0x483b10`) and a nameplate's (`0x7cb8b0`) publish the empty mouseover, and
//! the Minimap's (`0x4eb880`) hides `GameTooltip`. So the tooltip a window's `OnEnter` takes is
//! never torn down by the hover it replaced.
//!
//! The WorldFrame's enter is the base one (`0x76b6a0`) and publishes nothing: the world gains the
//! mouseover from its own update (`0x481790`), which picks only while the WorldFrame holds the
//! hover (`0x4817ae`), the app's world drive.
use mlua::Lua;
use crate::widget::{FrameHandle, FrameKind};
use super::{event, Model, UiScript};
/// What a frame's leave notify (`vtbl+0x50`) does beyond its `OnLeave`.
enum LeaveClass {
/// `0x483b10`: the base leave, then the empty mouseover.
World,
/// `0x7cb8b0`: the empty mouseover, then the Button's leave.
Nameplate,
/// `0x4eb880`: `GameTooltip` hidden, then the base leave.
Minimap,
Other,
}
fn leave_class(model: &Model, h: FrameHandle) -> LeaveClass {
match model.arena.frame(h).map(|f| f.kind) {
Some(FrameKind::WorldFrame) => LeaveClass::World,
Some(FrameKind::Minimap) => LeaveClass::Minimap,
Some(_) if model.nameplates.is_plate(h) => LeaveClass::Nameplate,
_ => LeaveClass::Other,
}
}
/// A frame's leave notify, by its class, around its `OnLeave`; `script` is the id whose `OnLeave`
/// fires, `None` where the notify skips it (a disabled Button's `0x7794e0`). The walk's motion
/// leave and the removal tail's leave (`0x764cce`) both come here. The notify's own argument, 0 for
/// a motion leave and 1 for a removal, only clears the drag arm (`0x76b725`); `OnLeave` runs
/// through the plain runner (`0x76b753` → `0x702690`), which sets no arg global.
pub(super) fn leave(lua: &Lua, h: FrameHandle, script: Option<u32>) {
// A hyperlink button's leave is its text frame's `OnHyperlinkLeave` (`0x7a34e0`), in place of
// the Button's.
if super::hyperlink::hover(lua, h, false) {
return;
}
let class = leave_class(&model(lua), h);
match class {
// `0x7cb8bf`: `0x492890` with an empty mouseover and an empty "previous", first.
LeaveClass::Nameplate => {
publish(lua, None);
}
// `0x4eb88e`–`0x4eb898`: the shown bit, then the hide slot, whoever holds it.
LeaveClass::Minimap => hide_tooltip(lua),
LeaveClass::World | LeaveClass::Other => {}
}
if let Some(id) = script {
if let Err(e) = event::fire_widget_handler(lua, id, "OnLeave", Vec::new()) {
model(lua).record_lua_error(&e);
}
}
// `0x483b1d` runs the base leave first; then, only while the WorldFrame's own last publish is
// not empty (`0x483b30`), the empty one (`0x483b46`).
if let LeaveClass::World = class {
publish(lua, None);
}
}
/// The publisher behind the change check: an unchanged mouseover does nothing (`0x4820b5`). A
/// change away from a published one requests the fade on `GameTooltip` (`0x4928d5`, `0x492909` →
/// `0x530ae0`), whoever holds it, after `"mouseover"` stops naming the old unit (the pair is zeroed
/// at `0x4928e8` first). Returns whether it changed.
pub(super) fn publish(lua: &Lua, guid: Option<u64>) -> bool {
let (prev, tooltip) = {
let mut model = model(lua);
if model.world_mouseover == guid {
return false;
}
let prev = std::mem::replace(&mut model.world_mouseover, guid);
// The token resolver reads the pair (`0x515970`), so `"mouseover"` names a unit only while
// it is the published guid; the app pushes the new unit after the publish.
if model
.pushed_unit("mouseover")
.is_some_and(|u| Some(u.guid) != guid)
{
model.units_by_lower.remove("mouseover");
}
if model.unit_guids.mouseover != 0 && Some(model.unit_guids.mouseover) != guid {
model.unit_guids.mouseover = 0;
}
(prev, model.arena.lookup("GameTooltip"))
};
if let (Some(_), Some(h)) = (prev, tooltip) {
super::tooltip::fade_out(lua, h);
}
true
}
/// The Minimap's direct hide of `GameTooltip`: the shown bit, then the hide slot, with no fade
/// (`0x4eb15e`–`0x4eb166`, `0x4eb55f`–`0x4eb567`, `0x4eb88e`–`0x4eb898`).
fn hide_tooltip(lua: &Lua) {
let tooltip = model(lua).arena.lookup("GameTooltip");
if let Some(h) = tooltip {
super::visibility::set_shown(lua, h, false);
}
}
/// The Minimap's probe (`0x4eb0c0`, its `vtbl+0x3c`) found the cursor outside it: a blip still
/// held is dropped and `GameTooltip` hidden (`0x4eb0ee`, `0x4eb12b`–`0x4eb166`). The walk probes
/// the Minimap while it holds the capture (`0x7661ca`), and in the sweep when no frame ahead of it
/// took the hit.
pub(super) fn minimap_probe_miss(lua: &Lua) {
if model(lua).minimap_blip.take().is_some() {
hide_tooltip(lua);
}
}
fn model(lua: &Lua) -> mlua::AppDataRefMut<'_, Model> {
lua.app_data_mut::<Model>().expect("model app_data")
}
impl UiScript {
/// The world mouseover's guid as last published (`[0xb4e2c8]`), a unit, a GameObject or a
/// corpse; `None` once the pick or a hover leave published the empty one.
pub fn world_mouseover(&self) -> Option<u64> {
self.model_ref().world_mouseover
}
/// Publish the world pick's mouseover ([`publish`]): the caller then pushes a unit's
/// `"mouseover"` and builds its plate. Returns whether it changed.
pub fn publish_world_mouseover(&mut self, guid: Option<u64>) -> bool {
publish(&self.lua, guid)
}
/// Whether the walk probes the Minimap with the cursor on it: it holds the hover, which a hit
/// in the sweep makes it, or the capture, whose probe runs before the sweep (`0x7661ca`). Its
/// probe shows and drops the blip tooltip.
pub fn minimap_probed(&self) -> bool {
let model = self.model_ref();
[model.mouseover, model.mouse_capture]
.into_iter()
.flatten()
.filter_map(|h| model.arena.frame(h))
.any(|f| f.kind == FrameKind::Minimap)
}
/// The blip whose tooltip [`Self::minimap_tooltip`] put up, while the probe holds it.
pub fn minimap_blip(&self) -> Option<String> {
self.model_ref().minimap_blip.clone()
}
/// The probe found no blip under the cursor inside the Minimap: it drops the held one and hides
/// `GameTooltip`, whoever holds it (`0x4eb54c`–`0x4eb567`).
pub fn minimap_blip_lost(&mut self) {
if self.model_mut().minimap_blip.take().is_some() {
hide_tooltip(&self.lua);
}
}
}
+126
View File
@@ -0,0 +1,126 @@
//! The `MovieFrame` methods (`CSimpleMovieFrame`, factory `0x6ef000`; method table `0x87ab4c`):
//! `StartMovie` (`0x78d770`), `StopMovie` (`0x78d870`) and `EnableSubtitles` (`0x78d940`).
//!
//! Deviation: benilla plays no pre-rendered movie (#968), so `StartMovie` answers nil, the
//! reference's "could not start" (`0x78d861`), which the stock `MovieFrame_PlayMovie` answers by
//! hiding the frame and so by returning to the login screen. With no movie ever running,
//! `StopMovie` has nothing to stop: `0x78ced0` returns at once when the playing slot `+0x314` is
//! clear, and only past that test fires `OnMovieFinished` (`0x78cf0b`-`0x78cf1e`). The three
//! `OnMovie*` scripts (script map `0x78c2e0`, slots `+0x320`/`+0x328`/`+0x330`) are accepted by
//! `SetScript` and never run.
use mlua::{Lua, MultiValue, Table, Value};
use super::binding_abi::bool_or_default;
use super::font_block::usage_name;
use super::object::frame_handle_of;
use super::Model;
use crate::widget::FrameKind;
/// Registry key of the MovieFrame method table (the MAXCSTACK discipline: a Lua-side root).
pub(super) const REG_MOVIEFRAME_METHODS: &str = "__benilla_movieframe_methods";
/// Raises unless `this` is a live MovieFrame: the method table is a plain Lua value a caller can
/// lift onto another frame (`0x847e98`, "Wrong object type for member function").
fn check_movie_frame(lua: &Lua, this: &Table) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
match model.arena.frame(h) {
Some(f) if f.kind == FrameKind::MovieFrame => Ok(()),
_ => Err(mlua::Error::runtime(
"Wrong object type for member function",
)),
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// `lua_isstring` on the path (`0x6f3510`: a string or a number), else
// `Usage: %s:StartMovie("filename")` (`0x87ab8c`); one value back (`0x78d84d`/`0x78d861`),
// 1 for a movie that started and nil for one that did not.
m.set(
"StartMovie",
lua.create_function(|lua, (this, path): (Table, Value)| {
check_movie_frame(lua, &this)?;
if !matches!(
path,
Value::String(_) | Value::Integer(_) | Value::Number(_)
) {
return Err(mlua::Error::runtime(format!(
"Usage: {}:StartMovie(\"filename\")",
usage_name(&this)
)));
}
Ok(Value::Nil)
})?,
)?;
// No arguments, no returns (`0x78d8b8`).
m.set(
"StopMovie",
lua.create_function(|lua, this: Table| check_movie_frame(lua, &this))?,
)?;
// The flag defaults to true when absent (`0x6f1c10` with the default `1`, as
// `EnableKeyboard`'s `0x776f4e`), stored at `+0x31c` for the player's subtitle events, which
// nothing here fires; no returns.
m.set(
"EnableSubtitles",
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
check_movie_frame(lua, &this)?;
let _enabled = bool_or_default(args.front(), true);
Ok(())
})?,
)?;
lua.set_named_registry_value(REG_MOVIEFRAME_METHODS, m)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// `StartMovie` answers nil (no player), takes a string or a number, and raises its usage on
/// anything else; `StopMovie` and `EnableSubtitles` answer nothing; and the three player
/// scripts are taken by `SetScript` and read back.
#[test]
fn the_movie_frame_takes_its_methods_and_scripts_and_plays_nothing() {
let s = UiScript::new_glue().unwrap();
s.run(r#"M = CreateFrame("MovieFrame", "MovieProbe")"#)
.unwrap();
assert!(s
.eval::<bool>(r#"return M:StartMovie("a\\b") == nil"#)
.unwrap());
assert!(s.eval::<bool>("return M:StartMovie(7) == nil").unwrap());
assert!(s
.eval::<bool>("return table.getn({M:StopMovie()}) == 0")
.unwrap());
assert!(s
.eval::<bool>("return table.getn({M:EnableSubtitles(1)}) + table.getn({M:EnableSubtitles()}) == 0")
.unwrap());
for bad in ["M:StartMovie()", "M:StartMovie({})", "M:StartMovie(true)"] {
let err = s.run(bad).unwrap_err().to_string();
assert!(
err.contains(r#"Usage: MovieProbe:StartMovie("filename")"#),
"{bad}: {err}"
);
}
for name in [
"OnMovieFinished",
"OnMovieShowSubtitle",
"OnMovieHideSubtitle",
] {
s.run(&format!("M:SetScript('{name}', function() end)"))
.unwrap();
assert!(
s.eval::<bool>(&format!("return M:GetScript('{name}') ~= nil"))
.unwrap(),
"{name}"
);
}
// A plain frame has none of the methods.
s.run(r#"P = CreateFrame("Frame", "NotAMovie")"#).unwrap();
assert!(s.eval::<bool>("return P.StartMovie == nil").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
}
+56 -48
View File
@@ -10,7 +10,7 @@
use mlua::Lua;
use crate::layout::{Anchor, Point};
use crate::layout::{Anchor, Anchors, Point};
use crate::order::DrawLayer;
use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle, RegionKind};
@@ -138,6 +138,11 @@ pub(crate) struct NamePlates {
}
impl NamePlates {
/// Whether `frame` is a plate, whose leave publishes the empty mouseover (`0x7cb8b0`).
pub(crate) fn is_plate(&self, frame: FrameHandle) -> bool {
self.by_frame.contains_key(&frame)
}
/// Whether the `0x7cba30` veto refuses `frame`: only a plate, only while the veto stands.
pub(crate) fn vetoes(&self, frame: FrameHandle) -> bool {
self.hit_test_vetoed && self.by_frame.contains_key(&frame)
@@ -272,13 +277,17 @@ impl UiScript {
}
/// The freelook toggle (`0x60f830`): entering camera freelook disables mouse input on every
/// plate and leaving re-enables it (called from `0x483e80` and `0x483e70`).
/// plate and leaving re-enables it (called from `0x483e80` and `0x483e70`). The disable is
/// `EnableMouse(false)`'s (`0x76af50(2)`), whose removal tail (`0x764ba0`, flag 0, `0x76af7f`)
/// ends a hovered plate's hover through its leave (`0x7cb8b0`).
pub fn set_nameplate_mouse(&mut self, enabled: bool) {
let lua = self.lua();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let frames: Vec<FrameHandle> = model.nameplates.plates.iter().map(|p| p.frame).collect();
let frames: Vec<FrameHandle> = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.nameplates.plates.iter().map(|p| p.frame).collect()
};
for frame in frames {
model.arena.set_mouse_enabled(frame, enabled);
super::visibility::set_mouse_enabled(lua, frame, enabled);
}
}
@@ -483,12 +492,12 @@ impl Plate {
}
for rh in [glow, skull, raid] {
model.region_data.entry(rh).or_default().hidden = true;
model.set_region_hidden(rh, true);
}
// Born retired and hidden: addons tell a live plate from a pooled one by `IsShown()`.
// Silent, since nothing can have set a handler on a frame not yet handed out.
model.arena.set_shown(frame, false);
model.set_shown_silent(frame, false);
model
.nameplates
@@ -525,32 +534,32 @@ impl Plate {
input.height = g.height;
for rh in [border, glow] {
model.region_data.entry(rh).or_default().anchors = vec![
model.region_data.entry(rh).or_default().anchors = Anchors::from([
Anchor::new(Point::TopLeft, plate_id, Point::TopLeft, 0.0, 0.0),
Anchor::new(Point::BottomRight, plate_id, Point::BottomRight, 0.0, 0.0),
];
]);
}
let bar_input = model.layout_inputs.entry(bar).or_default();
bar_input.anchors = vec![Anchor::new(
bar_input.anchors = Anchors::from([Anchor::new(
Point::BottomLeft,
plate_id,
Point::BottomLeft,
g.bar_off_x,
g.bar_off_y,
)];
)]);
bar_input.width = g.bar_width;
bar_input.height = g.bar_height;
// The name's (`0x7cb456`) and level's (`0x7cb7f5`) anchors, which `_Nameplates` checks.
let name_data = model.region_data.entry(name).or_default();
name_data.anchors = vec![Anchor::new(
name_data.anchors = Anchors::from([Anchor::new(
Point::Bottom,
plate_id,
Point::Center,
0.0,
0.0,
)];
)]);
name_data.font_height = Some(g.name_height);
for rh in [name, level] {
@@ -561,33 +570,39 @@ impl Plate {
}
let level_data = model.region_data.entry(level).or_default();
level_data.anchors = vec![Anchor::new(
level_data.anchors = Anchors::from([Anchor::new(
Point::Center,
plate_id,
Point::BottomRight,
-g.level_off_x,
g.level_off_y,
)];
)]);
level_data.font_height = Some(g.level_height);
level_data.font_explicit.height = true;
model.touch_measure(level);
let skull_data = model.region_data.entry(skull).or_default();
skull_data.anchors = vec![Anchor::new(
skull_data.anchors = Anchors::from([Anchor::new(
Point::Center,
plate_id,
Point::BottomRight,
-g.level_off_x,
g.level_off_y,
)];
)]);
skull_data.size = Some((g.skull_size, g.skull_size));
let raid_data = model.region_data.entry(raid).or_default();
raid_data.anchors = vec![Anchor::new(Point::Right, plate_id, Point::Left, 0.0, 0.0)];
raid_data.anchors =
Anchors::from([Anchor::new(Point::Right, plate_id, Point::Left, 0.0, 0.0)]);
raid_data.size = Some((g.raid_size, g.raid_size));
// The first lay-out adds anchor targets, so the full touch; once per geometry change.
model.touch_layout();
// Once per geometry change: each node it wrote is invalidated, as the plate setup's
// `SetPoint`/`SetAllPoints` with the invalidate flag do (`0x7cb456`..`0x7cb806`).
model.invalidate_frame(frame, true);
model.invalidate_frame(bar, true);
for rh in [border, glow, name, level, skull, raid] {
model.invalidate_region(rh, true);
}
model.nameplates.plates[i].laid_out = Some(g);
}
@@ -606,29 +621,21 @@ impl Plate {
let last = p.last.clone();
let world_id = model.frame_id(world);
// Re-seated whenever it moves, as the reference does (`0x509ec0`). A moved offset keeps
// the graph's edges, so the precise touch; the first seat adds an edge, so the retarget.
// Re-seated whenever it moves, as the reference does (`0x509ec0`).
if last
.as_ref()
.is_none_or(|l| l.top_centre != state.top_centre)
{
let fresh = model
.layout_inputs
.get(&frame)
.is_none_or(|i| i.anchors.is_empty());
let input = model.layout_inputs.entry(frame).or_default();
input.anchors = vec![Anchor::new(
input.anchors = Anchors::from([Anchor::new(
Point::Top,
world_id,
Point::BottomLeft,
state.top_centre.0,
state.top_centre.1,
)];
if fresh {
model.touch_layout_retarget_frame(frame, &[], &[world_id]);
} else {
model.touch_layout_frame(frame);
}
)]);
// `SetPoint` with the invalidate flag (`0x50a044`).
model.invalidate_frame(frame, true);
}
if last.is_none() {
@@ -665,7 +672,8 @@ impl Plate {
if last.as_ref().is_none_or(|l| l.name != state.name) {
model.region_data.entry(name).or_default().text = Some(state.name.clone());
model.touch_measure(name);
model.touch_layout_region(name);
// A new string clears the valid bit and invalidates (`0x771ea4`).
model.clear_region_valid(name);
}
if last.as_ref().is_none_or(|l| l.hovered != state.hovered) {
let c = if state.hovered {
@@ -675,7 +683,7 @@ impl Plate {
};
model.region_data.entry(name).or_default().vertex_color = Some(c);
// The glow really shows, as addons read mouseover from it; it only paints nothing.
model.region_data.entry(glow).or_default().hidden = !state.hovered;
model.set_region_hidden(glow, !state.hovered);
}
if last.as_ref().is_none_or(|l| {
@@ -684,23 +692,23 @@ impl Plate {
match (state.level, state.skull) {
// The skull flag wins the seat, whatever the level.
(_, true) => {
model.region_data.entry(level).or_default().hidden = true;
model.region_data.entry(skull).or_default().hidden = false;
model.set_region_hidden(level, true);
model.set_region_hidden(skull, false);
}
(Some(n), false) => {
let c = state.level_colour;
let data = model.region_data.entry(level).or_default();
data.text = Some(n.to_string());
data.vertex_color = Some([c[0], c[1], c[2], 1.0]);
data.hidden = false;
model.region_data.entry(skull).or_default().hidden = true;
model.set_region_hidden(level, false);
model.set_region_hidden(skull, true);
model.touch_measure(level);
model.touch_layout_region(level);
model.clear_region_valid(level);
}
// No level yet and no skull: the seat stays empty.
(None, false) => {
model.region_data.entry(level).or_default().hidden = true;
model.region_data.entry(skull).or_default().hidden = true;
model.set_region_hidden(level, true);
model.set_region_hidden(skull, true);
}
}
}
@@ -711,9 +719,9 @@ impl Plate {
let (u0, v0) = (f32::from(idx & 3) * 0.25, f32::from(idx >> 2) * 0.25);
let data = model.region_data.entry(raid).or_default();
data.tex_coords = Some(TexCoords::Rect([u0, u0 + 0.25, v0, v0 + 0.25]));
data.hidden = false;
model.set_region_hidden(raid, false);
}
None => model.region_data.entry(raid).or_default().hidden = true,
None => model.set_region_hidden(raid, true),
}
}
@@ -765,11 +773,11 @@ fn font_string(model: &mut Model, frame: FrameHandle, justify_v: JustifyV) -> Re
rh
}
/// Whether a text's owner is a plate. A plate's rect snaps to device pixels as it slides, and its
/// text must follow that rect, not the UI's coarser grid, or it jumps. Asked of the frame so an
/// addon's strings on the plate get it too; a string on the plate's health bar does not.
/// Whether a text's owner is a plate or a chat bubble. Their rects snap to device pixels as they
/// slide, and their text must follow that rect, not the UI's coarser grid, or it jumps. Asked of
/// the frame so an addon's strings on one get it too; a string on the plate's health bar does not.
pub(super) fn is_world_seated(model: &Model, frame: FrameHandle) -> bool {
model.nameplates.by_frame.contains_key(&frame)
model.nameplates.by_frame.contains_key(&frame) || super::chat_bubble::is_bubble(model, frame)
}
/// Whether this is the plate's own glow art on its ADD mode, which the quad walk
+118 -64
View File
@@ -11,10 +11,11 @@ use std::ffi::c_void;
use mlua::{LightUserData, Lua, ObjectLike, Table, Value};
use super::binding_abi::optional_string;
use super::glue::VmKind;
use super::{Model, REG_FRAME_META, REG_FRAME_METHODS, REG_SCRIPTS, REG_WRAPPERS};
use crate::layout::Point;
use crate::order::{DrawLayer, Strata};
use crate::widget::{FrameHandle, FrameKind};
use crate::widget::{FrameHandle, FrameKind, KindState};
// The frame method clusters, split out for size.
pub(crate) mod anchor_args;
@@ -22,10 +23,10 @@ mod events_regions;
mod frame_state;
mod layout_methods;
pub(crate) use layout_methods::eff_scale;
pub(in crate::script) use layout_methods::{settle_frame, settle_region};
pub(crate) mod movable;
pub(crate) mod toplevel;
pub(crate) use layout_methods::{anchor_bits_eq, anchor_retarget_is_structural};
pub(crate) use movable::{advance_move, advance_size, FrameMove, FrameSizing};
pub(crate) use movable::{cancel_on_hide, pump_move_or_size, FrameMove};
// ─────────────────────────────────────────────────────────────────────────────────────────────
// id ↔ lightuserdata
@@ -248,10 +249,10 @@ pub(super) fn draw_layer_name(l: DrawLayer) -> &'static str {
}
}
/// A widget tag or `CreateFrame` type → its [`FrameKind`] ([`enum_token`]). Public so the app asks
/// this one mapping rather than keeping a copy.
/// A widget tag or `CreateFrame` type → its [`FrameKind`] ([`enum_token`]), whichever registration
/// makes it known. Public so the app asks this one mapping rather than keeping a copy.
pub fn frame_kind_from_tag(s: &str) -> Option<FrameKind> {
frame_kind_from_str(s)
frame_type(s).map(|t| t.kind)
}
/// The type registry lookup `CreateFrame` and the XML loader share (`0x6ee280`, table
@@ -259,66 +260,100 @@ pub fn frame_kind_from_tag(s: &str) -> Option<FrameKind> {
/// record is released once the first one is made (`0x6ee439`), so a second one misses. On a miss
/// Lua raises and XML logs `Unknown frame type: %s` and skips the node.
pub(crate) fn registered_frame_kind(lua: &Lua, kind: &str) -> Option<FrameKind> {
let frame_kind = frame_kind_from_str(kind)?;
let one_shot_spent = frame_kind == FrameKind::WorldFrame
&& lua
.app_data_ref::<Model>()
.expect("model app_data")
.world_frame_made;
(!one_shot_spent).then_some(frame_kind)
let t = frame_type(kind)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let registered = match t.factory {
Factory::Startup => true,
Factory::InGame => model.vm == VmKind::InGame,
};
let one_shot_spent = t.kind == FrameKind::WorldFrame && model.world_frame_made;
(registered && !one_shot_spent).then_some(t.kind)
}
fn frame_kind_from_str(s: &str) -> Option<FrameKind> {
Some(match enum_token(s).as_str() {
"FRAME" => FrameKind::Frame,
/// `ModelFFX`, the glue's `CSimpleModelFFX` (factory `0x46fa00`): a `CSimpleModel` (its ctor
/// `0x46fa30` runs `0x76c8e0`, then swaps the vtable) answering as `Model` (`0x470030`) with
/// `Model`'s methods and `LoadXML` (`0x76cac0`), which differs in its OnShow (`0x46fa60`) and its
/// paint (`0x46fa90`, [`crate::widget::ModelState::ffx`]).
pub(crate) fn is_model_ffx(kind: &str) -> bool {
enum_token(kind) == "MODELFFX"
}
/// When a frame type's factory is registered (`0x6ee880`) into the process-wide registry, which
/// nothing clears before shutdown (`0x6ee800`, from `0x4036fc`).
#[derive(Clone, Copy)]
enum Factory {
/// Before any FrameScript state is built: the 13 base types (`0x6ef070`, from `0x402af3`), and
/// `ModelFFX`, which the glue manager's init registers (`0x46a4fe`) before its first build
/// (`0x46a505`), so the in-game state has it too.
Startup,
/// `UI_Init`'s eight (`0x495940`, from `0x48feb5`). They stay registered after a logout, so
/// only a glue state that never entered the world lacks them, which is the one benilla builds.
InGame,
}
/// A registered type: the [`FrameKind`] its factory builds, and its registration.
struct FrameType {
kind: FrameKind,
factory: Factory,
}
fn frame_type(s: &str) -> Option<FrameType> {
use Factory::{InGame, Startup};
let (kind, factory) = match enum_token(s).as_str() {
"FRAME" => (FrameKind::Frame, Startup),
// Registered at `0x495948`, the one row passing `1` as its third argument.
"WORLDFRAME" => FrameKind::WorldFrame,
"WORLDFRAME" => (FrameKind::WorldFrame, InGame),
// A plain `CSimpleFrame`: factory `0x495ba0` allocates `0x314` like `<Frame>`'s `0x6eec10`,
// the ctor `0x506950` adds no field, and its paint `0x506a90` does nothing, so
// `GetObjectType()` is "Frame" and `IsObjectType("TaxiRouteFrame")` false (`0x484800` is
// not overridden). The flight lines are the FrameXML's own rotated textures.
"TAXIROUTEFRAME" => FrameKind::Frame,
"BUTTON" => FrameKind::Button,
"CHECKBUTTON" => FrameKind::CheckButton,
"TAXIROUTEFRAME" => (FrameKind::Frame, InGame),
"BUTTON" => (FrameKind::Button, Startup),
"CHECKBUTTON" => (FrameKind::CheckButton, Startup),
// Its own registered type (`0x4959a6`), not a Button: 1.12 has no Lua verb to take loot
// slot N (`LootSlot` only continues a bind confirm), so a Button row could loot nothing.
"LOOTBUTTON" => FrameKind::LootButton,
"EDITBOX" => FrameKind::EditBox,
"STATUSBAR" => FrameKind::StatusBar,
"SLIDER" => FrameKind::Slider,
"SCROLLFRAME" => FrameKind::ScrollFrame,
"MODEL" => FrameKind::Model,
"PLAYERMODEL" => FrameKind::PlayerModel,
"DRESSUPMODEL" => FrameKind::DressUpModel,
"TABARDMODEL" => FrameKind::TabardModel,
"MESSAGEFRAME" => FrameKind::MessageFrame,
"SCROLLINGMESSAGEFRAME" => FrameKind::ScrollingMessageFrame,
"COLORSELECT" => FrameKind::ColorSelect,
"SIMPLEHTML" => FrameKind::SimpleHtml,
"MOVIEFRAME" => FrameKind::MovieFrame,
"GAMETOOLTIP" => FrameKind::GameTooltip,
"MINIMAP" => FrameKind::Minimap,
"LOOTBUTTON" => (FrameKind::LootButton, InGame),
"EDITBOX" => (FrameKind::EditBox, Startup),
"STATUSBAR" => (FrameKind::StatusBar, Startup),
"SLIDER" => (FrameKind::Slider, Startup),
"SCROLLFRAME" => (FrameKind::ScrollFrame, Startup),
"MODEL" => (FrameKind::Model, Startup),
"MODELFFX" => (FrameKind::Model, Startup),
"PLAYERMODEL" => (FrameKind::PlayerModel, InGame),
"DRESSUPMODEL" => (FrameKind::DressUpModel, InGame),
"TABARDMODEL" => (FrameKind::TabardModel, InGame),
"MESSAGEFRAME" => (FrameKind::MessageFrame, Startup),
"SCROLLINGMESSAGEFRAME" => (FrameKind::ScrollingMessageFrame, Startup),
"COLORSELECT" => (FrameKind::ColorSelect, Startup),
"SIMPLEHTML" => (FrameKind::SimpleHtml, Startup),
"MOVIEFRAME" => (FrameKind::MovieFrame, Startup),
"GAMETOOLTIP" => (FrameKind::GameTooltip, InGame),
"MINIMAP" => (FrameKind::Minimap, InGame),
_ => return None,
})
};
Some(FrameType { kind, factory })
}
/// A Lua number-ish → f32 (nil/other → 0.0), for offset/color args.
pub(super) fn as_f32(v: &Value) -> f32 {
match v {
Value::Number(n) => *n as f32,
Value::Integer(i) => *i as f32,
_ => 0.0,
}
/// A colour or coordinate argument by the reference's bare `lua_tonumber` (`0x6f3620`): a number,
/// or a string `luaO_str2d` (`0x6f5900`) reads as one, and 0.0 for anything else, with no type gate
/// and no raise. [`super::binding_abi::coerced_number`] narrowed to the `f32` the widgets store.
pub(super) fn tonumber_f32(lua: &Lua, v: &Value) -> f32 {
super::binding_abi::coerced_number(lua, Some(v.clone())) as f32
}
/// [`as_f32`] in `f64`, for `ColorSelect:SetColorRGB`, whose quantizer an `f32` detour could push
/// across a rounding boundary.
pub(super) fn as_f64(v: &Value) -> f64 {
match v {
Value::Number(n) => *n,
Value::Integer(i) => *i as f64,
_ => 0.0,
}
/// A colour's alpha argument: `lua_isnumber`-gated (`0x781037`, `0x79f5a2`, ...), so a numeric
/// string counts and anything else, a table or boolean included, stages 1.0 without raising.
pub(super) fn alpha_f32(lua: &Lua, v: &Value) -> f32 {
lua.coerce_number(v.clone())
.ok()
.flatten()
.map_or(1.0, |n| n as f32)
}
/// [`tonumber_f32`] in `f64`, for `ColorSelect:SetColorRGB`, whose quantizer an `f32` detour could
/// push across a rounding boundary.
pub(super) fn tonumber_f64(lua: &Lua, v: &Value) -> f64 {
super::binding_abi::coerced_number(lua, Some(v.clone()))
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -351,7 +386,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let create_frame = lua.create_function(create_frame)?;
lua.globals().set("CreateFrame", create_frame)?;
Ok(())
}
/// The frame globals only the in-game state registers (table `0x83de68`, `UI_Init`'s): the glue
/// state's `GetScreenWidth`/`GetScreenHeight` are other bindings (`0x46dd30`/`0x46dd80`), and it
/// has no modifier-key or fullscreen-scale verb.
pub(super) fn install_in_game(lua: &Lua) -> mlua::Result<()> {
// `GetScreenWidth`/`GetScreenHeight` (`0x48b480`/`0x48b4d0`): the screen root's size divided by
// `UIParent`'s scale (`GetUiScale`, `0x494590`), so they answer in `UIParent`'s units.
lua.globals().set(
@@ -466,6 +507,7 @@ fn kind_method_registries(kind: Option<FrameKind>) -> &'static [&'static str] {
Some(FrameKind::SimpleHtml) => &[super::simplehtml::REG_SIMPLEHTML_METHODS],
Some(FrameKind::Slider) => &[super::slider::REG_SLIDER_METHODS],
Some(FrameKind::ColorSelect) => &[super::colorselect::REG_COLORSELECT_METHODS],
Some(FrameKind::MovieFrame) => &[super::movieframe::REG_MOVIEFRAME_METHODS],
Some(FrameKind::Button) => &[super::button::REG_BUTTON_METHODS],
// Its one method, then `Button`'s: `0x4c1be0` probes its map `0xb71b64`, then `0x782c90`.
Some(FrameKind::LootButton) => &[
@@ -555,12 +597,13 @@ pub(super) fn create_frame(
// leaves no partial widget and no global. The raise never returns and `pcall` catches it, as
// the widget dispatcher (`0x704f10`) does for every handler.
if let Some(t) = template.as_deref().filter(|t| !t.is_empty()) {
let known = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let templates = model.framexml_templates.borrow();
// One name, verbatim, case-folded, as `framexml::expand` resolves it.
templates.contains_key(t) || templates.keys().any(|k| k.eq_ignore_ascii_case(t))
};
// One name, verbatim, case-folded, as `framexml::expand` resolves it.
let known = lua
.app_data_ref::<Model>()
.expect("model app_data")
.framexml_templates
.borrow()
.contains(t);
if !known {
return Err(mlua::Error::runtime(format!(
"CreateFrame(): Couldn't find inherited node \"{t}\""
@@ -583,6 +626,11 @@ pub(super) fn create_frame(
if frame_kind == FrameKind::WorldFrame {
model.world_frame_made = true;
}
if is_model_ffx(&kind) {
if let Some(KindState::Model(m)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
m.ffx = true;
}
}
// A child enters its parent's stratum at the parent's level + 1; an explicit
// `frameStrata` or `frameLevel` still overrides it afterwards.
if let Some(p) = parent_handle {
@@ -593,6 +641,11 @@ pub(super) fn create_frame(
}
let id = model.frame_id(h);
model.layout_inputs.entry(h).or_default();
// The builder's pre-load and post-load hooks (`0x6ee4d6`, `0x6ee4ea`): a frame born hidden
// is deferred, as a hidden parent leaves it. A template's load opens its own window.
model.layout_load_begin(h);
// No handler is bound yet, so the resolve this owes fires nothing.
let _ = model.layout_load_end(h);
id
};
@@ -600,12 +653,13 @@ pub(super) fn create_frame(
// this call, so the handler can reach the frame by name.
let wrapper = frame_wrapper(lua, id)?;
if let Some(template) = template {
let messages = crate::loader::apply_template(lua, &wrapper, &kind, &template);
if !messages.is_empty() {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
for m in messages {
model.record_warning(m);
}
let report = crate::loader::apply_template(lua, &wrapper, &kind, &template);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
for (log, heard) in report.raised() {
model.record_script_error(log.to_string(), heard.to_string());
}
for w in report.warnings {
model.record_warning(w);
}
}
Ok(wrapper)
@@ -195,3 +195,33 @@ pub(crate) fn resolve_xml_relative_to(lua: &Lua, name: &str) -> Option<Table> {
// A wrapper, not a name, so the Lua ladder's raise can never reach a loading document.
nt.wrapper.clone()
}
/// `GetPoint`'s index (`0x7a2340`), from 0: an `is-number` argument (`0x6f34d0`), truncated
/// toward zero (`0x40a2b0`), less one; any other argument, or one at or below 1, picks the first
/// anchor, since the walk takes the first held slot whose count reaches the index (`0x7a23fe`).
pub(crate) fn get_point_index(lua: &Lua, n: Value) -> mlua::Result<usize> {
let idx = match lua.coerce_number(n)? {
Some(n) => (n as i64 as i32).wrapping_sub(1),
None => 0,
};
Ok(idx.max(0) as usize)
}
/// `GetPoint`'s answer for `anchor` (`0x7a2416`..`0x7a24d6`): point, `relativeTo`, relative point
/// and the two offsets; no anchor at the index pushes no value at all (`0x7a240d`).
pub(crate) fn get_point_values(
lua: &Lua,
anchor: Option<crate::layout::Anchor>,
rel: impl FnOnce(u32) -> mlua::Result<Value>,
) -> mlua::Result<MultiValue> {
let Some(a) = anchor else {
return Ok(MultiValue::new());
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(super::point_name(a.point))?),
rel(a.relative_to)?,
Value::String(lua.create_string(super::point_name(a.relative_point))?),
Value::Number(f64::from(a.x_off)),
Value::Number(f64::from(a.y_off)),
]))
}
@@ -19,6 +19,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, (this, event): (Table, String)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// A name the VM's event table lacks registers nothing and raises nothing (`0x702140`).
let Some(event) = model.vm.event_key(&event).map(str::to_string) else {
return Ok(());
};
// Dispatch follows registration order, as the reference's listener list does; a
// re-register keeps its place and never fires twice.
let list = model.event_to_frames.entry(event.clone()).or_default();
@@ -34,6 +38,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, (this, event): (Table, String)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// Matched as `RegisterEvent` matches (`0x702280`).
let Some(event) = model.vm.event_key(&event).map(str::to_string) else {
return Ok(());
};
if let Some(list) = model.event_to_frames.get_mut(&event) {
list.retain(|x| x != &h);
}
@@ -152,7 +160,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let changed = !d.anchors.is_empty();
d.anchors.clear();
if changed {
model.touch_layout();
model.touch_layout_region(rh);
}
model.region_id(rh)
};
@@ -212,8 +220,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
/// `SetScript(name, func)`: stores the closure under one of [`SCRIPT_KINDS`], else raises.
/// Deviation: real 1.12 kinds nothing fires yet raise too (`OnMessageScrollChanged`,
/// `OnInputLanguageChanged`, the movie frame's), because an accepted handler that never runs fails
/// silently. `OnAttributeChanged` is 2.0's, with no 1.12 slot.
/// `OnInputLanguageChanged`), because an accepted handler that never runs fails silently. The movie
/// frame's three are accepted all the same: the stock XML sets them, and its player ([`super::super::movieframe`])
/// is the one thing that fires them. `OnAttributeChanged` is 2.0's, with no 1.12 slot.
fn set_script(lua: &Lua, this: &Table, name: &str, func: Option<Function>) -> mlua::Result<()> {
let kind = SCRIPT_KINDS
.iter()
@@ -240,7 +249,10 @@ fn set_script(lua: &Lua, this: &Table, name: &str, func: Option<Function>) -> ml
// The first handler of a kind joins that kind's list; nothing else writes `scripts`.
if model.scripts.entry(h).or_default().insert(kind) {
match kind {
"OnUpdate" => model.on_update_frames.push(h),
"OnUpdate" => {
model.on_update_frames.push(h);
model.on_update_joins += 1;
}
"OnSizeChanged" => model.on_size_changed_frames.push(h),
"OnUpdateModel" => model.on_update_model_frames.push(h),
_ => {}
@@ -293,11 +305,12 @@ fn apply_region_inherits(lua: &Lua, wrapper: &Table, from: &str) -> mlua::Result
if wrapper.call_method::<()>("SetFontObject", from).is_ok() {
return Ok(());
}
let is_template = {
let model = lua.app_data_ref::<Model>().expect("model");
let templates = model.framexml_templates.borrow();
templates.contains_key(from) || templates.keys().any(|k| k.eq_ignore_ascii_case(from))
};
let is_template = lua
.app_data_ref::<Model>()
.expect("model")
.framexml_templates
.borrow()
.contains(from);
if is_template {
let mut model = lua.app_data_mut::<Model>().expect("model");
model.record_warning(format!(
@@ -355,9 +368,12 @@ fn create_region(
crate::framexml::resolve_name(&name, &super::parent_token_base(&model, Some(owner)))
};
publish_global(lua, &name, &wrapper)?;
// First name wins, as for frames; a sibling's `SetPoint` can then name this region.
// The region keeps its own name; the lookup a sibling's `SetPoint` names it by stays
// first-come, as `_G` is.
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_names.entry(name).or_insert(id);
if let Some(rh) = model.id_to_region.get(&id).copied() {
crate::script::region::set_region_name(&mut model, rh, id, &name);
}
}
Ok(wrapper)
}
@@ -1,6 +1,8 @@
//! Frame methods: visibility, hierarchy, strata, level, scale, alpha, backdrop and input flags.
use mlua::{Lua, Table, Value};
use mlua::{Lua, MultiValue, Table, Value};
use crate::script::binding_abi::bool_or_default;
use crate::script::region_map::{set_shared, Side};
@@ -346,7 +348,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
model.arena.reparent_finish(h, new_parent);
// A reparent moves the subtree's effective scale, a layout input and part of every
// descendant FontString's measure key.
model.touch_layout_reparent(h);
model.touch_layout_subtree(h);
model.touch_measure_all();
}
if was_visible {
@@ -453,8 +455,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// `SetBackdrop(table|nil)` (`0x7776e0` → `0x76a5d0`) installs or removes the tiled background
// and 8-piece border, both white until tinted; the two colour setters tint the background and
// the border respectively (`0x77f410`/`0x77f440`).
// and 8-piece border, nine Texture regions on the frame (`script::backdrop::install`), both
// white until tinted; the two colour setters tint the background and the border respectively
// (`0x77f410`/`0x77f440`).
m.set(
"SetBackdrop",
lua.create_function(|lua, (this, arg): (Table, Value)| {
@@ -470,14 +473,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
}
};
let mut model = lua.app_data_mut::<Model>().expect("model");
match bd {
Some(b) => {
model.backdrops.insert(h, b);
}
None => {
model.backdrops.remove(&h);
}
}
crate::script::backdrop::install(&mut model, h, bd);
Ok(())
})?,
)?;
@@ -493,7 +489,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// re-enters and would panic on the app-data borrow.
let bd = {
let model = lua.app_data_ref::<Model>().expect("model");
model.backdrops.get(&h).cloned()
model.backdrops.get(&h).map(|i| i.spec.clone())
};
let Some(bd) = bd else {
return Ok(mlua::MultiValue::new());
@@ -535,9 +531,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let color = backdrop_color(lua, args);
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(bd) = model.backdrops.get_mut(&h) {
bd.bg_color = color;
}
crate::script::backdrop::set_color(&mut model, h, false, color);
Ok(())
})?,
)?;
@@ -547,9 +541,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let color = backdrop_color(lua, args);
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(bd) = model.backdrops.get_mut(&h) {
bd.border_color = color;
}
crate::script::backdrop::set_color(&mut model, h, true, color);
Ok(())
})?,
)?;
@@ -558,7 +550,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
match model.backdrops.get(&h) {
match model.backdrops.get(&h).map(|i| &i.spec) {
Some(bd) => Ok((
bd.bg_color[0],
bd.bg_color[1],
@@ -574,7 +566,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
match model.backdrops.get(&h) {
match model.backdrops.get(&h).map(|i| &i.spec) {
Some(bd) => Ok((
bd.border_color[0],
bd.border_color[1],
@@ -588,12 +580,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// Mouse interaction: `EnableMouse` gates hit-testing.
m.set(
"EnableMouse",
lua.create_function(|lua, (this, enable): (Table, bool)| {
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let enable = bool_or_default(args.front(), true); // `0x7770fe`
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
.arena
.set_mouse_enabled(h, enable);
visibility::set_mouse_enabled(lua, h, enable);
Ok(())
})?,
)?;
@@ -611,7 +601,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// (`script::keyboard`), separate from having a handler (`0x76af00`).
m.set(
"EnableKeyboard",
lua.create_function(|lua, (this, enable): (Table, bool)| {
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let enable = bool_or_default(args.front(), true); // `0x776f4e`
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
@@ -660,7 +651,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// in the reference, and the one the wheel hit test gates on.
m.set(
"EnableMouseWheel",
lua.create_function(|lua, (this, enable): (Table, bool)| {
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let enable = bool_or_default(args.front(), true); // `0x77727e`
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
@@ -679,18 +671,18 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
))
})?,
)?;
// Clamp-to-screen (`0x776c00`/`0x776cb0`, geometry flags bit 4): the layout resolve keeps the
// Clamp-to-screen (`0x776c00`/`0x776cb0`, geometry flags `0x10`): the layout resolve keeps the
// frame's rect inside the window, size preserved.
m.set(
"SetClampedToScreen",
lua.create_function(|lua, (this, clamp): (Table, bool)| {
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let clamp = bool_or_default(args.front(), true); // `0x776c8e`
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let changed = model.arena.is_clamped_to_screen(h) != clamp;
model.arena.set_clamped_to_screen(h, clamp);
if changed {
model.touch_layout();
}
// `0x768cc0` invalidates whether or not the flag moved.
model.invalidate_frame(h, changed);
Ok(())
})?,
)?;
@@ -740,10 +732,9 @@ fn backdrop_from_table(t: &Table) -> mlua::Result<Backdrop> {
if let Ok(Value::String(s)) = t.get::<Value>("edgeFile") {
bd.edge_file = Some(s.to_str()?.to_string());
}
bd.tile = !matches!(
t.get::<Value>("tile").unwrap_or(Value::Nil),
Value::Nil | Value::Boolean(false)
);
// `GetBoolOrDefault` (`0x7778a3`) with default 0 on the `tile` key's value, so `tile = "0"`
// and `tile = 0` are off and `tile = "yes"` is on.
bd.tile = bool_or_default(Some(&t.get::<Value>("tile").unwrap_or(Value::Nil)), false);
if let Ok(Value::Number(n)) = t.get::<Value>("tileSize") {
bd.tile_size = n as f32;
} else if let Ok(Value::Integer(n)) = t.get::<Value>("tileSize") {
@@ -2,13 +2,16 @@
use mlua::{Lua, MultiValue, Table, Value};
use crate::layout::{Anchor, Point};
use crate::layout::{Anchor, Anchors, Point};
use crate::script::region_map::{set_shared, Side};
use crate::script::{Model, SCREEN};
use crate::widget::FrameHandle;
use super::anchor_args::{parse_set_all_points, parse_set_point, resolve_rel_target, UNNAMED};
use super::{frame_handle_of, frame_parent_token_base, frame_wrapper, point_name};
use super::anchor_args::{
get_point_index, get_point_values, parse_set_all_points, parse_set_point, resolve_rel_target,
UNNAMED,
};
use super::{frame_handle_of, frame_parent_token_base, frame_wrapper};
/// The receiver's name for the error strings and its `$parent` base, read under one short `Model`
/// borrow that ends before the ladder's `_G` read.
@@ -35,54 +38,44 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
set_shared(lua, m, Side::Frame, "ClearAllPoints", |lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// Every layout setter mutates only on a real change, so a per-frame caller setting the
// same value never reopens the layout gate. Clearing is a retarget to no targets.
let old: Option<Vec<u32>> = match model.layout_inputs.get_mut(&h) {
Some(input) if !input.anchors.is_empty() => {
let old = input.anchors.iter().map(|a| a.relative_to).collect();
input.anchors.clear();
Some(old)
}
_ => None,
};
if let Some(old) = old {
model.touch_layout_retarget_frame(h, &old, &[]);
// `0x767620` frees the anchors and unhooks each from its target, and queues no resolve:
// the frame keeps its rect until its own next invalidate. Named, as the graph loses the
// edges. Every layout setter mutates only on a real change, so a per-frame caller setting
// the same value never reopens the layout gate.
let cleared = model
.layout_inputs
.get_mut(&h)
.is_some_and(|input| input.anchors.clear());
if cleared {
model.touch_layout_frame(h);
}
Ok(())
})?;
// GetPoint([n]) → point, relativeTo, relativePoint, x, y of the n-th anchor (default 1);
// relativeTo is nil for a screen-root anchor, since `SCREEN` has no wrapper.
// GetPoint([n]) → point, relativeTo, relativePoint, x, y of the n-th anchor in point order
// (`0x7a2340`); relativeTo is nil for a screen-root anchor, since `SCREEN` has no wrapper.
set_shared(
lua,
m,
Side::Frame,
"GetPoint",
|lua, (this, n): (Table, Option<i64>)| {
|lua, (this, n): (Table, Value)| {
let h = frame_handle_of(lua, &this)?;
let idx = get_point_index(lua, n)?;
let anchor = {
let model = lua.app_data_ref::<Model>().expect("model");
let idx = (n.unwrap_or(1).max(1) - 1) as usize;
model
.layout_inputs
.get(&h)
.and_then(|i| i.anchors.get(idx))
.cloned()
.and_then(|i| i.anchors.nth(idx))
.copied()
};
let Some(a) = anchor else {
return Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil, Value::Nil));
};
let rel = if a.relative_to == SCREEN {
Value::Nil
} else {
Value::Table(frame_wrapper(lua, a.relative_to)?)
};
Ok((
Value::String(lua.create_string(point_name(a.point))?),
rel,
Value::String(lua.create_string(point_name(a.relative_point))?),
Value::Number(f64::from(a.x_off)),
Value::Number(f64::from(a.y_off)),
))
get_point_values(lua, anchor, |rel| {
Ok(if rel == SCREEN {
Value::Nil
} else {
Value::Table(frame_wrapper(lua, rel)?)
})
})
},
)?;
// GetNumPoints(): on the Region method table (`0x87c9b8`), so every widget answers it.
@@ -116,17 +109,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
Anchor::new(Point::BottomRight, rel_id, Point::BottomRight, 0.0, 0.0),
];
let input = model.layout_inputs.entry(h).or_default();
let same = input.anchors.len() == 2
&& input
.anchors
.iter()
.zip(&pair)
.all(|(a, b)| anchor_bits_eq(a, b));
if !same {
input.anchors.clear();
input.anchors.extend_from_slice(&pair);
model.touch_layout();
}
// The reference clears, sets both and invalidates (`0x767db0`); a bit-identical pair
// moves no input.
let pair = Anchors::from(pair);
let changed = !input.anchors.bits_eq(&pair);
input.anchors = pair;
model.invalidate_frame(h, changed);
Ok(())
},
)?;
@@ -141,10 +129,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let input = model.layout_inputs.entry(h).or_default();
let changed = input.width.to_bits() != w.to_bits();
input.width = w;
if changed {
// A size write moves no edge and no roster membership.
model.touch_layout_frame(h);
}
// A size write moves no edge and no roster membership. It invalidates whether or not
// the width moved (`0x7683d0`).
model.invalidate_frame(h, changed);
model.note_authored_size(h);
Ok(())
},
@@ -160,9 +147,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let input = model.layout_inputs.entry(h).or_default();
let changed = input.height.to_bits() != ht.to_bits();
input.height = ht;
if changed {
model.touch_layout_frame(h);
}
model.invalidate_frame(h, changed);
model.note_authored_size(h);
Ok(())
},
@@ -170,22 +155,18 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// No `SetSize`: neither 1.12 method table has it.
set_shared(lua, m, Side::Frame, "GetWidth", |lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
settle(lua);
let model = lua.app_data_ref::<Model>().expect("model");
Ok(size_read(&model, h, true))
Ok(size_read(lua, h, true))
})?;
set_shared(lua, m, Side::Frame, "GetHeight", |lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
settle(lua);
let model = lua.app_data_ref::<Model>().expect("model");
Ok(size_read(&model, h, false))
Ok(size_read(lua, h, false))
})?;
// GetCenter(): the resolved rect's centre in the frame's own units (screen ÷ effective scale,
// y-up), as the reference's coordinate getters report; nil before the first resolve.
set_shared(lua, m, Side::Frame, "GetCenter", |lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
settle(lua);
settle_frame(lua, h);
let model = lua.app_data_ref::<Model>().expect("model");
let inv = 1.0 / eff_scale(&model, h);
Ok(match model.resolved.get(&h) {
@@ -219,7 +200,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
] {
set_shared(lua, m, Side::Frame, name, move |lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
settle(lua);
settle_frame(lua, h);
let model = lua.app_data_mut::<Model>().expect("model");
let inv = 1.0 / eff_scale(&model, h);
Ok(model.resolved.get(&h).map(|r| {
@@ -236,12 +217,40 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
}
/// Resolve the layout now if anything moved, as the reference answers a geometry query against
/// current layout; a settled tree costs one epoch compare. Deviation: `OnSizeChanged` fires at the
/// next `UiScript::resolve`, a tick after the reference's (`ApplyRect 0x76b580`), because running
/// Lua handlers inside a binding risks a borrow panic or unbounded recursion.
fn settle(lua: &Lua) {
/// current layout; a settled tree costs one epoch compare. The read frame `h`, if pending, resolves
/// inside the read, its deferral bit left set (`0x7a1a0b` → `0x7680e0(1)`), and its `OnSizeChanged`
/// fires here, as its `ApplyRect` (`0x76b580` → vt+0x48) runs inside the getter.
pub(in crate::script) fn settle_frame(lua: &Lua, h: FrameHandle) {
let id = lua
.app_data_ref::<Model>()
.expect("model")
.frame_to_id
.get(&h)
.copied();
settle_node(lua, id);
}
/// [`settle_frame`] for a region's rect, through the same getters (`0x7a1980`..).
pub(in crate::script) fn settle_region(lua: &Lua, rh: crate::widget::RegionHandle) {
let id = lua
.app_data_ref::<Model>()
.expect("model")
.region_to_id
.get(&rh)
.copied();
settle_node(lua, id);
}
fn settle_node(lua: &Lua, id: Option<u32>) {
let mut model = lua.app_data_mut::<Model>().expect("model");
crate::script::UiScript::resolve_layout(&mut model);
match id {
Some(id) if model.node_pending(id) => {
model.pull_read(id);
drop(model);
crate::script::visibility::resolve_and_fire(lua, vec![id]);
}
_ => crate::script::UiScript::resolve_layout(&mut model),
}
}
pub(crate) fn eff_scale(model: &Model, h: FrameHandle) -> f32 {
@@ -257,18 +266,28 @@ pub(crate) fn eff_scale(model: &Model, h: FrameHandle) -> f32 {
}
}
/// `GetWidth`/`GetHeight`: the resolved span in the frame's own units (screen ÷ effective scale),
/// else the size it was given, where 0 means derived, as in the reference.
fn size_read(model: &Model, h: FrameHandle, width: bool) -> f32 {
if let Some(r) = model.resolved.get(&h) {
let span = if width { r.width() } else { r.height() };
return span / eff_scale(model, h);
/// `GetWidth`/`GetHeight` (`0x7a1e00`, `0x7a2030`): the size the frame was given (`+0x1c`/`+0x20`
/// → `0x768420`/`0x768410`), with no resolve, whatever its anchors span (`0x7a1e99`–`0x7a1ea5`).
/// Only a zero size resolves a pending frame (`0x7a1eb6`) and answers its rect's span, else 0. The
/// span is not divided by the effective scale, though the rect carries it: the answer is only
/// converted (`0x41ad70`, `0x41ae40`), where `GetLeft` divides (`0x7a1a63`).
fn size_read(lua: &Lua, h: FrameHandle, width: bool) -> f32 {
let set = {
let model = lua.app_data_ref::<Model>().expect("model");
model
.layout_inputs
.get(&h)
.map_or(0.0, |i| if width { i.width } else { i.height })
};
if set != 0.0 {
return set;
}
settle_frame(lua, h);
let model = lua.app_data_ref::<Model>().expect("model");
model
.layout_inputs
.resolved
.get(&h)
.map(|i| if width { i.width } else { i.height })
.unwrap_or(0.0)
.map_or(0.0, |r| if width { r.width() } else { r.height() })
}
/// `SetPoint` (`0x7a2540`) for a frame: the argument ladder is [`super::anchor_args`], shared with
@@ -286,59 +305,18 @@ fn set_point(lua: &Lua, this: &Table, args: &MultiValue) -> mlua::Result<()> {
let rel_to_id = resolve_rel_target(&model, &p.target, &who, "SetPoint", me, parent)?;
let (point, rel_point, x, y) = (p.point, p.rel_point, p.x, p.y);
let units = model.layout_units();
let input = model.layout_inputs.entry(h).or_default();
let new = Anchor::new(point, rel_to_id, rel_point, x, y);
// A no-op only when the identical anchor is already last and no earlier one has this point,
// mirroring the retain and push below; bits compared, as the fingerprint tells -0.0 from 0.0.
let same_at_tail = input
// An unchanged anchor stores nothing and queues no resolve (`0x767cde` → `0x767d5e`).
if input
.anchors
.last()
.is_some_and(|a| anchor_bits_eq(a, &new))
&& !input.anchors[..input.anchors.len() - 1]
.iter()
.any(|a| a.point == point);
if !same_at_tail {
// Target lists are collected only for a retarget: the value-only change is the per-frame
// idiom (a dragged window) and must stay allocation-free.
let structural = anchor_retarget_is_structural(&input.anchors, &new);
let old_targets: Option<Vec<u32>> =
structural.then(|| input.anchors.iter().map(|a| a.relative_to).collect());
input.anchors.retain(|a| a.point != point);
input.anchors.push(new);
match old_targets {
None => model.touch_layout_frame(h),
Some(old) => {
let new_targets: Vec<u32> = model.layout_inputs[&h]
.anchors
.iter()
.map(|a| a.relative_to)
.collect();
model.touch_layout_retarget_frame(h, &old, &new_targets);
}
}
.set(Anchor::new(point, rel_to_id, rel_point, x, y), units)
{
model.invalidate_frame(h, true);
}
Ok(())
}
/// Whether this `SetPoint` changes the node's anchor targets, mirroring the setters' retain and
/// push: value-only exactly when one existing anchor has this point and keeps its target.
pub(crate) fn anchor_retarget_is_structural(anchors: &[Anchor], new: &Anchor) -> bool {
let mut same_point = anchors.iter().filter(|a| a.point == new.point);
match (same_point.next(), same_point.next()) {
(Some(old), None) => old.relative_to != new.relative_to,
_ => true,
}
}
/// Bit-exact anchor equality, as the layout gate's fingerprint compares, so the two always agree.
pub(crate) fn anchor_bits_eq(a: &Anchor, b: &Anchor) -> bool {
a.point == b.point
&& a.relative_to == b.relative_to
&& a.relative_point == b.relative_point
&& a.x_off.to_bits() == b.x_off.to_bits()
&& a.y_off.to_bits() == b.y_off.to_bits()
}
/// The default `relativeTo`: the parent, or [`SCREEN`] for a top-level frame (`0x76c6e0`).
fn default_parent_id(model: &mut Model, h: FrameHandle) -> u32 {
match model.arena.frame(h).and_then(|f| f.parent) {
+335 -207
View File
@@ -1,54 +1,49 @@
//! Frame methods: movable and resizable windows, and the drag pumps behind `StartMoving` and
//! Frame methods: movable and resizable windows, and the drag pump behind `StartMoving` and
//! `StartSizing`. The gesture that usually calls them, `OnDragStart`/`OnDragStop` (`0x76bf70`/
//! `0x76c040`), is [`crate::script::cursor`]'s.
//!
//! The flags are bits of `[frame+0xb4]`, set by `0x76a3c0`: movable `0x100`, resizable `0x200`,
//! userPlaced `0x1000`. A drag (`0x7652b0`) re-anchors the frame to one TOPLEFT against the screen
//! root at its current rect (`0x768430`), raises it, sets userPlaced and takes the one drag slot
//! (`root+0xcfc`). The pump (`0x7655b0`) adds each cursor delta, over the frame's scale, straight
//! into that anchor's offsets (`0x768710` case 4), so stopping (`0x765640`) writes nothing.
//!
//! Here nothing re-anchors: a move translates every authored anchor, and a resize keeps the set.
//! userPlaced `0x1000`. Every drag enters at `0x7652b0`, which pins the frame to one TOPLEFT
//! anchor against the screen root at its current rect, with an explicit size (`0x768430`), then
//! raises it, sets userPlaced and takes the one drag slot (`root+0xcfc`) with its grip (`+0xd04`).
//! The pump (`0x7655b0` → `0x768710`) works on that anchor and size: a move, grip CENTER, adds
//! each cursor delta over the frame's scale to the anchor's offsets (case 4), and each of the
//! eight grips sizes the frame and re-pins it shifted by its left and top travel only. Stopping
//! (`0x765640`) writes nothing, so after any drag `GetPoint` answers that one TOPLEFT.
use mlua::{Lua, MultiValue, Table, Value};
use crate::script::Model;
use crate::layout::{Anchor, Anchors, Point};
use crate::script::binding_abi::bool_or_default;
use crate::script::{Model, UiScript, SCREEN};
use crate::widget::FrameHandle;
use super::frame_handle_of;
use super::layout_methods::eff_scale;
use super::{frame_handle_of, point_from_str};
/// The one in-flight move, the reference's drag slot, held in [`Model::moving`].
/// The one move or size in flight, the reference's drag slot, held in [`Model::moving`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct FrameMove {
/// The frame being moved (`root+0xcfc`).
/// The frame being dragged (`root+0xcfc`).
frame: FrameHandle,
/// The grip (`root+0xd04`): CENTER moves the frame, any other point resizes it from there.
grip: Point,
/// The cursor position at the last pump (`root+0xd08`/`+0xd0c`), UI px, y-up.
sample: (f32, f32),
/// Whether mouse-up ends the move, the drag mode as Lua sees it: `0x766420` ends modes 1
/// (modifier drag) and 2 (title region), never 3 (`StartMoving`).
/// Whether mouse-up ends the drag, the drag mode as Lua sees it: `0x766420` ends modes 1
/// (modifier drag) and 2 (title region), never 3 (`StartMoving`, `StartSizing`).
pub(crate) auto_stop: bool,
}
/// An in-flight `StartSizing` drag: which frame, which grip, and the cursor at the last pump.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct FrameSizing {
frame: FrameHandle,
/// The edges the named grip moves: `LEFT` the left edge, `BOTTOMRIGHT` the bottom and right.
left: bool,
right: bool,
top: bool,
bottom: bool,
sample: (f32, f32),
}
/// Populate `m`'s movable and resizable methods.
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// SetMovable / IsMovable (`0x776420`/`0x7764d0`): a pure flag write, so clearing it mid-drag
// stops nothing. The argument is Lua truthiness, as the reference's `toboolean`.
// stops nothing. The argument goes through `GetBoolOrDefault` (`0x6f1c10`) with default 1
// (`0x7764ae`), as every flag setter of the Frame table does.
m.set(
"SetMovable",
lua.create_function(|lua, (this, flag): (Table, bool)| {
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let flag = bool_or_default(args.front(), true);
set_flag(lua, &this, Flag::Movable, flag)
})?,
)?;
@@ -59,7 +54,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// SetResizable / IsResizable (`0x776590`/`0x776640`): the same pure flag write.
m.set(
"SetResizable",
lua.create_function(|lua, (this, flag): (Table, bool)| {
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let flag = bool_or_default(args.front(), true); // `0x77661e`
set_flag(lua, &this, Flag::Resizable, flag)
})?,
)?;
@@ -140,7 +136,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// nor resizable (`0x776adb`).
m.set(
"SetUserPlaced",
lua.create_function(|lua, (this, flag): (Table, bool)| {
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let flag = bool_or_default(args.front(), true); // `0x776b29`
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let ok = model
@@ -172,48 +169,28 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
start_moving(&mut model, h)
})?,
)?;
// StartSizing(point) (`0x776830`; stock caller `FloatingChatFrame.lua:600`): a resize from the
// named grip, ended by `StopMovingOrSizing`. The reference's per-grip switch (`0x768710`)
// moves the named edges with the cursor and keeps the opposite ones.
// StartSizing([point]) (`0x776830`; stock caller `FloatingChatFrame.lua:600`): a resize from
// the named grip, ended by `StopMovingOrSizing`. The grip starts as BOTTOMRIGHT (`0x776913`)
// and only a string naming a point, in any case, replaces it (`0x77691a`, `0x6f1840`).
m.set(
"StartSizing",
lua.create_function(|lua, (this, point): (Table, Option<String>)| {
lua.create_function(|lua, (this, point): (Table, Value)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if !model.arena.frame(h).is_some_and(|f| f.resizable) {
return Err(not_flagged(&model, h, "resizable"));
}
// Same "do not start a second one" guard `start_moving` carries.
if model.sizing.is_some() || model.moving.is_some() {
// The same `root+0xcfc` guard `StartMoving` carries (`0x776947`).
if model.moving.is_some() {
return Ok(());
}
let p = point.unwrap_or_default().to_ascii_uppercase();
let (left, right) = (p.contains("LEFT"), p.contains("RIGHT"));
let (top, bottom) = (p.contains("TOP"), p.contains("BOTTOM"));
// The raise and userPlaced belong to the drag entry `0x7652b0`, whatever the grip.
super::toplevel::raise(&mut model, h);
set_user_placed(&mut model, h);
let sample = model.cursor_pos;
// A CENTER grip is a move: the pump's case 4 shifts the anchor offsets and returns
// before the size clamp (`0x768bfb`).
if p == "CENTER" {
model.moving = Some(FrameMove {
frame: h,
sample,
// `StartSizing` enters as drag mode 3 (`0x7652b0`), which mouse-up never ends.
auto_stop: false,
});
return Ok(());
let grip = match &point {
Value::String(s) => s.to_str().ok().and_then(|t| point_from_str(&t)),
_ => None,
}
// A name that is no point starts an inert drag: raised and userPlaced, moving nothing.
model.sizing = Some(FrameSizing {
frame: h,
left,
right,
top,
bottom,
sample,
});
.unwrap_or(Point::BottomRight);
// Drag mode 3, which mouse-up never ends.
begin_drag(&mut model, h, grip, false);
Ok(())
})?,
)?;
@@ -223,9 +200,6 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if model.sizing.is_some_and(|sz| sz.frame == h) {
model.sizing = None;
}
if model.moving.is_some_and(|mv| mv.frame == h) {
model.moving = None;
}
@@ -284,53 +258,168 @@ fn not_flagged(model: &Model, h: FrameHandle, want: &str) -> mlua::Error {
}
/// `StartMoving` (`0x776700` → `0x7652b0`): errors on a frame that is not movable (`0x77678b`),
/// ignores a second move, then raises the frame, sets userPlaced and takes the slot, in that order.
/// ignores a second drag, then starts a move, grip CENTER (`0x776819`).
fn start_moving(model: &mut Model, h: FrameHandle) -> mlua::Result<()> {
if !model.arena.frame(h).is_some_and(|f| f.movable) {
return Err(not_flagged(model, h, "movable"));
}
// The `root+0xcfc != 0` guard (`0x7767e8`); what the reference does beyond not starting a
// second move is untraced.
// second drag is untraced.
if model.moving.is_some() {
return Ok(());
}
// The drag-start raise (`0x7652d7`); the worker applies the toplevel gate.
super::toplevel::raise(model, h);
set_user_placed(model, h);
model.moving = Some(FrameMove {
frame: h,
sample: model.cursor_pos,
// Mode 3: only `StopMovingOrSizing` ends it.
auto_stop: false,
});
// Mode 3: only `StopMovingOrSizing` ends it.
begin_drag(model, h, Point::Center, false);
Ok(())
}
/// Begin a title-region move, drag mode 2 (`0x7662c0` → `0x765320` → `0x7652b0`), with no movable
/// check, since nothing on that path reads the bit. False when a move is already in flight.
/// check, since nothing on that path reads the bit; `0x765320` asks for grip CENTER when it is not
/// sizing (`0x765332` → `0x765464`). Neither reads the slot first, so a drag in flight is displaced
/// and nothing runs on its frame. False when the frame has no rect, so nothing started.
pub(crate) fn start_title_move(model: &mut Model, h: FrameHandle) -> bool {
if model.moving.is_some() {
return false;
}
super::toplevel::raise(model, h);
set_user_placed(model, h);
model.moving = Some(FrameMove {
frame: h,
sample: model.cursor_pos,
auto_stop: true,
});
true
begin_drag(model, h, Point::Center, true)
}
/// Set userPlaced as the drag entry `0x7652b0` does; the layout cache is dirtied only on the
/// transition, since every title-region click runs this.
fn set_user_placed(model: &mut Model, h: FrameHandle) {
/// Begin the layout-mode modifier drag on the toplevel window `h` that `0x765490`'s walk lifted,
/// from its flag test on: Control held sizes and needs the window resizable (`0x765570`), else it
/// moves and needs it movable (`0x765560`). Then `0x765320` with drag mode 1, which the release
/// ends: grip CENTER for a move, the cell of `grid_grip` for a size. False when nothing started.
pub(crate) fn start_layout_drag(
model: &mut Model,
h: FrameHandle,
at: (f32, f32),
sizing: bool,
) -> bool {
let allowed = model
.arena
.frame(h)
.is_some_and(|f| if sizing { f.resizable } else { f.movable });
if !allowed {
return false;
}
let grip = if sizing {
grid_grip(model, h, at)
} else {
Some(Point::Center)
};
grip.is_some_and(|grip| begin_drag(model, h, grip, true))
}
/// `0x765320`'s sizing grip: with the layout resolved (`0x76533e` → `0x7680e0`), the cell of a 3×3
/// grid of the frame's rect (`0x768320`) that `at` falls in, each axis cut at a quarter and three
/// quarters (`0.25` at `0x8029b0`, `3.0` at `0x80297c`), a cut belonging to the cell past it; the
/// middle cell is CENTER, a move (`0x765384`–`0x765464`). The x offset and the quarter-width stay
/// in x87 registers, the y offset and the quarter-height are stored as floats (`0x765390`,
/// `0x7653ab`). None when the frame has no rect (`0x765379`).
fn grid_grip(model: &mut Model, h: FrameHandle, (x, y): (f32, f32)) -> Option<Point> {
UiScript::resolve_layout(model);
let r = *model.resolved.get(&h)?;
let dx = f64::from(x) - f64::from(r.left);
let quarter_w = (f64::from(r.right) - f64::from(r.left)) * 0.25;
let dy = f64::from((f64::from(y) - f64::from(r.bottom)) as f32);
let quarter_h = f64::from(((f64::from(r.top) - f64::from(r.bottom)) * 0.25) as f32);
// 0 the near cell (left, bottom), 1 the middle, 2 the far.
let cell = |d: f64, quarter: f64| {
if d < quarter {
0
} else if d >= quarter * 3.0 {
2
} else {
1
}
};
Some(match (cell(dx, quarter_w), cell(dy, quarter_h)) {
(0, 2) => Point::TopLeft,
(1, 2) => Point::Top,
(2, 2) => Point::TopRight,
(0, 1) => Point::Left,
(2, 1) => Point::Right,
(0, 0) => Point::BottomLeft,
(1, 0) => Point::Bottom,
(2, 0) => Point::BottomRight,
_ => Point::Center,
})
}
/// The drag entry `0x7652b0`: pin the frame (`0x7652c6` → `0x768430`) and, only when it has a rect
/// to pin (`0x7652cd`), raise it (`0x7652d7`), set userPlaced (`0x7652e5`) and take the slot
/// (`0x7652f3`–`0x76530f`) over whatever held it. False when the frame has no rect, so nothing
/// started.
fn begin_drag(model: &mut Model, h: FrameHandle, grip: Point, auto_stop: bool) -> bool {
let Some(repinned) = pin_top_left(model, h, (0.0, 0.0), (0.0, 0.0)) else {
return false;
};
// The worker applies the toplevel gate.
super::toplevel::raise(model, h);
// The layout cache is dirtied on the bit's transition only, since every title-region click
// runs this, and again when the pin rewrote what a saved row holds.
if let Some(f) = model.arena.frame_mut(h) {
if !f.user_placed {
f.user_placed = true;
model.user_placed_changed = true;
}
}
if repinned {
mark_user_placed_change(model, h);
}
model.moving = Some(FrameMove {
frame: h,
grip,
sample: model.cursor_pos,
auto_stop,
});
true
}
/// `0x768430` against the screen root. With the layout settled first (`0x768464`–`0x76846f`), read
/// the frame's rect (`0x76847a`), clear its anchors (`0x767620`), set one TOPLEFT on the screen's
/// TOPLEFT at the rect's top-left moved by `travel` (`0x767c70`), and give it an explicit size,
/// `size` per axis or the rect's where that is 0 (`0x7684e1`, `0x768508`). Offsets and size are in
/// the frame's own units, screen over its effective scale (`[G+0x58]`). None when the frame has no
/// rect (`0x768481`); else whether its anchors or size changed.
fn pin_top_left(
model: &mut Model,
h: FrameHandle,
size: (f32, f32),
travel: (f32, f32),
) -> Option<bool> {
UiScript::resolve_layout(model);
let r = *model.resolved.get(&h)?;
let root = model.screen;
let scale = f64::from(eff_scale(model, h));
let inv = 1.0 / scale;
let x = ((f64::from(r.left) + f64::from(travel.0)) - f64::from(root.left)) * inv;
let y = ((f64::from(r.top) + f64::from(travel.1)) - f64::from(root.top)) * inv;
let w = if size.0 == 0.0 {
((f64::from(r.right) - f64::from(r.left)) / scale) as f32
} else {
size.0
};
let ht = if size.1 == 0.0 {
((f64::from(r.top) - f64::from(r.bottom)) / scale) as f32
} else {
size.1
};
let pin = Anchors::from([Anchor::new(
Point::TopLeft,
SCREEN,
Point::TopLeft,
x as f32,
y as f32,
)]);
let input = model.layout_inputs.entry(h).or_default();
let changed = !input.anchors.bits_eq(&pin)
|| input.width.to_bits() != w.to_bits()
|| input.height.to_bits() != ht.to_bits();
input.anchors = pin;
input.width = w;
input.height = ht;
// The size setters and a closing `0x7680e0(1)` invalidate (`0x768533`).
model.invalidate_frame(h, changed);
// The size it now carries is explicit, as a `SetWidth`/`SetHeight` would leave it.
model.note_authored_size(h);
Some(changed)
}
/// A drag moved the frame: if it is user-placed, the layout cache owes its file a rewrite.
@@ -340,134 +429,173 @@ fn mark_user_placed_change(model: &mut Model, h: FrameHandle) {
}
}
/// Apply the `SetMinResize`/`SetMaxResize` bounds to a proposed size per axis, as `0x768710`
/// does: only an exact 0.0 is unbounded, min applies before max so max wins a crossed pair, and
/// with no bound there is no floor, so a size can go negative.
fn clamp_resize(model: &Model, h: FrameHandle, w: f32, hgt: f32) -> (f32, f32) {
/// The hover walk's move/size branch (`0x766164`–`0x766193`): while the drag slot holds a target,
/// the walk pumps it to `pos` (`0x7655b0`) and returns before any probe. True when one is in
/// flight, a target whose frame is gone dropped first.
pub(crate) fn pump_move_or_size(model: &mut Model, pos: (f32, f32)) -> bool {
let Some(mv) = model.moving else {
return false;
};
if model.arena.frame(mv.frame).is_none() {
model.moving = None;
return false;
}
let (dx, dy) = (pos.0 - mv.sample.0, pos.1 - mv.sample.1);
// A zero delta on both axes skips the step (`0x7655d6`–`0x7655f4`).
if dx != 0.0 || dy != 0.0 {
drag_step(model, mv, dx, dy);
}
// The sample takes the whole delta, whatever a bound kept of it (`0x765610`–`0x765628`).
model.moving = Some(FrameMove {
sample: (mv.sample.0 + dx, mv.sample.1 + dy),
..mv
});
true
}
/// A hide ends the move or size whose target is the frame going hidden: the hide's de-registration
/// (`0x76ad9d` → `0x764920`, flag 0) clears the slot when it holds the frame
/// (`0x76492e`–`0x76493a` → `0x765640`).
pub(crate) fn cancel_on_hide(model: &mut Model, h: FrameHandle) {
if model.moving.is_some_and(|mv| mv.frame == h) {
model.moving = None;
}
}
/// One pump step, `0x768710` by way of `0x76a660`, for the cursor delta `(dx, dy)` in screen units.
/// The grips read the explicit size (`vtbl+0x1c`/`+0x20`, `G+0x50`/`+0x54`), size it by the travel
/// over the frame's scale, clamp it, and re-pin TOPLEFT shifted by the left and top travel only
/// (`0x768974` → `0x768430`); a move adds to the TOPLEFT anchor itself.
fn drag_step(model: &mut Model, mv: FrameMove, dx: f32, dy: f32) {
let h = mv.frame;
let scale = f64::from(eff_scale(model, h));
let inv = 1.0 / scale;
let (mut w, mut ht) = model
.layout_inputs
.get(&h)
.map_or((0.0, 0.0), |i| (i.width, i.height));
// `G+0x60`/`+0x68` bound the width and `G+0x5c`/`+0x64` the height.
let (min, max) = model
.arena
.frame(h)
.map_or(((0.0, 0.0), (0.0, 0.0)), |f| (f.min_resize, f.max_resize));
let axis = |v: f32, lo: f32, hi: f32| {
let v = if lo != 0.0 && v < lo { lo } else { v };
if hi != 0.0 && v > hi {
hi
} else {
v
let (mut dx, mut dy) = (dx, dy);
let add = |v: f32, by: f64| (f64::from(v) + by) as f32;
let x = Axis::new(min.0, max.0, scale);
let y = Axis::new(min.1, max.1, scale);
match mv.grip {
// Case 4: the delta over the scale goes straight into the TOPLEFT anchor's offsets
// (`0x768c15`–`0x768c2e`); with no TOPLEFT anchor it re-pins at the whole delta instead
// (`0x768c00`, `0x768c0f`).
Point::Center => {
let moved = model
.layout_inputs
.get_mut(&h)
.and_then(|i| i.anchors.offset_mut(Point::TopLeft))
.map(|(x_off, y_off)| {
*x_off = add(*x_off, f64::from(dx) / scale);
*y_off = add(*y_off, f64::from(dy) / scale);
})
.is_some();
if moved {
// The offsets move and the frame is invalidated (`0x768c31`).
model.invalidate_frame(h, true);
mark_user_placed_change(model, h);
return;
}
}
};
(axis(w, min.0, max.0), axis(hgt, min.1, max.1))
Point::TopLeft => {
w = add(w, -f64::from(dx) * inv);
x.clamp_near(&mut w, &mut dx, -1.0);
ht = add(ht, f64::from(dy) * inv);
y.clamp_near(&mut ht, &mut dy, 1.0);
}
Point::Top => {
ht = add(ht, f64::from(dy) / scale);
y.clamp_near(&mut ht, &mut dy, 1.0);
dx = 0.0;
}
Point::TopRight => {
w = add(w, f64::from(dx) * inv);
x.clamp_far(&mut w);
dx = 0.0;
ht = add(ht, f64::from(dy) * inv);
y.clamp_near(&mut ht, &mut dy, 1.0);
}
Point::Left => {
w = add(w, -f64::from(dx) / scale);
x.clamp_near(&mut w, &mut dx, -1.0);
dy = 0.0;
}
Point::Right => {
w = add(w, f64::from(dx) / scale);
x.clamp_far(&mut w);
(dx, dy) = (0.0, 0.0);
}
Point::BottomLeft => {
w = add(w, -f64::from(dx) * inv);
x.clamp_near(&mut w, &mut dx, -1.0);
ht = add(ht, -f64::from(dy) * inv);
y.clamp_far(&mut ht);
dy = 0.0;
}
Point::Bottom => {
ht = add(ht, -f64::from(dy) / scale);
y.clamp_far(&mut ht);
(dx, dy) = (0.0, 0.0);
}
Point::BottomRight => {
w = add(w, f64::from(dx) * inv);
x.clamp_far(&mut w);
ht = add(ht, -f64::from(dy) * inv);
y.clamp_far(&mut ht);
(dx, dy) = (0.0, 0.0);
}
}
if pin_top_left(model, h, (w, ht), (dx, dy)) == Some(true) {
mark_user_placed_change(model, h);
}
}
/// Pump an in-flight `StartSizing`: the gripped edges follow the cursor, the opposite ones stay.
pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
let Some(sz) = model.sizing else { return };
if model.arena.frame(sz.frame).is_none() {
model.sizing = None;
return;
}
let (dx, dy) = (pos.0 - sz.sample.0, pos.1 - sz.sample.1);
if dx == 0.0 && dy == 0.0 {
return;
}
let inv = 1.0 / eff_scale(model, sz.frame);
let (dx, dy) = (dx * inv, dy * inv);
let dw = if sz.right {
dx
} else if sz.left {
-dx
} else {
0.0
};
let dh = if sz.top {
dy
} else if sz.bottom {
-dy
} else {
0.0
};
let Some((w0, h0)) = model
.layout_inputs
.get(&sz.frame)
.map(|i| (i.width, i.height))
else {
return;
};
// The resize bounds are read only here, as only `0x76a660` reads them in the reference.
let (new_w, new_h) = clamp_resize(model, sz.frame, w0 + dw, h0 + dh);
// The rebate (`0x768770`/`0x7687a2`): the residual a clamp swallowed comes back out of the
// drag delta, so the moving edge stops at the bound instead of the window walking. The delta
// is already in local units, and an ungripped axis is not rebated.
let rebate = |d: f32, want: f32, got: f32, grew: bool, gripped: bool| {
if !gripped {
return d;
}
let residual = got - want;
if grew {
d + residual
} else {
d - residual
}
};
let dx = rebate(dx, w0 + dw, new_w, sz.right, sz.left || sz.right);
let dy = rebate(dy, h0 + dh, new_h, sz.top, sz.top || sz.bottom);
let Some(input) = model.layout_inputs.get_mut(&sz.frame) else {
return;
};
input.width = new_w;
input.height = new_h;
let mut moved = input.width.to_bits() != w0.to_bits() || input.height.to_bits() != h0.to_bits();
// A LEFT or BOTTOM grip also shifts the anchors on its axis, so a frame anchored by that side
// keeps its opposite edge. The reference instead re-anchors the frame to one TOPLEFT point at
// `StartSizing` (`0x768430`).
if !input.anchors.is_empty() && (sz.left || sz.bottom) {
for a in &mut input.anchors {
if sz.left {
a.x_off += dx;
}
if sz.bottom {
a.y_off += dy;
}
}
moved |= (sz.left && dx != 0.0) || (sz.bottom && dy != 0.0);
}
// A nonzero cursor delta can still move nothing (a grip dragged across its axis, or held at a
// bound), so the layout is touched only on a real change.
if moved {
// No anchor is retargeted, so only this node is touched.
model.touch_layout_frame(sz.frame);
mark_user_placed_change(model, sz.frame);
}
model.sizing = Some(FrameSizing { sample: pos, ..sz });
/// One axis's `SetMinResize`/`SetMaxResize` pair, as `0x768710` applies it: only an exact 0.0 is
/// unbounded, a size under the minimum or over the maximum takes the bound, min before max so max
/// wins a crossed pair, and with no bound there is no floor, so a size can go negative.
struct Axis {
min: f32,
max: f32,
scale: f64,
}
/// Pump an in-flight move to the cursor at `pos` (`0x7655b0`). Called from
/// [`crate::script::UiScript::mouse_move`] before its hover-boundary early return, since a frame
/// dragged under the cursor crosses no boundary.
pub(crate) fn advance_move(model: &mut Model, pos: (f32, f32)) {
let Some(mv) = model.moving else { return };
if model.arena.frame(mv.frame).is_none() {
model.moving = None;
return;
impl Axis {
fn new(min: f32, max: f32, scale: f64) -> Axis {
Axis { min, max, scale }
}
let (dx, dy) = (pos.0 - mv.sample.0, pos.1 - mv.sample.1);
if dx == 0.0 && dy == 0.0 {
return;
/// Clamp a size whose far edge moves (the right or the bottom), which the pin does not follow.
fn clamp_far(&self, v: &mut f32) {
if self.min != 0.0 && *v < self.min {
*v = self.min;
}
if self.max != 0.0 && *v > self.max {
*v = self.max;
}
}
// Anchor offsets are in the frame's own units, so the screen delta divides by its scale, as
// `0x768710`'s case 4 does.
let inv = 1.0 / eff_scale(model, mv.frame);
let (dx, dy) = (dx * inv, dy * inv);
if let Some(input) = model.layout_inputs.get_mut(&mv.frame) {
if !input.anchors.is_empty() {
for a in &mut input.anchors {
a.x_off += dx;
a.y_off += dy;
}
// No target moves, so only this node is touched; a zero delta already returned above.
model.touch_layout_frame(mv.frame);
mark_user_placed_change(model, mv.frame);
/// Clamp a size whose anchored edge moves (the left or the top), taking the residual back out
/// of that edge's travel so it stops on the bound, as TOPLEFT's case does at `0x768770`,
/// `0x7687a2`, `0x7687dd` and `0x768817`. `grows` is the sign the travel sizes the frame with:
/// +1 for the top edge, -1 for the left.
fn clamp_near(&self, v: &mut f32, travel: &mut f32, grows: f64) {
let mut seat = |v: &mut f32, b: f32| {
*travel =
(f64::from(*travel) + grows * (f64::from(b) - f64::from(*v)) * self.scale) as f32;
*v = b;
};
if self.min != 0.0 && *v < self.min {
seat(v, self.min);
}
if self.max != 0.0 && *v > self.max {
seat(v, self.max);
}
}
model.moving = Some(FrameMove { sample: pos, ..mv });
}
+15 -12
View File
@@ -6,15 +6,16 @@
//! 1. Walks up `+0x9c` to the nearest toplevel self-or-ancestor; with none it does nothing.
//! 2. With `force`, resolves the frame's layout and tests whether it is overlapped: a visible frame
//! of its stratum at its level or above, neither itself nor a descendant, meets its rect.
//! 3. Only then compacts the stratum's levels, hidden frames included (`0x764eb0`), and sets its
//! level to the top plus one, moving same-stratum children by the same delta (`0x76a4f0`).
//! 3. Only then compacts the stratum's levels below its high-water bound, hidden frames among
//! them (`0x764eb0`), and sets its level to the bound, moving same-stratum children by the same
//! delta (`0x76a4f0`).
//!
//! The stratum never changes. The overlapped bit is recomputed per raise, never stored: only the
//! `force = 0` trigger (`0x764a4c`) reads a stored bit, and it is not wired, so neither is the
//! per-tick pass that keeps it (`0x7657d0`). The wired triggers are `Raise` (`0x775b0a`), Show
//! (`0x76aeec`), drag start (`0x7652d7`) and mouse-down (`0x766392`).
use mlua::{Lua, Table, Value};
use mlua::{Lua, MultiValue, Table};
use crate::layout::Rect;
use crate::script::{Model, UiScript};
@@ -24,14 +25,12 @@ use super::frame_handle_of;
/// Populate `m`'s toplevel and raise methods.
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// SetToplevel's argument is optional and defaults to true (`0x775440`), unlike `SetMovable`'s.
// SetToplevel's argument goes through `GetBoolOrDefault` (`0x6f1c10`) with default 1
// (`0x775440`, `0x7754ce`): a bare call sets it, an explicit nil clears it.
m.set(
"SetToplevel",
lua.create_function(|lua, (this, flag): (Table, Option<Value>)| {
let on = match flag {
None => true,
Some(v) => !matches!(v, Value::Nil | Value::Boolean(false)),
};
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let on = crate::script::binding_abi::bool_or_default(args.front(), true);
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
@@ -79,7 +78,7 @@ pub(in crate::script) fn raise(model: &mut Model, frame: FrameHandle) -> bool {
if !overlapped(model, t) {
return true;
}
// 3. The raise: compact the stratum's occupied levels, then land one above the top.
// 3. The raise: compact the occupied levels below the stratum's bound, then land on the bound.
let Some(strata) = model.arena.frame(t).map(|f| f.strata) else {
return true;
};
@@ -96,8 +95,12 @@ pub(in crate::script) fn raise_on_show(model: &mut Model, h: FrameHandle) {
}
}
/// Step 1: the first frame up `+0x9c` with the toplevel bit (`0x7650fb..0x765135`).
fn nearest_toplevel(model: &Model, frame: FrameHandle) -> Option<FrameHandle> {
/// Step 1: the first frame up `+0x9c` with the toplevel bit (`0x7650fb..0x765135`), the walk the
/// layout drag's lift repeats (`0x766fe0`).
pub(in crate::script) fn nearest_toplevel(
model: &Model,
frame: FrameHandle,
) -> Option<FrameHandle> {
// A chain longer than the frame count is a cycle, which `SetParent` already refuses.
let bound = model.arena.iter_frames().count();
let mut cur = Some(frame);
+8 -8
View File
@@ -625,12 +625,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
})?,
)?;
// ConfirmReadyCheck(ready) reads its argument as truth: the reference's No button passes no
// argument, Yes passes `1`.
// ConfirmReadyCheck(ready) (`0x4bc090`) reads its argument through `GetBoolOrDefault`
// (`0x4bc0ad`) with default 0: the reference's No button passes no argument, Yes passes `1`.
g.set(
"ConfirmReadyCheck",
lua.create_function(|lua, ready: Value| {
let ready = !matches!(ready, Value::Nil | Value::Boolean(false));
lua.create_function(|lua, ready: Option<Value>| {
let ready = crate::script::binding_abi::bool_or_default(ready.as_ref(), false);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.party_requests
@@ -648,7 +648,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// GetNumSavedInstances() / GetSavedInstanceInfo(index), 1-based over the pushed list; a miss
// returns nothing.
// pushes nil, 0, nil (`0x4a19e9`).
g.set(
"GetNumSavedInstances",
lua.create_function(|lua, ()| {
@@ -673,7 +673,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Value::Integer(i64::from(r.instance)),
Value::Integer(i64::from(r.reset)),
]),
None => MultiValue::new(),
None => super::binding_abi::tuple(&[None, Some(0), None]),
})
})?,
)?;
@@ -1339,8 +1339,8 @@ mod tests {
PartyRequest::ReadyCheckAnswer(true),
PartyRequest::ReadyCheckAnswer(false),
PartyRequest::ReadyCheckAnswer(false),
// `0` is true in Lua.
PartyRequest::ReadyCheckAnswer(true),
// `0` truncates to 0 (`GetBoolOrDefault`, `0x4bc0ad`), where Lua calls it true.
PartyRequest::ReadyCheckAnswer(false),
]
);
}
+6 -5
View File
@@ -8,6 +8,7 @@
use mlua::{Lua, MultiValue, Value};
use super::Model;
use crate::script::binding_abi::Idx32;
// The slot-word values the one-shot orders synthesize, restated from
// `benilla_protocol::messages::pet` because this crate does not depend on the protocol.
@@ -285,13 +286,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// An out-of-range index answers a single nil, which the Lua reads as an empty slot.
// An out-of-range index answers seven nils (`0x4bdf3e`), which the Lua reads as an empty slot.
g.set(
"GetPetActionInfo",
lua.create_function(move |lua, i: u32| {
lua.create_function(move |lua, Idx32(i): Idx32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(slot) = slot_at(&model, i) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
return Ok(super::binding_abi::tuple(&[None; 7]));
};
let v = &slot.view;
let text = |s: &Option<String>| match s {
@@ -313,7 +314,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// An elapsed or absent cooldown answers (0, 0, 1), so a re-feed never replays the sweep.
g.set(
"GetPetActionCooldown",
lua.create_function(|lua, i: u32| {
lua.create_function(|lua, Idx32(i): Idx32| {
let now = crate::script::clock::now(lua);
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match slot_at(&model, i).and_then(|s| s.cooldown) {
@@ -328,7 +329,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// The one pet binding that answers a real boolean (`0x6f39f0`), `false` even out of range.
g.set(
"IsPetAttackActive",
lua.create_function(move |lua, i: u32| {
lua.create_function(move |lua, Idx32(i): Idx32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(slot_at(&model, i).is_some_and(|s| s.view.attack_active))
})?,
+375 -266
View File
@@ -2,10 +2,13 @@
//! turn host pointer events into FrameScript handler calls. [`cursor`] owns the payload and
//! gesture state; this file dispatches.
//!
//! A frame captures the cursor when it is mouse-enabled and effective-visible and its rect and
//! effective ScrollFrame clip hold the point (the reference's probe `0x76b020`), taking the first
//! in the order of the hover walk (`0x7661cd` in `0x7660d0`), which [`crate::order::hit_test`]
//! implements.
//! A frame captures the cursor when it is in the mouse index, which `0x76af00` enters it into
//! while it has the mouse bit (`[+0xcc] & 4`) and is visible (`[+0xd4]`), and the probe `0x76b020`
//! takes the point: its rect is valid, and its hit rect and effective ScrollFrame clip hold the
//! point. The first such frame in the order of the hover walk (`0x7661cd` in `0x7660d0`) wins,
//! which [`crate::order::hit_test`] implements.
use std::collections::HashMap;
use mlua::Value;
@@ -56,6 +59,19 @@ pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
Ok(())
}
/// The frame a release of `button` goes to: the release handler reads only the capture
/// (`0x76642b`), which `mouse_down_on` keeps per button as the press that set it, `None` once that
/// frame hid. A button with no press of its own, one a title or layout press took, releases onto
/// whatever capture is held.
fn release_target(model: &Model, button: &str) -> Option<FrameHandle> {
match model.mouse_down_on.get(button) {
Some(&pressed) => pressed,
None => model
.mouse_capture
.filter(|&c| model.arena.frame(c).is_some()),
}
}
/// Whether `h` is the world frame, whose mouse hits belong to the 3D world; it wins the hover walk
/// only when nothing else mouse-enabled is under the cursor.
fn is_world_handle(model: &Model, h: FrameHandle) -> bool {
@@ -65,21 +81,20 @@ fn is_world_handle(model: &Model, h: FrameHandle) -> bool {
.is_some_and(|f| f.kind == crate::widget::FrameKind::WorldFrame)
}
/// Whether `h` is a `Minimap`, whose probe drops its blip on a miss (`0x4eb0c0`).
fn is_minimap_handle(model: &Model, h: FrameHandle) -> bool {
model
.arena
.frame(h)
.is_some_and(|f| f.kind == crate::widget::FrameKind::Minimap)
}
/// Whether a press or release target is the world: the world frame, the reference's world click
/// target, or no frame where `WorldFrame.xml` is not loaded (most tests).
fn over_world(model: &Model, target: Option<FrameHandle>) -> bool {
target.is_none_or(|h| is_world_handle(model, h))
}
/// Whether `(x, y)` falls inside one of this frame's hyperlink spans, the engine half of the
/// reference's per-span `CSimpleHyperlinkButton` children.
fn link_span_hit(model: &Model, fh: FrameHandle, x: f32, y: f32) -> bool {
model
.link_spans
.get(&fh)
.is_some_and(|spans| spans.iter().any(|(r, _, _)| point_in_rect(*r, x, y)))
}
impl UiScript {
/// Whether a frame id names the world frame: hit-tested like any frame, so scripts on it fire,
/// but not counted by the app as over the UI.
@@ -91,55 +106,46 @@ impl UiScript {
.is_some_and(|h| is_world_handle(&model, *h))
}
/// The hyperlink span containing `(x, y)` on the frame `id`, as `(frame, link, markup)`.
fn span_at(&self, id: u32, x: f32, y: f32) -> Option<(FrameHandle, String, String)> {
let model = self.model_ref();
let fh = model.id_to_frame.get(&id).copied()?;
model
.link_spans
.get(&fh)?
.iter()
.find(|(r, _, _)| point_in_rect(*r, x, y))
.map(|(_, link, markup)| (fh, link.clone(), markup.clone()))
}
/// Fire a hyperlink script with `(link, markup)`, recording a handler error.
fn fire_hyperlink(&mut self, id: u32, script: &str, link: &str, markup: &str) {
let (Ok(l), Ok(m)) = (self.lua.create_string(link), self.lua.create_string(markup)) else {
return;
};
if let Err(e) = event::fire_widget_handler(
&self.lua,
id,
script,
vec![Value::String(l), Value::String(m)],
) {
self.push_error(e);
}
}
/// The handle of the frame that captures `(x, y)`, for a caller that checks it against a
/// handle set taken earlier (an anonymous frame has no name). Needs [`Self::resolve`] first.
pub fn hit_test_frame(&self, x: f32, y: f32) -> Option<FrameHandle> {
self.hover_walk(x, y).0
}
/// The hover walk's probes: the capture frame's first (`[root+0x80]`, `0x7661ba`–`0x7661ca`),
/// unconditionally and its answer discarded, then the sweep (`0x7661cd`–`0x766213`). Returns the
/// first frame whose probe takes the point, and whether a `Minimap` was probed and missed it,
/// which then drops its blip (`0x4eb0c0`). A frame that takes the hit ends the sweep, so a
/// window over the Minimap keeps it from the sweep's probe; a held capture is probed anyway.
fn hover_walk(&self, x: f32, y: f32) -> (Option<FrameHandle>, bool) {
let model = self.model_ref();
let sorted = order::traversal(&model.arena);
let scroll_sources = scroll_clip_sources(&model);
order::hit_test(&sorted, |fh| {
// Mouse-enabled, or over one of the frame's hyperlink spans. The chat frame takes no
// mouse (`[+0xcc] = 0`); the reference gives each span a mouse-enabled
// `CSimpleHyperlinkButton` child (`0x7a3240`) that routes its scripts to the parent, so
// a chat window hits only over a link and drags by its tab, not its body.
(model.arena.is_mouse_enabled(fh) || link_span_hit(&model, fh, x, y))
// The nameplate's `+0x3c` veto (`0x7cba30`), not its mouse-enabled bit: while a
// ground-targeted spell is armed a plate refuses the hit before its rect is
// tested, so the reticle is placed through it.
let blip_held = model.minimap_blip.is_some();
let minimap_missed = std::cell::Cell::new(
blip_held
&& model.mouse_capture.is_some_and(|c| {
is_minimap_handle(&model, c)
&& !frame_contains(&model, &scroll_sources, c, x, y)
}),
);
let hit = order::hit_test(&sorted, |fh| {
// In the mouse index. The chat frame takes no mouse (`[+0xcc] = 0`): its links are
// Button children of their own ([`super::hyperlink`]), so a chat window hits only over
// a link and drags by its tab, not its body.
let indexed = model.arena.is_mouse_enabled(fh);
// The nameplate's `+0x3c` veto (`0x7cba30`), not its mouse-enabled bit: while a
// ground-targeted spell is armed a plate refuses the hit before its rect is tested,
// so the reticle is placed through it.
let hit = indexed
&& !model.nameplates.vetoes(fh)
&& model.resolved.get(&fh).is_some_and(|r| {
point_in_rect(hit_rect(&model, fh, *r), x, y)
})
&& effective_clip(&model, &scroll_sources, fh)
.is_none_or(|c| point_in_rect(c, x, y))
})
&& frame_contains(&model, &scroll_sources, fh, x, y);
if blip_held && indexed && !hit && is_minimap_handle(&model, fh) {
minimap_missed.set(true);
}
hit
});
(hit, minimap_missed.get())
}
/// [`Self::hit_test`] over the frames that take the wheel: the same rect and clip rules, gated
@@ -176,45 +182,56 @@ impl UiScript {
model.arena.frame(fh)?.name.clone()
}
/// Move the cursor to `(x, y)`. When the hovered frame changes, fires `OnLeave(self, true)` on
/// the old one if still live and `OnEnter(self, true)` on the new; a disabled Button takes the
/// hover but fires neither ([`button::hover_notify_runs`]). Drags and frame moves advance
/// first. Returns the hovered frame id.
/// Move the cursor to `(x, y)`. When the hovered frame changes, fires `OnLeave` on the old one
/// if still live and `OnEnter` on the new, both with the arg globals untouched; a disabled
/// Button takes the hover but fires neither ([`button::hover_notify_runs`]). A frame move or
/// size in flight takes the move instead, and nothing else runs. Returns the hovered frame id.
pub fn mouse_move(&mut self, x: f32, y: f32) -> Option<u32> {
{
let mut model = self.model_mut();
// The walk saves the event before its move/size check (`0x76615f`), so the re-pick's
// position follows the cursor through a drag.
model.cursor_pos = (x, y);
// A move or size in flight is pumped and the walk returns (`0x766164`–`0x7661a0`)
// before any probe (`vtbl+0x3c`): no hit test, no drag start (`0x76b320`), no
// EditBox, slider or colour thumb drag (`0x77a860`, `0x789ba0`, `0x78bd80`), no
// Minimap probe (`0x4eb0c0`), and the hover holds until the drop.
if super::object::pump_move_or_size(&mut model, (x, y)) {
return model
.mouseover
.and_then(|h| model.frame_to_id.get(&h).copied());
}
}
// A drag-active EditBox extends its selection on every move (`0x77a860`).
editbox::drag_update(&self.lua, x, y);
let new_id = self.hit_test(x, y);
let (new_handle, minimap_missed) = self.hover_walk(x, y);
let new_id = new_handle.and_then(|h| self.model_ref().frame_to_id.get(&h).copied());
// The Minimap's probe runs in the sweep, ahead of the leave and the enter.
if minimap_missed {
super::mouseover::minimap_probe_miss(&self.lua);
}
#[allow(clippy::type_complexity)]
let (leave_id, drag_start, enter_id, slider_change, color_change): (
Option<u32>,
let (leave, drag_start, enter_id, slider_change, color_change): (
Option<(FrameHandle, Option<u32>)>,
Option<(u32, String)>,
Option<u32>,
Option<(u32, f32)>,
Option<(u32, f64, f64, f64)>,
) = {
let mut model = self.model_mut();
model.cursor_pos = (x, y);
// Drags advance on every move, before the hover test: a drag within one frame crosses
// no hover boundary.
let slider_change = super::slider::drag_move(&mut model, x, y);
// The colour picker fires on every move, not only on a change: it has no change gate.
let color_change = super::colorselect::drag_move(&mut model, x, y);
super::object::advance_move(&mut model, (x, y));
super::object::advance_size(&mut model, (x, y));
let drag_start = cursor::maybe_start_drag(&mut model, (x, y));
let new_handle = new_id.and_then(|id| model.id_to_frame.get(&id).copied());
let new_handle = new_id.and(new_handle);
if new_handle == model.mouseover {
(None, drag_start, None, slider_change, color_change)
} else {
let old_id = model.mouseover.and_then(|h| {
model
.arena
.frame(h)
.is_some()
.then(|| model.frame_to_id.get(&h).copied())
.flatten()
});
let old_handle = model.mouseover;
// A frame destroyed since it took the hover gets no leave.
let old_handle = model.mouseover.filter(|&h| model.arena.frame(h).is_some());
let old_id = old_handle.and_then(|h| model.frame_to_id.get(&h).copied());
model.mouseover = new_handle;
// Crossing a button's boundary changes no button state: the enter/leave notifies
// (`0x779490`/`0x7794e0`) read `[+0x328]` only as a disabled guard, so a press
@@ -223,7 +240,8 @@ impl UiScript {
// is still the hover: the reference moves `[root+0x7c]` before either notify.
let old_id = old_id.filter(|_| button::hover_notify_runs(&model, old_handle));
let enter_id = new_id.filter(|_| button::hover_notify_runs(&model, new_handle));
(old_id, drag_start, enter_id, slider_change, color_change)
let leave = old_handle.map(|h| (h, old_id));
(leave, drag_start, enter_id, slider_change, color_change)
}
};
// A thumb drag fires `OnValueChanged`, outside the borrow.
@@ -254,36 +272,18 @@ impl UiScript {
self.push_error(e);
}
}
if let Some(oid) = leave_id {
if let Err(e) =
event::fire_widget_handler(&self.lua, oid, "OnLeave", vec![Value::Boolean(true)])
{
self.push_error(e);
}
// The old frame's leave, its class's engine half included ([`super::mouseover::leave`]),
// runs before the new frame's enter (`0x766229`, then `0x766239`).
if let Some((h, script)) = leave {
super::mouseover::leave(&self.lua, h, script);
}
if let Some(nid) = enter_id {
if let Err(e) =
event::fire_widget_handler(&self.lua, nid, "OnEnter", vec![Value::Boolean(true)])
{
self.push_error(e);
}
}
// Crossing into or out of a hyperlink span fires `OnHyperlinkEnter`/`OnHyperlinkLeave`
// `(link, text)` on the owning frame; a chat frame carries no `OnEnter` of its own but its
// spans do, and the leave must fire when the cursor drops onto bare text in the same frame.
let new_span = new_id.and_then(|id| self.span_at(id, x, y));
let old_span = std::mem::replace(&mut self.model_mut().hover_link, new_span.clone());
if old_span != new_span {
if let Some((fh, link, markup)) = old_span {
let id = self.model_ref().frame_to_id.get(&fh).copied();
if let Some(id) = id {
self.fire_hyperlink(id, "OnHyperlinkLeave", &link, &markup);
}
}
if let Some((fh, link, markup)) = new_span {
let id = self.model_ref().frame_to_id.get(&fh).copied();
if let Some(id) = id {
self.fire_hyperlink(id, "OnHyperlinkEnter", &link, &markup);
// A hyperlink button's enter is its text frame's `OnHyperlinkEnter` (`0x7a34b0`); the
// base enter's plain runner (`0x76b6e5` → `0x702690`) sets no arg global.
let routed = new_handle.is_some_and(|h| super::hyperlink::hover(&self.lua, h, true));
if !routed {
if let Err(e) = event::fire_widget_handler(&self.lua, nid, "OnEnter", Vec::new()) {
self.push_error(e);
}
}
}
@@ -302,46 +302,130 @@ impl UiScript {
.is_some_and(|r| point_in_rect(*r, x, y))
}
/// `0x765490`'s walk: strata FULLSCREEN_DIALOG down to WORLD, never TOOLTIP (the loop starts
/// below `root+0xcf4`, `0x7654a3`), each level high to low, and each level's list from its head
/// (the terminator's `next`, `[level+0x14]`) along each link's `next` (`+4`, `0x766aa0`). A
/// frame links at the tail (`0x7648c9` → `0x7668c0` → `0x766921`), so that is the oldest link
/// first, the hover sweep's order. Every shown frame is probed with `0x76b020` alone, whatever
/// its mouse bit. The first hit is lifted to its nearest toplevel self-or-ancestor
/// (`0x766fe0`); one with none gives up the rest of its stratum and the walk goes on in the
/// next below (`0x76554a` → `0x765532`).
fn layout_drag_target(&self, x: f32, y: f32) -> Option<FrameHandle> {
let model = self.model_ref();
let sorted = order::traversal(&model.arena);
let scroll_sources = scroll_clip_sources(&model);
let mut below = order::Strata::Tooltip;
loop {
let hit = order::hit_test(&sorted, |fh| {
model.arena.frame(fh).is_some_and(|f| f.strata < below)
&& frame_contains(&model, &scroll_sources, fh, x, y)
})?;
if let Some(t) = super::object::toplevel::nearest_toplevel(&model, hit) {
return Some(t);
}
below = model.arena.frame(hit)?.strata;
}
}
/// A mouse button press or release at `(x, y)`; `button` is the WoW name (`"LeftButton"`, …).
/// `OnClick` follows `RegisterForClicks` ([`button::wants_click`], default `{"LeftButtonUp"}`):
/// a press fires it for `"<Button>ButtonDown"`, a release on the pressed frame for
/// `"<Button>ButtonUp"`. Its `down` argument is not 1.12, whose firer (`0x779540`) passes only
/// the button name. Returns whether the UI consumed the event.
pub fn mouse_button(&mut self, x: f32, y: f32, button: &str, down: bool) -> bool {
// ── The layout drag, ahead of everything ─────────────────────────────────────────────────
//
// With `SetLayoutMode` on, a press with Control (`0x41f8f0(1)`) or Alt (`0x41f8f0(2)`) held
// goes to `0x765490` as drag mode 1, Control asking for a size, and the handler returns
// consumed whatever that started (`0x766301`–`0x76635b`): no raise, title-region test,
// capture or press slot.
if down {
let (_, ctrl, alt) = self.model_ref().modifiers;
if self.model_ref().layout_mode != 0 && (ctrl || alt) {
let target = self.layout_drag_target(x, y);
let mut model = self.model_mut();
model.cursor_pos = (x, y);
if let Some(h) = target {
crate::script::object::movable::start_layout_drag(&mut model, h, (x, y), ctrl);
}
return true;
}
}
let hit_id = self.hit_test(x, y);
// ── The press target: the capture, else the hover ──────────────────────────────────────
//
// The press handler `0x7662c0` takes `root+0x80` first (`0x76637f`) and the hover
// `root+0x7c` only when none is held (`0x766389`); everything after, the raise, the
// title-region test, the capture store and the press slot (`vt+0x68`: `OnMouseDown`, the
// drag arm, a Button's press art and Down click), runs on that one frame. So a second
// button pressed over another frame fires on the held one. `mouse_down_on` keeps the
// frame per button for the release.
//
// The hover is what the last walk left. Every press follows a move to its point, so that
// is the frame under it, except while a move or size holds the slot: the walk then pumps
// it and returns before its sweep (`0x766164`), and the hover stays the frame it was when
// the drag began.
let mut release_lost = false;
let press_target: Option<crate::widget::FrameHandle> = if down {
let mut model = self.model_mut();
// A press for a button still held means its release never came (a focus loss); the
// reference's capture never loses one. That frame gets the release edge so it does not
// stay pushed, and with no other button down the stale capture goes too, so this
// press is not aimed at it.
if let Some(entry) = model.mouse_down_on.remove(button) {
release_lost = true;
if let Some(stale) = entry {
button::edge(&mut model, stale, ButtonState::on_mouse_up);
}
}
if model.mouse_down_on.is_empty() {
model.mouse_capture = None;
}
let held = model
.mouse_capture
.filter(|&c| model.arena.frame(c).is_some());
let hover = if model.moving.is_some() {
model.mouseover.filter(|&h| model.arena.frame(h).is_some())
} else {
hit_id.and_then(|id| model.id_to_frame.get(&id).copied())
};
held.or(hover)
} else {
None
};
let press_id: Option<u32> =
press_target.and_then(|h| self.model_ref().frame_to_id.get(&h).copied());
// ── The raise, first ────────────────────────────────────────────────────────────────────
//
// The press handler `0x7662c0` raises the capture (`root+0x80`) else the hover frame
// (`root+0x7c`) through `0x76a5b0`, called unguarded at `0x766392` (the toplevel walk is
// inside `0x7650f0`), before the title-region test: a click on a child or a title bar
// brings its window forward, and a chorded press raises the window being dragged. The
// release handler `0x766420` never raises.
if down {
let target = self.model_ref().mouse_capture;
if let Some(t) = target.or_else(|| self.hit_test_frame(x, y)) {
let mut model = self.model_mut();
super::object::toplevel::raise(&mut model, t);
}
// `0x76a5b0` is called unguarded at `0x766392` (the toplevel walk is inside `0x7650f0`),
// before the title-region test: a click on a child or a title bar brings its window
// forward, and a chorded press raises the window being dragged. The release handler
// `0x766420` never raises.
if let Some(t) = press_target {
let mut model = self.model_mut();
super::object::toplevel::raise(&mut model, t);
}
// ── The title region swallows the press ─────────────────────────────────────────────────
//
// A press inside the title region starts a mode-2 move and ends there: `0x7662c0` tests
// the region first and only a miss reaches `0x7663e6`, the capture and `OnMouseDown`. So
// no `OnMouseDown`, drag arm, `OnClick` or double-click stamp, and the event is consumed.
if down {
if let Some(h) = self.hit_test_frame(x, y) {
if self.title_region_hit(h, x, y) {
self.model_mut().cursor_pos = (x, y);
crate::script::object::movable::start_title_move(&mut self.model_mut(), h);
return true;
}
// A press inside the target's title region starts a mode-2 move and ends there:
// `0x7662c0` tests the region before the capture (`0x766397`–`0x7663e5`) and only a miss
// reaches `0x7663e6`, the capture and `OnMouseDown`. So no `OnMouseDown`, drag arm,
// `OnClick` or double-click stamp, and the event is consumed.
if let Some(h) = press_target {
if self.title_region_hit(h, x, y) {
self.model_mut().cursor_pos = (x, y);
crate::script::object::movable::start_title_move(&mut self.model_mut(), h);
return true;
}
}
// ── The release ends a title move ───────────────────────────────────────────────────────
// ── The release ends a title move, and that is all it does ─────────────────────────────
//
// `0x766420` cancels move modes 1 (modifier drag) and 2 (title region) on release and
// leaves mode 3 (`StartMoving`) to the addon's `StopMovingOrSizing`;
// `FrameMove::auto_stop` is that distinction.
// `0x766420` tests the drag mode before anything else (`0x76646d`–`0x76647c`): any release
// during mode 1 (modifier drag) or 2 (title region) clears the slot (`0x765640`) and
// returns consumed, with no `OnMouseUp`, drag stop or click on the capture, which it
// leaves held for a later release. Mode 3 (`StartMoving`) is left to the addon's
// `StopMovingOrSizing`; `FrameMove::auto_stop` is that distinction. The button is up all
// the same, so its own press entry goes.
if !down
&& self
.model_ref()
@@ -349,17 +433,17 @@ impl UiScript {
.as_ref()
.is_some_and(|m| m.auto_stop)
{
self.model_mut().moving = None;
let mut model = self.model_mut();
model.moving = None;
model.mouse_down_on.remove(button);
return true;
}
// The press this release belongs to, read before the borrow below drains `mouse_down_on`.
// The frame this release goes to, read before the borrow below drains `mouse_down_on`.
let captured_id: Option<u32> = if down {
None
} else {
let model = self.model_ref();
model
.mouse_down_on
.get(button)
.and_then(|h| model.frame_to_id.get(h).copied())
release_target(&model, button).and_then(|h| model.frame_to_id.get(&h).copied())
};
// The `GetTime()` clock for the double-click detector, read before the borrow.
let now = self.now();
@@ -371,13 +455,14 @@ impl UiScript {
return hit_id.is_some();
}
};
let mut press_art: Option<crate::widget::FrameHandle> = None;
#[allow(clippy::type_complexity)]
let (
click_id,
drag_release,
world_dropped,
slider_jump,
double_id,
released,
abandoned,
color_jump,
): (
@@ -385,57 +470,59 @@ impl UiScript {
Option<cursor::DragRelease>,
bool,
Option<(u32, f32)>,
Option<u32>,
Option<crate::widget::FrameHandle>,
Option<crate::widget::FrameHandle>,
Option<(u32, f64, f64, f64)>,
) = {
let mut model = self.model_mut();
let hit_handle = hit_id.and_then(|id| model.id_to_frame.get(&id).copied());
if down {
let displaced = match hit_handle {
Some(h) => model.mouse_down_on.insert(button.to_string(), h),
match press_target {
Some(h) => model.mouse_down_on.insert(button.to_string(), Some(h)),
None => model.mouse_down_on.remove(button),
};
// The press edge, `SetButtonState(PUSHED)` (`0x7792b1`), for a button registered
// for this mouse button up or down (`0x779210` tests `m | m << 8` at `0x77924b`).
if let Some(h) = hit_handle.filter(|&h| button::wants_press_visual(&model, h, button))
{
button::edge(&mut model, h, ButtonState::on_mouse_down);
}
// A press while this mouse button still holds another frame means a release was
// lost (a focus loss); that frame gets it so it does not stay pushed. The
// reference's capture never loses one.
if let Some(h) = displaced {
button::edge(&mut model, h, ButtonState::on_mouse_up);
}
// `0x7663e6` sets the capture `root+0x80` only when none is held: a second button
// pressed elsewhere does not displace it.
model.mouse_capture = model.mouse_capture.or(hit_handle);
// A press replaces any in-flight gesture; a started one gets its `OnDragStop`
// The Button press slot `0x779210` runs both legs only with the cursor inside the
// button (`0x77925c`) and the mouse button registered for it (`0x77924b` tests
// `m | m << 8`): the Down click for `…ButtonDown`, then the press edge,
// `SetButtonState(PUSHED)` (`0x7792b1`), for either registration, applied below
// after the scripts as the slot orders them.
let sources = scroll_clip_sources(&model);
let inside = press_target.filter(|&h| {
button::wants_press_visual(&model, h, button)
&& frame_contains(&model, &sources, h, x, y)
});
press_art = inside;
// `0x7663e9` stores the target as the capture: the one held, else the hover.
model.mouse_capture = press_target;
// The drag arm `0x76bf70`; a started gesture it displaces gets its `OnDragStop`
// below, outside this borrow.
let abandoned = cursor::abandon_drag(&mut model);
cursor::arm_drag(&mut model, hit_handle, button, (x, y));
let abandoned =
cursor::press_drag(&mut model, press_target, button, (x, y), release_lost);
// A left press on a Slider grabs the thumb, or on the track seats the thumb under
// the cursor first (the returned value jump).
let jump = if button == "LeftButton" {
super::slider::begin_drag(&mut model, hit_handle, x, y)
super::slider::begin_drag(&mut model, press_target, x, y)
} else {
None
};
// A left press in a ColorSelect's wheel or value strip also jumps the colour to the
// click point: its press handler calls its cursor handler (`0x78bf70`).
let color_jump = if button == "LeftButton" {
super::colorselect::begin_drag(&mut model, hit_handle, x, y)
super::colorselect::begin_drag(&mut model, press_target, x, y)
} else {
None
};
let wants = format!("{button}Down");
let click = hit_handle
let click = inside
.filter(|&h| button::wants_click(&model, h, &wants))
.and(hit_id);
.and(press_id);
(click, None, false, jump, None, abandoned, color_jump)
} else {
let pressed = model.mouse_down_on.remove(button);
let pressed = release_target(&model, button);
let entry = model.mouse_down_on.remove(button);
// The pressed frame hid (`None` entry): the capture is gone, so nothing of this
// release runs, the world click included (`over_world(None)` would take it).
let capture_lost = entry == Some(None);
// `0x7664bb` clears the capture only when no button is still held, so a chorded
// release keeps it.
if model.mouse_down_on.is_empty() {
@@ -445,18 +532,8 @@ impl UiScript {
super::slider::end_drag(&mut model);
super::colorselect::end_drag(&mut model);
}
let same_frame = matches!(
(pressed, hit_handle),
(Some(p), Some(released)) if p == released
);
let release = cursor::take_drag(&mut model, button);
let started = release.as_ref().is_some_and(|r| r.started);
// The release edge, `SetButtonState(NORMAL)` (`0x7793de`), on the pressed frame
// wherever the cursor is, skipped after a started drag (`0x7792df` tests
// `0x76c040`), which already un-pressed it.
if let (Some(h), false) = (pressed, started) {
button::edge(&mut model, h, ButtonState::on_mouse_up);
}
// The world drop: a completed left click on the world, never a drag release, which
// keeps carrying (`0x495300` runs on the WorldFrame click release). The world is
// `over_world`, not "no frame": the stock `WorldFrame` covers the screen.
@@ -464,43 +541,11 @@ impl UiScript {
&& !started
&& over_world(&model, hit_handle)
&& over_world(&model, pressed)
&& !capture_lost
&& cursor::world_drop_click(&mut model);
let wants = format!("{button}Up");
let click = if started {
None
} else {
hit_handle
.filter(|&h| same_frame && button::wants_click(&model, h, &wants))
.and(hit_id)
};
// The double click, on the release that qualified as a click: `0x77938d` in the
// mouse-up dispatcher `0x7792d0` fires `OnDoubleClick` instead of the second
// `OnClick` (`0x77939d jmp 0x7793b5` skips the single leg), only for a frame with
// an `OnDoubleClick` script (`[+0x4d4] != 0`). What suppresses the click
// suppresses it too.
let target = click.and(hit_handle);
let double = target.filter(|&h| {
model
.scripts
.get(&h)
.is_some_and(|s| s.contains("OnDoubleClick"))
&& model
.last_click
.get(&h)
.is_some_and(|&t| now - t <= DOUBLE_CLICK_SECS)
});
if let Some(h) = double {
// A double clears the stamp (`0x779393`), so clicks pair: four fast clicks are
// click, double, click, double.
model.last_click.remove(&h);
} else if let Some(h) = target {
// A single stamps the detector (`0x7793af`) whether or not an `OnClick` script
// is bound.
model.last_click.insert(h, now);
}
let double_id = double.and_then(|h| model.frame_to_id.get(&h).copied());
let click = if double_id.is_some() { None } else { click };
(click, release, dropped, None, double_id, None, None)
// The Button release slot `0x7792d0` (below, after `OnMouseUp`) does nothing on a
// started drag (`0x7792df` tests `0x76c040`), which already un-pressed the button.
(None, release, dropped, None, pressed.filter(|_| !started), None, None)
}
};
// A track press's value jump fires `OnValueChanged` first, outside the borrow.
@@ -525,54 +570,42 @@ impl UiScript {
// the hover frame, so a press on A released over B fires `OnMouseUp` on A, and a press
// that captured nothing fires nothing (`0x766498`). A resize grip dragged out from under
// the cursor still gets its `OnMouseUp`, so its resize ends.
let script = if down { "OnMouseDown" } else { "OnMouseUp" };
if let Some(id) = if down { hit_id } else { captured_id } {
if let Err(e) = event::fire_widget_handler(&self.lua, id, script, vec![btn.clone()]) {
self.push_error(e);
}
}
// A release in a hyperlink span fires `OnHyperlinkClick(link, markup, button)` on the
// owning frame, except after a started drag.
if !down && !drag_release.as_ref().is_some_and(|r| r.started) {
if let Some(id) = hit_id {
let span = {
let model = self.model_ref();
model
.id_to_frame
.get(&id)
.and_then(|fh| model.link_spans.get(fh))
.and_then(|spans| {
spans
.iter()
.find(|(r, _, _)| point_in_rect(*r, x, y))
.map(|(_, l, m)| (l.clone(), m.clone()))
})
};
if let Some((link, markup)) = span {
let args = match (
self.lua.create_string(&link),
self.lua.create_string(&markup),
) {
(Ok(l), Ok(m)) => {
vec![Value::String(l), Value::String(m), btn.clone()]
}
_ => Vec::new(),
};
if !args.is_empty() {
if let Err(e) =
event::fire_widget_handler(&self.lua, id, "OnHyperlinkClick", args)
{
self.push_error(e);
}
}
//
// The frame's mouse-up `0x76c040` runs the drag leg first: a release of a button the frame
// is registered to drag (`0x76c050`), with the drag started (`0x76c055`), fires
// `OnDragStop` (`0x76c06e`) and the hover frame's `OnReceiveDrag` (`0x76c081`) and returns
// 1 (`0x76c08f`) before the `OnMouseUp` leg (`0x76c099`). So a drag release fires no
// `OnMouseUp`; a release of a button the frame does not drag, or of a drag that never
// started, still does.
if down {
if let Some(id) = press_id {
if let Err(e) =
event::fire_widget_handler(&self.lua, id, "OnMouseDown", vec![btn.clone()])
{
self.push_error(e);
}
}
} else if let Some(release) = drag_release.as_ref().filter(|r| r.started) {
self.fire_drag_stop(release.source);
if let Some(id) = hit_id {
if let Err(e) =
event::fire_widget_handler(&self.lua, id, "OnReceiveDrag", Vec::new())
{
self.push_error(e);
}
}
} else if let Some(id) = captured_id {
if let Err(e) =
event::fire_widget_handler(&self.lua, id, "OnMouseUp", vec![btn.clone()])
{
self.push_error(e);
}
}
// A left press in an EditBox places the cursor, starts a selection drag and focuses it
// whatever its `autoFocus`, before any `OnClick` (`0x77b800`).
if button == "LeftButton" {
if down {
if let Some(id) = hit_id {
if let Some(id) = press_id {
editbox::click(&self.lua, id, x, y);
}
} else {
@@ -584,28 +617,19 @@ impl UiScript {
if let Some(source) = abandoned {
self.fire_drag_stop(source);
}
if let Some(release) = drag_release.filter(|r| r.started) {
self.fire_drag_stop(release.source);
if let Some(id) = hit_id {
if let Err(e) =
event::fire_widget_handler(&self.lua, id, "OnReceiveDrag", Vec::new())
{
self.push_error(e);
}
}
}
if let Some(id) = click_id {
// Shared with `Click()`: gated on the Button being enabled; a CheckButton toggles
// before `OnClick` fires.
button::click_button(&self.lua, id, button, down, false);
}
// `OnDoubleClick(self, button)` in place of `OnClick` (`0x77938d call [edx+0x98]`), with
// the same click sound: its firer `0x779650` is the twin of `OnClick`'s `0x779540` and
// passes only the button name of the completing release.
if let Some(id) = double_id {
if let Err(e) = event::fire_widget_handler(&self.lua, id, "OnDoubleClick", vec![btn]) {
self.push_error(e);
}
// The press art last (`0x7792b1` follows the Down click `0x779285`): `OnMouseDown`
// (`0x76bf70`, at the top of the slot) and a Down `OnClick` see the button unpushed, and
// the release's pushed gate reads what they left.
if let Some(h) = press_art {
button::edge(&mut self.model_mut(), h, ButtonState::on_mouse_down);
}
if let Some(h) = released {
self.release_slot(h, button, (x, y), now, &btn);
}
// A world-frame hit is not the UI consuming the event: the reference runs its
// `OnMouseDown` without consuming (`0x483c40`), then the `BUTTON1`/`BUTTON2` binding, the
@@ -613,6 +637,74 @@ impl UiScript {
hit_id.is_some_and(|id| !self.is_world_frame(id)) || world_dropped
}
/// The Button release slot `0x7792d0` past its base (`0x76c040`: `OnMouseUp`, which the caller
/// has fired; a started drag returns 1 there and never reaches this), on the frame that took the press: a PUSHED button (`0x7792fc`) with the cursor
/// inside its own rect (`0x779335`), not "the topmost frame under the cursor", and registered
/// for this button's Up (`0x77934e`) fires `OnDoubleClick` (`0x77938d`) or `OnClick`
/// (`0x7793a9`); only then does the tail set NORMAL (`0x7793de`) if the button is unlocked and
/// not disabled (`0x7793c2`..`0x7793d4`), on every path that got past the pushed gate and
/// wherever the cursor is. So a handler reads PUSHED, and what it sets (a lock, a disable)
/// decides the tail. The state is read here, after `OnMouseUp`, so a script that un-pushes the
/// button there cancels the click.
fn release_slot(
&mut self,
h: crate::widget::FrameHandle,
button: &str,
(x, y): (f32, f32),
now: f64,
btn: &Value,
) {
let (click, double) = {
let model = self.model_ref();
if model.arena.frame(h).is_none() {
return;
}
let sources = scroll_clip_sources(&model);
let clicks = button::release_may_click(&model, h)
&& frame_contains(&model, &sources, h, x, y)
&& button::wants_click(&model, h, &format!("{button}Up"));
// The double click: `0x77938d` fires `OnDoubleClick` instead of the second `OnClick`
// (`0x77939d jmp 0x7793b5` skips the single leg), only for a frame with an
// `OnDoubleClick` script (`[+0x4d4] != 0`) whose last click was within the interval.
// What suppresses the click suppresses it too.
let double = clicks
&& model
.scripts
.get(&h)
.is_some_and(|s| s.contains("OnDoubleClick"))
&& model
.last_click
.get(&h)
.is_some_and(|&t| now - t <= DOUBLE_CLICK_SECS);
(clicks, double)
};
let id = self.model_ref().frame_to_id.get(&h).copied();
if let (true, Some(id)) = (click, id) {
if double {
if let Err(e) =
event::fire_widget_handler(&self.lua, id, "OnDoubleClick", vec![btn.clone()])
{
self.push_error(e);
}
} else {
button::click_button(&self.lua, id, button, false, false);
}
// A double clears the stamp (`0x779393`), so clicks pair: four fast clicks are click,
// double, click, double. A single stamps the detector (`0x7793af`) whether or not an
// `OnClick` script is bound.
let mut model = self.model_mut();
if model.arena.frame(h).is_some() {
if double {
model.last_click.remove(&h);
} else {
model.last_click.insert(h, now);
}
}
}
// The release edge, `SetButtonState(NORMAL)` (`0x7793de`).
button::edge(&mut self.model_mut(), h, ButtonState::on_mouse_up);
}
/// Fire `OnDragStop` on a gesture's source, the one place a started drag ends; a source that
/// died since the press fires nothing.
pub(super) fn fire_drag_stop(&mut self, source: crate::widget::FrameHandle) {
@@ -670,6 +762,23 @@ impl UiScript {
}
}
/// The probe `0x76b020` alone: whether `(x, y)` is inside the frame's hit rect and its effective
/// ScrollFrame clip, whatever its mouse bit. The Button click legs test it against the capture
/// (`0x77925c`, `0x779335`).
fn frame_contains(
model: &Model,
scroll_sources: &HashMap<FrameHandle, Rect>,
fh: FrameHandle,
x: f32,
y: f32,
) -> bool {
model
.resolved
.get(&fh)
.is_some_and(|r| point_in_rect(hit_rect(model, fh, *r), x, y))
&& effective_clip(model, scroll_sources, fh).is_none_or(|c| point_in_rect(c, x, y))
}
/// Point-in-rect in UI space (y-up), inclusive on all four edges, as the reference's probe
/// `0x76b020` tests it.
pub(super) fn point_in_rect(r: Rect, x: f32, y: f32) -> bool {
+109 -8
View File
@@ -7,6 +7,7 @@ use mlua::{Lua, MultiValue, Value};
use super::binding_abi::flag;
use super::Model;
use crate::script::binding_abi::Idx;
/// Which questgiver panel a [`QuestState`] is for, set from the wire packet.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -246,7 +247,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
fn install_title(lua: &Lua, name: &str, active: bool) -> mlua::Result<()> {
lua.globals().set(
name,
lua.create_function(move |lua, i: usize| {
lua.create_function(move |lua, Idx(i): Idx| {
let title = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.quest.as_ref().and_then(|q| {
@@ -329,7 +330,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// GetQuestItemLink(type, index), for a row's ctrl and shift clicks (`QuestFrame.lua:118`).
g.set(
"GetQuestItemLink",
lua.create_function(|lua, (kind, index): (String, usize)| {
lua.create_function(|lua, (kind, index): (String, Value)| {
// `index - 1` is compared unsigned against 6 (`0x501924`), so a zero or negative index
// is no item; every miss pushes one nil (`0x50216a`).
let index = super::binding_abi::index_arg(
lua,
index,
"Usage: GetQuestItemLink(\"type\", index)",
)?;
let link = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.quest.as_ref().and_then(|q| {
@@ -447,11 +455,36 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// `RewChoiceItemId` with it.
g.set(
"GetQuestReward",
lua.create_function(|lua, choice: Option<u32>| {
lua.create_function(|lua, choice: Option<Value>| {
// `is-number` failing leaves the choice at -1 (`0x501d89`) and raises nothing; a
// number is `trunc(n) - 1` (`0x501da8`), read as signed.
let choice = match choice {
Some(v) => lua
.coerce_number(v)?
.map_or(-1, |n| super::binding_abi::ftol(n).wrapping_sub(1)),
None => -1,
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.quest_actions.push(super::QuestAction::Reward(
choice.unwrap_or(0).saturating_sub(1),
));
// Off the reward panel the reference does nothing and answers success (`0x5015b0`:
// `[0xbe0818] != 3`).
let Some(q) = model
.quest
.as_ref()
.filter(|q| q.panel == QuestPanel::Reward)
else {
return Ok(());
};
// With choices on offer an index outside `[0, n)` fails (`0x501608`..`0x50160e`) and
// the binding raises; without any, the wire gets `max(choice, 0)` (`0x501627`).
let n = q.choices.len() as i32;
if n > 0 && !(0..n).contains(&choice) {
return Err(mlua::Error::RuntimeError(
"Invalid reward choice in GetQuestReward([choice])".into(),
));
}
model
.quest_actions
.push(super::QuestAction::Reward(choice.max(0) as u32));
Ok(())
})?,
)?;
@@ -700,21 +733,89 @@ mod tests {
assert_eq!(s.take_quest_actions(), vec![QuestAction::Accept]);
s.run("CompleteQuest()").unwrap(); // progress -> reward
let mut offer = detail();
offer.panel = QuestPanel::Reward;
s.set_quest(Some(offer)); // the app pushes the reward panel
s.run("GetQuestReward(2)").unwrap(); // row 2 (1-based) → wire choice 1 (0-based)
s.run("GetQuestReward()").unwrap(); // no-choice quest -> 0
s.run("DeclineQuest()").unwrap();
assert_eq!(
s.take_quest_actions(),
vec![
QuestAction::Continue,
QuestAction::Reward(1),
QuestAction::Reward(0),
QuestAction::Decline,
]
);
assert!(s.take_quest_actions().is_empty(), "drained");
}
/// `GetQuestReward` (`0x501d80` → `0x5015b0`): with choices on offer an index outside `[0, n)`
/// raises "Invalid reward choice" (`0x84eda4`), the default for no argument being -1; with
/// none the wire gets `max(index, 0)`; off the reward panel it does nothing.
#[test]
fn get_quest_reward_validates_the_choice_against_the_offer() {
let mut s = UiScript::new().unwrap();
s.set_quest(Some(detail()));
let n_choices = s.eval::<i64>("return GetNumQuestChoices()").unwrap();
assert!(n_choices >= 2);
// Not on the reward panel yet: nothing happens, and nothing raises.
s.run("GetQuestReward(99)").unwrap();
assert!(s.take_quest_actions().is_empty());
s.run("CompleteQuest()").unwrap();
s.take_quest_actions();
let mut offer = detail();
offer.panel = QuestPanel::Reward;
s.set_quest(Some(offer));
for call in [
"GetQuestReward()",
"GetQuestReward(0)",
"GetQuestReward(-3)",
&format!("GetQuestReward({})", n_choices + 1),
"GetQuestReward('x')",
] {
let err = s.run(call).unwrap_err().to_string();
assert!(
err.contains("Invalid reward choice in GetQuestReward([choice])"),
"{call}: {err}"
);
}
assert!(
s.take_quest_actions().is_empty(),
"a failed pick sends nothing"
);
s.run(&format!("GetQuestReward({n_choices})")).unwrap();
s.run("GetQuestReward(1.9)").unwrap();
assert_eq!(
s.take_quest_actions(),
vec![
QuestAction::Reward(n_choices as u32 - 1),
QuestAction::Reward(0)
]
);
// No choices on offer: any index goes, as `max(index, 0)`.
let mut bare = detail();
bare.choices.clear();
bare.panel = QuestPanel::Reward;
let mut s = UiScript::new().unwrap();
s.set_quest(Some(bare));
for call in [
"GetQuestReward()",
"GetQuestReward(-3)",
"GetQuestReward(4)",
] {
s.run(call).unwrap();
}
assert_eq!(
s.take_quest_actions(),
vec![
QuestAction::Reward(0),
QuestAction::Reward(0),
QuestAction::Reward(3)
]
);
}
#[test]
fn clearing_the_quest_empties_it() {
let mut s = UiScript::new().unwrap();
+43 -12
View File
@@ -15,6 +15,7 @@ use mlua::{Lua, MultiValue, Value};
use super::binding_abi::{flag, number_arg};
use super::quest::QuestItemView;
use super::Model;
use crate::script::binding_abi::{Idx, Idx32};
/// One quest-log row; its 1-based position in [`QuestLogState::entries`] is the index every verb
/// takes.
@@ -291,9 +292,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// (`QuestLogFrame.lua:616`).
g.set(
"GetNumQuestLeaderBoards",
lua.create_function(|lua, i: Option<u32>| {
lua.create_function(|lua, i: Option<Idx32>| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let index = i.unwrap_or(model.quest_log_selection) as usize;
let index = i.map_or(model.quest_log_selection, |i| i.0) as usize;
Ok(index
.checked_sub(1)
.and_then(|n| model.quest_log.entries.get(n))
@@ -302,13 +303,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// `text, type, finished` of line `i`, for any row as above (`QuestLogFrame.lua:635`).
// `text, type, finished` of line `i`, for any row as above (`QuestLogFrame.lua:635`). Three
// values on every path (`mov eax,3` at `0x4e02ba`, `0x4e079b`, `0x4e07c8`): `finished` is the
// number 1 (`0x4e02ae`, `0x4e078f`) or nil (`0x4e07bc`), and a row or line that does not exist
// takes the tail at `0x4e07ac`, which pushes three nils.
g.set(
"GetQuestLogLeaderBoard",
lua.create_function(|lua, (i, quest): (usize, Option<u32>)| {
lua.create_function(|lua, (Idx(i), quest): (Idx, Option<Idx32>)| {
let line = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let index = quest.unwrap_or(model.quest_log_selection) as usize;
let index = quest.map_or(model.quest_log_selection, |q| q.0) as usize;
index
.checked_sub(1)
.and_then(|n| model.quest_log.entries.get(n))
@@ -316,12 +320,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.cloned()
};
let Some(l) = line else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Nil,
]));
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&l.text)?),
Value::String(lua.create_string(&l.kind)?),
Value::Boolean(l.finished),
flag(l.finished),
]))
})?,
)?;
@@ -547,7 +555,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// (`QuestTimerFrame.lua:43`, `QuestTimerFrame.xml:20`).
g.set(
"GetQuestIndexForTimer",
lua.create_function(|lua, t: usize| {
lua.create_function(|lua, Idx(t): Idx| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let now = model.server_unix_time;
let index = t.checked_sub(1).and_then(|n| {
@@ -696,7 +704,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
g.set(
"IsQuestWatched",
lua.create_function(|lua, i: u32| {
lua.create_function(|lua, Idx32(i): Idx32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(flag(
watch_id_at(&model, i).is_some_and(|id| model.quest_log_watched.contains(&id)),
@@ -728,7 +736,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// The watched quest's current 1-based row, nil when it is not a visible row.
g.set(
"GetQuestIndexForWatch",
lua.create_function(|lua, w: usize| {
lua.create_function(|lua, Idx(w): Idx| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let index = w
.checked_sub(1)
@@ -1042,15 +1050,38 @@ mod tests {
assert!(s
.eval::<bool>(
"local t, k, f = GetQuestLogLeaderBoard(1, 2)\n\
return t == 'Kobold Worker slain: 10/10' and k == 'monster' and f == true"
return t == 'Kobold Worker slain: 10/10' and k == 'monster' and f == 1"
)
.unwrap());
assert!(s
.eval::<bool>(
"local t, k, f = GetQuestLogLeaderBoard(1)\n\
return t == 'Kobold Vermin slain: 3/10' and k == 'monster' and f == false"
return t == 'Kobold Vermin slain: 3/10' and k == 'monster' and f == nil"
)
.unwrap());
// Three values on every path; a row or line with nothing answers three nils (`0x4e07ac`).
for call in [
"GetQuestLogLeaderBoard(1)",
"GetQuestLogLeaderBoard(1, 2)",
"GetQuestLogLeaderBoard(2)",
"GetQuestLogLeaderBoard(1, 9)",
"GetQuestLogLeaderBoard(0)",
] {
assert_eq!(s.arity(call).unwrap(), 3, "{call}");
}
for call in [
"GetQuestLogLeaderBoard(2)",
"GetQuestLogLeaderBoard(1, 9)",
"GetQuestLogLeaderBoard(0)",
] {
assert!(
s.eval::<bool>(&format!(
"local a, b, c = {call} return a == nil and b == nil and c == nil"
))
.unwrap(),
"{call}"
);
}
assert_eq!(
s.eval::<i64>("return GetQuestLogRewardMoney()").unwrap(),
40
+407 -89
View File
@@ -4,15 +4,13 @@
use mlua::{Lua, MultiValue, Table, Value};
use super::object::anchor_args::{parse_set_point, resolve_rel_target, UNNAMED};
use super::object::{
anchor_bits_eq, anchor_retarget_is_structural, decode_id, id_to_lud, NamedTarget,
};
use super::object::{decode_id, id_to_lud, NamedTarget};
use super::region_map::{set_shared, Side};
use super::{
Model, REG_FONTSTRING_META, REG_FONTSTRING_METHODS, REG_REGION_META, REG_REGION_METHODS,
REG_TEXTURE_META, REG_TEXTURE_METHODS, REG_TITLE_META, REG_TITLE_METHODS, REG_WRAPPERS, SCREEN,
};
use crate::layout::Anchor;
use crate::layout::{Anchor, Anchors};
use crate::widget::{RegionHandle, RegionKind};
/// Resolve `self` (a region wrapper) to its live [`RegionHandle`].
@@ -26,6 +24,229 @@ pub(super) fn region_handle_of(lua: &Lua, this: &Table) -> mlua::Result<RegionHa
.ok_or_else(|| mlua::Error::runtime("stale or invalid region handle"))
}
/// The Texture object a widget's texture setter was handed, as the setters resolve it: a table
/// that is no widget raises `Couldn't find 'this' in texture`, a widget that is no Texture
/// `Wrong object type, expected texture` (`0x781a8c`..`0x781ab1`, the same pair in every setter).
/// `method` is the name the setter's own format string carries; `who` is the receiver's global
/// name, or `<unnamed>`.
pub(super) fn texture_object_arg(
lua: &Lua,
this: &Table,
arg: &Table,
method: &str,
) -> mlua::Result<RegionHandle> {
let who = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let name = super::object::frame_handle_of(lua, this)
.ok()
.and_then(|h| model.arena.frame(h).and_then(|f| f.name.clone()));
name.unwrap_or_else(|| "<unnamed>".to_string())
};
if decode_id(arg).is_err() {
return Err(mlua::Error::runtime(format!(
"{who}:{method}(): Couldn't find 'this' in texture"
)));
}
let model = lua.app_data_ref::<Model>().expect("model app_data");
let rh = decode_id(arg)
.ok()
.and_then(|id| model.id_to_region.get(&id).copied())
.filter(|&rh| model.arena.region(rh).map(|r| r.kind) == Some(RegionKind::Texture));
rh.ok_or_else(|| {
mlua::Error::runtime(format!(
"{who}:{method}(): Wrong object type, expected texture"
))
})
}
/// The client's re-link `0x77fd10(owner, layer, shown)` for a region a widget takes into a slot:
/// out of the old parent (its region-removed hook runs, `0x77fd55`) and into `owner` on `layer`,
/// the layer set whether or not the parent changed (`0x77fd9a`).
///
/// The third argument is the region's own shown bit (`+0xc4`, what `Show`/`Hide` write and
/// `IsShown` reads): leaving the old parent clears it (`0x77fd3d`) and `shown` writes it back
/// (`0x77fd85`, `0x77fdc8`, and `0x77fdd9` clears it when the parent and layer stand). Every widget
/// that takes a texture calls with 1 (the bar, thumb, highlight, ColorSelect slots, the label)
/// except a Button's state and checked slots, whose 0 is followed by the state switch writing the
/// bit for the texture that shows (`0x779037`, `0x7797ea`, `0x785538`): the caller applies those
/// writes after the link.
pub(super) fn link_region(
model: &mut Model,
rh: RegionHandle,
owner: crate::widget::FrameHandle,
layer: crate::order::DrawLayer,
shown: bool,
) {
let drew = model.region_draws(rh);
let old_loading = model.region_owner_loading(rh);
let moved = model.arena.set_region_owner(rh, Some(owner));
if moved {
// The owner supplies the region's scale to layout; the re-link invalidates nothing, so
// the region keeps its rect until the resolve reaches it.
model.unsettle_region(rh);
}
if let Some(r) = model.arena.region_mut(rh) {
r.draw_layer = layer;
}
model.relink_region_layout(rh, drew, old_loading, moved, Some(shown));
}
/// Apply a Button state machine's writes to the regions' shown bit, the one `Show`/`Hide` write.
/// A region the machine names that has since been freed is skipped.
pub(super) fn apply_shown_writes(model: &mut Model, writes: crate::widget::ShownWrites) {
for (rh, shown) in writes.0 {
if model.arena.region(rh).is_some() {
model.set_region_hidden(rh, !shown);
}
}
}
/// A widget's one-texture slot taking `incoming` (a Texture object, or nothing to clear), as every
/// slot setter does (`0x778fd0`, `0x7831f0`, `0x789860`, `0x785330`, `0x78b580`..): the same
/// texture is a no-op, the displaced one is deleted, the incoming one is linked in on `layer`
/// (a thumb marker then loses its anchors, `0x767ed0`, since the widget places it), then it is
/// stored. `shown` is the link's third argument (see [`link_region`]). `get` and `set` read and
/// write the slot in the widget's own state. Answers whether the slot changed, for a caller whose
/// hook does more once it has.
pub(super) fn install_into_slot(
model: &mut Model,
owner: crate::widget::FrameHandle,
incoming: Option<RegionHandle>,
layer: crate::order::DrawLayer,
shown: bool,
clear_points: bool,
get: impl Fn(&crate::widget::KindState) -> Option<RegionHandle>,
set: impl Fn(&mut crate::widget::KindState, Option<RegionHandle>),
) -> mlua::Result<bool> {
let current = model
.arena
.frame(owner)
.map(|f| get(&f.kind_state))
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
if incoming == current {
return Ok(false);
}
if let Some(old) = current {
free_region(model, old);
}
if let Some(rh) = incoming {
link_region(model, rh, owner, layer, shown);
if clear_points {
layout::clear_points(model, rh);
}
}
if let Some(f) = model.arena.frame_mut(owner) {
set(&mut f.kind_state, incoming);
}
// The displaced region's free named its node; the incoming one re-derives with its new owner
// and points.
if let Some(rh) = incoming {
model.touch_layout_region(rh);
}
Ok(true)
}
/// What a widget's texture setter was handed (`SetStatusBarTexture` `0x78f870`, `SetThumbTexture`
/// `0x7902b0`, `SetNormalTexture` `0x781970`, the ColorSelect four): a Texture object, a path, or
/// nil to clear.
pub(super) enum SlotArg {
Object(RegionHandle),
Path(String),
Clear,
}
/// The setter's fork on its first argument. A table is a Texture object (`lua_type` 5), a string
/// or a number a path where the setter has a path leg (`lua_isstring` `0x6f3510`, then
/// `lua_tostring` `0x6f3690`, so `1` is the file `"1"`), nil (type 0) clears; anything else, no
/// argument included (type -1), raises `Usage: <name>:<usage>`. `object_method` names the setter in
/// the object leg's two raises, as each setter's own format string prints it.
pub(super) fn slot_arg(
lua: &Lua,
this: &Table,
args: &MultiValue,
usage: &str,
object_method: &str,
path_leg: bool,
) -> mlua::Result<SlotArg> {
match args.front() {
Some(Value::Table(t)) => {
return texture_object_arg(lua, this, t, object_method).map(SlotArg::Object)
}
Some(Value::Nil) => return Ok(SlotArg::Clear),
Some(v @ (Value::String(_) | Value::Number(_) | Value::Integer(_))) if path_leg => {
if let Some(path) = lua.coerce_string(v.clone())? {
return Ok(SlotArg::Path(path.to_string_lossy()));
}
}
_ => {}
}
let who = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
super::object::frame_handle_of(lua, this)
.ok()
.and_then(|h| model.arena.frame(h).and_then(|f| f.name.clone()))
.unwrap_or_else(|| "<unnamed>".to_string())
};
Err(mlua::Error::runtime(format!("Usage: {who}:{usage}")))
}
/// A fresh anchorless Texture region of `owner` on `layer`, as the client builds the texture a
/// path leg loads (`0x76fc40`) before it installs it as an object.
pub(super) fn new_texture_region(
model: &mut Model,
owner: crate::widget::FrameHandle,
layer: crate::order::DrawLayer,
) -> Option<RegionHandle> {
let rh = model
.arena
.create_region(owner, RegionKind::Texture, layer, 0)?;
model.region_data.insert(rh, super::RegionData::default());
model.touch_layout_region(rh);
Some(rh)
}
/// `CSimpleTexture::SetTexture(path)` (`0x770200`) on a region: the file replaces a solid fill, and
/// `""` clears the texture, which `QuestLogFrame.lua:165` relies on.
pub(super) fn set_texture_path(model: &mut Model, rh: RegionHandle, path: &str) {
let data = model.region_data.entry(rh).or_default();
data.texture = (!path.is_empty()).then(|| path.to_string());
data.fill = None;
model.note_art(rh);
}
/// The loader's install of the texture a `<BarTexture>`, `<ThumbTexture>`, Button state-texture or
/// ColorSelect texture element (`tag`) built, as the widget's object, never through a Lua setter.
/// `layer` is the element's `drawLayer` for the bar and the thumb.
pub(crate) fn install_loaded_texture(
lua: &Lua,
this: &Table,
tag: &str,
texture: &Table,
layer: Option<&str>,
) -> mlua::Result<()> {
let h = super::object::frame_handle_of(lua, this)?;
let rh = texture_object_arg(lua, this, texture, "SetStatusBarTexture")?;
let layer = layer.and_then(super::object::draw_layer_from_str);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
match tag {
"BarTexture" => super::statusbar::install_loaded(&mut model, h, rh, layer),
"ThumbTexture" => super::slider::install_loaded(&mut model, h, rh, layer),
"NormalTexture"
| "PushedTexture"
| "DisabledTexture"
| "HighlightTexture"
| "CheckedTexture"
| "DisabledCheckedTexture" => super::button::install_loaded(&mut model, h, tag, rh),
_ => super::colorselect::install_loaded(&mut model, h, tag, rh),
}
}
/// The region's shown bit (`+0xc4`) as `IsShown` reads it (`0x79b9b0`): the one bit `Show`/`Hide`
/// write, and a Button's state switch writes for its state and checked textures.
fn region_shown(model: &Model, rh: RegionHandle) -> bool {
!model.region_data.get(&rh).is_some_and(|d| d.hidden)
}
/// Argument 1 of a global that takes a Texture as its `this` (`SetLootPortrait` `0x4c2b40`,
/// `SetInventoryPortaitTexture` `0x4c9150`), with the member prologue's three raises: a non-table
/// (`0x847ef8`), a table with no object at `[0]` (`0x847ec0`), and any object but a Texture, the
@@ -57,6 +278,7 @@ pub(super) fn clear_texture(model: &mut Model, rh: RegionHandle) {
data.texture = None;
data.fill = None;
data.portrait_unit = None;
model.note_art(rh);
}
/// Apply the font parts an XML element supplies (`font=`, `<FontHeight>`, `outline=`), any of
@@ -119,10 +341,24 @@ pub(super) fn region_wrapper(lua: &Lua, id: u32) -> mlua::Result<Table> {
Ok(t)
}
/// Install the region method tables and the region globals.
/// Install the region method tables.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
install_region_methods(lua)?;
// `__index` is the method table itself, here and on the leaf metatables, not a function
// (~9 ns against ~195 ns per method access); sound while nothing replaces these tables.
let region_meta = lua.create_table()?;
region_meta.set(
"__index",
lua.named_registry_value::<Table>(REG_REGION_METHODS)?,
)?;
lua.set_named_registry_value(REG_REGION_META, region_meta)?;
Ok(())
}
/// The two portrait globals, which only the in-game state registers (`SetPortraitTexture` in
/// table `0x850438`, `SetPortraitToTexture` in `0x83de68`).
pub(super) fn install_in_game(lua: &Lua) -> mlua::Result<()> {
// SetPortraitTexture(texture, unit) (`UnitFrame.lua:26`): binds the region to a unit whose
// model the app bakes off-screen, drawn masked to its inscribed circle; a later `SetTexture`
// or `SetPortraitToTexture` clears it.
@@ -147,6 +383,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
data.texture = None;
data.fill = None;
data.circular = true;
model.note_art(rh);
Ok(())
})?,
)?;
@@ -159,7 +396,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"SetPortraitToTexture",
lua.create_function(|lua, (name, path): (String, String)| {
let mut model = lua.app_data_mut::<Model>().expect("model");
let Some(id) = model.region_names.get(&name).copied() else {
let Some(id) = model.region_by_name.get(&name).copied() else {
return Ok(());
};
let Some(rh) = model.id_to_region.get(&id).copied() else {
@@ -169,18 +406,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
data.texture = Some(path);
data.circular = true;
data.portrait_unit = None;
model.note_art(rh);
Ok(())
})?,
)?;
// `__index` is the method table itself, here and on the leaf metatables, not a function
// (~9 ns against ~195 ns per method access); sound while nothing replaces these tables.
let region_meta = lua.create_table()?;
region_meta.set(
"__index",
lua.named_registry_value::<Table>(REG_REGION_METHODS)?,
)?;
lua.set_named_registry_value(REG_REGION_META, region_meta)?;
Ok(())
}
@@ -301,10 +530,15 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
}
_ => return Err(wrong_type()),
};
if model.arena.set_region_owner(rh, new_owner) {
// The owner supplies the region's scale to layout, so a re-link is a layout change.
model.touch_layout();
let drew = model.region_draws(rh);
let old_loading = model.region_owner_loading(rh);
let moved = model.arena.set_region_owner(rh, new_owner);
if moved {
// The owner supplies the region's scale to layout; the re-link invalidates nothing
// (`0x77fd10`), so the region keeps its rect until the resolve reaches it.
model.unsettle_region(rh);
}
model.relink_region_layout(rh, drew, old_loading, moved, None);
Ok(())
},
)?;
@@ -315,7 +549,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().hidden = false;
model.set_region_hidden(rh, false);
Ok(())
})?,
)?;
@@ -325,7 +559,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().hidden = true;
model.set_region_hidden(rh, true);
Ok(())
})?,
)?;
@@ -335,7 +569,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let shown = !model.region_data.get(&rh).is_some_and(|d| d.hidden);
let shown = region_shown(&model, rh);
Ok(if shown { Value::Integer(1) } else { Value::Nil })
})?,
)?;
@@ -345,7 +579,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let shown = !model.region_data.get(&rh).is_some_and(|d| d.hidden);
let shown = region_shown(&model, rh);
let owner_visible = model
.arena
.region(rh)
@@ -412,25 +646,33 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
Ok(())
}
/// This region's global name, or `None` when anonymous: a scan of the region-name registry, the
/// one authority, linear in named regions.
/// This region's own name, or `None` when anonymous: the string on the region (`[+0x98]`, read by
/// `GetName` through vtable `+4`, `0x46ff70`), whether or not it holds the lookup entry.
pub(super) fn region_name_of(model: &Model, id: u32) -> Option<String> {
model
.region_names
.iter()
.find(|&(_, &v)| v == id)
.map(|(k, _)| k.clone())
let rh = *model.id_to_region.get(&id)?;
model.arena.region(rh)?.name.clone()
}
/// Publish a region's name into the registry a named `relativeTo` resolves through, first name
/// winning as for frames. For sub-textures a setter creates and the XML loader names later
/// (`SetThumbTexture`, `SetColorWheelTexture`); `CreateTexture(name)` publishes its own.
/// `CScriptRegion::SetName` (`0x76c650`): the region keeps its own copy of the (already expanded)
/// name, replacing the old one, and offers it to the name lookup, where the first holder stays
/// (`_G`'s `0x701bd0` writes only when the entry is empty, `0x701c8a`).
pub(crate) fn set_region_name(model: &mut Model, rh: RegionHandle, id: u32, name: &str) {
if let Some(region) = model.arena.region_mut(rh) {
region.name = Some(name.to_string());
}
model.region_by_name.entry(name.to_string()).or_insert(id);
}
/// Name a region the loader or a setter built nameless or under another name, as
/// [`set_region_name`]. For sub-textures a setter creates and the XML loader names later
/// (`SetThumbTexture`, `SetColorWheelTexture`); `CreateTexture(name)` names its own.
pub(crate) fn publish_region_name(lua: &Lua, name: &str, region: &Table) {
let Ok(id) = super::object::decode_id(region) else {
let Ok(rh) = region_handle_of(lua, region) else {
return;
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.region_names.entry(name.to_string()).or_insert(id);
let id = model.region_id(rh);
set_region_name(&mut model, rh, id, name);
}
/// The type `GetObjectType()` answers and `IsObjectType` matches; `"Region"` for a dead handle.
@@ -444,19 +686,113 @@ pub(super) fn region_type_name(model: &Model, rh: RegionHandle) -> &'static str
}
}
/// Free a region and every trace of its identity (arena slot, paint, rect, name, id), so a held
/// wrapper raises as stale; the reference frees too, as `CSimpleButton::SetFontString 0x778d20`
/// deletes the label it replaces.
/// Free a region; see [`free_regions`].
pub(crate) fn free_region(model: &mut Model, rh: RegionHandle) {
model.region_data.remove(&rh);
model.region_resolved.remove(&rh);
if let Some(id) = model.region_to_id.remove(&rh) {
model.id_to_region.remove(&id);
model.region_names.retain(|_, v| *v != id);
free_regions(model, &[rh]);
}
/// Free regions and every trace of their identity (arena slot, paint, rect, name, id), so a held
/// wrapper raises as stale. The reference destroys them: `CSimpleButton::SetFontString` the label
/// it replaces (`0x778d3c`), a state-texture setter the texture it displaces (`0x77900a`), a
/// backdrop teardown its pieces (`0x77f4d0`), a SimpleHTML parse its old blocks (`0x78a5aa`). The
/// destructor (`0x767720` → `0x768e20`) unhooks every object anchored to a dying one with
/// `0x767e70(dependent, dying, 0)`: each anchor naming it is freed, and the zero flag queues no
/// resolve, so the dependent keeps its state until the resolve reaches it.
pub(crate) fn free_regions(model: &mut Model, dying: &[RegionHandle]) {
let mut ids = Vec::with_capacity(dying.len());
for &rh in dying {
model.region_data.remove(&rh);
model.region_resolved.remove(&rh);
model.region_kept.remove(&rh);
let name = model.arena.region(rh).and_then(|r| r.name.clone());
if let Some(id) = model.region_to_id.remove(&rh) {
model.id_to_region.remove(&id);
model.layout_unsettled.remove(&id);
// The lookup entry goes only if this region is the one it holds; a later holder of the
// same name was never in it.
if let Some(name) = name {
if model.region_by_name.get(&name) == Some(&id) {
model.region_by_name.remove(&name);
}
}
// Its node leaves the cached graph with its edges at the next resolve.
model.name_layout_node(id);
ids.push(id);
}
model.arena.destroy_region(rh);
}
// Only a region with an id can be an anchor target.
if ids.is_empty() {
return;
}
let mut regions: Vec<RegionHandle> = Vec::new();
let mut frames: Vec<crate::widget::FrameHandle> = Vec::new();
match &model.layout_touched {
// The cached graph's readers of each dying id, and every node a write named since it was
// built, are all that can anchor to one.
Some(named) => {
let mut maybe = named.clone();
for &id in &ids {
maybe.extend(model.layout_scope.readers_of(id));
}
maybe.sort_unstable();
maybe.dedup();
for id in maybe {
if let Some(&rh) = model.id_to_region.get(&id) {
if let Some(d) = model.region_data.get_mut(&rh) {
if d.anchors.release_targets(&ids) {
regions.push(rh);
}
}
} else if let Some(&h) = model.id_to_frame.get(&id) {
if let Some(i) = model.layout_inputs.get_mut(&h) {
if i.anchors.release_targets(&ids) {
frames.push(h);
}
}
}
}
}
// No graph to vouch for: every object.
None => {
for (&rh, d) in model.region_data.iter_mut() {
if d.anchors.release_targets(&ids) {
regions.push(rh);
}
}
for (&h, i) in model.layout_inputs.iter_mut() {
if i.anchors.release_targets(&ids) {
frames.push(h);
}
}
}
}
// Under `WOW_LAYOUT_VERIFY` the graph's answer is checked against every object.
if model.layout_touched.is_some() && super::layout::layout_verify_enabled() {
let missed = model
.region_data
.values()
.map(|d| &d.anchors)
.chain(model.layout_inputs.values().map(|i| &i.anchors))
.any(|a| a.iter().any(|a| ids.contains(&a.relative_to)));
assert!(
!missed,
"WOW_LAYOUT_VERIFY: an object anchored to a freed region was not among the cached \
graph's readers of it or the nodes named since"
);
}
// Each dependent re-derives its edges and keeps the state it has, in handle order, as a naming
// can mint an id.
regions.sort_unstable_by_key(|rh| rh.fingerprint_bits());
regions.dedup();
frames.sort_unstable_by_key(|h| h.fingerprint_bits());
frames.dedup();
for rh in regions {
model.unsettle_region(rh);
}
for h in frames {
model.unsettle_frame(h);
}
model.arena.destroy_region(rh);
// A removed node takes its edges with it: a structural change, so the full touch.
model.touch_layout();
}
pub(super) fn region_owner_id(model: &mut Model, rh: RegionHandle) -> u32 {
@@ -484,18 +820,19 @@ pub(crate) fn implicit_creation_anchor(model: &mut Model, rh: RegionHandle) {
use crate::layout::Point;
match kind {
RegionKind::Texture => {
data.anchors = vec![
data.anchors = Anchors::from([
Anchor::new(Point::TopLeft, owner_id, Point::TopLeft, 0.0, 0.0),
Anchor::new(Point::BottomRight, owner_id, Point::BottomRight, 0.0, 0.0),
];
]);
}
RegionKind::FontString => {
let point = justify_anchor_point(data.justify.0);
data.anchors = vec![Anchor::new(point, owner_id, point, 0.0, 0.0)];
data.anchors = Anchors::from([Anchor::new(point, owner_id, point, 0.0, 0.0)]);
}
RegionKind::Title => return,
}
model.touch_layout();
// Both set their points with the invalidate flag (`0x7701ec`, `0x7714bc`).
model.invalidate_region(rh, true);
}
/// The middle-row point a justify word selects, as the FontString post-step (`0x771480`) and the
@@ -523,8 +860,9 @@ pub(crate) fn anchor_unanchored_at(
if !data.anchors.is_empty() {
return;
}
data.anchors = vec![Anchor::new(point, owner_id, point, 0.0, 0.0)];
model.touch_layout();
data.anchors = Anchors::from([Anchor::new(point, owner_id, point, 0.0, 0.0)]);
// The adopter's `SetPoint` invalidates (`0x778d96`, flag 1).
model.invalidate_region(rh, true);
}
/// [`implicit_creation_anchor`] for the loader, which holds region wrappers.
@@ -538,13 +876,11 @@ pub(crate) fn implicit_creation_anchor_lua(lua: &Lua, wrapper: &Table) -> mlua::
/// The receiver's name for error strings and what a leading `$parent` expands to, the owner frame
/// (`+0x9c`), read under a borrow dropped before the ladder reads `_G`.
pub(super) fn region_ladder_context(lua: &Lua, rh: RegionHandle) -> (String, String) {
let mut model = lua.app_data_mut::<Model>().expect("model");
let id = model.region_id(rh);
let model = lua.app_data_mut::<Model>().expect("model");
let who = model
.region_names
.iter()
.find(|(_, &v)| v == id)
.map(|(n, _)| n.clone())
.arena
.region(rh)
.and_then(|r| r.name.clone())
.unwrap_or_else(|| UNNAMED.to_string());
let owner = model.arena.region(rh).map(|r| r.owner);
let base = super::object::parent_token_base(&model, owner);
@@ -576,43 +912,25 @@ pub(super) fn region_set_point(lua: &Lua, this: &Table, args: &MultiValue) -> ml
let rel_to_id = resolve_rel_target(&model, &p.target, &who, "SetPoint", me, owner)?;
let (point, rel_point, x, y) = (p.point, p.rel_point, p.x, p.y);
// The frame's store and its unchanged-anchor test (`0x767c70`), which queues no resolve.
let units = model.layout_units();
let data = model.region_data.entry(rh).or_default();
let new = Anchor::new(point, rel_to_id, rel_point, x, y);
// A no-op only when the bit-identical anchor is already the tail and no earlier entry has
// this point, as for frames.
let same_at_tail = data.anchors.last().is_some_and(|a| anchor_bits_eq(a, &new))
&& !data.anchors[..data.anchors.len() - 1]
.iter()
.any(|a| a.point == point);
if !same_at_tail {
// The same targets are a value change on this node; a moved target set is a retarget.
let structural = anchor_retarget_is_structural(&data.anchors, &new);
let old_targets: Option<Vec<u32>> =
structural.then(|| data.anchors.iter().map(|a| a.relative_to).collect());
data.anchors.retain(|a| a.point != point);
data.anchors.push(new);
match old_targets {
None => model.touch_layout_region(rh),
Some(old) => {
let new_targets: Vec<u32> = model.region_data[&rh]
.anchors
.iter()
.map(|a| a.relative_to)
.collect();
model.touch_layout_retarget_region(rh, &old, &new_targets);
}
}
if data
.anchors
.set(Anchor::new(point, rel_to_id, rel_point, x, y), units)
{
model.invalidate_region(rh, true);
}
Ok(())
}
/// `Region:GetWidth()`/`GetHeight()` (`0x7a1e00`, `0x7a2030`): the geometry-vtable call the rect
/// resolver also makes (`0x767579`), so the Lua size is the rect's. Per axis, a Texture answers
/// its authored size unless exactly 0, else its texel extent (`0x770720`, `0x770790`). A
/// FontString answers its authored size unless 0 (`0x77294a`), else its measure, either floored
/// at one unit (`0x772930`, `0x772a60`); the width is unwrapped, as `GetStringWidth` gives it
/// (`0x772890`, `0x79e510`), the height wrapped (`0x7729b0`). A title region answers its field
/// (`0x768420`, `0x768410`).
/// A region's own size, the geometry-vtable call (`+0x1c`/`+0x20`) the rect resolver also makes
/// (`0x767579`), which `GetWidth`/`GetHeight` answer when non-zero (`0x7a1e00`, `0x7a2030`). Per
/// axis, a Texture answers its authored size unless exactly 0, else its texel extent (`0x770720`,
/// `0x770790`). A FontString answers its authored size unless 0 (`0x77294a`), else its measure,
/// either floored at one unit (`0x772930`, `0x772a60`); the width is unwrapped, as
/// `GetStringWidth` gives it (`0x772890`, `0x79e510`), the height wrapped (`0x7729b0`). A title
/// region answers its field (`0x768420`, `0x768410`).
pub(super) fn measured_wh(lua: &Lua, this: &Table) -> mlua::Result<(f32, f32)> {
let rh = region_handle_of(lua, this)?;
// Measures this tick when a host font engine is installed; a no-op for a Texture or a
+75 -54
View File
@@ -2,9 +2,11 @@
use mlua::{Lua, MultiValue, Table, Value};
use crate::layout::{Anchor, Point};
use crate::script::object::anchor_args::{parse_set_all_points, resolve_rel_target};
use crate::script::object::{anchor_bits_eq, frame_wrapper, point_name};
use crate::layout::{Anchor, Anchors, Point};
use crate::script::object::anchor_args::{
get_point_index, get_point_values, parse_set_all_points, resolve_rel_target,
};
use crate::script::object::frame_wrapper;
use crate::script::region_map::{set_shared, Side};
use crate::script::{Model, SCREEN};
@@ -13,6 +15,15 @@ use super::{
size_bits_eq,
};
/// `ClearAllPoints` on a region (`0x767ed0` → `0x767620`): the region leaves the cached graph with
/// its edges, and keeps its rect, as the clear queues no resolve, until its own next invalidate.
pub(in crate::script) fn clear_points(model: &mut Model, rh: crate::widget::RegionHandle) {
// Mutates only on a real change.
if model.region_data.entry(rh).or_default().anchors.clear() {
model.touch_layout_region(rh);
}
}
/// Install the layout methods into `m`.
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// The explicit size fills the axes the anchors do not pin, so SetAllPoints's two corners
@@ -29,11 +40,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let new = Some((w, d.size.map_or(0.0, |s| s.1)));
let changed = !size_bits_eq(d.size, new);
d.size = new;
// No edge or roster change; the width, as wrap width, keys a FontString's measure. It
// invalidates whether or not the size moved (`0x7683d0`, `0x7683f0`).
if changed {
// No edge or roster change; the width, as wrap width, keys a FontString's measure.
model.touch_layout_region(rh);
model.touch_measure(rh);
}
model.invalidate_region(rh, changed);
Ok(())
},
)?;
@@ -50,27 +62,32 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let new = Some((d.size.map_or(0.0, |s| s.0), h));
let changed = !size_bits_eq(d.size, new);
d.size = new;
// No edge or roster change; the width, as wrap width, keys a FontString's measure. It
// invalidates whether or not the size moved (`0x7683d0`, `0x7683f0`).
if changed {
// No edge or roster change; the width, as wrap width, keys a FontString's measure.
model.touch_layout_region(rh);
model.touch_measure(rh);
}
model.invalidate_region(rh, changed);
Ok(())
},
)?;
// No `SetSize`: not a 1.12 verb.
// The frame's `GetWidth`/`GetHeight` (`0x7a1e00`, `0x7a2030`): the region's own size
// ([`measured_wh`]) when non-zero, with no resolve; else, resolving a pending region, its
// rect's span, not divided by the owner's effective scale, or 0 with no rect.
set_shared(lua, m, Side::Region, "GetWidth", |lua, this: Table| {
Ok(measured_wh(lua, &this)?.0)
region_size_read(lua, &this, true)
})?;
set_shared(lua, m, Side::Region, "GetHeight", |lua, this: Table| {
Ok(measured_wh(lua, &this)?.1)
region_size_read(lua, &this, false)
})?;
// GetLeft/GetRight/GetTop/GetBottom: the resolved edges in the owner's units (y up, screen
// over the owner's effective scale, which a region shares); nil for a region never resolved.
// over the owner's effective scale, which a region shares); nil while the valid bit is clear.
// The frame's getters (`0x7a1980`..), so they resolve first, as a frame's do.
for (name, pick) in [
("GetLeft", 0u8),
("GetRight", 1u8),
@@ -79,6 +96,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
] {
set_shared(lua, m, Side::Region, name, move |lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
crate::script::object::settle_region(lua, rh);
let model = lua.app_data_ref::<Model>().expect("model");
let inv = 1.0 / owner_scale(&model, rh);
Ok(model.region_resolved.get(&rh).map(|r| {
@@ -110,14 +128,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
// A retarget onto no targets: the cached graph unlinks the edges, no re-derive.
let old: Option<Vec<u32>> =
(!d.anchors.is_empty()).then(|| d.anchors.iter().map(|a| a.relative_to).collect());
d.anchors.clear();
if let Some(old) = old {
model.touch_layout_retarget_region(rh, &old, &[]);
}
clear_points(&mut model, rh);
Ok(())
},
)?;
@@ -141,17 +152,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
Anchor::new(Point::BottomRight, rel_id, Point::BottomRight, 0.0, 0.0),
];
let data = model.region_data.entry(rh).or_default();
let same = data.anchors.len() == 2
&& data
.anchors
.iter()
.zip(&pair)
.all(|(a, b)| anchor_bits_eq(a, b));
if !same {
data.anchors.clear();
data.anchors.extend_from_slice(&pair);
model.touch_layout();
}
// The reference clears, sets both and invalidates (`0x767db0`); a bit-identical pair
// moves no input.
let pair = Anchors::from(pair);
let changed = !data.anchors.bits_eq(&pair);
data.anchors = pair;
model.invalidate_region(rh, changed);
Ok(())
},
)?;
@@ -171,9 +177,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?;
// GetCenter: the resolved rect's midpoint in the owner's units, as the edge readers answer, or
// two nils before the first resolve.
// two nils while the valid bit is clear; it resolves first, as the edge readers do.
set_shared(lua, m, Side::Region, "GetCenter", |lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
crate::script::object::settle_region(lua, rh);
let model = lua.app_data_ref::<Model>().expect("model");
let inv = 1.0 / owner_scale(&model, rh);
Ok(match model.region_resolved.get(&rh) {
@@ -194,48 +201,38 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
.map_or(0, |d| d.anchors.len() as i64))
})?;
// GetPoint([n]): the n-th anchor (1-based, default 1) as point, relativeTo, relativePoint, x
// and y, or five nils. Frames and regions share one id space and a region may anchor to a
// sibling region, so relativeTo is answered with the matching wrapper kind.
// GetPoint([n]): the frame's binding (`0x7a2340`), the n-th anchor in point order. Frames and
// regions share one id space and a region may anchor to a sibling region, so relativeTo is
// answered with the matching wrapper kind.
set_shared(
lua,
m,
Side::Region,
"GetPoint",
|lua, (this, n): (Table, Option<i64>)| {
|lua, (this, n): (Table, Value)| {
let rh = region_handle_of(lua, &this)?;
let idx = get_point_index(lua, n)?;
let anchor = {
let model = lua.app_data_ref::<Model>().expect("model");
let idx = (n.unwrap_or(1).max(1) - 1) as usize;
model
.region_data
.get(&rh)
.and_then(|d| d.anchors.get(idx))
.cloned()
.and_then(|d| d.anchors.nth(idx))
.copied()
};
let Some(a) = anchor else {
return Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil, Value::Nil));
};
let rel = {
get_point_values(lua, anchor, |rel| {
let is_frame = {
let model = lua.app_data_ref::<Model>().expect("model");
model.id_to_frame.contains_key(&a.relative_to)
model.id_to_frame.contains_key(&rel)
};
if a.relative_to == SCREEN {
Ok(if rel == SCREEN {
Value::Nil
} else if is_frame {
Value::Table(frame_wrapper(lua, a.relative_to)?)
Value::Table(frame_wrapper(lua, rel)?)
} else {
Value::Table(super::region_wrapper(lua, a.relative_to)?)
}
};
Ok((
Value::String(lua.create_string(point_name(a.point))?),
rel,
Value::String(lua.create_string(point_name(a.relative_point))?),
Value::Number(f64::from(a.x_off)),
Value::Number(f64::from(a.y_off)),
))
Value::Table(super::region_wrapper(lua, rel)?)
})
})
},
)?;
Ok(())
@@ -250,3 +247,27 @@ fn owner_scale(model: &Model, rh: crate::widget::RegionHandle) -> f32 {
.map(|r| crate::script::object::eff_scale(model, r.owner))
.unwrap_or(1.0)
}
/// The region side of `GetWidth`/`GetHeight`; see the bindings.
fn region_size_read(lua: &Lua, this: &Table, width: bool) -> mlua::Result<f32> {
let (w, h) = measured_wh(lua, this)?;
let set = if width { w } else { h };
let rh = region_handle_of(lua, this)?;
// A FontString's size is floored at one unit (`0x772930`, `0x772a60`), never 0, so it never
// reads its rect; its 0 is a host measure still in flight, which answers 0.
let text = lua
.app_data_ref::<Model>()
.expect("model")
.arena
.region(rh)
.is_some_and(|r| matches!(r.kind, crate::widget::RegionKind::FontString));
if set != 0.0 || text {
return Ok(set);
}
crate::script::object::settle_region(lua, rh);
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model
.region_resolved
.get(&rh)
.map_or(0.0, |r| if width { r.width() } else { r.height() }))
}
+87 -65
View File
@@ -3,7 +3,7 @@
use mlua::{Lua, MultiValue, Table, Value};
use crate::script::object::{as_f32, draw_layer_from_str, draw_layer_name};
use crate::script::object::{draw_layer_from_str, draw_layer_name, tonumber_f32};
use crate::script::{BlendMode, Model, TexCoords};
use super::region_handle_of;
@@ -36,18 +36,26 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
)?;
// SetVertexColor (`0x79abd0`) reads r, g and b with a bare `lua_tonumber`, so nil or a table is
// 0 and never raises; stock `QuestLogFrame.lua:337` can pass three nils. A missing alpha is 1
// here; the reference keeps the region's current alpha (read back at `0x79ac81`, copied over
// at `0x79adfe`/`0x79ae0a`), so there a three-argument call cannot restore opacity.
// 0 and never raises; stock `QuestLogFrame.lua:337` can pass three nils. The alpha is staged
// 1.0 (`0x79ad43`) but re-tested at the end (`0x79adfe`): when it is not a `lua_isnumber` the
// colour read back at the start (`0x79ac81`) lends its alpha byte (`0x79ae0a`), so a
// three-argument call keeps the region's alpha. Stock `ActionButton_UpdateUsable`
// (`ActionButton.lua:274-281`) leans on it: a ring `ActionButton_ShowGrid` dimmed stays dim.
m.set(
"SetVertexColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
let (r, g, b) = (as_f32(&r), as_f32(&g), as_f32(&b));
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = lua.coerce_number(a).ok().flatten().map(|n| n as f32);
let (r, g, b) = (
tonumber_f32(lua, &r),
tonumber_f32(lua, &g),
tonumber_f32(lua, &b),
);
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
d.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]);
let kept = d.vertex_color.map_or(1.0, |c| c[3]);
d.vertex_color = Some([r, g, b, a.unwrap_or(kept)]);
// The slot `SetTextColor` writes on a FontString, so it overrides the font object.
d.font_explicit.color = true;
Ok(())
@@ -90,8 +98,21 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
};
let n = if with_alpha { 8 } else { 6 };
let mut c = [0.0f32; 8];
for slot in c.iter_mut().take(n) {
*slot = it.next().as_ref().map(as_f32).unwrap_or(0.0);
for (i, slot) in c.iter_mut().take(n).enumerate() {
let v = it.next();
// Every channel is a bare `lua_tonumber` (`0x79aef3`-`0x79b083`, `0x79b242`-
// `0x79b490`) except the two alphas, which are `lua_isnumber`-gated with
// default 1 (`0x79b30d`, `0x79b480`).
*slot = match v {
Some(v) if with_alpha && matches!(i, 3 | 7) => lua
.coerce_number(v)
.ok()
.flatten()
.map_or(1.0, |n| n as f32),
Some(v) => tonumber_f32(lua, &v),
None if with_alpha && matches!(i, 3 | 7) => 1.0,
None => 0.0,
};
}
let (start, end) = if with_alpha {
([c[0], c[1], c[2], c[3]], [c[4], c[5], c[6], c[7]])
@@ -121,9 +142,11 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(
|lua, (this, arg, g, b, a): (Table, Value, Value, Value, Value)| {
let rh = region_handle_of(lua, &this)?;
// `lua_isnumber` (`0x79bbd5`) picks the colour form, a numeric string included.
let colour_form = lua.coerce_number(arg.clone()).ok().flatten().is_some();
// Scoped so the model borrow ends before the layout touch; the path arm drops it
// mid-way to call the host probe.
let (loaded, derived) = {
let loaded = {
let mut model = lua.app_data_mut::<Model>().expect("model");
// SetTexture drops any portrait mask and live-unit binding.
let data = model.region_data.entry(rh).or_default();
@@ -135,19 +158,31 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// the colour form (`0x770360`) never writes it.
let same_path = matches!((&arg, &data.texture),
(Value::String(s), Some(cur)) if s.to_str().is_ok_and(|s| *s == **cur));
let colour_form = matches!(&arg, Value::Number(_) | Value::Integer(_));
if !same_path && !colour_form {
data.desaturated = false;
}
// An anchored region with an axis sized 0 takes that span from its art, so a
// new texture moves it; an anchorless region never resolves, so for it this
// is only a paint.
let derived = !data.anchors.is_empty()
&& data.size.is_none_or(|(w, h)| w == 0.0 || h == 0.0);
// Both forms write the one texture slot (`+0xcc`), a file or an 8×8 solid, so
// each clears the other; neither touches the vertex colour (`+0xb8`), so a
// tint outlives its art.
let loaded = match &arg {
// The colour form (`lua_isnumber`, `0x79bbd5`, so a numeric string too): g
// and b are a bare `lua_tonumber`, and alpha is `lua_isnumber`-gated with
// default 1.
_ if colour_form => {
let alpha = lua
.coerce_number(a.clone())
.ok()
.flatten()
.map_or(1.0, |n| n as f32);
data.fill = Some([
tonumber_f32(lua, &arg),
tonumber_f32(lua, &g),
tonumber_f32(lua, &b),
alpha,
]);
data.texture = None;
true
}
// "" clears, as nil does (stock `QuestLogFrame.lua:166`), and answers nil
// like a failed load; the reference's answer for "" is untraced.
Value::String(s) if s.to_str()?.is_empty() => {
@@ -177,23 +212,6 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
}
resolvable && had_probe
}
// The colour form, the only one to read the trailing three; a non-number
// there takes the default a missing one does.
Value::Number(_) | Value::Integer(_) => {
let chan = |v: &Value, dflt: f32| match v {
Value::Number(_) | Value::Integer(_) => as_f32(v),
Value::String(s) => s
.to_str()
.ok()
.and_then(|s| s.parse::<f32>().ok())
.unwrap_or(dflt),
_ => dflt,
};
data.fill =
Some([as_f32(&arg), chan(&g, 0.0), chan(&b, 0.0), chan(&a, 1.0)]);
data.texture = None;
true
}
// nil clears, so the region draws nothing, and answers 1 (`0x79bb40`).
Value::Nil => {
data.texture = None;
@@ -202,15 +220,11 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
}
_ => false,
};
(loaded, derived)
loaded
};
// Only the art-sized shape can move; touching the layout on every repaint would
// reopen the resolve's change gate each frame.
if derived {
lua.app_data_mut::<Model>()
.expect("model")
.touch_layout_region(rh);
}
// The draw only (`0x77fdf0`); an art-sized texture now holds a state its next
// reaching resolve need not give back.
lua.app_data_mut::<Model>().expect("model").note_art(rh);
Ok(if loaded {
Value::Number(1.0)
} else {
@@ -222,10 +236,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// SetDesaturated(flag) (`0x79c1e0`), Texture only, answers shaderSupported, which stock
// `ItemButtonTemplate.lua:69` reads to fall back to a grey tint; the renderer greys the texel,
// so this answers 1. The flag is `GetBoolOrDefault` (`0x6f1c10`, default 1, jump table
// `0x6f1ce8`): no argument is on, hence the `MultiValue`, nil is off, a number truncating to 0
// is off (`0x6f3620`, `0x40a2b0`), and a table, function or userdata is on. Every string is on
// here; the reference's string arm (`0x6f1c51`, against `0x871460` and `0x853758`) is not.
// so this answers 1. The flag is `GetBoolOrDefault` (`0x6f1c10`, default 1, `0x79c27e`): no
// argument is on, hence the `MultiValue`, nil is off, and a string goes by its first byte.
m.set(
"SetDesaturated",
lua.create_function(|lua, args: MultiValue| {
@@ -234,14 +246,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
Some(Value::Table(t)) => t,
_ => return Ok(Value::Nil),
};
let on = match args.next() {
None => true,
Some(Value::Nil) => false,
Some(Value::Boolean(b)) => b,
Some(Value::Integer(i)) => i != 0,
Some(Value::Number(n)) => n.trunc() != 0.0,
Some(_) => true,
};
let on = crate::script::binding_abi::bool_or_default(args.next().as_ref(), true);
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().desaturated = on;
@@ -271,8 +276,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// SetAlphaGradient(start, length), a FontString's write-on reveal: answers whether `start` is
// still inside the text in chars, and stock `QuestFrame.lua:558` advances until it is not.
// SetAlphaGradient(start, length) (`0x79cc80`), a FontString's write-on reveal: answers 1
// (`0x79cd58`) or nil (`0x79cd6d`) for whether `start` is still inside the text in chars, and
// stock `QuestFrame.lua:558` advances until it is not.
m.set(
"SetAlphaGradient",
lua.create_function(|lua, (this, start, length): (Table, f32, f32)| {
@@ -281,7 +287,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let data = model.region_data.entry(rh).or_default();
data.alpha_gradient = Some((start, length));
let chars = data.text.as_deref().map_or(0, |t| t.chars().count());
Ok(start < chars as f32)
Ok(crate::script::binding_abi::flag(start < chars as f32))
})?,
)?;
@@ -316,16 +322,15 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
)?;
// SetTexCoordModifiesRect/GetTexCoordModifiesRect (`0x79c080`/`0x79c120`, table `0x87c128`):
// the setter is the only writer of `[texture+0x124]`, the getter answers 1 or nil. Stored, not
// applied: there a set flag makes `SetTexCoord` re-derive the rect from the UV quad
// (`0x770462`).
// the setter writes `[texture+0x124]` (`0x79c113`), as does installing the texture as a
// StatusBar's bar (`0x783220`); the getter answers 1 or nil. Stored, not applied: there a set
// flag makes `SetTexCoord` re-derive the rect from the UV quad (`0x770462`).
m.set(
"SetTexCoordModifiesRect",
lua.create_function(|lua, (this, arg): (Table, Value)| {
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let rh = region_handle_of(lua, &this)?;
// `GetBoolOrDefault` (`0x6f1c10`) with default 1 (`0x79c105`), but a missing argument
// arrives here as nil and reads false, where the reference sets the flag.
let on = crate::script::binding_abi::bool_or_default(Some(&arg), false);
// `GetBoolOrDefault` (`0x6f1c10`) with default 1 (`0x79c10e`): a bare call sets it.
let on = crate::script::binding_abi::bool_or_default(args.front(), true);
let mut model = lua.app_data_mut::<Model>().expect("model");
model
.region_data
@@ -406,7 +411,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// Each coordinate is a bare `lua_tonumber` (`0x79beb0`), so nil or a table is 0; only the
// count is gated, 4 or 8 (`0x79bf5d`).
lua.create_function(|lua, (this, args): (Table, mlua::Variadic<Value>)| {
let rest: Vec<f32> = args.iter().map(as_f32).collect();
let rest: Vec<f32> = args.iter().map(|v| tonumber_f32(lua, v)).collect();
let rh = region_handle_of(lua, &this)?;
let coords = match rest.len() {
4 => Some(TexCoords::Rect([rest[0], rest[1], rest[2], rest[3]])),
@@ -490,6 +495,23 @@ impl crate::script::UiScript {
/// as `CSimpleTexture::GetWidth` (`0x770720`) falls back to the texels; without one such a
/// region stays where it was.
pub fn set_texture_size_probe(&mut self, probe: crate::script::TextureSizeProbe) {
self.model_mut().texture_size_probe = Some(probe);
let mut model = self.model_mut();
model.texture_size_probe = Some(probe);
// The reference has every art's size at hand (`0x44b3b0` loads it), so a probe installed
// late re-resolves each texture that takes a span from its art, as if it had been there.
let art_sized: Vec<u32> = model
.region_data
.iter()
.filter(|(_, d)| {
!d.anchors.is_empty() && d.size.is_none_or(|(w, h)| w == 0.0 || h == 0.0)
})
.filter_map(|(rh, _)| model.region_to_id.get(rh).copied())
.collect();
for id in art_sized {
model.queue_invalidate(id);
}
// No per-node hash holds a probe's answer: a full-scope rebuild, as for a screen move.
model.layout_scope.invalidate();
model.touch_layout();
}
}
+25 -13
View File
@@ -1,7 +1,7 @@
//! The FontString-only methods: the string, its measured width, justification, non-space wrap and
//! text height. The font block it shares with EditBox comes from [`crate::script::font_block`].
use mlua::{Lua, Table, Value};
use mlua::{Lua, MultiValue, Table, Value};
use crate::justify;
use crate::script::Model;
@@ -18,12 +18,20 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let data = model.region_data.entry(rh).or_default();
let was_shown = data.text.as_deref().is_some_and(|t| !t.is_empty());
let same = data.text == text;
data.text = text;
// Fresh text draws whole: an armed write-on gradient belongs to the old string.
data.alpha_gradient = None;
// SetText must not touch the layout, since the extent has not moved yet; it names
// itself on the measure ledger.
// New text names itself on the measure ledger, and on the layout only when it empties
// or fills, which drops or takes up the measure the sweep reads.
model.touch_measure(rh);
model.touch_layout_text(rh, was_shown);
// A new string clears the valid bit and invalidates (`0x771ea4`); the same one returns
// first (`0x771df1`).
if !same {
model.clear_region_valid(rh);
}
Ok(())
})?,
)?;
@@ -152,15 +160,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
)?;
// SetNonSpaceWrap/CanNonSpaceWrap (`0x79e9f0`/`0x79ead0`), FontString only: the getter
// answers 1 or nil, and a call with no argument enables.
// answers 1 or nil. The flag is `GetBoolOrDefault` (`0x6f1c10`, default 1, `0x79ea7e`): a call
// with no argument enables, an explicit nil disables.
m.set(
"SetNonSpaceWrap",
lua.create_function(|lua, (this, enable): (Table, Value)| {
let on = match &enable {
Value::Nil => true,
Value::Boolean(b) => *b,
_ => true,
};
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let on = crate::script::binding_abi::bool_or_default(args.front(), true);
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().non_space_wrap = Some(on);
@@ -198,7 +203,14 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// ── the shared font block ───────────────────────────────────────────────────────────────
//
// The font object, font, text colour and shadow getters and setters are type-guard shims over
// one implementation each (`0x79f210` SetFont, `0x79f3b0` GetFont), shared with EditBox.
super::super::font_block::install(lua, m, region_handle_of, "FontString", None)
// The font object, font, text colour, shadow and spacing getters and setters are type-guard
// shims over one implementation each (`0x79f210` SetFont, `0x79f3b0` GetFont), shared with
// EditBox; the spacing pair is the FontString's own (`0x79e2b0`, `0x79e1d0`), which clamps.
super::super::font_block::install(
lua,
m,
region_handle_of,
super::super::font_block::FontKind::FontString,
None,
)
}
+40 -5
View File
@@ -14,6 +14,7 @@ use mlua::{Lua, MultiValue, Value};
use super::binding_abi;
use super::Model;
use crate::script::binding_abi::Idx;
/// The synthetic header key for factions the player moved to the inactive bucket.
const KEY_INACTIVE: i64 = -1;
@@ -270,7 +271,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// isCollapsed, isWatched; eleven wide on every path, a miss included.
g.set(
"GetFactionInfo",
lua.create_function(|lua, index: usize| {
lua.create_function(|lua, Idx(index): Idx| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(row) = index
.checked_sub(1)
@@ -397,12 +398,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// IsFactionInactive / SetFactionInactive / SetFactionActive (`0x4d69b0`, `0x4d6a00`): the flip
// is local first too, and moves the row under "Inactive", so the tree regroups here
// (`0x4d69b0` calls `0x4d5c40`); the folds survive, as only a server rebuild resets them.
// IsFactionInactive / SetFactionInactive / SetFactionActive (`0x4d69b0`, `0x4d6a00` →
// `0x4d60f0`): the flip is local first too, and moves the row under "Inactive", so the tree
// regroups here and `0x4d5c40` fires `UPDATE_FACTION` (`0x4d617b`, fire site `0x4d5dab`), which
// the pane repaints on; the folds survive, as only a server rebuild resets them.
g.set(
"IsFactionInactive",
lua.create_function(|lua, index: usize| {
lua.create_function(|lua, Idx(index): Idx| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(binding_abi::flag(
entry_at(&model, index).is_some_and(|e| e.inactive),
@@ -425,6 +427,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
model.reputation_sends.push(send);
let groups = build_groups(&model.reputation.entries);
model.reputation_groups = groups;
drop(model);
super::event::fire_global(lua, "UPDATE_FACTION", &[]);
Ok(())
})?,
)?;
@@ -713,6 +717,37 @@ mod tests {
assert!(s.errors().is_empty(), "{:#?}", s.errors());
}
/// The flip moves the row, and the pane repaints on `UPDATE_FACTION` alone: both verbs raise
/// it after the regroup (`0x4d60f0` → `0x4d5c40`), so a listener already sees the moved row.
#[test]
fn the_inactive_verbs_raise_update_faction_after_the_regroup() {
let s = seated();
s.run(
r#"log = {}
local f = CreateFrame("Frame") f:RegisterEvent("UPDATE_FACTION")
f:SetScript("OnEvent", function()
local n = GetNumFactions()
tinsert(log, (GetFactionInfo(n)))
end)
SetFactionInactive(3)"#,
)
.unwrap();
assert_eq!(
s.eval::<Vec<String>>("return log").unwrap(),
["Inactive"],
"heard once, and already regrouped: Stormwind sits under the new last header"
);
// Unfold Inactive (9 rows: its child is index 9), then bring Stormwind back.
s.run("ExpandFactionHeader(8) log = {}").unwrap();
s.run("SetFactionActive(9)").unwrap();
assert_eq!(
s.eval::<Vec<String>>("return log").unwrap(),
["Bloodsail Buccaneers"],
"heard once more, and the emptied Inactive header is gone"
);
assert!(s.errors().is_empty(), "{:#?}", s.errors());
}
#[test]
fn selection_follows_the_faction_not_the_row_index() {
let mut s = seated();
+139 -97
View File
@@ -1,6 +1,7 @@
//! The `ScrollFrame` methods (`CSimpleScrollFrame`): the scroll child, the two offsets, the two
//! ranges and `UpdateScrollChildRect`, over [`ScrollFrameState`]; the layout's `resolve`,
//! `extract` and `hit_test` place and clip the child. The axes are one mechanism: `0x786d30`
//! ranges and `UpdateScrollChildRect`, over [`ScrollFrameState`], and the frame's own update
//! ([`update`]), which keeps the ranges; the layout's `resolve`, `extract` and `hit_test` place
//! and clip the child. The axes are one mechanism: `0x786d30`
//! (horizontal) is a byte-clone of `0x786db0` (vertical) on `[+0x324]`/`[+0x32c]` for
//! `[+0x328]`/`[+0x334]`, and `0x786e30` measures both ranges in one pass. The methods sit in
//! their own table, so on every other frame kind `SetScrollChild` is nil, as in the client.
@@ -42,9 +43,8 @@ enum Axis {
Vertical,
}
/// `Get{Vertical,Horizontal}ScrollRange()`: `max(0, content − frame)` from the resolved rects,
/// computed on every call, where the reference caches both (`[+0x31c]`, `[+0x320]`) and recomputes
/// them only once marked dirty. The content is the scroll child's whole subtree, not its own rect
/// One range as the recompute `0x786e30` measures it: `max(0, content − frame)` from the resolved
/// rects. The content is the scroll child's whole subtree, not its own rect
/// (`0x786e30` calls the recursive `0x786f80`): stock `ItemTextPageScrollChild` is 10×10 around a
/// 270×304 SimpleHTML (`ItemTextFrame.xml:198`). Each extent is divided by its frame's effective
/// scale, since the offset is in local units; a range in screen px under-scrolls a scaled frame.
@@ -113,29 +113,6 @@ fn subtree_span(model: &Model, f: FrameHandle, axis: Axis) -> Option<(f32, f32)>
span
}
/// Every FontString under `f` (its own regions, then its visible children's), in tree order.
fn subtree_font_strings(model: &Model, f: FrameHandle) -> Vec<crate::widget::RegionHandle> {
let mut out = Vec::new();
let Some(frame) = model.arena.frame(f) else {
return out;
};
for &rh in &frame.regions {
if model
.arena
.region(rh)
.is_some_and(|r| matches!(r.kind, crate::widget::RegionKind::FontString))
{
out.push(rh);
}
}
for &ch in &frame.children {
if model.arena.frame(ch).is_some_and(|c| c.effective_visible) {
out.extend(subtree_font_strings(model, ch));
}
}
out
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
@@ -165,15 +142,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
_ => None,
}
};
let changed = with_scroll(lua, &this, |s| {
let changed = s.child != new_child;
// Every call marks the range (`0x786d16`), the same child again included.
with_scroll(lua, &this, |s| {
s.range_dirty = true;
s.child = new_child;
changed
})?;
if changed {
// The child is an input of the layout resolve, so a real change dirties it.
lua.app_data_mut::<Model>().expect("model").touch_layout();
}
// The old child keeps its point and is not invalidated (`0x786ce6`–`0x786cf4` only
// flags it); the new one is re-anchored on every call (`0x786d11`).
reanchor_child(lua, &this)?;
Ok(())
})?,
)?;
@@ -209,7 +185,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
if !changed {
return Ok(());
}
lua.app_data_mut::<Model>().expect("model").touch_layout();
// The re-anchor at the new offset (`0x786dd8` → `0x787100`).
reanchor_child(lua, &this)?;
fire_scroll(lua, &this, "OnVerticalScroll", px)
})?,
)?;
@@ -217,13 +194,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"GetVerticalScroll",
lua.create_function(|lua, this: Table| with_scroll(lua, &this, |s| s.vertical))?,
)?;
// The stored range (`0x7917d0` reads `[+0x320]` at `0x791873`), stale while the frame is
// hidden or its range unmarked.
m.set(
"GetVerticalScrollRange",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(scroll_range(&model, h, Axis::Vertical))
})?,
lua.create_function(|lua, this: Table| with_scroll(lua, &this, |s| s.range[1]))?,
)?;
// ── The horizontal axis ──
@@ -241,7 +216,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
if !changed {
return Ok(());
}
lua.app_data_mut::<Model>().expect("model").touch_layout();
reanchor_child(lua, &this)?;
fire_scroll(lua, &this, "OnHorizontalScroll", px)
})?,
)?;
@@ -251,63 +226,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
m.set(
"GetHorizontalScrollRange",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(scroll_range(&model, h, Axis::Horizontal))
})?,
lua.create_function(|lua, this: Table| with_scroll(lua, &this, |s| s.range[0]))?,
)?;
// UpdateScrollChildRect(): recomputes both ranges and fires `OnScrollRangeChanged` at once,
// where the reference only marks them dirty (`0x791943`) for its next pump, which fires on a
// change. A script resize reaches the resolved rects only at the app's next resolve, so this
// resolves first.
// UpdateScrollChildRect() (`0x7918b0`) marks the range and does nothing else (`0x791943`):
// the frame's next update while shown recomputes it.
m.set(
"UpdateScrollChildRect",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
// The reference measures text synchronously and ours lands a frame later, so the
// child's FontStrings are measured first: `QuestLog_UpdateQuestDetails` calls this
// right after its SetTexts (`QuestLogFrame.lua:458`).
let strings: Vec<crate::widget::RegionHandle> = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Scroll(sf)) => sf
.child
.map(|c| subtree_font_strings(&model, c))
.unwrap_or_default(),
_ => Vec::new(),
}
};
for rh in strings {
super::measure::ensure_measured(lua, rh);
}
let (id, x_range, y_range) = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
super::UiScript::resolve_layout(&mut model);
let x_range = scroll_range(&model, h, Axis::Horizontal);
let y_range = scroll_range(&model, h, Axis::Vertical);
(model.frame_id(h), x_range, y_range)
};
// The resolve can move sizes, and the reference fires `OnSizeChanged` during layout, so
// those fire before the range notify.
event::fire_size_changes(lua);
// `OnScrollRangeChanged(self, xRange, yRange)`: horizontal first (`0x786eff`), which is
// why stock passes `arg2` on as the vertical range (`UIPanelTemplates.xml:188`).
if let Err(e) = event::fire_widget_handler(
lua,
id,
"OnScrollRangeChanged",
vec![
Value::Number(f64::from(x_range)),
Value::Number(f64::from(y_range)),
],
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
}
Ok(())
})?,
lua.create_function(|lua, this: Table| with_scroll(lua, &this, |s| s.range_dirty = true))?,
)?;
lua.set_named_registry_value(REG_SCROLLFRAME_METHODS, m)?;
@@ -327,7 +252,124 @@ fn fire_scroll(lua: &Lua, this: &Table, script: &str, value: f32) -> mlua::Resul
{
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
Ok(())
}
/// The frame's update (`0x787140`, the walk's slot after its `OnUpdate`), so only while shown. A
/// resize since the last one marks the range, as the size slot `0x7871f0` does (`0x787205`). A
/// marked range is recomputed (`0x787152`–`0x7871a0`, `0x786e30`) unless the frame has no rect
/// (`0x787186`) or no scroll child (`0x786e42`), and the mark is cleared either way, after the
/// notify (`0x7871a5`). The recompute stores both ranges (`0x786eaa`, `0x786ed1`) and fires
/// `OnScrollRangeChanged(self, xRange, yRange)` when either moved by 2⁻²² (`[0x8029d4]`) or more
/// in the reference's units (the screen diagonal as 1) (`0x786ed7`–`0x786efd`), which is that times the
/// layout unit in ours.
///
/// The rects it reads are the ones the pump's drain before the walk (`0x7656ba`) resolved: the
/// recompute reads them through `0x768320`, which never resolves, so a resize made earlier in the
/// walk counts from the next tick, and the size it notes is the one that drain applied.
pub(super) fn update(lua: &Lua, h: FrameHandle) {
let notify = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let rect = model.resolved.get(&h).copied();
let eps = model.size_eps();
let Some(s) = scroll_state(&mut model, h) else {
return;
};
see_size(s, rect, eps);
if !s.range_dirty {
return;
}
let has_child = s.child.is_some();
let measured = (rect.is_some() && has_child).then(|| {
[
scroll_range(&model, h, Axis::Horizontal),
scroll_range(&model, h, Axis::Vertical),
]
});
let Some(s) = scroll_state(&mut model, h) else {
return;
};
let moved = measured.filter(|new| {
let old = std::mem::replace(&mut s.range, *new);
(0..2).any(|i| (f64::from(new[i]) - f64::from(old[i])).abs() >= eps)
});
if moved.is_none() {
s.range_dirty = false;
}
moved.map(|range| (model.frame_id(h), range))
};
let Some((id, [x_range, y_range])) = notify else {
return;
};
// Horizontal first (`0x786eff`), which is why stock passes `arg2` on as the vertical range
// (`UIPanelTemplates.xml:188`).
if let Err(e) = event::fire_widget_handler(
lua,
id,
"OnScrollRangeChanged",
vec![
Value::Number(f64::from(x_range)),
Value::Number(f64::from(y_range)),
],
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_lua_error(&e);
}
// The mark clears after the notify, so one the handler set is dropped.
if let Some(s) = scroll_state(&mut lua.app_data_mut::<Model>().expect("model app_data"), h) {
s.range_dirty = false;
}
}
/// Frame `h`'s ScrollFrame state, if it is a live ScrollFrame.
fn scroll_state(model: &mut Model, h: FrameHandle) -> Option<&mut ScrollFrameState> {
match model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
Some(KindState::Scroll(s)) => Some(s),
_ => None,
}
}
/// Note the frame's resolved size; a change since the last note marks the range, by the width or
/// height move that fires `OnSizeChanged` ([`crate::layout::size_changed`] at `eps`,
/// [`Model::size_eps`]). No rect, no note.
fn see_size(s: &mut ScrollFrameState, rect: Option<crate::layout::Rect>, eps: f64) {
let Some(r) = rect else { return };
let size = [r.width(), r.height()];
let moved = |i: usize| (f64::from(size[i]) - f64::from(s.seen_size[i])).abs() >= eps;
if moved(0) || moved(1) {
s.range_dirty = true;
}
s.seen_size = size;
}
/// The re-anchor (`0x787100`): when the frame holds a child, clear its points (`0x787110` →
/// `0x767620`) and set one TOPLEFT on the frame's TOPLEFT at the raw `(horizontal, vertical)`
/// offsets, y up, with the invalidate flag (`0x787136`). A real write on the child: its own
/// points are gone, and it keeps the new one when the frame lets it go.
fn reanchor_child(lua: &Lua, this: &Table) -> mlua::Result<()> {
let (child, x, y) = with_scroll(lua, this, |s| (s.child, s.horizontal, s.vertical))?;
let Some(child) = child else {
return Ok(());
};
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if model.arena.frame(child).is_none() {
return Ok(());
}
let sid = model.frame_id(h);
let pin = crate::layout::Anchors::from([crate::layout::Anchor::new(
crate::layout::Point::TopLeft,
sid,
crate::layout::Point::TopLeft,
x,
y,
)]);
let input = model.layout_inputs.entry(child).or_default();
let changed = !input.anchors.bits_eq(&pin);
input.anchors = pin;
model.invalidate_frame(child, changed);
Ok(())
}
+17 -3
View File
@@ -67,6 +67,17 @@ fn form_at(model: &Model, i: u32) -> Option<&StoredShapeshiftForm> {
.and_then(|n| model.shapeshift_forms.get(n))
}
/// The index of the two form getters: `lua_tonumber` (`0x6f3620`) compared with 0.0 (`0x4b45d8`,
/// `0x4b49b4`), so a zero, an absent or a non-number raises the binding's usage error (`0x846a6c`,
/// `0x846ab4`) before the table is read; the truncated value is then compared unsigned.
fn form_index(lua: &Lua, index: Option<Value>, usage: &'static str) -> mlua::Result<u32> {
let n = crate::script::binding_abi::coerced_number(lua, index);
if n == 0.0 {
return Err(mlua::Error::RuntimeError(usage.into()));
}
Ok(crate::script::binding_abi::ftol(n) as u32)
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
@@ -80,10 +91,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"GetShapeshiftFormInfo",
lua.create_function(|lua, i: u32| {
lua.create_function(|lua, index: Option<Value>| {
let i = form_index(lua, index, "Usage: GetShapeshiftFormInfo(index)")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(form) = form_at(&model, i) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
// `0x4b47d9`: four nils.
return Ok(super::binding_abi::tuple(&[None; 4]));
};
let texture = match &form.view.texture {
Some(t) => Value::String(lua.create_string(t)?),
@@ -103,7 +116,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// replays the sweep.
g.set(
"GetShapeshiftFormCooldown",
lua.create_function(|lua, i: u32| {
lua.create_function(|lua, index: Option<Value>| {
let i = form_index(lua, index, "Usage: GetShapeshiftFormCooldown(index)")?;
let now = crate::script::clock::now(lua);
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match form_at(&model, i).and_then(|f| f.cooldown) {
+66 -44
View File
@@ -11,10 +11,10 @@
//! ScrollFrame measures the blocks as regions (`0x786e30`).
//!
//! Deviation: the block step is not pixel-snapped (`0x766750`), because layout here is in logical
//! units; the two differ by under a pixel, and not at all at the default spacing of 0. Line spacing
//! inside a block is not applied, as nothing here models line spacing; `SetSpacing` sets the step
//! between blocks. Deviation: a block of width 0, in a frame sized by anchors, gets its text's
//! natural width where the reference keeps 0, because every width-less FontString here does.
//! units; the two differ by under a pixel, and not at all at the default spacing of 0. The spacing
//! is also each block's line gap, as the block FontString inherits it. Deviation: a block of width
//! 0, in a frame sized by anchors, gets its text's natural width where the reference keeps 0,
//! because every width-less FontString here does.
//!
//! The method table (`0x87ba80`) has 19 entries, all installed; the first sixteen take an optional
//! leading element name (`0x795d80`). Build 5875 has no `GetText`.
@@ -26,7 +26,7 @@ use mlua::{FromLua, Lua, MultiValue, Table, Value};
use super::object::frame_handle_of;
use super::{FontObject, FontShadow, Model, Outline, RegionData};
use crate::justify::{self, Justify};
use crate::layout::{Anchor, Point};
use crate::layout::{Anchor, Anchors, Point};
use crate::order::DrawLayer;
use crate::widget::{FrameHandle, FrameKind, RegionHandle, RegionKind};
@@ -62,7 +62,8 @@ pub(crate) struct ElementFont {
pub(crate) shadow: Option<FontShadow>,
/// `CSimpleFont+0x54`, ctor `0x212` (`0x783a98`): CENTER, MIDDLE and the one-to-one bit.
pub(crate) justify: Justify,
/// `+0x50`, ctor 0 (`0x783a81`): the step between blocks and, in the reference, the line gap.
/// `+0x50`, ctor 0 (`0x783a81`), as set, a negative included: the step between blocks and each
/// block's line gap, both read through the block FontString's clamp (`0x772240`).
pub(crate) spacing: f32,
}
@@ -129,6 +130,12 @@ fn resolve_font(model: &Model, ef: &ElementFont) -> BlockPaint {
if let Some(j) = fo.justify_v {
p.justify.set_v(j);
}
if let Some(sp) = fo.spacing {
p.spacing = sp;
}
}
if ef.explicit.spacing {
p.spacing = ef.spacing;
}
if ef.explicit.face {
p.font_path = ef.font_path.clone();
@@ -159,16 +166,14 @@ fn resolve_font(model: &Model, ef: &ElementFont) -> BlockPaint {
/// `CSimpleHTML::SetText` (`0x78a3a0`): free the old blocks, parse, build. Returns `usedMarkup`
/// (`0x78a519`), which the Lua shim drops.
pub(crate) fn set_text(model: &mut Model, fh: FrameHandle, raw: &str) -> bool {
// Free the previous parse's regions first, as the reference does, or a book's next page
// draws over the last.
// Free the previous parse's regions first, as the reference does (`0x78a580`), or a book's
// next page draws over the last.
let old = model
.simple_html
.get_mut(&fh)
.map(|s| std::mem::take(&mut s.blocks))
.unwrap_or_default();
for rh in old {
free_block(model, rh);
}
crate::script::region::free_regions(model, &old);
let (frame_name, hyperlink_format) = {
let name = model
@@ -234,7 +239,12 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) {
};
let id = model.region_id(rh);
let mut d = RegionData {
anchors: vec![anchor_for(prev_block, frame_id, Point::TopLeft, next_y)],
anchors: Anchors::from([anchor_for(
prev_block,
frame_id,
Point::TopLeft,
next_y,
)]),
// No height: the rect takes the wrapped text's height from the measure round
// trip, as the client's font engine gives it (`0x7729b0`, `0x5c2070`).
size: Some((width, 0.0)),
@@ -246,6 +256,7 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) {
outline: paint.outline,
vertex_color: paint.color,
font_shadow: paint.shadow,
spacing: paint.spacing,
..RegionData::default()
};
// The tag's `align` is written after `SetFontObject` (`0x78ae78`), so it beats
@@ -258,7 +269,8 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) {
model.region_data.insert(rh, d);
model.touch_measure(rh); // a text block arrives with its text
prev_block = Some(id);
next_y = -paint.spacing;
// The block FontString's field, which its setter clamped (`0x78aeaf`).
next_y = -paint.spacing.max(0.0);
made.push(rh);
}
Block::Image {
@@ -275,7 +287,7 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) {
else {
break; // unreachable, as above
};
// Minted though nothing anchors to an image; the resolve and `free_block` use it.
// Minted though nothing anchors to an image; the resolve and the free use it.
model.region_id(rh);
// `align` picks the corner; 8, 16 and 32 get no anchor at all (`0x78ac61`, a jump
// to `0x78ace8`), so the image draws nothing, here as there.
@@ -286,7 +298,7 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) {
_ => None,
};
let anchors = point
.map(|p| vec![anchor_for(prev_block, frame_id, p, next_y)])
.map(|p| Anchors::from([anchor_for(prev_block, frame_id, p, next_y)]))
.unwrap_or_default();
model.region_data.insert(
rh,
@@ -318,8 +330,12 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) {
}
}
// Each new block's `SetPoint` invalidates it (`0x78ae0c`, flag 1); the old ones' frees named
// theirs.
for &rh in &made {
model.invalidate_region(rh, true);
}
model.simple_html.entry(fh).or_default().blocks = made;
model.touch_layout();
}
/// A block's anchor (`SetPoint` `0x767c70`): block 0 to the frame, block N to the previous text
@@ -339,10 +355,6 @@ fn anchor_for(prev_block: Option<u32>, frame_id: u32, point: Point, next_y: f32)
}
}
fn free_block(model: &mut Model, rh: RegionHandle) {
crate::script::region::free_region(model, rh);
}
// ── The loader's seam ────────────────────────────────────────────────────────────────────────
/// Apply the `font=`, `<FontHeight>` and `outline=` an XML `<FontString>` or `<FontStringHeaderN>`
@@ -485,6 +497,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
ef.color = p.color;
ef.shadow = p.shadow;
ef.justify = p.justify;
ef.spacing = p.spacing;
ef.explicit = super::FontExplicit {
face: ef.font_path.is_some(),
height: ef.font_height.is_some(),
@@ -493,6 +506,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
shadow: ef.shadow.is_some(),
justify_h: true,
justify_v: true,
spacing: true,
};
}
// A re-point keeps the severance mask, as on a region: each local setter clears
@@ -522,33 +536,36 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// ── 2/3 · the face ──────────────────────────────────────────────────────────────────────
// SetFont([element,] file, height [, flags]) returns 1, or nil on an empty path; the shared
// gate (`0x79f210`) takes numeric strings and raises `0x87c69c`'s usage line on anything else.
// SetFont([element,] file, height [, flags]) returns 1, or nil, writing nothing, on a failed
// load; the shared gate (`0x79f210`) takes numeric strings and raises `0x87c69c`'s usage line
// on anything else.
m.set(
"SetFont",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let (path, height) =
super::font_block::set_font_args(&arg(&rest, 0), &arg(&rest, 1), "SimpleHTML")?;
let flags = match arg(&rest, 2) {
Value::String(s) => Some(s.to_str()?.to_string()),
_ => None,
};
let ok = !path.is_empty();
let super::font_block::FontArgs {
path,
height,
flags,
} = super::font_block::set_font_args(
lua,
&this,
&arg(&rest, 0),
&arg(&rest, 1),
&arg(&rest, 2),
)?;
if !super::font_block::instance_loads(lua, &path, height) {
return Ok(Value::Nil);
}
edit_font(lua, &this, elem, |ef| {
if ok {
ef.font_path = Some(path);
ef.explicit.face = true;
}
ef.font_path = Some(path);
ef.explicit.face = true;
ef.outline = flags;
ef.explicit.outline = true;
ef.font_height = Some(height);
ef.explicit.height = true;
if let Some(f) = flags {
// The Lua flags spelling, not XML's `outline=` ([`Outline::flags`]).
ef.outline = Outline::flags(&f);
ef.explicit.outline = true;
}
})?;
Ok(if ok { Value::Number(1.0) } else { Value::Nil })
Ok(Value::Number(1.0))
})?,
)?;
// GetFont([element]): path, height and a flags string, `""` rather than nil.
@@ -642,14 +659,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// ── 10/11 · spacing ─────────────────────────────────────────────────────────────────────
// SetSpacing([element,] n) clamps a negative to 0 (`0x772240`, `0x772246`); it is the only gap
// between blocks, which stack flush at the ctor default of 0.
// SetSpacing([element,] n) is the shared setter (`0x7965b0` → `0x79fb40`): the number as given,
// which the blocks read through their FontString's clamp. It is the only gap between blocks,
// which stack flush at the ctor default of 0.
m.set(
"SetSpacing",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let n = f32::from_lua(arg(&rest, 0), lua)?.max(0.0);
edit_font(lua, &this, elem, |ef| ef.spacing = n)
let n = super::font_block::spacing_arg(lua, &this, &arg(&rest, 0))?;
edit_font(lua, &this, elem, |ef| {
ef.spacing = n;
ef.explicit.spacing = true;
})
})?,
)?;
m.set(
@@ -713,7 +734,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let raw = match &text {
// Bytes, lossily; never a raise.
Value::String(s) => s.to_string_lossy(),
Value::Number(_) | Value::Integer(_) => super::object::as_f32(&text).to_string(),
Value::Number(n) => (*n as f32).to_string(),
Value::Integer(i) => (*i as f32).to_string(),
_ => String::new(),
};
let h = html_handle(lua, &this)?;
@@ -207,8 +207,8 @@ fn image(node: roxmltree::Node) -> Block {
}
Block::Image {
src: attr_ci(node, "src").map(str::to_string),
width: atof(attr_ci(node, "width").unwrap_or("")),
height: atof(attr_ci(node, "height").unwrap_or("")),
width: crate::framexml::atof(attr_ci(node, "width").unwrap_or("")),
height: crate::framexml::atof(attr_ci(node, "height").unwrap_or("")),
align,
floated,
}
@@ -273,43 +273,6 @@ fn sprintf_two(fmt: &str, a: &str, b: &str) -> String {
out
}
/// C's `atof` (`0x64aaa0`) for `width=` and `height=`: the longest numeric prefix after leading
/// whitespace, 0 when there is none, so `"12px"` is 12.
fn atof(s: &str) -> f32 {
let b = s.as_bytes();
let mut i = 0;
while i < b.len() && (b[i] as char).is_ascii_whitespace() {
i += 1;
}
let start = i;
if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
i += 1;
}
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
if i < b.len() && b[i] == b'.' {
i += 1;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
}
// An exponent counts only when complete: `"1e"` is 1, as `strtod` backtracks.
if i < b.len() && (b[i] | 0x20) == b'e' {
let mut j = i + 1;
if j < b.len() && (b[j] == b'+' || b[j] == b'-') {
j += 1;
}
if j < b.len() && b[j].is_ascii_digit() {
while j < b.len() && b[j].is_ascii_digit() {
j += 1;
}
i = j;
}
}
s[start..i].parse::<f32>().unwrap_or(0.0)
}
/// The whitespace collapse of markup blocks (`0x78a8af`-`0x78aa87`; the fallback at `0x78a501`
/// skips it): leading and trailing whitespace dropped, each run of tabs, newlines, CRs and spaces
/// made one space, and whitespace on either side of `|n` removed. Its arms: whitespace
@@ -383,17 +346,6 @@ mod tests {
assert_eq!(collapse(" "), "");
}
#[test]
fn atof_takes_the_numeric_prefix() {
assert_eq!(atof("64"), 64.0);
assert_eq!(atof(" 12.5px"), 12.5);
assert_eq!(atof("-3"), -3.0);
assert_eq!(atof("px"), 0.0);
assert_eq!(atof(""), 0.0);
assert_eq!(atof("1e2"), 100.0);
assert_eq!(atof("1e"), 1.0);
}
#[test]
fn hyperlink_format_takes_exactly_two_strings() {
assert_eq!(
+2 -1
View File
@@ -18,6 +18,7 @@ use mlua::{Lua, MultiValue, Value};
use super::binding_abi;
use super::Model;
use crate::script::binding_abi::Idx;
/// One known skill line off the player's `PLAYER_SKILL_INFO` block, resolved by the app.
#[derive(Clone, Debug, PartialEq)]
@@ -280,7 +281,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// stock `SkillFrame.lua:211`'s early return.
g.set(
"GetSkillLineInfo",
lua.create_function(|lua, index: usize| {
lua.create_function(|lua, Idx(index): Idx| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(n) = index.checked_sub(1) else {
return out_of_range_tuple(lua);
+2 -1
View File
@@ -15,7 +15,8 @@ impl super::UiScript {
// The rest of the line after the alias and one space, spaces intact: the reference's
// `strsub(text, strlen(cmdString) + 2)`.
if let Err(e) = handler.call::<()>(args.to_string()) {
self.model_mut().record_script_error(format!("/{cmd}: {e}"));
self.model_mut()
.record_script_error(format!("/{cmd}: {e}"), super::stdlib::lua_message(&e));
}
true
}
+89 -80
View File
@@ -8,14 +8,16 @@
use mlua::{Lua, Table, Value};
use super::object::{draw_layer_from_str, frame_handle_of};
use super::object::frame_handle_of;
use super::pointer::point_in_rect;
use super::region::region_wrapper;
use super::{event, Model, RegionData};
use super::region::{
install_into_slot, new_texture_region, region_wrapper, set_texture_path, slot_arg, SlotArg,
};
use super::{event, Model};
use crate::layout::Rect;
use crate::order::DrawLayer;
use crate::widget::{
slider_fraction, slider_grab, FrameHandle, KindState, RegionKind, SliderState,
slider_fraction, slider_grab, FrameHandle, KindState, RegionHandle, SliderState,
};
pub(super) const REG_SLIDER_METHODS: &str = "__benilla_slider_methods";
@@ -39,12 +41,58 @@ fn with_slider<T>(
}
}
/// Get or create the thumb texture region and return its id; `layer` re-layers an existing thumb,
/// and a new one defaults to OVERLAY (`0x789580`).
fn ensure_thumb(lua: &Lua, this: &Table, layer: Option<DrawLayer>) -> mlua::Result<u32> {
/// Install `incoming` as the thumb on `layer`, or clear it, through `0x789860`: the same texture
/// is a no-op, the displaced one is deleted, the incoming one is linked in shown and loses its
/// anchors (`0x789893`), the widget placing it.
fn install_thumb_model(
model: &mut Model,
h: FrameHandle,
incoming: Option<RegionHandle>,
layer: DrawLayer,
) -> mlua::Result<()> {
if !matches!(
model.arena.frame(h).map(|f| &f.kind_state),
Some(KindState::Slider(_))
) {
return Err(mlua::Error::runtime("not a Slider"));
}
install_into_slot(
model,
h,
incoming,
layer,
true,
true,
|ks| match ks {
KindState::Slider(sl) => sl.thumb,
_ => None,
},
|ks, rh| {
if let KindState::Slider(sl) = ks {
sl.thumb = rh;
}
},
)
.map(|_| ())
}
/// `<ThumbTexture>` (`0x7895f6`): the texture the texture adder built, installed as an object on
/// the element's `drawLayer`, OVERLAY without one.
pub(super) fn install_loaded(
model: &mut Model,
h: FrameHandle,
rh: RegionHandle,
layer: Option<DrawLayer>,
) -> mlua::Result<()> {
install_thumb_model(model, h, Some(rh), layer.unwrap_or(DrawLayer::Overlay))
}
/// `SetThumbTexture`'s path leg (`0x7897c0`): a thumb that exists has its file swapped and nothing
/// else; with none, a texture is built, given the file and installed on OVERLAY (`0x790431`'s
/// layer).
fn set_thumb_path(lua: &Lua, this: &Table, path: &str) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let existing = match &model
.arena
.frame(h)
@@ -54,35 +102,16 @@ fn ensure_thumb(lua: &Lua, this: &Table, layer: Option<DrawLayer>) -> mlua::Resu
KindState::Slider(s) => s.thumb,
_ => return Err(mlua::Error::runtime("not a Slider")),
};
let rh = match existing {
Some(rh) => {
if let (Some(l), Some(region)) = (layer, model.arena.region_mut(rh)) {
region.draw_layer = l;
}
rh
}
match existing {
Some(rh) => set_texture_path(&mut model, rh, path),
None => {
let rh = model
.arena
.create_region(
h,
RegionKind::Texture,
layer.unwrap_or(DrawLayer::Overlay),
0,
)
let rh = new_texture_region(&mut model, h, DrawLayer::Overlay)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
model.region_data.insert(rh, RegionData::default());
model.touch_layout(); // a region entered the layout gate's read set
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::Slider(s) = &mut frame.kind_state {
s.thumb = Some(rh);
}
}
rh
set_texture_path(&mut model, rh, path);
install_thumb_model(&mut model, h, Some(rh), DrawLayer::Overlay)?;
}
};
Ok(model.region_id(rh))
}
Ok(())
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
@@ -151,46 +180,34 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// A 1.12 Slider has no enabled state: `Enable`/`Disable`/`IsEnabled` exist only in the Button
// table (`0x879d00`: `0x77fef0`, `0x77ffd0`, `0x7800b0`), so a Slider must not answer them.
// `SetThumbTexture(path [, layer])` or `(r, g, b [, a])`, a region's two `SetTexture` forms, on
// the thumb region, created on first use.
// `SetThumbTexture(texture or "texture" or nil)` (`0x7902b0`): a Texture object is installed as
// the thumb, linked on OVERLAY (the constant `0x790431` passes) with its anchors cleared
// (`0x789893`), displacing the old thumb; a string or a number is the thumb's path
// (`0x790442`), and nil clears it (`0x790473`). It reads no layer and has no colour form.
m.set(
"SetThumbTexture",
lua.create_function(
|lua, (this, a1, a2, a3, a4): (Table, Value, Value, Value, Value)| {
let layer = match &a2 {
Value::String(s) => s.to_str().ok().and_then(|l| draw_layer_from_str(&l)),
_ => None,
};
let id = ensure_thumb(lua, &this, layer)?;
let rh = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
*model.id_to_region.get(&id).expect("thumb region id")
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let data = model.region_data.entry(rh).or_default();
match &a1 {
Value::String(s) => {
data.texture = Some(s.to_str()?.to_string());
data.fill = None;
}
// A solid thumb and a path share one slot; each clears the other.
Value::Number(_) | Value::Integer(_) => {
data.texture = None;
data.fill = Some([
num_f32(&a1),
num_f32(&a2),
num_f32(&a3),
match &a4 {
Value::Nil => 1.0,
v => num_f32(v),
},
]);
}
_ => {}
lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| {
match slot_arg(
lua,
&this,
&args,
r#"SetThumbTexture(texture or "texture" or nil)"#,
"SetThumbTexture",
true,
)? {
SlotArg::Object(rh) => {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
install_thumb_model(&mut model, h, Some(rh), DrawLayer::Overlay)
}
Ok(())
},
)?,
SlotArg::Clear => {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
install_thumb_model(&mut model, h, None, DrawLayer::Overlay)
}
SlotArg::Path(path) => set_thumb_path(lua, &this, &path),
}
})?,
)?;
m.set(
"GetThumbTexture",
@@ -228,19 +245,11 @@ fn fire_value_changed(lua: &Lua, this: &Table, changed: Option<f32>) -> mlua::Re
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
Ok(())
}
fn num_f32(v: &Value) -> f32 {
match v {
Value::Number(n) => *n as f32,
Value::Integer(i) => *i as f32,
_ => 0.0,
}
}
// ── Thumb geometry and drag ──────────────────────────────────────────────────────────────────
/// The thumb's rect at `fraction` along the track, centred across it and inset so it travels
+5 -9
View File
@@ -6,7 +6,7 @@
use mlua::{Lua, Value};
use super::binding_abi::string_arg;
use super::binding_abi::{bool_or_default, string_arg};
use super::who_sort::WhoSortChain;
use super::Model;
@@ -437,16 +437,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// SetWhoToUI(flag): the Who frame passes 1 on show and 0 on hide; nil, false and 0 are off.
// SetWhoToUI(flag) (`0x5ad870`): the Who frame passes 1 on show and 0 on hide; the flag goes
// through `GetBoolOrDefault` (`0x5ad877`) with default 0.
g.set(
"SetWhoToUI",
lua.create_function(|lua, flag: Value| {
let on = match flag {
Value::Nil | Value::Boolean(false) => false,
Value::Integer(0) => false,
Value::Number(n) => n != 0.0,
_ => true,
};
lua.create_function(|lua, flag: Option<Value>| {
let on = bool_or_default(flag.as_ref(), false);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.social_requests.push(SocialRequest::SetWhoToUi(on));
Ok(())
+98 -84
View File
@@ -12,7 +12,8 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::{bool_or_default, flag};
use super::cursor::{queue_cursor_update, CursorPayload, CursorSpell};
use super::cursor::{clear_cursor, set_cursor, CursorPayload, CursorSpell};
use super::tick::verb;
use super::{Model, ScriptCall};
const BOOKTYPE_SPELL: &str = "spell";
@@ -169,7 +170,11 @@ pub(super) fn is_pet_book(book_type: &str) -> bool {
/// The reference's shared spell-slot marshaller (`0x4b3ec0`): arg 1 a number, arg 2 a number or
/// string (`0x4b3ee8`), and the index `trunc(arg1 - 1)` in `[0, 0x400)`, else the binding raises
/// `Invalid spell slot in <Verb>`. Answers the 1-based id and the normalised book type.
fn spell_slot_args(id: Value, book_type: Value, verb: &str) -> mlua::Result<(u32, String)> {
pub(super) fn spell_slot_args(
id: Value,
book_type: Value,
verb: &str,
) -> mlua::Result<(u32, String)> {
let invalid = || mlua::Error::runtime(format!("Invalid spell slot in {verb}"));
// `lua_isnumber` + `lua_tonumber` (`0x6f34d0`/`0x6f3620`): a numeric string is a number.
let index = match id {
@@ -187,8 +192,10 @@ fn spell_slot_args(id: Value, book_type: Value, verb: &str) -> mlua::Result<(u32
Value::Integer(_) | Value::Number(_) => String::new(),
_ => return Err(invalid()),
};
let index = (index - 1.0).trunc();
if !(0.0..1024.0).contains(&index) {
// `fsub 1.0` then `_ftol` (`0x4b3efd`, `0x532dd9`): the cast wraps to its low dword, so
// 4294967297 is slot 1, and the signed result must lie in `[0, 0x400)`.
let index = super::binding_abi::ftol(index - 1.0);
if !(0..1024).contains(&index) {
return Err(invalid());
}
let book = if is_pet_book(&book) {
@@ -281,43 +288,54 @@ fn rank_number(subtext: Option<&str>) -> u32 {
.fold(0u32, |acc, c| acc * 10 + c.to_digit(10).unwrap_or(0))
}
/// `PickupSpell(id, bookType)`, the spell button's drag and shift-click
/// (`SpellBookFrame.lua:266-290`). It refuses while the cursor holds anything, where the
/// reference's setters clear the cursor first (`0x495190`, called at `0x494dab` and `0x494f00`).
fn pickup_spell(model: &mut Model, id: u32, book_type: &str) -> bool {
if model.cursor.is_some() {
/// `PickupSpell(id, bookType)` (`0x4b42a0` into `0x4b3260`), the spell button's drag and
/// shift-click (`SpellBookFrame.lua:266-290`). It reads the held spell, then clears with
/// `ClearCursor(1,1)` (`0x4b3293`) whatever is held. Picking up the spell already held only puts
/// it down (`0x4b329a`), and with another spell held the clear is all (`0x4b32aa`). Otherwise the
/// slot's setter runs, its own inner clear signalling again on the empty cursor: the pet arm puts
/// the pet's raw word on the cursor (`0x494e20`, cursor mode 4), the player arm a spell id
/// (`0x494d20`, mode 3); only the pet word drops on the pet bar. Returns whether a payload went
/// on.
fn pickup_spell(lua: &Lua, id: u32, book_type: &str) -> bool {
let (payload, target, held) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let held = match &model.cursor {
Some(CursorPayload::Spell(s)) => s.spell_id,
_ => 0,
};
let Some(slot) = book_slot(&model, id, book_type) else {
drop(model);
verb(lua, clear_cursor);
return false;
};
let payload = if is_pet_book(book_type) {
// `0x494e20` refuses type 0 and types 8 and up, before its clear.
let packed = slot.packed;
(1..=7).contains(&((packed >> 24) & 0x3F)).then(|| {
CursorPayload::PetAction(super::cursor::CursorPetAction {
// No source slot: the word comes from the book, not the bar.
src_slot: 0,
packed,
passive: slot.passive,
texture: slot.texture.clone(),
})
})
} else {
Some(CursorPayload::Spell(CursorSpell {
book_slot: id,
book_type: book_type.to_string(),
spell_id: slot.spell_id,
texture: slot.texture.clone(),
passive: slot.passive,
}))
};
(payload, slot.spell_id, held)
};
verb(lua, clear_cursor);
if target == held || held != 0 {
return false;
}
let Some(slot) = book_slot(model, id, book_type) else {
return false;
};
// The pet arm of `0x4b3260` puts the pet's raw word on the cursor (`0x494e20`, cursor mode 4),
// the player arm a spell id (`0x494d20`, mode 3); only the pet word drops on the pet bar.
let payload = if is_pet_book(book_type) {
// `0x494e20` refuses type 0 and types 8 and up.
let packed = slot.packed;
if !(1..=7).contains(&((packed >> 24) & 0x3F)) {
return false;
}
CursorPayload::PetAction(super::cursor::CursorPetAction {
// No source slot: the word comes from the book, not the bar.
src_slot: 0,
packed,
passive: slot.passive,
texture: slot.texture.clone(),
})
} else {
CursorPayload::Spell(CursorSpell {
book_slot: id,
book_type: book_type.to_string(),
spell_id: slot.spell_id,
texture: slot.texture.clone(),
passive: slot.passive,
})
};
model.cursor = Some(payload);
queue_cursor_update(model);
true
payload.is_some_and(|payload| set_cursor(lua, true, |_| Some(payload)))
}
/// Register the spellbook globals.
@@ -581,8 +599,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"PickupSpell",
lua.create_function(|lua, (id, book_type): (Value, Value)| {
let (id, book_type) = spell_slot_args(id, book_type, "PickupSpell")?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(pickup_spell(&mut model, id, &book_type))
pickup_spell(lua, id, &book_type);
Ok(())
})?,
)?;
@@ -655,7 +673,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
#[cfg(test)]
mod tests {
use super::{PetBookState, SpellBookState, SpellSlotView, SpellTabView};
use super::{pickup_spell, PetBookState, SpellBookState, SpellSlotView, SpellTabView};
use crate::script::cursor::{CursorAction, CursorPayload};
use crate::script::UiScript;
@@ -912,8 +930,7 @@ mod tests {
let mut s = UiScript::new().unwrap();
s.set_spellbook(book());
s.run(r#"picked = PickupSpell(1, BOOKTYPE_SPELL)"#).unwrap();
assert!(s.eval::<bool>("return picked").unwrap());
s.run(r#"PickupSpell(1, BOOKTYPE_SPELL)"#).unwrap();
assert!(s.cursor_payload().is_some());
assert!(
matches!(
@@ -925,8 +942,7 @@ mod tests {
s.cursor_payload()
);
// Tick first to flush the first pickup's `CURSOR_UPDATE`, so the count below is the
// refused call's alone.
// The pickup's events fired inside it; the listener below counts the next call's alone.
s.tick(0.0);
s.run(
r#"
@@ -937,48 +953,52 @@ mod tests {
"#,
)
.unwrap();
s.run(r#"PickupSpell(3, BOOKTYPE_SPELL)"#).unwrap(); // already holding -> refused, no-op
// Another spell held: the binding's clear is all (`0x4b3293`, `0x4b32aa`).
s.run(r#"PickupSpell(3, BOOKTYPE_SPELL)"#).unwrap();
s.tick(0.01);
assert_eq!(
s.eval::<i64>("return cursorUpdates").unwrap(),
0,
"refused pickup fires no CURSOR_UPDATE"
);
// Still holding the first pickup: a refusal never clobbers it.
assert!(
matches!(
s.cursor_payload(),
Some(crate::script::CursorPayload::Spell(c))
if c.book_slot == 1 && c.book_type == "spell" && c.spell_id == 133
),
"{:?}",
s.cursor_payload()
);
assert_eq!(s.eval::<i64>("return cursorUpdates").unwrap(), 1);
assert!(s.cursor_payload().is_none(), "{:?}", s.cursor_payload());
}
#[test]
fn pickup_spell_refuses_while_already_holding_any_payload() {
fn pickup_spell_clears_a_held_payload_first() {
let mut s = UiScript::new().unwrap();
s.set_spellbook(book());
crate::script::tick::heard::listen(
&s,
&["CURSOR_UPDATE", "ACTIONBAR_SHOWGRID", "ACTIONBAR_HIDEGRID"],
Some("CursorHasSpell()"),
);
// Another payload: the binding's clear (`0x4b3293`) and the setter's (`0x494dab`) drop
// it, then the spell goes on with its grid and no `CURSOR_UPDATE` after (`0x494dd1`).
s.set_cursor_for_test(CursorPayload::Action(CursorAction {
src_slot: 1,
kind: 0,
action: 111,
texture: None,
}));
assert!(!s
.eval::<bool>(r#"return PickupSpell(1, BOOKTYPE_SPELL)"#)
.unwrap());
// The original (action) payload survives untouched.
assert!(
matches!(
s.cursor_payload(),
Some(crate::script::CursorPayload::Action(_))
),
"{:?}",
s.cursor_payload()
assert!(pickup_spell(&s.lua, 1, "spell"));
assert!(matches!(s.cursor_payload(), Some(CursorPayload::Spell(_))));
assert_eq!(
crate::script::tick::heard::take(&s),
[
"CURSOR_UPDATE nil",
"CURSOR_UPDATE nil",
"ACTIONBAR_SHOWGRID 1"
]
);
// The held spell again: put down, nothing picked up (`0x4b329a`).
assert!(!pickup_spell(&s.lua, 1, "spell"));
assert!(s.cursor_payload().is_none());
assert_eq!(
crate::script::tick::heard::take(&s),
["ACTIONBAR_HIDEGRID nil", "CURSOR_UPDATE nil"]
);
// Another spell held: the clear is all (`0x4b32aa`).
assert!(pickup_spell(&s.lua, 1, "spell"));
crate::script::tick::heard::take(&s);
assert!(!pickup_spell(&s.lua, 2, "spell"));
assert!(s.cursor_payload().is_none());
}
#[test]
@@ -1093,9 +1113,7 @@ mod tests {
assert!(s.take_spell_casts().is_empty(), "pet cast is a no-op");
assert!(s.take_pet_spell_casts().is_empty());
assert!(!s
.eval::<bool>(r#"return PickupSpell(1, BOOKTYPE_PET)"#)
.unwrap());
assert!(!pickup_spell(&s.lua, 1, "pet"));
assert!(s.cursor_payload().is_none(), "pet pickup is a no-op");
}
@@ -1283,9 +1301,7 @@ mod tests {
s.set_spellbook(book());
s.set_pet_book(pet_book());
assert!(s
.eval::<bool>(r#"return PickupSpell(1, BOOKTYPE_PET)"#)
.unwrap());
assert!(pickup_spell(&s.lua, 1, "pet"));
let Some(CursorPayload::PetAction(p)) = s.cursor_payload() else {
panic!(
"expected a pet action payload, got {:?}",
@@ -1297,9 +1313,7 @@ mod tests {
// The player book still produces a spell payload.
s.run("ClearCursor()").unwrap();
assert!(s
.eval::<bool>(r#"return PickupSpell(1, BOOKTYPE_SPELL)"#)
.unwrap());
assert!(pickup_spell(&s.lua, 1, "spell"));
assert!(matches!(s.cursor_payload(), Some(CursorPayload::Spell(_))));
}
+105 -62
View File
@@ -186,6 +186,56 @@ fn drag_verb(from: u8, to: u8, state: &StableState) -> Option<StableIntent> {
}
}
/// `ClickStablePet(i)`'s one value (`0x4cb420`), keyed only on whether the cursor held a pet: a
/// plain click selects and answers 1, so the stock window repaints; a drop answers nil, packet or
/// not, and the repaint comes with the server's next list.
fn click_stable_pet(model: &mut Model, i: i64) -> Value {
let Some(to) = slot_index(i) else {
// Out of range: a click selects nothing, a drop clears the cursor.
if !matches!(
model.cursor,
Some(super::cursor::CursorPayload::StablePet(_))
) {
model.stable.selected = 0;
return Value::Number(1.0);
}
super::cursor::clear_cursor(model);
return Value::Nil;
};
let to = to as u8;
// A drop.
if let Some(super::cursor::CursorPayload::StablePet(held)) = model.cursor.clone() {
let from = held.slot;
let intent = model
.stable
.state
.as_ref()
.and_then(|state| drag_verb(from, to, state));
super::cursor::clear_cursor(model);
if let Some(intent) = intent {
model.stable.intents.push(intent);
}
return Value::Nil;
}
// A plain click: select only.
let pet_number = model
.stable
.state
.as_ref()
.and_then(|s| s.slots[usize::from(to)].as_ref())
.map(|p| p.pet_number);
model.stable.selected = match (to, pet_number) {
// Slot 0 selects the summoned pet by its sentinel, not a pet number.
(0, _) => -1,
(_, Some(n)) => n as i32,
// An empty slot selects nothing.
(_, None) => 0,
};
Value::Number(1.0)
}
/// Register the stable globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
@@ -296,77 +346,39 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// ClickStablePet(i) → one value (`0x4cb420`), keyed only on whether the cursor held a pet: a
// plain click selects and answers 1, so the stock window repaints; a drop answers nil, packet
// or not, and the repaint comes with the server's next list.
// ClickStablePet(i) → one value ([`click_stable_pet`]).
g.set(
"ClickStablePet",
lua.create_function(|lua, i: i64| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(to) = slot_index(i) else {
// Out of range: a click selects nothing, a drop clears the cursor.
if !matches!(
model.cursor,
Some(super::cursor::CursorPayload::StablePet(_))
) {
model.stable.selected = 0;
return Ok(Value::Number(1.0));
}
super::cursor::clear_cursor(&mut model);
return Ok(Value::Nil);
};
let to = to as u8;
// A drop.
if let Some(super::cursor::CursorPayload::StablePet(held)) = model.cursor.clone() {
let from = held.slot;
let intent = model
.stable
.state
.as_ref()
.and_then(|state| drag_verb(from, to, state));
super::cursor::clear_cursor(&mut model);
if let Some(intent) = intent {
model.stable.intents.push(intent);
}
return Ok(Value::Nil);
}
// A plain click: select only.
let pet_number = model
.stable
.state
.as_ref()
.and_then(|s| s.slots[usize::from(to)].as_ref())
.map(|p| p.pet_number);
model.stable.selected = match (to, pet_number) {
// Slot 0 selects the summoned pet by its sentinel, not a pet number.
(0, _) => -1,
(_, Some(n)) => n as i32,
// An empty slot selects nothing.
(_, None) => 0,
};
Ok(Value::Number(1.0))
Ok(super::tick::verb(lua, |model| click_stable_pet(model, i)))
})?,
)?;
// PickupStablePet(i): the mode-10 cursor grab, returning nothing. The gate is the family icon,
// not occupancy (`0x495010`): no icon, no grab.
// PickupStablePet(i) (`0x4cb7a0`): the mode-10 cursor grab, returning nothing. The binding
// takes an occupied slot only (`0x4cb7f9`); the grab (`0x495010`) clears with
// `ClearCursor(1,1)` (`0x495034`) before its own check, the family icon: no icon, no grab, the
// cursor left clear. The set fires nothing of its own.
g.set(
"PickupStablePet",
lua.create_function(|lua, i: i64| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let texture = slot_index(i)
.and_then(|i| model.stable.state.as_ref()?.slots[i].as_ref())
.and_then(|p| p.icon.clone());
if let Some(texture) = texture {
model.cursor = Some(super::cursor::CursorPayload::StablePet(
super::cursor::CursorStablePet {
slot: i as u8,
texture,
},
));
super::cursor::queue_cursor_update(&mut model);
let occupied = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
slot_index(i)
.and_then(|i| model.stable.state.as_ref()?.slots[i].as_ref())
.is_some()
};
if occupied {
super::cursor::set_cursor(lua, true, |model| {
let texture = slot_index(i)
.and_then(|i| model.stable.state.as_ref()?.slots[i].as_ref())
.and_then(|p| p.icon.clone())?;
Some(super::cursor::CursorPayload::StablePet(
super::cursor::CursorStablePet {
slot: i as u8,
texture,
},
))
});
}
Ok(())
})?,
@@ -730,4 +742,35 @@ mod tests {
let s = UiScript::new().unwrap();
s.eval::<()>("SetPetStablePaperdoll(nil)").unwrap();
}
/// The grab clears first (`0x495034`) and only then checks the family icon: a slot without one
/// leaves the cursor clear. The set fires nothing of its own, so every `CURSOR_UPDATE` reads
/// the emptied cursor.
#[test]
fn a_stable_grab_clears_before_its_icon_check() {
let mut s = UiScript::new().unwrap();
let mut iconless = pet(8, "Bruiser", 38);
iconless.icon = None;
s.set_stable(Some(state([Some(pet(7, "Rex", 41)), Some(iconless), None])));
crate::script::cursor::install_kind_probe(&s);
crate::script::tick::heard::listen(&s, &["CURSOR_UPDATE"], Some("CursorKind()"));
s.set_cursor_for_test(crate::script::cursor::CursorPayload::Macro(
crate::script::cursor::CursorMacro {
index: 1,
texture: None,
},
));
s.run("PickupStablePet(1)").unwrap();
assert!(s.cursor_payload().is_none(), "cleared, and nothing grabbed");
assert_eq!(crate::script::tick::heard::take(&s), ["CURSOR_UPDATE none"]);
s.run("PickupStablePet(0)").unwrap();
assert!(matches!(
s.cursor_payload(),
Some(crate::script::cursor::CursorPayload::StablePet(_))
));
assert_eq!(crate::script::tick::heard::take(&s), ["CURSOR_UPDATE none"]);
// An empty slot is the binding's own refusal, ahead of the grab and its clear.
s.run("PickupStablePet(2)").unwrap();
assert!(crate::script::tick::heard::take(&s).is_empty());
}
}
+122 -88
View File
@@ -7,10 +7,12 @@
use mlua::{Lua, Table, Value};
use super::object::{draw_layer_from_str, frame_handle_of};
use super::region::region_wrapper;
use super::{event, Model, RegionData};
use super::region::{
install_into_slot, new_texture_region, region_wrapper, set_texture_path, slot_arg, SlotArg,
};
use super::{event, Model};
use crate::order::DrawLayer;
use crate::widget::{KindState, RegionKind, StatusBarState};
use crate::widget::{KindState, RegionHandle, StatusBarState};
/// Registry key of the StatusBar method table.
pub(super) const REG_STATUSBAR_METHODS: &str = "__benilla_statusbar_methods";
@@ -44,12 +46,76 @@ fn store_value(sb: &mut StatusBarState, v: f32) -> Option<f32> {
})
}
/// Get or create the bar texture region, `ARTWORK` by default as in the reference; `layer`
/// re-layers an existing one.
fn ensure_bar(lua: &Lua, this: &Table, layer: Option<DrawLayer>) -> mlua::Result<u32> {
/// Install `incoming` as the bar texture, or clear it, through `0x7831f0`: the same texture is a
/// no-op, the displaced one is deleted, the incoming one is linked in on `layer` shown (`0x78321b`)
/// and its texcoord-modifies-rect flag is set (`0x783220`).
fn install_bar_model(
model: &mut Model,
h: crate::widget::FrameHandle,
incoming: Option<RegionHandle>,
layer: DrawLayer,
) -> mlua::Result<()> {
if !matches!(
model.arena.frame(h).map(|f| &f.kind_state),
Some(KindState::StatusBar(_))
) {
return Err(mlua::Error::runtime("not a StatusBar"));
}
let changed = install_into_slot(
model,
h,
incoming,
layer,
true,
false,
|ks| match ks {
KindState::StatusBar(sb) => sb.bar,
_ => None,
},
|ks, rh| {
if let KindState::StatusBar(sb) = ks {
sb.bar = rh;
}
},
)?;
if let (true, Some(rh)) = (changed, incoming) {
model
.region_data
.entry(rh)
.or_default()
.tex_coord_modifies_rect = true;
}
Ok(())
}
fn install_bar(
lua: &Lua,
this: &Table,
incoming: Option<RegionHandle>,
layer: DrawLayer,
) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
install_bar_model(&mut model, h, incoming, layer)
}
/// `<BarTexture>` (`0x782f63`): the texture the texture adder built, installed as an object on the
/// element's `drawLayer`, ARTWORK without one.
pub(super) fn install_loaded(
model: &mut Model,
h: crate::widget::FrameHandle,
rh: RegionHandle,
layer: Option<DrawLayer>,
) -> mlua::Result<()> {
install_bar_model(model, h, Some(rh), layer.unwrap_or(DrawLayer::Artwork))
}
/// `SetStatusBarTexture`'s path leg (`0x783140`): a bar that exists has its file swapped and
/// nothing else, its layer included; with none, a texture is built, given the file and installed
/// on `layer` (`0x7831cb`).
fn set_bar_path(lua: &Lua, this: &Table, path: &str, layer: DrawLayer) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let existing = match &model
.arena
.frame(h)
@@ -59,35 +125,18 @@ fn ensure_bar(lua: &Lua, this: &Table, layer: Option<DrawLayer>) -> mlua::Result
KindState::StatusBar(sb) => sb.bar,
_ => return Err(mlua::Error::runtime("not a StatusBar")),
};
let rh = match existing {
Some(rh) => {
if let (Some(l), Some(region)) = (layer, model.arena.region_mut(rh)) {
region.draw_layer = l;
}
rh
}
match existing {
Some(rh) => set_texture_path(&mut model, rh, path),
None => {
let rh = model
.arena
.create_region(
h,
RegionKind::Texture,
layer.unwrap_or(DrawLayer::Artwork),
0,
)
let rh = new_texture_region(&mut model, h, layer)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
model.region_data.insert(rh, RegionData::default());
model.touch_layout(); // a region entered the layout gate's read set
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::StatusBar(sb) = &mut frame.kind_state {
sb.bar = Some(rh);
}
}
rh
set_texture_path(&mut model, rh, path);
// `SetAllPoints` onto the bar (`0x7831bf`).
super::region::implicit_creation_anchor(&mut model, rh);
install_bar_model(&mut model, h, Some(rh), layer)?;
}
};
Ok(model.region_id(rh))
}
Ok(())
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
@@ -143,46 +192,36 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// A region's two `SetTexture` forms, `(path [, layer])` and `(r, g, b [, a])`, on the bar.
// `SetStatusBarTexture(texture or "texture" or nil [, "layer"])` (`0x78f870`): a Texture object
// is installed as the bar, linked on `layer` (ARTWORK without one, `0x78f902`) and displacing
// the old bar (`0x7831f0`); a string or a number is the path of the bar (`0x783140`), and nil
// clears it (`0x78fa6d`). There is no colour form. The object leg's raises print
// `SetCheckedTexture`, the client's copy-pasted format strings (`0x87b1e8`, `0x87b224`).
m.set(
"SetStatusBarTexture",
lua.create_function(
|lua, (this, a1, a2, a3, a4): (Table, Value, Value, Value, Value)| {
let layer = match &a2 {
Value::String(s) => s.to_str().ok().and_then(|l| draw_layer_from_str(&l)),
_ => None,
};
let id = ensure_bar(lua, &this, layer)?;
let rh = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
*model.id_to_region.get(&id).expect("bar region id")
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let data = model.region_data.entry(rh).or_default();
match &a1 {
Value::String(s) => {
data.texture = Some(s.to_str()?.to_string());
data.fill = None;
}
// A solid fill and a path clear each other; `SetStatusBarColor` is a separate
// tint that multiplies either.
Value::Number(_) | Value::Integer(_) => {
data.texture = None;
data.fill = Some([
num_f32(&a1),
num_f32(&a2),
num_f32(&a3),
match &a4 {
Value::Nil => 1.0,
v => num_f32(v),
},
]);
}
_ => {}
}
Ok(())
},
)?,
lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| {
// `lua_isstring` on the layer (`0x78f909`): a number takes the name parse too, and
// finds no layer.
let layer = match args.get(1) {
Some(v @ (Value::String(_) | Value::Number(_) | Value::Integer(_))) => lua
.coerce_string(v.clone())?
.and_then(|l| draw_layer_from_str(&l.to_string_lossy())),
_ => None,
}
.unwrap_or(DrawLayer::Artwork);
match slot_arg(
lua,
&this,
&args,
r#"SetStatusBarTexture(texture or "texture" or nil [, "layer"])"#,
"SetCheckedTexture",
true,
)? {
SlotArg::Object(rh) => install_bar(lua, &this, Some(rh), layer),
SlotArg::Clear => install_bar(lua, &this, None, DrawLayer::Artwork),
SlotArg::Path(path) => set_bar_path(lua, &this, &path, layer),
}
})?,
)?;
m.set(
"GetStatusBarTexture",
@@ -204,17 +243,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"SetStatusBarColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = crate::script::object::alpha_f32(lua, &a);
let (r, g, b) = (
crate::script::object::as_f32(&r),
crate::script::object::as_f32(&g),
crate::script::object::as_f32(&b),
crate::script::object::tonumber_f32(lua, &r),
crate::script::object::tonumber_f32(lua, &g),
crate::script::object::tonumber_f32(lua, &b),
);
let id = ensure_bar(lua, &this, None)?;
// `0x78fc20` hands the colour to the bar (`0x782dd0`), which sets its vertex colour
// only if a bar exists and keeps nothing otherwise.
let Some(rh) = with_bar(lua, &this, |sb| sb.bar)? else {
return Ok(());
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let rh = *model.id_to_region.get(&id).expect("bar region id");
model.region_data.entry(rh).or_default().vertex_color =
Some([r, g, b, a.unwrap_or(1.0)]);
model.region_data.entry(rh).or_default().vertex_color = Some([r, g, b, a]);
Ok(())
},
)?,
@@ -253,7 +295,7 @@ fn fire_value_changed(lua: &Lua, this: &Table, changed: Option<f32>) -> mlua::Re
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
.record_lua_error(&e);
}
Ok(())
}
@@ -274,14 +316,6 @@ pub(super) fn fire_engine_value_changed(lua: &Lua, bar: crate::widget::FrameHand
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
}
}
fn num_f32(v: &Value) -> f32 {
match v {
Value::Number(n) => *n as f32,
Value::Integer(i) => *i as f32,
_ => 0.0,
.record_lua_error(&e);
}
}
+45 -24
View File
@@ -4,19 +4,27 @@
//! `loadfile`, `load`, `module`, `newproxy` and `debug` removed), the reference's `debugstack`,
//! and a text-only `loadstring`.
//! - [`install`]: the 1.12 bare globals (string, table and math aliases, the `debug*` family, the
//! error-handler pair), `time` and `date`, the clock and zone getters ([`super::clock`],
//! [`super::zone_text`]), and a `string.format` that accepts positional `%N$` specs as the 1.12
//! client's does, rewriting them into a sequential format. Mixing positional and sequential
//! specs raises, as in the reference, whose single argument cursor a positional spec desyncs.
//! error-handler pair), `time` and `date`, and a `string.format` that accepts positional `%N$`
//! specs as the 1.12 client's does, rewriting them into a sequential format. Mixing positional
//! and sequential specs raises, as in the reference, whose single argument cursor a positional
//! spec desyncs.
use mlua::{Lua, Value, Variadic};
/// An mlua error's bare Lua message, as `loadstring` returns it: without the `syntax error: ` or
/// `runtime error: ` word mlua's `Display` adds and the reference never does.
pub(super) fn lua_message(e: &mlua::Error) -> String {
/// An mlua error's bare Lua message: what `lua_pcall` leaves for a 1.12 error function (`0x703b40`)
/// and what `loadstring` returns. It drops the `syntax error: ` and `runtime error: ` words mlua's
/// `Display` adds, the `stack traceback:` block mlua's message handler appends to a raise, and the
/// wrappers a Rust callback's failure arrives in.
pub fn lua_message(e: &mlua::Error) -> String {
match e {
mlua::Error::SyntaxError { message, .. } => message.clone(),
mlua::Error::RuntimeError(m) => m.clone(),
mlua::Error::RuntimeError(m) => m
.rfind("\nstack traceback:\n")
.map_or(m.as_str(), |at| &m[..at])
.to_string(),
mlua::Error::CallbackError { cause, .. } | mlua::Error::WithContext { cause, .. } => {
lua_message(cause)
}
other => other.to_string(),
}
}
@@ -93,9 +101,7 @@ pub(super) fn sandbox(lua: &Lua) -> mlua::Result<()> {
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
install_error_handler(lua)?;
install_time(lua)?;
// Before the Lua layer, whose `debugprofile*` pair keeps its own `GetTime`.
super::clock::install(lua)?;
super::zone_text::install(lua)?;
install_profile(lua)?;
lua.load(WOW_STDLIB)
.set_name("=[benilla wow stdlib]")
.set_mode(mlua::ChunkMode::Text)
@@ -103,6 +109,32 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
}
/// `debugprofilestart`/`debugprofilestop` (`0x702840`/`0x702860`), the two real `debug*` verbs (RDTSC
/// via `0x4293d0`): start latches, stop answers the milliseconds since. Ours read the `GetTime()`
/// clock ([`super::clock::now`]), the monotonic session seconds the tick advances, as benilla has
/// no cycle counter; read in Rust, so an addon that replaces the `GetTime` global cannot bend it.
fn install_profile(lua: &Lua) -> mlua::Result<()> {
use std::sync::atomic::{AtomicU64, Ordering};
let start = std::sync::Arc::new(AtomicU64::new(0f64.to_bits()));
let g = lua.globals();
let latch = start.clone();
g.set(
"debugprofilestart",
lua.create_function(move |lua, ()| {
latch.store(super::clock::now(lua).to_bits(), Ordering::Relaxed);
Ok(())
})?,
)?;
g.set(
"debugprofilestop",
lua.create_function(move |lua, ()| {
let since = f64::from_bits(start.load(Ordering::Relaxed));
Ok((super::clock::now(lua) - since) * 1000.0)
})?,
)?;
Ok(())
}
/// The handler `seterrorhandler` holds, `None` until one is given.
pub(super) fn error_handler(lua: &Lua) -> Option<mlua::Function> {
lua.named_registry_value::<Option<mlua::Function>>(super::REG_ERRORHANDLER)
@@ -215,13 +247,9 @@ ldexp = math.ldexp
-- That is what lets `BasicControls.xml` stop guarding its `debuginfo()` call and transcribe
-- `_ERRORMESSAGE` verbatim, which it now does.
--
-- `debugprofilestart`/`debugprofilestop` are the two that are REAL (RDTSC via `0x4293d0`): start
-- latches, stop answers the elapsed milliseconds since it. Modelled on `GetTime`'s own clock —
-- the same monotonic session seconds the tick advances — because benilla has no cycle counter and
-- an addon uses these to time its own work, which milliseconds answer honestly. The clock is
-- captured, so an addon that replaces the `GetTime` global cannot bend it.
-- `debugprofilestart`/`debugprofilestop` are the two that are REAL; they are Rust
-- (`install_profile`).
do
local GetTime = GetTime
local function noop() end
debuginfo = noop
debugload = noop
@@ -229,19 +257,12 @@ do
debugdump = noop
debugbreak = noop
debugtimestamp = noop
local profileStart = 0
function debugprofilestart() profileStart = GetTime() end
function debugprofilestop() return (GetTime() - profileStart) * 1000 end
end
-- ── _G accessors (getglobal/setglobal: _G[name] get/set) ───────────────────────────────
function getglobal(name) return _G[name] end
function setglobal(name, value) _G[name] = value end
-- ── GetLocale: benilla ships/reads enUS data only (the 5875 MPQs + Spell.dbc enUS column) ──────
function GetLocale() return "enUS" end
-- ── NOT here: wipe / tostringall / strsplit / strjoin / strconcat / strtrim ────────────────────
-- Six 2.0+ names benilla used to define. 1.12 has none of them (absent from
-- `reference/1.12-globals.tsv`, from the stock chain, and from every registrar table), and the
+1
View File
@@ -195,6 +195,7 @@ fn emblem_texture(lua: &Lua, this: Value, texture: Value, upper: bool) -> mlua::
data.fill = None;
data.portrait_unit = None;
data.circular = false;
model.note_art(rh);
Ok(())
}

Some files were not shown because too many files have changed in this diff Show More