diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 0cb7803e..695cf898 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -151,6 +151,7 @@ pub(crate) use probe_look::ProbeLookPlugin; pub(crate) fn start_state() -> crate::char_select::ClientState { match glue_screen() { Some(GlueScreen::CharCreate) => crate::char_select::ClientState::CharCreate, + Some(GlueScreen::Login) => crate::char_select::ClientState::Login, None if crate::run_mode::scenario_active() => crate::char_select::ClientState::InWorld, None => crate::char_select::ClientState::Login, } @@ -505,8 +506,13 @@ impl Plugin for CapturePlugin { // The preview pick goes through the existing `WOW_CHARCREATE_PICK` instrument rather // than a second path into `CreateSelection` — same reason the map is seeded by env // (decision 0743): one route into a fact, whoever is asking. - let (race, sex, class) = g.pick; - std::env::set_var("WOW_CHARCREATE_PICK", format!("{race},{sex},{class}")); + // …and an explicit pick in the environment outranks the scenario's default — the + // per-race lever, so one scenario photographs every `UI_*` stage. + if let Some((race, sex, class)) = g.pick { + if std::env::var_os("WOW_CHARCREATE_PICK").is_none() { + std::env::set_var("WOW_CHARCREATE_PICK", format!("{race},{sex},{class}")); + } + } } // The fxview instrument: a synthetic scenario (ground scene, noon) + the fixture // request from env. Not in SCENARIOS — `scripts/visual.sh`'s golden sweep must never diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index 23062de2..1ffd2398 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -231,27 +231,42 @@ pub(super) struct GlueScenario { pub(super) screen: GlueScreen, /// The preview's race, sex and class ids — the reference comparisons are per-race, so a /// scenario says which one it photographs. Applied through the existing - /// `WOW_CHARCREATE_PICK` instrument rather than a second path into `CreateSelection`. - pub(super) pick: (u8, u8, u8), + /// `WOW_CHARCREATE_PICK` instrument rather than a second path into `CreateSelection` — and + /// **that instrument wins when it is already set**, so one scenario photographs any race + /// (`WOW_CHARCREATE_PICK=6,0,11 WOW_CAPTURE=glue-charcreate` is the tauren stage). `None` for + /// a screen with no preview to pick. + pub(super) pick: Option<(u8, u8, u8)>, } -/// Which glue screen a [`GlueScenario`] photographs. One today; the select screen wants a roster -/// (so it needs a server, or a seeded one) and the login screen is nearly static. +/// Which glue screen a [`GlueScenario`] photographs. The select screen wants a roster (so it +/// needs a server, or a seeded one) and is not here. #[derive(Clone, Copy, PartialEq)] pub(crate) enum GlueScreen { /// The character-creation screen (decision 0423). Photographs, in one frame: the authored-rig /// lighting of the `UI_*` backdrop scene and its per-race fog (the lane 1171 left uncovered), /// the preview body in its starting outfit (0527), and the GlueXML panel around them. CharCreate, + /// The login screen (decision 0539) — `UI_MainMenu` behind the account form. Nearly static, + /// which is why it was not here until its framing was the subject (B330, decision 1619): the + /// gate's backdrop is the narrowest art of the seven scenes, so it is the one whose edges a + /// wide window reaches first. `WOW_WIN=2560x1440` is the reporter's shape. + Login, } /// The glue sweep. Human, 1 (Human male warrior) — the race whose scene the reference's own -/// screenshots use, and the outfit path most exercised elsewhere. -pub(super) const GLUE_SCENARIOS: &[GlueScenario] = &[GlueScenario { - name: "glue-charcreate", - screen: GlueScreen::CharCreate, - pick: (1, 0, 1), -}]; +/// screenshots use, and the outfit path most exercised elsewhere — and the login gate. +pub(super) const GLUE_SCENARIOS: &[GlueScenario] = &[ + GlueScenario { + name: "glue-charcreate", + screen: GlueScreen::CharCreate, + pick: Some((1, 0, 1)), + }, + GlueScenario { + name: "glue-login", + screen: GlueScreen::Login, + pick: None, + }, +]; /// THE golden baseline: **two spots FRAMED BY THE DIRECTOR, each at noon and at night — four /// captures, and that is the whole sweep.** Elwynn water and a Felwood hollow on Kalimdor. The diff --git a/crates/benilla-app/src/char_select/mod.rs b/crates/benilla-app/src/char_select/mod.rs index d2c65bc6..5d4c2e4f 100644 --- a/crates/benilla-app/src/char_select/mod.rs +++ b/crates/benilla-app/src/char_select/mod.rs @@ -5,7 +5,8 @@ //! The IO thread parks after the world handshake and emits the account roster //! ([`CharListMessage`]); the **pick policy** here decides what answers it — the pending pick //! (seamless reconnect, decision 0065), the `WOW_CHAR` env fast path, or the director's choice on -//! the screen. The pick travels the [`CharPick`] channel; `Connected` flips us `InWorld`; +//! the screen, which opens on whoever they last entered the world as (`lastCharacterIndex`, +//! decision 1622). The pick travels the [`CharPick`] channel; `Connected` flips us `InWorld`; //! a `/logout` round-trip ([`LoggedOutMessage`]) flips back to select with the pending pick //! cleared, and a **lost** session flips all the way back to the login screen (decision 1262 — //! the reference's `DISCONNECTED_FROM_SERVER`). @@ -112,6 +113,9 @@ impl Plugin for CharSelectPlugin { enter_on_connected, back_on_logout, back_on_disconnect, + // LAST, and outside both `run_if`s: it mirrors the pick this frame ended + // with, and world entry is reached from the create screen too (1622). + persist_last_character, ) .chain(), ( @@ -276,6 +280,109 @@ impl Roster { self.pending_pick .and_then(|g| self.chars.iter().find(|c| c.guid == g)) } + + /// The **roster position** of the pick in flight — what the reference persists as + /// `lastCharacterIndex` at Enter World (decision 1622). By guid rather than by the live + /// selection, so the row it names is the one actually being entered even if the selection has + /// since moved. + pub(super) fn pending_index(&self) -> Option { + let guid = self.pending_pick?; + self.chars.iter().position(|c| c.guid == guid) + } +} + +// ── The remembered character (`lastCharacterIndex`, decision 1622) ─────────────────────────────── + +/// The CVar the select screen remembers you by — a **real 1.12 CVar**, byte-verified in wow-re +/// (registered at `0x402d93`, name `0x82e8f8`, help "Last character selected", default `"0"`, +/// pointer cached at `[0x882674]`), and written engine-side: no shipped GlueXML names it and the +/// binary never looks it up by name. +pub(crate) const CVAR_LAST_CHARACTER: &str = "lastCharacterIndex"; + +/// The stored index is **0-based**, and `"0"` is the FIRST character — not "nothing remembered". +/// +/// It is the engine's own selection cell `[0x83856c]` printed with `"%d"`: `SelectCharacter` +/// (`0x473470`) takes the glue Lua's 1-based row and `dec`s it, and the event back out +/// (`0x472740`) re-adds the one. So the number on disk is one *less* than the row `CharacterSelect +/// .selectedIndex` counts in, and it lines up with [`Roster::selected`] exactly. +/// +/// The registered default being `"0"` is load-bearing rather than incidental: `CVar::SaveConfig` +/// (`0x63d980`) skips any value equal to its default, so a player who last entered on their first +/// character has **no such line in `Config.wtf` at all** — and reads back as row 0 anyway. Ours +/// composes the same way (`cvars::compose_file`), so `config.toml` gets the same shape for the +/// same reason. +fn last_character_value(row: usize) -> String { + row.to_string() +} + +/// The stored value → a row. `None` only for a value that is not a number at all (a hand edit); +/// out-of-range is the *caller's* business, because the reference's answer to it is not a clamp. +fn last_character_row(value: &str) -> Option { + value.trim().parse::().ok() +} + +/// The remembered row for a roster of `len` characters — the reference's whole selection rule, +/// `CGlueMgr::SetSelectedCharacter` `0x472740`: +/// +/// ```text +/// selected = (stored < 0 || stored >= count) ? 0 : stored +/// ``` +/// +/// Out of range falls back to the **first** row, never to the nearest one: a character deleted +/// since you last played, or a realm with fewer characters, puts you at the top of the list rather +/// than beside where the old row used to be. +fn remembered_row(persist: &crate::cvars::CvarPersist, len: usize) -> usize { + persist + .stored(CVAR_LAST_CHARACTER) + .and_then(last_character_row) + .filter(|&row| row < len) + .unwrap_or(0) +} + +/// Mirror the character we are **entering the world as** into [`CVAR_LAST_CHARACTER`]. +/// +/// **At Enter World, not at every click** — which is the difference between "the last character +/// you logged in as" and "the last row you happened to touch", and it is the reference's own +/// seam: `CGlueMgr::EnterWorld` (`0x46b500`) formats `[0x83856c]` into the CVar at `0x46b5fa`, +/// and clicks and arrow keys reach `0x472740` without ever going near it. [`Roster::pending_pick`] +/// is exactly that moment for us, so [`Roster::pending_index`] is what this reads. +/// +/// The write goes through [`UiScript::set_cvar_engine`] so it rides the change queue like a Lua +/// `SetCVar` and the host's sync persists it — the minimap-zoom pattern (1131), already used from +/// this screen by the AddOns panel's force-load box (1293). **One divergence, stated:** the +/// reference flushes `Config.wtf` synchronously in the same call (`0x46b6f6`), while ours reaches +/// disk on the exit edge with every other CVar (1528) — so a crash between entering the world and +/// quitting loses the memory, where the reference would not. That is the CVar store's shape, not +/// this key's, and changing it is an autosave design (1528's own "what this does NOT fix"). +fn persist_last_character( + roster: Res, + mut script: Option>, + // Memory about the VM's CVar table, so it dies with the VM (decision 1290). A bare `Local` + // here would be betting that `cvars::sync_cvars`'s per-VM seed carries this key into the next + // table — true today, and exactly the "correct against one VM, silently wrong against the + // next" shape 1290 built its structural gate to refuse. `get_for` because this system also + // runs while there is no VM at all. + mut mirrored: Local>>, +) { + let Some(row) = roster.pending_index() else { + return; // nobody is entering the world — nothing to remember + }; + let mirrored = mirrored.get_for(script.as_deref()); + if *mirrored == Some(row) { + return; + } + let Some(script) = script.as_deref_mut() else { + return; + }; + script.set_cvar_engine(CVAR_LAST_CHARACTER, &last_character_value(row)); + // Latch only once the table actually took it. An engine write to a name the host has not + // registered yet is a deliberate silent no-op (`script::cvars::set_from_engine`), and the + // per-VM seed that registers it runs in `cvars::sync_cvars` — a sibling `Update` system with + // no ordering against this one. Latching on the frame the write was dropped would swallow + // exactly one entry, and it would be the session's first. + if script.cvar(CVAR_LAST_CHARACTER).is_some() { + *mirrored = Some(row); + } } /// Ask the parked IO thread to log in as `guid` (the pick channel) and remember it as pending. @@ -295,6 +402,11 @@ fn apply_roster_policy( mut msgs: MessageReader, mut roster: ResMut, pick: Res, + // The remembered row (decision 1622) — read off the persist state rather than the VM's table + // because it is a value the *file* owns and the session only mirrors, and because reading it + // here keeps this system send-able. It is consulted exactly once per process: `selected` is + // `None` only before the first roster lands. + persist: Res, // Is the character pick already spoken for? Present only when a rig is driving this run // (decision 1174's always-present run fact) — absent in every ordinary run, which is the // player answer and the one this screen was written for. @@ -332,7 +444,12 @@ fn apply_roster_policy( // ref's literal `SELECT_LAST_CHARACTER` → `SelectCharacter(numChars)` and lands on the same // character regardless — vmangos enumerates `ORDER BY create_time, guid`, so the new one is // last. Otherwise clamp into range, first row default. - if roster.just_created.is_some() { + // **`SELECT_LAST_CHARACTER` outranks the remembered row** — the reference's own + // precedence, and the order is why: the C side restores the CVar and pushes it into Lua + // (`0x472563` → `UPDATE_SELECTED_CHARACTER`) *before* firing `CHARACTER_LIST_UPDATE`, and + // `UpdateCharacterList`'s deferred `selectLast` flag then overwrites it. + let created = roster.just_created.is_some(); + if created { roster.select_created_by_name(); if let Some(name) = roster.just_created.take() { warn!( @@ -344,9 +461,18 @@ fn apply_roster_policy( } if roster.chars.is_empty() { roster.select(None); - } else { - let sel = roster.selected().unwrap_or(0).min(roster.chars.len() - 1); - roster.select(Some(sel)); + } else if !created { + // The remembered character (decision 1622), re-applied on **every** roster — the + // reference reads the CVar in the char-list rebuild itself (`0x4724d0` → `0x472740`), + // not once at startup, so the screen always opens on whoever you last entered the + // world as. Out of range falls back to the first row; see `remembered_row`. + let row = remembered_row(&persist, roster.chars.len()); + // Greppable, and it names the character rather than only the index: "the first row + // happened to be right" and "the memory worked" are the same picture on screen, and + // this line is the only thing that tells them apart in a log. + let who = roster.chars[row].name.clone(); + info!("char select: {CVAR_LAST_CHARACTER} selects row {row} ({who})"); + roster.select(Some(row)); } // `WOW_CHARSELECT_PICK=` — **select** that row and stay on the screen, the // deliberate opposite of `WOW_CHAR`'s enter-the-world fast path. Without it the screen is @@ -767,37 +893,63 @@ pub(crate) fn class_name(class: u8) -> &'static str { } } +/// A `Character` with only its identity filled in — enough for any test that cares about *which* +/// row, not what stands on the stage. `pub(crate)` so [`crate::cvars`]'s end-to-end round trip can +/// build a roster without a second copy of this list drifting from the real one. +#[cfg(test)] +pub(crate) fn test_character(guid: u64, name: &str) -> Character { + Character { + guid, + name: name.to_string(), + race: 1, + class: 1, + gender: 0, + skin: 0, + face: 0, + hair_style: 0, + hair_color: 0, + facial_hair: 0, + level: 1, + zone: 0, + map: 0, + position: benilla_protocol::wire::Vector3d { + x: 0.0, + y: 0.0, + z: 0.0, + }, + flags: 0, + equipment: [benilla_protocol::CharEnumItem::default(); 19], + pet_display_id: 0, + pet_level: 0, + pet_family: 0, + } +} + +/// The roster policy and its CVar mirror, with none of the screen they normally run under — the +/// two systems the remembered selection is made of ([`apply_roster_policy`] reads it, +/// [`persist_last_character`] writes it), wired into a test app. +/// +/// `pub(crate)` so [`crate::cvars`] can drive them over a **real** CVar host: the seam where a +/// queued engine write becomes a line in `config.toml` lives entirely in that module, and this +/// module's own tests stub it. `CharSelectPlugin` itself is not usable there — it wants the whole +/// glue screen, its art and its sounds. +#[cfg(test)] +pub(crate) fn add_test_systems(app: &mut App, pick: crossbeam_channel::Sender) { + app.insert_state(ClientState::CharSelect) + .init_resource::() + .insert_resource(CharPick(pick)) + .add_message::() + .add_systems( + Update, + (apply_roster_policy, persist_last_character).chain(), + ); +} + #[cfg(test)] mod tests { use super::*; - fn character(guid: u64, name: &str) -> Character { - Character { - guid, - name: name.to_string(), - race: 1, - class: 1, - gender: 0, - skin: 0, - face: 0, - hair_style: 0, - hair_color: 0, - facial_hair: 0, - level: 1, - zone: 0, - map: 0, - position: benilla_protocol::wire::Vector3d { - x: 0.0, - y: 0.0, - z: 0.0, - }, - flags: 0, - equipment: [benilla_protocol::CharEnumItem::default(); 19], - pet_display_id: 0, - pet_level: 0, - pet_family: 0, - } - } + use super::test_character as character; /// **A session that dies during world entry does not leave the client in the world** /// (decision 1262). @@ -916,6 +1068,284 @@ mod tests { assert_eq!(roster.selected, Some(1)); } + // ── The remembered character (`lastCharacterIndex`, decision 1622) ─────────────────────────── + + /// Drive the REAL [`apply_roster_policy`] over one roster message, with `config.toml` already + /// holding `stored` for `lastCharacterIndex` (`None` = a launch that has never entered a + /// world). Returns the row the screen opens on. + fn roster_policy_over( + stored: Option<&str>, + names: &[&str], + preselected: Option, + ) -> Option { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .init_resource::() + .insert_resource(CharPick(tx)) + .insert_resource(match stored { + Some(v) => crate::cvars::CvarPersist::with_stored(CVAR_LAST_CHARACTER, v), + None => crate::cvars::CvarPersist::default(), + }) + .add_message::() + .add_message::() + .add_systems(Update, apply_roster_policy); + if let Some(row) = preselected { + app.world_mut().resource_mut::().select(Some(row)); + } + app.world_mut().write_message(CharListMessage { + characters: names + .iter() + .enumerate() + .map(|(i, n)| character(i as u64 + 1, n)) + .collect(), + realm: None, + }); + app.update(); + app.world().resource::().selected() + } + + /// **The report**: the screen must open on the character you last logged in as, as the + /// reference does. The stored value is 0-based — the engine's own selection cell, one less + /// than the row the glue Lua counts in — so `"2"` IS row 2. + #[test] + fn the_roster_opens_on_the_remembered_character() { + assert_eq!( + roster_policy_over( + Some("2"), + &["Kerwind", "Xero", "Zzbullone", "Wartwof"], + None + ), + Some(2), + "a config.toml remembering the third character must select it, not row one", + ); + } + + /// A client that has never entered a world stores nothing — and `"0"` is not "nothing", it is + /// the FIRST character (the registrar default, which is exactly why the key is absent from a + /// `Config.wtf` whose player last played their first character). Both land on row 0, and it + /// matters that they land there for the reference's reason. + #[test] + fn nothing_remembered_and_a_stored_zero_are_both_the_first_row() { + assert_eq!( + roster_policy_over(None, &["Kerwind", "Xero"], None), + Some(0) + ); + assert_eq!( + roster_policy_over(Some("0"), &["Kerwind", "Xero"], None), + Some(0), + ); + } + + /// The remembered character was deleted (or this realm simply has fewer): the reference's + /// `0x472740` sends an out-of-range index to the **first** row — never to the nearest one. + #[test] + fn a_remembered_row_past_the_end_falls_back_to_the_first() { + assert_eq!( + roster_policy_over(Some("9"), &["Kerwind", "Xero"], None), + Some(0), + "the reference clamps to 0, not to the last row — `(idx >= count) ? 0 : idx`", + ); + } + + /// A hand-edited or corrupt value is not a crash and not a wrong row: the first row. + #[test] + fn an_unparseable_remembered_value_is_the_first_row() { + assert_eq!( + roster_policy_over(Some("Kerwind"), &["Kerwind", "Xero"], None), + Some(0), + ); + } + + /// **Every** roster re-applies the stored row, not just the session's first — the reference + /// reads the CVar inside the char-list rebuild (`0x4724d0`), so a live selection that was + /// never entered as does not survive a re-enum. This is the half that makes the memory mean + /// "who you last **logged in** as" rather than "the last row you clicked". + #[test] + fn a_re_enumerated_roster_returns_to_the_remembered_character() { + assert_eq!( + roster_policy_over(Some("0"), &["Kerwind", "Xero", "Zzbullone"], Some(2)), + Some(0), + "row 2 was only ever clicked; the roster rebuild goes back to the remembered row 0", + ); + } + + /// …but a just-created character still wins, which is the reference's precedence and the + /// order it comes in: the C side pushes the restored index into Lua first, and + /// `UpdateCharacterList`'s deferred `selectLast` flag overwrites it (B119 stays fixed). + #[test] + fn a_created_character_outranks_the_remembered_one() { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .init_resource::() + .insert_resource(CharPick(tx)) + .insert_resource(crate::cvars::CvarPersist::with_stored( + CVAR_LAST_CHARACTER, + "0", + )) + .add_message::() + .add_message::() + .add_systems(Update, apply_roster_policy); + // The create result landed before the fresh roster (the order `net::io` does NOT use — + // the one `apply_roster_policy` exists to answer). + app.world_mut() + .resource_mut::() + .note_created("Zzbullone".into()); + app.world_mut().write_message(CharListMessage { + characters: vec![ + character(1, "Kerwind"), + character(2, "Xero"), + character(3, "Zzbullone"), + ], + realm: None, + }); + app.update(); + assert_eq!( + app.world().resource::().selected(), + Some(2), + "SELECT_LAST_CHARACTER outranks the remembered row 0", + ); + } + + /// The base, in the one place it lives, and the value's own spelling for the first character. + #[test] + fn the_stored_index_is_zero_based_and_round_trips() { + for row in [0usize, 1, 4, 9] { + assert_eq!(last_character_row(&last_character_value(row)), Some(row)); + } + assert_eq!( + last_character_value(0), + "0", + "row 0 IS the registrar default" + ); + assert_eq!( + last_character_row("0"), + Some(0), + "0 is the first row, not 'none'" + ); + assert_eq!(last_character_row(""), None); + } + + /// **The write is at Enter World, not at selection** — the reference's `0x46b500` formats the + /// CVar from the index it is about to enter as, while clicks and arrow keys reach `0x472740` + /// and never touch it. So "last logged in", exactly as reported. + #[test] + fn only_entering_the_world_writes_the_cvar() { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut script = benilla_ui::script::UiScript::new().unwrap(); + script.register_cvars([(CVAR_LAST_CHARACTER, "0")]); + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .init_resource::() + .insert_resource(CharPick(tx)) + .insert_non_send_resource(script) + .add_systems(Update, persist_last_character); + { + let mut roster = app.world_mut().resource_mut::(); + roster.chars = vec![ + character(1, "Kerwind"), + character(2, "Xero"), + character(3, "Zz"), + ]; + roster.select(Some(2)); // clicked around the list… + roster.select(Some(1)); + } + app.update(); + assert!( + app.world_mut() + .non_send_resource_mut::() + .take_cvar_changes() + .is_empty(), + "a selection alone must NOT be remembered — the reference writes nothing here", + ); + + // …and now Enter World, on the row that was selected. + app.world_mut().resource_mut::().pending_pick = Some(2); + app.update(); + assert_eq!( + app.world_mut() + .non_send_resource_mut::() + .take_cvar_changes(), + vec![(CVAR_LAST_CHARACTER.to_string(), "1".to_string())], + "guid 2 sits at row 1, and the row is what rides the queue", + ); + } + + /// **The 1290 property, at this call site.** A login replaces the VM, and the memo of "the + /// table already says 1" must die with it — otherwise the mirror stays quiet against a table + /// that has never been told, and the memory survives only for as long as some *other* module + /// happens to carry the key across (`cvars::sync_cvars`'s saved-base seed does, today). + #[test] + fn a_replaced_vm_is_told_the_remembered_row_again() { + let (tx, _rx) = crossbeam_channel::unbounded(); + let fresh = || { + let mut s = benilla_ui::script::UiScript::new().unwrap(); + s.register_cvars([(CVAR_LAST_CHARACTER, "0")]); + s + }; + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .init_resource::() + .insert_resource(CharPick(tx)) + .insert_non_send_resource(fresh()) + .add_systems(Update, persist_last_character); + { + let mut roster = app.world_mut().resource_mut::(); + roster.chars = vec![character(1, "Kerwind"), character(2, "Xero")]; + roster.pending_pick = Some(2); + } + app.update(); + app.update(); // steady frames stay quiet — the memo does its job within one VM + + // The login edge: `ui_script::lifecycle` drops the VM and installs a boot VM in its place. + app.world_mut() + .insert_non_send_resource::(fresh()); + app.update(); + + assert_eq!( + app.world_mut() + .non_send_resource_mut::() + .take_cvar_changes(), + vec![(CVAR_LAST_CHARACTER.to_string(), "1".to_string())], + "the new VM's table must be told the row too — a memo that outlived the old one \ + would leave this table on its default and lose the memory at quit", + ); + } + + /// The write must survive the frame in which the host has not registered its table yet: an + /// engine write to an unregistered name is a deliberate silent no-op, so latching on it would + /// swallow the session's FIRST entry — the one launch-to-launch memory exists for. + #[test] + fn an_entry_made_before_the_cvar_table_exists_is_not_lost() { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .init_resource::() + .insert_resource(CharPick(tx)) + .insert_non_send_resource(benilla_ui::script::UiScript::new().unwrap()) + .add_systems(Update, persist_last_character); + { + let mut roster = app.world_mut().resource_mut::(); + roster.chars = vec![character(1, "Kerwind"), character(2, "Xero")]; + roster.pending_pick = Some(2); + } + + app.update(); // the table has no such name yet — the write is dropped + app.world_mut() + .non_send_resource_mut::() + .register_cvars([(CVAR_LAST_CHARACTER, "0")]); + app.update(); // ...and the next frame must still catch it up + + assert_eq!( + app.world_mut() + .non_send_resource_mut::() + .take_cvar_changes(), + vec![(CVAR_LAST_CHARACTER.to_string(), "1".to_string())], + ); + } + /// The reverse arrival order (result first, roster after) stays armed and is answered by the /// roster policy — and a name that still can't be found falls back to the LAST row, which is the /// ref's literal `SELECT_LAST_CHARACTER` → `SelectCharacter(numChars)` and the same character diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 6ba737d4..4713fb1d 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -86,7 +86,7 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[ // clip. Registered so the fix can be A/B'd live against the defect it fixes. ("SoundOutputLimiter", "1"), ("uiScale", "0.9"), - ("farclip", "777"), + ("farclip", "350"), // The Controls-page trio (0961). `deselectOnClick`/`mouseInvertPitch` are 1.12's own // Interface Options CVars (UIOptionsFrame.lua indices 45/1); their defaults are the // reference behaviors benilla already shipped (empty-world click clears the target; no @@ -219,6 +219,19 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[ // ~1.6 ms at 1600×900, 7.6 ms at 4K, per frame while a body pane is open — and the director // retested the 30 fps doll as fine. Full-rate is one `SetCVar("boothHalfRate", 0)` away. ("boothHalfRate", "1"), + // The select screen's memory of who you last entered the world as (decision 1622) — 1.12's + // own `lastCharacterIndex`, help string "Last character selected". **No host knob**: the live + // value is the character screen's own state ([`crate::char_select::Roster::pending_index`]), + // which this row only mirrors — the `statusBarText` posture, and why the arm in + // [`apply_to_knobs`] is empty. + // + // Registered **"0"**, byte-read rather than chosen: `CVar::Register` at `0x402d93` pushes + // default string `0x82e570` = "0", category 4, and caches the CVar* at `[0x882674]`. The value + // is a **0-based** row (the engine's selection cell `[0x83856c]` under `"%d"`), so "0" is the + // FIRST character and not a "no memory" sentinel — which is exactly why a stock `Config.wtf` + // has no such line until you have played somebody other than your first character + // (`SaveConfig 0x63d980` skips values equal to their default; `compose_file` does the same). + (crate::char_select::CVAR_LAST_CHARACTER, "0"), ]; /// `config.toml`'s shape: a `[cvars]` table of `Name = "value"` strings (CVars are strings in @@ -250,16 +263,38 @@ pub(crate) struct CvarPersist { } impl CvarPersist { - /// The persisted `checkAddonVersion` (decision 1292) — what the addon load walk gates on. - /// Read from the persist state rather than the VM because the walk runs while the VM's CVar - /// table does not exist yet (registration is a per-VM `Update` seed, 1291); the 1291 fold - /// keeps this current across reloads, so it is the value the reference's live read would see. - /// Absent = the registrar default: check ON. - pub(crate) fn addon_version_check(&self) -> bool { + /// One CVar as `config.toml` holds it — matched case-insensitively, so a hand-edited + /// spelling still answers. + /// + /// Read from the persist state rather than from the VM's table for the callers that want a + /// value **before, or outside, a registered table**: the addon load walk runs while the VM's + /// CVar table does not exist yet (registration is a per-VM `Update` seed, 1291), and the + /// select screen wants its remembered row the moment a roster lands, from a system that has + /// no business holding the VM (1622). The 1291 fold keeps this current across VM + /// replacements, so it is the value the reference's live read would see. + pub(crate) fn stored(&self, name: &str) -> Option<&str> { self.file .iter() - .find(|(k, _)| k.eq_ignore_ascii_case("checkAddonVersion")) - .is_none_or(|(_, v)| v != "0") + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + } + + /// A persist state that already holds one stored value — a launch whose `config.toml` said + /// so, without a file. `#[cfg(test)]` and `pub(crate)` because [`Self::file`] is private: + /// `char_select`'s restore test drives the real [`apply_roster_policy`] over a real remembered + /// row rather than a copy of its logic (the `Roster::with_pending_pick` posture). + #[cfg(test)] + pub(crate) fn with_stored(name: &str, value: &str) -> Self { + Self { + file: BTreeMap::from([(name.to_string(), value.to_string())]), + ..Self::default() + } + } + + /// The persisted `checkAddonVersion` (decision 1292) — what the addon load walk gates on. + /// Absent = the registrar default: check ON. + pub(crate) fn addon_version_check(&self) -> bool { + self.stored("checkAddonVersion").is_none_or(|v| v != "0") } } @@ -431,6 +466,10 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool { // and the gate reads the live table — but a KNOWN key, so a toggle dirties the config // and persists (the statusBarText posture). "checkaddonversion" => {} + // The remembered character row (1622) — same posture again: the live value is the select + // screen's own, which writes this key rather than reading it back. Known, so entering the + // world dirties the config and the memory survives to the next launch. + "lastcharacterindex" => {} // Vertical Sync — a flag like every other checkbox here. `video::apply_present_mode` // watches the value and pushes it to the window; nothing else reads it. "gxvsync" => knobs.video.vsync = v != 0.0, @@ -783,8 +822,8 @@ mod tests { assert!(!sound.reverb, "zone reverb ships off (decision 1153)"); assert_eq!(d["uiScale"], DEFAULT_UI_SCALE); // ViewDistance::default() reads $WOW_FARCLIP; the registered default mirrors the - // env-less 777 literal (view.rs doc: "Default 777"). - assert_eq!(d["farclip"], 777.0); + // env-less 350 literal (view.rs doc: "Default 350" — the reference's own, 1624). + assert_eq!(d["farclip"], 350.0); // The Controls trio (0961) welds to its knob Defaults the same way. assert_eq!( d["deselectOnClick"] != 0.0, @@ -859,7 +898,7 @@ mod tests { fn apply_parses_clamps_and_reports_unknowns() { let mut sound = SoundConfig::default(); let mut scale = UiScaleCvar(0.9); - let mut view = ViewDistance { farclip: 777.0 }; + let mut view = ViewDistance { farclip: 350.0 }; let mut look = LookConfig::default(); let mut click = ClickConfig::default(); let mut loot = LootConfig::default(); @@ -1002,7 +1041,7 @@ mod tests { ("MusicVolume".into(), "0.7".into(), "0.4".into()), // moved: written ("MasterVolume".into(), "1".into(), "1".into()), // default: absent ("uiScale".into(), "1.2".into(), "0.9".into()), // env value: file keeps 0.8 - ("farclip".into(), "777".into(), "777".into()), // back to default: removed + ("farclip".into(), "350".into(), "350".into()), // back to default: removed ]; let out = compose_file(&previous, &env, &snapshot); assert_eq!(out.get("MusicVolume").map(String::as_str), Some("0.7")); @@ -1033,26 +1072,7 @@ mod tests { ) .unwrap(); - let mut app = App::new(); - app.add_plugins(bevy::MinimalPlugins) - .insert_resource(SoundConfig::default()) - .insert_resource(UiScaleCvar(DEFAULT_UI_SCALE)) - .insert_resource(ViewDistance { farclip: 777.0 }) - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .add_plugins(CvarPlugin); - app.insert_non_send_resource(UiScript::new().unwrap()); + let mut app = cvar_app(); // Startup: the file's MusicVolume reaches the knob; Update: the VM table seeds from it. app.update(); @@ -1084,6 +1104,114 @@ mod tests { std::fs::remove_dir_all(&tmp).ok(); } + /// A client whose CVar host is real: every knob resource the [`KnobParams`] census wants, + /// [`CvarPlugin`] itself, and a VM for the table to live in. The three end-to-end tests below + /// each stand a whole client up, and the census is one row per knob — copied per test, adding + /// a knob meant editing every copy. + fn cvar_app() -> App { + let mut app = App::new(); + app.add_plugins(bevy::MinimalPlugins) + .insert_resource(SoundConfig::default()) + .insert_resource(UiScaleCvar(DEFAULT_UI_SCALE)) + .insert_resource(ViewDistance { farclip: 350.0 }) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_plugins(CvarPlugin); + app.insert_non_send_resource(UiScript::new().unwrap()); + app + } + + /// **The reported bug, end to end** (decision 1622): "char screen doesn't remember the last + /// logged in char, the ref does". Two launches over one `benilla-config/`, with the real + /// [`CvarPlugin`] and the real [`crate::char_select`] systems in between — entering the world + /// as somebody has to survive the quit and bring the screen back to them. + /// + /// The seam this covers and the per-module tests cannot: `set_cvar_engine`'s queued change is + /// only *persisted* if [`apply_to_knobs`] answers `true` for the name. A knobless CVar that + /// falls through to `_ => return false` reaches the VM's table, reads back correctly all + /// session, and is silently dropped at the save — which is this bug again, one layer down. + #[test] + fn entering_the_world_survives_the_quit_and_comes_back_selected() { + use crate::char_select::{ClientState, Roster}; + use crate::local_state::test_env::{EnvGuard, ENV_LOCK}; + let _l = ENV_LOCK.lock().unwrap(); + let tmp = std::env::temp_dir().join(format!("benilla-lastchar-{}", std::process::id())); + std::fs::remove_dir_all(&tmp).ok(); + let _c = EnvGuard::unset("WOW_CAPTURE"); + let _u = EnvGuard::unset("WOW_UI_SCALE"); + let _f = EnvGuard::unset("WOW_FARCLIP"); + let _d = EnvGuard::unset("WOW_CLUTTER_DENSITY"); + let _w = EnvGuard::unset("WOW_CHAR"); + let _s = EnvGuard::unset("WOW_CHARSELECT_PICK"); + let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap()); + let roster = || { + (1..=4) + .map(|g| crate::char_select::test_character(g, &format!("Char{g}"))) + .collect::>() + }; + + // ── Launch 1: the roster lands, and the player enters the world as the third row. ──── + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut app = cvar_app(); + app.add_plugins(bevy::state::app::StatesPlugin); + crate::char_select::add_test_systems(&mut app, tx); + app.update(); // Startup loads the (absent) file; the first Update seeds the VM table + app.world_mut().write_message(crate::net::CharListMessage { + characters: roster(), + realm: None, + }); + app.update(); + assert_eq!( + app.world().resource::().selected(), + Some(0), + "nothing remembered yet, so the first row — the behaviour that was already right", + ); + app.world_mut().resource_mut::().pending_pick = Some(3); // guid 3 = row 2 + app.update(); + app.world_mut().write_message(AppExit::Success); + app.update(); + + let text = std::fs::read_to_string(tmp.join("config.toml")).unwrap(); + assert!( + text.contains("lastCharacterIndex = \"2\""), + "entering the world must reach the file, 0-based like Config.wtf:\n{text}" + ); + + // ── Launch 2: a fresh client over the same folder, and the roster arrives. ─────────── + let (tx, _rx2) = crossbeam_channel::unbounded(); + let mut app = cvar_app(); + app.add_plugins(bevy::state::app::StatesPlugin); + crate::char_select::add_test_systems(&mut app, tx); + app.update(); + app.world_mut().write_message(crate::net::CharListMessage { + characters: roster(), + realm: None, + }); + app.update(); + + assert_eq!( + app.world().resource::().selected(), + Some(2), + "the second launch must stand the SAME character on the stage — the whole report", + ); + assert_eq!( + *app.world().resource::>().get(), + ClientState::CharSelect, + ); + std::fs::remove_dir_all(&tmp).ok(); + } + /// The minimap's zoom rides the same loop, driven from the **engine** rather than a Lua /// `SetCVar` (decision 1131): the `+`/`-` buttons call `Minimap:SetZoom`, which writes the live /// index and its CVar together — and that has to reach the knob and the file exactly like a @@ -1106,26 +1234,7 @@ mod tests { ) .unwrap(); - let mut app = App::new(); - app.add_plugins(bevy::MinimalPlugins) - .insert_resource(SoundConfig::default()) - .insert_resource(UiScaleCvar(DEFAULT_UI_SCALE)) - .insert_resource(ViewDistance { farclip: 777.0 }) - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .add_plugins(CvarPlugin); - app.insert_non_send_resource(UiScript::new().unwrap()); + let mut app = cvar_app(); app.update(); // Startup restored the knob, and the VM's table answers with it — which is what the UI-load diff --git a/crates/benilla-app/src/entities/attach/char_skin.rs b/crates/benilla-app/src/entities/attach/char_skin.rs index d681c425..d87c7d70 100644 --- a/crates/benilla-app/src/entities/attach/char_skin.rs +++ b/crates/benilla-app/src/entities/attach/char_skin.rs @@ -309,7 +309,14 @@ pub(super) fn build_char_skin_materials( worn, ) .ok()??; - let handle = images.add(repeat_texture_authored(composed, (true, true))); + // Through the upload gate like every other texture: a composite is + // layered texel-by-texel on the CPU so it is already RGBA8, and + // `for_upload` is a no-op on it — but going through it is what makes + // the format and the bytes provably agree (decision 1626). + let handle = images.add(repeat_texture_authored( + benilla_assets::for_upload(composed), + (true, true), + )); skin_cache.insert(key, handle.clone()); Some(handle) } diff --git a/crates/benilla-app/src/hover_log.rs b/crates/benilla-app/src/hover_log.rs index 4458a4c2..5c480eb3 100644 --- a/crates/benilla-app/src/hover_log.rs +++ b/crates/benilla-app/src/hover_log.rs @@ -84,7 +84,7 @@ impl Plugin for HoverLogPlugin { let _ = writeln!( out, "frame_ms,cpu_ms,tick_us,resolve_us,measure_us,extract_us,convert_us,\ - diff_us,quads,solves,skipped,measured,measured_texts,tip_owner,tip_lines,\ + diff_us,quads,solves,derives,skipped,measured,measured_texts,tip_owner,tip_lines,\ tip_first_line,spliced" ); info!("hover log: recording every frame to {path}"); @@ -155,7 +155,7 @@ fn record( .unwrap_or_else(|| (String::new(), 0, String::new())); let _ = writeln!( rec.out, - "{frame_ms:.3},{cpu_ms:.3},{},{},{},{},{},{},{},{},{},{},{},{},{lines},{},{}", + "{frame_ms:.3},{cpu_ms:.3},{},{},{},{},{},{},{},{},{},{},{},{},{},{lines},{},{}", c.tick, c.resolve, c.measure, @@ -164,6 +164,7 @@ fn record( c.diff, c.quads, c.solves, + c.derives, u8::from(c.skipped), c.measured, cell(&c.measured_texts.join(" ⏎ ")), @@ -210,6 +211,11 @@ fn summarize(label: &str, rows: &[&Row]) -> String { // extract gate skipped the conversion+rasterize loop outright. #[allow(clippy::cast_precision_loss)] let solves = rows.iter().map(|r| r.cost.solves as f64).sum::() / f64::from(n); + // The term `solves` hides (decision 1625): a solve that had to DERIVE the layout graph first + // costs an order of magnitude more than one that used the ledger, and both count as one solve. + // The law is zero — anything else is a write site that gave up naming its node, which + // `WOW_LAYOUT_DERIVE_TRACE=:` will backtrace on a live run. + let derives = rows.iter().map(|r| r.cost.derives as f64).sum::() / f64::from(n); let skips = 100.0 * rows.iter().filter(|r| r.cost.skipped).count() as f32 / n; // Counted against the DROPPED threshold, not the raw budget: synced wall time rails at the // display's present grant and jitters around it, so counting frames over 16.7 ms mostly counts @@ -222,7 +228,7 @@ fn summarize(label: &str, rows: &[&Row]) -> String { " {label:<34} n={:<6} wall p50={:>6.2} p99={:>6.2} max={:>7.2} cpu p50={:>6.2} \ p99={:>6.2} dropped={:>5.1}%\n ui μs: tick={:>6.0} resolve={:>6.0} \ measure={:>5.0} extract={:>6.0} convert={:>6.0} diff={:>5.0}\n solves/frame={:.2} \ - reshaped/frame={:.2} extract-gate-skipped={:.0}%", + derives/frame={:.2} reshaped/frame={:.2} extract-gate-skipped={:.0}%", rows.len(), pct(&wall, 0.50), pct(&wall, 0.99), @@ -237,6 +243,7 @@ fn summarize(label: &str, rows: &[&Row]) -> String { ui_us(|c| c.convert), ui_us(|c| c.diff), solves, + derives, measured, skips, ) @@ -303,20 +310,20 @@ fn parse_row(line: &str) -> Option { } } fields.push(cur); - if fields.len() < 16 { + if fields.len() < 17 { return None; } let num = |i: usize| fields[i].parse::().ok(); - let owner = fields[13].clone(); + let owner = fields[14].clone(); Some(Row { frame_ms: num(0)? as f32, cpu_ms: num(1)? as f32, cost: UiFrameCost { - measured: num(11)? as usize, - measured_texts: if fields[12].is_empty() { + measured: num(12)? as usize, + measured_texts: if fields[13].is_empty() { Vec::new() } else { - fields[12].split(" ⏎ ").map(str::to_string).collect() + fields[13].split(" ⏎ ").map(str::to_string).collect() }, tick: num(2)? as u128, resolve: num(3)? as u128, @@ -326,14 +333,15 @@ fn parse_row(line: &str) -> Option { diff: num(7)? as u128, quads: num(8)? as usize, solves: num(9)? as u64, - skipped: num(10)? != 0.0, + derives: num(10)? as u64, + skipped: num(11)? != 0.0, // Appended column (absent in pre-splice recordings — those read back as 0). - spliced: fields.get(16).and_then(|f| f.parse().ok()).unwrap_or(0), + spliced: fields.get(17).and_then(|f| f.parse().ok()).unwrap_or(0), }, tip: if owner.is_empty() { None } else { - Some((owner, num(14)? as i64, fields[15].clone())) + Some((owner, num(15)? as i64, fields[16].clone())) }, }) } diff --git a/crates/benilla-app/src/portrait/framing.rs b/crates/benilla-app/src/portrait/framing.rs index 48b94438..e7609f0f 100644 --- a/crates/benilla-app/src/portrait/framing.rs +++ b/crates/benilla-app/src/portrait/framing.rs @@ -51,8 +51,9 @@ pub(super) fn diag_to_vert(fov: f32, aspect: f32) -> f32 { /// It is the client's own reference frame, not a guess. The Lua screen is `768a × 768` /// (wow-re `ui/scratch/modelframe-camera-law.md` §12.1), so at the era's default `a = 4/3` the /// design space is exactly `1024×768` — the resolution every `UI_` diorama and its camera-0 -/// framing were authored against. -pub(super) const GLUE_AUTHORED_ASPECT: f32 = 4.0 / 3.0; +/// framing were authored against. One definition, in the crate that also measures the art +/// against it ([`benilla_formats::glue_art_extent`]). +pub(super) use benilla_formats::{ArtExtent, GLUE_AUTHORED_ASPECT}; /// The **glue scene's** vertical opening angle — the authored 4:3 view box, never cropped. /// @@ -77,18 +78,104 @@ pub(super) const GLUE_AUTHORED_ASPECT: f32 = 4.0 / 3.0; /// narrow window opens upward rather than cropping the diorama's sides. Continuous at `4/3`, where /// both legs give `0.6·fov`. /// +/// **…as far as the art goes** (decision 1619, B330). Every diorama is finite — a sky card of some +/// authored width, a ground that stops — and 1587's hor+ had no ceiling: `UI_MainMenu` runs out +/// of backdrop before 16:9 and the frame past its slanted edges was the clear colour; `UI_Tauren` +/// runs out at ~2.24:1. **Not one of the seven scenes is drawn wider than about 3:2** +/// ([`benilla_formats::shipped_glue_art_extent`], measured per texel off the chain: MainMenu 1.54, +/// Human 1.47, Orc 1.57, Dwarf 1.43, NightElf 1.45, Scourge 1.57, Tauren 1.46 — 1587's "no void at +/// 21:9 on `UI_Human`" held only because the create screen's side panels stand exactly over it). +/// So `art` bounds both legs: the wide leg grows the width only to `art.half_w` and past it +/// **holds the width and closes the vertical** — the zoom the reference does everywhere, done +/// only where the art forces it — and the narrow leg does the same with `art.half_h`. The +/// authored box itself is never given up (`max(h0)` / `max(t0)`): a scene whose art does not fill +/// 4:3 is the art's problem, not a reason to zoom past the composition. `None` is 1587's +/// unbounded law. +/// +/// **…and never tighter than the reference at 16:9.** Holding the width zooms faster than the +/// reference's diagonal law, so left alone it re-crops the character at 21:9 (B242, the report +/// 1587 closed). The zoom has a floor ([`glue_zoom_floor`]): the reference's own 16:9 opening — +/// the tightest framing every race has been played at, on the most common panel, for fifteen +/// years, an empirical bound on "the character still fits", not a mechanism. (A floor of "what +/// 16:9 needs to reach this scene's art" was tried and clipped the night elf male's hair and +/// boots at 16:9 — 8% past the reference is past what fits.) Past the aspect where the floor's +/// opening cannot fill the art's width, the scene is **pillarboxed** — rendered at that aspect, +/// centred, black either side — which is what the modern client does past 16:9 and the only +/// honest answer once the art is narrower than the window: not void (B330), not a crop (B242), +/// a frame. +/// +/// Where this lands: at 16:9 every scene but one zooms to within a few percent of the reference's +/// own `0.49·fov`, by construction rather than by a constant, and fills the window; the night +/// elves' stage — its sky card exactly 4:3 wide, so the box itself is its ceiling — is boxed at +/// 1.65:1 there, ~3.6% of the width each side, where the reference shows black corners past the +/// same card. At 21:9 the reference cropped +/// and 1587 showed void, and every scene now shows its 16:9 framing with bars (~9–13% of the +/// width, per scene). At 4:3 nothing changes: byte-identical. +/// /// Scoped to the full-screen glue scene on purpose. A `` pane renders into a fixed /// rect that is *not* the window, so its crop is its own `W/H` and stays verbatim ([`diag_to_vert`], /// decisions 1089/1543). -pub(super) fn glue_scene_vert_fov(fov: f32, window_aspect: f32) -> f32 { +pub(super) fn glue_scene_framing( + fov: f32, + window_aspect: f32, + art: Option, +) -> GlueFraming { let authored = diag_to_vert(fov, GLUE_AUTHORED_ASPECT); - if window_aspect >= GLUE_AUTHORED_ASPECT || window_aspect <= 0.0 { - authored - } else { - // Hold the authored *horizontal* half-extent `tan(authored/2)·(4/3)` and solve for the - // vertical that a narrower window needs to keep it. - 2.0 * ((authored * 0.5).tan() * GLUE_AUTHORED_ASPECT / window_aspect).atan() + let full = |vert_fov: f32| GlueFraming { + vert_fov, + viewport_aspect: None, + }; + if window_aspect <= 0.0 { + return full(authored); // a degenerate (mid-resize) window: the authored opening, finite } + let t0 = (authored * 0.5).tan(); // the authored vertical half-extent + let h0 = t0 * GLUE_AUTHORED_ASPECT; // …and horizontal + if window_aspect >= GLUE_AUTHORED_ASPECT { + // hor+: the authored vertical opening, until the width reaches the art's edge; past it + // the width is held there and the vertical closes — down to the 16:9 floor, past which + // the window is wider than the framing can honestly fill: pillarbox. + let ceiling = art.map_or(f32::INFINITY, |a| a.half_w.max(h0)); + let half_w = (t0 * window_aspect).min(ceiling); + let floor = glue_zoom_floor(fov); + let half_h = half_w / window_aspect; + if half_h >= floor { + full(2.0 * half_h.atan()) + } else { + GlueFraming { + vert_fov: 2.0 * floor.atan(), + viewport_aspect: Some(half_w / floor), + } + } + } else { + // The mirror: hold the authored horizontal half-extent and open upward, until the art's + // top/bottom edge; past it the height is held and the sides crop — the stage's edges, + // never the character, so no floor and no bars. + let ceiling = art.map_or(f32::INFINITY, |a| a.half_h.max(t0)); + let half_h = (h0 / window_aspect).min(ceiling); + full(2.0 * half_h.atan()) + } +} + +/// What [`glue_scene_framing`] decided for one window. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct GlueFraming { + /// The booth camera's vertical opening (radians). + pub(super) vert_fov: f32, + /// `Some(aspect)`: render into a centred viewport of this aspect instead of the whole window + /// (pillarbox — the window is wider than the art can fill at the zoom floor). `None`: the + /// whole window. + pub(super) viewport_aspect: Option, +} + +/// The panel the reference was played on without complaint for fifteen years — the aspect whose +/// reference framing is the zoom floor, and the one the modern client fills before it boxes. +pub(super) const REFERENCE_PANEL: f32 = 16.0 / 9.0; + +/// The **zoom floor** — the vertical half-extent (tan units) the glue scene is never framed +/// tighter than: the reference's own opening at [`REFERENCE_PANEL`], `tan(½·fov/√((16/9)²+1))`. +/// See [`glue_scene_framing`]. +pub(super) fn glue_zoom_floor(fov: f32) -> f32 { + (diag_to_vert(fov, REFERENCE_PANEL) * 0.5).tan() } /// The real client's portrait/model **projection**: gxumath `0x5c3cc0`, a *diagonal-FOV* @@ -679,9 +766,9 @@ mod tests { /// every window shape, instead of spending height on width the way the reference's /// `aspect = a_screen` does. /// - /// The three numbers that matter: the authored `0.6·fov` survives at 4:3, at 16:9 and at the - /// 3440×1440 panel the report came from — where the un-pinned law gives `0.386·fov`, a 1.55× - /// zoom, and the character's head and feet leave the frame. + /// Unbounded (no art): the authored `0.6·fov` survives at 4:3, at 16:9 and at the 3440×1440 + /// panel the report came from — where the un-pinned law gives `0.386·fov`, a 1.55× zoom, and + /// the character's head and feet leave the frame. #[test] fn the_glue_scene_holds_its_authored_vertical_on_every_wide_window() { let close = |a: f32, b: f32| (a - b).abs() < 5e-4; @@ -697,9 +784,10 @@ mod tests { 3440.0 / 1440.0, 32.0 / 9.0, ] { + let f = glue_scene_framing(1.0, wide, None); assert!( - close(glue_scene_vert_fov(1.0, wide), authored), - "a{wide} must keep the authored vertical opening" + close(f.vert_fov, authored) && f.viewport_aspect.is_none(), + "a{wide} must keep the authored vertical opening, unboxed" ); } // What we are NOT doing any more — the reference's own numbers, kept here so the size of @@ -710,7 +798,7 @@ mod tests { /// The other side of the same clamp: a window NARROWER than 4:3 opens upward rather than /// cropping the diorama's sides, so the authored horizontal half-extent is the invariant there. - /// Continuous at 4:3, where both legs of [`glue_scene_vert_fov`] agree. + /// Continuous at 4:3, where both legs of [`glue_scene_framing`] agree. #[test] fn a_narrow_glue_window_holds_its_authored_width_instead() { let half_width = |vert: f32, a: f32| (vert * 0.5).tan() * a; @@ -719,16 +807,151 @@ mod tests { GLUE_AUTHORED_ASPECT, ); for narrow in [5.0 / 4.0, 1.0, 3.0 / 4.0] { - let got = half_width(glue_scene_vert_fov(1.0, narrow), narrow); + let got = half_width(glue_scene_framing(1.0, narrow, None).vert_fov, narrow); assert!( (got - authored).abs() < 5e-4, "a{narrow}: authored half-width {authored} vs {got}" ); } // …and it never *narrows* the view: a tall window sees more, never less. - assert!(glue_scene_vert_fov(1.0, 1.0) > glue_scene_vert_fov(1.0, GLUE_AUTHORED_ASPECT)); + assert!( + glue_scene_framing(1.0, 1.0, None).vert_fov + > glue_scene_framing(1.0, GLUE_AUTHORED_ASPECT, None).vert_fov + ); // A degenerate window (zero height, mid-resize) must not produce a NaN fov. - assert!(glue_scene_vert_fov(1.0, 0.0).is_finite()); + assert!(glue_scene_framing(1.0, 0.0, None).vert_fov.is_finite()); + } + + /// The art ceiling (decision 1619): with a measured extent the wide leg widens exactly to the + /// art's edge, past it holds that width and closes the vertical, and once the vertical would + /// close below the reference's 16:9 opening the window is pillarboxed at the aspect that + /// opening fills the art at. At and below the art's aspect nothing differs from 1587's law. + #[test] + fn a_wide_window_widens_to_the_arts_edge_then_zooms_then_pillarboxes() { + let close = |a: f32, b: f32| (a - b).abs() < 5e-4; + let authored = diag_to_vert(1.0, GLUE_AUTHORED_ASPECT); + let t0 = (authored * 0.5).tan(); + // Art that runs out of width at exactly 3:2 (the login gate's shape), tall enough never + // to bind. + let art = Some(ArtExtent { + half_w: t0 * 1.5, + half_h: 10.0, + }); + let half_width = + |f: GlueFraming, a: f32| (f.vert_fov * 0.5).tan() * f.viewport_aspect.unwrap_or(a); + // Up to 3:2 the vertical opening is the authored one — identical to the unbounded law. + for a in [GLUE_AUTHORED_ASPECT, 1.4, 1.5] { + let f = glue_scene_framing(1.0, a, art); + assert!( + close(f.vert_fov, authored) && f.viewport_aspect.is_none(), + "a{a}" + ); + } + // Past it the width is pinned at the art's edge and the vertical closes: the zoom. At + // 16:9 that lands beside the reference's own diagonal-law number (0.490) — a 3:2 edge + // gives 2·atan(1.5·t0/(16/9)) — within a few percent, and no constant says so. + let f = glue_scene_framing(1.0, 16.0 / 9.0, art); + assert!(f.viewport_aspect.is_none(), "16:9 fills the window"); + assert!(close(half_width(f, 16.0 / 9.0), t0 * 1.5)); + assert!( + f.vert_fov < authored && (f.vert_fov - 0.490_26).abs() < 0.03, + "{}", + f.vert_fov + ); + assert!(f.vert_fov >= 2.0 * glue_zoom_floor(1.0).atan() - 1e-6); + // Wider still, the zoom would pass the reference's 16:9 opening: the floor holds it + // there and the scene is boxed at the aspect the floor fills the art at (1.5·t0 / floor), + // the same box on any wider window. + let floor = glue_zoom_floor(1.0); + assert!(close(floor, (diag_to_vert(1.0, 16.0 / 9.0) * 0.5).tan())); + for a in [3440.0 / 1440.0, 32.0 / 9.0] { + let f = glue_scene_framing(1.0, a, art); + assert!( + close((f.vert_fov * 0.5).tan(), floor), + "a{a} sits on the floor" + ); + let boxed = f.viewport_aspect.expect("pillarboxed"); + assert!(close(boxed, t0 * 1.5 / floor), "a{a}: box {boxed}"); + assert!(boxed < a, "the box is narrower than the window"); + assert!( + close(half_width(f, a), t0 * 1.5), + "…and still ends at the art" + ); + } + // Wide art (a scene drawn out to 21:9) never binds: 1587's answer, unchanged. + let wide_art = Some(ArtExtent { + half_w: t0 * 4.0, + half_h: 10.0, + }); + let f = glue_scene_framing(1.0, 3440.0 / 1440.0, wide_art); + assert!(close(f.vert_fov, authored) && f.viewport_aspect.is_none()); + // Art narrower than 16:9 can reach at the reference's opening (the night elves' sky, a + // 4:3 card — 1.31, inside the authored box, so the box's own width is the ceiling): the + // floor holds and 16:9 itself is boxed, at the aspect the floor fills the box at — the + // same box on any wider window. + let narrow_art = Some(ArtExtent { + half_w: t0 * 1.31, + half_h: 10.0, + }); + let h0 = t0 * GLUE_AUTHORED_ASPECT; + for a in [16.0 / 9.0, 3440.0 / 1440.0] { + let f = glue_scene_framing(1.0, a, narrow_art); + assert!( + close((f.vert_fov * 0.5).tan(), floor), + "a{a} sits on the floor" + ); + let boxed = f.viewport_aspect.expect("boxed"); + assert!((boxed - h0 / floor).abs() < 2e-3, "a{a}: box {boxed}"); + } + // …and 16:10 is not: the box's width still fills it above the floor. + assert!(glue_scene_framing(1.0, 1.6, narrow_art) + .viewport_aspect + .is_none()); + } + + /// The authored box is never given up: art that does not even reach it (a `0.0` extent, or + /// one inside 4:3) leaves the 4:3 framing exactly where 1587 put it — on both legs. + #[test] + fn art_inside_the_authored_box_never_zooms_past_the_composition() { + let close = |a: f32, b: f32| (a - b).abs() < 5e-4; + let authored = diag_to_vert(1.0, GLUE_AUTHORED_ASPECT); + let none = Some(ArtExtent { + half_w: 0.0, + half_h: 0.0, + }); + assert!(close( + glue_scene_framing(1.0, GLUE_AUTHORED_ASPECT, none).vert_fov, + authored + )); + assert!(close(glue_scene_framing(1.0, 1.0, none).vert_fov, authored)); + // At 16:9 with no art past the box, the box's own width is held — and holding 4:3's + // width in a 16:9 window closes the vertical past the reference's 16:9 opening, so the + // floor takes it: the authored box at that opening, boxed. + let f = glue_scene_framing(1.0, 16.0 / 9.0, none); + let t0 = (authored * 0.5).tan(); + let floor = glue_zoom_floor(1.0); + assert!(close((f.vert_fov * 0.5).tan(), floor)); + let boxed = f.viewport_aspect.expect("boxed"); + assert!( + (boxed - t0 * GLUE_AUTHORED_ASPECT / floor).abs() < 2e-3, + "box {boxed}" + ); + // 16:10 still fills: 4:3's width there is above the floor. + assert!(glue_scene_framing(1.0, 1.6, none).viewport_aspect.is_none()); + // …and a narrow window with a bounded height holds that height, cropping the sides. + let short = Some(ArtExtent { + half_w: 10.0, + half_h: t0 * 1.1, + }); + let v = glue_scene_framing(1.0, 0.75, short).vert_fov; + assert!( + close((v * 0.5).tan(), t0 * 1.1), + "height held at the art's edge" + ); + assert!( + glue_scene_framing(1.0, 0.75, None).vert_fov > v, + "…below the unbounded law's" + ); } /// **The portrait bake's projection, pinned against the real client's own GL stream** diff --git a/crates/benilla-app/src/portrait/glue_booth.rs b/crates/benilla-app/src/portrait/glue_booth.rs index a995e68a..5141c2ce 100644 --- a/crates/benilla-app/src/portrait/glue_booth.rs +++ b/crates/benilla-app/src/portrait/glue_booth.rs @@ -25,7 +25,10 @@ use crate::entities::Creatures; use benilla_assets::m2_url; use benilla_assets::materials::WowModelMaterial; -use super::framing::{attachment_point, diag_to_vert, glue_scene_vert_fov, PORTRAIT_ASPECT}; +use super::framing::{ + attachment_point, diag_to_vert, glue_scene_framing, ArtExtent, GLUE_AUTHORED_ASPECT, + PORTRAIT_ASPECT, +}; use super::{ aim, body_frame, new_target_image, spawn_booth_effects, spawn_booth_model, Booth, BoothBillboardSpec, BoothCam, BoothEffects, BoothLight, BoothMotion, BoothPart, BoothRider, @@ -193,6 +196,14 @@ pub(crate) struct CreateScene { spawned: bool, /// The authored camera 0, captured at spawn (drives the booth camera while the scene shows). cam: Option, + /// How far the scene's art paints around that camera — the shipped scene's measured extent + /// ([`benilla_formats::shipped_glue_art_extent`], decision 1619) — the ceiling on the framing + /// law's widening ([`glue_scene_framing`]). `None` with no scene. + art: Option, + /// `Some(aspect)` while the framing law pillarboxes the scene (the window is wider than the + /// art can fill at the zoom floor): the booth camera renders into a centred viewport of this + /// aspect, black either side ([`pillarbox_glue_scene`]). `None`: the whole window. + viewport_aspect: Option, /// The character's stage spot — scene attachment 0 (Bevy model space), `ZERO` with no scene. /// (Verified for select too: the body seats on attachment **0**, `0x473039` — attachment 1 is /// the enum's secondary model, wow-re `glue-select-model.md` TU-B; 0429's attachment-1 @@ -491,6 +502,16 @@ pub(super) fn spawn_glue_booth( // composites the character straight over the page, the ref's model-in-the-scene look. // FfxGlow's combine carries the scene alpha through for exactly this. clear_color: ClearColorConfig::Custom(Color::NONE), + // The OUTPUT clear — what the upscaling blit paints on the target OUTSIDE the + // camera's viewport, when the framing law boxes the scene (decision 1619, + // [`pillarbox_glue_scene`]). Bevy's default here is the global `ClearColor`, which + // the world's lighting sets to the day's fog colour every frame — so without this + // the bars came out Elwynn-sky blue. Black: a frame, not a page showing through. + // With no viewport the blit covers the whole target and this is never seen. + output_mode: bevy::camera::CameraOutputMode::Write { + blend_state: None, + clear_color: ClearColorConfig::Custom(Color::BLACK), + }, ..default() }, bevy::camera::RenderTarget::Image(image.clone().into()), @@ -549,6 +570,8 @@ pub(super) fn spawn_glue_booth( handle: None, spawned: false, cam: None, + art: None, + viewport_aspect: None, char_spot: Vec3::ZERO, char_facing: Quat::IDENTITY, char_scale: 1.0, @@ -610,6 +633,8 @@ pub(super) fn sync_glue_scene( scene.handle = None; scene.spawned = false; scene.cam = None; + scene.art = None; + scene.viewport_aspect = None; scene.char_spot = Vec3::ZERO; scene.char_facing = Quat::IDENTITY; scene.char_scale = 1.0; @@ -648,6 +673,8 @@ pub(super) fn sync_glue_scene( )))); scene.spawned = false; scene.cam = None; + scene.art = None; + scene.viewport_aspect = None; scene.char_spot = Vec3::ZERO; clear_pet(&mut commands, &mut scene); commands.entity(scene.root).despawn_related::(); @@ -786,6 +813,21 @@ pub(super) fn sync_glue_scene( scene_rig.finish(&mut commands); scene.spawned = true; scene.cam = model.camera0; + // The scene's art extent — the shipped table's transcription of what the tool measured + // off this very model (decision 1619). Said out loud as the aspects the law acts on. + scene.art = benilla_formats::shipped_glue_art_extent(token); + if let (Some(art), Some(cam)) = (scene.art, model.camera0.as_ref()) { + let t0 = benilla_formats::authored_half_height(cam.fov); + info!( + "create scene: UI_{token} art extent — half_w {:.4} (widens to {:.2}:1), half_h {:.4} \ + (opens to 1:{:.2}); boxes past {:.2}:1", + art.half_w, + art.half_w / t0, + art.half_h, + art.half_h / (t0 * GLUE_AUTHORED_ASPECT), + art.half_w.max(t0 * GLUE_AUTHORED_ASPECT) / super::framing::glue_zoom_floor(cam.fov), + ); + } scene.char_spot = stage; scene.char_facing = stage_facing; scene.char_scale = stage_scale; @@ -836,13 +878,17 @@ pub(super) fn sync_glue_scene( let up = Quat::from_axis_angle(fwd, cam.roll) * Vec3::Y; // The record fov is the client's *diagonal* convention. The scene is a FULL-SCREEN pane, so // the reference feeds that conversion the display's own aspect and spends height on width — - // a 1.55× zoom at 21:9 that crops head and feet (B242). [`glue_scene_vert_fov`] pins the + // a 1.55× zoom at 21:9 that crops head and feet (B242). [`glue_scene_framing`] pins the // authored 4:3 view box instead; its doc carries the law. Far kept generous: fog is not // rendered yet, so the authored far (27.8 on Orc) would slice unfogged geometry. + let framing = glue_scene_framing(cam.fov, aspect, scene.art); + if scene.viewport_aspect != framing.viewport_aspect { + scene.viewport_aspect = framing.viewport_aspect; + } let rig = ( Transform::from_translation(cam.eye).looking_at(cam.target, up), Projection::from(PerspectiveProjection { - fov: glue_scene_vert_fov(cam.fov, aspect), + fov: framing.vert_fov, near: cam.near, far: cam.far.max(1000.0), ..default() @@ -852,6 +898,46 @@ pub(super) fn sync_glue_scene( } } +/// Apply the framing law's **pillarbox** to the glue booth camera (decision 1619): while +/// [`CreateScene::viewport_aspect`] is set, the camera renders into a centred viewport of that +/// aspect on the window-sized target, and the target either side is the camera's black *output* +/// clear (see [`spawn_glue_booth`]) — a frame. Inside the box nothing changes: the scene's own +/// unpainted pixels stay transparent over the page, as they always were. Writes only on change — +/// a `Camera` write re-runs the camera system. +pub(super) fn pillarbox_glue_scene( + scene: Res, + window: Query<&Window, With>, + mut cams: Query<(&BoothCam, &mut Camera)>, +) { + let Ok(w) = window.single() else { + return; + }; + let (full_w, full_h) = (w.physical_width().max(1), w.physical_height().max(1)); + let viewport = scene.viewport_aspect.map(|aspect| { + let box_w = ((full_h as f32 * aspect).round() as u32).clamp(1, full_w); + bevy::camera::Viewport { + physical_position: UVec2::new((full_w - box_w) / 2, 0), + physical_size: UVec2::new(box_w, full_h), + ..default() + } + }); + for (booth, mut cam) in cams.iter_mut() { + if booth.0 != GLUE_SLOT { + continue; + } + let same = match (&cam.viewport, &viewport) { + (None, None) => true, + (Some(a), Some(b)) => { + a.physical_position == b.physical_position && a.physical_size == b.physical_size + } + _ => false, + }; + if !same { + cam.viewport = viewport.clone(); + } + } +} + /// Resize the booth's render target if it isn't already `w`×`h` (content is discarded — the next /// frame repaints it whole). fn resize_target(images: &mut Assets, target: &Handle, w: u32, h: u32) { diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index 1f710423..91975310 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -994,6 +994,8 @@ impl Plugin for PortraitPlugin { sync_petdoll_booth, glue_booth::sync_glue_booth, glue_booth::sync_glue_scene, + // After the scene's framing: the viewport/clear its law decided (1619). + glue_booth::pillarbox_glue_scene, // After the scene: the pet's seat and its light are the scene's to publish. glue_booth::sync_glue_pet, dressup::sync_dressup_booth, diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index fbf9d239..c5393d31 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -390,6 +390,7 @@ pub(super) fn drive_script( let printing = ui_cost_enabled(); let cost_on = printing || ui_cost_wanted.0; let solves_before = cost_on.then(|| script.layout_solves()); + let derives_before = cost_on.then(|| script.layout_derivations()); // The measure counters are PER FRAME: `measure_fontstrings` adds to them, and both publish // sites below carry them forward so the two measure passes sum into one frame's row. Nothing // else zeroes them, so without this the recorder's "re-shaped strings" column is a lifetime @@ -599,6 +600,7 @@ pub(super) fn drive_script( diff: 0, quads: quads.quads.len(), solves, + derives: script.layout_derivations() - derives_before.unwrap_or(0), skipped: true, spliced: 0, }; @@ -785,6 +787,7 @@ pub(super) fn drive_script( diff: 0, quads: quads.quads.len(), solves, + derives: script.layout_derivations() - derives_before.unwrap_or(0), skipped: false, spliced: changed.len(), }; @@ -913,6 +916,7 @@ pub(super) fn drive_script( diff: us_diff, quads: n_quads, solves, + derives: script.layout_derivations() - derives_before.unwrap_or(0), skipped: false, spliced: 0, }; diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 09b76cae..31e06694 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -118,6 +118,12 @@ pub(crate) struct UiFrameCost { pub(crate) diff: u128, pub(crate) quads: usize, pub(crate) solves: u64, + /// How many times this frame's resolve DERIVED the layout graph from scratch (decision 1388's + /// `layout_derivations`). The law is zero, and it is here because it was not: the recorder was + /// built for the hover-cost symptom and reported `solves` — the cheap term — while a + /// derivation, ~30× more expensive and paid on the same frames, was invisible to it + /// (decision 1625). + pub(crate) derives: u64, pub(crate) skipped: bool, /// How many entries the per-entry splice re-converted this frame — `0` on a settled or /// full-conversion frame. Nonzero is the proof the splice path fired (the equivalence tests diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 0bf836d1..6abb7f0a 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -1344,7 +1344,7 @@ fn the_terrain_distance_slider_snaps_to_the_1_12_grid_and_writes_live() { } /// Defaults on the Graphics page: uiScale back to its registered default (0.9) and farclip to -/// its (777), the rows following, ONLY the moved values queuing — and a pending uiScale edit +/// its (350), the rows following, ONLY the moved values queuing — and a pending uiScale edit /// dies with it (the default write supersedes what Apply would have committed). #[test] fn defaults_resets_the_graphics_page_to_registered_defaults() { @@ -1367,7 +1367,7 @@ fn defaults_resets_the_graphics_page_to_registered_defaults() { "{changes:?}" ); assert!( - changes.contains(&("farclip".to_string(), "777".to_string())), + changes.contains(&("farclip".to_string(), "350".to_string())), "{changes:?}" ); assert_eq!( @@ -1380,7 +1380,7 @@ fn defaults_resets_the_graphics_page_to_registered_defaults() { "return OptionsFrameContainerBodyGraphicsRowFarclipControlValue:GetText()" ) .unwrap(), - "777" + "350" ); assert_eq!( s.eval::( diff --git a/crates/benilla-app/src/ui_script/resolve_bench.rs b/crates/benilla-app/src/ui_script/resolve_bench.rs index 9da18ee5..023006f1 100644 --- a/crates/benilla-app/src/ui_script/resolve_bench.rs +++ b/crates/benilla-app/src/ui_script/resolve_bench.rs @@ -494,3 +494,152 @@ fn measure_sweep_steady_state_cost() { residual, ); } + +/// **The hover-shape law** (decision 1388, ledger B06): a tooltip line that changes ROLE between +/// WRAPPED and PLAIN must cost **zero** derivations of the layout graph. +/// +/// `a_tooltip_content_change_costs_exactly_one_layout_solve` above asserts the same zero and could +/// not see this, because the tooltip it drives always has the same SHAPE — the same lines, the +/// same wrap flags, only the widths move. A real hover sweep changes shape on every step: one +/// item's line 2 is a wrapping "Use:" description, the next one's is a plain "Main Hand", and a +/// spell's line 2 is a plain mana cost where the next spell's is a wrapping tooltip body. +/// +/// `append_line` re-pins the line's wrap column from that flag on every append, and the write is +/// gated on a real change — so it fires exactly on the shape flip and nowhere else. It reached the +/// conservative `Model::touch_layout` (it predates 1388 and was never migrated, being an internal +/// write rather than a `SetWidth` binding), and the whole graph was therefore re-derived on every +/// hover from one item to a differently-shaped one: every live frame's scale re-synced, every seed +/// rect re-filtered, every anchored region re-hashed and its edges rebuilt. Live at 12,465 +/// anchored regions the preamble read `incr=0` on every content-change frame. +/// +/// The positive control is not optional: "zero derivations" is what a permanently broken counter +/// reports too, so the flip is driven for a while BEFORE the window opens (a birth is legitimately +/// structural) and the counter is proved able to move. +#[test] +fn a_tooltip_line_flipping_wrapped_to_plain_costs_no_graph_derivation() { + let mut s = settled_default_ui(); + s.run( + r#" + FlipOwner = CreateFrame("Button", "FlipOwner"); FlipOwner:SetPoint("CENTER", 0, 0) + FlipOwner:SetSize(10, 10) + flip_n = 0 + -- The two shapes a hover alternates between. The trailing `1` on the wrap arm is + -- `AddLine`'s positional wrapText flag (the byte-pinned 0x531630 signature) — it is what + -- pins the line's wrap column, and what un-pins it again on the plain arm. + function flip_change() + flip_n = flip_n + 1 + GameTooltip:SetOwner(FlipOwner, "ANCHOR_RIGHT") + GameTooltip:AddLine("Item head", 1, 1, 1) + if math.mod(flip_n, 2) == 0 then + GameTooltip:AddLine("Use: restores health over 21 sec.", 0, 1, 0, 1) + else + GameTooltip:AddLine("Main Hand", 1, 1, 1) + end + GameTooltip:Show() + end + "#, + ) + .unwrap(); + + // The positive control, taken across the owner's birth and the line pool's growth — both move + // the roster, which is the one thing a per-node ledger cannot describe, so both MUST derive. + let born_at = s.layout_derivations(); + for _ in 0..8 { + s.run("flip_change()").unwrap(); + app_frame(&mut s); + } + assert!( + s.layout_derivations() > born_at, + "creating the owner and growing the tooltip's line pool must derive the graph — reading \ + zero here means `layout_derivations` never moves and the assertion below is vacuous." + ); + + let derives_before = s.layout_derivations(); + for _ in 0..10 { + s.run("flip_change()").unwrap(); + app_frame(&mut s); + } + let derives = s.layout_derivations() - derives_before; + assert_eq!( + derives, 0, + "10 hovers between a wrapped line and a plain one derived the layout graph {derives} \ + times. The wrap-pin write in `tooltip::append_line` moves ONE region's explicit size and \ + must name it (`touch_layout_region`); the conservative `touch_layout` re-derives the \ + whole roster on every hover (decision 1388)." + ); +} + +/// **The hover-sweep law** (decision 1625, ledger B06): sweeping the cursor across a bag grid or a +/// spellbook page — a NEW tooltip OWNER on every step — must cost **zero** derivations of the +/// layout graph. +/// +/// This is the shape the director's report actually has, and it is the one the guard above still +/// could not see: that one keeps a single owner and only changes the tooltip's content. A real +/// sweep does both, and the owner half is the more expensive of the two, because +/// `GameTooltip:SetOwner` RETARGETS the plate's anchor at the button under the cursor. 1388 +/// classified a retarget as structural — an edge disappears and another appears, which no per-node +/// hash can describe — and answered it by throwing the cached graph away. That answer cost a full +/// whole-roster derivation on every slot crossed. 1625 keeps the graph and re-points the one +/// node's edges instead. +/// +/// The correctness half of that patch is guarded where it can be *seen*, on rects, by +/// `benilla-ui`'s `a_retargeted_anchor_follows_its_new_target` (and by `WOW_LAYOUT_VERIFY` behind +/// every one of that crate's tests). This one guards the COST, on the shipped UI, where the roster +/// is big enough for the derivation to matter. +#[test] +fn a_hover_sweep_across_owners_costs_no_graph_derivation() { + let mut s = settled_default_ui(); + s.run( + r#" + for i = 1, 12 do + local b = CreateFrame("Button", "SweepOwner" .. i) + b:SetPoint("CENTER", 0, 0); b:SetSize(10, 10) + end + sweep_n = 0 + -- A different owner AND a different line shape every step: the two halves of a real sweep + -- across a bag grid, where each slot is its own button and each item its own plate. + function sweep_change() + sweep_n = sweep_n + 1 + local slot = math.mod(sweep_n, 12) + 1 + GameTooltip:SetOwner(getglobal("SweepOwner" .. slot), "ANCHOR_RIGHT") + GameTooltip:AddLine("Item " .. slot, 1, 1, 1) + if math.mod(sweep_n, 2) == 0 then + GameTooltip:AddLine("Use: restores health over 21 sec.", 0, 1, 0, 1) + else + GameTooltip:AddLine("Main Hand", 1, 1, 1) + end + GameTooltip:Show() + end + "#, + ) + .unwrap(); + + // The positive control: twelve owner births and the tooltip's line pool growing are all + // roster moves, so the settling pass MUST derive. Zero here would make the assertion vacuous. + let born_at = s.layout_derivations(); + for _ in 0..40 { + s.run("sweep_change()").unwrap(); + app_frame(&mut s); + } + assert!( + s.layout_derivations() > born_at, + "creating twelve owners and growing the line pool must derive the graph — reading zero \ + here means `layout_derivations` never moves and the assertion below proves nothing." + ); + + // Two full laps of the twelve owners, so every step is a re-hover of a button the graph + // already knows: nothing structural is left to discover. + let derives_before = s.layout_derivations(); + for _ in 0..24 { + s.run("sweep_change()").unwrap(); + app_frame(&mut s); + } + let derives = s.layout_derivations() - derives_before; + assert_eq!( + derives, 0, + "24 hovers across 12 owners derived the layout graph {derives} times — one per slot \ + crossed. `SetOwner` re-points ONE node's anchor and must patch that node's edges \ + (`Model::touch_layout_retarget_frame`); the conservative touch re-derives the whole \ + roster on every slot the cursor passes over (decisions 1388, 1625)." + ); +} diff --git a/crates/benilla-assets/src/blp.rs b/crates/benilla-assets/src/blp.rs index b2f3368d..a3d0b59b 100644 --- a/crates/benilla-assets/src/blp.rs +++ b/crates/benilla-assets/src/blp.rs @@ -19,7 +19,9 @@ use bevy::reflect::TypePath; use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; use serde::{Deserialize, Serialize}; -use benilla_formats::{blp_bytes_to_mip_chain, blp_to_rgba, BlpMipChain}; +use benilla_formats::{blp_bytes_to_native_chain, blp_to_rgba}; + +use crate::gpu_blp::{for_upload, UploadChain}; /// Which on-GPU form a BLP decodes to. See the module docs. #[derive(Default, Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] @@ -76,7 +78,9 @@ impl AssetLoader for BlpImageLoader { let to_io = |e: anyhow::Error| std::io::Error::other(format!("{e:#}")); Ok(match settings.variant { BlpVariant::WorldArt => world_art_image( - blp_bytes_to_mip_chain(&bytes).map_err(to_io)?, + // The blocks go straight to the GPU where it can take them (decision 1626); + // `for_upload` decodes only when it cannot, and hands back the matching format. + for_upload(blp_bytes_to_native_chain(&bytes).map_err(to_io)?), // The address mode rides the asset path (`crate::texture_url`, decision 0763): the // sampler is a property of this upload, and one `.blp` legitimately has two. crate::sampler_mode_of(&ctx.path().to_string()), @@ -93,9 +97,9 @@ impl AssetLoader for BlpImageLoader { let (w, h, rgba) = blp_to_rgba(&bytes).map_err(to_io)?; effect_image(w, h, rgba) } - BlpVariant::PointSprite => { - point_sprite_image(blp_bytes_to_mip_chain(&bytes).map_err(to_io)?) - } + BlpVariant::PointSprite => point_sprite_image(for_upload( + blp_bytes_to_native_chain(&bytes).map_err(to_io)?, + )), BlpVariant::Cursor => { let (w, h, rgba) = blp_to_rgba(&bytes).map_err(to_io)?; cursor_image(w, h, rgba) @@ -108,30 +112,31 @@ impl AssetLoader for BlpImageLoader { } } -/// Albedo format: non-sRGB `Rgba8Unorm` — the GPU does not linearize albedo on sample, keeping shader -/// math in WoW's gamma/byte space (the RE'd faithful invariant; `wow-5875-re/system/lighting`). -fn color_format() -> TextureFormat { - TextureFormat::Rgba8Unorm -} +/// The gamma-byte lane's uncompressed format — non-sRGB, so the GPU does not linearize on sample +/// and shader math stays in WoW's byte space (the RE'd faithful invariant; +/// `wow-5875-re/system/lighting`). Every decoded lane below uploads as this. +const GAMMA_BYTES: TextureFormat = TextureFormat::Rgba8Unorm; /// World/model albedo: the BLP's authored mip pyramid laid in verbatim, repeat + trilinear + /// anisotropic. (Port of the old `repeat_texture_authored`.) -fn world_art_image(chain: BlpMipChain, wrap: (bool, bool)) -> Image { +fn world_art_image(upload: UploadChain, wrap: (bool, bool)) -> Image { + let UploadChain { chain, format } = upload; let levels = chain.mips.len() as u32; - let mip0 = chain.mips[0].clone(); // satisfies Image::new's length assert; full chain set below let mut data = Vec::with_capacity(chain.mips.iter().map(Vec::len).sum()); for mip in &chain.mips { data.extend_from_slice(mip); } - let mut image = Image::new( + // `new_uninit` rather than `new`: `Image::new`'s length assert is written for uncompressed + // formats (it skips itself when `pixel_size()` errors, which is every BC format), and feeding + // it mip 0 just to satisfy that assert cost a clone of the largest level on every load. + let mut image = Image::new_uninit( Extent3d { width: chain.width, height: chain.height, depth_or_array_layers: 1, }, TextureDimension::D2, - mip0, - color_format(), + format, // `RENDER_WORLD`: the render world TAKES the mip chain on extract instead of cloning it and // leaving a main-world copy resident for the asset's life (bevy_render `render_asset.rs` — // the `asset_usage == RENDER_WORLD` branch moves, every other usage clones). This is the @@ -203,7 +208,7 @@ fn sprite_image(width: u32, height: u32, rgba: Vec) -> Image { /// linear before the filter weights it, so every blend across an edge lands on a different colour /// than the reference's — darker, because averaging in linear space and re-encoding is darker than /// averaging the gamma bytes. On a 1-bit-alpha bake whose edges sit against black that is a visible -/// dark fringe at every alpha boundary. So the tile arrives as gamma bytes ([`color_format`], no +/// dark fringe at every alpha boundary. So the tile arrives as gamma bytes ([`GAMMA_BYTES`], no /// decode) and the shader converts AFTER the filter. fn map_tile_image(width: u32, height: u32, rgba: Vec) -> Image { effect_image(width, height, rgba) @@ -220,7 +225,7 @@ fn effect_image(width: u32, height: u32, rgba: Vec) -> Image { }, TextureDimension::D2, rgba, - color_format(), + GAMMA_BYTES, RenderAssetUsages::RENDER_WORLD, ); image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { @@ -236,22 +241,21 @@ fn effect_image(width: u32, height: u32, rgba: Vec) -> Image { /// Point sprite: [`effect_image`]'s gamma lane and clamp, with the **authored mip pyramid** — /// see [`BlpVariant::PointSprite`]. No anisotropy: a point sprite is always screen-axis-aligned /// and square, so there is no anisotropic footprint to correct for. -fn point_sprite_image(chain: BlpMipChain) -> Image { +fn point_sprite_image(upload: UploadChain) -> Image { + let UploadChain { chain, format } = upload; let levels = chain.mips.len() as u32; - let mip0 = chain.mips[0].clone(); // satisfies Image::new's length assert; full chain set below let mut data = Vec::with_capacity(chain.mips.iter().map(Vec::len).sum()); for mip in &chain.mips { data.extend_from_slice(mip); } - let mut image = Image::new( + let mut image = Image::new_uninit( Extent3d { width: chain.width, height: chain.height, depth_or_array_layers: 1, }, TextureDimension::D2, - mip0, - color_format(), + format, RenderAssetUsages::RENDER_WORLD, ); image.data = Some(data); diff --git a/crates/benilla-assets/src/gpu_blp.rs b/crates/benilla-assets/src/gpu_blp.rs new file mode 100644 index 00000000..fbfa83e6 --- /dev/null +++ b/crates/benilla-assets/src/gpu_blp.rs @@ -0,0 +1,221 @@ +//! Where a BLP's stored form meets the GPU: **can we hand the blocks over untouched?** +//! +//! WoW ships almost all world art as S3TC (DXT1/DXT3/DXT5) blocks, and the reference client uploads +//! those blocks verbatim — `glCompressedTexImage2DARB`, with the software DXT decoder existing only +//! as a 16-bit-device fallback (wow-re `system/image/image.md`: *"raw passthrough — device eats +//! DXT"*, and `system/models/scratch/wmo-texture-sampling.md` for the per-level upload formats). +//! benilla used to decode every one of them to `Rgba8Unorm` on the CPU and upload that: **4x the +//! bytes of DXT1, 2x of DXT3/DXT5**, on the whole resident working set and on every texture fetch +//! that misses the cache. On a bandwidth-starved integrated GPU that is the frame's dominant cost, +//! and it is the divergence Liho's Steam Deck reading exposed (decision 1626). +//! +//! Two things have to be true before the blocks can go straight over, and this module owns both: +//! +//! 1. **The device must accept BC.** [`bc_supported`] is a process-wide fact published once from +//! the render device (see [`publish_bc_support`]). Not a guess: `wgpu-hal`'s Metal backend gates +//! `TEXTURE_COMPRESSION_BC` on `format_bc: os_is_mac` and the Vulkan backend on the device's +//! `textureCompressionBC`, and bevy's `WgpuSettingsPriority::Functionality` (the default) requests +//! every feature the adapter reports. Every desktop target we ship has it — but "every target we +//! ship" is not "every device", so the fallback stays. +//! 2. **The dimensions must be whole 4x4 blocks.** A BC texture is addressed in blocks; a BLP whose +//! mip 0 is not a multiple of 4 on both axes cannot be uploaded as one. Real 1.12 world art is +//! power-of-two, so this practically never fires — it exists so that when it does, the texture +//! renders decoded instead of failing. +//! +//! When either is false the caller decodes, exactly as before. That is why the fallback lives here +//! as one function and not as a flag threaded through two loaders: there is one rule, and both the +//! async `AssetLoader` lane ([`crate::blp`]) and the synchronous `WorldAssets` lane +//! ([`crate::world_assets`]) ask it the same question. + +use std::sync::OnceLock; + +use benilla_formats::{BlpMipChain, BlpTexels}; +use bevy::image::{CompressedImageFormatSupport, CompressedImageFormats}; +use bevy::prelude::*; +use bevy::render::render_resource::TextureFormat; + +static BC_SUPPORTED: OnceLock = OnceLock::new(); + +/// Publish whether this run's GPU accepts BC textures. Called once, from the app's plugin +/// `finish()`, after `RenderPlugin` has inserted `CompressedImageFormatSupport` into the main world. +/// +/// **Ordering is why this is a `OnceLock` and not a resource.** An `AssetLoader` runs on the async +/// task pool with no world access, and the synchronous `WorldAssets` lane is called from ordinary +/// systems — neither can read a resource the other can. But every plugin's `finish()` completes +/// before the runner's first update, and no BLP is loaded before then, so a single process-wide +/// publish is both sufficient and race-free. Absent a render device (headless tests), nothing +/// publishes and [`bc_supported`] reports `false` — the decoded path, which is what a test that +/// inspects pixels wants anyway. +pub fn publish_bc_support(supported: bool) { + let _ = BC_SUPPORTED.set(supported); +} + +/// Does this run's GPU accept BC? `false` until [`publish_bc_support`] says otherwise. +pub fn bc_supported() -> bool { + *BC_SUPPORTED.get().unwrap_or(&false) +} + +/// Publishes [`bc_supported`] from the live render device. Added by +/// [`crate::register_asset_loaders`], so both the client and the world viewer get it. +/// +/// **All the work is in `finish`, deliberately.** `RenderPlugin::finish` is what inserts +/// `CompressedImageFormatSupport` into the main world (bevy_render `lib.rs`, the +/// `FutureRenderResources` block) — it does not exist at `build` time, and it never exists at all +/// in a headless app. Reading it here, one plugin later, is the earliest honest moment. +pub struct BlpGpuSupportPlugin; + +impl Plugin for BlpGpuSupportPlugin { + fn build(&self, _app: &mut App) {} + + fn finish(&self, app: &mut App) { + // `WOW_NO_BC=1` — force the decoded path on a device that could take blocks. The A/B lever + // for this whole change: it is the only way to price the passthrough against what it + // replaced on one machine in one session, and the first thing to try if a texture ever + // looks wrong (hardware block decode is not bit-identical to `texpresso`'s — see 1626). + let forced_off = std::env::var("WOW_NO_BC").as_deref() == Ok("1"); + let device_can = app + .world() + .get_resource::() + .is_some_and(|s| s.0.contains(CompressedImageFormats::BC)); + let supported = device_can && !forced_off; + publish_bc_support(supported); + // At `info`, not `debug`: "which texture lane am I on?" is the first question any perf or + // look report about this change has to answer, and a line that only exists in a debug build + // cannot answer it from a player's log. + if supported { + info!("blp: DXT blocks upload natively (BC)"); + } else if forced_off { + info!("blp: WOW_NO_BC=1 — decoding textures to RGBA8"); + } else { + info!("blp: device reports no BC support — decoding textures to RGBA8"); + } + } +} + +/// Can `chain` be uploaded in the form it is already in? +fn uploadable_as_blocks(chain: &BlpMipChain) -> bool { + chain.texels.is_block_compressed() + && bc_supported() + && chain.width.is_multiple_of(4) + && chain.height.is_multiple_of(4) +} + +/// A chain and the `TextureFormat` it uploads as — **decided together, returned together.** +/// +/// This pairing is the whole point of the type. The first cut of this module had two functions, one +/// that picked the chain and one that picked the format, and a caller reached the second without +/// going through the first: it uploaded DXT3 blocks under an `Rgba8Unorm` descriptor and wgpu +/// panicked slicing 262144 bytes out of an 87392-byte chain. There is no way to hold one of these +/// without the other, so that class of mistake cannot recur. +pub struct UploadChain { + pub chain: BlpMipChain, + pub format: TextureFormat, +} + +/// A BLP's authored mip chain in the best form this GPU can take, with the format that matches it: +/// **blocks if it can, pixels if it cannot.** +/// +/// The format is always in the **gamma-byte (non-sRGB) lane** every world/model albedo lives in — +/// the RE'd invariant that the GPU must not linearize albedo on sample +/// (`wow-5875-re/system/lighting`). `Bc1RgbaUnorm` and friends carry that rule exactly as +/// `Rgba8Unorm` does, which is what makes the passthrough look-neutral. +pub fn for_upload(chain: BlpMipChain) -> UploadChain { + if uploadable_as_blocks(&chain) { + let format = match chain.texels { + BlpTexels::Bc1 => TextureFormat::Bc1RgbaUnorm, + BlpTexels::Bc2 => TextureFormat::Bc2RgbaUnorm, + BlpTexels::Bc3 => TextureFormat::Bc3RgbaUnorm, + // `uploadable_as_blocks` already excluded this arm. + BlpTexels::Rgba8Unorm => TextureFormat::Rgba8Unorm, + }; + return UploadChain { chain, format }; + } + // Either it was never blocks, or this device/texture cannot take them. `into_rgba8` is a no-op + // in the first case and the decode we were trying to avoid in the second — and it cannot fail, + // which is why the format below is unconditionally right. + UploadChain { + chain: chain.into_rgba8(), + format: TextureFormat::Rgba8Unorm, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chain(texels: BlpTexels, w: u32, h: u32) -> BlpMipChain { + let mips = vec![vec![0u8; texels.level_bytes(w, h)]]; + BlpMipChain { + width: w, + height: h, + texels, + mips, + } + } + + /// Without a published device fact we decode. A headless test that reads pixels depends on + /// this, and so does any run whose GPU genuinely lacks BC. + #[test] + fn no_published_support_means_the_decoded_path() { + // `publish_bc_support` is never called in this test binary, so the lock stays empty. + assert!(!bc_supported()); + let up = for_upload(chain(BlpTexels::Bc1, 16, 16)); + assert_eq!(up.format, TextureFormat::Rgba8Unorm); + assert!(up.chain.is_rgba8(), "blocks must have been decoded"); + } + + /// **The invariant this module exists to hold**: whatever comes back, the bytes and the format + /// agree about how big mip 0 is. This is the assertion whose absence let block bytes be + /// uploaded under an `Rgba8Unorm` descriptor. + #[test] + fn the_returned_format_always_matches_the_returned_bytes() { + for texels in [ + BlpTexels::Rgba8Unorm, + BlpTexels::Bc1, + BlpTexels::Bc2, + BlpTexels::Bc3, + ] { + for (w, h) in [(16u32, 16u32), (8, 32), (258, 256), (6, 6)] { + let up = for_upload(chain(texels, w, h)); + let expected = match up.format { + TextureFormat::Rgba8Unorm => BlpTexels::Rgba8Unorm, + TextureFormat::Bc1RgbaUnorm => BlpTexels::Bc1, + TextureFormat::Bc2RgbaUnorm => BlpTexels::Bc2, + TextureFormat::Bc3RgbaUnorm => BlpTexels::Bc3, + other => panic!("unexpected albedo format {other:?}"), + }; + assert_eq!(up.chain.texels, expected, "{texels:?} at {w}x{h}"); + assert_eq!( + up.chain.mips[0].len(), + expected.level_bytes(w, h), + "mip 0 bytes must match what {:?} implies at {w}x{h}", + up.format + ); + } + } + } + + /// The block-alignment rule is on mip 0's dimensions, both axes. wgpu-core rejects a compressed + /// texture whose size is not a whole number of blocks (`NotMultipleOfBlockWidth/Height`), and + /// its default error handler makes that a process panic — so this is a hard gate, not a + /// preference. + #[test] + fn non_block_aligned_dimensions_are_refused() { + for (w, h) in [(258u32, 256u32), (256, 258), (6, 6), (1, 1)] { + assert!( + !uploadable_as_blocks(&chain(BlpTexels::Bc1, w, h)), + "{w}x{h} is not whole blocks" + ); + } + } + + /// An already-decoded chain is never re-encoded, and never copied through a decode path. + #[test] + fn an_rgba_chain_passes_through_untouched() { + let c = chain(BlpTexels::Rgba8Unorm, 8, 8); + let before = c.mips[0].len(); + let up = for_upload(c); + assert_eq!(up.format, TextureFormat::Rgba8Unorm); + assert_eq!(up.chain.mips[0].len(), before); + } +} diff --git a/crates/benilla-assets/src/lib.rs b/crates/benilla-assets/src/lib.rs index 4a681fb1..14c59368 100644 --- a/crates/benilla-assets/src/lib.rs +++ b/crates/benilla-assets/src/lib.rs @@ -45,6 +45,9 @@ pub use adt::{chunk_to_mesh, AdtLoader, AdtTile, ChunkShading}; pub use wdt::{WdtIndex, WdtIndexLoader}; mod blp; pub use blp::{BlpImageLoader, BlpLoaderSettings, BlpVariant}; +/// Whether this run's GPU can eat WoW's stored DXT blocks, and the chain form that follows. +mod gpu_blp; +pub use gpu_blp::{bc_supported, for_upload, publish_bc_support, BlpGpuSupportPlugin, UploadChain}; mod m2; pub use m2::{ EmitterBillboard, M2Model, M2ModelLoader, ModelEmitter, ModelLight, ModelRibbon, PortraitCamera, @@ -234,6 +237,9 @@ pub fn register_asset_loaders(app: &mut App) { app.init_asset::(); app.init_asset::(); app.init_asset::(); + // Publishes whether this device can eat DXT blocks (decision 1626). A plugin, because the + // answer only exists after `RenderPlugin::finish` — see `BlpGpuSupportPlugin`. + app.add_plugins(BlpGpuSupportPlugin); app.register_asset_loader(BlpImageLoader); app.register_asset_loader(M2ModelLoader); app.register_asset_loader(WmoModelLoader); diff --git a/crates/benilla-assets/src/terrain.rs b/crates/benilla-assets/src/terrain.rs index 6d103ebb..86dbf6bd 100644 --- a/crates/benilla-assets/src/terrain.rs +++ b/crates/benilla-assets/src/terrain.rs @@ -43,6 +43,15 @@ pub(crate) struct LayerTexture { /// smaller mips slotting into the deeper array levels; when its chain runs out the last authored mip /// is reused (NEAREST-resampled if the dims still differ). pub(crate) fn chain_to_layer(blp: BlpMipChain, target_size: u32) -> LayerTexture { + // **This lane cannot take DXT blocks and must not be handed them.** It NEAREST-resamples levels + // to a uniform array size and its caller stomps the alpha byte for the matte case — both are + // per-texel CPU writes, which is exactly what a block form does not have. So terrain layers + // stay decoded while model/WMO albedo passes through (decision 1626); the day this lane wants + // the same win, the resample and the alpha stomp are what have to go first. + debug_assert!( + blp.is_rgba8(), + "terrain layer packing indexes texels; it needs a decoded chain" + ); let mut chain = Vec::with_capacity(mip_chain_byte_size(target_size, LAYER_MIP_COUNT)); for level in 0..LAYER_MIP_COUNT { let level_w = (target_size >> level).max(1); @@ -192,6 +201,7 @@ mod tests { }) .collect(); BlpMipChain { + texels: benilla_formats::BlpTexels::Rgba8Unorm, width: size, height: size, mips: levels, diff --git a/crates/benilla-assets/src/world_assets/images.rs b/crates/benilla-assets/src/world_assets/images.rs index ab382fa9..8bd21151 100644 --- a/crates/benilla-assets/src/world_assets/images.rs +++ b/crates/benilla-assets/src/world_assets/images.rs @@ -33,22 +33,24 @@ pub fn color_texture_format() -> TextureFormat { /// coverage-correct, leaf-coloured downsample, so we lay them in as-is under the alpha test — no /// re-filter, no alpha-to-coverage (1.12 does neither). Mips + anisotropy give the no-shimmer look /// at distance without softening the up-close art. -pub fn repeat_texture_authored(chain: BlpMipChain, wrap: (bool, bool)) -> Image { +pub fn repeat_texture_authored(upload: crate::gpu_blp::UploadChain, wrap: (bool, bool)) -> Image { + let crate::gpu_blp::UploadChain { chain, format } = upload; let levels = chain.mips.len() as u32; - let mip0 = chain.mips[0].clone(); // satisfies Image::new's length assert; full chain set below let mut data = Vec::with_capacity(chain.mips.iter().map(Vec::len).sum()); for mip in &chain.mips { data.extend_from_slice(mip); } - let mut image = Image::new( + // Taking an `UploadChain` rather than a bare chain is deliberate: the format and the bytes are + // decided together by `gpu_blp::for_upload`, so this function cannot be handed blocks under an + // uncompressed descriptor. It was, once — see `UploadChain`'s doc (decision 1626). + let mut image = Image::new_uninit( Extent3d { width: chain.width, height: chain.height, depth_or_array_layers: 1, }, TextureDimension::D2, - mip0, - color_texture_format(), + format, // `RENDER_WORLD` — see [`image_gpu_bytes`] and the `blp.rs` `WorldArt` variant this is the // synchronous twin of: the render world takes the chain rather than cloning it and keeping // a main-world copy. Its callers are `WorldAssets::texture` and the character-skin diff --git a/crates/benilla-assets/src/world_assets/mod.rs b/crates/benilla-assets/src/world_assets/mod.rs index f342b786..c0db3706 100644 --- a/crates/benilla-assets/src/world_assets/mod.rs +++ b/crates/benilla-assets/src/world_assets/mod.rs @@ -26,7 +26,7 @@ use bevy::render::render_resource::{Buffer, Face}; use crate::materials::{WowModelExt, WowModelMaterial, VANILLA_ALPHA_KEY_REF}; use crate::SpatialCache; -use benilla_formats::{blp_to_rgba, read_texture_mip_chain, tga_to_rgba, Chain, ModelBlend}; +use benilla_formats::{blp_to_rgba, read_texture_native_chain, tga_to_rgba, Chain, ModelBlend}; /// BLP/RGBA -> Bevy `Image` texture helpers (+ solid/cursor/liquid-frame textures). Split out for /// size; re-exported so callers keep using `benilla_assets::{solid_layer_chain, …}`. @@ -374,8 +374,11 @@ impl WorldAssets { if let Some(handle) = self.textures.fetch(&key) { return Some(handle); } - let chain = read_texture_mip_chain(&mut self.chain.lock_recover(), &key.0).ok()?; - let handle = images.add(repeat_texture_authored(chain, wrap)); + // Passthrough: world art is uploaded in the form the BLP stores it (decision 1626). + // Nothing reads a world texture main-side — the same reasoning that already makes this + // lane `RenderAssetUsages::RENDER_WORLD` — so there are no CPU texels to lose. + let chain = read_texture_native_chain(&mut self.chain.lock_recover(), &key.0).ok()?; + let handle = images.add(repeat_texture_authored(crate::for_upload(chain), wrap)); self.textures.insert(key, handle.clone()); Some(handle) } diff --git a/crates/benilla-blp/src/lib.rs b/crates/benilla-blp/src/lib.rs index 86798153..1b3ea796 100644 --- a/crates/benilla-blp/src/lib.rs +++ b/crates/benilla-blp/src/lib.rs @@ -7,6 +7,13 @@ //! - **DXTC** — S3TC blocks; `alpha_type` selects DXT1 (0) / DXT3 (1) / DXT5 (7), decoded via the same //! `texpresso` codec `wow-blp` uses, so the output is byte-identical. //! +//! **Two entry points, because the GPU wants the blocks and the CPU wants the pixels.** +//! [`decode`] always yields `Rgba8Unorm`; [`decode_native`] hands back the DXTC blocks *verbatim* +//! and only decodes the shapes that have no GPU-native form (Raw1/Raw3). The reference client +//! uploads the stored blocks untouched (`glCompressedTexImage2DARB`; wow-re `system/image/image.md` +//! "raw passthrough — device eats DXT"), so the native path is both the faithful one and 4x-8x +//! cheaper in VRAM and texture bandwidth. Decoding is for consumers that read texels on the CPU. +//! //! Two fidelity points that cost the old `wow-blp` two forks, folded in here natively: //! 1. the palette is **BGRA**, not RGBA (reading it as RGBA swaps R↔B on every palettized Blizzard //! atlas — the "Westfall clutter is blue" bug); @@ -117,8 +124,99 @@ fn level_size(width: u32, height: u32, level: usize) -> (u32, u32) { } } -/// Decode a BLP2 texture (raw archive bytes) to RGBA8 mip levels. -pub fn decode(bytes: &[u8]) -> Result { +/// The texel form a [`NativeBlp`] level carries. +/// +/// A BLP is one of three shapes on disk (module header); only DXTC has a GPU-native form, so the +/// other two report [`Self::Rgba8Unorm`] and carry decoded pixels. Callers switch on this to pick a +/// `TextureFormat` and to size a level. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum BlpTexels { + /// Decoded pixels — the Raw1 (palettized) and Raw3 (BGRA8) shapes, which have no block form. + Rgba8Unorm, + /// DXT1 blocks (8 bytes per 4x4), `alpha_type` 0. 1-bit alpha at most. + Bc1, + /// DXT3 blocks (16 bytes per 4x4), `alpha_type` 1. Explicit 4-bit alpha. + Bc2, + /// DXT5 blocks (16 bytes per 4x4), `alpha_type` 7. Interpolated alpha. + Bc3, +} + +impl BlpTexels { + /// Is this a block-compressed form (i.e. can it be uploaded to the GPU verbatim)? + pub fn is_block_compressed(self) -> bool { + !matches!(self, Self::Rgba8Unorm) + } + + /// How many bytes a `width x height` level occupies in this form. + /// + /// Block forms round **up** to whole 4x4 blocks, which is why a 2x2 or 1x1 tail mip still costs + /// one full block — exactly what the BLP stores for it, and what wgpu expects to receive. + pub fn level_bytes(self, width: u32, height: u32) -> usize { + match self { + Self::Rgba8Unorm => (width as usize) * (height as usize) * 4, + Self::Bc1 | Self::Bc2 | Self::Bc3 => { + let blocks = width.div_ceil(4) as usize * height.div_ceil(4) as usize; + blocks * if self == Self::Bc1 { 8 } else { 16 } + } + } + } + + /// The `texpresso` codec for a block form, or `None` for [`Self::Rgba8Unorm`]. + fn codec(self) -> Option { + match self { + Self::Rgba8Unorm => None, + Self::Bc1 => Some(texpresso::Format::Bc1), + Self::Bc2 => Some(texpresso::Format::Bc2), + Self::Bc3 => Some(texpresso::Format::Bc3), + } + } +} + +/// A BLP with its levels in whatever form the file stores them — see [`decode_native`]. +pub struct NativeBlp { + pub width: u32, + pub height: u32, + /// What every entry of `mips` holds. + pub texels: BlpTexels, + /// One buffer per level, level 0 first. Never empty (same guarantee as [`DecodedBlp::mips`]). + pub mips: Vec, + mip_chain_count: usize, +} + +/// One level of a [`NativeBlp`]. +pub struct NativeMip { + pub width: u32, + pub height: u32, + /// Block bytes (DXTC) or RGBA8 pixels — per [`NativeBlp::texels`]. Always exactly + /// [`BlpTexels::level_bytes`] long: a short tail level is zero-padded, matching what + /// [`decode`] does before handing blocks to the codec. + pub bytes: Vec, +} + +impl NativeBlp { + /// See [`DecodedBlp::mip_chain_count`]. + pub fn mip_chain_count(&self) -> usize { + self.mip_chain_count + } +} + +/// The header fields both entry points read, parsed once. +struct Header<'a> { + compression: u8, + alpha_bits: u32, + alpha_type: u8, + width: u32, + height: u32, + offsets: Vec, + sizes: Vec, + palette: &'a [u8], + /// The authored chain length, `mip_chain_count(width, height, has_mipmaps)`. + chain: usize, +} + +/// Parse and bounds-check the BLP2 header, palette and level table. Shared by [`decode`] and +/// [`decode_native`] so the two can never disagree about what a file says. +fn parse_header(bytes: &[u8]) -> Result> { if bytes.len() < HEADER_SIZE || &bytes[0..4] != b"BLP2" { return Err(Error::NotBlp2); } @@ -153,24 +251,62 @@ pub fn decode(bytes: &[u8]) -> Result { .ok_or(Error::BadColorMap)?; let chain = mip_chain_count(width, height, has_mipmaps); + Ok(Header { + compression, + alpha_bits, + alpha_type, + width, + height, + offsets, + sizes, + palette, + chain, + }) +} + +impl Header<'_> { + /// The raw stored bytes of level `level`, or `None` when the chain ends early (some textures + /// stop before the formula's count). + fn level_data<'b>(&self, bytes: &'b [u8], level: usize) -> Result> { + let off = self.offsets[level] as usize; + let sz = self.sizes[level] as usize; + if level > 0 && (sz == 0 || off == 0) { + return Ok(None); + } + bytes + .get(off..off + sz) + .map(Some) + .ok_or(Error::OutOfBounds { level }) + } + + /// Which block form this file's DXTC levels are in. `alpha_type` 0→DXT1, 1→DXT3, 7→DXT5; + /// anything else (a stale byte — e.g. `2` on alpha-less particle atlases) → DXT1, since + /// `alpha_bits` already governs whether alpha is meaningful. + fn dxt_texels(&self) -> BlpTexels { + match self.alpha_type { + 1 => BlpTexels::Bc2, + 7 => BlpTexels::Bc3, + _ => BlpTexels::Bc1, + } + } +} + +/// Decode a BLP2 texture (raw archive bytes) to RGBA8 mip levels. +pub fn decode(bytes: &[u8]) -> Result { + let h = parse_header(bytes)?; // Always decode level 0 (blp_to_rgba needs it even when the chain count is 0); then deeper levels // up to the chain count, stopping if the file doesn't carry one. - let n = chain.clamp(1, 16); + let n = h.chain.clamp(1, 16); let mut mips = Vec::with_capacity(n); for level in 0..n { - let (lw, lh) = level_size(width, height, level); - let off = offsets[level] as usize; - let sz = sizes[level] as usize; - if level > 0 && (sz == 0 || off == 0) { - break; // chain ends early (some textures stop before the formula's count) - } - let data = bytes - .get(off..off + sz) - .ok_or(Error::OutOfBounds { level })?; - let rgba = match compression { - 1 => decode_raw1(palette, data, lw, lh, alpha_bits), - 2 => decode_dxt(data, lw, lh, alpha_type), + let (lw, lh) = level_size(h.width, h.height, level); + let Some(data) = h.level_data(bytes, level)? else { + break; + }; + let rgba = match h.compression { + 1 => decode_raw1(h.palette, data, lw, lh, h.alpha_bits), + 2 => decode_dxt(data, lw, lh, h.dxt_texels()), 3 => decode_raw3(data, lw, lh), other => return Err(Error::UnknownCompression(other)), }?; @@ -182,13 +318,84 @@ pub fn decode(bytes: &[u8]) -> Result { } Ok(DecodedBlp { - width, - height, + width: h.width, + height: h.height, mips, - mip_chain_count: chain, + mip_chain_count: h.chain, }) } +/// Decode a BLP2 texture **keeping its DXTC blocks verbatim** — the form the reference uploads. +/// +/// DXTC levels come back as block bytes with [`NativeBlp::texels`] naming the variant; Raw1/Raw3 +/// files, which have no block form, are decoded exactly as [`decode`] would and report +/// [`BlpTexels::Rgba8Unorm`]. Either way every level is padded to its full +/// [`BlpTexels::level_bytes`], so a caller can concatenate the chain and hand it to the GPU without +/// re-deriving sizes. +pub fn decode_native(bytes: &[u8]) -> Result { + let h = parse_header(bytes)?; + let texels = if h.compression == 2 { + h.dxt_texels() + } else { + BlpTexels::Rgba8Unorm + }; + let n = h.chain.clamp(1, 16); + + let mut mips = Vec::with_capacity(n); + for level in 0..n { + let (lw, lh) = level_size(h.width, h.height, level); + let Some(data) = h.level_data(bytes, level)? else { + break; + }; + let bytes = match h.compression { + 1 => decode_raw1(h.palette, data, lw, lh, h.alpha_bits)?, + 2 => pad_to(data, texels.level_bytes(lw, lh)), + 3 => decode_raw3(data, lw, lh)?, + other => return Err(Error::UnknownCompression(other)), + }; + mips.push(NativeMip { + width: lw, + height: lh, + bytes, + }); + } + + Ok(NativeBlp { + width: h.width, + height: h.height, + texels, + mips, + mip_chain_count: h.chain, + }) +} + +/// Decode one block-compressed level to RGBA8 — the inverse of keeping the blocks. +/// +/// For a caller that took [`decode_native`]'s passthrough and then found it could not upload it +/// after all (no BC on the device): it can get the pixels without re-reading and re-parsing the +/// file. `texels` must be a block form; [`BlpTexels::Rgba8Unorm`] returns `bytes` unchanged, since +/// that is already the answer. +pub fn decode_level(texels: BlpTexels, width: u32, height: u32, bytes: &[u8]) -> Vec { + let Some(fmt) = texels.codec() else { + return bytes.to_vec(); + }; + let blocks = pad_to(bytes, fmt.compressed_size(width as usize, height as usize)); + let mut out = vec![0u8; (width as usize) * (height as usize) * 4]; + fmt.decompress(&blocks, width as usize, height as usize, &mut out); + out +} + +/// `data` grown to exactly `need` bytes with zeros (or truncated if the file over-stores). +/// +/// The pad is what [`decode`] has always done before handing a short tail mip to the codec +/// (matching wow-blp / SereniaBLPLib); the native path owes the GPU the same full level. +fn pad_to(data: &[u8], need: usize) -> Vec { + let mut out = vec![0u8; need]; + let n = data.len().min(need); + out[..n].copy_from_slice(&data[..n]); + out +} + /// Palettized: 1-byte indices into a 256-entry BGRA palette, then a packed alpha block (`alpha_bits`). fn decode_raw1(palette: &[u8], data: &[u8], w: u32, h: u32, alpha_bits: u32) -> Result> { let px = (w as usize) * (h as usize); @@ -246,23 +453,12 @@ fn decode_raw3(data: &[u8], w: u32, h: u32) -> Result> { Ok(out) } -/// DXTC: alpha_type 0→DXT1(Bc1), 1→DXT3(Bc2), 7→DXT5(Bc3); anything else (stale byte) → DXT1, since -/// `alpha_bits` already governs whether alpha is meaningful. Undersized small-mip data is zero-padded -/// to the block size (matching wow-blp / SereniaBLPLib), so a short tail mip still decodes. -fn decode_dxt(data: &[u8], w: u32, h: u32, alpha_type: u8) -> Result> { - let fmt = match alpha_type { - 1 => texpresso::Format::Bc2, - 7 => texpresso::Format::Bc3, - _ => texpresso::Format::Bc1, - }; - let need = fmt.compressed_size(w as usize, h as usize); - let blocks = if data.len() < need { - let mut p = vec![0u8; need]; - p[..data.len()].copy_from_slice(data); - std::borrow::Cow::Owned(p) - } else { - std::borrow::Cow::Borrowed(data) - }; +/// DXTC → RGBA8. The variant comes from [`Header::dxt_texels`]. Undersized small-mip data is +/// zero-padded to the block size (matching wow-blp / SereniaBLPLib), so a short tail mip still +/// decodes. +fn decode_dxt(data: &[u8], w: u32, h: u32, texels: BlpTexels) -> Result> { + let fmt = texels.codec().expect("dxt_texels never returns Rgba8Unorm"); + let blocks = pad_to(data, fmt.compressed_size(w as usize, h as usize)); let mut out = vec![0u8; (w as usize) * (h as usize) * 4]; fmt.decompress(&blocks, w as usize, h as usize, &mut out); Ok(out) @@ -333,6 +529,125 @@ mod tests { assert!(matches!(decode(b"not a blp at all"), Err(Error::NotBlp2))); } + /// Build a DXT-compressed (compression 2) BLP with a real mip chain: 8x8 down to 1x1, so the + /// tail levels are the sub-block ones that make block handling interesting. + fn dxt_blp(alpha_type: u8, block_bytes: usize) -> Vec { + let mut offsets = [0u32; 16]; + let mut sizes = [0u32; 16]; + let mut payload = Vec::new(); + let base = (HEADER_SIZE + PALETTE_SIZE) as u32; + // 8x8, 4x4, 2x2, 1x1 — mip_chain_count(8, 8, true) == 3, so both entry points read the + // first THREE levels (8x8, 4x4, 2x2); the 1x1 is stored but past the authored count. + for (i, (w, h)) in [(8u32, 8u32), (4, 4), (2, 2), (1, 1)] + .into_iter() + .enumerate() + { + let blocks = w.div_ceil(4) as usize * h.div_ceil(4) as usize; + let n = blocks * block_bytes; + offsets[i] = base + payload.len() as u32; + sizes[i] = n as u32; + // Deterministic non-trivial block payload so a byte swap would show. + payload.extend((0..n).map(|k| (k as u32 * 37 + i as u32 * 11) as u8)); + } + let mut b = header(2, 8, alpha_type, 1, 8, 8, offsets, sizes); + b.resize(HEADER_SIZE + PALETTE_SIZE, 0); + b.extend_from_slice(&payload); + b + } + + /// The load-bearing equivalence for the native upload path: `decode_native` must hand back the + /// file's blocks *verbatim*, and running those blocks through the codec must reproduce exactly + /// what `decode` produces. If these ever diverge, a GPU-uploaded texture stops matching what + /// every CPU-side consumer sees. + #[test] + fn native_blocks_are_verbatim_and_decode_to_the_same_pixels() { + for (alpha_type, texels, block_bytes) in [ + (0u8, BlpTexels::Bc1, 8usize), + (1, BlpTexels::Bc2, 16), + (7, BlpTexels::Bc3, 16), + (2, BlpTexels::Bc1, 8), // stale alpha_type byte falls back to DXT1 + ] { + let b = dxt_blp(alpha_type, block_bytes); + let native = decode_native(&b).expect("valid DXT BLP decodes natively"); + let decoded = decode(&b).expect("valid DXT BLP decodes to pixels"); + + assert_eq!(native.texels, texels, "alpha_type {alpha_type}"); + assert!(native.texels.is_block_compressed()); + assert_eq!(native.mips.len(), decoded.mips.len()); + assert_eq!(native.mip_chain_count(), decoded.mip_chain_count()); + + for (level, (n, d)) in native.mips.iter().zip(&decoded.mips).enumerate() { + assert_eq!((n.width, n.height), (d.width, d.height)); + // Every level is exactly its full block size — including the 2x2 and 1x1 tails, + // which occupy one whole block each. + assert_eq!( + n.bytes.len(), + texels.level_bytes(n.width, n.height), + "level {level} block size" + ); + // Verbatim: the bytes are the file's, not re-encoded. + let off = native_level_offset(&b, level); + assert_eq!( + &n.bytes[..], + &b[off..off + n.bytes.len()], + "level {level} must be the file's own blocks" + ); + // And they decode to exactly what the RGBA path produced. + let fmt = texels.codec().unwrap(); + let mut out = vec![0u8; (n.width as usize) * (n.height as usize) * 4]; + fmt.decompress(&n.bytes, n.width as usize, n.height as usize, &mut out); + assert_eq!(out, d.rgba, "level {level} pixels must match decode()"); + } + } + } + + /// Where level `i`'s payload starts, read back out of the header we built. + fn native_level_offset(blp: &[u8], level: usize) -> usize { + u32::from_le_bytes(blp[20 + level * 4..24 + level * 4].try_into().unwrap()) as usize + } + + /// Raw1/Raw3 have no block form, so the native path decodes them and says so — a caller that + /// switches on `texels` gets a correct `Rgba8Unorm` upload rather than garbage. + #[test] + fn native_reports_rgba_for_the_shapes_with_no_block_form() { + let pixel_offset = (HEADER_SIZE + PALETTE_SIZE) as u32; + let mut offsets = [0u32; 16]; + let mut sizes = [0u32; 16]; + offsets[0] = pixel_offset; + sizes[0] = 2 * 2 * 4; + let mut b = header(3, 8, 0, 0, 2, 2, offsets, sizes); + b.resize(HEADER_SIZE + PALETTE_SIZE, 0); + b.extend_from_slice(&[ + 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, + ]); + + let native = decode_native(&b).expect("raw3 decodes natively"); + assert_eq!(native.texels, BlpTexels::Rgba8Unorm); + assert!(!native.texels.is_block_compressed()); + assert_eq!(native.mips[0].bytes, decode(&b).unwrap().mips[0].rgba); + assert_eq!( + native.mips[0].bytes.len(), + BlpTexels::Rgba8Unorm.level_bytes(2, 2) + ); + } + + /// A tail level the file under-stores is zero-padded to a whole block, so the concatenated + /// chain a caller uploads is always exactly the size wgpu expects. + #[test] + fn a_short_tail_level_is_padded_to_its_full_block() { + let mut b = dxt_blp(0, 8); + // Level 2 is the 2x2 tail — one whole BC1 block. Shrink its recorded size to 3 bytes. + let short = 3u32; + b[84 + 2 * 4..88 + 2 * 4].copy_from_slice(&short.to_le_bytes()); + let native = decode_native(&b).expect("short tail still decodes"); + let tail = native.mips.last().unwrap(); + assert_eq!((tail.width, tail.height), (2, 2)); + assert_eq!(tail.bytes.len(), 8, "one whole BC1 block"); + assert!(tail.bytes[short as usize..].iter().all(|&x| x == 0)); + // And decode() survives the same truncation — the two paths pad identically. + assert_eq!(decode(&b).unwrap().mips.len(), native.mips.len()); + } + #[test] fn tiny_raw3_decodes_expected_pixels() { // A 2x2 uncompressed BGRA8 (Raw3) image, no mipmaps: the smallest concrete shape decode() diff --git a/crates/benilla-formats/src/bin/benilla-extract/glueextent.rs b/crates/benilla-formats/src/bin/benilla-extract/glueextent.rs new file mode 100644 index 00000000..23513d35 --- /dev/null +++ b/crates/benilla-formats/src/bin/benilla-extract/glueextent.rs @@ -0,0 +1,149 @@ +//! `glueextent` — how wide each shipped glue scene's art is, off the chain (decision 1619). +//! +//! The instrument behind the glue framing ceiling: for every `UI_*` diorama it prints the measured +//! [`benilla_formats::ArtExtent`] (the same call the client makes at scene spawn), the authored +//! 4:3 half-extents it is measured against, and the derived facts the framing law acts on — the +//! window aspect at which the art runs out of **width** under 1587's hor+ (past it the law zooms +//! in, holding the width) and the aspect at which it runs out of **height** for a narrow window. +//! +//! Beside the law's own reading (opaque batches, front faces) it prints two looser ones — opaque + +//! alpha-tested, and every batch — so a scene whose measured edge looks wrong against a capture can +//! be read for *which* batches sit at that edge without a second tool. + +use anyhow::{Context, Result}; +use benilla_formats::{ + authored_half_height, batch_footprint, glue_art_extent, parse_m2_camera, + parse_m2_render_submeshes, Chain, Coverage, CoverageReader, ModelBlend, GLUE_AUTHORED_ASPECT, +}; + +/// The seven shipped scenes: the login gate + the six race stages (Gnome→Dwarf, Troll→Orc share). +const SCENES: [&str; 7] = [ + "MainMenu", "Human", "Orc", "Dwarf", "NightElf", "Scourge", "Tauren", +]; + +pub fn glueextent(chain: &mut Chain, batches: bool) -> Result<()> { + println!( + "{:<9} {:>6} {:>7} {:>7} | {:>7} {:>7} | {:>9} {:>9} | {:>7} {:>7}", + "scene", "fov", "t0", "h0", "half_w", "half_h", "wide@", "narrow@", "opaque", "+all" + ); + for token in SCENES { + let name = format!("Interface\\Glues\\Models\\UI_{token}\\UI_{token}.m2"); + let bytes = chain + .read_file(&name) + .with_context(|| format!("reading '{name}' from chain"))?; + let subs = parse_m2_render_submeshes(&bytes, "", &[]) + .with_context(|| format!("parsing M2 render submeshes '{name}'"))?; + let Some(cam) = parse_m2_camera(&bytes, 0) else { + println!("{token:<9} (no camera 0)"); + continue; + }; + let t0 = authored_half_height(cam.fov); + let h0 = t0 * GLUE_AUTHORED_ASPECT; + // The law's reading — every batch painting by its texels (the module's rule) — and the + // two readings on either side of it, so a scene whose measured edge looks wrong against + // a capture can be read for which rule moved it: opaque batches only, and every batch + // as if it painted fully. + let mut reader = CoverageReader::new(chain); + let mut paints: Vec> = Vec::with_capacity(subs.len()); + for s in &subs { + paints.push(reader.coverage(s)?); + } + let ext = { + let mut i = 0; + glue_art_extent(&subs, &cam, |_| { + let c = paints[i].clone(); + i += 1; + c + }) + }; + let opaque_only = glue_art_extent( + subs.iter().filter(|s| s.blend == ModelBlend::Opaque), + &cam, + |_| Some(Coverage::Full), + ) + .half_w + / t0; + let with_all = glue_art_extent(&subs, &cam, |_| Some(Coverage::Full)).half_w / t0; + println!( + "{token:<9} {:>6.3} {:>7.4} {:>7.4} | {:>7.4} {:>7.4} | {:>9.3} {:>9.3} | {:>7.3} {:>7.3}", + cam.fov, + t0, + h0, + ext.half_w, + ext.half_h, + ext.half_w / t0, + if ext.half_h > 0.0 { h0 / ext.half_h } else { f32::NAN }, + opaque_only, + with_all, + ); + // Which batches sit at the measured edge: every opaque batch, with its blend, so a + // suspicious number can be traced to a card without a second command. + let mut kinds = [0usize; 5]; + for s in &subs { + kinds[match s.blend { + ModelBlend::Opaque => 0, + ModelBlend::AlphaTest => 1, + ModelBlend::Blend => 2, + ModelBlend::Mod => 3, + ModelBlend::Mod2x => 4, + }] += 1; + } + println!( + " {} batches: {} opaque, {} alpha-test, {} blend, {} mod, {} mod2x; camera eye {:?} target {:?} near {:.3}", + subs.len(), + kinds[0], + kinds[1], + kinds[2], + kinds[3], + kinds[4], + cam.position, + cam.target, + cam.near_clip, + ); + if batches { + // Every batch's footprint in the frame, in units of t0 (so `±1.333` is the authored + // 4:3 box's side, `±1.0` its top/bottom): which card sets the edge. + println!( + " {:>3} {:<9} {:<3} {:<6} {:>5} {:>5} {:>4} {:>14} {:>14} tex", + "idx", "blend", "2s", "paints", "front", "back", "clip", "x'/t0", "y'/t0" + ); + for (i, s) in subs.iter().enumerate() { + let fp = batch_footprint(s, &cam); + let paints = match &paints[i] { + Some(Coverage::Full) => "full", + Some(Coverage::Alpha(_)) => "alpha", + None => "no", + }; + let range = |r: Option<(f32, f32)>| { + r.map_or("—".to_string(), |(lo, hi)| { + format!("{:+.2}..{:+.2}", lo / t0, hi / t0) + }) + }; + println!( + " {i:>3} {:<9} {:<3} {:<6} {:>5} {:>5} {:>4} {:>14} {:>14} {}", + format!("{:?}", s.blend), + if s.two_sided { "yes" } else { "no" }, + paints, + fp.front, + fp.back, + fp.clipped, + range(fp.x), + range(fp.y), + s.texture.as_deref().unwrap_or("-"), + ); + } + } + } + println!(); + println!( + "t0/h0: the authored 4:3 vertical/horizontal half-extents (tan units); half_w/half_h: the" + ); + println!( + "opaque art's measured half-extents across the authored opening; wide@: the window aspect" + ); + println!( + "past which the law holds the width and zooms (half_w/t0); narrow@: the aspect below which" + ); + println!("it holds the height (h0/half_h). opaque / +all: wide@ counting opaque batches only / every batch."); + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index b8e377f0..6f71de86 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -18,6 +18,7 @@ use clap::{Parser, Subcommand}; mod chaincensus; mod charatlas; mod charprocs; +mod glueextent; mod m2dump; mod scan; mod shakecensus; @@ -94,6 +95,17 @@ enum Command { #[arg(long)] out: Option, }, + /// Measure every shipped glue scene's **art extent** (decision 1619): how far the opaque art + /// of each `UI_*` diorama covers around its authored camera 0, and the window aspects past + /// which the glue framing law stops widening and zooms instead. The instrument behind B330 + /// (the login backdrop's edges showing at 16:9) — the same measurement the client makes at + /// scene spawn, printed for all seven scenes with the looser readings beside it. + Glueextent { + /// Also print every batch's footprint in the frame (front/back/clipped triangle counts, + /// projected extents) — which card sets the measured edge. + #[arg(long)] + batches: bool, + }, /// Dump a DBC table to CSV using our schema for it (headers included). Dbc { /// Internal path to the `.dbc` (forward or back slashes accepted). @@ -778,6 +790,7 @@ fn main() -> Result<()> { output.display() ); } + Command::Glueextent { batches } => glueextent::glueextent(&mut chain, batches)?, Command::M2coll { internal_path } => m2dump::m2coll(&mut chain, &internal_path)?, Command::M2seq { internal_path } => m2dump::m2seq(&mut chain, &internal_path)?, Command::M2events { internal_path } => m2dump::m2events(&mut chain, &internal_path)?, diff --git a/crates/benilla-formats/src/characters/sections.rs b/crates/benilla-formats/src/characters/sections.rs index 75793a35..45f7bdd5 100644 --- a/crates/benilla-formats/src/characters/sections.rs +++ b/crates/benilla-formats/src/characters/sections.rs @@ -482,6 +482,14 @@ pub fn equip_region_candidates(layer: usize, name: &str, sex: u8) -> [String; 2] /// plain copy (the client's REPLACE), an alpha one blends. Levels past either pyramid's end, and the /// sub-pixel remainder of a degenerate deep-mip tile, are clamped/skipped. fn blit_over(dst: &mut BlpMipChain, src: &BlpMipChain, tile: Tile) { + // The composite is a per-texel source-over blend, so both sides must be decoded pixels. Every + // reader here goes through `read_texture_mip_chain` (never the block-passthrough twin) — this + // says so out loud, because a chain that arrived as DXT blocks would blend garbage silently + // rather than fail (decision 1626). + debug_assert!( + dst.is_rgba8() && src.is_rgba8(), + "character-skin compositing needs decoded chains on both sides" + ); let (tx, ty, tw, th) = tile; let levels = dst.mips.len().min(src.mips.len()); for i in 0..levels { @@ -863,6 +871,7 @@ mod tests { BlpMipChain { width, height, + texels: crate::BlpTexels::Rgba8Unorm, mips: vec![px], } } diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 93f3f1e6..96029e3e 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -14,6 +14,10 @@ use std::path::Path; use anyhow::{Context, Result}; use benilla_dbc::{DbcParser, FieldType, Schema, SchemaField}; +/// The BLP texel forms, re-exported so a consumer of [`BlpMipChain`] can switch on +/// [`BlpMipChain::texels`] without depending on `benilla-blp` directly. +pub use benilla_blp::BlpTexels; + mod chain; pub use chain::{Chain, ChainEntry}; /// The UI texture table's other half — loose `.tga` art (addon folders; decision 1322). @@ -206,26 +210,28 @@ pub use value_track::{TrackValue, ValueTrack}; mod models; pub use models::{ accumulate_wmo_group_camera_collision, accumulate_wmo_group_camera_only_collision, - accumulate_wmo_group_collision, hand_grip_finger_poses, load_m2_animation_summary, - load_m2_bone_spins, load_m2_bounds, load_m2_collision_hull, load_m2_mesh, load_m2_mesh_skinned, - load_object_model, load_wmo, load_wmo_collision_tris, m2_bone_spins, m2_owner_reach, - m2_ribbon_emitter_count, m2_sequence_visible_textures, m2_texture_transform_count, - non_separable_billboard_bones, owner_last_rung, owner_last_rung_bucket, - parse_m2_animation_lookup, parse_m2_animation_summary, parse_m2_animations, - parse_m2_attachments, parse_m2_bounds, parse_m2_camera, parse_m2_cch_marker, - parse_m2_collision_hull, parse_m2_event_markers, parse_m2_global_sequence_bones, - parse_m2_lights, parse_m2_playable_animation_lookup, parse_m2_portrait_camera, - parse_m2_render_submeshes, parse_m2_skeleton, parse_m2_string_anchors, parse_wmo_fogs, - parse_wmo_lights, parse_wmo_portals, parse_wmo_root, wmo_group_doodad_refs, - wmo_group_fixed_colors, wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs, - wmo_group_liquid_mesh, wmo_group_raw_colors, wmo_group_submeshes, wmo_root_id, AlphaAnim, - AlphaSeq, AnimEvent, Billboard, BillboardKind, BoneKeys, BoneScaleAnim, BoneSpin, CharSkinSlot, - CollisionMesh, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone, - GlobalSeqChannel, GroundQuad, KeyAnim, M2AnimSummary, M2Attachment, M2Bounds, M2Light, - M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis, PlayableAnim, - RenderSubmesh, RgbAnim, ScalarAnim, SeqLoops, Skeleton, SkeletonBone, StringAnchors, UvAnim, - WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight, - WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot, NO_GROUP_LIQUID, OWNER_RUNG_BUCKETS, + accumulate_wmo_group_collision, authored_half_height, batch_footprint, glue_art_extent, + hand_grip_finger_poses, load_m2_animation_summary, load_m2_bone_spins, load_m2_bounds, + load_m2_collision_hull, load_m2_mesh, load_m2_mesh_skinned, load_object_model, load_wmo, + load_wmo_collision_tris, m2_bone_spins, m2_owner_reach, m2_ribbon_emitter_count, + m2_sequence_visible_textures, m2_texture_transform_count, non_separable_billboard_bones, + owner_last_rung, owner_last_rung_bucket, parse_m2_animation_lookup, parse_m2_animation_summary, + parse_m2_animations, parse_m2_attachments, parse_m2_bounds, parse_m2_camera, + parse_m2_cch_marker, parse_m2_collision_hull, parse_m2_event_markers, + parse_m2_global_sequence_bones, parse_m2_lights, parse_m2_playable_animation_lookup, + parse_m2_portrait_camera, parse_m2_render_submeshes, parse_m2_skeleton, + parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights, parse_wmo_portals, parse_wmo_root, + shipped_glue_art_extent, wmo_group_doodad_refs, wmo_group_fixed_colors, + wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs, wmo_group_liquid_mesh, + wmo_group_raw_colors, wmo_group_submeshes, wmo_root_id, AlphaAnim, AlphaMap, AlphaSeq, + AnimEvent, ArtExtent, BatchFootprint, Billboard, BillboardKind, BoneKeys, BoneScaleAnim, + BoneSpin, CharSkinSlot, CollisionMesh, Coverage, CoverageReader, EmitterBoneLink, EventMarker, + FogPolicy, FootprintTris, GlobalSeqBone, GlobalSeqChannel, GroundQuad, KeyAnim, M2AnimSummary, + M2Attachment, M2Bounds, M2Light, M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, + ParentBasis, PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, SeqLoops, Skeleton, + SkeletonBone, StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog, + WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot, + ALPHA_KEY_REF, GLUE_AUTHORED_ASPECT, NO_GROUP_LIQUID, OWNER_RUNG_BUCKETS, }; mod terrain; pub use terrain::{ @@ -360,16 +366,22 @@ pub fn read_texture_rgba(chain: &mut Chain, path: &str) -> Result<(u32, u32, Vec blp_to_rgba(&bytes) } -/// A decoded BLP, mip-major: each entry is one authored mip level's RGBA8 bytes, level 0 first. +/// A BLP's authored mip pyramid, mip-major, level 0 first. /// **Never empty**: the chain length is `BlpHeader::mipmaps_count().max(1)` — a no-mipmaps BLP (the /// count formula reports 0) still yields a 1-level chain (level 0), matching `benilla_blp::decode`'s /// own guarantee that `mips` is never empty (see [`blp_bytes_to_mip_chain`]). +/// +/// [`texels`](Self::texels) says what the buffers hold. [`blp_bytes_to_mip_chain`] always decodes to +/// `Rgba8Unorm`; [`blp_bytes_to_native_chain`] keeps DXTC blocks verbatim for a GPU that can eat +/// them. A consumer that reads texels on the CPU must use the former (or check `texels`). #[derive(Debug, Clone)] pub struct BlpMipChain { /// Mip-0 dimensions; level i's dims are `(width >> i).max(1) × (height >> i).max(1)`. pub width: u32, pub height: u32, - /// One RGBA8 buffer per authored mip level, level 0 first. **Authored, not regenerated** — + /// What every entry of `mips` holds — decoded pixels, or S3TC blocks. + pub texels: BlpTexels, + /// One buffer per authored mip level, level 0 first. **Authored, not regenerated** — /// the BLP's pre-baked mip levels are what the real 1.12 client uploads ("the BLP's own stored mip /// chain is used verbatim — no client-side mip regeneration"). Using these instead of /// CPU-resampling mip 0 in gamma byte space is the @@ -383,6 +395,37 @@ impl BlpMipChain { pub fn mip_size(&self, i: u32) -> (u32, u32) { ((self.width >> i).max(1), (self.height >> i).max(1)) } + + /// Are these levels CPU-readable `Rgba8Unorm` pixels? The guard for any consumer that indexes + /// into a level (resample, alpha stomp, colour read) — block bytes are not pixels. + pub fn is_rgba8(&self) -> bool { + !self.texels.is_block_compressed() + } + + /// This chain with every level decoded to `Rgba8Unorm`; a no-op if it already is. + /// + /// The escape hatch for a consumer that asked for the passthrough form and then could not use + /// it — it costs the decode it was trying to avoid, but it needs no second read of the file and + /// it cannot fail, so the caller is never left holding blocks it must not upload. + pub fn into_rgba8(self) -> Self { + if self.is_rgba8() { + return self; + } + let mips = self + .mips + .iter() + .enumerate() + .map(|(i, level)| { + let (w, h) = self.mip_size(i as u32); + benilla_blp::decode_level(self.texels, w, h, level) + }) + .collect(); + Self { + texels: BlpTexels::Rgba8Unorm, + mips, + ..self + } + } } /// Decode every authored mip level of a BLP texture from the chain into `Rgba8Unorm` buffers. @@ -398,6 +441,16 @@ pub fn read_texture_mip_chain(chain: &mut Chain, path: &str) -> Result Result { + let name = path.replace('/', "\\"); + let bytes = chain + .read_file(&name) + .with_context(|| format!("reading texture '{name}'"))?; + blp_bytes_to_native_chain(&bytes).with_context(|| format!("decoding texture '{name}'")) +} + /// Decode every authored mip level of an **in-memory** BLP into `Rgba8Unorm` buffers — the BLP's own /// stored pyramid, used verbatim (no client-side regeneration; the C2-faithful behaviour). This is the /// bytes-in entry point used by the Bevy `AssetLoader`; [`read_texture_mip_chain`] is the @@ -423,6 +476,38 @@ pub fn blp_bytes_to_mip_chain(bytes: &[u8]) -> Result { Ok(BlpMipChain { width, height, + texels: BlpTexels::Rgba8Unorm, + mips, + }) +} + +/// Every authored mip level of an in-memory BLP **with its DXTC blocks kept verbatim** — the form +/// the reference client uploads (`glCompressedTexImage2DARB`; wow-re `system/image/image.md`). +/// +/// Raw1/Raw3 BLPs have no block form and come back decoded, reporting +/// [`BlpTexels::Rgba8Unorm`] — so a caller switches on [`BlpMipChain::texels`] rather than +/// assuming. Levels are padded to whole blocks, so the concatenated chain is exactly what wgpu +/// expects for the reported format. +pub fn blp_bytes_to_native_chain(bytes: &[u8]) -> Result { + let decoded = + benilla_blp::decode_native(bytes).map_err(|e| anyhow::anyhow!("decoding BLP: {e}"))?; + // Same floor-at-one reasoning as `blp_bytes_to_mip_chain`. + let (width, height, count) = ( + decoded.width, + decoded.height, + decoded.mip_chain_count().max(1), + ); + let texels = decoded.texels; + let mips: Vec> = decoded + .mips + .into_iter() + .take(count) + .map(|m| m.bytes) + .collect(); + Ok(BlpMipChain { + width, + height, + texels, mips, }) } diff --git a/crates/benilla-formats/src/models/art_extent.rs b/crates/benilla-formats/src/models/art_extent.rs new file mode 100644 index 00000000..2c27c03a --- /dev/null +++ b/crates/benilla-formats/src/models/art_extent.rs @@ -0,0 +1,870 @@ +//! The **art extent** of a glue scene — how far its art actually paints around its authored +//! camera, measured off the geometry and the textures (decision 1619). +//! +//! Every `UI_` / `UI_MainMenu` diorama was composed for a 4:3 screen (1587: the Lua design +//! space is `1024×768`), and its backdrop is *finite*: a sky card of some authored width, a ground +//! plane that stops. A camera law that widens the view on a wide window (1587's hor+) keeps showing +//! more of the diorama until the art runs out — and past that edge the frame is the render target's +//! clear colour, which no taste calls a composition (B330: `UI_MainMenu`'s backdrop edges show at +//! 16:9, `UI_Tauren`'s at ~2.24:1). The reference never shows that void only because it never +//! widens; it zooms instead (0116/1543's diagonal-FOV law) and crops the character at 21:9 (B242). +//! +//! So the framing law needs one more authored fact per scene: **how wide is the art**. This module +//! measures it. In camera 0's own tan-space — `x' = x/z`, `y' = y/z` in a right-handed eye frame, +//! the space a projection's half-extents live in — it rasterises every batch that paints into a +//! coverage grid and asks: +//! +//! - across the authored **vertical** opening (`±tan(fovy/2)` at 4:3), what horizontal half-width +//! does *every* row reach, left and right of the axis? → [`ArtExtent::half_w`] +//! - across the authored **horizontal** opening, what vertical half-height does every column +//! reach? → [`ArtExtent::half_h`] +//! +//! The minimum over rows is the point: a sky card seen in perspective is a trapezoid on screen, and +//! the frame runs out of it at the *narrow* row first (MainMenu's edges slant inward toward the +//! top). +//! +//! **What counts as painted is the texel, not the batch.** An `Opaque` batch paints every pixel it +//! covers. A `Blend`/`AlphaTest` batch paints where its texture's alpha would pass the reference's +//! own alpha test — **`≥ 224/255`**, the 1.12 alpha-key reference (wow-re `models.md`, not Cata's +//! 128) — sampled per pixel through the batch's UVs, perspective-correct, with its wrap/clamp mode. +//! Both halves of that rule matter on the real art: the artists drew `UI_Human`'s street and its +//! sky card as *blend* batches over opaque textures (so "opaque batches only" measured that scene +//! at half its authored box), and `UI_NightElf`'s edges are an alpha-tested wall of trees over a +//! sky card that ends at exactly 4:3 (so "opaque textures only" measured it a full step narrower +//! than what is on screen). A cloud, a canopy, a ground shadow, `UI_Tauren`'s 0.55 corner vignette +//! paint only where their texels pass, which is nowhere at their soft edges — the void this +//! measures for. `Mod`/`Mod2x` only tint what is already drawn. Backfaces of single-sided batches +//! are skipped the way the renderer skips them (`Face::Back` cull, CCW front — the model's own +//! winding survives the axis remap, a proper rotation), so a ground plane seen from below does not +//! count as sky. +//! +//! Because the rule reads textures, the measurement runs **offline**, off the chain: `benilla- +//! extract glueextent` prints every scene's numbers, and the client carries them as +//! [`shipped_glue_art_extent`] — a transcription of the shipped 1.12.1 art's authored width, +//! pinned by [`tests::the_shipped_table_matches_the_measurement`], which re-measures every scene +//! against the table. (A runtime probe at scene spawn would have to wait for every batch texture +//! to land before it could answer, and until it answered the login gate would open with its void +//! edges showing, then snap.) +//! +//! Positions are the M2's own model space (WoW axes, Z up) and so is the camera — nothing here +//! needs the engine's frame. The consumer ([`benilla`]'s glue framing) turns the two numbers into a +//! ceiling on 1587's law. + +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::Result; + +use super::records::M2PortraitCamera; +use super::types::{ModelBlend, RenderSubmesh}; +use crate::Chain; + +/// How far a scene's art paints around its authored camera, as projection-space half-extents +/// (tan units — the same space `tan(fov/2)` lives in). +/// +/// `half_w` is measured across the authored 4:3 **vertical** opening, `half_h` across the authored +/// **horizontal** one: each is the widest symmetric box of *that* shape the art still fills. A scene +/// whose art does not even fill the authored box (an axis row with no painted pixel over it) +/// reports `0.0` on that axis — the consumer clamps to the authored extent, never below it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ArtExtent { + pub half_w: f32, + pub half_h: f32, +} + +/// The authored aspect every glue composition was made for (1587, `1024×768`). +pub const GLUE_AUTHORED_ASPECT: f32 = 4.0 / 3.0; + +/// The 1.12 client's alpha-key reference: a texel passes its alpha test at `alpha ≥ 224` +/// (wow-re `models.md` — `ALPHAREF` 224, `GEQUAL`). The one threshold this module paints by. +pub const ALPHA_KEY_REF: u8 = 224; + +/// The **shipped** scenes' measured extents — `benilla-extract glueextent` on the 1.12.1 (5875) +/// chain, transcribed. Keyed by the scene token (`UI_.m2`). The test re-measures them. +/// +/// Read as aspects (`half_w / t0`, the window aspect past which the art runs out of width under +/// the authored vertical): MainMenu 1.54 · Human 1.47 · Orc 1.57 · Dwarf 1.42 · NightElf 1.31 · +/// Scourge 1.57 · Tauren 1.46 — every one of the seven runs out **before 16:9**. Not one diorama +/// was drawn wider than about 3:2; the reference never showed it because its diagonal law zooms +/// from 4:3 onward. +/// +/// `UI_NightElf` is the odd one on both axes, and honestly so: its sky card is exactly 4:3 wide, +/// and its `half_h` is *inside* the authored box — the gaps between its tree-cutout leaves at the +/// frame's sides paint nothing even at 4:3. Both are the artist's intent: it is a night scene, and +/// what shows through is the page behind the scene, near-black, read as sky. The framing law never +/// opens past the authored box on an axis the art does not reach, so the number costs nothing +/// there. +const SHIPPED: [(&str, ArtExtent); 7] = [ + ( + "MainMenu", + ArtExtent { + half_w: 0.7431, + half_h: 0.5067, + }, + ), + ( + "Human", + ArtExtent { + half_w: 0.6548, + half_h: 0.4866, + }, + ), + ( + "Orc", + ArtExtent { + half_w: 0.5573, + half_h: 0.3954, + }, + ), + ( + "Dwarf", + ArtExtent { + half_w: 0.5040, + half_h: 0.4272, + }, + ), + ( + "NightElf", + ArtExtent { + half_w: 0.4262, + half_h: 0.1221, + }, + ), + ( + "Scourge", + ArtExtent { + half_w: 0.5542, + half_h: 0.4776, + }, + ), + ( + "Tauren", + ArtExtent { + half_w: 0.5177, + half_h: 0.4066, + }, + ), +]; + +/// The shipped scene's measured [`ArtExtent`] by token, `None` for a token that is not one of +/// the seven `UI_*` dioramas (the consumer then keeps 1587's unbounded law). +pub fn shipped_glue_art_extent(token: &str) -> Option { + SHIPPED.iter().find(|(t, _)| *t == token).map(|(_, e)| *e) +} + +/// The authored **vertical** half-extent (tan units) of a glue camera's `fov` at 4:3 — the number +/// 1587's law pins: `tan(fovy/2)` with `fovy = fov/√((4/3)²+1)`. +pub fn authored_half_height(fov: f32) -> f32 { + (fov / (GLUE_AUTHORED_ASPECT * GLUE_AUTHORED_ASPECT + 1.0).sqrt() * 0.5).tan() +} + +/// How a batch paints the pixels it covers. +#[derive(Clone, Debug)] +pub enum Coverage { + /// Every covered pixel is painted (an opaque batch, or a blend over a texture with no alpha + /// below the key). + Full, + /// Painted where the texture's alpha, sampled through the batch's UVs, is `≥` [`ALPHA_KEY_REF`]. + Alpha(Arc), +} + +/// A texture's alpha channel, for [`Coverage::Alpha`]. +#[derive(Clone, Debug)] +pub struct AlphaMap { + pub width: u32, + pub height: u32, + /// Row-major, one byte per texel. + pub alpha: Vec, +} + +impl AlphaMap { + /// The alpha at `(u, v)` under the batch's wrap (`true`) or clamp-to-edge (`false`) modes, + /// nearest texel — the same texel the sampler's mip 0 would return at its centre. + fn sample(&self, u: f32, v: f32, wrap_x: bool, wrap_y: bool) -> u8 { + let axis = |t: f32, n: u32, wrap: bool| -> u32 { + let t = if wrap { + t - t.floor() + } else { + t.clamp(0.0, 1.0) + }; + ((t * n as f32) as u32).min(n - 1) + }; + if self.width == 0 || self.height == 0 { + return 0; + } + let x = axis(u, self.width, wrap_x); + let y = axis(v, self.height, wrap_y); + self.alpha[(y * self.width + x) as usize] + } +} + +/// The coverage rule with the chain in hand: `Opaque` → [`Coverage::Full`]; `Blend`/`AlphaTest` +/// → the texture's alpha, read once per path (a texture that never dips below the key collapses +/// to `Full`); no texture, or `Mod`/`Mod2x` → `None` (paints nothing that counts). +pub struct CoverageReader<'c> { + chain: &'c mut Chain, + cache: HashMap>, +} + +impl<'c> CoverageReader<'c> { + pub fn new(chain: &'c mut Chain) -> Self { + Self { + chain, + cache: HashMap::new(), + } + } + + /// How `sub` paints, or `None` when it counts for nothing. + pub fn coverage(&mut self, sub: &RenderSubmesh) -> Result> { + match sub.blend { + ModelBlend::Opaque => Ok(Some(Coverage::Full)), + ModelBlend::Mod | ModelBlend::Mod2x => Ok(None), + ModelBlend::AlphaTest | ModelBlend::Blend => { + let Some(path) = sub.texture.as_deref() else { + return Ok(None); + }; + if let Some(known) = self.cache.get(path) { + return Ok(known.clone()); + } + let (width, height, rgba) = crate::read_texture_rgba(self.chain, path)?; + let alpha: Vec = rgba.chunks_exact(4).map(|px| px[3]).collect(); + let cov = if alpha.iter().all(|&a| a >= ALPHA_KEY_REF) { + Some(Coverage::Full) + } else if alpha.iter().all(|&a| a < ALPHA_KEY_REF) { + None + } else { + Some(Coverage::Alpha(Arc::new(AlphaMap { + width, + height, + alpha, + }))) + }; + self.cache.insert(path.to_string(), cov.clone()); + Ok(cov) + } + } + } +} + +/// Measure a scene's [`ArtExtent`]: its batches, its authored camera, and how each batch paints +/// (`coverage` — [`CoverageReader::coverage`] with the chain, or anything a test hands in). +/// +/// Front faces only unless two-sided; triangles are clipped to the camera's own near plane, +/// exactly as the render would clip them. +pub fn glue_art_extent<'a>( + subs: impl IntoIterator, + cam: &M2PortraitCamera, + mut coverage: impl FnMut(&RenderSubmesh) -> Option, +) -> ArtExtent { + let t0 = authored_half_height(cam.fov); + let h0 = t0 * GLUE_AUTHORED_ASPECT; + let mut grid = Grid::new(t0); + if let Some(frame) = EyeFrame::of(cam) { + for s in subs { + if let Some(cov) = coverage(s) { + grid.paint_batch(s, &frame, cam.near_clip.max(1e-3), &cov); + } + } + } + ArtExtent { + half_w: grid.half_extent(Axis::Row, t0), + half_h: grid.half_extent(Axis::Column, h0), + } +} + +/// The coverage grid over the camera's tan-space: `x' ∈ ±X_SPAN·t0`, `y' ∈ ±Y_SPAN·t0`. The spans +/// cover what the framing law can ask for — a wide window holds the width at the art's edge, and +/// the widest edge any diorama has is under `2·t0`; a narrow window opens the height up to the +/// art's, and `2.7·t0` is a 1:2 window. +struct Grid { + cells: Vec, + /// Tan units per cell, both axes. + dx: f32, + dy: f32, + x_lo: f32, + y_lo: f32, +} + +/// Cells per axis. Even, so the axis (`x' = 0`, `y' = 0`) is a cell boundary and the run around +/// it is read symmetrically. 2048 cells over `4.4·t0` is `0.0021·t0` per cell — 1.5 px at 1440p — +/// and the reported extent is shortened by half a cell, so the quantisation only ever +/// under-reports. +const CELLS: usize = 2048; +const X_SPAN: f32 = 2.2; +const Y_SPAN: f32 = 2.7; + +impl Grid { + fn new(t0: f32) -> Self { + let x_lo = -X_SPAN * t0; + let y_lo = -Y_SPAN * t0; + Self { + cells: vec![false; CELLS * CELLS], + dx: (2.0 * X_SPAN * t0) / CELLS as f32, + dy: (2.0 * Y_SPAN * t0) / CELLS as f32, + x_lo, + y_lo, + } + } + + /// Rasterise every front-facing, near-clipped triangle of `sub` into the grid. + fn paint_batch(&mut self, sub: &RenderSubmesh, frame: &EyeFrame, near: f32, cov: &Coverage) { + for tri in sub.indices.chunks_exact(3) { + let Some(eye) = tri + .iter() + .map(|&i| { + let p = sub.positions.get(i as usize)?; + let uv = sub.uvs.get(i as usize).copied().unwrap_or([0.0; 2]); + Some((frame.to_eye(*p), uv)) + }) + .collect::>>() + else { + continue; + }; + for piece in clip_near(&eye, near) { + let proj: [Vert; 3] = piece.map(|((x, y, z), uv)| Vert { + x: x / z, + y: y / z, + inv_z: 1.0 / z, + u_z: uv[0] / z, + v_z: uv[1] / z, + }); + let area = signed_area(&proj); + // The renderer culls back faces of single-sided batches: CCW on screen is front. + if !sub.two_sided && area <= 0.0 { + continue; + } + if area == 0.0 { + continue; + } + self.paint_triangle(&proj, area, sub, cov); + } + } + } + + fn paint_triangle(&mut self, t: &[Vert; 3], area: f32, sub: &RenderSubmesh, cov: &Coverage) { + let (x_min, x_max) = t + .iter() + .fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), v| { + (lo.min(v.x), hi.max(v.x)) + }); + let (y_min, y_max) = t + .iter() + .fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), v| { + (lo.min(v.y), hi.max(v.y)) + }); + let col_of = |x: f32| ((x - self.x_lo) / self.dx).floor(); + let row_of = |y: f32| ((y - self.y_lo) / self.dy).floor(); + let c0 = col_of(x_min).max(0.0) as usize; + let c1 = (col_of(x_max) as isize).min(CELLS as isize - 1); + let r0 = row_of(y_min).max(0.0) as usize; + let r1 = (row_of(y_max) as isize).min(CELLS as isize - 1); + if c1 < 0 || r1 < 0 || c0 > c1 as usize || r0 > r1 as usize { + return; + } + let inv_area = 1.0 / area; + for r in r0..=r1 as usize { + let y = self.y_lo + (r as f32 + 0.5) * self.dy; + for c in c0..=c1 as usize { + let x = self.x_lo + (c as f32 + 0.5) * self.dx; + // Barycentrics from the edge functions (all same sign as `area` ⇒ inside). + let w0 = edge(&t[1], &t[2], x, y) * inv_area; + let w1 = edge(&t[2], &t[0], x, y) * inv_area; + let w2 = edge(&t[0], &t[1], x, y) * inv_area; + if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 { + continue; + } + let painted = match cov { + Coverage::Full => true, + Coverage::Alpha(map) => { + // Perspective-correct UV: interpolate u/z, v/z, 1/z, divide. + let inv_z = w0 * t[0].inv_z + w1 * t[1].inv_z + w2 * t[2].inv_z; + let u = (w0 * t[0].u_z + w1 * t[1].u_z + w2 * t[2].u_z) / inv_z; + let v = (w0 * t[0].v_z + w1 * t[1].v_z + w2 * t[2].v_z) / inv_z; + map.sample(u, v, sub.wrap_x, sub.wrap_y) >= ALPHA_KEY_REF + } + }; + if painted { + self.cells[r * CELLS + c] = true; + } + } + } + } + + /// The largest half-extent along `axis` that every scanline across the authored opening + /// (`±opening` on the other axis) reaches on both sides of the axis. `0.0` if any scanline + /// leaves the axis unpainted. Shortened by half a cell, so quantisation under-reports. + fn half_extent(&self, axis: Axis, opening: f32) -> f32 { + let (lines, cells_per_line, step, lo, line_step) = match axis { + Axis::Row => (CELLS, CELLS, self.dx, self.y_lo, self.dy), + Axis::Column => (CELLS, CELLS, self.dy, self.x_lo, self.dx), + }; + let mut extent = f32::INFINITY; + let mut any = false; + for line in 0..lines { + let pos = lo + (line as f32 + 0.5) * line_step; + if pos < -opening || pos > opening { + continue; + } + any = true; + let at = |i: usize| match axis { + Axis::Row => self.cells[line * CELLS + i], + Axis::Column => self.cells[i * CELLS + line], + }; + let mid = cells_per_line / 2; // the axis sits between `mid - 1` and `mid` + let mut right = 0usize; + while mid + right < cells_per_line && at(mid + right) { + right += 1; + } + let mut left = 0usize; + while left < mid && at(mid - 1 - left) { + left += 1; + } + if left == 0 || right == 0 { + return 0.0; + } + extent = extent.min(left.min(right) as f32 * step - step * 0.5); + } + if any && extent.is_finite() { + extent.max(0.0) + } else { + 0.0 + } + } +} + +#[derive(Clone, Copy)] +enum Axis { + /// Scan horizontal rows; the extent is along `x'`. + Row, + /// Scan vertical columns; the extent is along `y'`. + Column, +} + +/// A projected vertex with what perspective-correct interpolation needs. +#[derive(Clone, Copy)] +struct Vert { + x: f32, + y: f32, + inv_z: f32, + u_z: f32, + v_z: f32, +} + +/// The edge function of `a → b` at `(x, y)`: twice the signed area of `(a, b, p)`. +fn edge(a: &Vert, b: &Vert, x: f32, y: f32) -> f32 { + (b.x - a.x) * (y - a.y) - (b.y - a.y) * (x - a.x) +} + +/// Twice the signed area of a projected triangle — positive for counter-clockwise. +fn signed_area(t: &[Vert; 3]) -> f32 { + edge(&t[0], &t[1], t[2].x, t[2].y) +} + +/// Where ONE batch lands in the camera's tan-space — the per-batch diagnostic beside +/// [`glue_art_extent`]: how many of its triangles face the camera (and so can paint), how many +/// face away, and the box its front faces cover. `benilla-extract glueextent --batches` prints it, +/// so a measured edge can be traced to the card that sets it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BatchFootprint { + /// Triangles in front of the near plane that face the camera (or are two-sided). + pub front: usize, + /// Single-sided triangles facing away — culled by the renderer, painting nothing. + pub back: usize, + /// Clipped away entirely (behind the near plane). + pub clipped: usize, + /// The front faces' `x'` range, `None` with no front face. + pub x: Option<(f32, f32)>, + /// …and `y'` range. + pub y: Option<(f32, f32)>, +} + +/// One batch's [`BatchFootprint`] under `cam`, regardless of how it paints. +pub fn batch_footprint(sub: &RenderSubmesh, cam: &M2PortraitCamera) -> BatchFootprint { + let mut fp = BatchFootprint { + front: 0, + back: 0, + clipped: 0, + x: None, + y: None, + }; + let Some(frame) = EyeFrame::of(cam) else { + return fp; + }; + let near = cam.near_clip.max(1e-3); + for tri in sub.indices.chunks_exact(3) { + let Some(eye) = tri + .iter() + .map(|&i| { + sub.positions + .get(i as usize) + .map(|&p| (frame.to_eye(p), [0.0; 2])) + }) + .collect::>>() + else { + continue; + }; + let pieces = clip_near(&eye, near); + if pieces.is_empty() { + fp.clipped += 1; + continue; + } + for piece in pieces { + let proj: [Vert; 3] = piece.map(|((x, y, z), _)| Vert { + x: x / z, + y: y / z, + inv_z: 1.0 / z, + u_z: 0.0, + v_z: 0.0, + }); + if !sub.two_sided && signed_area(&proj) <= 0.0 { + fp.back += 1; + continue; + } + fp.front += 1; + for p in proj { + fp.x = Some(fp.x.map_or((p.x, p.x), |(lo, hi)| (lo.min(p.x), hi.max(p.x)))); + fp.y = Some(fp.y.map_or((p.y, p.y), |(lo, hi)| (lo.min(p.y), hi.max(p.y)))); + } + } + } + fp +} + +/// An eye-space vertex `(x, y, depth)` with its UV — the unit `clip_near` works on. +type EyeVert = ((f32, f32, f32), [f32; 2]); + +/// Clip an eye-space triangle against `depth = near` (keep `depth ≥ near`), fanning the resulting +/// polygon (0, 3 or 4 vertices) back into triangles. UVs are interpolated with the position. +fn clip_near(tri: &[EyeVert], near: f32) -> Vec<[EyeVert; 3]> { + let inside = |p: &EyeVert| p.0 .2 >= near; + let mut poly: Vec = Vec::with_capacity(4); + for i in 0..3 { + let a = tri[i]; + let b = tri[(i + 1) % 3]; + let (ia, ib) = (inside(&a), inside(&b)); + if ia { + poly.push(a); + } + if ia != ib { + let t = (near - a.0 .2) / (b.0 .2 - a.0 .2); + let lerp = |p: f32, q: f32| p + (q - p) * t; + poly.push(( + (lerp(a.0 .0, b.0 .0), lerp(a.0 .1, b.0 .1), near), + [lerp(a.1[0], b.1[0]), lerp(a.1[1], b.1[1])], + )); + } + } + match poly.len() { + 3 => vec![[poly[0], poly[1], poly[2]]], + 4 => vec![[poly[0], poly[1], poly[2]], [poly[0], poly[2], poly[3]]], + _ => Vec::new(), + } +} + +/// The camera's eye frame in model space: `right`, `up`, `forward` unit vectors + the eye. +/// +/// Mirrors the engine's `Transform::looking_at(target, up)` with `up = roll about forward ⋅ +Z` +/// (the glue booth's own rig): `forward = target − eye`, `right = forward × up`, `up' = right × +/// forward`. Model space is WoW's (Z up); the engine's remap is a proper rotation, so cross +/// products — and with them winding — agree between the two frames. +struct EyeFrame { + eye: [f32; 3], + right: [f32; 3], + up: [f32; 3], + forward: [f32; 3], +} + +impl EyeFrame { + fn of(cam: &M2PortraitCamera) -> Option { + let forward = normalize(sub(cam.target, cam.position))?; + let up0 = rotate_about(WOW_UP, forward, cam.roll); + let right = normalize(cross(forward, up0))?; + let up = cross(right, forward); + Some(Self { + eye: cam.position, + right, + up, + forward, + }) + } + + /// A model-space point in eye coordinates: `(x, y, depth)` with `depth` along `forward`. + fn to_eye(&self, p: [f32; 3]) -> (f32, f32, f32) { + let d = sub(p, self.eye); + (dot(d, self.right), dot(d, self.up), dot(d, self.forward)) + } +} + +/// WoW model space is Z-up. +const WOW_UP: [f32; 3] = [0.0, 0.0, 1.0]; + +// ---- small vector helpers (model space; no engine types in this crate) ---- + +fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +fn normalize(v: [f32; 3]) -> Option<[f32; 3]> { + let len = dot(v, v).sqrt(); + (len > 1e-6).then(|| [v[0] / len, v[1] / len, v[2] / len]) +} + +/// Rodrigues: rotate `v` about the unit `axis` by `angle` radians. +fn rotate_about(v: [f32; 3], axis: [f32; 3], angle: f32) -> [f32; 3] { + if angle == 0.0 { + return v; + } + let (s, c) = angle.sin_cos(); + let k = cross(axis, v); + let d = dot(axis, v); + [ + v[0] * c + k[0] * s + axis[0] * d * (1.0 - c), + v[1] * c + k[1] * s + axis[1] * d * (1.0 - c), + v[2] * c + k[2] * s + axis[2] * d * (1.0 - c), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A camera at the origin looking down +X (WoW), Z up: `right` = −Y, `up` = +Z, `forward` = +X. + fn cam(fov: f32) -> M2PortraitCamera { + M2PortraitCamera { + fov, + far_clip: 100.0, + near_clip: 0.1, + position: [0.0, 0.0, 0.0], + target: [1.0, 0.0, 0.0], + roll: 0.0, + } + } + + /// An axis-aligned card facing the camera at depth `d`, spanning `±w` horizontally (world −Y + /// is screen right) and `±h` vertically (Z), wound CCW as seen from the camera, UVs + /// `(0,0)` at screen left-bottom to `(1,1)` at right-top. + fn card(d: f32, w: f32, h: f32, blend: ModelBlend, two_sided: bool) -> RenderSubmesh { + // Screen-right is −Y: corners in screen order (left-bottom, right-bottom, right-top, + // left-top) are y = +w, −w, −w, +w. + RenderSubmesh { + positions: vec![[d, w, -h], [d, -w, -h], [d, -w, h], [d, w, h]], + uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], + indices: vec![0, 1, 2, 0, 2, 3], + blend, + two_sided, + ..Default::default() + } + } + + /// `fov = 1`: `t0 = tan(0.3) = 0.309`, `h0 = 0.413`; the grid spans `±0.68` wide, `±0.84` + /// tall. The cards below sit inside the span and outside the authored box. + const FOV: f32 = 1.0; + /// Two grid cells, the quantisation any extent can be short by. + const TOL: f32 = 2.0 * 2.0 * X_SPAN * 0.31 / CELLS as f32; + + fn full(_: &RenderSubmesh) -> Option { + Some(Coverage::Full) + } + + /// An alpha map opaque only across `lo..hi` of `u`, below the key elsewhere. + fn band(lo: f32, hi: f32) -> Arc { + let (w, h) = (64u32, 16u32); + let alpha = (0..w * h) + .map(|i| { + let u = (i % w) as f32 / w as f32; + if (lo..hi).contains(&u) { + 255 + } else { + ALPHA_KEY_REF - 1 + } + }) + .collect(); + Arc::new(AlphaMap { + width: w, + height: h, + alpha, + }) + } + + #[test] + fn a_wide_card_reports_its_own_half_extents() { + // At depth 10, a card ±5 wide / ±4 tall projects to ±0.5 / ±0.4 — both larger than the + // authored box, so the measured extents are the card's. + let sub = card(10.0, 5.0, 4.0, ModelBlend::Opaque, false); + let ext = glue_art_extent([&sub], &cam(FOV), full); + assert!((ext.half_w - 0.5).abs() < TOL, "half_w {}", ext.half_w); + assert!((ext.half_h - 0.4).abs() < TOL, "half_h {}", ext.half_h); + } + + #[test] + fn a_card_narrower_than_the_authored_box_reports_zero() { + // ±2 at depth 10 → ±0.2 < t0 = 0.309: the authored vertical opening is not even covered. + let sub = card(10.0, 2.0, 2.0, ModelBlend::Opaque, false); + let ext = glue_art_extent([&sub], &cam(FOV), full); + assert_eq!( + ext, + ArtExtent { + half_w: 0.0, + half_h: 0.0 + } + ); + } + + #[test] + fn backfaces_are_not_coverage_unless_two_sided() { + let mut back = card(10.0, 5.0, 4.0, ModelBlend::Opaque, false); + back.indices.reverse(); // now wound CW as seen from the camera + assert_eq!(glue_art_extent([&back], &cam(FOV), full).half_w, 0.0); + back.two_sided = true; + assert!((glue_art_extent([&back], &cam(FOV), full).half_w - 0.5).abs() < TOL); + } + + #[test] + fn a_batch_that_counts_for_nothing_paints_nothing() { + let sub = card(10.0, 5.0, 4.0, ModelBlend::Blend, false); + assert_eq!(glue_art_extent([&sub], &cam(FOV), |_| None).half_w, 0.0); + } + + #[test] + fn an_alpha_texture_paints_only_where_it_passes_the_key() { + // A ±5 card (±0.5 at depth 10) whose texture is opaque only across u ∈ [0.2, 0.8]: it + // paints ±0.3 — the rows read that band; and since ±0.3 is inside the authored ±0.413, + // a column of the box goes unpainted and the height reads 0. + let sub = card(10.0, 5.0, 4.0, ModelBlend::AlphaTest, false); + let map = band(0.2, 0.8); + let ext = glue_art_extent([&sub], &cam(FOV), |_| Some(Coverage::Alpha(map.clone()))); + assert!( + (ext.half_w - 0.3).abs() < TOL + 1.0 / 64.0, + "half_w {}", + ext.half_w + ); + assert_eq!(ext.half_h, 0.0, "a column inside the box is unpainted"); + // A band wider than the box (u ∈ [0.05, 0.95] → ±0.45) paints the full height. + let map = band(0.05, 0.95); + let ext = glue_art_extent([&sub], &cam(FOV), |_| Some(Coverage::Alpha(map.clone()))); + assert!((ext.half_h - 0.4).abs() < TOL, "half_h {}", ext.half_h); + assert!( + (ext.half_w - 0.45).abs() < TOL + 1.0 / 64.0, + "half_w {}", + ext.half_w + ); + } + + #[test] + fn the_narrowest_row_wins() { + // A trapezoid: ±5 wide at the bottom, ±2.5 at the top (depth 10) — the frame's top row + // (y' = +t0 = 0.309, i.e. Z = 3.09) sees a half-width interpolated between the two: + // at Z = 3.09 of a ±4-tall card, w = 5 − 2.5·(3.09+4)/8 = 2.78 → 0.278. + let mut sub = card(10.0, 5.0, 4.0, ModelBlend::Opaque, false); + sub.positions[2] = [10.0, -2.5, 4.0]; + sub.positions[3] = [10.0, 2.5, 4.0]; + let ext = glue_art_extent([&sub], &cam(FOV), full); + let t0 = authored_half_height(FOV); + let expect = (5.0 - 2.5 * (t0 * 10.0 + 4.0) / 8.0) / 10.0; + assert!( + (ext.half_w - expect).abs() < 3e-3, + "{} vs {expect}", + ext.half_w + ); + } + + #[test] + fn a_card_behind_the_camera_is_clipped_away_and_one_straddling_it_is_clipped_to_near() { + let behind = card(-10.0, 5.0, 4.0, ModelBlend::Opaque, true); + assert_eq!(glue_art_extent([&behind], &cam(FOV), full).half_w, 0.0); + // A ground plane running from behind the camera to far ahead, below the eye: its near + // clip leaves finite geometry, the projection stays finite, and the covered rows are the + // lower half only — so the *full* authored opening is not covered and the extent is 0, + // without any NaN or panic on the way. + let mut ground = card(0.0, 50.0, 0.0, ModelBlend::Opaque, true); + ground.positions = vec![ + [-5.0, 50.0, -1.0], + [-5.0, -50.0, -1.0], + [50.0, -50.0, -1.0], + [50.0, 50.0, -1.0], + ]; + let ext = glue_art_extent([&ground], &cam(FOV), full); + assert!(ext.half_w.is_finite() && ext.half_h.is_finite()); + assert_eq!(ext.half_w, 0.0); + } + + #[test] + fn adjacent_cards_paint_one_run_across_their_shared_edge() { + let mut left = card(10.0, 5.0, 4.0, ModelBlend::Opaque, false); + left.positions = vec![ + [10.0, 5.0, -4.0], + [10.0, 0.0, -4.0], + [10.0, 0.0, 4.0], + [10.0, 5.0, 4.0], + ]; + let mut right = card(10.0, 5.0, 4.0, ModelBlend::Opaque, false); + right.positions = vec![ + [10.0, 0.0, -4.0], + [10.0, -5.0, -4.0], + [10.0, -5.0, 4.0], + [10.0, 0.0, 4.0], + ]; + let ext = glue_art_extent([&left, &right], &cam(FOV), full); + assert!((ext.half_w - 0.5).abs() < TOL, "half_w {}", ext.half_w); + } + + /// Measure one shipped scene off the chain, the way the tool does. + fn measure(chain: &mut Chain, token: &str) -> (ArtExtent, f32) { + let name = format!("Interface\\Glues\\Models\\UI_{token}\\UI_{token}.m2"); + let bytes = chain.read_file(&name).expect("read scene"); + let subs = crate::parse_m2_render_submeshes(&bytes, "", &[]).expect("parse"); + let cam = crate::parse_m2_camera(&bytes, 0).expect("camera 0"); + let mut reader = CoverageReader::new(chain); + let ext = glue_art_extent(&subs, &cam, |s| reader.coverage(s).expect("texture")); + (ext, authored_half_height(cam.fov)) + } + + /// The transcription is the measurement: every shipped scene re-measures to the table's + /// numbers (within the grid's quantisation), every one covers (about) its authored 4:3 box + /// in width, and every one runs out of width before 16:9 — the fact the whole law rests on. + #[test] + fn the_shipped_table_matches_the_measurement() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + for (token, shipped) in SHIPPED { + let (ext, t0) = measure(&mut chain, token); + assert!( + (ext.half_w - shipped.half_w).abs() < 2e-3 + && (ext.half_h - shipped.half_h).abs() < 2e-3, + "UI_{token}: measured {ext:?}, table {shipped:?}" + ); + let runs_out_at = ext.half_w / t0; + assert!( + (GLUE_AUTHORED_ASPECT - 0.03..16.0 / 9.0).contains(&runs_out_at), + "UI_{token} covers up to aspect {runs_out_at:.3}" + ); + // Every stage but the night elves' fills its authored box top to bottom; theirs + // shows black night sky between the leaves at its sides (the table's doc). + if token == "NightElf" { + assert!( + ext.half_h < t0, + "UI_NightElf: half_h {} — leaf gaps closed?", + ext.half_h + ); + } else { + assert!( + ext.half_h >= t0, + "UI_{token}: half_h {} < t0 {t0}", + ext.half_h + ); + } + } + } +} diff --git a/crates/benilla-formats/src/models/mod.rs b/crates/benilla-formats/src/models/mod.rs index aa42e676..541af695 100644 --- a/crates/benilla-formats/src/models/mod.rs +++ b/crates/benilla-formats/src/models/mod.rs @@ -20,6 +20,7 @@ use crate::Chain; mod anim; mod anim_summary; +mod art_extent; mod bounds; mod collision; mod key_anim; @@ -31,6 +32,7 @@ mod types; mod wmo; pub use anim::*; pub use anim_summary::*; +pub use art_extent::*; pub use bounds::*; pub use collision::*; pub(crate) use key_anim::{bake_track, SeqSlot}; diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index 9ce3b700..4eddc04a 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -206,6 +206,10 @@ pub(crate) struct LayoutScope { dep_head: Vec, dep_to: Vec, dep_next: Vec, + /// Edge slots freed by [`Self::retarget`], for [`Self::edge`] to reuse. Without it a session + /// of hovers would grow `dep_to` by one entry per retarget forever — bounded only by the next + /// full derivation, which is exactly the event retargeting exists to avoid. + dep_free: Vec, /// Per id: in the dirty closure. dirty: Vec, /// The closure's worklist. @@ -269,6 +273,7 @@ impl LayoutScope { self.dirty.resize(n, false); self.dep_to.clear(); self.dep_next.clear(); + self.dep_free.clear(); self.stack.clear(); self.plan.clear(); self.regions.clear(); @@ -302,12 +307,91 @@ impl LayoutScope { return; // an anchor to an id this pass has no node for (the screen root, a dead // target): it cannot become dirty, so it constrains nothing. }; - let e = self.dep_to.len() as u32; - self.dep_to.push(from); - self.dep_next.push(*head); + let e = match self.dep_free.pop() { + Some(e) => { + self.dep_to[e as usize] = from; + self.dep_next[e as usize] = *head; + e + } + None => { + let e = self.dep_to.len() as u32; + self.dep_to.push(from); + self.dep_next.push(*head); + e + } + }; *head = e; } + /// Re-point node `id`'s outgoing edges after an anchor RETARGET — the write 1388 had to + /// classify as structural and answer with a whole-graph derivation. + /// + /// 1388's reasoning was that "an edge has to disappear and another to appear, and no per-node + /// hash can say so, so the cached graph must be thrown away". The first half is true and the + /// second does not follow: the write site knows both edge sets, because a node's outgoing + /// edges ARE its anchor targets (see the derive's two `scope.edge` loops). What it could not + /// do was *say* so — this is the vocabulary that was missing, not a cost that was unavoidable. + /// + /// It matters because the hottest retarget in the client is `GameTooltip:SetOwner`, which + /// re-points the plate at the button under the cursor: every bag slot and every spellbook + /// button crossed in a hover sweep was re-deriving the whole roster. + /// + /// `old`/`new` are the node's FULL anchor-target lists, before and after the write — not the + /// one that moved. That is what makes duplicates exact: a node with two anchors onto the same + /// target has two edges, and removing one per old entry leaves precisely the right count. + /// + /// Returns `false` when it cannot do this safely and the caller must fall back to the + /// conservative touch. Removing an edge is the only direction that can be WRONG (a missing + /// edge under-dirties, and under-dirtying ships a stale rect), so every uncertainty is + /// resolved by refusing: an id the cached graph has no node for, or a frame whose edges come + /// from a ScrollFrame override rather than from its own anchors (the derive does not consult + /// the authored anchors at all in that case). An old target with no edge to remove is NOT an + /// uncertainty — `edge` silently drops an anchor onto an id this pass has no node for, so its + /// absence is the expected shape, and skipping it removes nothing that exists. + pub(crate) fn retarget(&mut self, id: u32, old: &[u32], new: &[u32]) -> bool { + let Some(&n) = self.node_of.get(id as usize) else { + return false; + }; + if n == u32::MAX { + return false; + } + if n & REGION_TAG == 0 && self.plan[n as usize].2.is_some() { + return false; // a ScrollFrame override owns this frame's edges, not its anchors + } + for &t in old { + self.unlink_edge(t, id); + } + for &t in new { + self.edge(id, t); + } + true + } + + /// Unlink ONE `to → from` edge, if the list holds one. The lists are short by construction + /// (a node's dependents), and even a hub's is walked in microseconds against the ~1.3 ms + /// derivation this is replacing, so no bound is imposed on the walk. + fn unlink_edge(&mut self, to: u32, from: u32) { + let Some(&head) = self.dep_head.get(to as usize) else { + return; + }; + let mut prev = u32::MAX; + let mut e = head; + while e != u32::MAX { + let next = self.dep_next[e as usize]; + if self.dep_to[e as usize] == from { + if prev == u32::MAX { + self.dep_head[to as usize] = next; + } else { + self.dep_next[prev as usize] = next; + } + self.dep_free.push(e); + return; + } + prev = e; + e = next; + } + } + /// Mark `id` dirty and queue it, if it is not already. #[inline] fn seed(&mut self, id: u32) { diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 777214d9..6f4217db 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -1703,7 +1703,7 @@ impl Model { // next resolve must derive it in full. Every site that has NOT been migrated to a precise // touch lands here, which is why migration can be incremental and a missed one is slow // rather than wrong. - self.layout_touched = None; + self.give_up_naming(); self.bump_layout_epoch(); } @@ -1732,6 +1732,43 @@ impl Model { } } + /// An anchor RETARGET that names its node: the write moved node `h`'s (or `rh`'s) set of + /// anchor TARGETS, and hands over both target lists so the cached graph's edges can be + /// re-pointed instead of thrown away (decision 1625, extending 1388). + /// + /// `old`/`new` are the node's FULL anchor-target lists either side of the write. Falls back to + /// the conservative touch whenever the scope refuses the patch — the same "worst case is a + /// derivation" safety the other precise touches have. + pub(crate) fn touch_layout_retarget_frame(&mut self, h: FrameHandle, old: &[u32], new: &[u32]) { + match self.frame_to_id.get(&h).copied() { + // Already conservative ⇒ the next resolve rebuilds every edge from scratch anyway, so + // patching them would be work with no reader. + Some(id) + if self.layout_touched.is_some() && self.layout_scope.retarget(id, old, new) => + { + self.touch_layout_node(id); + } + _ => self.touch_layout(), + } + } + + /// [`Self::touch_layout_retarget_frame`]'s region twin. + pub(crate) fn touch_layout_retarget_region( + &mut self, + rh: RegionHandle, + old: &[u32], + new: &[u32], + ) { + match self.region_to_id.get(&rh).copied() { + Some(id) + if self.layout_touched.is_some() && self.layout_scope.retarget(id, old, new) => + { + self.touch_layout_node(id); + } + _ => self.touch_layout(), + } + } + /// A write that could move region `rh`'s measure key — its text, its font face/height/ /// outline, its text height, its explicit size (the wrap width) — names the region on the /// measure ledger ([`Self::measure_dirty`]). Push-only and unfiltered: non-FontStrings and @@ -1771,7 +1808,7 @@ impl Model { .expect("checked above") .push(id); } - _ => self.layout_touched = None, + _ => self.give_up_naming(), } } } @@ -1786,7 +1823,7 @@ impl Model { .expect("checked above") .push(id); } - _ => self.layout_touched = None, + _ => self.give_up_naming(), } } } @@ -1798,12 +1835,33 @@ impl Model { match &mut self.layout_touched { // Already conservative: a precise touch cannot un-say an imprecise one. None => {} - Some(_) if !in_graph => self.layout_touched = None, + Some(_) if !in_graph => self.give_up_naming(), Some(list) => list.push(id), } self.bump_layout_epoch(); } + /// Give up naming what this write moved: the cached graph can no longer be trusted, so the + /// next resolve derives it in full. **Every** site that sets [`Self::layout_touched`] to + /// `None` goes through here, because that transition is the only event worth a name. + /// + /// `WOW_LAYOUT_DERIVE_TRACE=:` — backtrace the first `n` of them after `secs`. 1388 + /// left `layout_derivations` as "the counter to watch", and a non-zero reading as the proof + /// that "a write site somewhere fell back to the conservative touch" — but gave nobody a way + /// to find out WHICH, and `WOW_LAYOUT_TOUCH_TRACE` cannot answer it: it fires on every touch, + /// and the precise ones outnumber the poisoning one by hundreds to one. Only the Some → None + /// EDGE is printed: once a frame has given up, the fallbacks behind it are consequences, not + /// causes, and printing them buries the one line that matters. + fn give_up_naming(&mut self) { + if self.layout_touched.is_some() && derive_trace_armed() { + eprintln!( + "[layout-derive] the ledger gave up naming, at:\n{}", + std::backtrace::Backtrace::force_capture() + ); + } + self.layout_touched = None; + } + /// Tier 1's counter, shared by every touch above. Bumping it is what re-opens the gate; which /// of [`Self::layout_touched`]'s two states the caller leaves behind is what decides how much /// the re-opened resolve has to derive. @@ -1843,3 +1901,27 @@ impl Model { } } } + +/// `WOW_LAYOUT_DERIVE_TRACE=:`'s latch — arm after `secs` (past UI load, whose thousands +/// of legitimate births are all conservative by construction and would eat the budget), then allow +/// `n` prints. Same spec shape as `WOW_LAYOUT_TOUCH_TRACE`, deliberately: one thing to remember. +fn derive_trace_armed() -> bool { + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::OnceLock; + static SPEC: OnceLock> = OnceLock::new(); + let Some((t0, delay, left)) = SPEC.get_or_init(|| { + let v = std::env::var("WOW_LAYOUT_DERIVE_TRACE").ok()?; + let (secs, n) = v.split_once(':')?; + Some(( + std::time::Instant::now(), + secs.trim().parse().ok()?, + AtomicU32::new(n.trim().parse().ok()?), + )) + }) else { + return false; + }; + t0.elapsed().as_secs_f64() >= *delay + && left + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| v.checked_sub(1)) + .is_ok() +} diff --git a/crates/benilla-ui/src/script/object/layout_methods.rs b/crates/benilla-ui/src/script/object/layout_methods.rs index 4ab17362..d168252f 100644 --- a/crates/benilla-ui/src/script/object/layout_methods.rs +++ b/crates/benilla-ui/src/script/object/layout_methods.rs @@ -442,13 +442,26 @@ fn set_point(lua: &Lua, this: &Table, point: &str, rest: [Value; 4]) -> mlua::Re // Value-only unless the target moved (decision 1388) — the per-frame `SetPoint` idiom // (a dragged window, a moving spark) re-points the SAME anchor at the SAME target with new // offsets, so it names its node and the cached graph survives the frame. + // + // A retarget names its node too now (decision 1625): the edge set is not derivable from a + // per-node hash, but it IS derivable from the anchors, and both lists are right here. The + // target lists are collected only on the structural path — the value-only one is the hot + // idiom and must stay allocation-free. let structural = anchor_retarget_is_structural(&input.anchors, &new); + let old_targets: Option> = + structural.then(|| input.anchors.iter().map(|a| a.relative_to).collect()); input.anchors.retain(|a| a.point != point); input.anchors.push(new); - if structural { - model.touch_layout(); - } else { - model.touch_layout_frame(h); + match old_targets { + None => model.touch_layout_frame(h), + Some(old) => { + let new_targets: Vec = model.layout_inputs[&h] + .anchors + .iter() + .map(|a| a.relative_to) + .collect(); + model.touch_layout_retarget_frame(h, &old, &new_targets); + } } } Ok(()) @@ -460,7 +473,14 @@ fn set_point(lua: &Lua, this: &Table, point: &str, rest: [Value; 4]) -> mlua::Re /// node reads that node's rect". An anchor whose OFFSETS moved is a value change — the node /// re-solves and nothing else does, which is exactly what a precise touch claims. An anchor whose /// TARGET moved is not: an edge has to disappear and another to appear, and no per-node hash can -/// say so, so the cached graph must be thrown away instead (decision 1388). +/// say so. +/// +/// 1388 answered that by throwing the cached graph away. **Since decision 1625 it does not have +/// to be**: the write site holds both target lists, so the node's edges are re-pointed in place +/// (`Model::touch_layout_retarget_frame`) and the roster and every other node's hash survive. +/// What this predicate decides is therefore no longer "value change or catastrophe" but which of +/// two precise touches to use — and it still earns its place, because the value-only answer is +/// the hot per-frame idiom and must stay allocation-free. /// /// Mirrors the `retain(point) + push` the setters do. It is value-only in exactly one shape — one /// existing anchor carries this point and keeps the same target. Zero (an edge appears) and two or diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index 7278383d..81083b98 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -751,13 +751,22 @@ pub(super) fn region_set_point( // The frame twin's law (decision 1388): re-pointing the same anchor at the same target is // a VALUE change and names its node; anything that moves the target set is structural. // This is the castbar spark's and every combat-text string's per-frame write. + // …and a retarget names its node too (decision 1625) — see the frame twin. let structural = anchor_retarget_is_structural(&data.anchors, &new); + let old_targets: Option> = + structural.then(|| data.anchors.iter().map(|a| a.relative_to).collect()); data.anchors.retain(|a| a.point != point); data.anchors.push(new); - if structural { - model.touch_layout(); - } else { - model.touch_layout_region(rh); + match old_targets { + None => model.touch_layout_region(rh), + Some(old) => { + let new_targets: Vec = model.region_data[&rh] + .anchors + .iter() + .map(|a| a.relative_to) + .collect(); + model.touch_layout_retarget_region(rh, &old, &new_targets); + } } } Ok(()) diff --git a/crates/benilla-ui/src/script/tests/layout_gate.rs b/crates/benilla-ui/src/script/tests/layout_gate.rs index e57e8c8f..e46f04c7 100644 --- a/crates/benilla-ui/src/script/tests/layout_gate.rs +++ b/crates/benilla-ui/src/script/tests/layout_gate.rs @@ -490,3 +490,68 @@ fn a_paint_only_write_leaves_tier_one_closed() { s.resolve(); assert_eq!(solves(&s), before); } + +/// **The retarget falsifier** (decision 1625): after a node is re-pointed from target A to target +/// B, the cached graph's EDGES must describe the new shape — moving B must move the node. +/// +/// This is the one direction that can be silently wrong. 1625 keeps the cached graph across an +/// anchor retarget by patching one node's edges instead of re-deriving all of them, and a patch +/// that dropped the *new* edge would under-dirty: the node would sit at a stale rect while the +/// thing it is anchored to moved. (The other direction — a leftover edge to the OLD target — only +/// costs a needless re-solve, so it is asserted second and separately.) +/// +/// It asserts on RECTS, not on counts, because a wrong edge set is a wrong picture: the failure +/// this guards against is a tooltip left behind at the last button's position. +#[test] +fn a_retargeted_anchor_follows_its_new_target() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + a = CreateFrame("Frame", "A", nil); a:SetWidth(10); a:SetHeight(10) + a:SetPoint("TOPLEFT", nil, "TOPLEFT", 0, 0) + b = CreateFrame("Frame", "B", nil); b:SetWidth(10); b:SetHeight(10) + b:SetPoint("TOPLEFT", nil, "TOPLEFT", 100, 0) + plate = CreateFrame("Frame", "Plate", nil) + plate:SetWidth(10); plate:SetHeight(10) + plate:SetPoint("TOPLEFT", a, "TOPRIGHT", 0, 0) + "#, + ) + .expect("setup"); + s.resolve(); + s.resolve(); + + // The retarget: the plate now hangs off B, exactly as `GameTooltip:SetOwner` re-points the + // plate at the next button under the cursor. + s.run(r#"plate:SetPoint("TOPLEFT", b, "TOPRIGHT", 0, 0)"#) + .expect("retarget"); + s.resolve(); + let after_retarget = s.eval::("return Plate:GetLeft()").expect("plate left"); + assert!( + (after_retarget - 110.0).abs() < 0.01, + "the retargeted plate must sit at B's right edge (110), not {after_retarget}" + ); + + // …and it must keep following B. This is the assertion the edge patch exists to satisfy: the + // graph was NOT re-derived across the retarget, so if the new edge never made it into the + // cached edge set, B moving reaches nothing and the plate stays put. + s.run(r#"b:SetPoint("TOPLEFT", nil, "TOPLEFT", 300, 0)"#) + .expect("move B"); + s.resolve(); + let after_move = s.eval::("return Plate:GetLeft()").expect("plate left"); + assert!( + (after_move - 310.0).abs() < 0.01, + "moving the NEW target must move the plate (310), not leave it at {after_move} — the \ + retarget's edge patch dropped the new edge and the node is under-dirtied (decision 1625)" + ); + + // …and it must NOT follow the old one any more. + s.run(r#"a:SetPoint("TOPLEFT", nil, "TOPLEFT", 0, -50)"#) + .expect("move A"); + s.resolve(); + let plate_now = s.eval::("return Plate:GetLeft()").expect("plate left"); + assert!( + (plate_now - 310.0).abs() < 0.01, + "the plate must not follow its OLD target any more (310), got {plate_now}" + ); +} diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index 8cfad1e4..48345a31 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -601,7 +601,15 @@ pub(super) fn append_line( if let Some(d) = model.region_data.get_mut(&lh) { if d.size != pin { d.size = pin; - model.touch_layout(); + // NAMED, not conservative (decision 1388). This writes one region's EXPLICIT SIZE and + // nothing else — no anchor target, no roster membership — which is the exact shape + // 1388 migrated every size setter to. It was missed because it is an internal write + // rather than a `SetWidth` binding, and the miss is expensive in the one place it can + // least afford to be: the pin flips whenever a line changes ROLE between wrapped and + // plain, which is what every hover from one item or spell to a differently-shaped one + // does. A conservative touch there re-derives the whole layout graph — every live + // frame's scale re-synced, every anchored region re-hashed — on each hover. + model.touch_layout_region(lh); // The wrap pin is the measure key's wrap-width input. model.touch_measure(lh); } diff --git a/crates/benilla-ui/src/script/tooltip/verbs.rs b/crates/benilla-ui/src/script/tooltip/verbs.rs index 956cdec0..a900fb57 100644 --- a/crates/benilla-ui/src/script/tooltip/verbs.rs +++ b/crates/benilla-ui/src/script/tooltip/verbs.rs @@ -71,8 +71,16 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { let same = input.anchors.len() == 1 && crate::script::object::anchor_bits_eq(&input.anchors[0], &new); if !same { + // The plate re-points at the button under the cursor, which moves + // an EDGE — structural under 1388, and therefore a whole-graph + // derivation on every bag slot and every spellbook button a hover + // sweep crossed. It names its node now (decision 1625): the old + // and new target lists are both right here, so the cached graph's + // edges are re-pointed instead of thrown away. + let old_targets: Vec = + input.anchors.iter().map(|a| a.relative_to).collect(); input.anchors = vec![new]; - model.touch_layout(); + model.touch_layout_retarget_frame(h, &old_targets, &[owner_id]); } } None if anchor.eq_ignore_ascii_case("ANCHOR_NONE") => { diff --git a/crates/benilla-visual/src/main.rs b/crates/benilla-visual/src/main.rs index 385de36c..a5a6130d 100644 --- a/crates/benilla-visual/src/main.rs +++ b/crates/benilla-visual/src/main.rs @@ -4,6 +4,7 @@ //! benilla-visual diff [--out ] [--fail ] [--amplify ] //! benilla-visual diff-dir [--out ] [--fail ] [--amplify ] //! benilla-visual flicker [--out ] [--fail ] [--amplify ] +//! benilla-visual stat [--rect ,,,] //! //! `diff` compares two images; `diff-dir` compares every `*.png` present in *both* directories by name. //! Prints the metrics; writes amplified heatmap(s) when `--out` is given; exits non-zero if any image's @@ -206,6 +207,10 @@ fn main() -> Result<()> { .context("compose-dir needs --out ")?; compose_dir(Path::new(da), Path::new(db), out)?; } + "stat" => { + let [img] = one(rest, "stat")?; + stat_cmd(Path::new(img), opts.rect.as_deref())?; + } "crop" => { let [img] = one(rest, "crop")?; let rect = opts @@ -274,6 +279,67 @@ fn series(dir: &Path, at: &str) -> Result<()> { /// the frame entirely is an ERROR, never an edge-clamped guess; a rect that only partially fits is /// clamped *out loud*; and every pixel sample prints in SOURCE coordinates at source resolution, so /// nothing downstream does coordinate math on a zoomed image. +/// Per-channel min / mean / max over a rect (or the whole frame) — the "is this region flat?" +/// instrument. A frame edge that shows the render target's clear colour instead of art reads as +/// `min == max` on every channel (decision 1619, the glue framing's void check); a region of art +/// never does. Printed, not judged: the caller compares two rects or two captures. +fn stat_cmd(path: &Path, rect: Option<&str>) -> Result<()> { + let img = load(path)?; + let (w, h) = img.dimensions(); + let r = match rect { + Some(spec) => { + let want = parse_rect(spec)?; + if want.x0 >= w || want.y0 >= h { + bail!("--rect {spec} starts outside the {w}x{h} frame"); + } + Rect { + x0: want.x0, + y0: want.y0, + x1: want.x1.min(w), + y1: want.y1.min(h), + } + } + None => Rect { + x0: 0, + y0: 0, + x1: w, + y1: h, + }, + }; + let mut min = [u8::MAX; 3]; + let mut max = [u8::MIN; 3]; + let mut sum = [0u64; 3]; + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let p = img.get_pixel(x, y).0; + for c in 0..3 { + min[c] = min[c].min(p[c]); + max[c] = max[c].max(p[c]); + sum[c] += u64::from(p[c]); + } + } + } + let n = u64::from(r.width()) * u64::from(r.height()); + let mean = sum.map(|s| s as f64 / n as f64); + let flat = min == max; + println!( + "{}: {w}x{h} rect [{}..{}, {}..{}] ({} px) min {:?} mean [{:.1}, {:.1}, {:.1}] max {:?} {}", + path.display(), + r.x0, + r.x1, + r.y0, + r.y1, + n, + min, + mean[0], + mean[1], + mean[2], + max, + if flat { "FLAT" } else { "varied" } + ); + Ok(()) +} + fn crop_cmd(path: &Path, rect: &str, at: Option<&str>, scale: u32, out: &Path) -> Result<()> { let img = load(path)?; let (w, h) = img.dimensions(); @@ -787,6 +853,7 @@ fn print_usage() { benilla-visual hotspot --out [--toggle-delta ] [--pad ]\n \ benilla-visual series --at \",[;,…]\"\n \ benilla-visual compose-dir --out (side-by-side `a | b` per image)\n \ + benilla-visual stat [--rect \",,,\"]\n\ benilla-visual crop --rect \",,,\" --out [--scale ] [--at \",;…\"]\n\ \n\ flicker reads a WOW_LIVE_SHOT_COUNT burst (adjacent frames) in shot order and reports both\n\ diff --git a/crates/benilla-world/src/static_gx/pool.rs b/crates/benilla-world/src/static_gx/pool.rs index 499556a4..16d955b1 100644 --- a/crates/benilla-world/src/static_gx/pool.rs +++ b/crates/benilla-world/src/static_gx/pool.rs @@ -218,6 +218,7 @@ impl GxTexturePool { }); for class in &mut self.classes { for (src, layer) in class.pending.drain(..) { + let (block_w, block_h) = class.format.block_dimensions(); for mip in 0..class.mips.min(src.mip_level_count()) { let mut dst = class.array.as_image_copy(); dst.mip_level = mip; @@ -227,9 +228,17 @@ impl GxTexturePool { encoder.copy_texture_to_texture( s, dst, + // The **physical** mip extent — rounded up to whole blocks. wgpu-core's + // `validate_texture_copy_range` checks `copy_size % block_dimensions` + // unconditionally (there is no "it's the whole mip" exemption), and a + // BLP's authored chain bottoms out at 2x2 — below a BC block on EVERY + // texture. Uncompressed formats have 1x1 blocks, so this is the identity + // there and the line reads the same for both. Without the round-up, the + // first BC texture pooled here is a validation error, and wgpu's default + // handler makes that a process panic (decision 1626). Extent3d { - width: (class.size.width >> mip).max(1), - height: (class.size.height >> mip).max(1), + width: (class.size.width >> mip).max(1).div_ceil(block_w) * block_w, + height: (class.size.height >> mip).max(1).div_ceil(block_h) * block_h, depth_or_array_layers: 1, }, ); diff --git a/crates/benilla-world/src/view.rs b/crates/benilla-world/src/view.rs index 141088cc..52f8e687 100644 --- a/crates/benilla-world/src/view.rs +++ b/crates/benilla-world/src/view.rs @@ -97,8 +97,11 @@ impl Viewer { /// View distance in yards. `farclip` = WoW's `farclip` CVar — the ONE view distance: the far plane of /// the detailed world (geometry beyond it is clipped per-pixel, the wall, and the WDL horizon fills /// in beyond) **and** the reach of terrain residency (`terrain_stream::window`, decision 1513). -/// Default **777** (the vanilla clamp's max; the reference registers 350 — 0954's deliberate -/// divergence, matching the reference `Config.wtf` most players ran). +/// Default **350** — the reference client's own registered default (decision 1624, superseding +/// 0954's divergence to the clamp's max 777). 777 shipped as "the `Config.wtf` most players ran", +/// but it is the *maximum*, and it is what every player gets before they touch anything: at 777 the +/// residency window is 24 chunks per axis against 350's 11 ([`terrain_stream::window::inner_radius`]), +/// ~4.8x the area streamed, drawn and held resident. The slider still reaches 777. #[derive(Resource, Clone, Copy)] pub struct ViewDistance { pub farclip: f32, @@ -118,7 +121,7 @@ pub const FARCLIP_RANGE: std::ops::RangeInclusive = 177.0..=777.0; pub const CAM_FAR: f32 = 3000.0; impl Default for ViewDistance { - /// `$WOW_FARCLIP` (yd, clamped to [`FARCLIP_RANGE`]) overrides the 777 default. The options row is + /// `$WOW_FARCLIP` (yd, clamped to [`FARCLIP_RANGE`]) overrides the 350 default. The options row is /// the live lever, but a headless capture has no hands — and a horizon or fog report almost always /// arrives with the director's slider somewhere other than the default (the 0684 gap was invisible /// at 777 and glaring at 320), so reproducing one must not need a human. Read once at startup, like @@ -127,7 +130,7 @@ impl Default for ViewDistance { let farclip = std::env::var("WOW_FARCLIP") .ok() .and_then(|v| v.parse::().ok()) - .map_or(777.0, |v| { + .map_or(350.0, |v| { v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end()) }); Self { farclip }