From 64296b306fd126183e268099a33b72c0391dcf4e Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 11 Sep 2026 21:02:31 +0700 Subject: [PATCH] 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 --- crates/benilla-app/examples/addon_harness.rs | 90 +++++ crates/benilla-app/src/addon_harness/mod.rs | 349 +++++++++++++++--- crates/benilla-app/src/addon_harness/probe.rs | 12 +- .../benilla-app/src/addon_harness/together.rs | 323 ++++++++++++++++ crates/benilla-app/src/cvars.rs | 23 +- crates/benilla-app/src/entities/attach/mod.rs | 4 + crates/benilla-app/src/entities/display.rs | 17 + crates/benilla-app/src/player/body_pose.rs | 20 +- crates/benilla-app/src/player/camera.rs | 135 ++++++- .../benilla-app/src/player/camera_dynamics.rs | 225 ++++++++++- crates/benilla-app/src/player/controller.rs | 16 +- crates/benilla-app/src/player/view_subject.rs | 5 +- crates/benilla-app/src/ui_auction/mod.rs | 4 +- crates/benilla-app/src/ui_char.rs | 10 + .../src/ui_script/options_tests.rs | 14 - crates/benilla-app/src/ui_talent.rs | 14 +- crates/benilla-formats/src/models/bounds.rs | 123 ++++-- .../tests/camera_swim_pivot_drop.rs | 103 ++++++ crates/benilla-ui/src/script/char_stats.rs | 85 +++++ crates/benilla-world/src/collision.rs | 126 ++++++- crates/benilla-world/src/liquid/surface.rs | 43 +++ 21 files changed, 1614 insertions(+), 127 deletions(-) create mode 100644 crates/benilla-app/src/addon_harness/together.rs create mode 100644 crates/benilla-formats/tests/camera_swim_pivot_drop.rs diff --git a/crates/benilla-app/examples/addon_harness.rs b/crates/benilla-app/examples/addon_harness.rs index 658a79f7..f89ab53c 100644 --- a/crates/benilla-app/examples/addon_harness.rs +++ b/crates/benilla-app/examples/addon_harness.rs @@ -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 ` 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 = 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 [--eval ...]` — 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 diff --git a/crates/benilla-app/src/addon_harness/mod.rs b/crates/benilla-app/src/addon_harness/mod.rs index 221c2724..4e49402a 100644 --- a/crates/benilla-app/src/addon_harness/mod.rs +++ b/crates/benilla-app/src/addon_harness/mod.rs @@ -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 `` names a FONT — so a name registered as either is resolved. pub missing_inherits: Vec, - /// 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 = Vec::new(); + let mut dep_loads: Vec = Vec::new(); load_dependencies( - &script, + &mut script, root, &toc, installed, &mut BTreeSet::new(), - &mut dep_order, + &mut dep_loads, ); + let dep_order: Vec = 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, ) -> Vec { - // **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\\`), 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\\`), 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 { /// 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, seen: &mut BTreeSet, - loaded: &mut Vec, + loaded: &mut Vec, ) { // 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 { + use std::sync::OnceLock; + static TREE: OnceLock> = 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 { + 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::(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 + /// `` 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::( + "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::("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::("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. /// diff --git a/crates/benilla-app/src/addon_harness/probe.rs b/crates/benilla-app/src/addon_harness/probe.rs index c35bcf81..1f66f62e 100644 --- a/crates/benilla-app/src/addon_harness/probe.rs +++ b/crates/benilla-app/src/addon_harness/probe.rs @@ -97,9 +97,9 @@ pub fn probe(root: &Path, name: &str, evals: &[String]) -> Option super::seat_a_session(&mut script); let _ = crate::ui_script::load_default_ui(&script); - let mut dep_order: Vec = Vec::new(); + let mut dep_order: Vec = 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 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() diff --git a/crates/benilla-app/src/addon_harness/together.rs b/crates/benilla-app/src/addon_harness/together.rs new file mode 100644 index 00000000..7f8e1b8e --- /dev/null +++ b/crates/benilla-app/src/addon_harness/together.rs @@ -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, + /// 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 { + let mut merged: Vec = 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)> { + 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 = 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)> = 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 = 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. `\\` 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)> = + 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 = 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); + } +} diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index f0abc085..63cfdf2d 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -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, diff --git a/crates/benilla-app/src/entities/attach/mod.rs b/crates/benilla-app/src/entities/attach/mod.rs index b459717c..7889715d 100644 --- a/crates/benilla-app/src/entities/attach/mod.rs +++ b/crates/benilla-app/src/entities/attach/mod.rs @@ -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). diff --git a/crates/benilla-app/src/entities/display.rs b/crates/benilla-app/src/entities/display.rs index f4613bf3..f78ed8e3 100644 --- a/crates/benilla-app/src/entities/display.rs +++ b/crates/benilla-app/src/entities/display.rs @@ -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; diff --git a/crates/benilla-app/src/player/body_pose.rs b/crates/benilla-app/src/player/body_pose.rs index 37246cb6..ecefe3d1 100644 --- a/crates/benilla-app/src/player/body_pose.rs +++ b/crates/benilla-app/src/player/body_pose.rs @@ -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 { - 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=`: pin the body counter-twist's yaw gap instead of deriving it from diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index 52b382d8..d04d3b3f 100644 --- a/crates/benilla-app/src/player/camera.rs +++ b/crates/benilla-app/src/player/camera.rs @@ -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, + /// **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. diff --git a/crates/benilla-app/src/player/camera_dynamics.rs b/crates/benilla-app/src/player/camera_dynamics.rs index 27c47e56..3260b511 100644 --- a/crates/benilla-app/src/player/camera_dynamics.rs +++ b/crates/benilla-app/src/player/camera_dynamics.rs @@ -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" + ); + } } diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs index 6465c219..7d9f9f98 100644 --- a/crates/benilla-app/src/player/controller.rs +++ b/crates/benilla-app/src/player/controller.rs @@ -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, diff --git a/crates/benilla-app/src/player/view_subject.rs b/crates/benilla-app/src/player/view_subject.rs index 2ac4b99d..3aeae245 100644 --- a/crates/benilla-app/src/player/view_subject.rs +++ b/crates/benilla-app/src/player/view_subject.rs @@ -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(); diff --git a/crates/benilla-app/src/ui_auction/mod.rs b/crates/benilla-app/src/ui_auction/mod.rs index 59caf848..f37527af 100644 --- a/crates/benilla-app/src/ui_auction/mod.rs +++ b/crates/benilla-app/src/ui_auction/mod.rs @@ -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 { diff --git a/crates/benilla-app/src/ui_char.rs b/crates/benilla-app/src/ui_char.rs index 3f5d2a0a..dfc890b0 100644 --- a/crates/benilla-app/src/ui_char.rs +++ b/crates/benilla-app/src/ui_char.rs @@ -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) } } diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index facb360b..842fe9c7 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -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: diff --git a/crates/benilla-app/src/ui_talent.rs b/crates/benilla-app/src/ui_talent.rs index f04d5f69..2d314558 100644 --- a/crates/benilla-app/src/ui_talent.rs +++ b/crates/benilla-app/src/ui_talent.rs @@ -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 { /// 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, - 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(); diff --git a/crates/benilla-formats/src/models/bounds.rs b/crates/benilla-formats/src/models/bounds.rs index 59164b77..a368f138 100644 --- a/crates/benilla-formats/src/models/bounds.rs +++ b/crates/benilla-formats/src/models/bounds.rs @@ -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 { /// 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 { + 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 { + 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 { + 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 { + 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 { @@ -159,5 +239,6 @@ pub fn parse_m2_bounds(bytes: &[u8]) -> Result { ring_footprint, pivot_z: model.pivot_attach_z, stand_box_z: rz.max(0.0), + swim_pivot_drop: swim_pivot_drop(bytes), }) } diff --git a/crates/benilla-formats/tests/camera_swim_pivot_drop.rs b/crates/benilla-formats/tests/camera_swim_pivot_drop.rs new file mode 100644 index 00000000..2433410f --- /dev/null +++ b/crates/benilla-formats/tests/camera_swim_pivot_drop.rs @@ -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 + ); + } +} diff --git a/crates/benilla-ui/src/script/char_stats.rs b/crates/benilla-ui/src/script/char_stats.rs index 0b43f046..0ea72a71 100644 --- a/crates/benilla-ui/src/script/char_stats.rs +++ b/crates/benilla-ui/src/script/char_stats.rs @@ -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::().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::("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::("return GetDodgeChance()").unwrap(), 5.25); + assert_eq!(s.eval::("return GetParryChance()").unwrap(), 3.5); + assert_eq!(s.eval::("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::("local a, b = GetDodgeChance() return b == nil and 1 or 2") + .unwrap(), + 1 + ); + assert_eq!( + s.eval::(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(); diff --git a/crates/benilla-world/src/collision.rs b/crates/benilla-world/src/collision.rs index dbb55d18..abaf1bc6 100644 --- a/crates/benilla-world/src/collision.rs +++ b/crates/benilla-world/src/collision.rs @@ -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 { 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::(); + 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 { + 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 diff --git a/crates/benilla-world/src/liquid/surface.rs b/crates/benilla-world/src/liquid/surface.rs index 51ea2d70..f276001f 100644 --- a/crates/benilla-world/src/liquid/surface.rs +++ b/crates/benilla-world/src/liquid/surface.rs @@ -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 { + 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 = 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); } }