Files
benilla-octo/crates/benilla-ui/src/script/compat/classic_api/focus.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

275 lines
11 KiB
Rust

//! ClassicAPI's `Focus` namespace (bare globals, not a `C_*` table): `FocusUnit` and `ClearFocus`,
//! the persistent secondary selection 1.12 has neither a verb nor a unit token for. The focus is a
//! unit the player picks once and keeps referencing — independent of the current target, surviving
//! every retarget — so it is stored as a GUID ([`Model::focus_guid`]) and re-resolved on every
//! read, never snapshotted.
//!
//! What this file owns is the *state*: the GUID, the token check (`script/unit/mod.rs`'s
//! `classic_api_token_recognised`), the resolution arm (`Model::unit`) and
//! `PLAYER_FOCUS_CHANGED`. What it does not own is the data behind the tokens: `benilla-app`'s
//! `ui_unit.rs` and `ui_aura.rs` read [`crate::script::UiScript::focus_guid`] back and push the
//! `"focus"` and `"focustarget"` snapshots and aura lists through the same `set_unit`/`set_auras`
//! feed `"target"` and `"targettarget"` ride, which is what makes every `Unit*` verb and every
//! `UNIT_*` event answer for both tokens. `"focustarget"` is the app's alone: the focus's
//! `UNIT_FIELD_TARGET` is an object-manager hop this crate cannot make.
//!
//! The `/focus` and `/clearfocus` aliases are not here, because they cannot be: the stock
//! `ChatFrame.lua` *assigns* `SlashCmdList = { }` and this file installs before FrameXML loads.
//! They go in with the rest of the load-time repairs, `benilla-app`'s
//! `ui_script::manifest::install_focus_slash_commands`.
use mlua::Lua;
use crate::script::{Model, ScriptValue};
impl crate::script::UiScript {
/// The focus unit's GUID, `None` for no focus. Read by the app's unit and aura feeds, which
/// turn it into the `"focus"`/`"focustarget"` token snapshots; a read, not a drain, because
/// the focus persists across frames rather than being consumed by one.
pub fn focus_guid(&self) -> Option<u64> {
self.model_ref().focus_guid
}
}
/// Assign or clear the focus, firing `PLAYER_FOCUS_CHANGED` only on a real change — the event is a
/// "repaint the focus frames" signal (pfUI's `modules/focus.lua` registers it for exactly that),
/// and re-`/focus`ing the unit already focused changes nothing to repaint.
fn set_focus(model: &mut Model, guid: Option<u64>) {
if model.focus_guid == guid {
return;
}
model.focus_guid = guid;
// Argless, as the later clients' own event is: a handler reads the `"focus"` token.
model.pending_events.push((
"PLAYER_FOCUS_CHANGED".to_string(),
Vec::<ScriptValue>::new(),
));
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// FocusUnit(unit): store whatever the token names *now* as the focus. Resolved here rather
// than queued for the app because the token's meaning is the caller's frame, not the drain's:
// `/swapfocus` is `FocusUnit("target")` followed by `TargetUnit(oldFocus)` in one Lua call,
// and a request resolved after that retarget committed would focus the wrong unit.
//
// A recognised token naming nothing CLEARS the focus, which is the later clients' own `/focus`
// behaviour (it doubles as the toggle: press it with no target and the focus goes away). An
// unrecognised one raises `Unknown unit name` through the shared check, as every `Unit*` verb
// does — naming the bad token beats silently dropping the focus.
g.set(
"FocusUnit",
lua.create_function(|lua, token: Option<String>| {
crate::script::unit::check_unit_token(&token)?;
let guid = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
token
.as_deref()
.and_then(|t| model.unit(t))
.map(|u| u.guid)
.filter(|g| *g != 0)
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
set_focus(&mut model, guid);
Ok(())
})?,
)?;
// ClearFocus(): drop the focus. Not the EditBox method of the same name — that lives in the
// widget method tables and 1.12 has it there; this is the bare global, which 1.12 has not.
g.set(
"ClearFocus",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
set_focus(&mut model, None);
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::{UiScript, UnitState};
/// A unit under some token, so `FocusUnit` has something to resolve.
fn unit(guid: u64, name: &str, health: u32) -> Option<UnitState> {
Some(UnitState {
exists: true,
has_object: true,
guid,
name: Some(name.to_string()),
health,
max_health: 100,
..Default::default()
})
}
/// The whole point of storing a GUID: the focus answers the unit's health as it moves, not the
/// health it had when `/focus` ran.
#[test]
fn the_focus_token_answers_live_state_because_a_guid_is_stored_not_a_snapshot() {
let mut s = UiScript::new().unwrap();
s.set_unit("target", unit(0x1111, "Hogger", 80));
s.run(r#"FocusUnit("target")"#).unwrap();
assert_eq!(
s.eval::<String>(r#"return UnitName("focus")"#).unwrap(),
"Hogger"
);
assert_eq!(s.eval::<i64>(r#"return UnitHealth("focus")"#).unwrap(), 80);
// The app's next push of the same unit, under its own token, moves what `"focus"` reads.
s.set_unit("focus", unit(0x1111, "Hogger", 12));
s.set_unit("target", None);
assert_eq!(s.eval::<i64>(r#"return UnitHealth("focus")"#).unwrap(), 12);
}
/// The error pfUI's `pfFocus.UpdateVisibility` hit at session start: `UnitName("focus")` must
/// answer nil for no focus, never raise `Unknown unit name: focus`.
#[test]
fn the_focus_tokens_are_recognised_with_no_focus_set_and_answer_nil() {
let s = UiScript::new().unwrap();
for token in ["focus", "focustarget", "focustargettarget", "FOCUS"] {
assert_eq!(
s.eval::<Option<String>>(&format!(r#"return UnitName("{token}")"#))
.unwrap_or_else(|e| panic!("UnitName({token:?}) raised: {e}")),
None,
"{token} with no focus set"
);
assert_eq!(
s.eval::<Option<i64>>(&format!(r#"return UnitExists("{token}")"#))
.unwrap(),
None
);
}
// Still only the focus family: a token no compare table matches keeps raising.
assert!(s
.eval::<Option<String>>(r#"return UnitName("focu")"#)
.is_err());
}
/// `ClearFocus()` takes effect on the call, not on the app's next push: both tokens go nil at
/// once, so a handler reading them inside the `PLAYER_FOCUS_CHANGED` it raises sees the clear.
#[test]
fn clearing_the_focus_empties_both_tokens_on_the_call() {
let mut s = UiScript::new().unwrap();
s.set_unit("target", unit(0x2222, "Mankrik", 50));
s.run(r#"FocusUnit("target")"#).unwrap();
s.set_unit("focus", unit(0x2222, "Mankrik", 50));
s.set_unit("focustarget", unit(0x3333, "Wife", 50));
assert_eq!(
s.eval::<String>(r#"return UnitName("focustarget")"#)
.unwrap(),
"Wife"
);
s.run("ClearFocus()").unwrap();
assert_eq!(
s.eval::<Option<String>>(r#"return UnitName("focus")"#)
.unwrap(),
None,
"the stale `focus` push must not outlive the clear"
);
assert_eq!(
s.eval::<Option<String>>(r#"return UnitName("focustarget")"#)
.unwrap(),
None
);
}
/// `PLAYER_FOCUS_CHANGED` fires on assign and on clear, once each, and not on a re-assign of
/// the unit already focused — pfUI's refresher does a full unit-frame rebuild per fire.
#[test]
fn player_focus_changed_fires_on_assign_and_clear_and_not_on_a_repeat() {
let mut s = UiScript::new().unwrap();
s.set_unit("target", unit(0x4444, "Defias Pillager", 30));
let fired = |s: &UiScript| -> usize {
s.model_ref()
.pending_events
.iter()
.filter(|(e, _)| e == "PLAYER_FOCUS_CHANGED")
.count()
};
s.run(r#"FocusUnit("target")"#).unwrap();
assert_eq!(fired(&s), 1, "assign");
s.run(r#"FocusUnit("target")"#).unwrap();
assert_eq!(fired(&s), 1, "the same unit again is not a change");
s.run("ClearFocus()").unwrap();
assert_eq!(fired(&s), 2, "clear");
s.run("ClearFocus()").unwrap();
assert_eq!(fired(&s), 2, "already clear is not a change");
}
/// A recognised token naming nothing clears, the later clients' `/focus`-with-no-target
/// behaviour; an unrecognised one raises and leaves the focus alone.
#[test]
fn a_token_naming_nothing_clears_and_an_unknown_token_raises_without_touching_the_focus() {
let mut s = UiScript::new().unwrap();
s.set_unit("target", unit(0x5555, "Chen", 90));
s.run(r#"FocusUnit("target")"#).unwrap();
assert!(s.run(r#"FocusUnit("notaunit")"#).is_err(), "unknown token");
assert_eq!(
s.eval::<String>(r#"return UnitName("focus")"#).unwrap(),
"Chen",
"a raise must not clear a live focus"
);
s.run(r#"FocusUnit("party3")"#).unwrap();
assert_eq!(
s.eval::<Option<String>>(r#"return UnitName("focus")"#)
.unwrap(),
None,
"a recognised token naming nothing clears"
);
}
/// `UnitGUID("focus")` is what pfUI's `/castfocus` and `/swapfocus` read, and it must round-trip
/// through `UnitTokenFromGUID` like every other token's.
#[test]
fn unit_guid_answers_the_focus_and_round_trips_through_the_token() {
let mut s = UiScript::new().unwrap();
assert_eq!(
s.eval::<Option<String>>(r#"return UnitGUID("focus")"#)
.unwrap(),
None,
"no focus is nil, which pfUI tests before comparing to 0x0..0"
);
s.set_unit("target", unit(0x6666, "Vanndar", 100));
s.run(r#"FocusUnit("target")"#).unwrap();
s.set_unit("target", None);
s.set_unit("focus", unit(0x6666, "Vanndar", 100));
let guid = s.eval::<String>(r#"return UnitGUID("focus")"#).unwrap();
assert_eq!(
s.eval::<String>(&format!(r#"return UnitTokenFromGUID("{guid}")"#))
.unwrap(),
"focus"
);
}
/// SuperWoW's own override and the focus are independent stores: pointing `"mouseover"` at a
/// unit must not move the focus, and vice versa.
#[test]
fn the_focus_and_the_mouseover_override_do_not_alias() {
let mut s = UiScript::new().unwrap();
s.set_unit("target", unit(0x7777, "Focused", 10));
s.set_unit("npc", unit(0x8888, "Hovered", 20));
s.run(r#"FocusUnit("target") SetMouseoverUnit("npc")"#)
.unwrap();
assert_eq!(
s.eval::<String>(r#"return UnitName("focus")"#).unwrap(),
"Focused"
);
assert_eq!(
s.eval::<String>(r#"return UnitName("mouseover")"#).unwrap(),
"Hovered"
);
}
}