Quiver loads, teleports stop losing the city, the camera eases right

- Quiver works end to end (the reported nil-field error): 1.12's widget method tables are flat, so the 19 Region methods are ONE function per name — a frame's `GetHeight` IS the texture's — where we had two implementations behind two disjoint registries; `Button:SetFontString` and the Set/GetMin/MaxResize quad land with it, and the 219-addon survey moves loads-without-error 150 → 151 with every other addon byte-identical
- a teleport stops dropping you into a bare city: the reveal gate now waits on geometry that can actually draw (14 regions had sat undrawn for 630 ms with the cover already down), a relocating snap ≥100 yd raises the cover, and the pipeline warm pass runs once per process — a far same-map teleport's cover drops 3.8 s → 0.8 s
- the empty-world flash on a fully-resident teleport dies with it: the camera's world pose publishes before anything in Update reads it, so the visibility verdict stops being computed up to 644 yd from the eye — the lag reads 0 yd on every snap frame of every arm
- the camera follow becomes the engine's own curve — an edge-armed cosine smoothstep clamped to 0.1–2 s behind a 7-state input classifier, the enum 0/1/2 with Smart staying the default — and overlapping chat bubbles stack as whole cards, one frame level per bubble, nearest to the camera on top

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-21 18:10:46 +07:00
co-authored by Claude Fable 5
parent 532240f6e7
commit ad2a1fb2d1
36 changed files with 2375 additions and 232 deletions
+5
View File
@@ -853,6 +853,11 @@ mod shipped_xml_tests;
#[cfg(test)]
mod bagnon_render_tests;
/// Bug B267 end to end: a hunter's Quiver publishes its global functions (see the file header for
/// the three walls that stopped it).
#[cfg(test)]
mod quiver_tests;
/// The UI's per-world-entry lifecycle: teardown at the character screen, a genuine second load at
/// the next login (decision 1290).
#[cfg(test)]
@@ -2803,16 +2803,16 @@ fn the_max_camera_distance_slider_stores_a_factor_and_reads_out_yards() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Camera Following Style** (decision 1493) — 1.12's `cameraSmoothStyle`, worn as a Controls-page
/// dropdown, and the setting that decides whether the camera swings back behind the character at
/// all. Three things pinned: the reference's own values *and* their order (Smart `"1"`, Always
/// `"2"`, Never `"3"` — Never is **3**, not 0, which is exactly what a tidy-up would get wrong);
/// the registered default landing on Smart, where benilla behaved as Never for its whole life
/// before this row; and the plate following the SELECTION rather than the row — the reference's own
/// dropdown repaints its tooltip per entry (`OPTION_TOOLTIP_CAMERA1/2/3`) and this is the first row
/// here whose description is not a property of the row alone.
/// **Camera Following Style** (decisions 1493/1502) — 1.12's `cameraSmoothStyle`, worn as a
/// Controls-page dropdown, and the setting that decides whether the camera returns to behind the
/// character at all. What is pinned here is the trap: the reference's own dropdown writes `1/2/3`,
/// but the ENGINE's tables are indexed `0 = Never · 1 = Smart · 2 = Always`, and `3` is not a style
/// — the validator accepts it while the terrain-tilt consumer indexes off the end of its table
/// (wow-re `camera-smooth-style.md` §2/§4). So our entries carry the engine's numbers in the
/// reference's display order, a stray `3` still reads as Never rather than as the numerically
/// nearest "Always", and the plate follows the SELECTION the way that dropdown's own does.
#[test]
fn the_camera_following_style_dropdown_carries_the_reference_values_and_plate() {
fn the_camera_following_style_dropdown_carries_the_engine_enum_and_plate() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowControls:Click()")
@@ -2843,8 +2843,7 @@ fn the_camera_following_style_dropdown_carries_the_reference_values_and_plate()
"reading the table on select must not write it back"
);
// The list is the reference dropdown's, entry for entry: Smart, Always, Never — with the
// stored value checked.
// The list is the reference dropdown's, entry for entry and in its order.
s.run("OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownButton:Click()")
.unwrap();
assert_eq!(
@@ -2863,12 +2862,12 @@ fn the_camera_following_style_dropdown_carries_the_reference_values_and_plate()
.eval::<bool>("return DropDownList1Button1Check:IsVisible()")
.unwrap());
// Never stores "3" — the value the reference writes, not the 0 a fresh enum would invent —
// Never stores "0" — the engine's own index, not the "3" the reference's dropdown writes —
// and the row's plate becomes Never's own description.
s.run("DropDownList1Button3:Click()").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("cameraSmoothStyle".to_string(), "3".to_string())]
vec![("cameraSmoothStyle".to_string(), "0".to_string())]
);
assert_eq!(
s.eval::<String>(
@@ -2884,8 +2883,7 @@ fn the_camera_following_style_dropdown_carries_the_reference_values_and_plate()
"the plate follows the selection, like the reference dropdown's own"
);
// Always is the middle entry and stores "2" (the pairing that goes wrong if the order is read
// off the alphabet instead of off the reference).
// Always is the middle entry and stores "2".
s.run("OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownButton:Click()")
.unwrap();
s.run("DropDownList1Button2:Click()").unwrap();
@@ -2894,6 +2892,24 @@ fn the_camera_following_style_dropdown_carries_the_reference_values_and_plate()
vec![("cameraSmoothStyle".to_string(), "2".to_string())]
);
// A config written by a REAL 1.12 client says "3" for Never. It must not display as the
// numerically nearest stop — which is Always, the opposite of what it means.
s.set_cvar_host("cameraSmoothStyle", "3");
s.run("OptionsFrameCategoryListRowAudio:Click(); OptionsFrameCategoryListRowControls:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownText:GetText()"
)
.unwrap(),
"Never",
"the reference dropdown's own stray value still means Never"
);
assert!(
s.take_cvar_changes().is_empty(),
"displaying a stray value must not write it back"
);
// Defaults walks it back to Smart.
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
assert_eq!(
@@ -0,0 +1,272 @@
//! **"`Quiver.CastPetAction` is nil when the addon's own macro runs"** — bug B267, reproduced end
//! to end and then closed.
//!
//! ## Why this file exists
//!
//! The report is a `/run Quiver.CastPetAction("Furious Howl")` macro raising
//! `attempt to call field 'CastPetAction' (a nil value)`. The field is published on the addon's
//! **last** line of `VARIABLES_LOADED`:
//!
//! ```lua
//! if event == "VARIABLES_LOADED" then
//! LoadLocale() Migrations() savedVariablesRestore()
//! initSlashCommandsAndModules() -- builds the whole config UI, HUNTERS ONLY
//! RegisterGlobalFunctions() -- <- Quiver.CastPetAction = … lives here
//! ```
//!
//! so anything that raises in that handler takes every global function with it. Three separate
//! walls did, one behind the other, and each is a real gap of ours:
//!
//! | | what it does | who found it |
//! |---|---|---|
//! | the Region method map was two implementations, not one | `Api._Height = WorldFrame.GetHeight` applied to a Texture raised `stale or invalid frame handle` | this bug |
//! | `Button:SetFontString` missing | every dropdown option row died on its first line | behind wall 1 |
//! | `Frame:SetMinResize`/`SetMaxResize` missing | `SideEffectMakeMoveable` died on every module frame | behind wall 2 |
//!
//! **The addon survey scored Quiver `loaded, session=ok, probe=ok` through all three.** It seats a
//! WARRIOR, and every one of these is behind `if cl == "HUNTER"` — the same blind spot shape as
//! Bagnon's (`ui_script::bagnon_render_tests`), one axis over: there the columns asked "did it
//! raise" and never "did it draw"; here they ask both, of a session the addon declines to run in.
//! So the fixture below seats a **hunter**, and that is the load-bearing part of it.
//!
//! Nothing from the corpus is committed, and the test skips cleanly on a machine without it — the
//! `ui_chat::ace_gate_tests` rule.
use std::path::{Path, PathBuf};
use benilla_ui::script::{ScriptValue, UiScript, UnitState};
use benilla_ui::toc::Toc;
/// Where the vanilla addon corpus might be — `$BENILLA_ADDON_CORPUS`, else a sibling checkout
/// resolved from this crate's manifest (a pool worktree's cwd is not stable across tool calls).
fn corpus_candidates() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") {
out.push(PathBuf::from(over));
}
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
for up in [2usize, 3, 4] {
if let Some(root) = manifest.ancestors().nth(up) {
out.push(root.join("wow-addons-vanilla"));
}
}
out
}
/// The corpus root **and** a Quiver in it, or `None` — a skip, never a failure. Quiver is a live
/// third-party addon (`github.com/SabineWren/Quiver`) whose shipped file is a generated bundle, so
/// a machine that wants this test builds it into the corpus once.
fn quiver_root() -> Option<PathBuf> {
corpus_candidates()
.into_iter()
.find(|c| c.join("Quiver").join("Quiver.toc").is_file())
}
macro_rules! quiver_or_skip {
() => {
match quiver_root() {
Some(root) => root,
None => {
eprintln!(
"skipping: no Quiver in the addon corpus — looked in {:?} \
(set $BENILLA_ADDON_CORPUS; the folder needs Quiver.toc + Quiver.bundle.lua)",
corpus_candidates()
);
return;
}
}
};
}
fn read_toc(root: &Path, name: &str) -> Toc {
let path = root.join(name).join(format!("{name}.toc"));
Toc::parse(&benilla_ui::source::decode(
&std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())),
))
}
/// One addon's `.toc` files through the same two arms the real loader uses (decision 1186).
fn load_addon_files(script: &UiScript, root: &Path, name: &str) -> Vec<String> {
let toc = read_toc(root, name);
let provider = |req: &str| -> Option<Vec<u8>> { std::fs::read(root.join(req)).ok() };
let mut errors = Vec::new();
for file in &toc.files {
let path = benilla_ui::loader::join_ref(name, file);
let Ok(bytes) = std::fs::read(root.join(&path)) else {
errors.push(format!("{file}: not found"));
continue;
};
if file.to_ascii_lowercase().ends_with(".lua") {
if let Err(e) =
script.run_chunk_named(&bytes, &benilla_ui::script::addon_chunk_name(name, file))
{
errors.push(format!("{file}: {e}"));
}
continue;
}
match benilla_ui::framexml::parse(&benilla_ui::source::decode(&bytes)) {
Ok(doc) => {
let report = benilla_ui::loader::load_in(script, &doc, &path, &provider);
errors.extend(report.errors.into_iter().map(|e| format!("{file}: {e}")));
}
Err(e) => errors.push(format!("{file}: {e}")),
}
}
errors
}
/// A VM shaped like the reporter's session: our whole interface, and a **hunter** at the keyboard.
///
/// The class is the point (see the module doc). Everything else is the addon-survey seat's own
/// minimum — a named player on a named realm, with a faction group, because a character in a real
/// session always has all three and a nil one is a failure mode no player can produce.
fn seat_a_hunter(root: &Path) -> UiScript {
let mut s = UiScript::new().expect("VM");
s.set_screen_size(1024.0, 768.0);
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
s.set_realm_name("Harness");
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Harness".into()),
health: 100,
max_health: 100,
level: 60,
power_type: 0,
power: 100,
max_power: 100,
race: Some("Night Elf".into()),
race_file: Some("NightElf".into()),
class: Some("Hunter".into()),
class_file: Some("HUNTER".into()),
sex: 2,
is_player: true,
faction_group: Some("Alliance".into()),
..Default::default()
}),
);
// A HUNTER'S SPELLBOOK. Quiver's `Api.Spell.FindSpellIndex` opens
// `GetSpellTabInfo(GetNumSpellTabs())` and adds `offset + numSpells`, so an EMPTY book is
// `attempt to perform arithmetic on local 'tabOffset'` every tick — the reference answers nil
// there too, so that is the addon's own defect on a spell-less character and not a gap of
// ours. Seating a book is what lets this test TICK, which is where the module OnUpdates live.
// Two tabs and four spells is the addon-survey fixture's own shape, with a hunter's names.
{
use benilla_ui::script::{SpellBookState, SpellSlotView, SpellTabView};
let slot = |spell_id: u32, name: &str, rank: Option<&str>| SpellSlotView {
spell_id,
name: name.to_string(),
rank: rank.map(str::to_string),
texture: Some("Interface\\Icons\\INV_Misc_QuestionMark".into()),
..Default::default()
};
s.set_spellbook(SpellBookState {
tabs: vec![
SpellTabView {
name: "General".into(),
texture: Some("Interface\\Icons\\INV_Misc_QuestionMark".into()),
offset: 0,
num_spells: 2,
},
SpellTabView {
name: "Marksmanship".into(),
texture: Some("Interface\\Icons\\Ability_Marksmanship".into()),
offset: 2,
num_spells: 2,
},
],
slots: vec![
slot(6603, "Attack", None),
slot(75, "Auto Shot", None),
slot(2973, "Raptor Strike", Some("Rank 1")),
slot(1978, "Serpent Sting", Some("Rank 1")),
],
});
}
let info = super::addons::info_from_toc("Quiver", &read_toc(root, "Quiver"));
s.register_addons(vec![info], Some(root.to_path_buf()), None, None);
let failures = super::load_default_ui(&s);
assert!(
failures.is_empty(),
"our own FrameXML failed to load: {failures:#?}"
);
s
}
/// **The reported symptom.** Load Quiver into a hunter's session, drive the session start the
/// client drives, and ask the question the macro asked.
///
/// The assertion is deliberately the addon's own field and not "no errors": B267 was filed as a
/// nil field, and a future wall inside `initSlashCommandsAndModules` would take this field out
/// again even if it left a different error behind.
#[test]
fn quiver_publishes_its_global_functions_for_a_hunter() {
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
let load_errors = load_addon_files(&s, &root, "Quiver");
assert!(load_errors.is_empty(), "Quiver's files: {load_errors:#?}");
s.fire_event("ADDON_LOADED", vec![ScriptValue::Str("Quiver".into())]);
for e in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] {
s.fire_event(e, Vec::new());
}
// The macro the report ran: `/run Quiver.CastPetAction("Furious Howl"); …`
assert_eq!(
s.eval::<String>("return type(Quiver.CastPetAction)")
.unwrap(),
"function",
"B267: the addon's own field is nil because its VARIABLES_LOADED handler died \
before publishing it — errors so far: {:#?}",
s.errors()
);
// The whole of `RegisterGlobalFunctions`, not just the one the report named — they publish
// together, so any of them missing means the same handler died at the same place.
for name in [
"CastNoClip",
"CastPetAction",
"FdPrepareTrap",
"GetSecondsRemainingReload",
"GetSecondsRemainingShoot",
"PredMidShot",
"TrinketSwap1",
"TrinketSwap2",
] {
assert_eq!(
s.eval::<String>(&format!("return type(Quiver.{name})"))
.unwrap(),
"function",
"Quiver.{name} was never published"
);
}
}
/// The session start itself raises **nothing** — the three walls, stated as the thing they were.
///
/// Separate from the field assertion above because it fails differently and more usefully: this is
/// the test that names a *new* wall the moment one appears, instead of reporting a nil field.
#[test]
fn quiver_survives_a_hunter_session_start_without_raising() {
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
assert!(load_addon_files(&s, &root, "Quiver").is_empty());
s.fire_event("ADDON_LOADED", vec![ScriptValue::Str("Quiver".into())]);
for e in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] {
s.fire_event(e, Vec::new());
}
// …and then a second of frames, because Quiver's modules are OnUpdate-driven (the auto-shot
// timer, the range indicator, the aspect tracker) and a handler that only raises once it is
// *running* is invisible to the event burst alone.
for _ in 0..10 {
s.tick(0.1);
}
let raised = s.take_errors();
assert!(
raised.is_empty(),
"Quiver raised at session start: {raised:#?}"
);
}