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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user