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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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\""] {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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())?,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -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())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+1129
-341
File diff suppressed because it is too large
Load Diff
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})?,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }))
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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(_))));
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user