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
275 lines
11 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|