From cc16abfae902ea2e953955a765a37bea08f5aeeb Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:43:15 +0700 Subject: [PATCH] Glue scenes stand any file; one model-name rule for every lane (#1932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three differences from 1.12.1 on the model and glue lanes. - `SetCharSelectBackground`/`SetCharCustomizeBackground` stood only the stock seven scenes. Any pane file now stands as itself, opened loose-aware as the scene model is (`0x76ccf5` flags 5, `0x707365`), and a scene with no camera draws on the orthographic leg (`0x76ce80`, `0x7ad7f0`) instead of keeping the last aim. - Every model lane takes one name rule (`0x706a50`): the last dot, `.mdl`/`.mdx` to `.m2`, `.m2` as is, anything else the ErrorCube (`0x70737f`); the name is cut at 0x103 bytes first. - `LaunchURL` refuses a digit after `http://` (`0x73fb91`, isalpha). A missing file that passes the rule still stands nothing; that is #330's fallback. Tests pin each fact; a byte audit re-derived every address and lane. Fixes #1836 Fixes #1873 Fixes #1108 Refs #330 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/portrait/glue_booth.rs | 418 ++++++++++++------ crates/benilla-app/src/portrait/mod.rs | 5 +- .../src/ui_script/glue_mgr_tests.rs | 41 +- .../benilla-app/src/ui_script/glue_scene.rs | 128 ++++-- crates/benilla-assets/src/lib.rs | 45 +- crates/benilla-assets/src/m2.rs | 11 +- crates/benilla-formats/src/cinematics.rs | 33 +- crates/benilla-formats/src/ground_effects.rs | 23 +- crates/benilla-formats/src/items.rs | 9 +- crates/benilla-formats/src/lib.rs | 33 +- crates/benilla-formats/src/light.rs | 2 +- .../src/models/anim_summary.rs | 4 +- crates/benilla-formats/src/models/bounds.rs | 4 +- .../benilla-formats/src/models/collision.rs | 4 +- crates/benilla-formats/src/models/mod.rs | 83 +++- crates/benilla-formats/src/models/wmo/root.rs | 2 +- crates/benilla-ui/src/script/glue_core.rs | 15 +- crates/benilla-ui/src/script/glue_select.rs | 10 + docs/DEVIATIONS.md | 14 +- 19 files changed, 604 insertions(+), 280 deletions(-) diff --git a/crates/benilla-app/src/portrait/glue_booth.rs b/crates/benilla-app/src/portrait/glue_booth.rs index 006434fb9..226b92f15 100644 --- a/crates/benilla-app/src/portrait/glue_booth.rs +++ b/crates/benilla-app/src/portrait/glue_booth.rs @@ -6,13 +6,12 @@ use benilla_assets::M2Model; use bevy::camera::visibility::RenderLayers; -use bevy::camera::PerspectiveProjection; +use bevy::camera::{OrthographicProjection, PerspectiveProjection}; use bevy::prelude::*; use bevy::render::render_resource::Extent3d; use bevy::window::PrimaryWindow; use crate::entities::Creatures; -use benilla_assets::m2_url; use benilla_assets::materials::WowModelMaterial; use super::booth::{redress_booth_batches, set_grip, BoothBatch, BoothRig}; @@ -121,86 +120,70 @@ impl GlueLook { } } -/// Which glue background scene shows, by its model's token, `UI_`: the login screen's main -/// menu (`AccountLogin.xml`'s `ModelFFX`) or a race's stage. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(crate) enum GlueScene { - MainMenu, - Human, - Orc, - Dwarf, - NightElf, - Scourge, - Tauren, +/// The glue background scene's file, the pane's own: the path its last `SetModel` gave it +/// (`SetCharSelectBackground` and `SetCharCustomizeBackground` reach the same slot, `0x472128`, +/// `0x4707c8`), whatever it names. The identity is the model cache's key: the name with its +/// extension at the last dot folded to `.m2` and its case folded (`tolower` `0x40f92b`, `SStrCmpI`, +/// `0x706b35`-`0x706b8a`), its separators as written, so `/` and `\` spell two keys. +#[derive(Clone, Debug)] +pub(crate) struct GlueSceneFile { + path: String, + url: std::sync::Arc, + key: std::sync::Arc, } -impl GlueScene { - const ALL: [GlueScene; 7] = [ - GlueScene::MainMenu, - GlueScene::Human, - GlueScene::Orc, - GlueScene::Dwarf, - GlueScene::NightElf, - GlueScene::Scourge, - GlueScene::Tauren, - ]; +impl PartialEq for GlueSceneFile { + fn eq(&self, other: &Self) -> bool { + self.key == other.key + } +} - /// The model-name token, `Interface\Glues\Models\UI_\UI_.mdx`. - pub(crate) fn token(self) -> &'static str { - match self { - GlueScene::MainMenu => "MainMenu", - GlueScene::Human => "Human", - GlueScene::Orc => "Orc", - GlueScene::Dwarf => "Dwarf", - GlueScene::NightElf => "NightElf", - GlueScene::Scourge => "Scourge", - GlueScene::Tauren => "Tauren", +impl GlueSceneFile { + /// The scene file `path` names: `0x76cce0` creates a model frame's model with flags 5 + /// (`0x76ccf5`), whose 4 bit lets the `.m2` and its textures open loose. A refused name keys + /// as the error cube it stands. + pub(crate) fn new(path: &str) -> Self { + let key = benilla_formats::m2_file_name(path) + .or_else(|| benilla_formats::m2_file_name(benilla_assets::ERROR_CUBE)) + .unwrap_or_default(); + Self { + path: path.to_string(), + url: benilla_assets::m2_url_on(benilla_assets::MPQ_LOOSE_SOURCE, path).into(), + key: key.into(), } } - /// A race's stage, `GlueParent.lua`'s `SetBackgroundModel` remap: Gnome stands in Dwarf's, - /// Troll in Orc's. - pub(crate) fn of_race(race: u8) -> Self { - match race { - 2 | 8 => GlueScene::Orc, - 3 | 7 => GlueScene::Dwarf, - 4 => GlueScene::NightElf, - 5 => GlueScene::Scourge, - 6 => GlueScene::Tauren, - _ => GlueScene::Human, - } + /// The path as the pane holds it. + pub(crate) fn path(&self) -> &str { + &self.path } - /// The scene a model path names, by its file stem (`UI_`, any case, any extension); - /// `None` for a file that is none of the shipped scenes. - pub(crate) fn from_model_path(path: &str) -> Option { - let file = path.rsplit(['\\', '/']).next()?; - let stem = file.rsplit_once('.').map_or(file, |(stem, _)| stem); - let token = stem.get(..3).filter(|p| p.eq_ignore_ascii_case("UI_"))?; - let token = &stem[token.len()..]; - Self::ALL - .into_iter() - .find(|s| s.token().eq_ignore_ascii_case(token)) + /// The URL the scene loads from. + pub(crate) fn url(&self) -> &str { + &self.url } +} - /// The fog the stock files give the scene, near 0: the race rows are `GlueParent.lua`'s - /// `CharModelFogInfo`; the main menu's is `AccountLogin.xml`'s `` and `fogFar`. - pub(crate) fn stock_fog(self) -> SceneFog { - let (rgb, far) = match self { - GlueScene::MainMenu => ([0.25, 0.06, 0.015], 1200.0), - GlueScene::Orc => ([0.5, 0.5, 0.5], 270.0), - GlueScene::Dwarf => ([0.85, 0.88, 1.0], 500.0), - GlueScene::NightElf => ([0.25, 0.22, 0.55], 611.0), - GlueScene::Scourge => ([0.0, 0.22, 0.22], 26.0), - GlueScene::Tauren => ([1.0, 0.61, 0.42], 153.0), - GlueScene::Human => ([0.8, 0.65, 0.73], 222.0), - }; +/// The `WOW_CREATE_TEST` harness's stage for `race`: `GlueParent.lua`'s `SetBackgroundModel` +/// remap (Gnome in Dwarf's, Troll in Orc's) and its `CharModelFogInfo` row, near 0. +fn stock_scene(race: u8) -> (GlueSceneFile, SceneFog) { + let (token, rgb, far) = match race { + 2 | 8 => ("Orc", [0.5, 0.5, 0.5], 270.0), + 3 | 7 => ("Dwarf", [0.85, 0.88, 1.0], 500.0), + 4 => ("NightElf", [0.25, 0.22, 0.55], 611.0), + 5 => ("Scourge", [0.0, 0.22, 0.22], 26.0), + 6 => ("Tauren", [1.0, 0.61, 0.42], 153.0), + _ => ("Human", [0.8, 0.65, 0.73], 222.0), + }; + let path = format!("Interface\\Glues\\Models\\UI_{token}\\UI_{token}.mdx"); + ( + GlueSceneFile::new(&path), SceneFog { rgb, near: 0.0, far, - } - } + }, + ) } /// A scene's fog: the colour and the span the fog fill callback (`0x76d680`) reads off the pane. @@ -218,7 +201,7 @@ pub(crate) struct SceneFog { /// none once `SetCharSelectBackground` put the select screen's in its place (`0x472150`). #[derive(Resource)] pub(crate) struct GluePreview { - pub(crate) scene: Option, + pub(crate) scene: Option, pub(crate) look: Option, pub(crate) yaw: f32, pub(crate) fog: Option, @@ -240,6 +223,30 @@ pub(crate) struct GluePreview { /// `ModelFFX` keeps its model and scene and advances its clock only in its `OnUpdate` /// (`0x76d7f0`), which the visible-frame pump (`0x765650`) calls ([`pace_glue_clock`]). pub(crate) hidden: bool, + /// The pane's root inputs, which place the scene on the orthographic leg a file with no + /// camera takes ([`ortho_scene_rig`]). + pub(crate) root: SceneRoot, +} + +/// A scene pane's root inputs (`0x76d1a0`): `SetModelScale`, the frame's effective scale, +/// `SetPosition` in layout units and `SetFacing` in radians. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) struct SceneRoot { + pub(crate) model_scale: f32, + pub(crate) layout_scale: f32, + pub(crate) position: Vec3, + pub(crate) facing: f32, +} + +impl Default for SceneRoot { + fn default() -> Self { + Self { + model_scale: 1.0, + layout_scale: 1.0, + position: Vec3::ZERO, + facing: 0.0, + } + } } impl Default for GluePreview { @@ -254,6 +261,7 @@ impl Default for GluePreview { shown: 0, hidden: false, live: Vec::new(), + root: SceneRoot::default(), } } } @@ -262,10 +270,10 @@ impl GluePreview { /// The create screen's stage: the race's scene, fogged as the create screen fogs it, with the /// dialled look standing in it. pub(crate) fn show_create(&mut self, race: u8, look: CreateLook) { - let scene = GlueScene::of_race(race); + let (scene, fog) = stock_scene(race); self.scene = Some(scene); self.look = Some(GlueLook::Create(look)); - self.fog = Some(scene.stock_fog()); + self.fog = Some(fog); } } @@ -275,7 +283,7 @@ impl GluePreview { #[derive(Resource)] pub(crate) struct CreateScene { root: Entity, - token: Option<&'static str>, + file: Option, handle: Option>, spawned: bool, /// The authored camera 0, captured at spawn. @@ -332,7 +340,7 @@ pub(crate) struct CreateScene { /// keep the values they stopped at. struct ParkedScene { root: Entity, - token: &'static str, + file: GlueSceneFile, handle: Handle, cam: Option, char_spot: Vec3, @@ -350,7 +358,7 @@ impl CreateScene { fn empty(root: Entity, pet_root: Entity) -> Self { CreateScene { root, - token: None, + file: None, handle: None, spawned: false, cam: None, @@ -378,7 +386,7 @@ impl CreateScene { /// Step off the standing scene for stage `next`: park it while its pane still holds it /// (`live`), else strip its root; then stand `next` back up if it is parked, returning whether /// it was, so the caller lights it instead of spawning. `children` lists an entity's children, - /// for the emitters to freeze and thaw. Token and handle stay for the caller to set on a + /// for the emitters to freeze and thaw. File and handle stay for the caller to set on a /// spawn; a resumed scene brings its own. fn leave_for( &mut self, @@ -394,12 +402,12 @@ impl CreateScene { .position(|p| p.stage == next) .map(|i| self.parked.remove(i)); let parks = self.spawned && self.stage != next && live.contains(&self.stage); - match (parks, self.token, self.handle.clone()) { - (true, Some(token), Some(handle)) => { + match (parks, self.file.clone(), self.handle.clone()) { + (true, Some(file), Some(handle)) => { hold_scene(commands, self.root, true, children); self.parked.push(ParkedScene { root: self.root, - token, + file, handle, cam: self.cam, char_spot: self.char_spot, @@ -435,7 +443,7 @@ impl CreateScene { }; hold_scene(commands, p.root, false, children); self.root = p.root; - self.token = Some(p.token); + self.file = Some(p.file); self.handle = Some(p.handle); self.cam = p.cam; self.char_spot = p.char_spot; @@ -456,14 +464,14 @@ impl CreateScene { fn stand_down(&mut self, commands: &mut Commands) -> bool { self.evict(commands, &[]); self.variants.clear(); - if self.token.is_none() { + if self.file.is_none() { return false; } commands.entity(self.root).despawn_related::(); self.dropped.push(self.root); // Strip the rig state (pose buffer, player, palette slot): the root outlives its bakes. super::clear_booth_rig(commands, self.root); - self.token = None; + self.file = None; self.handle = None; self.spawned = false; self.cam = None; @@ -526,8 +534,8 @@ fn hold_scene( /// wrong stage; the reference's `SelectCharacter` loads both in one blocking call. The loads start /// on the click; only the commit waits. struct PendingSwap { - /// The requested scene token (`UI_`). - token: &'static str, + /// The requested scene file. + file: GlueSceneFile, /// Its model, loading since the click. handle: Handle, /// `Time` at the click, for [`GLUE_SWAP_HOLD_SECS`]. @@ -848,8 +856,9 @@ fn glue_ffx( } } -/// Drive the background scene ([`CreateScene`]): load and spawn the race's `UI_*` model, size the -/// target to the window, seat the character, and hold the booth camera on the scene's camera 0. +/// Drive the background scene ([`CreateScene`]): load and spawn the pane's file, size the target +/// to the window, seat the character, and hold the booth camera on the scene's camera 0, or with +/// none on the orthographic leg. #[allow(clippy::type_complexity)] pub(super) fn sync_glue_scene( mut commands: Commands, @@ -902,7 +911,7 @@ pub(super) fn sync_glue_scene( return; }; let children = |e: Entity| kids.get(e).map(|c| c.to_vec()).unwrap_or_default(); - let Some(which) = preview.scene else { + let Some(which) = preview.scene.as_ref() else { // Off the glue screens: tear the scene down, free the model, square target back. if scene.stand_down(&mut commands) { resize_target(&mut images, &booth.target, GLUE_SIZE, GLUE_SIZE); @@ -913,7 +922,7 @@ pub(super) fn sync_glue_scene( return; }; - // The target is the window's, before a token resolves; a pane is a viewport inside it + // The target is the window's, before the file resolves; a pane is a viewport inside it // ([`pillarbox_glue_scene`]). if let Ok(w) = window.single() { resize_target( @@ -925,29 +934,28 @@ pub(super) fn sync_glue_scene( } scene.evict(&mut commands, &preview.live); - let token = which.token(); + let name = which.path(); let fog = preview.fog; // The ghost light fork (`0x472150`, [`ghost_rig`]), shared by the scene, character and pet. let ghost = matches!( preview.look, Some(GlueLook::Select(l)) if l.flags & benilla_protocol::CHARACTER_FLAG_GHOST != 0 ); - if scene.token == Some(token) { + let standing_now = scene.file.as_ref() == Some(which); + if standing_now { // The requested stage is already standing: no swap pends. scene.pending = None; } // A parked scene that stands again ([`CreateScene::leave_for`]). let mut resumed = false; - if scene.token != Some(token) { + if !standing_now { // The pair swaps together or not at all ([`PendingSwap`]). let now = real.elapsed_secs(); let pending = match scene.pending.take() { - Some(p) if p.token == token => p, + Some(p) if p.file == *which => p, _ => PendingSwap { - token, - handle: asset_server.load(m2_url(&format!( - "Interface\\Glues\\Models\\UI_{token}\\UI_{token}.mdx" - ))), + file: which.clone(), + handle: asset_server.load(which.url().to_string()), since: now, }, }; @@ -963,7 +971,7 @@ pub(super) fn sync_glue_scene( } if standing && waited >= GLUE_SWAP_HOLD_SECS { warn!( - "glue scene: UI_{token} swapping after {waited:.1}s without its pair (art \ + "glue scene: {name} swapping after {waited:.1}s without its pair (art \ {art_ready}, body {body_ready}) — the bound is GLUE_SWAP_HOLD_SECS" ); } @@ -976,7 +984,7 @@ pub(super) fn sync_glue_scene( &children, ); if !resumed { - scene.token = Some(token); + scene.file = Some(pending.file); scene.handle = Some(pending.handle); } } else if scene.spawned && scene.stage != preview.stage { @@ -1041,7 +1049,7 @@ pub(super) fn sync_glue_scene( scene.pet_baked = None; scene.rev += 1; info!( - "glue scene: UI_{token} resumed at {} ms on its own clock", + "glue scene: {name} resumed at {} ms on its own clock", scene.clock_ms ); apply_yaw( @@ -1091,7 +1099,7 @@ pub(super) fn sync_glue_scene( scene.ghost = ghost; let dc = blob.probe_dc(); info!( - "create scene: UI_{token} rig — ambient {:?}, {} point light(s), probe dc ({:.2},{:.2},{:.2})", + "create scene: {name} rig — ambient {:?}, {} point light(s), probe dc ({:.2},{:.2},{:.2})", rig.ambient, blob.point_count(), dc[0], @@ -1169,7 +1177,7 @@ pub(super) fn sync_glue_scene( }) .collect(); info!( - "create scene: UI_{token} material lane — {} of {} batches animate (UV/tint registered)", + "create scene: {name} material lane — {} of {} batches animate (UV/tint registered)", scene_parts.iter().filter(|p| p.mat_anim).count(), scene_parts.len(), ); @@ -1202,7 +1210,7 @@ pub(super) fn sync_glue_scene( ); if emitters > 0 { info!( - "create scene: UI_{token} — {emitters} particle emitter(s) up \ + "create scene: {name} — {emitters} particle emitter(s) up \ ({fx_frames} on a billboard frame)" ); } @@ -1226,7 +1234,7 @@ pub(super) fn sync_glue_scene( scene.pet_baked = None; scene.rev += 1; // the character re-lights onto the scene rig (sync_glue_booth's key) info!( - "create scene: UI_{token} up — {} submeshes, camera {} stage {:?} facing {:.2}° \ + "create scene: {name} up — {} submeshes, camera {} stage {:?} facing {:.2}° \ scale {:.3}", model.submeshes.len(), scene.cam.is_some(), @@ -1244,21 +1252,19 @@ pub(super) fn sync_glue_scene( preview.yaw, ); } - // The authored camera owns the booth camera while the scene shows, every frame. + // The installed camera owns the booth camera while the scene shows, every frame: the file's + // camera 0, or with none the orthographic leg (`0x76ceeb` installs no camera, `0x76ce80(NULL)`), + // never the last scene's aim. + let w = window.single().ok(); + let pane = w + .zip(preview.pane) + .and_then(|(w, pane)| stock_pane_view(w.physical_width(), w.physical_height(), pane)); if let Some(cam) = scene.cam { - let aspect = window - .single() - .map(|w| w.width() / w.height().max(1.0)) - .unwrap_or(4.0 / 3.0); + let aspect = w.map_or(4.0 / 3.0, |w| w.width() / w.height().max(1.0)); let fwd = (cam.target - cam.eye).normalize_or_zero(); let up = Quat::from_axis_angle(fwd, cam.roll) * Vec3::Y; // The record fov is diagonal, converted at the pane's own aspect as the reference does. // Far is kept generous (27.8 authored on Orc). - let pane = window - .single() - .ok() - .zip(preview.pane) - .and_then(|(w, pane)| stock_pane_view(w.physical_width(), w.physical_height(), pane)); let vert_fov = scene_vert_fov(cam.fov, aspect, pane); let rig = ( Transform::from_translation(cam.eye).looking_at(cam.target, up), @@ -1270,9 +1276,59 @@ pub(super) fn sync_glue_scene( }), ); aim(&mut cams, GLUE_SLOT, &rig); + } else if let Some(w) = w.filter(|_| scene.spawned) { + let window_px = UVec2::new(w.physical_width(), w.physical_height()).max(UVec2::ONE); + // The projection spans the pane's raw rectangle, unclamped and unrounded, which the + // clamped viewport then squashes (`modelview#viewport-and-clamp`). + let rect = preview + .pane + .map(|[l, r, t, b]| window_px.as_vec2() * Vec2::new(r - l, b - t)) + .filter(|size| size.min_element() > 0.0) + .unwrap_or(window_px.as_vec2()); + aim( + &mut cams, + GLUE_SLOT, + &ortho_scene_rig(window_px, rect, preview.root), + ); } } +/// The orthographic leg of a scene pane with no camera, as a camera in the scene's own model +/// space over a `rect`-pixel pane of a `window`-pixel screen. The paint projects the pane's +/// rectangle with model (0, 0) at its bottom-left, +X right, +Y up and +Z toward the viewer, depth +/// ±500 layout units about a view at z 0 (`0x7ad7f0`, `0x5c3d90`), through the root `T(layoutScale · position) · +/// R(facing, +Z) · S(G48 · 5/3 · modelScale · layoutScale)` (`0x76d1a0`): one model unit is +/// `5/3 · modelScale · layoutScale` screen heights, one layout unit `√(a²+1)` of them. +fn ortho_scene_rig(window: UVec2, rect: Vec2, root: SceneRoot) -> (Transform, Projection) { + use benilla_assets::coords::wow_to_bevy; + let h = window.y as f32; + let diag = (window.x as f32 / h).hypot(1.0); + let px_per_unit = (5.0 / 3.0 * root.model_scale * root.layout_scale * h).max(f32::EPSILON); + let px_per_layout = diag * h; + let pos = root.position * root.layout_scale * px_per_layout; + let depth = 500.0 * px_per_layout; + let (sin, cos) = (-root.facing).sin_cos(); + let unturn = |v: Vec2| Vec2::new(cos * v.x - sin * v.y, sin * v.x + cos * v.y); + let centre = unturn((rect * 0.5 - pos.truncate()) / px_per_unit); + let eye = [centre.x, centre.y, -pos.z / px_per_unit]; + let up = unturn(Vec2::Y); + ( + Transform::from_translation(wow_to_bevy(eye)).looking_to( + wow_to_bevy([0.0, 0.0, -1.0]), + wow_to_bevy([up.x, up.y, 0.0]), + ), + Projection::Orthographic(OrthographicProjection { + near: -depth / px_per_unit, + far: depth / px_per_unit, + scaling_mode: bevy::camera::ScalingMode::Fixed { + width: rect.x / px_per_unit, + height: rect.y / px_per_unit, + }, + ..OrthographicProjection::default_3d() + }), + ) +} + /// The scene camera's vertical opening for its record's diagonal `fov` by the reference's law /// (`0x5c3cc0`): at a stock pane's own aspect, else, with no pane laid out, the window's, as a /// fullscreen `ModelFFX` fills it (`0x76d42d`). @@ -1457,7 +1513,7 @@ pub(super) fn sync_glue_booth( // stage matching the request means this bake may show ([`PendingSwap`]). let staged = scene .as_deref() - .is_none_or(|s| s.token == preview.scene.map(GlueScene::token)); + .is_none_or(|s| s.file.as_ref() == preview.scene.as_ref()); let rebake = staged && (last_rev != bake.revision || last_scene != scene_rev); let reyaw = last_yaw != preview.yaw; if !rebake && !reyaw { @@ -2303,30 +2359,103 @@ mod tests { assert_eq!(set(&mut app, Some(living), true), GlueFfx::SelectLiving); } - /// A scene is its model's token, from the path the glue screens give the pane in any case or - /// extension; `SetBackgroundModel`'s race remap lands on the same scenes. + /// A scene is the pane's own file, loaded loose-aware (`0x76ccf5`, flags 5): a path under any + /// folder keeps its own URL, and a case or extension spelling of one file is one scene. #[test] - fn a_scene_is_named_by_its_models_token() { - for scene in GlueScene::ALL { - let token = scene.token(); - let path = format!("Interface\\Glues\\Models\\UI_{token}\\UI_{token}.mdx"); - assert_eq!(GlueScene::from_model_path(&path), Some(scene), "{path}"); - } + fn a_scene_is_the_panes_own_file() { + let custom = GlueSceneFile::new("Interface\\Glues\\Models\\Custom\\UI_Orc.mdx"); assert_eq!( - GlueScene::from_model_path("interface/glues/models/ui_nightelf/UI_NIGHTELF.M2"), - Some(GlueScene::NightElf) + custom.url(), + "mpq-loose://interface/glues/models/custom/ui_orc.m2" ); - for odd in [ - "", - "UI_", - "UI_Gnome.mdx", - "Interface\\Glues\\Models\\Orc.mdx", - ] { - assert_eq!(GlueScene::from_model_path(odd), None, "{odd}"); - } - assert_eq!(GlueScene::of_race(8), GlueScene::Orc); - assert_eq!(GlueScene::of_race(7), GlueScene::Dwarf); - assert_eq!(GlueScene::of_race(1), GlueScene::Human); + assert_ne!(custom, stock_scene(2).0, "not the stock UI_Orc"); + // Case and extension fold; the separators do not. + let night_elf = "Interface\\Glues\\Models\\UI_NightElf\\UI_NightElf.MDX"; + assert_eq!( + GlueSceneFile::new(night_elf), + GlueSceneFile::new("interface\\glues\\models\\ui_nightelf\\ui_nightelf.m2"), + ); + assert_ne!( + GlueSceneFile::new(night_elf), + GlueSceneFile::new("interface/glues/models/ui_nightelf/ui_nightelf.m2"), + ); + assert_eq!( + GlueSceneFile::new("Interface\\Glues\\Models\\UI_Gnome\\UI_Gnome.mdx").path(), + "Interface\\Glues\\Models\\UI_Gnome\\UI_Gnome.mdx" + ); + assert_eq!( + stock_scene(8).0.url(), + "mpq-loose://interface/glues/models/ui_orc/ui_orc.m2" + ); + assert_eq!(stock_scene(7).0, stock_scene(3).0, "a gnome in Dwarf's"); + // A file the cache refuses stands the error cube, off the archives (`0x70737f`). + assert_eq!( + GlueSceneFile::new("Interface\\Glues\\Models\\UI_Orc\\UI_Orc.blp").url(), + "mpq://spells/errorcube.m2" + ); + } + + /// Clip-space XY of a Bevy-space point through an orthographic rig. + fn ortho_ndc(rig: &(Transform, Projection), rect: Vec2, p: Vec3) -> Vec2 { + use bevy::camera::CameraProjection; + let Projection::Orthographic(mut ortho) = rig.1.clone() else { + panic!("the orthographic leg"); + }; + ortho.update(rect.x, rect.y); + let clip = ortho.get_clip_from_view() * rig.0.to_matrix().inverse() * p.extend(1.0); + clip.truncate().truncate() / clip.w + } + + /// A scene file with no camera takes the orthographic leg (`0x76ceeb` → `0x76ce80(NULL)`): + /// model (0, 0) at the pane's bottom-left, +X right, +Y up, one model unit `5/3 · modelScale + /// · layoutScale` screen heights, a position in layout units of `√(a²+1)` screen heights, and + /// the view at layout z 0 with depth ±500 layout units (`0x76d1a0`, `0x7ad7f0`). + #[test] + fn a_scene_with_no_camera_draws_on_the_orthographic_leg() { + use benilla_assets::coords::wow_to_bevy; + let near = |got: Vec2, want: Vec2| (got - want).abs().max_element() < 1e-4; + let window = UVec2::new(1024, 768); + let full = window.as_vec2(); + let at = |rig: &(Transform, Projection), p: [f32; 3]| ortho_ndc(rig, full, wow_to_bevy(p)); + let rig = ortho_scene_rig(window, full, SceneRoot::default()); + assert!(near(at(&rig, [0.0; 3]), Vec2::new(-1.0, -1.0))); + // 768 px is 0.6 model units at scale 1; 1024 px is 0.8. + assert!(near(at(&rig, [0.8, 0.6, 0.0]), Vec2::new(1.0, 1.0))); + // A quarter turn puts +X up. + let turned = SceneRoot { + model_scale: 2.0, + facing: std::f32::consts::FRAC_PI_2, + ..default() + }; + let rig = ortho_scene_rig(window, full, turned); + assert!(near(at(&rig, [0.3, 0.0, 0.0]), Vec2::new(-1.0, 1.0))); + + // 16:9, a 600×400 pane, layout scale 0.8, model scale 1.5: a model unit is + // 5/3 · 1.5 · 0.8 · 1080 = 2160 px, a layout unit 1080 · √((16/9)² + 1) px. + let window = UVec2::new(1920, 1080); + let rect = Vec2::new(600.0, 400.0); + let root = SceneRoot { + model_scale: 1.5, + layout_scale: 0.8, + position: Vec3::new(0.05, 0.02, 0.1), + facing: 0.0, + }; + let (px_per_unit, px_per_layout) = (2160.0, 1080.0 * (16.0f32 / 9.0).hypot(1.0)); + let pos = root.position * root.layout_scale * px_per_layout / px_per_unit; + let rig = ortho_scene_rig(window, rect, root); + let at = |p: Vec3| ortho_ndc(&rig, rect, wow_to_bevy(p.to_array())); + assert!(near(at(-pos), Vec2::new(-1.0, -1.0)), "{}", at(-pos)); + let corner = (rect / px_per_unit).extend(0.0) - pos; + assert!(near(at(corner), Vec2::new(1.0, 1.0)), "{}", at(corner)); + // Layout z 0 is the view plane, and depth runs ±500 layout units about it. + let plane = Vec3::new(0.0, 0.0, -pos.z); + let view = rig.0.to_matrix().inverse() * wow_to_bevy(plane.to_array()).extend(1.0); + assert!(view.z.abs() < 1e-4, "{view}"); + let Projection::Orthographic(o) = &rig.1 else { + unreachable!() + }; + let depth = 500.0 * px_per_layout / px_per_unit; + assert!((o.near + depth).abs() < 1e-2 && (o.far - depth).abs() < 1e-2); } /// `petDisplayId == 0` is the client's whole gate: the server zeroes the triple for all but a @@ -3111,7 +3240,7 @@ mod tests { e }; let mut scene = CreateScene::empty(login, pet); - scene.token = Some("MainMenu"); + scene.file = Some(GlueSceneFile::new("UI_MainMenu.mdx")); scene.handle = Some(Handle::default()); scene.spawned = true; scene.stage = 1; @@ -3173,15 +3302,20 @@ mod tests { // The select stage stands, then the return to login. let select = scene.root; - scene.token = Some("Orc"); + scene.file = Some(GlueSceneFile::new("UI_Orc.mdx")); scene.spawned = true; scene.stage = 2; scene.clock_ms = 50; assert!(step(&mut world, &mut scene, &[1, 2], 1), "login resumes"); assert_eq!(scene.root, login); assert_eq!( - (scene.token, scene.stage, scene.clock_ms, scene.spawned), - (Some("MainMenu"), 1, 1234, true), + ( + scene.file.clone(), + scene.stage, + scene.clock_ms, + scene.spawned + ), + (Some(GlueSceneFile::new("UI_MainMenu.mdx")), 1, 1234, true), "on the clock it stopped at" ); assert_eq!( @@ -3225,7 +3359,7 @@ mod tests { resumed }; let stand = |scene: &mut CreateScene, stage: u64, clock_ms: u64| { - scene.token = Some("Orc"); + scene.file = Some(GlueSceneFile::new("UI_Orc.mdx")); scene.handle = Some(Handle::default()); scene.spawned = true; scene.stage = stage; @@ -3299,12 +3433,12 @@ mod tests { let parked_root = world.spawn(Transform::IDENTITY).id(); let pet = world.spawn_empty().id(); let mut scene = CreateScene::empty(standing, pet); - scene.token = Some("Orc"); + scene.file = Some(GlueSceneFile::new("UI_Orc.mdx")); scene.handle = Some(Handle::default()); scene.spawned = true; scene.parked.push(ParkedScene { root: parked_root, - token: "MainMenu", + file: GlueSceneFile::new("UI_MainMenu.mdx"), handle: Handle::default(), cam: None, char_spot: Vec3::ZERO, @@ -3324,7 +3458,7 @@ mod tests { queue.apply(&mut world); assert!(stood); assert!(scene.variants.is_empty(), "the twins go"); - assert!(scene.parked.is_empty() && scene.token.is_none()); + assert!(scene.parked.is_empty() && scene.file.is_none()); assert_eq!(scene.dropped, vec![parked_root, standing]); assert!(world.get_entity(parked_root).is_err()); } diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index 9c0829cfb..0751f344c 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -57,8 +57,9 @@ mod glue_booth; /// A model file's own authored lights, folded as the reference's gather does (`0x718960`). pub(crate) use glue_booth::{scene_rig, SceneRig}; pub(crate) use glue_booth::{ - CreateLook, GhostKit, GlueLook, GluePetBake, GluePreview, GluePreviewBake, GlueScene, PetLook, - PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider, SceneFog, SelectLook, GLUE_SLOT, + CreateLook, GhostKit, GlueLook, GluePetBake, GluePreview, GluePreviewBake, GlueSceneFile, + PetLook, PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider, SceneFog, SceneRoot, + SelectLook, GLUE_SLOT, }; mod light; pub(crate) use light::{material_variant, VariantLane}; diff --git a/crates/benilla-app/src/ui_script/glue_mgr_tests.rs b/crates/benilla-app/src/ui_script/glue_mgr_tests.rs index cc4ef1a93..6e3e4ca5c 100644 --- a/crates/benilla-app/src/ui_script/glue_mgr_tests.rs +++ b/crates/benilla-app/src/ui_script/glue_mgr_tests.rs @@ -3070,6 +3070,13 @@ fn preview(app: &App) -> &crate::portrait::GluePreview { app.world().resource::() } +/// The stock scene file `GlueParent.lua`'s `SetBackgroundModel` names, `UI_`. +fn stock_scene(name: &str) -> crate::portrait::GlueSceneFile { + crate::portrait::GlueSceneFile::new(&format!( + "Interface\\Glues\\Models\\UI_{name}\\UI_{name}.mdx" + )) +} + /// The stock login's `AccountLogin` pane drives the glue booth: `UI_MainMenu` under the pane's /// own fog, `AccountLogin.xml`'s `` and `fogFar`, with nobody standing in it. #[test] @@ -3077,7 +3084,7 @@ fn the_stock_login_stands_the_main_menu_in_the_glue_booth() { let _ = benilla_formats::wow_data_or_skip!(); let (app, _rx, _abandon) = stock_app(); let p = preview(&app); - assert_eq!(p.scene, Some(crate::portrait::GlueScene::MainMenu)); + assert_eq!(p.scene, Some(stock_scene("MainMenu"))); assert!(p.look.is_none()); let fog = p.fog.expect("the main menu fogs"); assert_eq!((fog.near, fog.far), (0.0, 1200.0)); @@ -3091,7 +3098,7 @@ fn the_stock_login_stands_the_main_menu_in_the_glue_booth() { /// facing cell `SetCharacterSelectFacing` writes, and the next select squares it. #[test] fn the_stock_select_stands_the_selected_character_at_the_cells_facing() { - use crate::portrait::{GlueLook, GlueScene, SelectLook}; + use crate::portrait::{GlueLook, SelectLook}; let _ = benilla_formats::wow_data_or_skip!(); let mut roster = roster3(); let mut dwarf = crate::glue_session::roster::test_character(0x14, "Delta"); @@ -3107,14 +3114,17 @@ fn the_stock_select_stands_the_selected_character_at_the_cells_facing() { p.look == Some(GlueLook::Select(SelectLook::from(&roster[1]))), "lastCharacterIndex 1 stands Bravo" ); - assert_eq!((p.scene, p.fog), (Some(GlueScene::Human), None)); + assert_eq!((p.scene.clone(), p.fog), (Some(stock_scene("Human")), None)); assert_eq!(p.yaw, 0.0); vm(&app).run("CharacterSelect_SelectCharacter(1)").unwrap(); app.update(); let p = preview(&app); assert!(p.look == Some(GlueLook::Select(SelectLook::from(&roster[0])))); - assert_eq!((p.scene, p.fog), (Some(GlueScene::NightElf), None)); + assert_eq!( + (p.scene.clone(), p.fog), + (Some(stock_scene("NightElf")), None) + ); vm(&app).run("SetCharacterSelectFacing(90)").unwrap(); app.update(); @@ -3126,7 +3136,7 @@ fn the_stock_select_stands_the_selected_character_at_the_cells_facing() { assert_eq!(p.yaw, 0.0, "every select zeroes the facing (`0x472a61`)"); assert_eq!( p.scene, - Some(GlueScene::Dwarf), + Some(stock_scene("Dwarf")), "a gnome stands in Dwarf's stage" ); // The dwarf after the gnome: the same stage, set again by its own file string, is a new @@ -3135,7 +3145,7 @@ fn the_stock_select_stands_the_selected_character_at_the_cells_facing() { vm(&app).run("CharacterSelect_SelectCharacter(4)").unwrap(); app.update(); let p = preview(&app); - assert_eq!(p.scene, Some(GlueScene::Dwarf)); + assert_eq!(p.scene, Some(stock_scene("Dwarf"))); assert_ne!(p.stage, gnome_stage); // The pane is GlueParent's, the whole 4:3 screen. assert_eq!(p.pane, Some([0.0, 1.0, 0.0, 1.0])); @@ -3147,7 +3157,6 @@ fn the_stock_select_stands_the_selected_character_at_the_cells_facing() { /// empties: an empty roster sets no background. #[test] fn the_stock_select_fogs_the_empty_roster_and_keeps_the_last_stage() { - use crate::portrait::GlueScene; let _ = benilla_formats::wow_data_or_skip!(); let (mut app, _realmd, _world) = char_select_app(&[], "0", true); until(&mut app, "the roster load", |app| { @@ -3155,7 +3164,7 @@ fn the_stock_select_fogs_the_empty_roster_and_keeps_the_last_stage() { }); app.update(); let p = preview(&app); - assert_eq!(p.scene, Some(GlueScene::Orc)); + assert_eq!(p.scene, Some(stock_scene("Orc"))); assert!(p.look.is_none()); let fog = p.fog.expect("the empty roster's scene is fogged"); assert_eq!((fog.near, fog.far), (0.0, 270.0)); @@ -3168,7 +3177,10 @@ fn the_stock_select_fogs_the_empty_roster_and_keeps_the_last_stage() { }); app.update(); let p = preview(&app); - assert_eq!((p.scene, p.fog), (Some(GlueScene::Tauren), None)); + assert_eq!( + (p.scene.clone(), p.fog), + (Some(stock_scene("Tauren")), None) + ); assert!(p.look.is_some()); world.set_roster(&[]); @@ -3179,7 +3191,10 @@ fn the_stock_select_fogs_the_empty_roster_and_keeps_the_last_stage() { }); app.update(); let p = preview(&app); - assert_eq!((p.scene, p.fog), (Some(GlueScene::Tauren), None)); + assert_eq!( + (p.scene.clone(), p.fog), + (Some(stock_scene("Tauren")), None) + ); assert!(p.look.is_none(), "the records are freed and detached"); assert_eq!(vm(&app).errors(), Vec::::new()); } @@ -4604,7 +4619,7 @@ fn create_look(app: &App) -> benilla_ui::script::GlueCreateLook { /// up, which asks for the list again and selects the new last row (`SELECT_LAST_CHARACTER`). #[test] fn the_stock_create_makes_the_character_and_selects_it() { - use crate::portrait::{CreateLook, GlueLook, GlueScene}; + use crate::portrait::{CreateLook, GlueLook}; let _ = benilla_formats::wow_data_or_skip!(); let (mut app, _realmd, world) = create_screen(); assert!(shown(&app, "CharacterCreate") && !shown(&app, "CharacterSelect")); @@ -4637,7 +4652,7 @@ fn the_stock_create_makes_the_character_and_selects_it() { facial_hair: 0, })) ); - assert_eq!(p.scene, Some(GlueScene::Human)); + assert_eq!(p.scene, Some(stock_scene("Human"))); assert!((p.yaw + 15f32.to_radians()).abs() < 1e-5, "{}", p.yaw); // Orc (the list's fifth), female, Hunter (the Orc list's second), one skin colour on. @@ -4682,7 +4697,7 @@ fn the_stock_create_makes_the_character_and_selects_it() { facial_hair: cycled.facial_hair, })) ); - assert_eq!(p.scene, Some(GlueScene::Orc)); + assert_eq!(p.scene, Some(stock_scene("Orc"))); let fog = p.fog.expect("the create scene keeps its fog"); assert_eq!((fog.near, fog.far), (0.0, 270.0)); diff --git a/crates/benilla-app/src/ui_script/glue_scene.rs b/crates/benilla-app/src/ui_script/glue_scene.rs index a24f7a1ac..35098da24 100644 --- a/crates/benilla-app/src/ui_script/glue_scene.rs +++ b/crates/benilla-app/src/ui_script/glue_scene.rs @@ -25,7 +25,9 @@ use bevy::prelude::*; use benilla_ui::script::{GlueScenePane, UiScript, VmKind}; use super::glue_mgr::SharedGlueMgr; -use crate::portrait::{CreateLook, GlueLook, GluePreview, GlueScene, SceneFog, SelectLook}; +use crate::portrait::{ + CreateLook, GlueLook, GluePreview, GlueSceneFile, SceneFog, SceneRoot, SelectLook, +}; /// The create scene's character and facing, as the create verbs left them. #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -36,24 +38,23 @@ struct CreateStage { /// What the booth stands while the stock glue screens are up: `pane` is the shown `ModelFFX`'s /// scene, `standing` the select character on its attachment 0, `facing` the select facing cell in -/// radians, `create` the create scene's. A file that names none of the shipped scenes stands -/// nothing. +/// radians, `create` the create scene's. The scene is the pane's file, whatever it names: slot 37 +/// (`0x76cce0`) loads any non-empty name, and an empty one leaves the pane no model. fn stock_preview( pane: Option<&GlueScenePane>, standing: Option<&benilla_protocol::Character>, facing: f32, create: CreateStage, -) -> (Option, Option, f32, Option) { - let Some(pane) = pane else { +) -> ( + Option, + Option, + f32, + Option, +) { + let Some(pane) = pane.filter(|p| !p.path.is_empty()) else { return (None, None, 0.0, None); }; - let scene = GlueScene::from_model_path(&pane.path); - if scene.is_none() { - warn_once!( - "glue scene: {} is none of the shipped glue scenes; the glue booth stands nothing", - pane.path - ); - } + let scene = Some(GlueSceneFile::new(&pane.path)); // The character and the facing are the select frame's scene's (`[0xb42174]`) or the // customize frame's (`[0xb420f0]`), each its own. let (look, yaw) = if pane.select { @@ -180,6 +181,15 @@ pub(crate) fn feed_stock_glue_preview( if preview.pane != rect { preview.pane = rect; } + let root = pane.as_ref().map_or(SceneRoot::default(), |p| SceneRoot { + model_scale: p.model_scale, + layout_scale: p.layout_scale, + position: Vec3::from_array(p.position), + facing: p.facing, + }); + if preview.root != root { + preview.root = root; + } if preview.scene != scene { preview.scene = scene; } @@ -230,7 +240,7 @@ mod tests { 1.0, CreateStage::default(), ); - assert_eq!(scene, Some(GlueScene::MainMenu)); + assert_eq!(scene, Some(file("UI_MainMenu"))); assert!( look.is_none() && yaw == 0.0, "only the select frame's scene holds a character" @@ -288,7 +298,7 @@ mod tests { facing: -0.25, }, ); - assert_eq!(scene, Some(GlueScene::Orc)); + assert_eq!(scene, Some(file("UI_Orc"))); assert!( look == Some(GlueLook::Create(CreateLook { race: 2, @@ -306,24 +316,74 @@ mod tests { assert!(look.is_none()); } + /// The stock folder's scene file `UI_`. + fn file(name: &str) -> GlueSceneFile { + GlueSceneFile::new(&format!("Interface\\Glues\\Models\\{name}\\{name}.mdx")) + } + + /// The same file spelled with `/`, another key of the cache (`0x706b35`-`0x706b8a`). + fn slashed(name: &str) -> GlueSceneFile { + GlueSceneFile::new(&format!("Interface/Glues/Models/{name}/{name}.mdx")) + } + + /// No shown pane, or one whose `SetModel` name was empty, stands nothing; any other file + /// stands as itself (`0x76cce0`), a scene 1.12.1 never shipped included. #[test] - fn no_shown_pane_and_an_unknown_file_stand_nothing() { + fn a_pane_stands_whatever_file_it_holds() { + let none = CreateStage::default(); + assert_eq!(stock_preview(None, None, 0.0, none).0, None); assert_eq!( - stock_preview(None, None, 0.0, CreateStage::default()).0, + stock_preview(Some(&pane("", true, None)), None, 0.0, none).0, None ); - assert_eq!( - stock_preview( - Some(&pane("Interface\\Glues\\Models\\Odd.mdx", true, None)), - None, - 0.0, - CreateStage::default() - ) - .0, - None + let gnome = "Interface\\Glues\\Models\\UI_Gnome\\UI_Gnome.mdx"; + let (scene, ..) = stock_preview(Some(&pane(gnome, true, None)), None, 0.0, none); + assert_eq!(scene.as_ref().map(GlueSceneFile::path), Some(gnome)); + let custom = "Interface\\Glues\\Models\\Custom\\UI_Orc.mdx"; + let (scene, ..) = stock_preview(Some(&pane(custom, true, None)), None, 0.0, none); + assert_eq!(scene, Some(GlueSceneFile::new(custom))); + assert_ne!( + scene, + Some(file("UI_Orc")), + "its own file, not the stock UI_Orc" ); } + /// Through the verbs: `SetCharSelectBackground` stands its file unfogged whatever the script + /// sets after (`0x472150` replaces the fog callback), and `SetCharCustomizeBackground` keeps + /// the pane's fog (`0x4707b0`), none after `ClearFog`. + #[test] + fn the_background_verbs_stand_any_file_under_their_screens_fog() { + let s = UiScript::new_glue().unwrap(); + s.run( + "CreateFrame('ModelFFX', 'Select') SetCharSelectModelFrame('Select') \ + SetCharSelectBackground('Interface\\\\Glues\\\\Models\\\\UI_Goblin\\\\UI_Goblin.mdx') \ + Select:SetFogColor(1, 0, 0) Select:SetFogFar(100)", + ) + .unwrap(); + let none = CreateStage::default(); + let (scene, _, _, fog) = stock_preview(s.glue_scene().as_ref(), None, 0.0, none); + assert_eq!(scene, Some(file("UI_Goblin"))); + assert_eq!(fog, None, "the select scene never fogs"); + + s.run( + "Select:Hide() CreateFrame('ModelFFX', 'Create') SetCharCustomizeFrame('Create') \ + SetCharCustomizeBackground('Custom\\\\Stage.mdx') \ + Create:SetFogColor(1, 0, 0) Create:SetFogFar(100)", + ) + .unwrap(); + let (scene, _, _, fog) = stock_preview(s.glue_scene().as_ref(), None, 0.0, none); + assert_eq!(scene, Some(GlueSceneFile::new("Custom\\Stage.mdx"))); + assert_eq!( + fog.map(|f| f.far), + Some(100.0), + "the create scene keeps its fog" + ); + s.run("Create:ClearFog()").unwrap(); + let (.., fog) = stock_preview(s.glue_scene().as_ref(), None, 0.0, none); + assert_eq!(fog, None); + } + /// `SetGlueScreen("credits")` hides `AccountLogin` and shows no model (`GlueParent.lua:34-45`): /// the login pane keeps its model and scene while hidden, so the booth keeps standing it, /// hidden; the return shows the same instance, which fires its OnShow (`0x46fa60`) again. @@ -346,19 +406,19 @@ mod tests { s.resolve(); app.update(); let p = app.world().resource::(); - (p.scene, p.hidden, p.stage, p.shown) + (p.scene.clone(), p.hidden, p.stage, p.shown) }; let (scene, hidden, stage, shown) = run(&mut app, ""); - assert_eq!((scene, hidden), (Some(GlueScene::MainMenu), false)); + assert_eq!((scene, hidden), (Some(slashed("UI_MainMenu")), false)); assert_eq!( run(&mut app, "Login:Hide()"), - (Some(GlueScene::MainMenu), true, stage, shown), + (Some(slashed("UI_MainMenu")), true, stage, shown), "hidden, the scene stays up: no teardown, no new instance" ); assert_eq!( run(&mut app, "Login:Show()"), - (Some(GlueScene::MainMenu), false, stage, shown + 1), + (Some(slashed("UI_MainMenu")), false, stage, shown + 1), "shown again, the same instance resumes, and its OnShow pins the glow" ); @@ -368,7 +428,7 @@ mod tests { "Login:Hide() Login:SetModel('Interface/Glues/Models/UI_Orc/UI_Orc.mdx')", ); let (scene, hidden, again, _) = run(&mut app, "Login:Show()"); - assert_eq!((scene, hidden), (Some(GlueScene::Orc), false)); + assert_eq!((scene, hidden), (Some(slashed("UI_Orc")), false)); assert_ne!(again, stage); } @@ -395,13 +455,13 @@ mod tests { s.resolve(); app.update(); let p = app.world().resource::(); - (p.scene, p.stage, p.live.clone()) + (p.scene.clone(), p.stage, p.live.clone()) }; let (scene, login, live) = run(&mut app, ""); - assert_eq!((scene, live), (Some(GlueScene::MainMenu), vec![login])); + assert_eq!((scene, live), (Some(slashed("UI_MainMenu")), vec![login])); let (scene, select, live) = run(&mut app, "Login:Hide() Select:Show()"); - assert_eq!(scene, Some(GlueScene::Orc)); + assert_eq!(scene, Some(slashed("UI_Orc"))); assert_ne!(select, login); assert_eq!( live, @@ -412,7 +472,7 @@ mod tests { let (scene, back, live) = run(&mut app, "Select:Hide() Login:Show()"); assert_eq!( (scene, back), - (Some(GlueScene::MainMenu), login), + (Some(slashed("UI_MainMenu")), login), "the same instance, the same stage" ); assert_eq!(live, vec![login, select]); diff --git a/crates/benilla-assets/src/lib.rs b/crates/benilla-assets/src/lib.rs index 108af3647..105cd4cf1 100644 --- a/crates/benilla-assets/src/lib.rs +++ b/crates/benilla-assets/src/lib.rs @@ -205,16 +205,16 @@ pub fn m2_url(raw: &str) -> String { m2_url_on(MPQ_SOURCE, raw) } +pub use benilla_formats::ERROR_CUBE; + /// [`m2_url`] on the asset source `source`: [`MPQ_LOOSE_SOURCE`] for a model created with the -/// loose flag. +/// loose flag. The file is the cache's ([`benilla_formats::m2_file_name`]); a path it refuses +/// stands [`ERROR_CUBE`] with open flag 0, off the archives (`0x70737f`). pub fn m2_url_on(source: &str, raw: &str) -> String { - let p = raw.to_ascii_lowercase().replace('\\', "/"); - let stem = p - .strip_suffix(".mdx") - .or_else(|| p.strip_suffix(".mdl")) - .or_else(|| p.strip_suffix(".m2")) - .unwrap_or(&p); - format!("{source}://{stem}.m2") + match benilla_formats::m2_file_name(raw) { + Some(file) => format!("{source}://{}", file.replace('\\', "/")), + None => m2_url_on(MPQ_SOURCE, ERROR_CUBE), + } } /// A WMO root path as its `mpq://…wmo` URL, lowercased for handle dedup. @@ -297,6 +297,35 @@ mod tests { use bevy::camera::primitives::MeshAabb; use bevy::tasks::block_on; + /// `0x706a50`'s extension rule: the last dot's `.mdx`/`.mdl` in any case is `.m2`, `.m2` + /// stands, and anything else stands the error cube from the archives (`0x70737f`). + #[test] + fn a_model_path_is_taken_by_its_last_extension() { + assert_eq!( + m2_url_on(MPQ_LOOSE_SOURCE, "Creature\\Wolf\\Wolf.MDX"), + "mpq-loose://creature/wolf/wolf.m2" + ); + assert_eq!( + m2_url("Creature\\Wolf\\Wolf.Mdl"), + "mpq://creature/wolf/wolf.m2" + ); + assert_eq!( + m2_url("Creature\\Wolf\\Wolf.M2"), + "mpq://creature/wolf/wolf.m2" + ); + let cube = "mpq://spells/errorcube.m2"; + for refused in [ + "Creature\\Wolf\\Wolf", + "Creature\\Wolf\\Wolf.blp", + "Creature\\Wolf\\Wolf.mdx.bak", + "World\\a.mdx\\Wolf", + "", + ] { + assert_eq!(m2_url(refused), cube, "{refused}"); + assert_eq!(m2_url_on(MPQ_LOOSE_SOURCE, refused), cube, "{refused}"); + } + } + #[test] fn sampler_mode_rides_the_asset_path_and_round_trips() { let tex = "World\\KhazModan\\Ironforge\\PassiveDoodads\\Trees\\IronForgeleaves01.blp"; diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs index d7762cc11..10aaf2f9c 100644 --- a/crates/benilla-assets/src/m2.rs +++ b/crates/benilla-assets/src/m2.rs @@ -185,15 +185,10 @@ fn idle_pose_differs(anim: &benilla_formats::ModelAnimation) -> bool { !anim.is_rest_pose() } -/// A model path inside an M2 record (`.mdx`/`.mdl`, backslashes) as its `mpq://….m2` URL. +/// A model path inside an M2 record as its URL: an emitter's child model is created with flags 0 +/// (`0x7b1c99`, `0x7b5da7`), off the archives, under the cache's extension rule. fn m2_dep_url(raw: &str) -> String { - let p = raw.to_ascii_lowercase().replace('\\', "/"); - let stem = p - .strip_suffix(".mdx") - .or_else(|| p.strip_suffix(".mdl")) - .or_else(|| p.strip_suffix(".m2")) - .unwrap_or(&p); - format!("mpq://{stem}.m2") + crate::m2_url(raw) } /// One ribbon emitter, riding its bone like [`ModelEmitter::bone_pivot`] (`0x718960`). diff --git a/crates/benilla-formats/src/cinematics.rs b/crates/benilla-formats/src/cinematics.rs index f20c6e8a1..44b455e29 100644 --- a/crates/benilla-formats/src/cinematics.rs +++ b/crates/benilla-formats/src/cinematics.rs @@ -90,15 +90,11 @@ impl CinematicCatalog { } } -/// The archive path for a camera row's model: the table ships `.mdx`, the MPQ holds `.m2`. +/// The archive path for a camera row's model: `CCamera::LoadShot` creates it through `0x707350` +/// (`0x50c8b9`), so the cache's rule maps `.mdx` to the MPQ's `.m2` and a refused name reads the +/// error cube, which holds no camera. pub fn camera_model_path(model: &str) -> String { - let stem = model.rsplit_once('.').map_or(model, |(stem, ext)| { - match ext.to_ascii_lowercase().as_str() { - "mdx" | "mdl" | "m2" => stem, - _ => model, - } - }); - format!("{stem}.m2") + crate::models::model_path(model) } fn sequences_schema() -> Schema { @@ -320,12 +316,23 @@ mod tests { fn model_paths_map_mdx_to_the_archive_m2() { assert_eq!( camera_model_path("Cameras\\FlyByDwarf.mdx"), - "Cameras\\FlyByDwarf.m2" + "cameras\\flybydwarf.m2" ); - assert_eq!(camera_model_path("Cameras\\X.MDX"), "Cameras\\X.m2"); - assert_eq!(camera_model_path("Cameras\\X.m2"), "Cameras\\X.m2"); - // A path with no extension still names an `.m2`. - assert_eq!(camera_model_path("Cameras\\X"), "Cameras\\X.m2"); + assert_eq!(camera_model_path("Cameras\\X.MDX"), "cameras\\x.m2"); + assert_eq!(camera_model_path("Cameras\\X.m2"), "cameras\\x.m2"); + // A path with no extension is refused, and the shot reads the error cube. + assert_eq!(camera_model_path("Cameras\\X"), "spells\\errorcube.m2"); + } + + /// A refused camera name reads the error cube, which the install ships with no camera record + /// (its header's count at `0x124` is 0), so such a shot has no camera to fly. + #[test] + fn the_error_cube_holds_no_camera() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let path = camera_model_path("Cameras\\NoSuchShot"); + let bytes = chain.read_file(&path).expect("the error cube ships"); + assert!(benilla_m2::parse_cameras(&bytes).is_empty(), "{path}"); } #[test] diff --git a/crates/benilla-formats/src/ground_effects.rs b/crates/benilla-formats/src/ground_effects.rs index 6fe1c1171..dffd1d8c0 100644 --- a/crates/benilla-formats/src/ground_effects.rs +++ b/crates/benilla-formats/src/ground_effects.rs @@ -306,11 +306,12 @@ fn doodad_schema() -> Schema { s } -/// A DBC doodad name, a bare `ElwGra01.mdl`, to its model `World\NoDXT\Detail\ElwGra01.m2`. +/// A DBC doodad name as the path its model is created from: `0x6b2460` copies the folder +/// (`0x86b5d4`) and appends the raw name (`0x6b2473`-`0x6b2499`), then creates it through +/// `0x707350` with flags 0 (`0x6b24ad`), whose cache rule maps the extension or stands the error +/// cube ([`crate::models::model_path`], read by the mesh load). fn resolve_model(dbc_name: &str) -> String { - let file = dbc_name.rsplit(['\\', '/']).next().unwrap_or(dbc_name); - let stem = file.rsplit_once('.').map(|(s, _)| s).unwrap_or(file); - format!("{DETAIL_DIR}{stem}.m2") + format!("{DETAIL_DIR}{dbc_name}") } /// Load both ground-effect DBCs off the patch chain. The reference matches `doodadId` against @@ -368,16 +369,18 @@ pub fn load_ground_effect_catalog( mod tests { use super::*; + /// The folder and the raw name, decided by the cache's rule on the whole path. #[test] - fn resolve_model_forces_detail_m2_path() { + fn a_doodad_name_is_the_detail_folder_and_the_raw_name() { + let file = |name: &str| crate::models::model_path(&resolve_model(name)); assert_eq!( resolve_model("ElwGra01.mdl"), - "World\\NoDXT\\Detail\\ElwGra01.m2" - ); - assert_eq!( - resolve_model("ElwFlo01.MDX"), - "World\\NoDXT\\Detail\\ElwFlo01.m2" + "World\\NoDXT\\Detail\\ElwGra01.mdl" ); + assert_eq!(file("ElwFlo01.MDX"), "world\\nodxt\\detail\\elwflo01.m2"); + assert_eq!(file("Sub\\Tuft.mdx"), "world\\nodxt\\detail\\sub\\tuft.m2"); + assert_eq!(file("ElwGra01"), "spells\\errorcube.m2"); + assert_eq!(file("ElwGra01.blp"), "spells\\errorcube.m2"); } #[test] diff --git a/crates/benilla-formats/src/items.rs b/crates/benilla-formats/src/items.rs index 85c3f7739..9c595a1a5 100644 --- a/crates/benilla-formats/src/items.rs +++ b/crates/benilla-formats/src/items.rs @@ -12,7 +12,7 @@ use anyhow::{Context, Result}; use benilla_dbc::{FieldType, RecordSet, Schema, SchemaField}; use crate::dbc::{parse, str_at, u32_at}; -use crate::models::model_path; +use crate::models::model_name; const ITEM_DISPLAY_INFO: &str = "DBFilesClient\\ItemDisplayInfo.dbc"; @@ -21,7 +21,8 @@ const ITEM_DISPLAY_INFO: &str = "DBFilesClient\\ItemDisplayInfo.dbc"; /// the inventory type, which is not a column here. #[derive(Debug, Clone, Default)] pub struct ItemDisplay { - /// Left and right model basenames, normalized by [`crate::models::model_path`]. + /// Left and right model basenames, normalized by [`crate::models::model_name`]; the cache's + /// rule decides on the full path the model is made from. pub model: [Option; 2], /// Left and right texture basenames, raw and extensionless; never derived from [`Self::model`]. pub model_texture: [Option; 2], @@ -146,8 +147,8 @@ fn catalog_from_records(rs: RecordSet) -> ItemDisplayCatalog { for r in rs.records() { let Some(id) = u32_at(r, 0) else { continue }; let model = [ - str_at(&rs, r, 1).map(|s| model_path(&s)), - str_at(&rs, r, 2).map(|s| model_path(&s)), + str_at(&rs, r, 1).map(|s| model_name(&s)), + str_at(&rs, r, 2).map(|s| model_name(&s)), ]; let model_texture = [str_at(&rs, r, 3), str_at(&rs, r, 4)]; let geoset_groups = [ diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index b3687ecfd..a15820e59 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -258,26 +258,27 @@ pub use ribbons::{parse_m2_ribbon_emitters, RibbonEmitterDef, RibbonVisibility}; mod value_track; pub use value_track::{TrackValue, ValueTrack}; mod models; +pub use models::ERROR_CUBE; 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_pane_cameras, 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, - rotation_2x2, uv_transform, 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, M2CameraTracks, M2Light, - M2PaneCamera, M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis, + load_object_model, load_wmo, load_wmo_collision_tris, m2_bone_spins, m2_file_name, + 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_pane_cameras, + 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, rotation_2x2, uv_transform, 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, M2CameraTracks, + M2Light, M2PaneCamera, M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis, PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, SeqLoops, Skeleton, SkeletonBone, StringAnchors, TextureStage, UvAnim, UvRotAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot, diff --git a/crates/benilla-formats/src/light.rs b/crates/benilla-formats/src/light.rs index 57c080524..53362bb58 100644 --- a/crates/benilla-formats/src/light.rs +++ b/crates/benilla-formats/src/light.rs @@ -330,7 +330,7 @@ impl LightCatalog { let mut m = HashMap::with_capacity(rs.records().len()); for r in rs.records() { if let (Some(id), Some(path)) = (u32_at(r, 0), str_at(&rs, r, 1)) { - m.insert(id, crate::models::model_path(&path)); + m.insert(id, crate::models::model_name(&path)); } } m diff --git a/crates/benilla-formats/src/models/anim_summary.rs b/crates/benilla-formats/src/models/anim_summary.rs index 8e6b15402..4d05652b3 100644 --- a/crates/benilla-formats/src/models/anim_summary.rs +++ b/crates/benilla-formats/src/models/anim_summary.rs @@ -8,7 +8,7 @@ use benilla_m2::parse_m2; use crate::Chain; -use super::{le_u32, model_path, parse_m2_animations, parse_m2_global_sequence_bones}; +use super::{le_u32, model_name, parse_m2_animations, parse_m2_global_sequence_bones}; /// The texture-transform (UV animation) array count, MD20 `0x74` (count) / `0x78` (offset), as /// the header walk `0x71cdf0` reads it: whether the model authors any UV animation. @@ -203,7 +203,7 @@ pub fn parse_m2_animation_summary(bytes: &[u8]) -> Result { /// Read an M2's animation-channel summary from the chain, by model or doodad (`.mdx`/`.mdl`) path. pub fn load_m2_animation_summary(chain: &mut Chain, raw_path: &str) -> Result { - let path = model_path(raw_path); + let path = model_name(raw_path); let bytes = chain .read_file(&path) .with_context(|| format!("reading M2 {path}"))?; diff --git a/crates/benilla-formats/src/models/bounds.rs b/crates/benilla-formats/src/models/bounds.rs index 179de08c3..6572b5149 100644 --- a/crates/benilla-formats/src/models/bounds.rs +++ b/crates/benilla-formats/src/models/bounds.rs @@ -9,7 +9,7 @@ use benilla_m2::parse_m2; use crate::Chain; -use super::model_path; +use super::model_name; /// An M2's authored bounds and the reads sized from them, in model-local yards before the /// placement scale. @@ -50,7 +50,7 @@ pub struct M2Bounds { /// Read an M2's bounds from the chain, by model or doodad (`.mdx`/`.mdl`) path. pub fn load_m2_bounds(chain: &mut Chain, raw_path: &str) -> Result { - let path = model_path(raw_path); + let path = model_name(raw_path); let bytes = chain .read_file(&path) .with_context(|| format!("reading M2 {path}"))?; diff --git a/crates/benilla-formats/src/models/collision.rs b/crates/benilla-formats/src/models/collision.rs index 60bd6881d..bda890641 100644 --- a/crates/benilla-formats/src/models/collision.rs +++ b/crates/benilla-formats/src/models/collision.rs @@ -9,7 +9,7 @@ use benilla_wmo::{parse_wmo, ParsedWmo}; use crate::Chain; -use super::model_path; +use super::model_name; /// A collision triangle list in raw model-local coordinates, the space of /// [`super::RenderSubmesh::positions`]; empty when the source has no collision geometry. @@ -32,7 +32,7 @@ impl CollisionMesh { /// `bounding_triangles` (`u16` indices): a coarse solid far smaller than the render mesh, so the /// trunk blocks and the canopy does not. A doodad with no hull does not collide. pub fn load_m2_collision_hull(chain: &mut Chain, raw_path: &str) -> Result { - let path = model_path(raw_path); + let path = model_name(raw_path); let bytes = chain .read_file(&path) .with_context(|| format!("reading M2 {path}"))?; diff --git a/crates/benilla-formats/src/models/mod.rs b/crates/benilla-formats/src/models/mod.rs index 871c41daa..d67aac122 100644 --- a/crates/benilla-formats/src/models/mod.rs +++ b/crates/benilla-formats/src/models/mod.rs @@ -105,16 +105,42 @@ fn remap_submesh( ) } -/// Normalize a model path for the chain: lowercase, with `.mdx`/`.mdl` as `.m2`. +/// The model `0x707350` creates in place of a file the cache refuses (`0x70737f`). +pub const ERROR_CUBE: &str = "Spells\\ErrorCube.mdx"; + +/// The bytes of a model path the cache reads: `0x706a50` copies it into a 0x104-byte buffer with +/// `SStrCopy` (`0x706a6e`), which stops one short of the size (`0x64a5cf`, `0x64a5e2`). +const NAME_CAP: usize = 0x103; + +/// The archive file a model path names under the cache's rule (`0x706a50`), lowercased: the path +/// cut to [`NAME_CAP`] bytes, its extension taken at the last dot (`strrchr`, `0x706a80`), `.mdx` +/// and `.mdl` in any case made `.m2` and `.m2` kept (`0x706a92`-`0x706adf`). `None` for no dot or +/// any other extension (`0x706a8c`, `0x706adf`), which the caller stands [`ERROR_CUBE`] for. +pub fn m2_file_name(raw: &str) -> Option { + let bytes = &raw.as_bytes()[..raw.len().min(NAME_CAP)]; + let dot = bytes.iter().rposition(|&b| b == b'.')?; + let ext = &bytes[dot + 1..]; + let known = ["mdx", "mdl", "m2"] + .iter() + .any(|e| ext.eq_ignore_ascii_case(e.as_bytes())); + known.then(|| { + format!( + "{}.m2", + String::from_utf8_lossy(&bytes[..dot]).to_ascii_lowercase() + ) + }) +} + +/// The archive file a model created through `0x707350` reads: [`m2_file_name`], or the error cube +/// for a refused path. pub(crate) fn model_path(raw: &str) -> String { - let lower = raw.to_ascii_lowercase(); - match lower - .strip_suffix(".mdx") - .or_else(|| lower.strip_suffix(".mdl")) - { - Some(stem) => format!("{stem}.m2"), - None => lower, - } + m2_file_name(raw).unwrap_or_else(|| model_name(ERROR_CUBE)) +} + +/// A model name normalized for the chain by [`m2_file_name`], left as it is (lowercased) when the +/// rule refuses it: for a name whose model is made later, from a longer path or not at all. +pub(crate) fn model_name(raw: &str) -> String { + m2_file_name(raw).unwrap_or_else(|| raw.to_ascii_lowercase()) } /// Panicking little-endian reads at a byte offset, for the raw walks not on @@ -137,3 +163,42 @@ pub fn load_object_model(chain: &mut Chain, raw_path: &str) -> Result Option { if raw.is_empty() { return None; } - Some(crate::models::model_path(raw)) + Some(crate::models::model_name(raw)) } /// MOGI, stride 32: `flags: u32, bbox: 6 × f32, nameOff: i32`; the flags mirror each group's MOGP diff --git a/crates/benilla-ui/src/script/glue_core.rs b/crates/benilla-ui/src/script/glue_core.rs index cfe8f2480..88d0c3058 100644 --- a/crates/benilla-ui/src/script/glue_core.rs +++ b/crates/benilla-ui/src/script/glue_core.rs @@ -357,11 +357,12 @@ const LAUNCH_HOSTS: [&str; 10] = [ ]; /// The gate in front of the browser: `0x5abd00` tests `0x5abc10` and `0x429ff0` takes the URL. -/// The URL begins `http://` (`0x85d6bc`, compared case-folded), every byte after it is a letter, -/// a digit, `.`, `-` or `/` (so no query, port, escape or `_`), and its host, the text up to the -/// first `/`, is one of [`LAUNCH_HOSTS`], compared case-folded. `0x429ff0` refuses a URL of 0x400 -/// bytes or more (`0x42a01c`). The reference works on a C string, so the verb cuts its argument -/// at a NUL before asking; a URL with a NUL in it is refused here. +/// The URL begins `http://` (`0x85d6bc`, compared case-folded), every byte after it is `/`, `.`, +/// `-` (`0x5abc54`-`0x5abc5e`) or passes `isalpha` (`0x73fb91`, mask `0x103`), so no digit, query, +/// port, escape or `_`, and its host, the text up to the first `/`, is one of [`LAUNCH_HOSTS`], +/// compared case-folded. `0x429ff0` refuses a URL of 0x400 bytes or more (`0x42a01c`). The +/// reference works on a C string, so the verb cuts its argument at a NUL before asking; a URL with +/// a NUL in it is refused here. /// /// Deviation: a host of 0x80 bytes or more is refused. The reference copies the host into a 0x80-byte /// buffer (`0x5abc7c`-`0x5abc9c`) and writes its terminator one byte past the end, and the cut @@ -381,7 +382,7 @@ pub fn launch_url_allowed(url: &str) -> bool { }; if !rest .iter() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'/')) + .all(|b| b.is_ascii_alphabetic() || matches!(b, b'.' | b'-' | b'/')) { return false; } @@ -1332,6 +1333,8 @@ mod tests { "http://battle.net/", "http://www.blizzard.com/?q=1", "http://www.blizzard.com:80/", + "http://www.blizzard.com/patch1121", + "http://us1.blizzard.com/", "http://www.blizzard.com/a_b", "http://www.blizzard.com/a b", "http://www.blizzard.com\n/", diff --git a/crates/benilla-ui/src/script/glue_select.rs b/crates/benilla-ui/src/script/glue_select.rs index 2f62f6816..1746a0953 100644 --- a/crates/benilla-ui/src/script/glue_select.rs +++ b/crates/benilla-ui/src/script/glue_select.rs @@ -102,6 +102,12 @@ pub struct GlueScenePane { /// The pane is the create screen's customize frame (`[0xb420f0]`), whose scene the preview /// character stands in. pub create: bool, + /// The root inputs the paint places the scene by (`0x76d1a0`): `SetModelScale`, the frame's + /// effective scale, `SetPosition` in layout units and `SetFacing` in radians. + pub model_scale: f32, + pub layout_scale: f32, + pub position: [f32; 3], + pub facing: f32, } /// A record of the array: the character and whether `+0x110`, the built composite, is set. @@ -391,6 +397,10 @@ fn scene_pane(model: &Model, h: crate::widget::FrameHandle) -> Option`, `|`, `^`, `\`, brace or control byte reaches it. ([`src/script/glue_core.rs:410`](../crates/benilla-ui/src/script/glue_core.rs#L410)) +- A host of 0x80 bytes or more is refused. The reference copies the host into a 0x80-byte buffer (`0x5abc7c`-`0x5abc9c`) and writes its terminator one byte past the end, and the cut host could end in a whitelisted suffix the real one does not; this takes the safe side. ([`src/script/glue_core.rs:367`](../crates/benilla-ui/src/script/glue_core.rs#L367)) +- Benilla keeps that decode and then encodes it again, every byte outside the URL characters `URL_SAFE` as `%XY` (a decoded `%` too), because the opener on some platforms is a shell script (`xdg-open`) or takes the raw command tail (`rundll32`) and the string comes off the wire: no space, quote, backtick, `$`, `;`, `<`, `>`, `|`, `^`, `\`, brace or control byte reaches it. ([`src/script/glue_core.rs:411`](../crates/benilla-ui/src/script/glue_core.rs#L411)) - Past `NAME_TRIES` names the answer is nil, where the reference keeps trying, forever when the generator has learned nothing. ([`src/script/glue_create.rs:968`](../crates/benilla-ui/src/script/glue_create.rs#L968)) - With no Region rows the reference reads a NULL category (`0x46e5b8`, `0x46f26a`) and faults; benilla lists every realm under one category, so a client whose `Wow.ini` names another Region still reaches a realm. ([`src/script/glue_realm.rs:195`](../crates/benilla-ui/src/script/glue_realm.rs#L195)) - Equal nonzero counts tie here, where the reference answers +1 both ways (its name tie-break at `0x46e841` is clobbered by `0x46e846`), because a comparator that is not a total order has no defined sort. ([`src/script/glue_realm.rs:260`](../crates/benilla-ui/src/script/glue_realm.rs#L260))