the login scene frames its art, char select remembers, DXT stays DXT

- the glue scene widens only as far as its art goes: none of the seven login dioramas is drawn wider than about 3:2 — measured per texel by `benilla-extract glueextent`, which rasterises every batch into the camera's own tan-space, painting where the reference's alpha-key test passes — so the frame zooms down to the reference's own 16:9 opening (never tighter) and pillarboxes past the aspect the art cannot fill, instead of showing the clear colour past the backdrop's slanted edges; 4:3 stays byte-identical
- the character screen opens on whoever you last logged in as: the reference's `lastCharacterIndex` CVar, written at Enter World and only there — clicks and arrow keys never touch it, which is what makes the value mean "last logged in" rather than "last clicked" — restored on every character-list rebuild and clamped into range
- spam-hovering tooltips stops costing 2 ms: two write sites gave up naming their node and re-derived the whole layout graph (1,812 µs against the ledger's 44, on 919 of 1,262 resolves) — the wrap pin now touches the one region it wrote, and re-pointing a tooltip at the button under the cursor retargets the node's edges in place instead of throwing the cached graph away
- two corrections off the project's first GPU-bound report (a Steam Deck at 94% GPU busy on 8.4 W of a 15 W budget, the memory-stall signature): `farclip` ships the reference's registered 350 instead of the clamp's 777 — the value also sizes terrain residency, so 777 streamed, held and drew 4.8× the area — and BLP textures upload WoW's own DXT blocks verbatim the way the reference does, instead of decoding to RGBA8 at 8× the bytes of DXT1; the terrain slider still reaches 777, and the lanes that read texels on the CPU (terrain arrays, the character-skin compositor) stay decoded and say why

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-27 15:11:59 +07:00
co-authored by Claude Fable 5
parent 88f036206e
commit 7146bc91f0
36 changed files with 3333 additions and 244 deletions
+8 -2
View File
@@ -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
+25 -10
View File
@@ -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
+462 -32
View File
@@ -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<usize> {
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<usize> {
value.trim().parse::<usize>().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<Roster>,
mut script: Option<NonSendMut<benilla_ui::script::UiScript>>,
// 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<crate::ui_script::VmMemo<Option<usize>>>,
) {
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<CharListMessage>,
mut roster: ResMut<Roster>,
pick: Res<CharPick>,
// 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<crate::cvars::CvarPersist>,
// 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=<name>` — **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<CharRequest>) {
app.insert_state(ClientState::CharSelect)
.init_resource::<Roster>()
.insert_resource(CharPick(pick))
.add_message::<CharListMessage>()
.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<usize>,
) -> Option<usize> {
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<Roster>()
.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::<CharListMessage>()
.add_message::<AppExit>()
.add_systems(Update, apply_roster_policy);
if let Some(row) = preselected {
app.world_mut().resource_mut::<Roster>().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::<Roster>().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::<Roster>()
.insert_resource(CharPick(tx))
.insert_resource(crate::cvars::CvarPersist::with_stored(
CVAR_LAST_CHARACTER,
"0",
))
.add_message::<CharListMessage>()
.add_message::<AppExit>()
.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::<Roster>()
.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::<Roster>().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::<Roster>()
.insert_resource(CharPick(tx))
.insert_non_send_resource(script)
.add_systems(Update, persist_last_character);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
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::<benilla_ui::script::UiScript>()
.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::<Roster>().pending_pick = Some(2);
app.update();
assert_eq!(
app.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>()
.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::<Roster>()
.insert_resource(CharPick(tx))
.insert_non_send_resource(fresh())
.add_systems(Update, persist_last_character);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
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::<benilla_ui::script::UiScript>(fresh());
app.update();
assert_eq!(
app.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>()
.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::<Roster>()
.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>();
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::<benilla_ui::script::UiScript>()
.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::<benilla_ui::script::UiScript>()
.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
+162 -53
View File
@@ -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::<LookConfig>()
.init_resource::<ClickConfig>()
.init_resource::<LootConfig>()
.init_resource::<NameConfig>()
.init_resource::<VPlateMode>()
.init_resource::<ClutterConfig>()
.init_resource::<MinimapZoom>()
.init_resource::<BubbleConfig>()
.init_resource::<ZoomLimit>()
.init_resource::<FollowConfig>()
.init_resource::<VideoConfig>()
.init_resource::<PaneRate>()
.init_resource::<crate::ui_guild::GuildMemberNotify>()
.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::<LookConfig>()
.init_resource::<ClickConfig>()
.init_resource::<LootConfig>()
.init_resource::<NameConfig>()
.init_resource::<VPlateMode>()
.init_resource::<ClutterConfig>()
.init_resource::<MinimapZoom>()
.init_resource::<BubbleConfig>()
.init_resource::<ZoomLimit>()
.init_resource::<FollowConfig>()
.init_resource::<VideoConfig>()
.init_resource::<PaneRate>()
.init_resource::<crate::ui_guild::GuildMemberNotify>()
.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::<Vec<_>>()
};
// ── 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::<Roster>().selected(),
Some(0),
"nothing remembered yet, so the first row — the behaviour that was already right",
);
app.world_mut().resource_mut::<Roster>().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::<Roster>().selected(),
Some(2),
"the second launch must stand the SAME character on the stage — the whole report",
);
assert_eq!(
*app.world().resource::<State<ClientState>>().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::<LookConfig>()
.init_resource::<ClickConfig>()
.init_resource::<LootConfig>()
.init_resource::<NameConfig>()
.init_resource::<VPlateMode>()
.init_resource::<ClutterConfig>()
.init_resource::<MinimapZoom>()
.init_resource::<BubbleConfig>()
.init_resource::<ZoomLimit>()
.init_resource::<FollowConfig>()
.init_resource::<VideoConfig>()
.init_resource::<PaneRate>()
.init_resource::<crate::ui_guild::GuildMemberNotify>()
.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
@@ -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)
}
+19 -11
View File
@@ -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>() / 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=<secs>:<n>` will backtrace on a live run.
let derives = rows.iter().map(|r| r.cost.derives as f64).sum::<f64>() / 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<Row> {
}
}
fields.push(cur);
if fields.len() < 16 {
if fields.len() < 17 {
return None;
}
let num = |i: usize| fields[i].parse::<f64>().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<Row> {
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()))
},
})
}
+241 -18
View File
@@ -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_<Race>` 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 `<PlayerModel>` 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<ArtExtent>,
) -> 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<f32>,
}
/// 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**
+89 -3
View File
@@ -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<benilla_assets::PortraitCamera>,
/// 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<ArtExtent>,
/// `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<f32>,
/// 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::<Children>();
@@ -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<CreateScene>,
window: Query<&Window, With<PrimaryWindow>>,
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<Image>, target: &Handle<Image>, w: u32, h: u32) {
+2
View File
@@ -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,
@@ -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,
};
+6
View File
@@ -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
@@ -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::<String>(
@@ -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)."
);
}
+26 -22
View File
@@ -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<u8>) -> 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<u8>) -> Image {
effect_image(width, height, rgba)
@@ -220,7 +225,7 @@ fn effect_image(width: u32, height: u32, rgba: Vec<u8>) -> 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<u8>) -> 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);
+221
View File
@@ -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<bool> = 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::<CompressedImageFormatSupport>()
.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);
}
}
+6
View File
@@ -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::<WmoModel>();
app.init_asset::<AdtTile>();
app.init_asset::<WdtIndex>();
// 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);
+10
View File
@@ -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,
@@ -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
@@ -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)
}
+350 -35
View File
@@ -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<DecodedBlp> {
/// 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<texpresso::Format> {
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<NativeMip>,
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<u8>,
}
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<u32>,
sizes: Vec<u32>,
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<Header<'_>> {
if bytes.len() < HEADER_SIZE || &bytes[0..4] != b"BLP2" {
return Err(Error::NotBlp2);
}
@@ -153,24 +251,62 @@ pub fn decode(bytes: &[u8]) -> Result<DecodedBlp> {
.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<Option<&'b [u8]>> {
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<DecodedBlp> {
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<DecodedBlp> {
}
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<NativeBlp> {
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<u8> {
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<u8> {
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<Vec<u8>> {
let px = (w as usize) * (h as usize);
@@ -246,23 +453,12 @@ fn decode_raw3(data: &[u8], w: u32, h: u32) -> Result<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<u8> {
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()
@@ -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<Option<Coverage>> = 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(())
}
@@ -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<PathBuf>,
},
/// 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)?,
@@ -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],
}
}
+107 -22
View File
@@ -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<BlpMipCha
blp_bytes_to_mip_chain(&bytes).with_context(|| format!("decoding texture '{name}'"))
}
/// Read a texture from the chain by internal path and keep its DXTC blocks verbatim —
/// [`blp_bytes_to_native_chain`]'s chain-reading wrapper, the twin of [`read_texture_mip_chain`].
pub fn read_texture_native_chain(chain: &mut Chain, path: &str) -> Result<BlpMipChain> {
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<BlpMipChain> {
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<BlpMipChain> {
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<Vec<u8>> = decoded
.mips
.into_iter()
.take(count)
.map(|m| m.bytes)
.collect();
Ok(BlpMipChain {
width,
height,
texels,
mips,
})
}
@@ -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_<Race>` / `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_<token>.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<ArtExtent> {
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<AlphaMap>),
}
/// 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<u8>,
}
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<String, Option<Coverage>>,
}
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<Option<Coverage>> {
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<u8> = 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<Item = &'a RenderSubmesh>,
cam: &M2PortraitCamera,
mut coverage: impl FnMut(&RenderSubmesh) -> Option<Coverage>,
) -> 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<bool>,
/// 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::<Option<Vec<_>>>()
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::<Option<Vec<_>>>()
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<EyeVert> = 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<Self> {
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<Coverage> {
Some(Coverage::Full)
}
/// An alpha map opaque only across `lo..hi` of `u`, below the key elsewhere.
fn band(lo: f32, hi: f32) -> Arc<AlphaMap> {
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
);
}
}
}
}
+2
View File
@@ -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};
+87 -3
View File
@@ -206,6 +206,10 @@ pub(crate) struct LayoutScope {
dep_head: Vec<u32>,
dep_to: Vec<u32>,
dep_next: Vec<u32>,
/// 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<u32>,
/// Per id: in the dirty closure.
dirty: Vec<bool>,
/// 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) {
+86 -4
View File
@@ -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=<secs>:<n>` — 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=<secs>:<n>`'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<Option<(std::time::Instant, f64, AtomicU32)>> = 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()
}
@@ -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<Vec<u32>> =
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<u32> = 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
+13 -4
View File
@@ -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<Vec<u32>> =
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<u32> = model.region_data[&rh]
.anchors
.iter()
.map(|a| a.relative_to)
.collect();
model.touch_layout_retarget_region(rh, &old, &new_targets);
}
}
}
Ok(())
@@ -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::<f64>("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::<f64>("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::<f64>("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}"
);
}
+9 -1
View File
@@ -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);
}
@@ -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<u32> =
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") => {
+67
View File
@@ -4,6 +4,7 @@
//! benilla-visual diff <a.png> <b.png> [--out <diff.png>] [--fail <mae>] [--amplify <n>]
//! benilla-visual diff-dir <dir_a> <dir_b> [--out <diff_dir>] [--fail <mae>] [--amplify <n>]
//! benilla-visual flicker <burst_dir> [--out <envelope.png>] [--fail <mae>] [--amplify <n>]
//! benilla-visual stat <img.png> [--rect <x>,<y>,<w>,<h>]
//!
//! `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 <dir>")?;
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 <burst_dir> --out <strip.png> [--toggle-delta <n>] [--pad <n>]\n \
benilla-visual series <burst_dir> --at \"<x>,<y>[;<x>,<y>…]\"\n \
benilla-visual compose-dir <dir_a> <dir_b> --out <dir> (side-by-side `a | b` per image)\n \
benilla-visual stat <img.png> [--rect \"<x>,<y>,<w>,<h>\"]\n\
benilla-visual crop <img.png> --rect \"<x>,<y>,<w>,<h>\" --out <crop.png> [--scale <n>] [--at \"<x>,<y>;…\"]\n\
\n\
flicker reads a WOW_LIVE_SHOT_COUNT burst (adjacent frames) in shot order and reports both\n\
+11 -2
View File
@@ -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,
},
);
+7 -4
View File
@@ -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<f32> = 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::<f32>().ok())
.map_or(777.0, |v| {
.map_or(350.0, |v| {
v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end())
});
Self { farclip }