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
198 lines
7.5 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|