Files
benilla-octo/crates/benilla-ui/src/script/compat/classic_api/hooks.rs
T
gromandClaude Opus 5 81d96af35d Everwood: the HD/OctoWoW line of benilla, imported onto upstream
This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.

Today's work, which is the reason a baseline exists at all:

- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
  characters were entering the sun's shadow cascades, so every skinned-rig
  shadow specialization compiled live mid-frame; the hitch detector logged
  "main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
  the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
  tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
  an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
  and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
  measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
  frame-cost reading no longer pays 2.7 ms a frame for it.

Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-15 15:01:14 -06:00

198 lines
7.5 KiB
Rust

//! ClassicAPI's `Hooks` namespace: `hooksecurefunc`, in both documented forms
//! (`hooksecurefunc(name, cb)` over a global, `hooksecurefunc(table, name, cb)` over a field).
//! Not a 1.12 verb — the reference has no post-hook helper at all, and an addon that wants one
//! there writes the `local old = Foo; Foo = function(...) ... end` dance by hand.
//!
//! The contract this keeps, which is the whole point of the function:
//! - the original runs first, with the caller's own arguments;
//! - **the original's return values pass through untouched** — the hook's own returns are
//! discarded, so a hooked getter still answers what it answered before;
//! - a hook that errors does not break the call: the error is recorded the way a failing event
//! handler's is ([`Model::record_script_error`]), and the original's returns still come back;
//! - hooking the same function twice **chains** — the second hook wraps the first wrapper, so both
//! run, in the order they were installed.
//!
//! The original and the hook live in the Lua registry, not in the Rust closure: this crate holds
//! no persistent `Function` handle (see `script/mod.rs`'s `LUAI_MAXCSTACK` note). A hook is
//! permanent, as it is in the reference clients that have this verb, so the two slots it takes are
//! never reclaimed.
use mlua::{Function, Lua, MultiValue, Value};
use crate::script::Model;
/// Wrap `orig` so `hook` runs after it on every call, under the contract in this module's header.
fn chained(lua: &Lua, orig: Function, hook: Function) -> mlua::Result<Function> {
let orig = lua.create_registry_value(orig)?;
let hook = lua.create_registry_value(hook)?;
lua.create_function(move |lua, args: MultiValue| {
let original: Function = lua.registry_value(&orig)?;
let after: Function = lua.registry_value(&hook)?;
// The original's own error propagates: a caller that would have seen it still does.
let returns: MultiValue = original.call(args.clone())?;
// The hook's does not — it is somebody else's observer, not part of this call's contract.
if let Err(e) = after.call::<()>(args) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
}
Ok(returns)
})
}
/// `hooksecurefunc([table,] "name", callback)`. A name that holds no function is a silent no-op,
/// as there is nothing to run before the hook; the reference's own implementations raise there,
/// but an addon loaded against a client missing the verb it meant to hook is the common case here
/// and taking its UI down over it would be worse than leaving the hook unarmed.
fn hooksecurefunc(lua: &Lua, args: MultiValue) -> mlua::Result<()> {
let mut it = args.into_iter();
let first = it.next().unwrap_or(Value::Nil);
// Form two (`table, name, cb`) when the first argument is a table, else form one over `_G`.
let (host, name, hook) = match first {
Value::Table(t) => {
let name = it.next().unwrap_or(Value::Nil);
let hook = it.next().unwrap_or(Value::Nil);
(t, name, hook)
}
other => (lua.globals(), other, it.next().unwrap_or(Value::Nil)),
};
let (Value::String(name), Value::Function(hook)) = (name, hook) else {
return Ok(()); // not the documented shape; nothing to arm
};
let name = name.to_str()?.to_string();
// `raw_get`, not `get`: a field reached only through a metatable is not this table's to replace.
let Value::Function(orig) = host.raw_get::<Value>(name.as_str())? else {
return Ok(());
};
host.raw_set(name, chained(lua, orig, hook)?)
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"hooksecurefunc",
lua.create_function(|lua, args: MultiValue| hooksecurefunc(lua, args))?,
)
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
#[test]
fn the_originals_returns_pass_through_and_the_hooks_are_discarded() {
let s = UiScript::new().unwrap();
s.run(
r#"
function Orig(a, b) return a + b, "second" end
seen = nil
hooksecurefunc("Orig", function(a, b) seen = a .. "/" .. b return "ignored" end)
"#,
)
.unwrap();
// Both of the original's returns survive, in order, and nothing of the hook's leaks in.
assert_eq!(
s.eval::<(i64, String)>("return Orig(2, 3)").unwrap(),
(5, "second".to_string())
);
assert_eq!(
s.arity("Orig(2, 3)").unwrap(),
2,
"the wrapper must not add or drop a return value"
);
// The hook saw the caller's own arguments.
assert_eq!(s.eval::<String>("return seen").unwrap(), "2/3");
}
#[test]
fn hooking_twice_chains_in_install_order_rather_than_replacing() {
let s = UiScript::new().unwrap();
s.run(
r#"
function Orig() return "r" end
log = ""
hooksecurefunc("Orig", function() log = log .. "a" end)
hooksecurefunc("Orig", function() log = log .. "b" end)
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return Orig()").unwrap(),
"r",
"the original still answers through two wrappers"
);
assert_eq!(
s.eval::<String>("return log").unwrap(),
"ab",
"both hooks ran, first-installed first — a replace would log only \"b\""
);
}
#[test]
fn a_hook_that_errors_leaves_the_call_working() {
let s = UiScript::new().unwrap();
s.run(
r#"
function Orig() return 7 end
hooksecurefunc("Orig", function() error("hook blew up") end)
"#,
)
.unwrap();
assert_eq!(
s.eval::<i64>("return Orig()").unwrap(),
7,
"the original's return survives its hook erroring"
);
assert!(
s.errors().iter().any(|e| e.contains("hook blew up")),
"the hook's error is recorded, not swallowed silently: {:?}",
s.errors()
);
}
#[test]
fn the_originals_own_error_still_propagates() {
let s = UiScript::new().unwrap();
s.run(
r#"
function Orig() error("original blew up") end
hooksecurefunc("Orig", function() end)
"#,
)
.unwrap();
assert!(
s.eval::<()>("return Orig()")
.unwrap_err()
.to_string()
.contains("original blew up"),
"a caller that would have seen the original's error still sees it"
);
}
#[test]
fn the_table_form_hooks_a_field_and_an_unknown_name_is_a_no_op() {
let s = UiScript::new().unwrap();
s.run(
r#"
T = { Go = function(x) return x * 2 end }
saw = nil
hooksecurefunc(T, "Go", function(x) saw = x end)
-- Nothing to hook: must not raise, must not create the field.
hooksecurefunc(T, "Missing", function() end)
hooksecurefunc("NoSuchGlobal", function() end)
"#,
)
.unwrap();
assert_eq!(s.eval::<i64>("return T.Go(21)").unwrap(), 42);
assert_eq!(s.eval::<i64>("return saw").unwrap(), 21);
assert_eq!(
s.eval::<String>("return type(T.Missing)").unwrap(),
"nil",
"an unarmed hook leaves the table alone rather than planting a wrapper"
);
}
}