Glue scenes stand any file; one model-name rule for every lane (#1932)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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_<token>`: 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<str>,
|
||||
key: std::sync::Arc<str>,
|
||||
}
|
||||
|
||||
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_<token>\UI_<token>.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_<token>`, any case, any extension);
|
||||
/// `None` for a file that is none of the shipped scenes.
|
||||
pub(crate) fn from_model_path(path: &str) -> Option<Self> {
|
||||
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 `<FogColor>` 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<GlueScene>,
|
||||
pub(crate) scene: Option<GlueSceneFile>,
|
||||
pub(crate) look: Option<GlueLook>,
|
||||
pub(crate) yaw: f32,
|
||||
pub(crate) fog: Option<SceneFog>,
|
||||
@@ -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<GlueSceneFile>,
|
||||
handle: Option<Handle<M2Model>>,
|
||||
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<M2Model>,
|
||||
cam: Option<benilla_assets::PortraitCamera>,
|
||||
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::<Children>();
|
||||
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>`).
|
||||
token: &'static str,
|
||||
/// The requested scene file.
|
||||
file: GlueSceneFile,
|
||||
/// Its model, loading since the click.
|
||||
handle: Handle<M2Model>,
|
||||
/// `Time<Real>` 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());
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -3070,6 +3070,13 @@ fn preview(app: &App) -> &crate::portrait::GluePreview {
|
||||
app.world().resource::<crate::portrait::GluePreview>()
|
||||
}
|
||||
|
||||
/// The stock scene file `GlueParent.lua`'s `SetBackgroundModel` names, `UI_<name>`.
|
||||
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 `<FogColor>` 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::<String>::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));
|
||||
|
||||
|
||||
@@ -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<GlueScene>, Option<GlueLook>, f32, Option<SceneFog>) {
|
||||
let Some(pane) = pane else {
|
||||
) -> (
|
||||
Option<GlueSceneFile>,
|
||||
Option<GlueLook>,
|
||||
f32,
|
||||
Option<SceneFog>,
|
||||
) {
|
||||
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_<name>`.
|
||||
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::<GluePreview>();
|
||||
(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::<GluePreview>();
|
||||
(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]);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<String>; 2],
|
||||
/// Left and right texture basenames, raw and extensionless; never derived from [`Self::model`].
|
||||
pub model_texture: [Option<String>; 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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<M2AnimSummary> {
|
||||
|
||||
/// 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<M2AnimSummary> {
|
||||
let path = model_path(raw_path);
|
||||
let path = model_name(raw_path);
|
||||
let bytes = chain
|
||||
.read_file(&path)
|
||||
.with_context(|| format!("reading M2 {path}"))?;
|
||||
|
||||
@@ -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<M2Bounds> {
|
||||
let path = model_path(raw_path);
|
||||
let path = model_name(raw_path);
|
||||
let bytes = chain
|
||||
.read_file(&path)
|
||||
.with_context(|| format!("reading M2 {path}"))?;
|
||||
|
||||
@@ -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<CollisionMesh> {
|
||||
let path = model_path(raw_path);
|
||||
let path = model_name(raw_path);
|
||||
let bytes = chain
|
||||
.read_file(&path)
|
||||
.with_context(|| format!("reading M2 {path}"))?;
|
||||
|
||||
@@ -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<String> {
|
||||
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<Vec<Render
|
||||
load_m2_mesh(chain, raw_path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `0x706a50`: the last dot's extension, `.mdx`/`.mdl` in any case made `.m2`, `.m2` kept,
|
||||
/// anything else refused; a creating lane reads the error cube, a name-only one keeps it.
|
||||
#[test]
|
||||
fn the_cache_takes_the_last_extension() {
|
||||
assert_eq!(
|
||||
m2_file_name("Creature\\Wolf\\Wolf.MDX").as_deref(),
|
||||
Some("creature\\wolf\\wolf.m2")
|
||||
);
|
||||
assert_eq!(m2_file_name("a.Mdl").as_deref(), Some("a.m2"));
|
||||
assert_eq!(m2_file_name("a.M2").as_deref(), Some("a.m2"));
|
||||
for refused in ["a", "a.blp", "a.mdx.bak", "World\\a.mdx\\b", ""] {
|
||||
assert_eq!(m2_file_name(refused), None, "{refused}");
|
||||
}
|
||||
assert_eq!(model_path("a.blp"), "spells\\errorcube.m2");
|
||||
assert_eq!(model_name("A.blp"), "a.blp");
|
||||
}
|
||||
|
||||
/// The name is cut to 0x103 bytes before the dot is looked for (`0x64a5a0` into the 0x104-byte
|
||||
/// buffer): an extension that ends past the cut is refused, or read short.
|
||||
#[test]
|
||||
fn a_path_past_the_buffer_is_cut_before_its_extension() {
|
||||
let at = |n: usize, ext: &str| format!("{}{ext}", "x".repeat(n - ext.len()));
|
||||
assert_eq!(
|
||||
m2_file_name(&at(0x103, ".mdx")),
|
||||
Some(format!("{}.m2", "x".repeat(0x103 - 4)))
|
||||
);
|
||||
assert_eq!(m2_file_name(&at(0x104, ".mdx")), None, "cut to `.md`");
|
||||
assert_eq!(
|
||||
m2_file_name(&format!("{}.m2x", "x".repeat(0x100))),
|
||||
Some(format!("{}.m2", "x".repeat(0x100))),
|
||||
"cut to `.m2`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ fn parse_skybox(bytes: &[u8]) -> Option<String> {
|
||||
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
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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<GlueSceneP
|
||||
fog: m.scene_fog(),
|
||||
select: model_frame_handle(model) == Some(h),
|
||||
create: super::glue_create::create_frame_handle(model) == Some(h),
|
||||
model_scale: m.scale,
|
||||
layout_scale: f.effective_scale,
|
||||
position: m.position.into(),
|
||||
facing: m.facing,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+7
-7
@@ -210,11 +210,11 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
|
||||
- The auction house's `AuctionDressUpModel` draws the same one assembly, because the app's `TryOn`/`Dress` intents are not per-widget; the reference's two widgets each clone their own model. ([`src/portrait/dressup.rs:8`](../crates/benilla-app/src/portrait/dressup.rs#L8))
|
||||
- A fixed neutral front-lit studio (`studio_light`) replaces the portrait bake's hard-coded rig (`0x525b10`: ambient 0.45 grey, white directional), because the studio is the look chosen for them. ([`src/portrait/light.rs:65`](../crates/benilla-app/src/portrait/light.rs#L65))
|
||||
- The round portraits bake at 256², not 64², because the 64² still is a resolution limit, not a look; and under a fixed studio light, not the ambient state, because that neutral light is the chosen portrait look (`light::BoothLight`). ([`src/portrait/mod.rs:16`](../crates/benilla-app/src/portrait/mod.rs#L16))
|
||||
- Only the four party slots hand over, capped at `Self::CAP`, instead of every portrait until `ClientDestroyGame` (`0x401ee0`), because a general cache would pay a texture per unit ever seen. ([`src/portrait/mod.rs:251`](../crates/benilla-app/src/portrait/mod.rs#L251))
|
||||
- This reads the window's aspect, not `gxResolution` (here only the windowed size) under `widescreen` (not registered), because fullscreen is the monitor's own size with no mode list, where the two agree. ([`src/portrait/mod.rs:566`](../crates/benilla-app/src/portrait/mod.rs#L566))
|
||||
- `boothHalfRate` is benilla's own CVar, because the reference draws its doll in the main pass and has no second view; a full-rate pass costs up to 7.6 ms a frame at 4K. ([`src/portrait/mod.rs:599`](../crates/benilla-app/src/portrait/mod.rs#L599))
|
||||
- The reference widget draws only its model; a body pane clears to near-black, the chosen look where nothing sits behind the pane. ([`src/portrait/mod.rs:837`](../crates/benilla-app/src/portrait/mod.rs#L837))
|
||||
- The reference probes a bake cache keyed by the record's display id (`0x525cb0`, `0xc0cea4`) before the stand-in, so a pet out of view keeps its face. benilla holds no such cache, and `PortraitBakes` is keyed by guid for the party slots alone: the stand-in shows. ([`src/portrait/mod.rs:1582`](../crates/benilla-app/src/portrait/mod.rs#L1582))
|
||||
- Only the four party slots hand over, capped at `Self::CAP`, instead of every portrait until `ClientDestroyGame` (`0x401ee0`), because a general cache would pay a texture per unit ever seen. ([`src/portrait/mod.rs:252`](../crates/benilla-app/src/portrait/mod.rs#L252))
|
||||
- This reads the window's aspect, not `gxResolution` (here only the windowed size) under `widescreen` (not registered), because fullscreen is the monitor's own size with no mode list, where the two agree. ([`src/portrait/mod.rs:567`](../crates/benilla-app/src/portrait/mod.rs#L567))
|
||||
- `boothHalfRate` is benilla's own CVar, because the reference draws its doll in the main pass and has no second view; a full-rate pass costs up to 7.6 ms a frame at 4K. ([`src/portrait/mod.rs:600`](../crates/benilla-app/src/portrait/mod.rs#L600))
|
||||
- The reference widget draws only its model; a body pane clears to near-black, the chosen look where nothing sits behind the pane. ([`src/portrait/mod.rs:838`](../crates/benilla-app/src/portrait/mod.rs#L838))
|
||||
- The reference probes a bake cache keyed by the record's display id (`0x525cb0`, `0xc0cea4`) before the stand-in, so a pet out of view keeps its face. benilla holds no such cache, and `PortraitBakes` is keyed by guid for the party slots alone: the stand-in shows. ([`src/portrait/mod.rs:1583`](../crates/benilla-app/src/portrait/mod.rs#L1583))
|
||||
|
||||
#### quest_markers
|
||||
|
||||
@@ -695,8 +695,8 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
|
||||
- The getter reads `CVAR_WORLD_DETAIL`, where the reference's reads only `smallCull` (`0x488d77`), because benilla's stop follows the knob that draws: a console `frillDensity` write moves the ground clutter and `WorldDetail` with it, and no renderer reads `smallCull` (its callback's `[0x868620]` has no reader). ([`src/script/cvars.rs:1016`](../crates/benilla-ui/src/script/cvars.rs#L1016))
|
||||
- The promotion's IME legs (`0x77d417`–`0x77d431`) and the IME writer's `or 6` (`0x77acde`, in `0x77ac60`) are not modelled: benilla has no IME composition. ([`src/script/editbox/flush.rs:28`](../crates/benilla-ui/src/script/editbox/flush.rs#L28))
|
||||
- The nameplate glow is shown but never drawn, because its additive rim reads as hard edge lines in our linear-blending pipeline; the lit plate brightens its bar instead. ([`src/script/extract.rs:230`](../crates/benilla-ui/src/script/extract.rs#L230))
|
||||
- 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:366`](../crates/benilla-ui/src/script/glue_core.rs#L366))
|
||||
- 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: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))
|
||||
|
||||
Reference in New Issue
Block a user