dodge, parry and block answer, and the camera boom stops at the water

- GetDodgeChance, GetParryChance and GetBlockChance answer the numbers the character sheet already had, so the addons that ask for them get a figure instead of a nil (2166)
- with Follow Terrain on, pressing mouse-look no longer jolts the view: the terrain lean folds into the angle you drag and comes back out on release, and smart pivot holds its bias while a server-driven turn runs (2165)
- entering water, the camera's pivot eases down to the model's own swimming height, so the eye no longer sits at a different height for the same zoom (2165)
- the camera's water collision is back as what the option actually is: with it on, the camera boom stops at a bare water surface instead of passing through it; the pivot corridor that snapped last time is not rebuilt (2170)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-11 21:02:31 +07:00
co-authored by Claude Fable 5.1
parent 3b084ba052
commit 64296b306f
21 changed files with 1614 additions and 127 deletions
@@ -304,6 +304,96 @@ fn main() {
let _ = DEEP.set(deep);
let root = std::path::PathBuf::from(root);
// `--together` — the whole folder in ONE VM, the control for the survey's one-VM-per-addon
// bound. Not a column and not a headline (see `addon_harness::together`'s header for what it
// cannot answer); `--diff <a survey roster>` names the rows the bound is costing, which is
// the question it exists for.
if rest.iter().any(|a| a == "--together") {
let rows = addon_harness::together::survey_together(&root);
if rows.is_empty() {
eprintln!(
"no addons under {} — is that an AddOns folder?",
root.display()
);
std::process::exit(1);
}
let raised: Vec<&addon_harness::together::TogetherRow> =
rows.iter().filter(|r| r.always_raises()).collect();
let wobbly: Vec<&addon_harness::together::TogetherRow> =
rows.iter().filter(|r| r.order_sensitive()).collect();
println!(
"\n{} addon(s) under {}, ALL IN ONE VM — {} runs",
rows.len(),
root.display(),
addon_harness::together::DEFAULT_RUNS
);
println!(
" raised in EVERY run : {}/{} (clean in every run: {})",
raised.len(),
rows.len(),
rows.len() - raised.len() - wobbly.len()
);
// Named, never folded into either count: Lua hashes a table key by its pointer, so a
// registry keyed by objects walks in a different order every process (see the module
// doc). A row that raises in some runs and not others is that showing through.
println!(
" ORDER-SENSITIVE — raised in some runs, not all ({}):",
wobbly.len()
);
for r in &wobbly {
println!(
" {:<28} {}/{} {}",
r.name,
r.raised_in,
r.runs,
r.errors[0].lines().next().unwrap_or("")
);
}
if let Some(path) = &diff {
// The survey's roster, read back: `ok` there and clean here is agreement; `fail`
// there and clean here is the bound, priced.
let prior: std::collections::BTreeMap<String, bool> = std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.filter_map(|l| l.rsplit_once(' '))
.map(|(n, v)| (n.trim().to_string(), v.trim() == "ok"))
.collect();
let mut freed: Vec<&str> = Vec::new();
let mut only_together: Vec<&str> = Vec::new();
for r in &rows {
// Only the rows that are the same in every run are compared — an order-sensitive
// one belongs to neither list, which is the whole point of naming it separately.
match (prior.get(&r.name), r.raised_in) {
(Some(false), 0) => freed.push(&r.name),
(Some(true), n) if n == r.runs => only_together.push(&r.name),
_ => {}
}
}
println!(
"\n FAILS ALONE, CLEAN TOGETHER ({}) — the one-VM bound, priced:",
freed.len()
);
for n in &freed {
println!(" {n}");
}
println!(
"\n CLEAN ALONE, RAISES TOGETHER ({}) — a neighbour's global, or an order the\n \x20 survey never reaches:",
only_together.len()
);
for n in &only_together {
println!(" {n}");
}
}
println!(
"\n STILL RAISING IN EVERY RUN, WITH EVERY NEIGHBOUR PRESENT (first error each):"
);
for r in &raised {
let first = r.errors[0].lines().next().unwrap_or("");
println!(" {:<28} {first}", r.name);
}
return;
}
// `--probe <Name> [--eval <lua> ...]` — ONE addon, loaded the way the survey loads it, then
// asked. Handled before the survey because it is not one: it prints no column and it is not a
// measurement (an eval can mutate the VM), so mixing the two outputs would invite a probe
+305 -44
View File
@@ -84,6 +84,9 @@ mod probe_tests;
pub mod render;
#[cfg(test)]
mod render_tests;
/// The control for this module's one-VM-per-addon bound — the whole folder in ONE VM, the way a
/// real client runs it. Its header says what it can and cannot answer.
pub mod together;
/// The use column — *does the thing it drew survive being **touched**?* Its own module for the
/// same two reasons `render` is; its header is the design, the bounds and the four-deep history
/// that made it necessary.
@@ -176,9 +179,11 @@ pub struct AddonReport {
/// Same static caveat, plus one of its own: `inherits=` spans two namespaces — a `<FontString
/// inherits="GameFontNormal">` names a FONT — so a name registered as either is resolved.
pub missing_inherits: Vec<String>,
/// Errors raised **after** the files loaded, while the session start was driven —
/// `ADDON_LOADED` → `VARIABLES_LOADED` → `PLAYER_LOGIN` → `PLAYER_ENTERING_WORLD`, then a few
/// ticks to drain `OnUpdate` and anything scheduled.
/// Errors raised **after** this addon's own files loaded, while the session start was driven —
/// its own `ADDON_LOADED` → `VARIABLES_LOADED` → `PLAYER_LOGIN` → `PLAYER_ENTERING_WORLD`,
/// then a few ticks to drain `OnUpdate` and anything scheduled. Every DEPENDENCY's
/// `ADDON_LOADED` is already behind this point: each fires beside its own addon's files, where
/// `0x51f5ad` fires it ([`load_dependencies`], 2166).
///
/// **This is the survey's answer to its own oldest blind spot.** Every number beside it is
/// load-time, and this arc has now written the same sentence into four decision records —
@@ -559,15 +564,16 @@ fn survey_one(
//
// Loaded BEFORE `globals_of` below, so a name a dependency provides does not also count as a
// missing global — the same double-count the FrameXML-underneath decision avoided.
let mut dep_order: Vec<String> = Vec::new();
let mut dep_loads: Vec<LoadedDep> = Vec::new();
load_dependencies(
&script,
&mut script,
root,
&toc,
installed,
&mut BTreeSet::new(),
&mut dep_order,
&mut dep_loads,
);
let dep_order: Vec<String> = dep_loads.into_iter().map(|d| d.name).collect();
let known = globals_of(&script);
// The METHODS the dependency chain defines, gathered from the same folders whose files were
// just loaded above. `known` covers their globals because the VM actually ran them; a method
@@ -594,16 +600,15 @@ fn survey_one(
// addon at `0x51f5ad`'s position (`ui_script::addons`'s `mark_addon_loaded`, just before
// `ADDON_LOADED`), and without it here `IsAddOnLoaded` answered nil for an addon whose files
// had just run — and a LoadOnDemand dependent of it got `DEP_NOT_DEMAND_LOADED` rather than
// loading (decision 2102). The dependency chain is stamped too, in load order.
for loaded in dep_order.iter().chain(std::iter::once(&name.to_string())) {
script.mark_addon_loaded(loaded);
}
// loading (decision 2102). The dependency chain stamped itself as it loaded, each one beside
// its own `ADDON_LOADED` (`load_dependencies`, 2166); only the surveyed addon is left.
script.mark_addon_loaded(name);
let wants = missing_calls(root, name, &toc, &known, &dep_methods);
// AFTER the addon's files: a template it declares in its OWN XML is registered by then, so it
// is not missing. The check asks the VM's live registry, not a name list.
let missing_templates = missing_templates(&script, root, name, &toc);
let missing_inherits = missing_inherits(&script, root, name, &toc);
let session_errors = drive_session_start(&mut script, name, &dep_order, installed);
let session_errors = drive_session_start(&mut script, name, installed);
let probe_errors = drive_ui_probe(&mut script);
// Everything filed since the mark, warnings only — the errors have their own columns and are
// retained under their own kinds.
@@ -945,6 +950,11 @@ fn only_on(have: &[String], probes: &[String]) -> String {
/// `PLAYER_LOGIN`; `PLAYER_ENTERING_WORLD` follows in the cascade. Then a few ticks, because a
/// great deal of addon code runs from `OnUpdate` or from something scheduled on the first one.
///
/// **Only the surveyed addon's `ADDON_LOADED` is fired here** (2166). The client fires each one
/// inside that addon's own `AddOn_Load` (`0x51f5ad`), not in a batch afterwards, so every
/// dependency's event has already happened in [`load_dependencies`] — before the surveyed addon's
/// first line ran, which is what the real client presents to it.
///
/// **Deliberately not `ui_script::finish_ui_load`**, which is the production path: that also runs
/// `load_saved_variables`, which reads the machine's real `BENILLA_HOME`. A survey must not depend
/// on — or write to — the director's own saved variables, so the events are fired directly.
@@ -957,12 +967,14 @@ fn only_on(have: &[String], probes: &[String]) -> String {
fn drive_session_start(
script: &mut UiScript,
name: &str,
deps: &[String],
installed: &BTreeSet<String>,
) -> Vec<String> {
// **Every addon in the VM gets its OWN `ADDON_LOADED`, in load order** — the client fires one
// per loaded addon with that addon's folder in `arg1`, and a dependency's initialiser is
// almost always gated on exactly that:
// **The dependencies' `ADDON_LOADED`s are already behind us** — each fired beside its own
// addon's files, at `0x51f5ad`'s position, inside `load_dependencies` (2166). What is left of
// the client's session start is the SURVEYED addon's own event and the three that follow it
// once every addon has loaded: `VARIABLES_LOADED`, `PLAYER_LOGIN`, `PLAYER_ENTERING_WORLD`.
//
// A dependency's initialiser is almost always gated on its own name arriving in `arg1`:
//
// -- Atlas.lua:326
// if (event == "ADDON_LOADED" and arg1 == "Atlas") then Atlas_Init(); end
@@ -970,34 +982,22 @@ fn drive_session_start(
// `Atlas_Init` is what assigns `AtlasOptions` (l.199). Firing only the SURVEYED addon's name
// meant that guard never passed for a dependency, so `FuBar_AtlasFu` met an Atlas that had
// loaded its files and never initialised — and died at `AtlasButton.lua:30` on a nil
// `AtlasOptions`, a fault the survey then recorded against FuBar_AtlasFu. The state the real
// client presents was never reachable.
// `AtlasOptions`, a fault the survey then recorded against FuBar_AtlasFu. That was fixed by
// firing every dependency's event here; 2166 moved each one to where the client fires it.
//
// Fired BEFORE the error mark, deliberately: a dependency's own handler raising is the
// dependency's row, not its consumers'. That is this module's stated rule for load errors
// (`load_dependencies`) and there is no reason session errors should differ — charging it here
// would count one library's fault once per addon that embeds it, which is the whole reason
// one-VM-per-addon exists.
//
// **Attribution is by the RAISING CHUNK now, not by which event window the raise fell in**
// (decision 1226 — recorded when this was still window-based, fixed here). The window proxy
// **Attribution is by the RAISING CHUNK, not by which event window the raise fell in**
// (decision 1226 — recorded when this was still window-based, fixed since). The window proxy
// broke on the shape it was written for: AceAddon drains its ENTIRE `nextAddon` queue on any
// `ADDON_LOADED` it sees (`AceAddon-2.0.lua:104-105`) and calls each consumer's
// `OnInitialize` there (`:230`). So the SURVEYED addon's own code runs inside a DEPENDENCY's
// window, and firing deps outside the mark charged those raises to nobody — the whole
// FuBar/Ace family was silently OVER-reported as surviving. `Region:SetParent` landing made
// FuBar_FuXPFu flip `ok -> fail` and that looked like a regression; it was this.
// `OnInitialize` there (`:230`). So the SURVEYED addon's own code can run inside a
// DEPENDENCY's window, and a mark that only covered its own window charged those raises to
// nobody — the whole FuBar/Ace family was silently OVER-reported as surviving.
//
// Every `ADDON_LOADED` fires inside the mark now, and the dependency exemption is applied
// afterwards by asking WHOSE FILE raised. Chunk names have been truthful since 1217
// (`@Interface\AddOns\<Folder>\<File>`), which is what makes the honest key available at all.
// The mark opens BEFORE the surveyed addon's own event for exactly that reason, and the
// dependency exemption is applied afterwards by asking WHOSE FILE raised. Chunk names have
// been truthful since 1217 (`@Interface\AddOns\<Folder>\<File>`), which is what makes the
// honest key available at all.
let before = script.errors().len();
for dep in deps {
script.fire_event(
"ADDON_LOADED",
vec![benilla_ui::script::ScriptValue::Str(dep.clone())],
);
}
script.fire_event(
"ADDON_LOADED",
vec![benilla_ui::script::ScriptValue::Str(name.to_string())],
@@ -1206,12 +1206,12 @@ fn drive_ui_probe(script: &mut UiScript) -> Vec<String> {
/// own row in this survey; blaming its consumers would count one fault N times, which is the
/// mistake the one-VM-per-addon rule exists to prevent.
fn load_dependencies(
script: &UiScript,
script: &mut UiScript,
root: &Path,
toc: &Toc,
installed: &BTreeSet<String>,
seen: &mut BTreeSet<String>,
loaded: &mut Vec<String>,
loaded: &mut Vec<LoadedDep>,
) {
// OPTIONAL first, then required — the reference's own order, and the half this walk was
// missing. The doc above has claimed since 1193 that the two are "folded together"; only
@@ -1238,13 +1238,49 @@ fn load_dependencies(
continue;
};
load_dependencies(script, root, &dep_toc, installed, seen, loaded);
let _ = load_addon_files(script, root, dep, &dep_toc);
// Recorded AFTER its own files, and after its own dependencies — the order the client
// fires `ADDON_LOADED` in, which is the order `drive_session_start` replays.
loaded.push(dep.to_string());
let files = load_addon_files(script, root, dep, &dep_toc);
// **`ADDON_LOADED` fires HERE, at the reference's own position** — not later, with every
// other one, which is what this harness did until 2166. `AddOn_Load 0x51f240` emits, per
// addon: its dependencies → its own `.toc` files (`0x51f3fa`) → `Bindings.xml` → the two
// SavedVariables chunks → **`ADDON_LOADED` (event 429) at `0x51f5ad`** — and only then
// returns to load the next addon. So a dependency's event lands BEFORE its dependent's
// first line runs, and the loaded flag is set before the event (`[rec+0x18]=1` at
// `0x51f313`, the loop preamble) so a handler asking `IsAddOnLoaded` gets the truth.
//
// Deferring them all to `drive_session_start` put the corpus's largest family in a state
// the client never produces. AceAddon-2.0 drains its **whole** `nextAddon` queue on the
// first `ADDON_LOADED` it sees, whoever it names (`AceAddon-2.0.lua:104-105`), and calls
// each consumer's `OnInitialize` there (`:230`) — so with the events deferred, every Ace2
// addon in the VM initialised under its FIRST DEPENDENCY's name, after every file in the
// chain had already run. ~87 corpus addons are Ace2.
//
// Fired OUTSIDE the surveyed addon's error mark, deliberately and for the same reason the
// dependency's load errors are: a library that raises in its own initialiser is its own
// row in this survey, where it is the surveyed addon and this same event fires inside the
// mark. Charging it here would count one fault once per addon that embeds the library.
script.mark_addon_loaded(dep);
script.fire_event(
"ADDON_LOADED",
vec![benilla_ui::script::ScriptValue::Str(dep.to_string())],
);
loaded.push(LoadedDep {
name: dep.to_string(),
files,
});
}
}
/// One dependency the walk pulled in, and what its own files did.
///
/// The name alone was enough while every consumer only wanted the load ORDER. The shared-VM
/// control ([`together`]) wants the failures too, and it cannot recover them any other way: a
/// library folder loads exactly once in that VM, under whichever dependent reached it first, so
/// it never gets a turn of its own to be measured in.
struct LoadedDep {
name: String,
files: FileLoad,
}
/// Template names the addon passes to `CreateFrame` that the VM cannot resolve.
///
/// Scanned rather than traced, for `missing_globals`' reason: the calls overwhelmingly happen in
@@ -1812,6 +1848,112 @@ fn seat_a_session(script: &mut UiScript) {
spell_slot(772, "Rend", Some("Rank 1")),
],
});
// ── THE TWO LOGIN-SCOPED CATALOGUES (2167) ─────────────────────────────────────────────
//
// Both of these are answered by the client with **no session behind them at all** — they are
// DBC reads, live from the moment the VM exists — so a VM that answers them empty is in a
// state the reference cannot be in, exactly like the nil faction group and the empty CVar
// table above. Both are read off the PLAYER'S OWN CHAIN, never shipped: we seat the
// structure, the install supplies the words (`ui_auction`'s own rule).
//
// **The auction class tree.** `GetAuctionItemClasses()` is `ItemClass.dbc` in the browse
// menu's order (1971), and the stock `AuctionFrameBrowse_OnLoad` reads it at the addon's
// LOAD. `Auctioneer`'s `AucCore.lua:105` is
//
// local classes = pack(GetAuctionItemClasses());
// local bidBasedCategories = {[classes[1]]=true, ..., [classes[10]]=true}
//
// — ten subscripts into a list that answered nothing, so `AucCore.lua:106` died on
// `table index is nil` and took `BeanCounter`, `Informant`, `Enchantrix` and `FonzAppraiser`
// with it. Five corpus addons read the tree; four of them at file scope.
script.set_auction_item_classes(auction_item_classes());
// **The talent tree.** `GetTalentInfo(tab, i)` answers off `Talent.dbc` × `TalentTab.dbc`;
// ten corpus addons read it, and `KLHThreatMeter` reads it by POSITION at file scope
// (`KTM_Data.lua:376`, `_, _, _, _, rank = GetTalentInfo(info[1], info[2])`) and then does
// `-rank` arithmetic on the answer (`KTM_My.lua:721`). An unseated tree is nil, not zero.
//
// **Zero ranks, the full unspent pool** — a character who has just reached 60 and not spent a
// point. Deliberately, and for 1209's reason: a specific build would put arbitrary constants
// into every threat and damage calculation in the corpus, and a row nobody can attribute is
// worse than a row that is merely quiet. Rank 0 is a NUMBER, which is the whole difference
// this seat makes. 51 points is `level - 9` at 60, and the class is the seated warrior's.
if let Some(state) = talent_snapshot(script) {
script.set_talents(state);
}
}
/// The reference's own auction browse tree, read once off the player's chain.
///
/// The same builder the live client feeds from (`ui_auction::categories`) over the same two DBCs,
/// so the survey cannot drift from the window: a second copy of the ten-class set would be a
/// second thing to keep right. Empty with no install, in which case the survey simply measures a
/// worse VM and says so through [`seated_with_global_strings`].
fn auction_item_classes() -> Vec<benilla_ui::script::AuctionCategory> {
use std::sync::OnceLock;
static TREE: OnceLock<Vec<benilla_ui::script::AuctionCategory>> = OnceLock::new();
TREE.get_or_init(|| {
let Some(data) = benilla_formats::wow_data() else {
return Vec::new();
};
let Ok(mut chain) = benilla_formats::open_chain(&data) else {
return Vec::new();
};
let classes = benilla_formats::load_item_classes(&mut chain)
.ok()
.map(crate::ui_items::ItemClasses);
let subclasses = benilla_formats::load_item_sub_classes(&mut chain)
.ok()
.map(crate::ui_items::ItemSubClasses);
crate::ui_auction::categories(classes.as_ref(), subclasses.as_ref())
})
.clone()
}
/// The seated character's talent pages, built by the live client's own `build_pages`.
///
/// The catalogues are read once per process (they are two DBC loads plus `Spell.dbc`, and the
/// survey stands up 219 VMs); the PAGES are built per VM, because the red requirement lines are
/// filled from that VM's own `GlobalStrings.lua` — the same rule the live feed follows (2045).
/// `None` with no install.
fn talent_snapshot(script: &UiScript) -> Option<benilla_ui::script::TalentUiState> {
use std::sync::OnceLock;
#[allow(clippy::type_complexity)]
static DBCS: OnceLock<
Option<(
benilla_formats::TalentCatalog,
benilla_formats::SpellCatalog,
)>,
> = OnceLock::new();
let (talents, spells) = DBCS
.get_or_init(|| {
let data = benilla_formats::wow_data()?;
let mut chain = benilla_formats::open_chain(&data).ok()?;
let talents = benilla_formats::load_talent_catalog(&mut chain).ok()?;
let spells = benilla_formats::load_spell_catalog(&mut chain).ok()?;
Some((talents, spells))
})
.as_ref()?;
let get = |key: &str| {
script
.lua()
.globals()
.get::<String>(key)
.ok()
.filter(|t| !t.is_empty())
};
// Human (`ChrRaces.dbc` 1) warrior (`ChrClasses.dbc` 1) — the character `seat_a_session`
// seats above, so the pages are the ones that character's talent window would show.
Some(crate::ui_talent::build_pages(
talents,
&BTreeSet::new(),
spells,
1,
1,
(51, 0),
&get,
))
}
/// One seated equipment slot — full durability, so no alert region lights up.
@@ -3839,6 +3981,75 @@ mod dependency_tests {
let _ = std::fs::remove_dir_all(&tmp);
}
/// **A dependency's `ADDON_LOADED` lands before the dependent's FIRST LINE runs** (2166).
///
/// The reference emits, per addon: its dependencies → its own `.toc` files (`0x51f3fa`) →
/// `Bindings.xml` → the SavedVariables chunks → **`ADDON_LOADED` at `0x51f5ad`** — and only
/// then moves to the next addon. So by the time a dependent's file scope runs, every
/// dependency has already had its own event.
///
/// This harness deferred all of them to `drive_session_start` instead, which is a state the
/// client never produces and which no earlier test could see: the one above reads the
/// dependency's state from a `PLAYER_LOGIN` handler, and that passes either way. **The
/// discriminating read is at FILE SCOPE**, and it is the corpus's own shape — `KTMAutoHider`'s
/// `<OnLoad>` calls into `KLHThreatMeter`'s GUI, which is nil until KLHThreatMeter's own
/// `ADDON_LOADED` builds it.
///
/// The control below is what stops this test passing for the wrong reason: `EagerLib` must
/// initialise **on its own event and nowhere else**, so a consumer that sees it has genuinely
/// received the event and not merely run a file.
#[test]
fn a_dependencys_addon_loaded_precedes_the_dependents_file_scope() {
let tmp =
std::env::temp_dir().join(format!("benilla-harness-eager-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let write = |name: &str, toc: &str, file: &str, body: &str| {
let dir = tmp.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap();
std::fs::write(dir.join(file), body).unwrap();
};
write(
"EagerLib",
"## Interface: 11200\nlib.lua\n",
"lib.lua",
"EagerLibFrame = CreateFrame(\"Frame\")\n\
EagerLibFrame:RegisterEvent(\"ADDON_LOADED\")\n\
EagerLibFrame:SetScript(\"OnEvent\", function()\n\
if event == \"ADDON_LOADED\" and arg1 == \"EagerLib\" then EagerLibReady = {} end\n\
end)\n",
);
// The consumer reads it AT FILE SCOPE — no handler, no later event.
write(
"EagerUser",
"## Interface: 11200\n## Dependencies: EagerLib\nuse.lua\n",
"use.lua",
"EagerUserSaw = EagerLibReady.anything\n",
);
let reports = survey(&tmp);
let of = |n: &str| reports.iter().find(|r| r.name == n).unwrap();
assert!(
of("EagerUser").errors.is_empty(),
"the dependency's ADDON_LOADED must precede the dependent's file scope: {:?}",
of("EagerUser").errors
);
assert!(
of("EagerUser").loaded && of("EagerUser").session_errors.is_empty(),
"...and the row is clean end to end: {:?}",
of("EagerUser").session_errors
);
// The control: EagerLib's own row still gets its own event, and nothing raised there.
assert!(
of("EagerLib").loaded && of("EagerLib").session_errors.is_empty(),
"the library's own row is unaffected: {:?}",
of("EagerLib").session_errors
);
let _ = std::fs::remove_dir_all(&tmp);
}
/// **A manifest entry with no file is split by WHOSE package is short** — the column that
/// stops a session hunting for a client bug that is not one.
///
@@ -4701,6 +4912,56 @@ mod dependency_tests {
assert_eq!(script.widget_kind("NoSuchFrameAnywhere"), None);
}
/// **The seated session answers the two login-scoped catalogues** (2167) — the auction browse
/// tree and the player's talent pages.
///
/// Both are DBC reads the client answers with no session behind them, so an empty answer is a
/// state the reference cannot be in — and both were read AT FILE SCOPE by corpus addons that
/// then did arithmetic or subscripting on the answer. `Auctioneer` subscripts `classes[10]`
/// (`AucCore.lua:106`) and `KLHThreatMeter` negates `GetTalentInfo`'s rank
/// (`KTM_My.lua:721`); nil is what both were getting.
///
/// The subscript and the arithmetic are what this asserts, not a count: the ten-class set and
/// the warrior's three pages come off the player's own DBCs, and pinning their contents here
/// would be pinning the install rather than the seat.
#[test]
fn the_seated_session_answers_the_login_scoped_catalogues() {
if benilla_formats::wow_data().is_none() {
eprintln!("skipping: no WoW client data");
return;
}
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
seat_a_session(&mut script);
// Auctioneer's own line, reduced: ten subscripts into the packed return.
assert_eq!(
script
.eval::<i64>(
"local c = {GetAuctionItemClasses()} \
local t = {} for i = 1, 10 do t[c[i]] = true end \
return table.getn(c)"
)
.ok(),
Some(10),
"the browse tree is the reference's ten auctionable classes"
);
// KLHThreatMeter's own line, reduced: a positional read and arithmetic on the rank.
assert_eq!(
script
.eval::<i64>("local _, _, _, _, rank = GetTalentInfo(3, 13) return -rank")
.ok(),
Some(0),
"a talent read by POSITION answers a number — nil is what the corpus was getting"
);
assert!(
script
.eval::<bool>("return GetNumTalentTabs() == 3 and GetNumTalents(1) > 0")
.unwrap_or(false),
"the seated warrior has his three pages"
);
}
/// **The UI probe actually INVOKES an addon's override**, which is the only thing that makes it
/// different from every other column here.
///
@@ -97,9 +97,9 @@ pub fn probe(root: &Path, name: &str, evals: &[String]) -> Option<ProbeOutcome>
super::seat_a_session(&mut script);
let _ = crate::ui_script::load_default_ui(&script);
let mut dep_order: Vec<String> = Vec::new();
let mut dep_order: Vec<super::LoadedDep> = Vec::new();
super::load_dependencies(
&script,
&mut script,
root,
&toc,
&installed,
@@ -110,11 +110,9 @@ pub fn probe(root: &Path, name: &str, evals: &[String]) -> Option<ProbeOutcome>
let load_errors = super::load_addon_files(&script, root, name, &toc).errors;
// Same stamp the survey applies, for the same reason: the registry must agree with the VM
// about what is loaded, or `IsAddOnLoaded` and every dependency verdict answer for a session
// that does not exist.
for loaded in dep_order.iter().chain(std::iter::once(&name.to_string())) {
script.mark_addon_loaded(loaded);
}
let session_errors = super::drive_session_start(&mut script, name, &dep_order, &installed);
// that does not exist. The chain stamped itself as it loaded (2166); this is the surveyed one.
script.mark_addon_loaded(name);
let session_errors = super::drive_session_start(&mut script, name, &installed);
let answers = evals
.iter()
@@ -0,0 +1,323 @@
//! **The control for the one-VM-per-addon bound** — the whole folder in ONE VM, the way a real
//! client runs it.
//!
//! The survey next door loads each addon into a VM of its own, with only its declared dependencies
//! underneath, and says so in its own header: *the headline is a floor, not an estimate*. That
//! bound is real and it is the largest single category left in the corpus's failures — a package
//! that does not ship `Tablet-2.0` and simply expects the library to be there, because in a folder
//! of 219 addons seventy of its neighbours ship a copy and Ace2's registry is one global table.
//!
//! **A bound that is stated and never measured is an excuse.** This is the measurement: one Lua
//! state, every addon in the folder walked in load order, dependencies first, each addon's
//! `ADDON_LOADED` at its own position — then the session start, once, for everybody. What comes
//! back is *which addons raise inside their own files when their neighbours are present*, and the
//! difference between that list and the survey's is exactly the size of the bound.
//!
//! ## What this is NOT
//!
//! Not a replacement for the survey and not a headline. It cannot attribute a *render* or a *use*
//! result (every addon's frames are in one world), it cannot tell "clean" from "never reached",
//! and one addon's runaway is everybody's. Attribution here is by the RAISING CHUNK and nothing
//! else — the same rule the survey applies to session errors, and the only one available when 219
//! addons share a state.
//!
//! ## It is run N times, and the reason is the whole finding
//!
//! **A big shared VM is not reproducible, and that is a property of Lua rather than a defect of
//! ours.** Lua 5.1 hashes a **table key by its POINTER** (`hashpointer`), so `pairs()` over a
//! registry keyed by objects — which is what every Ace2 library's registry is — walks in a
//! different order on every process, because ASLR moves the addresses. Measured directly rather
//! than deduced: twelve fresh tables as keys in one table, iterated, gave `3,7,11,1,…` then
//! `12,5,9,2,…` then `11,1,4,8,…` on three consecutive runs of the same binary.
//!
//! The reference's Lua does exactly the same thing, so this is a fact about the sessions we are
//! modelling and not something to fix. What it means for an INSTRUMENT is that a single run's
//! number cannot be quoted: three consecutive runs here disagreed by one addon and swapped three
//! rows. So the walk runs [`DEFAULT_RUNS`] times in fresh VMs and the verdict is per row:
//!
//! - **raised in EVERY run** — a real failure with the neighbours present;
//! - **raised in SOME** — order-sensitive, named as such and never folded into either count;
//! - **clean in every run** — clean.
//!
//! The per-addon survey next door has been stable run to run (three rosters, byte-identical), and
//! the reason is scale rather than luck: one addon and its declared dependencies rarely build a
//! pointer-keyed registry big enough for the order to change an outcome. "Rarely" is not "never",
//! which is worth knowing about every `--diff` this arc has read as exact.
//!
//! **LoadOnDemand is ignored on purpose.** 62 of the corpus's manifests carry `## LoadOnDemand: 1`
//! and the boot walk skips every one (`0x51f600` loads only records whose LoadOnDemand byte is 0),
//! but the corpus's own addons then demand-load them: `FuBar.lua:1034`'s `LoadLoadOnDemandPlugins`
//! pulls in every installed `FuBar_*` the moment any one of them seats, and Auctioneer's stub does
//! the same for its family. A run that honoured the flag would answer for a session five minutes
//! shorter than the one anybody plays.
use std::collections::BTreeSet;
use std::path::Path;
use benilla_ui::script::UiScript;
use super::{
corpus, load_addon_files, load_dependencies, manifest_path, LoadedDep, ADDON_INSTRUCTION_BUDGET,
};
use benilla_ui::toc::Toc;
/// How many times [`survey_together`] repeats the walk before it reports — see the module doc on
/// why one run is not quotable. Three is the smallest number that can tell "always" from
/// "sometimes"; each run is about twenty-five seconds.
pub const DEFAULT_RUNS: usize = 3;
/// One addon's verdict in the shared VM.
pub struct TogetherRow {
pub name: String,
/// Raises whose first stack frame is inside **this** addon's own folder, load-time and
/// session-time together — the shared VM cannot separate the two windows per addon. From the
/// FIRST run that produced any, so the text is a real traceback and not a merge of several.
pub errors: Vec<String>,
/// How many of the [`runs`](Self::runs) raised at all.
pub raised_in: usize,
pub runs: usize,
}
impl TogetherRow {
/// Raised in every run — a failure the neighbours do not fix.
pub fn always_raises(&self) -> bool {
self.raised_in == self.runs
}
/// Raised in some runs and not others — the pointer-hash order showing through (module doc).
pub fn order_sensitive(&self) -> bool {
self.raised_in > 0 && self.raised_in < self.runs
}
}
/// Load every addon under `root` into one VM and drive the session start — [`DEFAULT_RUNS`] times,
/// in a fresh VM each time, because one run's answer is not reproducible (module doc).
pub fn survey_together(root: &Path) -> Vec<TogetherRow> {
let mut merged: Vec<TogetherRow> = Vec::new();
for run in 0..DEFAULT_RUNS {
let rows = survey_together_once(root);
if run == 0 {
merged = rows
.into_iter()
.map(|(name, errors)| TogetherRow {
name,
raised_in: usize::from(!errors.is_empty()),
errors,
runs: DEFAULT_RUNS,
})
.collect();
continue;
}
for (name, errors) in rows {
let Some(row) = merged.iter_mut().find(|r| r.name == name) else {
continue;
};
if errors.is_empty() {
continue;
}
row.raised_in += 1;
if row.errors.is_empty() {
row.errors = errors;
}
}
}
merged
}
/// One walk, one VM — the measurement [`survey_together`] repeats.
fn survey_together_once(root: &Path) -> Vec<(String, Vec<String>)> {
let (names, installed, registry) = corpus(root);
let Ok(mut script) = UiScript::new() else {
return Vec::new();
};
script.set_screen_size(1024.0, 768.0);
script.register_addons(registry, Some(root.to_path_buf()), None, None);
super::seat_a_session(&mut script);
let _ = crate::ui_script::load_default_ui(&script);
// `seen` spans the WHOLE walk, not one addon: a library folder declared by eighty dependents
// loads once, which is the property that makes this a session rather than eighty of them.
let mut seen: BTreeSet<String> = BTreeSet::new();
// `(folder, its own load failures)` — kept beside the walk rather than read back out of the
// VM, because the diagnostics log caps at 256 distinct rows and evicts, and 219 addons
// overflow it. The survey has the same rule for the same reason.
let mut load_errors: Vec<(String, Vec<String>)> = Vec::new();
for name in &names {
let Some(toc) = manifest_path(root, name)
.and_then(|p| std::fs::read(p).ok())
.map(|b| Toc::parse(&benilla_ui::source::decode(&b)))
else {
continue;
};
if !seen.insert(name.to_ascii_lowercase()) {
continue; // already pulled in as somebody's dependency
}
// Re-armed PER ADDON, exactly as the live walk re-arms it (1306): without the reset one
// runaway spends the whole allowance and every addon after it fails for somebody else's
// loop.
script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET);
let mut pulled: Vec<LoadedDep> = Vec::new();
load_dependencies(&mut script, root, &toc, &installed, &mut seen, &mut pulled);
// **A dependency's load failures are collected HERE and nowhere else.** In this VM a
// library folder loads exactly once, under whichever dependent reached it first, so it
// never gets a turn of its own in the loop below — and its failures would simply vanish.
for dep in pulled {
load_errors.push((dep.name, dep.files.errors));
}
let files = load_addon_files(&script, root, name, &toc);
load_errors.push((name.clone(), files.errors));
script.mark_addon_loaded(name);
script.fire_event(
"ADDON_LOADED",
vec![benilla_ui::script::ScriptValue::Str(name.clone())],
);
}
script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET);
for event in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] {
script.fire_event(event, Vec::new());
}
for _ in 0..10 {
script.tick(0.1);
}
// Attribution by the raising chunk. `\<Folder>\` rather than a prefix match, because Lua
// truncates a long chunk name from the LEFT (`...Ons\FuBar_DakSmak\Libs\…`) and the prefix is
// the half it eats.
let mut rows: Vec<(String, Vec<String>)> =
names.iter().map(|n| (n.clone(), Vec::new())).collect();
// The LOAD half first, already attributed by whose manifest was being walked — the survey's
// own `loaded` rule applies unchanged: a named file the package does not contain is not a
// raise (2155), so it is not counted here either.
for (folder, errs) in load_errors {
let Some(row) = rows.iter_mut().find(|r| r.0 == folder) else {
continue;
};
row.1
.extend(errs.into_iter().filter(|e| !is_absent_file(e)));
}
for err in script.errors() {
let Some(first) = err.lines().next().map(str::to_ascii_lowercase) else {
continue;
};
// Longest folder name first, so `FuBar` never claims a raise inside `FuBar_AtlasFu`.
let mut owner: Option<usize> = None;
for (i, row) in rows.iter().enumerate() {
let folder = row.0.to_ascii_lowercase();
let hit = first.contains(&format!("\\{folder}\\"))
|| first.contains(&format!("{folder}/"))
|| first.starts_with(&format!("{folder}\\"));
if hit && owner.is_none_or(|o| rows[o].0.len() < row.0.len()) {
owner = Some(i);
}
}
if let Some(i) = owner {
rows[i].1.push(err);
}
}
rows
}
/// A load failure that is only *a file the package does not contain* — the reference logs
/// `Couldn't open %s` and carries on (2155), so it is not a raise here either.
fn is_absent_file(err: &str) -> bool {
err.ends_with(": not found")
|| err.contains("no provider hit for")
|| err.contains("; the whole document it names is missing")
}
#[cfg(test)]
mod tests {
use super::*;
/// **The control answers the question it exists for**: an addon whose package is short a
/// library its neighbour ships fails ALONE and is clean TOGETHER.
///
/// Both halves are asserted, because either one alone would pass for the wrong reason — a
/// control that finds everything clean is worthless, and so is one that finds nothing.
#[test]
fn a_library_a_neighbour_ships_is_there_in_the_shared_vm() {
let tmp =
std::env::temp_dir().join(format!("benilla-harness-together-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let write = |name: &str, toc: &str, file: &str, body: &str| {
let dir = tmp.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap();
std::fs::write(dir.join(file), body).unwrap();
};
// Loads FIRST (the walk is alphabetical) and puts the library in the one global state,
// exactly as any of the seventy corpus packages that ship a copy of `Tablet-2.0` does.
write(
"AlphaShipsIt",
"## Interface: 11200\nlib.lua\n",
"lib.lua",
"SharedLibrary = { greet = function() return 1 end }\n",
);
// Declares no dependency on it and does not ship it — the corpus's own shape.
write(
"ZuluWantsIt",
"## Interface: 11200\nuse.lua\n",
"use.lua",
"ZuluSaw = SharedLibrary.greet()\n",
);
let alone = crate::addon_harness::survey(&tmp);
let zulu = alone.iter().find(|r| r.name == "ZuluWantsIt").unwrap();
assert!(
!zulu.loaded,
"ALONE it must fail — otherwise this control is measuring nothing: {:?}",
zulu.errors
);
let rows = survey_together(&tmp);
let of = |n: &str| rows.iter().find(|r| r.name == n).unwrap();
assert_eq!(
of("ZuluWantsIt").raised_in,
0,
"TOGETHER the neighbour's library is simply there, in every run: {:?}",
of("ZuluWantsIt").errors
);
assert_eq!(
of("AlphaShipsIt").raised_in,
0,
"and the provider is unaffected: {:?}",
of("AlphaShipsIt").errors
);
let _ = std::fs::remove_dir_all(&tmp);
}
/// ...and a raise is still ATTRIBUTED, rather than the shared VM turning everything green.
#[test]
fn a_raise_in_the_shared_vm_lands_on_the_folder_that_raised() {
let tmp = std::env::temp_dir().join(format!(
"benilla-harness-together-attrib-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&tmp);
for (name, body) in [("Quiet", "QuietGlobal = 1\n"), ("Loud", "error('boom')\n")] {
let dir = tmp.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("{name}.toc")),
"## Interface: 11200\nf.lua\n",
)
.unwrap();
std::fs::write(dir.join("f.lua"), body).unwrap();
}
let rows = survey_together(&tmp);
let of = |n: &str| rows.iter().find(|r| r.name == n).unwrap();
assert!(
of("Loud").always_raises() && of("Loud").errors.iter().any(|e| e.contains("boom")),
"the raise is the loud one's, in every run: {:?}",
of("Loud").errors
);
assert_eq!(
of("Quiet").raised_in,
0,
"and not its neighbour's: {:?}",
of("Quiet").errors
);
let _ = std::fs::remove_dir_all(&tmp);
}
}
+22 -1
View File
@@ -717,6 +717,17 @@ pub(crate) const REGISTERED: &[Registered] = &[
// `0x512a50`'s `duration = |Δ| / (rate · π/180)`). No panel row here or there — the reader is
// the host, exactly like `cameraSmoothTrackingStyle` above it.
same("cameraTargetSmoothSpeed", "90"),
// **`cameraWaterCollision`** `[0xbe1088]` "1" (`0x50bd63`) — one of the two that ship ON, and
// this row is its SECOND life. 2149 registered it against a pivot corridor built on
// `camera-arm-liquid-blind.md`'s "the arm never touches liquid"; 2165 took that back out after
// the corridor moved the framing pivot 19/18 yd in one frame, and wow-re's
// `water-band-discontinuity.md` then refuted the verdict itself: `0x50e5ec` ORs the `0xf0000`
// ADT-liquid nibble into the trace mask ALL THREE of `0x50e570`'s queries carry — the arm sweep
// included — where `0x69cc13` gates a per-layer intersection over the chunk's four MCLQ slots.
// A capability requested through an argument flag is invisible to a census of call sites, which
// is how a correct, controlled census published the wrong negative. The reader is the camera
// boom's collision filter; the corridor is the reference's second consumer and is not rebuilt.
same("cameraWaterCollision", "1"),
// `cameraTerrainTilt` `[0xbe0fd4]` **"0"** (`0x50bcfd`) — Follow Terrain, and the one of the
// four that ships OFF, so building it changed nothing until a player ticks the box. Mechanism:
// wow-re `camera-cvar-kernels.md` §2 (the ahead-probe and the five-step staircase) and
@@ -1452,6 +1463,7 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
// reference's own validator on them is `0x50b330`'s range REFUSAL, which lives in
// `benilla_ui`'s `SetCVar` path, not here.
"camerapivot" => knobs.camera_opts.pivot = v != 0.0,
"camerawatercollision" => knobs.camera_opts.water_collision = v != 0.0,
"camerapivotdxmax" => knobs.camera_opts.pivot_dx_max = v,
"camerapivotdymin" => knobs.camera_opts.pivot_dy_min = v,
"cameratargetsmoothspeed" => knobs.camera_opts.target_smooth_speed = v,
@@ -1838,7 +1850,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 84] = [
let session: [(&str, String); 85] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1883,6 +1895,7 @@ fn sync_cvars(
"cameraTargetSmoothSpeed",
camera_opts.target_smooth_speed.to_string(),
),
("cameraWaterCollision", flag(camera_opts.water_collision)),
("cameraTerrainTilt", flag(camera_opts.terrain_tilt)),
(
"cameraGroundSmoothSpeed",
@@ -2407,6 +2420,14 @@ mod tests {
let camera_opts = crate::player::camera_dynamics::CameraOptions::default();
assert_eq!(d["cameraPivot"] != 0.0, camera_opts.pivot);
assert!(camera_opts.pivot, "the binary registers cameraPivot \"1\"");
assert_eq!(
d["cameraWaterCollision"] != 0.0,
camera_opts.water_collision
);
assert!(
camera_opts.pivot && camera_opts.water_collision,
"the binary registers cameraPivot and cameraWaterCollision both \"1\""
);
assert_eq!(d["cameraTerrainTilt"] != 0.0, camera_opts.terrain_tilt);
assert!(
!camera_opts.terrain_tilt,
@@ -912,6 +912,10 @@ pub(super) fn attach_entity_visuals(
// camera controller to target ~neck height instead of a fixed offset (harmless on NPCs).
commands.entity(entity).insert(CameraPivot {
height_local: dm.map(|d| d.pivot_height_local).unwrap_or(0.0),
// …and how far that pivot drops while this body swims — the reference's third
// preset `cam+0x124`, stored as the delta it is built from (`0x50ccf6`). `0.0` for
// anything with no Swim sequence, so the two presets coincide.
swim_drop_local: dm.map(|d| d.swim_pivot_drop_local).unwrap_or(0.0),
});
// The overhead-anchor fallback input (combat text over a model with no PlayerName
// attachment — `0x608640`'s defensive branch).
@@ -200,6 +200,14 @@ pub(crate) struct DisplayModel {
/// the third-person camera targets ~neck height rather than a fixed offset (wow-re `follow-camera`).
/// `0.0` for a bounds-less / WMO / model-less display (→ the camera floors it).
pub(super) pivot_height_local: f32,
/// How far that framing pivot drops while this body **swims**, model-local yards, pre-scale —
/// [`M2Bounds::swim_pivot_drop`], captured in [`build_parts`] beside the height it modifies.
/// The reference builds its swim preset `cam+0x124` as the standing height less exactly this
/// (`0x50ccf6`: `StandSeq.bounds.max.z − SwimSeq.bounds.max.z`) and `0x50f880` picks it on
/// MOVEFLAG_SWIMMING. Stamped onto each instance as [`CameraPivot::swim_drop_local`]. `0.0` for
/// a model with no Swim sequence (every non-character model) and for a bounds-less / WMO /
/// model-less display — the swim preset then simply is the standing one.
pub(super) swim_pivot_drop_local: f32,
/// The target selection-ring radius in **model-local yards, pre-scale**: the Stand-animation footprint
/// `sqrt(0.5 · sqrt(dx² + dy²))` ([`M2Bounds::ring_footprint`]). The ring's world radius is this × the
/// unit's `OBJECT_FIELD_SCALE_X` (wow-re selection-ring RE, `0x608e00`/`0x60aee0`, emulated to the
@@ -344,6 +352,7 @@ pub(crate) fn empty_shell() -> DisplayModel {
animations: None,
first_seq_span: None,
pivot_height_local: 0.0,
swim_pivot_drop_local: 0.0,
ground_radius_local: 0.0,
portrait_camera: None,
pane_camera: None,
@@ -398,6 +407,7 @@ pub(super) fn build_parts(
let mut animations = None;
let mut first_seq_span = None;
let mut pivot_height_local = 0.0;
let mut swim_pivot_drop_local = 0.0;
let mut ground_radius_local = 0.0;
let mut portrait_camera = None;
let mut pane_camera = None;
@@ -438,6 +448,12 @@ pub(super) fn build_parts(
.map(|z| z + 0.0972)
.unwrap_or_else(|| 0.9 * (b.bbox_max[2] - b.bbox_min[2]).max(0.0))
});
// The camera's SWIM framing-pivot preset, as the delta the reference stores it as: the
// standing height above less `StandSeq.max.z − SwimSeq.max.z` is `cam+0x124`
// (`0x50ccf6`), and `0x50f880` selects it whenever the camera target carries
// MOVEFLAG_SWIMMING. `0.0` for a model with no Swim sequence, which is the reference's
// own both-present guard and means a body that cannot swim keeps one height.
swim_pivot_drop_local = model.bounds.map_or(0.0, |b| b.swim_pivot_drop);
// The raw vertex-box z-extent — the overhead-anchor FALLBACK's input (see the struct
// field). Kept separate from the pivot: the fallback is the client's own formula, and
// only fires for a model with no PlayerName attachment.
@@ -592,6 +608,7 @@ pub(super) fn build_parts(
dm.animations = animations;
dm.first_seq_span = first_seq_span;
dm.pivot_height_local = pivot_height_local;
dm.swim_pivot_drop_local = swim_pivot_drop_local;
dm.ground_radius_local = ground_radius_local;
dm.portrait_camera = portrait_camera;
dm.pane_camera = pane_camera;
+18 -2
View File
@@ -61,7 +61,16 @@ pub(super) fn drive(
descent: player.fall_start_y - player.pos.y,
});
}
cam_pivot_target = pivot_target(pivot, net_entity);
// The camera's framing-pivot target, taken off the same word the body pitch above reads:
// `0x50f880` selects the swim preset on the CAMERA TARGET's own MOVEFLAG_SWIMMING, and the
// camera target is this driven body (0041/1277). Reading the flag word rather than the
// `swimming` argument keeps the pitch and the pivot on one source, which is what stops them
// ever disagreeing about which frame the water started.
cam_pivot_target = pivot_target(
pivot,
net_entity,
move_flags_now & crate::creature_anim::move_flags::SWIMMING != 0,
);
if let Some(mut motion) = motion {
// A swimmer's stroke rate takes the flag-scalar directional speed (full rate at
// any pitch — a vertical climb must not freeze the stroke); the ground gaits
@@ -94,6 +103,12 @@ pub(super) fn drive(
/// The camera pivot's **target** height for a driven body: its model-local [`CameraPivot`] × the
/// body's raw `OBJECT_FIELD_SCALE_X`, clamped — or `None` before its model has attached.
///
/// `swimming` is the body's own MOVEFLAG_SWIMMING, and it picks the preset exactly as `0x50f880`
/// does (`0x50f89e test [[unit+0x118]+0x40],0x200000` → `cam+0x124`): the framing pivot sits lower
/// on a swimmer by the model's [`CameraPivot::swim_drop_local`]. Nothing here glides — the preset is
/// a step function of the flag, and [`super::camera::PivotGlide`] is what walks the channel between
/// two of them, so entering the water reads as one smooth dip for free.
///
/// `None` is the load-bearing half. The reference recomputes the pivot preset only on a model event
/// and *skips the camera update entirely* while the model is unresolved (`0x50e907`), so a
/// display swap reads as a brief hold and then one glide. Aiming the channel at a placeholder height
@@ -104,8 +119,9 @@ pub(super) fn drive(
pub(super) fn pivot_target(
pivot: Option<&CameraPivot>,
net: Option<&crate::net::NetEntity>,
swimming: bool,
) -> Option<f32> {
pivot.map(|p| model_pivot_height(p, net.map_or(1.0, |n| n.scale)))
pivot.map(|p| model_pivot_height(p, net.map_or(1.0, |n| n.scale), swimming))
}
/// `WOW_TWIST_GAP=<radians>`: pin the body counter-twist's yaw gap instead of deriving it from
+125 -10
View File
@@ -646,6 +646,13 @@ const CAM_RETURN_RATE: f32 = 6.0;
/// floors. The collision sweep still starts from the *head* (not the pivot), so a jump in a low room
/// stops the camera under the ceiling — see `control`.
///
/// That height is one of the reference's **three** presets, rebuilt together by `0x50ca90` and
/// chosen between per frame by `0x50f880`. benilla builds two of the three: the standing height
/// above, and the **swim** preset `cam+0x124` — the same height less the model's
/// [`CameraPivot::swim_drop_local`] — selected on MOVEFLAG_SWIMMING. The zoomed-in/zoomed-out pair
/// (`cam+0x11c`/`cam+0x120`, split on `cam+0x198 < 1.8315`) is **not built**; see
/// [`model_pivot_height`].
///
/// Floor (yd) on the world pivot height — VERIFIED `5/6` (`0x50ca90`'s per-preset clamp, and
/// `0x50e570`'s corridor lower bound).
pub(super) const CAM_PIVOT_FLOOR: f32 = 5.0 / 6.0;
@@ -657,10 +664,29 @@ pub(super) const CAM_PIVOT_CEIL: f32 = 15.0;
/// model-derived value the moment the body attaches — as a **snap**, not a glide ([`PivotGlide`]).
pub(super) const CAM_PIVOT_FALLBACK: f32 = 1.8;
/// One modeled unit's world head height: its model-local [`CameraPivot`] × the given scale, clamped
/// to `[CAM_PIVOT_FLOOR, CAM_PIVOT_CEIL]` — the reference's per-preset clamp in `0x50ca90`.
pub(super) fn model_pivot_height(pivot: &CameraPivot, scale: f32) -> f32 {
(pivot.height_local * scale).clamp(CAM_PIVOT_FLOOR, CAM_PIVOT_CEIL)
/// One modeled unit's world framing-pivot height: its model-local [`CameraPivot`] × the given scale,
/// clamped to `[CAM_PIVOT_FLOOR, CAM_PIVOT_CEIL]` — the reference's per-preset clamp in `0x50ca90`.
///
/// **`swimming` selects the preset, per frame, the way `0x50f880` does**
/// (`0x50f89e test [[unit+0x118]+0x40],0x200000` — MOVEFLAG_SWIMMING on the *camera target's*
/// CMovement word): set, and the pivot drops by the model's own
/// [`benilla_formats::M2Bounds::swim_pivot_drop`] before the clamp, which is the reference's
/// `cam+0x124` preset. Clear, and it stays the standing height. The drop is a *model* constant
/// (`StandSeq.max.z − SwimSeq.max.z`, `0x50ccf6`), so a body that authors no Swim sequence carries
/// `0.0` and the two presets are the same number — correct for anything that cannot swim.
///
/// **What is deliberately not here:** the reference keeps a *third* preset. `0x50f880`'s non-swim
/// leg picks `cam+0x11c` (zoomed-in) or `cam+0x120` (zoomed-out) on `cam+0x198 < 1.8315`
/// (`[0x8089b0]`), and benilla builds neither — one standing height serves both zoom regimes. On a
/// scale-1 human the reference computes those two presets *equal* (both 1.9002692), so nothing yet
/// says what authors them apart; naming it beats stubbing a threshold we cannot justify (1203).
pub(super) fn model_pivot_height(pivot: &CameraPivot, scale: f32, swimming: bool) -> f32 {
let local = if swimming {
pivot.height_local - pivot.swim_drop_local
} else {
pivot.height_local
};
(local * scale).clamp(CAM_PIVOT_FLOOR, CAM_PIVOT_CEIL)
}
/// World head height above a modeled unit's feet — [`model_pivot_height`], or the neck-height
@@ -677,8 +703,14 @@ pub(super) fn model_pivot_height(pivot: &CameraPivot, scale: f32) -> f32 {
/// [`PivotGlide`] then walks; multiplying by the eased scale instead would stack a second, slower
/// ease on top of the first and is what made a shapeshift snap *and* drift. The audio listener still
/// passes the rendered scale — it tracks the drawn body, and nothing verified says otherwise.
///
/// **Always the standing preset.** Its two consumers are a *head*, not the camera's framing pivot:
/// the 3D-audio listener sits at our own head, and the far-sight subject is a unit whose movement
/// flags we do not carry. The swim preset is the framing pivot's alone — [`model_pivot_height`]
/// with `swimming` — and the driven body's own target goes through
/// [`super::body_pose::pivot_target`], not here.
pub(crate) fn head_height(pivot: Option<&CameraPivot>, scale: f32) -> f32 {
pivot.map_or(CAM_PIVOT_FALLBACK, |p| model_pivot_height(p, scale))
pivot.map_or(CAM_PIVOT_FALLBACK, |p| model_pivot_height(p, scale, false))
}
/// `cameraHeightSmoothSpeed` (yd/s) — the pivot channel's rate, VERIFIED registrar default `"1.2"`.
@@ -770,6 +802,11 @@ pub(crate) struct CameraControl {
pub(super) collision_distance: f32,
/// The button currently held for look, or `None`.
pub(super) look: Option<LookButton>,
/// **Is the player in mouse-look right now?** — the reference's `[cam+0x90] & 1`, set at
/// `0x50fe41` and cleared at `0x50fddd`, identified at the bytes by the two mode strings
/// `"Camera FREELOOK"` / `"Camera NORMAL"`. Written by [`run_look_session`] and read the same
/// frame by [`seat_on_subject`], which is `cameraTerrainTilt`'s hand-off edge.
pub(super) freelook: bool,
/// **Which mouse buttons the world owns** this frame ([`WorldMouse`]) — the player side's one
/// answer to "did the UI eat that press?", written by [`latch_world_mouse`] before anything
/// reads a button. The look session, the camera's input command word and the both-button run
@@ -974,6 +1011,13 @@ impl FlyCam {
#[derive(Component, Clone, Copy)]
pub(crate) struct CameraPivot {
pub height_local: f32,
/// How far that height drops while this body **swims**, model-local and pre-scale
/// ([`benilla_formats::M2Bounds::swim_pivot_drop`] — `StandSeq.max.z − SwimSeq.max.z`,
/// `0x50ccf6`). The reference builds the swim framing-pivot preset `cam+0x124` by subtracting
/// exactly this from the standing one before the shared clamp, and `0x50f880` picks it whenever
/// the camera target carries MOVEFLAG_SWIMMING. `0.0` for a model with no Swim sequence (every
/// non-character model) and for a bounds-less display — the two presets then coincide.
pub swim_drop_local: f32,
}
/// Mouse-look session state machine — start/stop/hand-off between the two look buttons, cursor
@@ -1152,6 +1196,11 @@ pub(super) fn run_look_session(
*face_yaw = cam.yaw;
}
}
// **The freelook latch.** Right-held is the reference's mouse-look; a left-drag is an orbit and
// is not freelook. A both-button run steers exactly as a right-drag does, which is the same
// "the world holds the right button" the `face_yaw` sync above tests — so it counts, and the
// two tests stay written the same way on purpose.
rig.freelook = rig.look == Some(LookButton::Right) || (rig.look.is_some() && both_buttons);
}
/// Wheel-zoom: the CAMERAZOOMIN/OUT bindings set a new target orbit distance, and the actual
@@ -1253,6 +1302,14 @@ pub(super) fn seat_on_subject(
&dynamics.options,
dt,
);
// **The mouse-look hand-off** (`0x50d500` push / `0x50d520` pop; wow-re's §5 re-audit, Q-C).
// Run after the channel has stepped, so an edge hands off the value this frame is about to
// compose. The reference fires it from the input handler instead; the two differ by at most one
// frame of channel motion, which at `cameraGroundSmoothSpeed` is a fortieth of a degree.
let handed = rig.terrain_tilt.hand_off(rig.freelook);
if handed != 0.0 {
cam.pitch = (cam.pitch + handed).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT);
}
// **`cameraBobbing`'s latch and kernel.** The session is armed off the input-command word, not
// off this gate, so it runs whatever the CVar says and only the OUTPUT is gated — which is what
@@ -1372,7 +1429,22 @@ pub(super) fn seat_camera(
let boom_len = boom.length().max(1.0e-3);
// The camera collides with the WMO *camera/LOS* faces (keeps DETAIL overhangs like forge pipes,
// drops NOCAMCOLLIDE) + terrain/doodads/GameObjects — its own audience, not the walking mesh.
let hit = collide.cast_camera(cam_probe, head, Quat::IDENTITY, boom, 0.0);
//
// **And the waterline, under `cameraWaterCollision`** — registered `"1"`, so this is on out of
// the box. It is a change to the trace's MASK and nothing else, which is the shape the
// reference gives it (`0x50e5ec` ORs the `0xf0000` ADT-liquid nibble into the word all three of
// `0x50e570`'s queries carry). Decision 2149 read the arm as liquid-blind and built a pivot
// corridor in its place; wow-re's `water-band-discontinuity.md` refuted that — the nibble
// reaches `0x69cc13` through four direct calls and gates a per-layer intersection over the
// chunk's four MCLQ slots, so the sweep hits bare water and cannot tell it from ground.
let hit = collide.cast_camera(
cam_probe,
head,
Quat::IDENTITY,
boom,
0.0,
dynamics.options.water_collision,
);
// The solver's own clip verdict (`0x50e570`'s `0x30000` return, OR'd into `[cam+0x90]` by the
// driver) — [`SmartPivot`]'s sixth conjunct, and the reason an unobstructed camera never
// pivots. Written here because here is the only place that knows.
@@ -1464,6 +1536,7 @@ pub(super) fn seat_camera(
cam.pitch,
&dynamics.subject,
rig.clipped,
dynamics.tracking_style,
&dynamics.options,
dt,
);
@@ -1893,10 +1966,52 @@ mod tests {
/// and a shrink cannot bury it in the floor.
#[test]
fn the_pivot_target_is_clamped_to_the_references_band() {
let p = CameraPivot { height_local: 2.0 };
assert_eq!(model_pivot_height(&p, 1.0), 2.0);
assert_eq!(model_pivot_height(&p, 0.01), CAM_PIVOT_FLOOR);
assert_eq!(model_pivot_height(&p, 100.0), CAM_PIVOT_CEIL);
let p = CameraPivot {
height_local: 2.0,
swim_drop_local: 0.0,
};
assert_eq!(model_pivot_height(&p, 1.0, false), 2.0);
assert_eq!(model_pivot_height(&p, 0.01, false), CAM_PIVOT_FLOOR);
assert_eq!(model_pivot_height(&p, 100.0, false), CAM_PIVOT_CEIL);
}
/// **The swim preset** — the reference's `cam+0x124`, selected by `0x50f880` on
/// MOVEFLAG_SWIMMING and built at `0x50ccf6` as the standing height less
/// `StandSeq.max.z − SwimSeq.max.z`. The numbers are the shipped Human Male's, as wow-re
/// measured them off the binary (`water-band-discontinuity.md` §7): standing 1.9002692,
/// swimming 1.5120120.
#[test]
fn swimming_takes_the_lower_pivot_preset() {
let human = CameraPivot {
height_local: 1.9002692,
swim_drop_local: 0.3882572,
};
assert!((model_pivot_height(&human, 1.0, false) - 1.9002692).abs() < 1e-5);
assert!((model_pivot_height(&human, 1.0, true) - 1.512_012).abs() < 1e-5);
// The preset multiplies the scale, then clamps — the swim leg shares the band with the
// standing one (`0x50ca90` clamps all three presets together).
assert!(
(model_pivot_height(&human, 2.0, true) - 2.0 * 1.512_012).abs() < 1e-5,
"the swim preset scales like its sibling"
);
assert_eq!(model_pivot_height(&human, 0.01, true), CAM_PIVOT_FLOOR);
assert_eq!(model_pivot_height(&human, 100.0, true), CAM_PIVOT_CEIL);
}
/// A model with **no Swim sequence** — every non-character model, and the reference's own
/// both-sequences-present guard (`0x711960` on ids 0 and 0x2a). Its drop is `0.0`, so the swim
/// preset degenerates to the standing one and a body that cannot swim never dips.
#[test]
fn a_model_that_cannot_swim_keeps_the_standing_preset() {
let chicken = CameraPivot {
height_local: 1.2,
swim_drop_local: 0.0,
};
assert_eq!(
model_pivot_height(&chicken, 1.0, true),
model_pivot_height(&chicken, 1.0, false),
);
assert_eq!(model_pivot_height(&chicken, 1.0, true), 1.2);
}
/// A press that has travelled `yaw`/`pitch` **degrees** of camera rotation.
+213 -12
View File
@@ -71,6 +71,19 @@ pub(crate) struct CameraOptions {
pub(crate) pivot_dy_min: f32,
/// `cameraTargetSmoothSpeed` — see [`TARGET_SMOOTH_SPEED_DEFAULT`].
pub(crate) target_smooth_speed: f32,
/// **`cameraWaterCollision`** — registered **"1"**, so this is ON out of the box.
///
/// One consumer, and it is a **trace mask**: it adds the liquid layer to the camera boom's
/// collision filter, so the arm stops at a waterline. Decision 2149 built a pivot corridor
/// instead, on `camera-arm-liquid-blind.md`'s verdict that the arm never touches liquid; that
/// verdict is **refuted** by wow-re's `water-band-discontinuity.md` (the capability is
/// requested through an argument flag, which no census of call sites could see), and the
/// corridor it built had a 19/18 yd discontinuity that 2165 took back out.
///
/// The corridor is the CVar's *second* consumer in the reference and is **not** rebuilt here
/// yet: it is only safe once the framing pivot's depth is pinned the way the reference pins it,
/// and that is a separate piece of work with its own continuity sweep to earn.
pub(crate) water_collision: bool,
/// `cameraTerrainTilt` — registered **"0"**, so Follow Terrain is OFF out of the box.
pub(crate) terrain_tilt: bool,
/// `cameraGroundSmoothSpeed`, deg/s — the ground channel's rate (`[0xbe0fc0]`, `"7.5"`).
@@ -97,6 +110,7 @@ impl Default for CameraOptions {
pivot_dx_max: PIVOT_DX_MAX_DEFAULT,
pivot_dy_min: PIVOT_DY_MIN_DEFAULT,
target_smooth_speed: TARGET_SMOOTH_SPEED_DEFAULT,
water_collision: true,
terrain_tilt: false,
ground_smooth_speed: GROUND_SMOOTH_SPEED_DEFAULT,
tilt_time_min: TILT_TIME_MIN_DEFAULT,
@@ -119,6 +133,10 @@ pub(super) struct DynamicsInput {
/// (`camera-smooth-style.md` §2b), so this is the knob and not the auto-follow's own
/// far-sight-adjusted copy.
pub(super) smooth_style: super::camera::FollowStyle,
/// `cameraSmoothTrackingStyle` — read by exactly one thing here, [`SmartPivot::advance`]'s
/// third release conjunct (`0x511010`). Separate from `smooth_style` because the reference
/// keeps two registrations (`[0xbe10c4]` vs `[0xbe1098]`) off the same default pointer.
pub(super) tracking_style: super::camera::FollowStyle,
pub(super) subject: SubjectState,
/// The live `nearclip` ([`benilla_world::view::ViewDistance::nearclip`]) — the self-avatar
/// fade's reference plane, carried on this bundle rather than as a fourteenth `seat_camera`
@@ -203,11 +221,16 @@ use crate::creature_anim::move_flags as mf;
/// `|bias| ≥ 0.001 ∧ 0x511010(cam) == 0 ∧ ¬gate` (the last negated) arms `+0x104` back to zero at
/// `cameraTargetSmoothSpeed`; a false verdict snapshots and disarms instead.
///
/// **One conjunct of the release is not reproduced and is named rather than buried:** `0x511010`
/// is `[cam+0x90] & 0x100` then `cameraSmoothTrackingStyle` then `0x60f8f0`, all negated — the
/// externally-driven *tracking* transition that benilla's auto-follow ([`super::camera::FollowRig`])
/// has no state for. Its effect is to hold the bias while a Track/Fear swing is in flight, so
/// omitting it can only release the bias *earlier*, never later, and never while the gate holds.
/// **The third release conjunct, `0x511010`**, decoded: `[cam+0x90] & 0x100` — the camera's TRACK
/// latch, the same bit that picks the tilt matrix's `Track` row, fed from `[inputState+4] &
/// 0xf00000` (externally-driven movement, which is benilla's `server_riding`) — and then, only if
/// that is set, `cameraSmoothTrackingStyle == Never` OR a move-type-**0** order in flight. A true
/// answer BLOCKS the ease home, holding the bias while a tracking swing runs.
///
/// benilla builds the first disjunct and cannot build the second: `[0xc4d888]` is the
/// click-to-move / auto-action move-type global, and this client has no click-to-move to put an
/// order in flight. That half is inert at every state benilla can reach, so it is named here
/// rather than stubbed — the day click-to-move exists, this is the line that needs the arm.
pub(super) struct SmartPivot {
/// The pitch-bias channel `+0x104` — angular, so the armer's `2π` rewrap applies. Its armed
/// bit is the reference's `[cam+0x90] & 0x8000000`, which is read as a routing conjunct.
@@ -291,13 +314,19 @@ impl SmartPivot {
pitch: f32,
subject: &SubjectState,
clipped: bool,
tracking_style: super::camera::FollowStyle,
cfg: &CameraOptions,
dt: f32,
) {
let verdict = cfg.pivot && !subject.translating() && pitch >= 0.0 && clipped;
if !verdict && self.bias().abs() >= CHANNEL_EPS {
// `0x511010`'s first disjunct — the TRACK latch up with tracking set to `Never`. A true
// answer blocks the ease home, so it joins the gate on the holding side rather than
// forming a third arm.
let tracking_hold = subject.track && tracking_style == super::camera::FollowStyle::Never;
let holding = verdict || tracking_hold;
if !holding && self.bias().abs() >= CHANNEL_EPS {
self.release(cfg);
} else if verdict {
} else if holding {
// The FALSE leg of `0x5107f0`'s caller (`0x50ed93`): snapshot and clear the armed bit,
// i.e. a re-engaged gate cancels an ease in flight rather than fighting it.
let live = self.bias();
@@ -360,6 +389,11 @@ pub(super) struct TerrainTilt {
slope_pitch: f32,
/// Seconds since the probe last ran — the 100 ms throttle (`0x50d94f`).
since_probe: f32,
/// **The mouse-look hand-off, `[cam+0xa8]`** — is the lean currently living inside the pitch
/// rather than being composed onto it? The reference keeps a signed REFCOUNT here and gates
/// the compose on `> 0` (`0x50f809 jg`); benilla has exactly one look session, so the count
/// can only ever be 0 or 1 and a bool carries it without pretending otherwise.
handed_off: bool,
}
impl Default for TerrainTilt {
@@ -369,6 +403,7 @@ impl Default for TerrainTilt {
slope_pitch: 0.0,
// The probe runs on the very first frame rather than 100 ms into the session.
since_probe: PROBE_THROTTLE,
handed_off: false,
}
}
}
@@ -455,7 +490,42 @@ impl TerrainTilt {
/// The signed pitch the ground is contributing this frame, radians, benilla's sign — added to
/// the view pitch inside the ±89° clamp.
pub(super) fn pitch(&self) -> f32 {
self.ground.live()
// `0x50f809 test eax,eax / jg` — with the hand-off refcount up, the compose is SKIPPED,
// because the lean is already inside the pitch that `0x50d500` pushed it into.
if self.handed_off {
0.0
} else {
self.ground.live()
}
}
/// **The mouse-look hand-off** (`0x50d500` push / `0x50d520` pop) — returns the delta the
/// pitch owes, which is `0x510120`'s argument and nothing else.
///
/// It is a hand-off, not a suppression. On the freelook 0→1 edge the lean's *current* value is
/// added to the pitch and the compose stops, so the view does not move: the lean simply becomes
/// part of the angle the player is now dragging. The channel keeps tracking the terrain
/// underneath throughout — `0x50d900`/`0x50dbc0` are not gated by `+0xa8` — and on the →0 edge
/// the *then-current* value is subtracted back out and the compose resumes.
///
/// So crossing a slope change mid-drag leaves a step of exactly the difference between the
/// lean at press and at release. That is the reference's behaviour, not a defect of this
/// transcription: the reference eases that step through the pitch channel at
/// `cameraPitchSmoothSpeed`, and benilla's pitch is an immediate write (the §5 verdict on
/// `0x510120`), so here it lands in one frame. Named, not smoothed over.
pub(super) fn hand_off(&mut self, freelook: bool) -> f32 {
if freelook == self.handed_off {
return 0.0;
}
self.handed_off = freelook;
// Always the RAW channel value: `pitch()` answers zero once the flag is up, and the pop
// has to give back what the push took plus whatever the terrain moved in between.
let live = self.ground.live();
if freelook {
live
} else {
-live
}
}
/// The probe's staircase (`0x50db37`'s downward walk over `0x808a40`, then the ±20° clamp),
@@ -859,14 +929,28 @@ mod tests {
assert!(held > 0.0);
// The gate still true: the bias sits exactly where it is, indefinitely.
for _ in 0..120 {
p.advance(0.5, &moving(false), true, &cfg, DT);
p.advance(
0.5,
&moving(false),
true,
super::super::camera::FollowStyle::Smart,
&cfg,
DT,
);
}
assert_eq!(p.bias(), held, "a held gate must not move the bias");
// Step out of the wall: it eases home over |bias| / 90°/s.
let expected = held / cfg.target_smooth_speed.to_radians();
let mut took = None;
for frame in 0..600 {
p.advance(0.5, &moving(false), false, &cfg, DT);
p.advance(
0.5,
&moving(false),
false,
super::super::camera::FollowStyle::Smart,
&cfg,
DT,
);
if took.is_none() && p.bias().abs() < CHANNEL_EPS {
took = Some(frame as f32 * DT);
}
@@ -1308,12 +1392,26 @@ mod tests {
let mut p = SmartPivot::default();
p.route_pitch(0.3, 0.0, 0.5, &moving(false), true, &cfg);
for _ in 0..6 {
p.advance(0.5, &moving(false), false, &cfg, DT);
p.advance(
0.5,
&moving(false),
false,
super::super::camera::FollowStyle::Smart,
&cfg,
DT,
);
}
let mid = p.bias();
assert!(mid > CHANNEL_EPS && mid < 0.3, "mid-return, got {mid}");
for _ in 0..60 {
p.advance(0.5, &moving(false), true, &cfg, DT);
p.advance(
0.5,
&moving(false),
true,
super::super::camera::FollowStyle::Smart,
&cfg,
DT,
);
}
assert_eq!(p.bias(), mid, "the return was cancelled, not completed");
}
@@ -1396,4 +1494,107 @@ mod tests {
// The disarm's own ramp, bounded at what `cameraBobbingSmoothSpeed` actually buys.
assert_bounded_step((3.9, 6.0), DT, amplitude * 0.5, &mut step);
}
/// **The mouse-look hand-off** (`0x50d500` push / `0x50d520` pop; wow-re's §5 re-audit Q-C).
///
/// The lean moves *into* the pitch for the duration of a drag and comes back out on release,
/// so the view does not move at either edge — and a slope change crossed mid-drag leaves
/// behind exactly the difference between the lean at press and the lean at release, which is
/// the reference's behaviour and the reason this is a hand-off rather than a suppression.
#[test]
fn mouse_look_hands_the_lean_into_the_pitch_and_takes_it_back() {
use super::super::camera::FollowStyle;
let cfg = CameraOptions {
terrain_tilt: true,
..CameraOptions::default()
};
let moving = SubjectState {
move_flags: mf::FORWARD,
..SubjectState::default()
};
let mut tilt = TerrainTilt::default();
for _ in 0..900 {
tilt.advance(|| 0.5, true, &moving, FollowStyle::Smart, &cfg, DT);
}
let lean = tilt.pitch();
assert!(lean > 0.0, "uphill leans the view UP in benilla's sign");
// What the camera composes, across the press.
let mut pitch = 0.2_f32;
let composite = pitch + tilt.pitch();
pitch += tilt.hand_off(true);
assert_eq!(
tilt.pitch(),
0.0,
"the compose stops while the hand-off holds"
);
assert!(
(pitch + tilt.pitch() - composite).abs() < 1.0e-6,
"the press must not move the view"
);
// Idempotent while held — the reference's refcount does not double-push.
assert_eq!(tilt.hand_off(true), 0.0);
// Released over the same terrain: the pitch gives back exactly what it took.
pitch += tilt.hand_off(false);
assert!((pitch - 0.2).abs() < 1.0e-6, "the pop returns the push");
assert!(
(pitch + tilt.pitch() - composite).abs() < 1.0e-6,
"and the release must not move the view either"
);
// Now cross a slope change mid-drag: press on the hill, flatten, release.
pitch += tilt.hand_off(true);
for _ in 0..900 {
tilt.advance(|| 0.0, true, &moving, FollowStyle::Smart, &cfg, DT);
}
assert_eq!(
tilt.ground.live(),
0.0,
"the channel keeps tracking under the hand-off"
);
pitch += tilt.hand_off(false);
assert!(
(pitch - (0.2 + lean)).abs() < 1.0e-6,
"the step left behind is the lean at press minus the lean at release"
);
}
/// **`0x511010`'s first disjunct**: with the TRACK latch up and `cameraSmoothTrackingStyle` at
/// `Never`, the bias is HELD where it stands instead of easing home — the reference's way of
/// not fighting a tracking swing in flight. Both of the other two legs release as before.
#[test]
fn a_never_tracking_swing_holds_the_bias_the_gate_would_have_released() {
use super::super::camera::FollowStyle;
let cfg = CameraOptions::default();
// Arm a bias through the pure-pivot leg, then drop the gate and run half a second.
let run = |subject: &SubjectState, tracking: FollowStyle| {
let mut p = SmartPivot::default();
assert_eq!(p.route_pitch(UP, 0.0, 0.1, subject, true, &cfg), None);
let armed = p.bias();
for _ in 0..30 {
p.advance(0.1, subject, false, tracking, &cfg, DT);
}
(armed, p.bias())
};
let tracked = SubjectState {
track: true,
..SubjectState::default()
};
let (armed, after) = run(&tracked, FollowStyle::Never);
assert_eq!(after, armed, "tracking + Never blocks the ease home");
let (armed, after) = run(&tracked, FollowStyle::Smart);
assert!(
after.abs() < armed.abs(),
"the block is the tracking STYLE's, not the latch's alone"
);
let (armed, after) = run(&SubjectState::default(), FollowStyle::Never);
assert!(
after.abs() < armed.abs(),
"and not the style's alone either — the latch has to be up"
);
}
}
+13 -3
View File
@@ -218,6 +218,7 @@ pub(super) fn control(
options: *pointer.4,
nearclip: pointer.5.nearclip,
smooth_style: pointer.3.style,
tracking_style: pointer.3.tracking_style,
subject: camera_dynamics::SubjectState {
move_flags: player.move_flags,
// Off the DRIVEN body's descriptor (1277) — a possessed creature's taxi state is what
@@ -525,9 +526,18 @@ pub(super) fn control(
0.0,
player.pos,
head,
body.single()
.ok()
.and_then(|(_, _, _, pivot, .., net)| body_pose::pivot_target(pivot, net)),
body.single().ok().and_then(|(_, _, _, pivot, .., net)| {
// Even while a spline/taxi/fear owns the body, the pivot preset follows
// that body's own MOVEFLAG_SWIMMING (`0x50f880` reads the camera target's
// CMovement word, not ours). On this path the controller builds no live
// flag word, so the last-streamed one — which `wire_in` merges from the
// server's own poses — is the body's state.
body_pose::pivot_target(
pivot,
net,
player.move_flags() & crate::creature_anim::move_flags::SWIMMING != 0,
)
}),
view_subject,
&mut rig,
&mut cam,
@@ -158,7 +158,10 @@ mod tests {
.world_mut()
.spawn((
Transform::from_xyz(100.0, 0.0, 0.0),
CameraPivot { height_local: 2.0 },
CameraPivot {
height_local: 2.0,
swim_drop_local: 0.0,
},
))
.id();
+3 -1
View File
@@ -464,7 +464,9 @@ fn resolve_row(
}
/// Build the Browse tab's category tree from the player's own DBCs (decision 1511 §5).
fn categories(
/// `pub(crate)`: the addon-corpus survey seats the same tree off the player's chain (2167), and a
/// second copy of the class set would be a second thing to keep right.
pub(crate) fn categories(
classes: Option<&crate::ui_items::ItemClasses>,
subclasses: Option<&crate::ui_items::ItemSubClasses>,
) -> Vec<AuctionCategory> {
+10
View File
@@ -573,6 +573,10 @@ pub(crate) fn unit_combat_stats(store: &ObjectStore) -> UnitCombatStats {
offhand_weapon_skill: (0, 0),
ranged_weapon_skill: (0, 0),
defense_skill: (0, 0),
// Player fields — the player's own snapshot fills them (`combat_stats`); a pet has none.
dodge_percent: 0.0,
parry_percent: 0.0,
block_percent: 0.0,
}
}
@@ -609,6 +613,12 @@ fn combat_stats(store: &ObjectStore, items: &mut Items, commands: &NetCommands)
// `PaperDollFrame` registers, l.28, and `watch_skill_ups` already fires), NOT a
// `UNIT_DEFENSE` — the character sheet never registers one.
defense_skill: skill_pair(store, SKILL_DEFENSE),
// `GetDodgeChance`/`GetParryChance`/`GetBlockChance` — player fields, so they live on the
// player's snapshot and not on the shared core a pet also fills. Already a percent on the
// wire; an unstreamed field is 0, which is the wire's own default.
dodge_percent: store.0.player_dodge_percentage().unwrap_or(0.0),
parry_percent: store.0.player_parry_percentage().unwrap_or(0.0),
block_percent: store.0.player_block_percentage().unwrap_or(0.0),
..unit_combat_stats(store)
}
}
@@ -4402,20 +4402,6 @@ const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
one step further back than its guild twin: the rank byte streams, but the key's second \
index is a FACTION SIDE that `ui_unit` does not resolve for an arbitrary player yet",
),
(
"cameraWaterCollision",
"REVERTED 2026-09-10, and the row is back because the feature is not. Two consumers were \
transcribed off `pivot-height-glide.md` §5: (1) the collision sweep's class mask \
becomes `0x1f0171` instead of `0x100171` — the `0xf0000` nibble is the ADT liquid \
layers — and `0x511ad0`'s bands re-base the pivot floor/cap; (2) a band CROSSING \
arms a 5° pitch through the `+0xf4` channel. Built as (1)=the pivot corridor alone, \
on the reading that the arm stays liquid-blind. **That corridor is discontinuous**: at \
`excess = 2/9` the framing pivot drops 1.04 yd in one frame and pops back 0.29 yd at \
`5/9`, so entering or leaving water at head depth slams the camera — which is what \
the director saw. Whether the discontinuity is the reference's or a mis-transcription is \
a live §5 (2165). Registered \"1\", so benilla is the divergence while this row \
stands",
),
// **The SLIDERS' half is empty, and that is the point.** 2115 extended this census from
// `UIOptionsFrameCheckButtons` to `UIOptionsFrameSliders` and it caught exactly one row —
// `cameraYawMoveSpeed`, the MOUSE_LOOK_SPEED slider — which was not cosmetic:
+8 -6
View File
@@ -160,7 +160,7 @@ fn feed_talents(
build_pages(
&talents.catalog,
&actions.spells,
spells,
&spells.catalog,
race,
class,
points,
@@ -223,10 +223,13 @@ fn rank_of(t: &Talent, known: &BTreeSet<u32>) -> u32 {
/// Build the pushed snapshot — the app's whole resolve (module doc).
#[allow(clippy::too_many_arguments)] // the catalogs, the player's state, and the string table
fn build_pages(
pub(crate) fn build_pages(
catalog: &TalentCatalog,
known: &BTreeSet<u32>,
spells: &Spells,
// The DISPLAY catalog only — `Spell.dbc` for each talent's name and icon. Narrowed from the
// whole `Spells` resource (2167) so the addon-corpus survey can build the same snapshot off
// the player's chain without standing up five more DBCs it has no use for.
spells: &benilla_formats::SpellCatalog,
race: u8,
class: u8,
points: (u32, u32),
@@ -249,7 +252,7 @@ fn build_pages(
} else {
0
};
let d = spells.catalog.get(display_spell);
let d = spells.get(display_spell);
// meetsPrereq is the requiredSpell known-check ONLY (byte-verified GetTalentInfo,
// wow-re talent-api.md — the 0305 fold-back; talent prereqs live in the triplets).
let meets_prereq = t.required_spell == 0 || known.contains(&t.required_spell);
@@ -266,7 +269,7 @@ fn build_pages(
// key reused) — byte-verified in the SetTalent builder, wow-re talent-api.md. It is
// NOT the `LOCKED_WITH_*`/`SPELL_FAILED_*` family, which reads identically in enUS.
if !meets_prereq {
if let Some(req) = spells.catalog.get(t.required_spell) {
if let Some(req) = spells.get(t.required_spell) {
req_lines.extend(get("ITEM_REQ_SKILL").map(|f| fill(&f, &[Arg::S(&req.name)])));
}
}
@@ -282,7 +285,6 @@ fn build_pages(
});
if !learnable {
let p_name = spells
.catalog
.get(p.ranks[0])
.map(|d| d.name.clone())
.unwrap_or_default();
+102 -21
View File
@@ -58,6 +58,35 @@ pub struct M2Bounds {
///
/// Falls back to the header render-box Z extent for a model with no sequences, like the ring.
pub stand_box_z: f32,
/// How far the camera's framing pivot drops when this body **swims** (model-local yards,
/// pre-scale): `StandSeq.bounds.max.z − SwimSeq.bounds.max.z`, floored at zero.
///
/// The reference keeps **three** framing-pivot presets side by side (`cam+0x11c`/`+0x120`/
/// `+0x124`, rebuilt from the model by `0x50ca90`) and picks one per frame at `0x50f880`. All
/// three start from the same base — the `attach17.z + 0.0972222 [0x808ab0]` neck height
/// [`M2Bounds::pivot_z`] carries — and only the swim one is then pulled down, by exactly this:
/// `0x50ccf6 fsubr [esi+0x124]` subtracts `S · (box0.max.z − box42.max.z)`, where the two boxes
/// come from `0x711a20(model, 0)` and `0x711a20(model, 0x2a)` — the same `M2Sequence` `CAaBox`
/// query [`stand_box`] reads, on animation id **0** (Stand) and id **42** (Swim). Byte-decoded
/// and VERIFIED in wow-re `ui/scratch/water-band-discontinuity.md` §7, which measured
/// `+0x11c = +0x120 = 1.9002692` and `+0x124 = 1.5120120` off the shipped `HumanMale.m2` at
/// scale 1 — a drop of `0.3882572`, the number the fixture test pins.
///
/// It is a **delta**, not a height, because that is the shape of the byte: the base is added to
/// all three presets at `0x50cc0c`–`0x50cc2e` and only `+0x124` is decremented, so a consumer
/// gets the swim preset as `pivot_height − this`, then the shared `[5/6, 15.0]` clamp
/// (`0x50ca90`, `0x50d00d`–`0x50d092`).
///
/// **`0.0` when the model has no Swim sequence** — which is every non-character model, and the
/// reference's own answer too: `0x50cc67`–`0x50cd02` runs only when `0x711960` reports *both*
/// id 0 and id 42 present, so a body that cannot swim keeps the standing preset in every state.
///
/// **The zoom pair is NOT built.** `0x50f880`'s other leg picks `+0x11c` (zoomed-in) or
/// `+0x120` (zoomed-out) on `cam+0x198 < 1.8315` — and on a scale-1 human the reference computes
/// those two *equal*, so nothing here yet says what makes them differ. benilla has one standing
/// preset and this drop; the threshold and whatever authors the zoomed pair apart are absent,
/// not stubbed.
pub swim_pivot_drop: f32,
}
/// Read an M2's authored bounds + the vertex-derived radius (see [`M2Bounds`]). Uses the same path
@@ -84,37 +113,68 @@ pub fn load_m2_bounds(chain: &mut Chain, raw_path: &str) -> Result<M2Bounds> {
/// same question, and this is the reference's answer to it.
pub const DEGENERATE_RING_FOOTPRINT: f32 = 1.2;
/// Resolve an animation **id** to its `M2Sequence` record index — the animation-lookup indirection
/// every sequence-box read below goes through. The lookup array is at MD20 `0x24`(count)/`0x28`
/// (offset), one `u16` per animation id; the sequence array it indexes is at `0x1c`/`0x20`. An id
/// is NOT a record number (a chicken's record 0 is a flap, its Stand sits at index 2), and a model
/// that simply does not author an animation is the common case, not an error: `None` for a missing
/// lookup, an id past its end, the `0xffff` "absent" sentinel, or an index past the sequence array.
///
/// This is the client's own `0x711960(id)` presence test folded into the `0x711a20(id)` box query —
/// the pair `0x50ca90` calls on ids 0 and 42 before it will build the swim preset at all.
fn seq_index(bytes: &[u8], anim_id: usize) -> Option<usize> {
let anim_count = bytes.u32_at(0x1c)? as usize;
let lookup_count = bytes.u32_at(0x24)? as usize;
let lookup_ofs = bytes.u32_at(0x28)? as usize;
if anim_count == 0 || anim_id >= lookup_count {
return None;
}
match bytes.u16_at(lookup_ofs.checked_add(anim_id * 2)?) {
Some(i) if (i as usize) != 0xffff && (i as usize) < anim_count => Some(i as usize),
_ => None,
}
}
/// The byte offset of a resolved sequence record — stride `0x44` from the array at MD20 `0x20`,
/// with the `CAaBox` at record `+0x24` (min `C3Vector` `+0x24`, max `C3Vector` `+0x30`, so `max.z`
/// is `+0x38`).
fn seq_record(bytes: &[u8], idx: usize) -> Option<usize> {
let anim_ofs = bytes.u32_at(0x20)? as usize;
anim_ofs.checked_add(idx * 0x44)
}
/// The **Stand** sequence's record — animation id 0 through [`seq_index`], falling back to record 0
/// for a model whose lookup is absent or points nowhere (the pre-existing behaviour the ring and
/// the chat bubble are pinned to; every model that has sequences at all has *something* to stand on).
/// `None` only when the model has no sequences.
fn stand_record(bytes: &[u8]) -> Option<usize> {
if bytes.u32_at(0x1c)? as usize == 0 {
return None;
}
seq_record(bytes, seq_index(bytes, 0).unwrap_or(0))
}
/// One sequence box's `max.z` — `rec+0x38`, the `out+0x20` field `0x711a20` returns (wow-re
/// `water-band-discontinuity.md` §7). The camera's swim preset is the difference of two of these.
fn seq_max_z(bytes: &[u8], anim_id: usize) -> Option<f32> {
let rec = seq_record(bytes, seq_index(bytes, anim_id)?)?;
bytes.f32_at(rec + 0x38)
}
/// Read the horizontal (X,Y) extents of the **Stand** animation's bounding box from a raw M2 — the input
/// the real client's living-unit selection ring is sized from (wow-re selection-ring RE, `0x60aee0`).
/// The animation `M2Sequence` array is at MD20 `0x1c`(count)/`0x20`(offset), stride `0x44`, with the
/// sequence's `CAaBox` at record `+0x24` (min C3 `+0x24`, max C3 `+0x30`). Stand is animation **id 0**,
/// whose *sequence index* is `animationLookup[0]` (array at `0x24`/`0x28`, `u16` each) — NOT necessarily
/// record 0 (a chicken's record 0 is a flap, its Stand sits at index 2). Returns `(dx, dy, dz)`, or
/// `None` when the model has no sequences / lookup or the indices are out of range (caller falls back
/// to the header box). VERIFIED: reproduces the reference ring radii to ~1 mm.
/// record 0 (a chicken's record 0 is a flap, its Stand sits at index 2) — resolved by [`seq_index`].
/// Returns `(dx, dy, dz)`, or `None` when the model has no sequences (caller falls back to the header
/// box). VERIFIED: reproduces the reference ring radii to ~1 mm.
///
/// The Z extent joins the pair the ring needs because the chat bubble's anchor is the **same box**
/// read on the other axis (1406) — one parse, one Stand-sequence resolution, two consumers, so the
/// ring and the bubble can never drift onto different animations.
fn stand_box(bytes: &[u8]) -> Option<(f32, f32, f32)> {
let anim_count = bytes.u32_at(0x1c)? as usize;
let anim_ofs = bytes.u32_at(0x20)? as usize;
let lookup_count = bytes.u32_at(0x24)? as usize;
let lookup_ofs = bytes.u32_at(0x28)? as usize;
if anim_count == 0 {
return None;
}
// Stand (anim id 0) → its sequence index via animationLookup[0]; fall back to record 0 if the lookup
// is absent, out of bounds, or points nowhere (0xffff / out of range).
let idx = if lookup_count > 0 {
match bytes.u16_at(lookup_ofs) {
Some(i) if (i as usize) != 0xffff && (i as usize) < anim_count => i as usize,
_ => 0,
}
} else {
0
};
let rec = anim_ofs.checked_add(idx * 0x44)?;
let rec = stand_record(bytes)?;
// CAaBox @ rec+0x24: min C3 (+0x24), max C3 (+0x30). Horizontal (X,Y) extents; squared in the ring
// formula so an unsorted box is harmless.
let dx = bytes.f32_at(rec + 0x30)? - bytes.f32_at(rec + 0x24)?;
@@ -123,6 +183,26 @@ fn stand_box(bytes: &[u8]) -> Option<(f32, f32, f32)> {
Some((dx, dy, dz))
}
/// `AnimationData` id **42** — Swim. The second box `0x50ca90` queries (`0x50ccde call 0x711a20`
/// with `seq=0x2a`) when it builds the camera's swim framing-pivot preset.
const SWIM_ANIM_ID: usize = 42;
/// The camera's swim pivot drop (see [`M2Bounds::swim_pivot_drop`]): `StandSeq.bounds.max.z −
/// SwimSeq.bounds.max.z`, floored at zero, and `0.0` for any model missing either sequence — the
/// reference's own both-present guard (`0x711960` on ids 0 and `0x2a` at `0x50cc43`/`0x50cc4f`).
///
/// Stand resolves through [`stand_record`], not [`seq_index`] alone, so the standing side of the
/// subtraction is byte-for-byte the box the ring and the chat bubble already read.
fn swim_pivot_drop(bytes: &[u8]) -> f32 {
let Some(stand_z) = stand_record(bytes).and_then(|rec| bytes.f32_at(rec + 0x38)) else {
return 0.0;
};
let Some(swim_z) = seq_max_z(bytes, SWIM_ANIM_ID) else {
return 0.0;
};
(stand_z - swim_z).max(0.0)
}
/// Read an **in-memory** M2's authored bounds + vertex-derived radius (see [`M2Bounds`]). The bytes-in
/// entry point for the Bevy `AssetLoader`; [`load_m2_bounds`] is the chain-reading wrapper.
pub fn parse_m2_bounds(bytes: &[u8]) -> Result<M2Bounds> {
@@ -159,5 +239,6 @@ pub fn parse_m2_bounds(bytes: &[u8]) -> Result<M2Bounds> {
ring_footprint,
pivot_z: model.pivot_attach_z,
stand_box_z: rz.max(0.0),
swim_pivot_drop: swim_pivot_drop(bytes),
})
}
@@ -0,0 +1,103 @@
//! Pins the camera's **swim** framing-pivot preset against real build-5875 assets — the third
//! sibling of `selection_ring_radius` and `chat_bubble_anchor_height`, reading the same
//! `M2Sequence` `CAaBox` those two read, on a second animation.
//!
//! The reference keeps three framing-pivot presets and picks one per frame at `0x50f880`:
//!
//! ```text
//! swimming (MOVEFLAG 0x200000) -> cam+0x124 ; the swim preset
//! else cam+0x198 < 1.8315 -> cam+0x11c ; zoomed in -- NOT BUILT
//! else cam+0x120 ; zoomed out -- NOT BUILT
//! ```
//!
//! All three are rebuilt from the model by `0x50ca90` off one shared base — the
//! `attach17.z + 0.0972` neck height — and only `+0x124` is then pulled down:
//!
//! ```text
//! 50cccf call 0x711a20(model, seq=0) ; Stand
//! 50ccde call 0x711a20(model, seq=0x2a) ; Swim (42)
//! 50ccf6 fsubr [esi+0x124] ; +0x124 -= S * (Stand.max.z - Swim.max.z)
//! ```
//!
//! byte-decoded and VERIFIED in wow-re `ui/scratch/water-band-discontinuity.md` §7, which measured
//! the shipped Human Male at scale 1 as `+0x11c = +0x120 = 1.9002692` and `+0x124 = 1.5120120`.
//! That pair — and only that pair — is a number read off the binary; everything else below is
//! measured off the shipped M2s, pinned so the id-42 lookup cannot quietly stop resolving.
//!
//! The Human Male standing side comes out 1.900247 here rather than 1.9002692 because benilla
//! ships the base constant as `0.0972` while the reference's `[0x808ab0]` is `0.0972222` — 0.02 mm,
//! and *not* part of this feature, which is why the presets get a 1e-4 window and the drop itself
//! (a pure box difference, no constant in it) gets 1e-5.
//!
//! Skips when the gitignored client data isn't present.
use benilla_formats::{load_m2_bounds, open_chain};
/// The base the reference adds to all three presets — `attach17.z + 0.0972222 [0x808ab0]`, as
/// benilla's own display builder rounds it (`entities::display::build_parts`).
const PIVOT_BASE: f32 = 0.0972;
#[test]
fn the_swim_preset_is_the_stand_minus_swim_sequence_box_delta() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open chain");
let b = load_m2_bounds(&mut chain, "Character\\Human\\Male\\HumanMale.mdx")
.expect("bounds HumanMale");
let standing = b.pivot_z.expect("HumanMale authors attachment 17") + PIVOT_BASE;
// The one number read off the binary: 1.9002692 − 1.5120120.
assert!(
(b.swim_pivot_drop - 0.3882572).abs() < 1e-5,
"HumanMale swim_pivot_drop {:.7} should be the reference's 1.9002692 − 1.5120120 = 0.3882572",
b.swim_pivot_drop
);
assert!(
(standing - 1.9002692).abs() < 1e-4,
"HumanMale standing preset {standing:.7} should be the reference's cam+0x11c 1.9002692"
);
assert!(
(standing - b.swim_pivot_drop - 1.512_012).abs() < 1e-4,
"HumanMale swim preset {:.7} should be the reference's cam+0x124 1.5120120",
standing - b.swim_pivot_drop
);
}
#[test]
fn the_drop_is_per_model_and_zero_for_a_body_with_no_swim_sequence() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open chain");
// (model, the drop measured off the shipped M2). Character models only for the non-zero rows:
// the drop is `Stand.max.z − Swim.max.z`, and how far a race's swim pose sinks below its stand
// pose is authored per skeleton — a night elf barely dips, a tauren drops nearly half a yard.
// A single-model pin would pass just as happily on a lookup that always resolved record 0.
let cases = [
("Character\\Human\\Male\\HumanMale.mdx", 0.3882571_f32),
("Character\\Tauren\\Male\\TaurenMale.mdx", 0.4236124),
("Character\\NightElf\\Female\\NightElfFemale.mdx", 0.1288066),
// No Swim sequence at all: the chicken's animation lookup is 17 entries long, so id 42 is
// past its end and no record carries that id. This is the reference's own both-present
// guard (`0x711960` on 0 and 0x2a at `0x50cc43`/`0x50cc4f`) — a body that cannot swim keeps
// the standing preset in every state.
("Creature\\Chicken\\Chicken.mdx", 0.0),
];
for (path, drop) in cases {
let b = load_m2_bounds(&mut chain, path).unwrap_or_else(|e| panic!("bounds {path}: {e}"));
assert!(
(b.swim_pivot_drop - drop).abs() < 1e-5,
"{path}: swim_pivot_drop {:.7} should be {drop:.7}",
b.swim_pivot_drop
);
// A drop is a drop: never negative, and never so large it would put the swim preset under
// the model's feet. Both hold for every shipped body; either failing means the id-42
// lookup landed on the wrong record.
let standing = b.pivot_z.map(|z| z + PIVOT_BASE).unwrap_or(0.0);
assert!(
b.swim_pivot_drop >= 0.0 && b.swim_pivot_drop < standing,
"{path}: a swim drop of {:.7} against a standing preset of {standing:.7} is not a dip",
b.swim_pivot_drop
);
}
}
@@ -183,6 +183,23 @@ pub struct UnitCombatStats {
/// Whether a wand is equipped (`HasWandEquipped` — the ref swaps the ranged-attack action for
/// wand Shoot on it).
pub has_wand: bool,
/// `GetDodgeChance()` / `GetParryChance()` / `GetBlockChance()` — the player's avoidance
/// percentages (`PLAYER_DODGE_PERCENTAGE` and its two siblings), already a percent on the
/// wire: `2.62` is "2.62%".
///
/// **The player's only, and no unit argument.** All three bindings take zero arguments and
/// return exactly one number (`reference/1.12-shapes.tsv`: `GetDodgeChance 0x516f00`,
/// `GetBlockChance 0x516f60`, `GetParryChance 0x516fc0` — argc 0, arity 1, kind number, all
/// `exact`), so there is no pet leg to mirror and no absent answer to model: a field that has
/// not streamed reads 0, which is what the wire's own default is.
///
/// The shipped 1.12 FrameXML never calls them — the character sheet's own defense block is
/// `UnitDefense` — so this trio is addon-facing surface, and the corpus asks for it: four
/// vanilla addons read `GetDodgeChance` (FuBar_TankPointsFu, FuBar_DakSmak, Outfitter,
/// BetterCharacterStats) and three read each of the other two.
pub dodge_percent: f32,
pub parry_percent: f32,
pub block_percent: f32,
}
impl Default for UnitCombatStats {
@@ -220,6 +237,9 @@ impl Default for UnitCombatStats {
ranged_weapon_skill: (0, 0),
defense_skill: (0, 0),
has_wand: false,
dodge_percent: 0.0,
parry_percent: 0.0,
block_percent: 0.0,
}
}
}
@@ -934,6 +954,31 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetDodgeChance() / GetParryChance() / GetBlockChance() → one number each, no unit argument
// (`reference/1.12-shapes.tsv`, all three `exact` on argc, arity and kind). Player-implicit,
// like `HasWandEquipped` below; a field that has not streamed reads 0, which is the wire's own
// default rather than an absence to model.
for (name, pick) in [
("GetDodgeChance", 0usize),
("GetParryChance", 1),
("GetBlockChance", 2),
] {
g.set(
name,
lua.create_function(move |lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model
.player_combat_stats
.as_ref()
.map_or(0.0, |s| match pick {
0 => f64::from(s.dodge_percent),
1 => f64::from(s.parry_percent),
_ => f64::from(s.block_percent),
}))
})?,
)?;
}
// UnitRangedDamage("player") → (speed, minDamage, maxDamage, physicalBonusPos,
// physicalBonusNeg, percent) — RANGEDATTACKTIME/1000 + the ranged damage range + the same
// school-0 mods as UnitDamage.
@@ -1249,6 +1294,9 @@ mod tests {
ranged_weapon_skill: (18, 0),
defense_skill: (55, 4),
has_wand: false,
dodge_percent: 0.0,
parry_percent: 0.0,
block_percent: 0.0,
}
}
@@ -1294,6 +1342,43 @@ mod tests {
}
}
/// **The three avoidance verbs are player-implicit and answer ONE number each.**
///
/// `reference/1.12-shapes.tsv` has all three at argc 0 / arity 1 / kind number, every column
/// `exact` (`GetDodgeChance 0x516f00`, `GetBlockChance 0x516f60`, `GetParryChance 0x516fc0`),
/// so an argument is ignored rather than resolved and there is no pet leg. The absent
/// snapshot answers 0 — the wire's own default for a field that has not streamed, and the
/// only answer a one-number arity can give.
///
/// The three values below are exactly representable in `f32`, so the widening to Lua's double
/// is lossless and the assertions can be equalities rather than epsilons.
#[test]
fn the_avoidance_verbs_are_player_implicit_and_answer_one_number() {
let mut s = UiScript::new().unwrap();
// Before anything streams: a number, not nil — the arity is exact.
assert_eq!(s.eval::<f64>("return GetDodgeChance()").unwrap(), 0.0);
s.set_player_combat_stats(Some(UnitCombatStats {
dodge_percent: 5.25,
parry_percent: 3.5,
block_percent: 2.75,
..stats()
}));
assert_eq!(s.eval::<f64>("return GetDodgeChance()").unwrap(), 5.25);
assert_eq!(s.eval::<f64>("return GetParryChance()").unwrap(), 3.5);
assert_eq!(s.eval::<f64>("return GetBlockChance()").unwrap(), 2.75);
// Exactly one return, and a unit token is not a parameter — an addon passing one gets the
// player's number, which is what a zero-argc binding does with a stack it never reads.
assert_eq!(
s.eval::<i64>("local a, b = GetDodgeChance() return b == nil and 1 or 2")
.unwrap(),
1
);
assert_eq!(
s.eval::<f64>(r#"return GetDodgeChance("target")"#).unwrap(),
5.25
);
}
#[test]
fn unit_stat_serves_the_raw_field_twice_and_the_buff_split() {
let mut s = UiScript::new().unwrap();
+121 -5
View File
@@ -35,6 +35,22 @@ pub(crate) enum CollisionLayer {
Walk,
/// WMO faces only the **camera** collides with — the camera/LOS gather (skip NOCAMCOLLIDE `0x02`).
Camera,
/// **Liquid surfaces** — the wet-cell lattice of every MCLQ layer and WMO pool, on a layer of
/// their own so that *no* query sees them unless it asks.
///
/// This is `cameraWaterCollision`'s primary consumer and the shape the reference gives it: the
/// CVar does not re-lane any geometry, it adds `0xf0000` to the **trace mask** the solver hands
/// its three collision queries (`0x50e5ec`; the nibble is read at `0x69cc13` and gates a
/// per-layer intersection over the chunk's four MCLQ slots — `0x10000` river/lake, `0x20000`
/// ocean, `0x40000` magma, `0x80000` slime). A per-trace mask is exactly a `SpatialQueryFilter`,
/// which is why this is a layer and not a component the camera looks for.
///
/// wow-re `ui/scratch/water-band-discontinuity.md`. It **refutes** that tree's own
/// `camera-arm-liquid-blind.md` §2, a VERIFIED NEGATIVE that stood from June: the census was
/// correct and controlled, but a capability requested through an argument flag is invisible to
/// any census of call sites. Decision 2149 rested benilla on that verdict and built the wrong
/// half of the feature; 2165 took it out.
Liquid,
}
/// `CollisionLayers` for a WMO's **walking** collider — member of [`CollisionLayer::Walk`] only, so the
@@ -49,6 +65,16 @@ pub(crate) fn camera_layers() -> CollisionLayers {
CollisionLayers::new(CollisionLayer::Camera, LayerMask::ALL)
}
/// `CollisionLayers` for a **liquid surface** — member of [`CollisionLayer::Liquid`] only.
///
/// Every existing filter omits that bit, so these colliders are inert to the body, the camera, the
/// mouse pick and the particle snap alike until a query asks for them by name. A swimmer must not
/// be stopped by the water they are in, and nothing but the camera sweep under
/// `cameraWaterCollision` has any business hitting a waterline.
pub(crate) fn liquid_layers() -> CollisionLayers {
CollisionLayers::new(CollisionLayer::Liquid, LayerMask::ALL)
}
/// **How many times the world's collider set has changed** — bumped when [`crate::terrain_stream`]
/// attaches a batch of built colliders, and when any collider is removed (a tile or placement
/// streaming out, an entity despawning).
@@ -174,10 +200,16 @@ impl WorldCollision<'_, '_> {
/// …and what the third-person **camera** collides with: the other way round on those two WMO
/// face layers (it takes NOCAMCOLLIDE faces the body walks through, and skips the DETAIL faces
/// the body walks on).
pub(crate) fn camera_filter() -> SpatialQueryFilter {
SpatialQueryFilter::from_mask(LayerMask(
CollisionLayer::Default.to_bits() | CollisionLayer::Camera.to_bits(),
))
/// `liquid` adds the waterline — `cameraWaterCollision`'s primary consumer. The reference does
/// exactly this and no more: `0x50e5ec` ORs `0xf0000` into the trace mask word the solver hands
/// its three collision queries, so the CVar changes *what this one query is allowed to see* and
/// re-lanes nothing. `false` and it is byte-for-byte the filter it always was.
pub(crate) fn camera_filter(liquid: bool) -> SpatialQueryFilter {
let mut mask = CollisionLayer::Default.to_bits() | CollisionLayer::Camera.to_bits();
if liquid {
mask |= CollisionLayer::Liquid.to_bits();
}
SpatialQueryFilter::from_mask(LayerMask(mask))
}
/// Sweep `shape` along `movement` against the body's world.
@@ -219,6 +251,10 @@ impl WorldCollision<'_, '_> {
/// Deliberately avian's own two-sided cast rather than the one-sided law above: the facing
/// gate exists because a *body* must not stand on a face wound away from it, and a camera has
/// no such contract — it just must not end up inside geometry, from either side.
///
/// `liquid` is `cameraWaterCollision`, straight through: with it set the boom stops at a
/// waterline the way it stops at ground, which is the whole of the option's primary effect and
/// what its own tooltip promises the player.
pub fn cast_camera(
&self,
shape: &Collider,
@@ -226,6 +262,7 @@ impl WorldCollision<'_, '_> {
rotation: Quat,
movement: Vec3,
skin_width: f32,
liquid: bool,
) -> Option<MoveHitData> {
self.ms.cast_move(
shape,
@@ -233,7 +270,7 @@ impl WorldCollision<'_, '_> {
rotation,
movement,
skin_width,
&Self::camera_filter(),
&Self::camera_filter(liquid),
)
}
@@ -352,6 +389,85 @@ impl WorldCollision<'_, '_> {
}
}
/// **`cameraWaterCollision` is a trace mask and nothing else.** The same waterline collider is a
/// wall to the camera boom with the CVar on, thin air with it off, and thin air to the walking body
/// either way — which is the whole of the reference's `0x50e5ec`, and the reason a swimmer is never
/// stopped by the water they are in.
#[cfg(test)]
mod liquid_trace_mask {
use super::*;
use bevy::ecs::system::RunSystemOnce;
use bevy::prelude::*;
/// A headless world holding one 10×10 horizontal surface on [`CollisionLayer::Liquid`].
fn world_with_waterline() -> App {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
bevy::transform::TransformPlugin,
bevy::asset::AssetPlugin::default(),
bevy::scene::ScenePlugin,
PhysicsPlugins::new(bevy::app::PostUpdate),
));
app.init_asset::<Mesh>();
app.world_mut().spawn((
RigidBody::Static,
Collider::trimesh(
vec![
Vec3::new(-5.0, 0.0, -5.0),
Vec3::new(5.0, 0.0, -5.0),
Vec3::new(5.0, 0.0, 5.0),
Vec3::new(-5.0, 0.0, 5.0),
],
vec![[0u32, 2, 1], [0, 3, 2]],
),
liquid_layers(),
Transform::default(),
));
app.finish();
app.cleanup();
app.update();
app
}
/// Drop a small probe from above the surface onto it, through `filter`.
fn descend(app: &mut App, filter: SpatialQueryFilter) -> Option<f32> {
app.world_mut()
.run_system_once(move |ms: MoveAndSlide| {
ms.cast_move(
&Collider::sphere(0.1),
Vec3::new(0.0, 3.0, 0.0),
Quat::IDENTITY,
Vec3::new(0.0, -6.0, 0.0),
0.0,
&filter,
)
.map(|h| h.distance)
})
.expect("system runs")
}
#[test]
fn the_waterline_stops_the_camera_only_when_the_cvar_asks_for_it() {
let mut app = world_with_waterline();
let on = descend(&mut app, WorldCollision::camera_filter(true));
assert!(
on.is_some_and(|d| (d - 2.9).abs() < 0.05),
"with cameraWaterCollision the boom stops at the surface, got {on:?}"
);
assert_eq!(
descend(&mut app, WorldCollision::camera_filter(false)),
None,
"with the CVar off the camera passes through, exactly as it did before this existed"
);
assert_eq!(
descend(&mut app, WorldCollision::body_filter()),
None,
"and the BODY passes through either way — a swimmer is not stopped by their own water"
);
}
}
/// The contact pipeline we deliberately do not run (decision 1232, disabled in `world_plugins`).
///
/// These two tests are a matched pair: the first shows the cost was real — a kinematic trimesh
@@ -24,7 +24,9 @@ use bevy::pbr::ExtendedMaterial;
use bevy::prelude::*;
use super::query::{wet_footprint, FoamPatch, LiquidSource, WmoPool};
use crate::collision::liquid_layers;
use crate::lighting::WATER_SHININESS;
use avian3d::prelude::{Collider, RigidBody};
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::materials::{LiquidExt, LiquidMaterial};
use benilla_assets::LockRecover;
@@ -292,9 +294,41 @@ pub(crate) fn spawn_liquids<'a>(
.entity(*entities.last().expect("just pushed"))
.insert(FoamPatch);
}
// **The waterline the camera sweep can hit** under `cameraWaterCollision` — inert to every
// other query, because nothing else asks for [`CollisionLayer::Liquid`].
if let Some(collider) = liquid_collider(lq) {
commands
.entity(*entities.last().expect("just pushed"))
.insert((collider, RigidBody::Static, liquid_layers()));
}
}
}
/// The **collision** shape for one [`LiquidMesh`] — the same wet-cell triangles the render mesh
/// draws, as a parry trimesh on [`CollisionLayer::Liquid`](crate::collision::CollisionLayer).
///
/// `lq.indices` is already the wet derivative (6 per wet cell), so this is the waterline exactly
/// where there is water and nothing over the dry cells of a part-flooded chunk — which is the
/// difference between a camera that stops at a lake's edge and one that stops in mid-air over the
/// shore. Built inline rather than through [`crate::terrain_stream::PendingCollider`]'s async pool:
/// an MCNK layer's 9×9 lattice is at most 64 cells, 128 triangles, next to the 256 a terrain chunk
/// defers, and a WMO pool's MLIQ is the same order.
///
/// `None` for a degenerate layer with no triangles, which is a real case (an MCLQ layer whose cells
/// are all dry) and not a failure.
fn liquid_collider(lq: &LiquidMesh) -> Option<Collider> {
let tris: Vec<[u32; 3]> = lq
.indices
.chunks_exact(3)
.map(|c| [c[0], c[1], c[2]])
.collect();
if tris.is_empty() {
return None;
}
let verts: Vec<Vec3> = lq.positions.iter().map(|p| wow_to_bevy(*p)).collect();
Some(Collider::trimesh(verts, tris))
}
/// Build the Bevy render mesh for one [`LiquidMesh`]: positions mapped WoW→Bevy (`lq.positions` are
/// raw WoW coords — absolute for MCLQ, WMO-model-local for WMO liquid), a flat up normal, the tiling
/// UVs, and the per-vertex swatch `V` packed into UV1.x for the shader's colour/opacity ramp. The
@@ -439,6 +473,15 @@ pub(crate) fn spawn_wmo_liquids<'a>(
if !lq.kind.is_fullbright() {
commands.entity(surface).insert(FoamPatch);
}
// The camera's waterline, as on the ADT path. The shape is MODEL-LOCAL here and the
// entity carries the placement `transform`, so avian lifts it into the world the same way
// it lifts the render mesh — no second bake, and a rotated pool stays consistent with the
// surface the player sees.
if let Some(collider) = liquid_collider(lq) {
commands
.entity(surface)
.insert((collider, RigidBody::Static, liquid_layers()));
}
entities.push(surface);
}
}