From 5c7eb5fc75e8b5748416456c60a879db78a4580b Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:13:47 +0700 Subject: [PATCH] Stock glue screens draw their 3-D scenes through the glue booth (#1039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 4b of the stock GlueXML (`WOW_GLUE_STOCK=1`): the stock screens' ModelFFX scenes now go through the glue booth instead of the model-tile path, which drew them black. - The scene, look and facing come from the glue VM. `SetCharSelectBackground` runs the frame's own SetModel and swaps its fill callback (`0x472128`, `0x472144`), so the select scene fogs until a character sets its stage. - An ModelFFX pane draws the booth's scene at its own bucket's ARTWORK layer (`0x46fa90`), framed at the pane's aspect with the diagonal law (`0x7ada40`, `0x5c3cc0`). The native box Deviation now holds for the native screens only. - Each SetModel restarts the stage. The select glow holds through an empty roster until the screen shows again. - An empty roster shows the Orc scene fogged, and after the last delete the last stage stays, as `CharacterSelect.lua` leaves them. Part of #856 Fixes #978 ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/char_create/mod.rs | 10 +- crates/benilla-app/src/char_create/screen.rs | 5 +- crates/benilla-app/src/char_select/mod.rs | 21 +- crates/benilla-app/src/char_select/refresh.rs | 145 ++++- crates/benilla-app/src/char_select/screen.rs | 14 +- crates/benilla-app/src/game_plugins.rs | 126 ++++- crates/benilla-app/src/login/mod.rs | 32 +- crates/benilla-app/src/login/screen.rs | 12 +- crates/benilla-app/src/portrait/framing.rs | 34 ++ crates/benilla-app/src/portrait/glue_booth.rs | 528 ++++++++++++++---- crates/benilla-app/src/portrait/mod.rs | 10 +- .../benilla-app/src/ui_script/extract/mod.rs | 138 ++++- crates/benilla-app/src/ui_script/glue.rs | 6 +- crates/benilla-app/src/ui_script/glue_mgr.rs | 6 + .../src/ui_script/glue_mgr_tests.rs | 144 ++++- .../benilla-app/src/ui_script/glue_scene.rs | 196 +++++++ crates/benilla-app/src/ui_script/mod.rs | 1 + crates/benilla-ui/src/script/extract.rs | 3 +- crates/benilla-ui/src/script/glue_select.rs | 277 ++++++++- crates/benilla-ui/src/script/mod.rs | 2 +- crates/benilla-ui/src/script/model.rs | 3 + crates/benilla-ui/src/script/modelframe.rs | 50 +- crates/benilla-ui/src/script/types.rs | 5 +- crates/benilla-ui/src/widget/kinds/mod.rs | 23 + 24 files changed, 1597 insertions(+), 194 deletions(-) create mode 100644 crates/benilla-app/src/ui_script/glue_scene.rs diff --git a/crates/benilla-app/src/char_create/mod.rs b/crates/benilla-app/src/char_create/mod.rs index ff13406c..81598b02 100644 --- a/crates/benilla-app/src/char_create/mod.rs +++ b/crates/benilla-app/src/char_create/mod.rs @@ -20,7 +20,7 @@ use crate::char_select::{ClientState, Roster}; use crate::entities::CharCreate; use crate::glue_strings::GlueStrings; use crate::net::{CharActionResultMessage, CharPick, CharRequest}; -use crate::portrait::{CreateLook, GlueLook, GluePreview}; +use crate::portrait::{CreateLook, GluePreview}; use crate::sound::GlueSound; /// Alliance and Horde race columns, top to bottom: `CharacterCreateEnumerateRaces` fills buttons @@ -63,6 +63,8 @@ impl Plugin for CharCreatePlugin { ) .chain() .before(crate::glue::GlueVisuals) + // The create screen writes the glue booth's preview. + .in_set(crate::portrait::GluePreviewFeed) .run_if(in_state(ClientState::CharCreate)) .after(benilla_world::schedule::WorldStage::Net), ); @@ -165,8 +167,7 @@ fn debug_pick( sel.dials[i] = want.min(n - 1); } } - preview.scene = Some(crate::portrait::GlueScene::Race(sel.race)); - preview.look = Some(GlueLook::Create(sel.look())); + preview.show_create(sel.race, sel.look()); info!( "char create: pick instrument set race {} sex {} class {} dials {:?}", sel.race, sel.sex, sel.class, sel.dials @@ -445,8 +446,7 @@ fn create_input( let _ = pick.0.send(CharRequest::Create(sel.request())); } if changed_look { - preview.scene = Some(crate::portrait::GlueScene::Race(sel.race)); - preview.look = Some(GlueLook::Create(sel.look())); + preview.show_create(sel.race, sel.look()); } } diff --git a/crates/benilla-app/src/char_create/screen.rs b/crates/benilla-app/src/char_create/screen.rs index b97b3a12..8c6f9230 100644 --- a/crates/benilla-app/src/char_create/screen.rs +++ b/crates/benilla-app/src/char_create/screen.rs @@ -9,7 +9,7 @@ use bevy::window::PrimaryWindow; use crate::char_select::{race_name, wow_font}; use crate::entities::CharCreate; use crate::glue_strings::GlueStrings; -use crate::portrait::{GlueLook, GluePreview, PortraitImages, PortraitSource, GLUE_SLOT}; +use crate::portrait::{GluePreview, PortraitImages, PortraitSource, GLUE_SLOT}; use benilla_assets::WorldAssets; use super::parts::{CharCreateUi, DialRow, DynIcon, DynText, DynTint, StatusLine}; @@ -41,8 +41,7 @@ pub(super) fn enter_create( window: Query<&Window, With>, ) { sel.reset(catalog.as_deref()); - preview.scene = Some(crate::portrait::GlueScene::Race(sel.race)); - preview.look = Some(GlueLook::Create(sel.look())); + preview.show_create(sel.race, sel.look()); preview.yaw = INITIAL_FACING; if let Some(mut wa) = world_assets { art.ensure_loaded(&mut wa, &mut images, &mut add_mats); diff --git a/crates/benilla-app/src/char_select/mod.rs b/crates/benilla-app/src/char_select/mod.rs index 79c64ac0..8271fdd7 100644 --- a/crates/benilla-app/src/char_select/mod.rs +++ b/crates/benilla-app/src/char_select/mod.rs @@ -73,13 +73,25 @@ impl Plugin for CharSelectPlugin { ) .init_resource::() .init_resource::() + .init_resource::() .add_systems( OnEnter(ClientState::CharSelect), - (screen::enter_select, stamp_select_entry), + ( + screen::enter_select.run_if(crate::ui_script::glue::native_glue), + stamp_select_entry, + ), ) .add_systems( OnExit(ClientState::CharSelect), - (screen::exit_select, clear_select_entry), + ( + screen::exit_select, + screen::clear_select_scene.run_if(crate::ui_script::glue::native_glue), + clear_select_entry, + ), + ) + .add_systems( + OnEnter(ClientState::InWorld), + refresh::reset_select_background, ) .add_systems(Update, (debug_glue_roundtrip, debug_logout_smoke)) .add_systems( @@ -102,7 +114,10 @@ impl Plugin for CharSelectPlugin { addons::drive_addons_panel.run_if(crate::ui_script::glue::native_glue), refresh::refresh_list, refresh::refresh_banner_and_buttons, - refresh::feed_glue_preview, + // The stock screens drive the booth off the glue VM instead. + refresh::feed_glue_preview + .run_if(crate::ui_script::glue::native_glue) + .in_set(crate::portrait::GluePreviewFeed), debug_select_shot, ) .chain() diff --git a/crates/benilla-app/src/char_select/refresh.rs b/crates/benilla-app/src/char_select/refresh.rs index 68ab0b02..6647aa8c 100644 --- a/crates/benilla-app/src/char_select/refresh.rs +++ b/crates/benilla-app/src/char_select/refresh.rs @@ -7,7 +7,7 @@ use crate::area::AreaTableRes; use crate::glue::widgets::{GlueDisabled, Hilight, LockHighlight}; use crate::glue_strings::GlueStrings; use crate::net::NetStatus; -use crate::portrait::{GlueLook, GluePreview, SelectLook}; +use crate::portrait::{GlueLook, GluePreview, GlueScene, SelectLook}; use super::screen::{RealmBanner, RowText, SelectAction, SelectedName, MAX_ROWS}; use super::{class_name, Roster}; @@ -170,10 +170,32 @@ pub(super) fn refresh_banner_and_buttons( } } -/// Feed the glue booth: the scene is the selected character's race (`SetBackgroundModel`; Orc -/// with no character, the reference's OnLoad default), the look its geared enum record. +/// The select scene's background as `CharacterSelect` holds it for the glue session: `race` is +/// `None` while only its OnLoad has set the scene (`Model:SetModel(UI_Orc)` under Orc's fog, +/// `CharacterSelect.lua:26-31`), else the race whose file string `SetBackgroundModel` last set +/// (`CharacterSelect.lua:341-344`), drawn unfogged (`0x472110` replaces the fog callback). The +/// call is keyed on the race's file string, not its stage, so a gnome after a dwarf loads the +/// same stage again, a new instance whose scene starts over (`0x76cce0`): `stage` counts them. An +/// emptied roster selects nothing and calls neither, so the scene stays. The glue rebuild at world +/// entry restores OnLoad's. +#[derive(Resource, Default)] +pub(crate) struct SelectBackground { + pub(super) race: Option, + pub(super) stage: u64, +} + +/// The glue rebuild (`0x46a7b0`) runs `CharacterSelect`'s OnLoad again, a new instance. +pub(super) fn reset_select_background(mut background: ResMut) { + background.race = None; + background.stage = background.stage.wrapping_add(1); +} + +/// Feed the glue booth: the scene is the selected character's stage (`SetBackgroundModel`), +/// unfogged; with no character the last stage stays, or the OnLoad Orc scene under its fog stands +/// ([`SelectBackground`]); the look is the selected character's geared enum record. pub(super) fn feed_glue_preview( roster: Res, + mut background: ResMut, mut preview: ResMut, mut showing: Local>, ) { @@ -184,13 +206,25 @@ pub(super) fn feed_glue_preview( *showing = Some(roster.select_seq); preview.yaw = 0.0; } - let (race, look) = match roster.selected_char() { - Some(c) => (c.race, Some(GlueLook::Select(SelectLook::from(c)))), - None => (2, None), // UI_Orc, the reference's empty-account scene + let look = roster.selected_char().map(|c| { + if background.race != Some(c.race) { + background.race = Some(c.race); + background.stage = background.stage.wrapping_add(1); + } + GlueLook::Select(SelectLook::from(c)) + }); + let (scene, fog) = match background.race { + Some(race) => (GlueScene::of_race(race), None), + None => (GlueScene::Orc, Some(GlueScene::Orc.stock_fog())), }; - let scene = Some(crate::portrait::GlueScene::Race(race)); - if preview.scene != scene { - preview.scene = scene; + if preview.scene != Some(scene) { + preview.scene = Some(scene); + } + if preview.stage != background.stage { + preview.stage = background.stage; + } + if preview.fog != fog { + preview.fog = fog; } if preview.look != look { preview.look = look; @@ -201,6 +235,99 @@ pub(super) fn feed_glue_preview( mod tests { use super::*; + /// #978: an empty roster's select scene is OnLoad's Orc under Orc's fog; a selected character + /// sets its stage, unfogged, and that stage stays when the roster empties; the glue rebuild at + /// world entry restores OnLoad's. + fn tauren_again() -> benilla_protocol::Character { + let mut c = crate::char_select::test_character(1, "Moo"); + c.race = 6; + c + } + + #[test] + fn the_select_scene_fogs_until_a_background_is_set_and_then_stays() { + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, feed_glue_preview); + let shown = |app: &App| { + let p = app.world().resource::(); + (p.scene, p.fog.map(|f| f.far), p.look.is_some()) + }; + app.update(); + assert_eq!(shown(&app), (Some(GlueScene::Orc), Some(270.0), false)); + + let mut tauren = crate::char_select::test_character(1, "Moo"); + tauren.race = 6; + { + let mut roster = app.world_mut().resource_mut::(); + roster.chars = vec![tauren]; + roster.select(Some(0)); + } + app.update(); + assert_eq!(shown(&app), (Some(GlueScene::Tauren), None, true)); + + // A gnome after a dwarf: the same stage, set again, starts over (`0x76cce0`). + let stage = |app: &App| app.world().resource::().stage; + let mut dwarf = crate::char_select::test_character(2, "Stone"); + dwarf.race = 3; + let mut gnome = crate::char_select::test_character(3, "Cog"); + gnome.race = 7; + let first = stage(&app); + for (c, row) in [(dwarf, 0), (gnome, 1)] { + let before = stage(&app); + { + let mut roster = app.world_mut().resource_mut::(); + roster.chars.insert(row, c); + roster.select(Some(row)); + } + app.update(); + assert_eq!(shown(&app).0, Some(GlueScene::Dwarf)); + assert_ne!( + stage(&app), + before, + "a new race's background is a new instance" + ); + } + // Back to the dwarf, then the same row again: only a race change makes one. + { + let mut roster = app.world_mut().resource_mut::(); + roster.select(Some(0)); + } + app.update(); + let dwarf_again = stage(&app); + assert!(dwarf_again != first); + { + let mut roster = app.world_mut().resource_mut::(); + roster.select(Some(0)); + } + app.update(); + assert_eq!(stage(&app), dwarf_again); + { + let mut roster = app.world_mut().resource_mut::(); + roster.chars = vec![tauren_again()]; + roster.select(Some(0)); + } + app.update(); + assert_eq!(shown(&app), (Some(GlueScene::Tauren), None, true)); + + // The last character deleted: nothing is selected, and no background call replaces it. + { + let mut roster = app.world_mut().resource_mut::(); + roster.chars.clear(); + roster.select(None); + } + app.update(); + assert_eq!(shown(&app), (Some(GlueScene::Tauren), None, false)); + + app.world_mut() + .run_system_cached(reset_select_background) + .unwrap(); + app.update(); + assert_eq!(shown(&app), (Some(GlueScene::Orc), Some(270.0), false)); + } + /// The composition order of `CharacterSelect.lua:48-64`. #[test] fn the_down_note_hangs_off_the_name_and_the_type_suffix_follows_it() { diff --git a/crates/benilla-app/src/char_select/screen.rs b/crates/benilla-app/src/char_select/screen.rs index 6602fb61..39b3d490 100644 --- a/crates/benilla-app/src/char_select/screen.rs +++ b/crates/benilla-app/src/char_select/screen.rs @@ -70,10 +70,13 @@ const ROW_HIT_H: f32 = 55.0; const ROW_PITCH: f32 = 57.0; /// Entry only resets state: the initial state's `OnEnter` fires at startup, before the MPQ chain -/// and booth slots exist, so the tree spawns in [`materialize_screen`]. +/// and booth slots exist, so the tree spawns in [`materialize_screen`]. The native screen's alone: +/// the stock screens' facing is the glue VM's. pub(super) fn enter_select(mut preview: ResMut) { // The model faces the camera on entry: the reference's facing global defaults to zero. preview.yaw = 0.0; + // `CharacterSelect`'s OnShow (`0x46fa60`). + preview.shown = preview.shown.wrapping_add(1); } /// Spawn the screen tree once the art is loaded, respawn it when art lands after an artless spawn @@ -602,18 +605,21 @@ pub(super) fn hide_under_world_cover( } } +/// Leaving the native select screen drops its scene; the next screen sets its own. +pub(super) fn clear_select_scene(mut preview: ResMut) { + preview.look = None; + preview.scene = None; +} + pub(super) fn exit_select( mut commands: Commands, roots: Query>, - mut preview: ResMut, mut dialog: ResMut, mut glue_dialog: ResMut, ) { for e in &roots { commands.entity(e).despawn(); } - preview.look = None; - preview.scene = None; dialog.close(); // The glue dialog must not follow into the world or the create screen. Its tree is despawned // here, since its driver runs on the glue screens only. diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 55d29e6f..78bf179f 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -507,6 +507,125 @@ pub(crate) mod schedule_tests { c.systems.values().any(|s| s.name.contains("benilla_app::")) } + /// The systems of one schedule whose declared access writes the resource `T`, each with every + /// run condition on it or on a set it is under, by name. + fn resource_writers( + app: &mut App, + label: impl ScheduleLabel, + ) -> Vec<(String, Vec)> { + app.world_mut() + .schedule_scope(label.intern(), |world, schedule| { + schedule.graph_mut().systems.initialize(world); + let Some(id) = world.components().get_resource_id(TypeId::of::()) else { + return Vec::new(); + }; + let graph = schedule.graph(); + let mut out = Vec::new(); + for (key, system, conds) in graph.systems.iter() { + let writes = graph + .systems + .get(key) + .is_some_and(|s| s.access.combined_access().has_resource_write(id)); + if !writes { + continue; + } + let mut conditions: Vec = conds + .iter() + .map(|c| c.condition.name().to_string()) + .collect(); + let mut stack = vec![NodeId::System(key)]; + while let Some(node) = stack.pop() { + for parent in graph + .hierarchy() + .graph() + .neighbors_directed(node, Direction::Incoming) + { + if let NodeId::Set(set) = parent { + if let Some((_, set_conds)) = graph + .system_sets + .iter() + .find(|(k, _, _)| *k == set) + .map(|(_, s, c)| (s, c)) + { + conditions.extend( + set_conds.iter().map(|c| c.condition.name().to_string()), + ); + } + stack.push(parent); + } + } + } + out.push((system.name().to_string(), conditions)); + } + out + }) + } + + /// The glue booth has one feeder per switch: under `WOW_GLUE_STOCK=1` only the glue VM's + /// writes `GluePreview`, and off it only the native screens' do. A writer of neither kind is + /// named here with why it cannot run against the other. + #[test] + fn the_glue_preview_has_one_feeder_per_switch() { + use crate::char_select::ClientState; + // The create screen's state, which only the native select screen enters, and its + // instrument. + const EITHER: [&str; 2] = ["benilla_app::char_create::", "drive_create_test"]; + let mut app = headless_client(); + let mut writers = Vec::new(); + writers.extend(resource_writers::( + &mut app, Update, + )); + for state in [ClientState::Login, ClientState::CharSelect] { + writers.extend(resource_writers::( + &mut app, + OnEnter(state), + )); + writers.extend(resource_writers::( + &mut app, + OnExit(state), + )); + } + if !writers.iter().any(|(n, _)| n.contains("benilla_app::")) { + eprintln!( + "skipped: this build carries no type names (bevy/debug rides with the dev feature)" + ); + return; + } + let has = |conds: &[String], c: &str| conds.iter().any(|n| n.ends_with(c)); + let mut stock = Vec::new(); + let mut native = Vec::new(); + let mut neither = Vec::new(); + for (name, conds) in &writers { + match (has(conds, "::stock_glue"), has(conds, "::native_glue")) { + (true, false) => stock.push(name.as_str()), + (false, true) => native.push(name.as_str()), + _ if EITHER.iter().any(|e| name.contains(e)) => {} + _ => neither.push(name.as_str()), + } + } + assert!( + neither.is_empty(), + "GluePreview writers on neither switch: {neither:?}" + ); + assert_eq!( + stock, + ["benilla_app::ui_script::glue_scene::feed_stock_glue_preview"], + "the stock screens' one feeder" + ); + for want in [ + "benilla_app::char_select::refresh::feed_glue_preview", + "benilla_app::login::show_main_menu", + "benilla_app::login::screen::clear_login_scene", + "benilla_app::char_select::screen::enter_select", + "benilla_app::char_select::screen::clear_select_scene", + ] { + assert!( + native.contains(&want), + "{want} is not gated to the native screens: {native:?}" + ); + } + } + /// One row of the class table: the type's name for the message, and its `TypeId`. type ClassRow = (&'static str, fn() -> TypeId); @@ -656,8 +775,9 @@ pub(crate) mod schedule_tests { /// would, off the same clock; /// - the area-spirit-healer poll over `Transform` and `NetEntity`: it is level-triggered, so a /// unit seen a frame early or late is decided again next frame; - /// - `ui_models::forget_dead_vm_tiles` against `portrait::glue_booth::sync_glue_scene` over - /// `MatAnimTable`, a slot allocator whose owners touch disjoint slots; + /// - `ui_script::glue_scene::feed_stock_glue_preview` against the native glue screens' + /// writers of `GluePreview`: the switch runs one side or the other (`stock_glue` against + /// `native_glue`), never both; /// - the cursor classifier against `world_focus`'s focus publish and settle release over /// `Player`: it reads `control_lost`, and they write only `settling` and `world_stale`; /// - `ui_quest::lines::feed_quest_lines` against `ui_items::feed_item_stats` and @@ -693,7 +813,7 @@ pub(crate) mod schedule_tests { /// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the /// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource /// that commutes by construction belongs in [`Classes`]. - const UPDATE_ACTIONABLE_CEILING: usize = 4_980; + const UPDATE_ACTIONABLE_CEILING: usize = 4_689; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { diff --git a/crates/benilla-app/src/login/mod.rs b/crates/benilla-app/src/login/mod.rs index 646f9180..976b8169 100644 --- a/crates/benilla-app/src/login/mod.rs +++ b/crates/benilla-app/src/login/mod.rs @@ -54,8 +54,21 @@ impl Plugin for LoginPlugin { app.init_resource::() .init_resource::() .add_message::() - .add_systems(OnEnter(ClientState::Login), enter_login) - .add_systems(OnExit(ClientState::Login), screen::exit_login) + .add_systems( + OnEnter(ClientState::Login), + ( + enter_login, + // Under the stock glue screens their own feeder drives the booth. + show_main_menu.run_if(crate::ui_script::glue::native_glue), + ), + ) + .add_systems( + OnExit(ClientState::Login), + ( + screen::exit_login, + screen::clear_login_scene.run_if(crate::ui_script::glue::native_glue), + ), + ) .add_systems( Update, ( @@ -68,6 +81,10 @@ impl Plugin for LoginPlugin { .after(crate::ui_script::UiInput) .after(crate::realm_select::RealmSelectSet) .run_if(crate::ui_script::glue::stock_glue), + // Off the roster and the screen the manager and the tick just moved. + crate::ui_script::glue_scene::feed_stock_glue_preview + .run_if(crate::ui_script::glue::stock_glue) + .in_set(crate::portrait::GluePreviewFeed), answer_glue, drive_policy, to_select_on_roster, @@ -753,8 +770,8 @@ impl LoginForm { struct QuitArm(Option); /// `AccountLogin_OnShow`: prefill the saved account, clear the password, focus the first empty -/// box, check Remember when a name is saved; then show the `UI_MainMenu` scene. -fn enter_login(mut form: ResMut, mut preview: ResMut) { +/// box, check Remember when a name is saved. +fn enter_login(mut form: ResMut) { let saved = load_saved_account(); form.save = !saved.is_empty(); form.focus = if saved.is_empty() { @@ -768,9 +785,16 @@ fn enter_login(mut form: ResMut, mut preview: ResMut) { // remembered name is typed over. let focus = form.focus; form.focus(focus); +} + +/// The native login's scene, `AccountLogin.xml`'s `ModelFFX`: `UI_MainMenu` under its fog. +fn show_main_menu(mut preview: ResMut) { preview.scene = Some(GlueScene::MainMenu); preview.look = None; preview.yaw = 0.0; + preview.fog = Some(GlueScene::MainMenu.stock_fog()); + // `AccountLogin`'s OnShow (`0x46fa60`). + preview.shown = preview.shown.wrapping_add(1); } /// The screen's input: typing, Tab, Enter submits, Esc quits (an open dialog takes it first), diff --git a/crates/benilla-app/src/login/screen.rs b/crates/benilla-app/src/login/screen.rs index cf630206..3d38d010 100644 --- a/crates/benilla-app/src/login/screen.rs +++ b/crates/benilla-app/src/login/screen.rs @@ -604,25 +604,27 @@ pub(super) fn refresh_checkbox( } } -/// Leaving the login screen: drop the tree, the scene, and any open dialog. +/// Leaving the login screen: drop the tree and any open dialog. pub(super) fn exit_login( mut commands: Commands, roots: Query>, - mut preview: ResMut, mut dialog: ResMut, ) { for e in &roots { commands.entity(e).despawn(); } - // The next screen sets its own scene the same frame. - preview.scene = None; - preview.look = None; if let Some(root) = dialog.root.take() { commands.entity(root).despawn(); } dialog.close(); } +/// Leaving the native login screen drops its scene; the next screen sets its own the same frame. +pub(super) fn clear_login_scene(mut preview: ResMut) { + preview.scene = None; + preview.look = None; +} + /// `WOW_LOGIN_SHOT_OUT=`: one PNG of the login screen once it has settled. pub(super) fn debug_login_shot( mut commands: Commands, diff --git a/crates/benilla-app/src/portrait/framing.rs b/crates/benilla-app/src/portrait/framing.rs index 432d7a12..f96f8437 100644 --- a/crates/benilla-app/src/portrait/framing.rs +++ b/crates/benilla-app/src/portrait/framing.rs @@ -43,6 +43,10 @@ pub(super) use benilla_formats::{ArtExtent, GLUE_AUTHORED_ASPECT}; /// about 3:2. Below 4/3 the authored horizontal extent holds and the view opens upward, up to the /// art's `half_h` but never inside the authored box. Continuous at 4/3, where both legs give /// `0.6ยทfov`. A `` pane is not the window and keeps its verbatim crop. +/// +/// The Deviation is the native glue screens' alone. Under the stock screens `GlueParent` caps +/// every glue screen at 16:9 (`GlueParent.lua:78-84`), so the crop it answers cannot arise, and +/// the scene is framed as the reference frames it ([`stock_pane_view`]). pub(super) fn glue_scene_framing(fov: f32, window_aspect: f32, art: Option) -> f32 { let authored = diag_to_vert(fov, GLUE_AUTHORED_ASPECT); if window_aspect <= 0.0 { @@ -64,6 +68,36 @@ pub(super) fn glue_scene_framing(fov: f32, window_aspect: f32, art: Option Option { + let [l, r, t, b] = pane; + let px = |f: f32, full: u32| (f.clamp(0.0, 1.0) * full as f32).round() as u32; + let (x0, x1) = (px(l, full_w), px(r, full_w)); + let (y0, y1) = (px(t, full_h), px(b, full_h)); + if x1 <= x0 || y1 <= y0 { + return None; + } + let size = UVec2::new(x1 - x0, y1 - y0); + Some(PaneView { + position: UVec2::new(x0, y0), + size, + aspect: size.x as f32 / size.y as f32, + }) +} + /// The pillarbox aspect for a window: `Some(GLUE_BOX_ASPECT)` when the window is wider, else /// `None`. It depends on the window alone, so the booth viewport and the chrome's canvas /// ([`crate::glue::GlueCanvas`]) hold still when the selected race changes the stage. diff --git a/crates/benilla-app/src/portrait/glue_booth.rs b/crates/benilla-app/src/portrait/glue_booth.rs index fba0ce80..3af6d4d1 100644 --- a/crates/benilla-app/src/portrait/glue_booth.rs +++ b/crates/benilla-app/src/portrait/glue_booth.rs @@ -17,7 +17,7 @@ use benilla_assets::materials::WowModelMaterial; use super::framing::{ attachment_point, diag_to_vert, glue_box_aspect, glue_box_physical, glue_scene_framing, - ArtExtent, GLUE_AUTHORED_ASPECT, PORTRAIT_ASPECT, + stock_pane_view, ArtExtent, GLUE_AUTHORED_ASPECT, PORTRAIT_ASPECT, }; use super::{ aim, body_frame, new_target_image, spawn_booth_effects, spawn_booth_model, Booth, @@ -123,24 +123,116 @@ impl GlueLook { } } -/// Which glue background scene shows: the login screen's main menu, or a race's `UI_` stage. -/// The main menu always fogs; a race stage fogs at create only. -#[derive(Clone, Copy, PartialEq, Eq)] +/// 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 { - /// `UI_MainMenu`, the login screen's burning gate (`AccountLogin.xml`'s ModelFFX). MainMenu, - /// A race's `UI_` stage (Gnome uses Dwarf's, Troll uses Orc's). - Race(u8), + Human, + Orc, + Dwarf, + NightElf, + Scourge, + Tauren, } -/// The glue screens' input to the booth: the scene (`None` tears the booth down; select shows Orc -/// before a list arrives), the standing look (`None` for an empty stage), and the yaw in radians, -/// which create resets to the reference's โˆ’15ยฐ and select to 0; a yaw change never re-bakes. +impl GlueScene { + const ALL: [GlueScene; 7] = [ + GlueScene::MainMenu, + GlueScene::Human, + GlueScene::Orc, + GlueScene::Dwarf, + GlueScene::NightElf, + GlueScene::Scourge, + GlueScene::Tauren, + ]; + + /// 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", + } + } + + /// 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 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 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), + }; + SceneFog { + rgb, + near: 0.0, + far, + } + } +} + +/// A scene's fog: the colour and the span the fog fill callback (`0x76d680`) reads off the pane. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) struct SceneFog { + pub(crate) rgb: [f32; 3], + pub(crate) near: f32, + pub(crate) far: f32, +} + +/// The glue screens' input to the booth: the scene (`None` tears the booth down), the standing +/// look (`None` for an empty stage), the yaw in radians, which create resets to the reference's +/// โˆ’15ยฐ and select to 0 (a yaw change never re-bakes), and the scene's fog. The fog is the +/// pane's while the scene holds the fog fill callback `Model:SetModel` installs (`0x76d680`), and +/// none once `SetCharSelectBackground` put the select screen's in its place (`0x472150`). #[derive(Resource)] pub(crate) struct GluePreview { pub(crate) scene: Option, pub(crate) look: Option, pub(crate) yaw: f32, + pub(crate) fog: Option, + /// The stock screens' pane the scene fills, `[left, right, top, bottom]` as window fractions + /// from the top-left ([`super::framing::stock_pane_view`]); `None` frames the native way. + pub(crate) pane: Option<[f32; 4]>, + /// The stage's instance: a change starts the scene over, as each `SetModel` makes a new + /// instance (`0x76cce0`), the same file included. + pub(crate) stage: u64, + /// Bumped as a glue screen's scene shows: the `ModelFFX`'s OnShow pins the glow back to + /// 0.30 (`0x46fa60`), which nothing else does ([`sync_glue_ffx`]). + pub(crate) shown: u64, } impl Default for GluePreview { @@ -149,10 +241,25 @@ impl Default for GluePreview { scene: None, look: None, yaw: 0.0, + fog: None, + pane: None, + stage: 0, + shown: 0, } } } +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); + self.scene = Some(scene); + self.look = Some(GlueLook::Create(look)); + self.fog = Some(scene.stock_fog()); + } +} + /// The glue background scene, the reference's fullscreen `ModelFFX`: `SetBackgroundModel` loads /// `UI_.mdx`, plays sequence 0 and shows camera 0. Its root never yaws; the character /// stands on its attachment 0, and its camera drives the booth camera while it shows. @@ -167,9 +274,10 @@ pub(crate) struct CreateScene { /// The shipped scene's art extent ([`benilla_formats::shipped_glue_art_extent`]): caps how far /// [`glue_scene_framing`] opens upward on a narrow window. `None` with no scene. art: Option, - /// `Some(aspect)` while the window is wider than [`super::framing::GLUE_BOX_ASPECT`]: the - /// camera renders into a centred viewport of this aspect ([`pillarbox_glue_scene`]). Set from - /// the window, not the scene, so the chrome's canvas never moves on a race change. + /// `Some(aspect)` while the window is wider than [`super::framing::GLUE_BOX_ASPECT`] under the + /// native screens: the camera renders into a centred viewport of this aspect + /// ([`pillarbox_glue_scene`]); a stock pane is its own viewport. Set from the window, not the + /// scene, so the chrome's canvas never moves on a race change. viewport_aspect: Option, /// The character's stage spot, scene attachment 0 in Bevy model space (`ZERO` with no scene); /// the body seats on attachment 0 at select too (`0x473039`). @@ -188,13 +296,14 @@ pub(crate) struct CreateScene { pet_stage_scale: f32, /// The revision of the pet bake standing at [`Self::pet_spot`], so a re-bake happens once. pet_baked: Option, - /// Whether the spawn wrote fog enabled (create); the scene rebuilds when it flips. - fog: bool, + /// The fog the spawn wrote; the scene rebuilds when it changes. + fog: Option, + /// The [`GluePreview::stage`] the spawn stood; the scene rebuilds when it changes. + stage: u64, /// Whether the buffer holds the ghost rig ([`ghost_rig`]); a flip rewrites the buffer in /// place, like the reference's per-frame fill callback, so the stage's emitters keep running. ghost: bool, - /// The scene's light buffer: its authored rig ([`SceneRig`]) plus the per-race fog - /// (`CharModelFogInfo`) at create only; select renders unfogged (`0x472110`). + /// The scene's light buffer: its authored rig ([`SceneRig`]) plus [`GluePreview::fog`]. light: Option, /// Booth twins of the character's materials against the scene buffer (`BoothLight::variants`). variants: std::collections::HashMap, Handle>, @@ -204,6 +313,36 @@ pub(crate) struct CreateScene { pending: Option, } +impl CreateScene { + /// No scene up, over its two roots. + fn empty(root: Entity, pet_root: Entity) -> Self { + CreateScene { + root, + token: None, + handle: None, + spawned: false, + cam: None, + art: None, + viewport_aspect: None, + char_spot: Vec3::ZERO, + char_facing: Quat::IDENTITY, + char_scale: 1.0, + pet_root, + pet_spot: Vec3::ZERO, + pet_facing: Quat::IDENTITY, + pet_stage_scale: 1.0, + pet_baked: None, + fog: None, + stage: 0, + ghost: false, + light: None, + variants: default(), + rev: 0, + pending: None, + } + } +} + /// A requested stage swap, held until both halves are ready. The scene M2 and the character's /// assembly land frames apart, and whichever came first would stand the wrong character in the /// wrong stage; the reference's `SelectCharacter` loads both in one blocking call. The loads start @@ -220,22 +359,6 @@ struct PendingSwap { /// The bound on that hold, so a look whose assembly never completes cannot freeze the screen. const GLUE_SWAP_HOLD_SECS: f32 = 2.0; -/// The scene's model-name token, `Interface\Glues\Models\UI_\UI_.mdx`. The race -/// mapping is `GlueParent.lua`'s `SetBackgroundModel`; the main menu is `AccountLogin.xml`'s. -fn scene_token(scene: GlueScene) -> &'static str { - match scene { - GlueScene::MainMenu => "MainMenu", - GlueScene::Race(race) => match race { - 2 | 8 => "Orc", // Troll shares Orc's scene - 3 | 7 => "Dwarf", // Gnome shares Dwarf's - 4 => "NightElf", - 5 => "Scourge", - 6 => "Tauren", - _ => "Human", - }, - } -} - /// Whether the stage may swap this frame: nothing coherent is `standing`, both the requested art /// and body are ready, or the hold has reached [`GLUE_SWAP_HOLD_SECS`]. fn may_swap(standing: bool, art_ready: bool, body_ready: bool, waited: f32) -> bool { @@ -311,15 +434,15 @@ fn ghost_rig() -> SceneRig { /// Pack a [`SceneRig`] into the glue scene's light buffer. The core rows carry the plain ambient /// with a zero sun for any non-rig lane; the directional fold lands in probe slot 0 (`MeshTag` 0). -fn scene_light_blob( - rig: &SceneRig, - fog_rgb: [f32; 3], - fog_far: f32, - fog: bool, -) -> benilla_world::lighting::LightBlob { +fn scene_light_blob(rig: &SceneRig, fog: Option) -> benilla_world::lighting::LightBlob { + let span = fog.unwrap_or(SceneFog { + rgb: [0.0; 3], + near: 0.0, + far: 1.0, + }); let mut blob = benilla_world::lighting::LightBlob::model(rig.ambient, [0.0; 3], Vec3::NEG_Y) .probe(rig.ambient, &rig.lobes) - .fog(fog_rgb, fog_far, fog) + .fog_span(span.rgb, span.near, span.far, fog.is_some()) .dial(1.0); for (pos, color) in &rig.points { blob = blob.point(*pos, SCENE_POINT_RANGE, *color); @@ -327,20 +450,6 @@ fn scene_light_blob( blob } -/// The per-scene fog, colour and far (near is 0): the race rows are `GlueParent.lua`'s -/// `CharModelFogInfo`; the main menu's is `AccountLogin.xml`'s `` and `fogFar`. -fn scene_fog(token: &str) -> ([f32; 3], f32) { - match token { - "MainMenu" => ([0.25, 0.06, 0.015], 1200.0), - "Orc" => ([0.5, 0.5, 0.5], 270.0), - "Dwarf" => ([0.85, 0.88, 1.0], 500.0), - "NightElf" => ([0.25, 0.22, 0.55], 611.0), - "Scourge" => ([0.0, 0.22, 0.22], 26.0), - "Tauren" => ([1.0, 0.61, 0.42], 153.0), - _ => ([0.8, 0.65, 0.73], 222.0), // Human - } -} - /// One assembled preview mesh, the tuple-driven counterpart of [`super::PortraitPart`]. #[derive(Clone)] pub(crate) struct PreviewPart { @@ -522,29 +631,7 @@ pub(super) fn spawn_glue_booth( let pet_root = commands .spawn((Transform::IDENTITY, Visibility::Visible, layer)) .id(); - commands.insert_resource(CreateScene { - root: scene_root, - token: None, - handle: None, - spawned: false, - cam: None, - art: None, - viewport_aspect: None, - char_spot: Vec3::ZERO, - char_facing: Quat::IDENTITY, - char_scale: 1.0, - pet_root, - pet_spot: Vec3::ZERO, - pet_facing: Quat::IDENTITY, - pet_stage_scale: 1.0, - pet_baked: None, - fog: true, - ghost: false, - light: None, - variants: default(), - rev: 0, - pending: None, - }); + commands.insert_resource(CreateScene::empty(scene_root, pet_root)); } /// Drive the glue FFX state ([`benilla_world::ffx_glow::GlueFfx`]) off the live look: the pass @@ -552,32 +639,48 @@ pub(super) fn spawn_glue_booth( /// /// | look | reference | pass | glow | /// |---|---|---|---| -/// | none (login, empty account) | `CSimpleModelFFX::OnShow 0x46fa60` | glow | `*0x8380b4` 0.30 | +/// | none, from the show (login) | `CSimpleModelFFX::OnShow 0x46fa60` | glow | `*0x8380b4` 0.30 | /// | a create look | same, no select build runs | glow | 0.30 | /// | a living select record | `0x472ff7`/`0x473002` | glow | `*0x838574` 0.40 | /// | a ghost select record | `0x472fde`/`0x472fe9` | death | `*0x838570` 0.15 | /// -/// An empty account is the OnShow state: `0x472950` is gated `-1 โ‰ค [0x83856c] < [0xb42140]`. +/// Only the OnShow and a select build write it. A select scene with nobody standing keeps what +/// the last build wrote: the list ask (`0x4723d0`), the teardown (`0x472580`) and the load +/// (`0x4724d0`) never touch it, and an empty account's build exits before it (`0x472950` is gated +/// `-1 โ‰ค [0x83856c] < [0xb42140]`). The screen's show ([`GluePreview::shown`]) puts it back. pub(super) fn sync_glue_ffx( preview: Res, mut ffx: ResMut, + mut shown: Local>, ) { - use benilla_world::ffx_glow::GlueFfx; - let want = match preview.look { - Some(GlueLook::Select(l)) => { - if l.flags & benilla_protocol::CHARACTER_FLAG_GHOST != 0 { - GlueFfx::SelectGhost - } else { - GlueFfx::SelectLiving - } - } - Some(GlueLook::Create(_)) | None => GlueFfx::Shown, + let held = if *shown != Some(preview.shown) { + *shown = Some(preview.shown); + benilla_world::ffx_glow::GlueFfx::Shown + } else { + *ffx }; + let want = glue_ffx(preview.look.as_ref(), held); if *ffx != want { *ffx = want; } } +/// The glue FFX state a look leaves, over `held`, the state before it ([`sync_glue_ffx`]). +fn glue_ffx( + look: Option<&GlueLook>, + held: benilla_world::ffx_glow::GlueFfx, +) -> benilla_world::ffx_glow::GlueFfx { + use benilla_world::ffx_glow::GlueFfx; + match look { + Some(GlueLook::Select(l)) if l.flags & benilla_protocol::CHARACTER_FLAG_GHOST != 0 => { + GlueFfx::SelectGhost + } + Some(GlueLook::Select(_)) => GlueFfx::SelectLiving, + Some(GlueLook::Create(_)) => GlueFfx::Shown, + None => held, + } +} + /// 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. #[allow(clippy::type_complexity)] @@ -656,19 +759,16 @@ pub(super) fn sync_glue_scene( w.physical_width().max(1), w.physical_height().max(1), ); - let box_aspect = glue_box_aspect(w.width() / w.height().max(1.0)); + // A stock pane is its own frame ([`pillarbox_glue_scene`]). + let box_aspect = + glue_box_aspect(w.width() / w.height().max(1.0)).filter(|_| preview.pane.is_none()); if scene.viewport_aspect != box_aspect { scene.viewport_aspect = box_aspect; } } - let token = scene_token(which); - // A race stage fogs at create and not at select (`0x472110` replaces the fog callback); the - // main menu always fogs. - let fog = match which { - GlueScene::MainMenu => true, - GlueScene::Race(_) => matches!(preview.look, Some(GlueLook::Create(_))), - }; + let token = which.token(); + let fog = preview.fog; // The ghost light fork (`0x472150`, [`ghost_rig`]), shared by the scene, character and pet. let ghost = matches!( preview.look, @@ -718,8 +818,9 @@ pub(super) fn sync_glue_scene( commands.entity(scene.root).despawn_related::(); // The model may still be loading: strip the old rig state off the root now. super::clear_booth_rig(&mut commands, scene.root); - } else if scene.spawned && scene.fog != fog { - // Same scene, other screen: rebuild in place (the handle is cached, so it respawns now). + } else if scene.spawned && (scene.fog != fog || scene.stage != preview.stage) { + // Same scene, other screen or a new instance of it: rebuild in place (the handle is + // cached, so it respawns now). scene.spawned = false; commands.entity(scene.root).despawn_related::(); } else if scene.spawned && scene.ghost != ghost { @@ -734,8 +835,7 @@ pub(super) fn sync_glue_scene( } else { scene_rig(&model.lights) }; - let (fog_rgb, fog_far) = scene_fog(token); - scene_light_blob(&rig, fog_rgb, fog_far, fog).write(&queue, &light); + scene_light_blob(&rig, fog).write(&queue, &light); scene.ghost = ghost; // The character and the pet re-bake onto the rewritten buffer. scene.rev += 1; @@ -767,8 +867,7 @@ pub(super) fn sync_glue_scene( } else { scene_rig(&model.lights) }; - let (fog_rgb, fog_far) = scene_fog(token); - let blob = scene_light_blob(&rig, fog_rgb, fog_far, fog); + let blob = scene_light_blob(&rig, fog); let light = scene .light .get_or_insert_with(|| blob.create(&device, "wow_create_scene_light")) @@ -782,6 +881,7 @@ pub(super) fn sync_glue_scene( anim_mirrors.0.insert("glue_scene", light.clone()); blob.write(&queue, &light); scene.fog = fog; + scene.stage = preview.stage; scene.ghost = ghost; let dc = blob.probe_dc(); info!( @@ -952,10 +1052,15 @@ pub(super) fn sync_glue_scene( .unwrap_or(4.0 / 3.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; [`glue_scene_framing`] holds the authored 4:3 view box - // where the reference converts at the display's aspect (the Deviation is stated there). - // Far is kept generous (27.8 authored on Orc). - let vert_fov = glue_scene_framing(cam.fov, aspect, scene.art); + // The record fov is diagonal. A stock pane converts it at the pane's own aspect, as the + // reference does; the native screens' [`glue_scene_framing`] holds the authored 4:3 view + // box instead (the Deviation is stated there). 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, scene.art, pane); let rig = ( Transform::from_translation(cam.eye).looking_at(cam.target, up), Projection::from(PerspectiveProjection { @@ -969,10 +1074,26 @@ pub(super) fn sync_glue_scene( } } -/// Pillarbox the glue booth camera to [`CreateScene::viewport_aspect`], black either side; -/// writes only on change. +/// The scene camera's vertical opening for its record's diagonal `fov`: at a stock pane's own +/// aspect by the reference's law (`0x5c3cc0`), else the native screens' [`glue_scene_framing`] +/// for the window. +fn scene_vert_fov( + fov: f32, + window_aspect: f32, + art: Option, + pane: Option, +) -> f32 { + match pane { + Some(view) => diag_to_vert(fov, view.aspect), + None => glue_scene_framing(fov, window_aspect, art), + } +} + +/// Set the glue booth camera's viewport: a stock pane's rect ([`stock_pane_view`]), else the +/// native pillarbox to [`CreateScene::viewport_aspect`], black either side; writes only on change. pub(super) fn pillarbox_glue_scene( scene: Res, + preview: Res, window: Query<&Window, With>, mut cams: Query<(&BoothCam, &mut Camera)>, ) { @@ -980,15 +1101,23 @@ pub(super) fn pillarbox_glue_scene( return; }; let full_h = w.physical_height().max(1); - // The same arithmetic the chrome's canvas insets by ([`glue_box_physical`]). - let viewport = - glue_box_physical(w.physical_width(), full_h, scene.viewport_aspect).map(|(x, box_w)| { - bevy::camera::Viewport { + let viewport = match preview.pane { + Some(pane) => { + stock_pane_view(w.physical_width(), full_h, pane).map(|view| bevy::camera::Viewport { + physical_position: view.position, + physical_size: view.size, + ..default() + }) + } + // The same arithmetic the chrome's canvas insets by ([`glue_box_physical`]). + None => glue_box_physical(w.physical_width(), full_h, scene.viewport_aspect).map( + |(x, box_w)| bevy::camera::Viewport { physical_position: UVec2::new(x, 0), physical_size: UVec2::new(box_w, full_h), ..default() - } - }); + }, + ), + }; for (booth, mut cam) in cams.iter_mut() { if booth.0 != GLUE_SLOT { continue; @@ -1059,7 +1188,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(scene_token)); + .is_none_or(|s| s.token == preview.scene.map(GlueScene::token)); let rebake = staged && (last_rev != bake.revision || last_scene != scene_rev); let reyaw = last_yaw != preview.yaw; if !rebake && !reyaw { @@ -1506,8 +1635,7 @@ pub(super) fn drive_create_test( look.hair_color, look.facial_hair ); - preview.scene = Some(GlueScene::Race(look.race)); - preview.look = Some(GlueLook::Create(look)); + preview.show_create(look.race, look); // Show the booth's render target full-screen, so a live shot shows the body. if let Some(PortraitSource::Live(image)) = portraits.0.get(GLUE_SLOT) { commands.spawn(( @@ -1529,6 +1657,170 @@ mod tests { use benilla_assets::ModelLight; use benilla_formats::M2Light; + /// `GlueParent`'s rect on a window of `aspect` (`GlueParent.lua:78-84`): the whole screen up to + /// 16:9, a centred 16:9 box past it; as window fractions from the top-left. + fn glue_parent(aspect: f32) -> [f32; 4] { + let bar = ((aspect - 16.0 / 9.0) / (2.0 * aspect)).max(0.0); + [bar, 1.0 - bar, 0.0, 1.0] + } + + const WINDOWS: [(u32, u32); 4] = [(1024, 768), (1280, 800), (1920, 1080), (2560, 1080)]; + + /// Under the stock screens the scene fills the pane, `GlueParent`'s rect, at the pane's + /// aspect, `min(window, 16:9)`, through the diagonal law: no box inside it, and the only bars + /// are outside `GlueParent`, at 21:9. + #[test] + fn a_stock_pane_frames_the_scene_at_its_own_aspect() { + let fov = 1.0; + for (w, h) in WINDOWS { + let a = w as f32 / h as f32; + let view = super::super::framing::stock_pane_view(w, h, glue_parent(a)).unwrap(); + let pane_w = (h as f32 * a.min(16.0 / 9.0)).round() as u32; + assert_eq!(view.size, UVec2::new(pane_w, h), "{w}x{h}"); + assert_eq!(view.position, UVec2::new((w - pane_w) / 2, 0), "{w}x{h}"); + let want = a.min(16.0 / 9.0); + assert!( + (view.aspect - want).abs() < 1e-3, + "{w}x{h}: {}", + view.aspect + ); + let vert = scene_vert_fov(fov, a, None, Some(view)); + let law = fov / (want * want + 1.0).sqrt(); + assert!((vert - law).abs() < 1e-3, "{w}x{h}: {vert} vs {law}"); + } + // Not the native screens' held box: at 16:10 that opens wider than the law. + let native = scene_vert_fov(fov, 1.6, None, None); + assert!((native - fov / (1.6f32 * 1.6 + 1.0).sqrt()).abs() > 1e-2); + } + + /// The booth camera's viewport is the stock pane's rect, no pillarbox inside it; the native + /// screens keep their pillarbox past [`super::super::framing::GLUE_BOX_ASPECT`]. + #[test] + fn the_viewport_is_the_stock_pane_and_the_native_box_otherwise() { + for stock in [true, false] { + for (w, h) in WINDOWS { + let a = w as f32 / h as f32; + let mut app = App::new(); + let root = app.world_mut().spawn_empty().id(); + let pet = app.world_mut().spawn_empty().id(); + let mut scene = CreateScene::empty(root, pet); + scene.viewport_aspect = glue_box_aspect(a).filter(|_| !stock); + let preview = GluePreview { + pane: stock.then(|| glue_parent(a)), + ..GluePreview::default() + }; + app.insert_resource(scene) + .insert_resource(preview) + .add_systems(Update, pillarbox_glue_scene); + app.world_mut().spawn(( + Window { + resolution: UVec2::new(w, h).into(), + ..default() + }, + PrimaryWindow, + )); + let cam = app + .world_mut() + .spawn((BoothCam(GLUE_SLOT.to_string()), Camera::default())) + .id(); + app.update(); + let vp = app + .world() + .get::(cam) + .unwrap() + .viewport + .clone() + .map(|v| (v.physical_position, v.physical_size)); + let want = if stock { + let view = super::super::framing::stock_pane_view(w, h, glue_parent(a)); + view.map(|v| (v.position, v.size)) + } else { + glue_box_physical(w, h, glue_box_aspect(a)) + .map(|(x, bw)| (UVec2::new(x, 0), UVec2::new(bw, h))) + }; + assert_eq!(vp, want, "stock {stock} {w}x{h}"); + if stock && a <= 16.0 / 9.0 { + assert_eq!( + vp, + Some((UVec2::ZERO, UVec2::new(w, h))), + "no bars at {w}x{h}" + ); + } + } + } + } + + /// The glow a look leaves: a select build writes its own, and nobody standing keeps what the + /// last build wrote until the screen's show puts the OnShow 0.30 back. + #[test] + fn an_empty_select_holds_the_last_builds_glow_until_the_screen_shows() { + use benilla_world::ffx_glow::GlueFfx; + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .add_systems(Update, sync_glue_ffx); + let mut ghost = benilla_protocol::Character { + flags: benilla_protocol::CHARACTER_FLAG_GHOST, + ..crate::char_select::test_character(1, "Boo") + }; + let set = |app: &mut App, look: Option, show: bool| { + { + let mut p = app.world_mut().resource_mut::(); + p.look = look; + if show { + p.shown += 1; + } + } + app.update(); + *app.world().resource::() + }; + assert_eq!(set(&mut app, None, true), GlueFfx::Shown); + let ghost_look = GlueLook::Select(SelectLook::from(&ghost)); + assert_eq!(set(&mut app, Some(ghost_look), false), GlueFfx::SelectGhost); + assert_eq!( + set(&mut app, None, false), + GlueFfx::SelectGhost, + "a reload keeps it" + ); + ghost.flags = 0; + let living = GlueLook::Select(SelectLook::from(&ghost)); + assert_eq!(set(&mut app, Some(living), false), GlueFfx::SelectLiving); + assert_eq!(set(&mut app, None, false), GlueFfx::SelectLiving); + assert_eq!( + set(&mut app, None, true), + GlueFfx::Shown, + "the show puts 0.30 back" + ); + // A build in the show's own frame wins: it runs after the OnShow. + 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. + #[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}"); + } + assert_eq!( + GlueScene::from_model_path("interface/glues/models/ui_nightelf/UI_NIGHTELF.M2"), + Some(GlueScene::NightElf) + ); + 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); + } + /// `petDisplayId == 0` is the client's whole gate: the server zeroes the triple for all but a /// living hunter or warlock (vmangos `Player::BuildEnumData`). #[test] diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index efdd9933..306ed6ce 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -56,7 +56,7 @@ pub(crate) use dressup::{DressUpBake, DressUpLook, DressUpPreview}; mod glue_booth; pub(crate) use glue_booth::{ CreateLook, GhostKit, GlueLook, GluePetBake, GluePreview, GluePreviewBake, GlueScene, PetLook, - PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider, SelectLook, GLUE_SLOT, + PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider, SceneFog, SelectLook, GLUE_SLOT, }; mod light; pub(crate) use light::{material_variant, VariantLane}; @@ -120,6 +120,11 @@ pub(crate) fn model_pane_booth(name: &str) -> Option<&'static str> { .map(|(_, slot)| *slot) } +/// The systems that write [`GluePreview`], one per glue screen set (the native screens', the +/// stock glue VM's); the glue booth reads it after them, the same frame. +#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct GluePreviewFeed; + /// World is layer 0, the UI pass layer 1; each portrait slot gets its own layer from here up. const PORTRAIT_LAYER_BASE: usize = 2; /// The paper-doll booth's layer, the first past the portrait slots. @@ -803,7 +808,8 @@ impl Plugin for PortraitPlugin { // Last: it reads the wake/pending state every sync above may have armed. gate_booth_cameras, ) - .chain(), + .chain() + .after(GluePreviewFeed), ) // Re-face each booth's eye-glow cards to its own camera. .add_systems(Update, booth::face_booth_billboards) diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index b7b2ba99..2397217d 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -1140,8 +1140,9 @@ fn convert_entry( }); } // A ``/`` pane draws itself, as the stock files declare a bare pane - // with no Texture: a file pane is a model tile, a unit pane samples its window's body - // bake square ([`crate::portrait::model_pane_booth`]), and an unclaimed pane draws nothing. + // with no Texture: a glue `ModelFFX` is the glue booth's scene, a file pane is a model + // tile, a unit pane samples its window's body bake square + // ([`crate::portrait::model_pane_booth`]), and an unclaimed pane draws nothing. QuadContent::ModelPane { handle, name, @@ -1154,8 +1155,41 @@ fn convert_entry( camera, light, fog, + ffx, } => { use crate::portrait::PortraitSource; + // A `ModelFFX`'s scene draws once, at its own bucket's ARTWORK callback (`0x46fa90` + // overrides the paint registration `0x76d160`), so every UI region paints over it. + // The scene is the glue booth's, which renders the window's view of the stage the + // glue VM feeds it (`crate::ui_script::glue_scene`); the pane shows its part of it. + if ffx { + let Some(PortraitSource::Live(image)) = + booths.images.0.get(crate::portrait::GLUE_SLOT) + else { + return; + }; + if w <= 0.0 || h <= 0.0 { + return; + } + out.push(UiQuad { + rect, + z_key: eq.z, + texture: Some(image.clone()), + uv: UvRect::from_tex_coords([ + rect.min.x / w, + rect.max.x / w, + rect.min.y / h, + rect.max.y / h, + ]), + // The scene draws at the widget's own alpha (`0x76d120`). + color: [1.0, 1.0, 1.0, own_alpha], + // The booth's target is premultiplied. + premultiplied: true, + clip, + ..default() + }); + return; + } // A file pane publishes an idempotent tile request; `ui_models::compose_tiles` draws // its quad each frame, so a cell packed later needs no re-conversion. if let Some(path) = model.as_deref() { @@ -1939,6 +1973,11 @@ mod extract_gate_tests { fn app_from_script(lua: &str) -> App { let script = UiScript::new().unwrap(); script.run(lua).unwrap(); + app_over(script) + } + + /// [`app_from_script`] over a VM of the caller's. + fn app_over(script: UiScript) -> App { let mut app = App::new(); app.insert_non_send_resource(script); app.init_resource::(); @@ -2015,6 +2054,101 @@ mod extract_gate_tests { ); } + /// A glue `ModelFFX` draws the glue booth's image over its own rect, sampling the window's + /// part of the booth's view, at its bucket's callback rank, and asks the UI model tiles for + /// nothing; a plain `Model` beside it is still a tile. + #[test] + fn a_model_ffx_draws_the_glue_booth_and_asks_for_no_tile() { + let script = UiScript::new_glue().unwrap(); + script + .run( + r#" + local holder = CreateFrame("Frame", "Holder") + holder:SetPoint("TOPLEFT", 0, 0); holder:SetWidth(1024); holder:SetHeight(768) + holder:SetAlpha(0.5) + local scene = CreateFrame("ModelFFX", "Scene", holder) + scene:SetPoint("TOPLEFT", 100, -50) + scene:SetWidth(400); scene:SetHeight(300) + scene:SetModel("Interface\\Glues\\Models\\UI_Orc\\UI_Orc.mdx") + scene:SetAlpha(0.8) + local art = scene:CreateTexture(nil, "ARTWORK") + art:SetAllPoints(scene); art:SetTexture(1, 0, 0) + local over = scene:CreateTexture(nil, "OVERLAY") + over:SetAllPoints(scene); over:SetTexture(0, 1, 0) + local child = CreateFrame("Frame", "Child", scene) + child:SetAllPoints(scene) + local under = child:CreateTexture(nil, "BACKGROUND") + under:SetAllPoints(child); under:SetTexture(0, 0, 1) + local plain = CreateFrame("Model", "Plain") + plain:SetPoint("TOPLEFT", 600, -50) + plain:SetWidth(64); plain:SetHeight(64) + plain:SetModel("Plain.mdx") + "#, + ) + .unwrap(); + let mut app = app_over(script); + let image = app + .world_mut() + .resource_mut::>() + .add(Image::default()); + app.world_mut().resource_mut::().0.insert( + crate::portrait::GLUE_SLOT.to_string(), + crate::portrait::PortraitSource::Live(image.clone()), + ); + app.update(); + + let paths: Vec = app + .world() + .resource::() + .requests + .values() + .map(|r| r.path.clone()) + .collect(); + assert_eq!(paths, ["Plain.mdx"], "the ModelFFX is no tile"); + + let quads = &app.world().resource::().quads; + let scene: Vec<_> = quads + .iter() + .filter(|q| q.texture.as_ref() == Some(&image)) + .collect(); + assert_eq!(scene.len(), 1, "the pane draws the glue booth's image once"); + let q = scene[0]; + assert!(q.premultiplied); + // The widget's own alpha (`0x76d120`); `SetAlpha` writes the raw value down the subtree + // with no product, so the holder's earlier 0.5 is gone. + assert!((q.color[3] - 0.8).abs() < 1e-3, "{:?}", q.color); + // The ARTWORK callback of the pane's own bucket: after its ARTWORK regions, before its + // OVERLAY ones and every child frame's (`0x46fa90`, layer 2). + let z_of = |rgb: [f32; 3]| { + quads + .iter() + .find(|q| q.texture.is_none() && q.color[..3] == rgb) + .map(|q| q.z_key) + .expect("the region drew") + }; + let (art, over, child) = ( + z_of([1.0, 0.0, 0.0]), + z_of([0.0, 1.0, 0.0]), + z_of([0.0, 0.0, 1.0]), + ); + assert!( + art < q.z_key && q.z_key < over && over < child, + "{art} {} {over} {child}", + q.z_key + ); + assert!(q.rect.width() > 0.0 && q.rect.height() > 0.0); + let (w, h) = (1024.0, 768.0); + assert!( + q.uv == super::UvRect::from_tex_coords([ + q.rect.min.x / w, + q.rect.max.x / w, + q.rect.min.y / h, + q.rect.max.y / h, + ]), + "the pane samples its own part of the window's view" + ); + } + /// `PaperDollFrame.xml:204` declares a bare ``; a pane /// no window claims ([`crate::portrait::model_pane_booth`]) must not borrow a bake. #[test] diff --git a/crates/benilla-app/src/ui_script/glue.rs b/crates/benilla-app/src/ui_script/glue.rs index 52b3b70e..58944383 100644 --- a/crates/benilla-app/src/ui_script/glue.rs +++ b/crates/benilla-app/src/ui_script/glue.rs @@ -23,9 +23,9 @@ //! back from the world `0x402090` fires it with `"charselect"` or `"login"`. benilla plays no movie //! yet (#968), so its boot screen is `"login"`. //! -//! The screens' `ModelFFX` panes (P4) must drive the glue booth's scene rig, the scene M2's own -//! point lights (`portrait::glue_booth::scene_rig`) and the `FfxGlow::GLUE_SCENE` pass, never the -//! `ui_models` tile path, where every lit batch draws black. +//! The screens' `ModelFFX` panes drive the glue booth ([`super::glue_scene`]): its scene rig, the +//! scene M2's own point lights and the `FfxGlow::GLUE_SCENE` pass, never the `ui_models` tile +//! path, where every lit batch draws black. use bevy::prelude::*; diff --git a/crates/benilla-app/src/ui_script/glue_mgr.rs b/crates/benilla-app/src/ui_script/glue_mgr.rs index fcbc1479..7eb3f4d9 100644 --- a/crates/benilla-app/src/ui_script/glue_mgr.rs +++ b/crates/benilla-app/src/ui_script/glue_mgr.rs @@ -485,6 +485,12 @@ pub(crate) struct GlueMgr { } impl GlueMgr { + /// The record the last character list reply carried for `guid`: what the scene drive builds + /// the selected character from (`0x472950` reads the select record's own copy). + pub(crate) fn roster_character(&self, guid: u64) -> Option<&Character> { + self.roster.iter().find(|c| c.guid == guid) + } + /// The agreements as read at the startup (`0x46a400`). pub(super) fn set_notices(&mut self, notices: [NoticeState; 4]) { self.notices = Some(notices); 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 5fe71155..182d33bc 100644 --- a/crates/benilla-app/src/ui_script/glue_mgr_tests.rs +++ b/crates/benilla-app/src/ui_script/glue_mgr_tests.rs @@ -771,7 +771,16 @@ fn stock_app() -> ( .add_message::() .add_message::() .add_message::() - .add_systems(Update, (drive_glue_mgr, crate::login::answer_glue).chain()); + .init_resource::() + .add_systems( + Update, + ( + drive_glue_mgr, + super::super::glue_scene::feed_stock_glue_preview, + crate::login::answer_glue, + ) + .chain(), + ); let mut s = UiScript::new_glue().unwrap(); s.set_screen_size(1024.0, 768.0); let _ = super::super::glue::load_glue(&mut s); @@ -1126,9 +1135,16 @@ fn live_app_with( .add_message::() .add_message::() .add_message::() + .init_resource::() .add_systems( Update, - (pump, drive_glue_mgr, crate::login::answer_glue).chain(), + ( + pump, + drive_glue_mgr, + super::super::glue_scene::feed_stock_glue_preview, + crate::login::answer_glue, + ) + .chain(), ); let mut s = UiScript::new_glue().unwrap(); s.set_screen_size(1024.0, 768.0); @@ -1588,6 +1604,130 @@ fn an_empty_roster_asks_for_the_realm_list_only_with_no_realm_chosen() { assert_eq!(vm(&app).errors(), Vec::::new()); } +// โ”€โ”€ The stock screens' scenes through the glue booth โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +fn preview(app: &App) -> &crate::portrait::GluePreview { + app.world().resource::() +} + +/// 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] +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!(p.look.is_none()); + let fog = p.fog.expect("the main menu fogs"); + assert_eq!((fog.near, fog.far), (0.0, 1200.0)); + for (got, want) in fog.rgb.iter().zip([0.25, 0.06, 0.015]) { + assert!((got - want).abs() < 1.0 / 255.0, "{:?}", fog.rgb); + } +} + +/// The selected character stands in the select scene: the roster load's selection, then a +/// row's `CharacterSelect_SelectCharacter`, which sets its race's stage unfogged; the yaw is the +/// 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}; + let _ = benilla_formats::wow_data_or_skip!(); + let mut roster = roster3(); + let mut dwarf = crate::char_select::test_character(0x14, "Delta"); + (dwarf.race, dwarf.zone) = (3, 12); + roster.push(dwarf); + let (mut app, _realmd, _world) = char_select_app(&roster, "1", false); + until(&mut app, "the roster", |app| { + seen(app, "CHARACTER_LIST_UPDATE") >= 2 + && app.world().resource::().state == ScreenState::Idle + }); + app.update(); + let p = preview(&app); + assert!( + 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.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)); + + vm(&app).run("SetCharacterSelectFacing(90)").unwrap(); + app.update(); + let yaw = preview(&app).yaw; + assert!((yaw - std::f32::consts::FRAC_PI_2).abs() < 1e-4, "{yaw}"); + vm(&app).run("CharacterSelect_SelectCharacter(3)").unwrap(); + app.update(); + let p = preview(&app); + assert_eq!(p.yaw, 0.0, "every select zeroes the facing (`0x472a61`)"); + assert_eq!( + p.scene, + Some(GlueScene::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 + // instance (`0x76cce0`), so the scene starts over. + let gnome_stage = p.stage; + vm(&app).run("CharacterSelect_SelectCharacter(4)").unwrap(); + app.update(); + let p = preview(&app); + assert_eq!(p.scene, Some(GlueScene::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])); + assert_eq!(vm(&app).errors(), Vec::::new()); +} + +/// #978: an account with no characters shows `CharacterSelect`'s OnLoad scene, `UI_Orc` under +/// Orc's fog. Once a character has set its stage the scene stays, unfogged, when the roster +/// 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| { + seen(app, "CHARACTER_LIST_UPDATE") >= 2 + && app.world().resource::().state == ScreenState::Idle + }); + app.update(); + let p = preview(&app); + assert_eq!(p.scene, Some(GlueScene::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)); + + let mut tauren = crate::char_select::test_character(0x41, "Moo"); + (tauren.race, tauren.zone) = (6, 12); + let (mut app, _realmd, world) = char_select_app(&[tauren], "0", true); + until(&mut app, "the roster load", |app| { + seen(app, "CHARACTER_LIST_UPDATE") >= 2 + && app.world().resource::().state == ScreenState::Idle + }); + app.update(); + let p = preview(&app); + assert_eq!((p.scene, p.fog), (Some(GlueScene::Tauren), None)); + assert!(p.look.is_some()); + + world.set_roster(&[]); + let before = seen(&app, "CHARACTER_LIST_UPDATE"); + vm(&app).run("GetCharacterListUpdate()").unwrap(); + until(&mut app, "the empty roster", |app| { + seen(app, "CHARACTER_LIST_UPDATE") > before + && app.world().resource::().state == ScreenState::Idle + }); + app.update(); + let p = preview(&app); + assert_eq!((p.scene, p.fog), (Some(GlueScene::Tauren), None)); + assert!(p.look.is_none(), "the records are freed and detached"); + assert_eq!(vm(&app).errors(), Vec::::new()); +} + /// The roster's race and class names are the DBC rows' `Name_Lang`, the file string the race /// row's, and a race or class with no row reads empty (`0x473336`, `0x47336e`); with no catalogs /// loaded every row is missing. diff --git a/crates/benilla-app/src/ui_script/glue_scene.rs b/crates/benilla-app/src/ui_script/glue_scene.rs new file mode 100644 index 00000000..33331f56 --- /dev/null +++ b/crates/benilla-app/src/ui_script/glue_scene.rs @@ -0,0 +1,196 @@ +//! The stock glue screens' 3-D scenes: the glue VM's shown `ModelFFX` pane drives the glue booth +//! ([`crate::portrait::GluePreview`]), which renders the scene, its light and fog, the selected +//! character on attachment 0 and its pet on attachment 1; the pane then draws the booth's image at +//! its own bucket (the extract's `ModelPane` arm), so every GlueXML region paints over it. +//! +//! The scene is the pane's file: `AccountLogin.xml`'s `UI_MainMenu`, `CharacterSelect`'s OnLoad +//! `UI_Orc` (`CharacterSelect.lua:26`), or the stage `SetCharSelectBackground` gives it. Its fog is +//! the pane's own until `SetCharSelectBackground` swaps the fog fill callback for the select +//! screen's (`0x472110`), which never fogs. The character is the select record the last +//! `SelectCharacter` built and attached (`0x472950`), turned by the facing cell (`[0xb4217c]`, +//! `SetCharacterSelectFacing`). +//! +//! The scene fills the pane at the pane's own aspect, as the reference frames it, and starts over +//! with each `SetModel` instance; the pane's showing resets the glue glow (`0x46fa60`). + +use bevy::prelude::*; + +use benilla_ui::script::{GlueScenePane, UiScript, VmKind}; + +use super::glue_mgr::GlueMgr; +use crate::portrait::{GlueLook, GluePreview, GlueScene, SceneFog, SelectLook}; + +/// What the booth stands while the stock glue screens are up: `pane` is the shown `ModelFFX`'s +/// scene, `standing` the character on its attachment 0, `facing` the select facing cell in +/// radians. A file that names none of the shipped scenes stands nothing. +fn stock_preview( + pane: Option<&GlueScenePane>, + standing: Option<&benilla_protocol::Character>, + facing: f32, +) -> (Option, Option, f32, Option) { + let Some(pane) = pane 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 + ); + } + // The character and the facing are the select frame's scene's alone (`[0xb42174]`). + let (look, yaw) = if pane.select { + ( + standing.map(|c| GlueLook::Select(SelectLook::from(c))), + facing, + ) + } else { + (None, 0.0) + }; + let fog = pane.fog.map(|f| SceneFog { + rgb: f.rgb(), + near: f.near, + far: f.far, + }); + (scene, look, yaw, fog) +} + +/// What the feeder remembers about the glue VM between frames: the pane shown last and the +/// instance it held, numbered into [`GluePreview::stage`]. +#[derive(Default)] +pub(crate) struct StockFeed { + shown: Option, + instance: Option<(benilla_ui::widget::FrameHandle, u64)>, +} + +/// Feed the glue booth from the glue VM while the stock glue screens are up, and stand it down +/// once they are not (the world entered, the VM gone). +pub(crate) fn feed_stock_glue_preview( + script: Option>, + mgr: Res, + state: Option>>, + mut preview: ResMut, + mut feed: Local>, +) { + let in_world = state.is_some_and(|s| *s.get() == crate::char_select::ClientState::InWorld); + let script = script.filter(|s| s.vm_kind() == VmKind::Glue && !in_world); + let pane = script.as_deref().and_then(|s| s.glue_scene()); + let (scene, look, yaw, fog) = match script.as_deref() { + Some(s) => { + let standing = s.glue_standing_guid().and_then(|g| mgr.roster_character(g)); + stock_preview(pane.as_ref(), standing, s.glue_select_cells().facing) + } + None => (None, None, 0.0, None), + }; + // A new VM's panes are new frames: the memo starts over with it. + let feed = feed.get_for(script.as_deref()); + // A pane that was not the one shown last frame has just shown: its OnShow (`0x46fa60`). + let shown = pane.as_ref().map(|p| p.handle); + if shown.is_some() && shown != feed.shown { + preview.shown = preview.shown.wrapping_add(1); + } + feed.shown = shown; + // A new instance, or another pane's, is another scene. + let instance = pane.as_ref().map(|p| (p.handle, p.instance)); + if instance.is_some() && instance != feed.instance { + feed.instance = instance; + preview.stage = preview.stage.wrapping_add(1); + } + let rect = pane.as_ref().and_then(|p| p.rect); + if preview.pane != rect { + preview.pane = rect; + } + if preview.scene != scene { + preview.scene = scene; + } + if preview.look != look { + preview.look = look; + } + if preview.yaw != yaw { + preview.yaw = yaw; + } + if preview.fog != fog { + preview.fog = fog; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A shown pane's scene as a glue VM reads it, then given `path`, `select` and `fog`. + fn pane(path: &str, select: bool, fog: Option) -> GlueScenePane { + let s = UiScript::new_glue().unwrap(); + s.run("CreateFrame('ModelFFX', 'Pane'):SetModel('x.mdx')") + .unwrap(); + GlueScenePane { + path: path.into(), + fog, + select, + ..s.glue_scene().unwrap() + } + } + + /// The login's pane: `UI_MainMenu` under `AccountLogin.xml`'s fog, nobody on it. + #[test] + fn the_main_menu_pane_stands_its_scene_under_its_own_fog() { + let fog = benilla_ui::widget::ModelFog { + color: 0xff40_0f04, + near: 0.0, + far: 1200.0, + }; + let c = crate::char_select::test_character(7, "Seven"); + let (scene, look, yaw, got) = stock_preview( + Some(&pane( + "Interface\\Glues\\Models\\UI_MainMenu\\UI_MainMenu.mdx", + false, + Some(fog), + )), + Some(&c), + 1.0, + ); + assert_eq!(scene, Some(GlueScene::MainMenu)); + assert!( + look.is_none() && yaw == 0.0, + "only the select frame's scene holds a character" + ); + let got = got.expect("fogged"); + assert_eq!((got.near, got.far), (0.0, 1200.0)); + assert_eq!(got.rgb, fog.rgb()); + } + + /// The stock login's pane is `GlueParent`'s rect, which fills the screen up to 16:9 and is a + /// centred 16:9 box past it (`GlueParent.lua:78-84`): the rect the scene is framed in. + #[test] + fn the_stock_pane_is_glue_parents_rect_at_every_aspect() { + let _ = benilla_formats::wow_data_or_skip!(); + for aspect in [4.0 / 3.0, 16.0 / 10.0, 16.0 / 9.0, 21.0 / 9.0] { + let mut s = UiScript::new_glue().unwrap(); + s.set_screen_size(768.0 * aspect, 768.0); + let _ = super::super::glue::load_glue(&mut s); + super::super::glue::set_glue_screen(&mut s, super::super::glue::GlueScreen::Login); + s.resolve(); + let pane = s.glue_scene().expect("AccountLogin is shown"); + assert!(pane.path.ends_with("UI_MainMenu.mdx"), "{}", pane.path); + let bar = ((aspect - 16.0 / 9.0) / (2.0 * aspect)).max(0.0); + let rect = pane.rect.expect("laid out"); + for (got, want) in rect.iter().zip([bar, 1.0 - bar, 0.0, 1.0]) { + assert!((got - want).abs() < 1e-4, "{aspect}: {rect:?}"); + } + } + } + + #[test] + fn no_shown_pane_and_an_unknown_file_stand_nothing() { + assert_eq!(stock_preview(None, None, 0.0).0, None); + assert_eq!( + stock_preview( + Some(&pane("Interface\\Glues\\Models\\Odd.mdx", true, None)), + None, + 0.0 + ) + .0, + None + ); + } +} diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 6a54c181..c004b07c 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -20,6 +20,7 @@ pub(crate) mod extract; pub(crate) mod glue; /// `CGlueMgr`'s screen states over the native login, for the stock glue screens. pub(crate) mod glue_mgr; +pub(crate) mod glue_scene; mod input; #[cfg(test)] pub(crate) use input::feed_keys; diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index c3d8dc87..aca9b6b1 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -89,7 +89,8 @@ impl UiScript { icon: m.icon.clone(), camera: m.camera, light: m.light, - fog: m.armed_fog(), + fog: m.scene_fog(), + ffx: m.ffx, }, _ => QuadContent::Frame, }; diff --git a/crates/benilla-ui/src/script/glue_select.rs b/crates/benilla-ui/src/script/glue_select.rs index 7a22d229..e45bf52b 100644 --- a/crates/benilla-ui/src/script/glue_select.rs +++ b/crates/benilla-ui/src/script/glue_select.rs @@ -8,10 +8,11 @@ //! only read it answer here, and `SelectCharacter` fires `UPDATE_SELECTED_CHARACTER` as it returns, //! as `0x472740` does. Indices are 1-based in Lua and 0-based inside, the boundary at `0x473470`. //! -//! The 3-D side of the reference (`0x472950`'s model build into the scene, the attachment detaches -//! `0x4722c0`/`0x4722e0`, the camera reset `0x76cec0`) is the host's scene drive and lives outside -//! this module; what a build leaves behind for Lua to see is kept here: the facing reset and the -//! build counter that gates `SetCharacterSelectFacing`. +//! The 3-D side of the reference (`0x472950`'s model build into the scene) is the host's scene +//! drive; what it reads is kept here: the shown `ModelFFX` pane's scene ([`GlueScenePane`]), the +//! character standing on the scene's attachment 0 ([`super::UiScript::glue_standing_guid`]), and +//! the facing. What a build leaves behind for Lua to see is kept here too: the facing reset and +//! the build counter that gates `SetCharacterSelectFacing`. use mlua::{Lua, MultiValue, Value}; @@ -70,6 +71,27 @@ impl Default for GlueSelectCells { } } +/// The scene a shown `ModelFFX` pane draws, as the host's scene drive reads it. +#[derive(Clone, Debug, PartialEq)] +pub struct GlueScenePane { + /// The pane, whose `OnShow` (`0x46fa60`) resets the glue glow when it appears. + pub handle: crate::widget::FrameHandle, + /// The pane's rect as fractions of the screen, `[left, right, top, bottom]` from the top-left: + /// the scene's viewport (`0x76d5d7`), whose `w/h` is the projection's aspect. `None` until the + /// layout has placed it. + pub rect: Option<[f32; 4]>, + /// The instance the pane's last `SetModel` made ([`crate::widget::ModelState::instance`]). + pub instance: u64, + /// The file the pane's last `SetModel` gave it: the XML `file`, `Model:SetModel`, or + /// `SetCharSelectBackground`'s (`0x472128`, the pane's own `SetModel`). + pub path: String, + /// The fog the scene draws with ([`crate::widget::ModelState::scene_fog`]). + pub fog: Option, + /// The pane is the select screen's model frame (`[0xb42174]`), whose scene the selected + /// character stands in. + pub select: bool, +} + /// A record of the array: the character and whether `+0x110`, the built composite, is set. #[derive(Clone, Debug)] struct Record { @@ -84,8 +106,6 @@ pub(crate) struct GlueRoster { cells: GlueSelectCells, /// `[0xb42174]`: the name of the model frame `SetCharSelectModelFrame` found. model_frame: Option, - /// The file `SetCharSelectBackground` last gave the model frame (`0x472110`). - background: Option, } /// `rad = deg ยท 0.017453292` (`0x7ffaac`, a float). @@ -145,10 +165,77 @@ impl super::UiScript { self.model_mut().glue.roster.cells = cells; } - /// The model frame's background file, as `SetCharSelectBackground` last recorded it: what the - /// scene drive loads into the select scene. + /// The file `SetCharSelectBackground` gave the model frame, while its scene still holds it: a + /// later `SetModel` on the frame replaces the scene and its fill callback with it. pub fn glue_select_background(&self) -> Option { - self.model_ref().glue.roster.background.clone() + let model = self.model_ref(); + let h = model_frame_handle(&model)?; + match &model.arena.frame(h)?.kind_state { + crate::widget::KindState::Model(m) if m.select_fill => m.path.clone(), + _ => None, + } + } + + /// The character standing on the select scene's attachment 0: the selected record once + /// `0x472950` has built its composite (`+0x110`) and attached it (`0x473039`). The list ask + /// (`0x4723d0`) and the teardown (`0x472580`) free the records and then detach + /// (`0x4722c0`/`0x4722e0` at `0x472409` and `0x4725b9`); the load (`0x4724d0`) frees them at + /// `0x4724ee` and detaches later in the same call, through the select's build + /// (`0x472740` โ†’ `0x472a44`) or, for an empty roster, at `0x472533`. World entry's free + /// (`0x472090`) detaches nothing, and the glue screens go with it. + pub fn glue_standing_guid(&self) -> Option { + let model = self.model_ref(); + let roster = &model.glue.roster; + usize::try_from(roster.cells.selected) + .ok() + .and_then(|i| roster.records.get(i)) + .filter(|r| r.built) + .map(|r| r.character.guid) + } + + /// The scene of the shown `ModelFFX` pane, the topmost by (strata, level) if more than one is + /// shown; `None` with none shown, or one holding no file. + pub fn glue_scene(&self) -> Option { + let model = self.model_ref(); + let select = model_frame_handle(&model); + let (sw, sh) = (model.screen.width(), model.screen.height()); + model + .arena + .model_kinds() + .iter() + .filter_map(|&h| { + let f = model.arena.frame(h)?; + let crate::widget::KindState::Model(m) = &f.kind_state else { + return None; + }; + if !m.ffx || !f.effective_visible { + return None; + } + Some(( + (f.strata, f.level), + GlueScenePane { + handle: h, + rect: model + .resolved + .get(&h) + .filter(|_| sw > 0.0 && sh > 0.0) + .map(|r| { + [ + r.left / sw, + r.right / sw, + 1.0 - r.top / sh, + 1.0 - r.bottom / sh, + ] + }), + instance: m.instance, + path: m.path.clone()?, + fog: m.scene_fog(), + select: select == Some(h), + }, + )) + }) + .max_by_key(|(order, _)| *order) + .map(|(_, pane)| pane) } /// The selected character's guid, none off the roster. @@ -174,11 +261,17 @@ impl super::UiScript { /// The roster teardown (`0x472580`): the records freed, the count 0, the selection reset /// through `0x472740(-1)`, which clamps to 0 and fires `UPDATE_SELECTED_CHARACTER` with 1 on - /// the empty roster whatever it held, then `CHARACTER_LIST_UPDATE`. The reset of the model - /// frame's camera (`0x76cec0`) is the scene drive's. + /// the empty roster whatever it held, the model frame's `SetCamera(0)` (`0x4725d7` โ†’ + /// `0x76cec0`), then `CHARACTER_LIST_UPDATE`. pub fn teardown_glue_roster(&mut self) { self.model_mut().glue.roster.clear(); self.select_glue_character(-1); + { + let mut model = self.model_mut(); + if let Some(h) = model_frame_handle(&model) { + model.set_pane_camera(h, 0); + } + } self.fire_event("CHARACTER_LIST_UPDATE", Vec::new()); } @@ -226,6 +319,18 @@ impl super::UiScript { } } +/// `[0xb42174]`'s frame, while the name `SetCharSelectModelFrame` took still names a model pane. +fn model_frame_handle(model: &Model) -> Option { + let h = model + .arena + .lookup(model.glue.roster.model_frame.as_deref()?)?; + matches!( + model.arena.frame(h).map(|f| &f.kind_state), + Some(crate::widget::KindState::Model(_)) + ) + .then_some(h) +} + fn model(lua: &Lua) -> mlua::AppDataRefMut<'_, Model> { lua.app_data_mut::().expect("model app_data") } @@ -262,10 +367,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { )?; // `0x473230`: the file (`0x6f3510`, else `0x83874c`) into `0x472110`, which needs the frame - // set and a non-empty name and hands it to the frame's SetModel (vtable `+0x94`). - // - // Only the path is recorded: the scene drive loads it into the select scene, and the frame's - // own model state, which benilla's widget renders nowhere on the glue, stays as it was. + // set and a non-empty name, hands it to the frame's own `SetModel` (vtable `+0x94`, + // `0x472128`), and on the new scene puts the select screen's fill callback `0x472150` where + // `SetModel` put the fog one (`0x472144`): the select scene never fogs once a background is set. g.set( "SetCharSelectBackground", lua.create_function(|lua, args: MultiValue| { @@ -275,8 +379,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "Usage: SetCharSelectBackground(\"filename\")", )?; let mut m = model(lua); - if m.glue.roster.model_frame.is_some() && !file.is_empty() { - m.glue.roster.background = Some(file); + let Some(h) = model_frame_handle(&m).filter(|_| !file.is_empty()) else { + return Ok(()); + }; + m.set_pane_model(h, file); + if let Some(crate::widget::KindState::Model(pane)) = + m.arena.frame_mut(h).map(|f| &mut f.kind_state) + { + pane.select_fill = true; } Ok(()) })?, @@ -668,9 +778,18 @@ mod tests { s.run("SetCharSelectModelFrame('Scene') SetCharSelectBackground('a.mdx')") .unwrap(); assert_eq!(s.glue_select_background().as_deref(), Some("a.mdx")); + // It is the frame's own `SetModel` (`0x472128`). + assert_eq!( + s.eval::("return Scene:GetModel()").unwrap(), + "a.mdx" + ); // An empty name is ignored (`0x472123`). s.run("SetCharSelectBackground('')").unwrap(); assert_eq!(s.glue_select_background().as_deref(), Some("a.mdx")); + // The frame's own `SetModel` replaces the scene, and the select fill with it. + s.run("Scene:SetModel('b.mdx')").unwrap(); + assert_eq!(s.glue_select_background(), None); + s.run("SetCharSelectBackground('a.mdx')").unwrap(); for (call, usage) in [ ( "SetCharSelectModelFrame()", @@ -687,6 +806,130 @@ mod tests { s.run("UpdateSelectionCustomizationScene()").unwrap(); } + /// The scene's fog follows its fill callback: `SetModel` installs the fog one (`0x76cd96`), so + /// the select screen's OnLoad scene fogs with the fog its fields hold; `SetCharSelectBackground` + /// installs `0x472150` over it (`0x472144`), and the fog fields written after it change nothing; + /// the next `SetModel` brings the fog back. + #[test] + fn the_select_scene_fogs_until_a_background_is_set() { + let s = UiScript::new_glue().unwrap(); + s.run( + "local f = CreateFrame('ModelFFX', 'Scene') \ + f:SetModel('UI_Orc.mdx') f:SetFogColor(0.5, 0.5, 0.5) f:SetFogNear(0) \ + f:SetFogFar(270) SetCharSelectModelFrame('Scene')", + ) + .unwrap(); + let pane = s.glue_scene().expect("the shown ModelFFX"); + assert_eq!(pane.path, "UI_Orc.mdx"); + assert!(pane.select); + let fog = pane.fog.expect("the OnLoad scene fogs"); + assert_eq!((fog.near, fog.far), (0.0, 270.0)); + assert_eq!(fog.rgb().map(|c| (c * 255.0).round()), [128.0; 3]); + + s.run( + "SetCharSelectBackground('UI_Human.mdx') \ + Scene:SetFogColor(0.8, 0.65, 0.73) Scene:SetFogFar(222)", + ) + .unwrap(); + let pane = s.glue_scene().unwrap(); + assert_eq!((pane.path.as_str(), pane.fog), ("UI_Human.mdx", None)); + // Every call makes a new instance, the same file included (`0x76ccf5`-`0x76cd0d`). + let first = pane.instance; + s.run("SetCharSelectBackground('UI_Human.mdx')").unwrap(); + assert!(s.glue_scene().unwrap().instance > first); + + s.run("Scene:SetModel('UI_Dwarf.mdx')").unwrap(); + let pane = s.glue_scene().unwrap(); + assert_eq!(pane.path, "UI_Dwarf.mdx"); + assert_eq!(pane.fog.map(|f| f.far), Some(222.0)); + } + + /// The scene is the shown `ModelFFX`'s, the topmost when two are shown; a hidden one, a plain + /// `Model` and a pane with no file are none. + #[test] + fn the_scene_is_the_topmost_shown_model_ffx() { + let mut s = UiScript::new_glue().unwrap(); + s.set_screen_size(1024.0, 768.0); + assert_eq!(s.glue_scene(), None); + s.run( + "local low = CreateFrame('ModelFFX', 'Low') low:SetModel('low.mdx') \ + low:SetFrameLevel(2) \ + local high = CreateFrame('ModelFFX', 'High') high:SetModel('high.mdx') \ + high:SetFrameLevel(5) \ + local plain = CreateFrame('Model', 'Plain') plain:SetModel('plain.mdx') \ + plain:SetFrameLevel(9) \ + CreateFrame('ModelFFX', 'Empty'):SetFrameLevel(9)", + ) + .unwrap(); + let pane = s.glue_scene().unwrap(); + assert_eq!(pane.path, "high.mdx"); + assert!(!pane.select && pane.fog.is_none()); + assert_eq!(pane.rect, None, "not laid out yet"); + s.run("High:SetPoint('TOPLEFT', 256, -192) High:SetWidth(512) High:SetHeight(384)") + .unwrap(); + s.resolve(); + assert_eq!( + s.glue_scene().unwrap().rect, + Some([0.25, 0.75, 0.25, 0.75]), + "the rect as screen fractions from the top-left" + ); + s.run("High:Hide()").unwrap(); + assert_eq!(s.glue_scene().unwrap().path, "low.mdx"); + s.run("Low:Hide()").unwrap(); + assert_eq!(s.glue_scene(), None); + } + + /// The character on the scene's attachment 0 is the selected record once its composite is + /// built: none for a record with no model, none once the records are freed. + #[test] + fn the_standing_character_is_the_built_selection() { + let mut roster = three(); + roster[1].model = false; + let mut s = vm(roster); + assert_eq!(s.glue_standing_guid(), Some(1)); + s.run("SelectCharacter(2)").unwrap(); + assert_eq!(s.glue_selected_guid(), Some(2)); + assert_eq!(s.glue_standing_guid(), None, "no model, nothing built"); + s.run("SelectCharacter(3)").unwrap(); + assert_eq!(s.glue_standing_guid(), Some(3)); + s.run("GetCharacterListUpdate()").unwrap(); + assert_eq!(s.glue_standing_guid(), None); + let _ = s.load_glue_roster(three()); + assert_eq!(s.glue_standing_guid(), Some(1)); + s.teardown_glue_roster(); + assert_eq!(s.glue_standing_guid(), None); + } + + /// `0x472580` ends with the model frame's `SetCamera(0)` (`0x4725d7`). + #[test] + fn the_teardown_puts_the_model_frame_back_on_camera_zero() { + let mut s = vm(three()); + s.set_model_facts( + "a.mdx", + crate::widget::ModelFileFacts { + sequences: Vec::new(), + bbox: ([0.0; 3], [1.0; 3]), + cameras: 2, + }, + ); + s.run( + "CreateFrame('ModelFFX', 'Scene'):SetModel('a.mdx') Scene:SetCamera(1) \ + SetCharSelectModelFrame('Scene')", + ) + .unwrap(); + let camera = |s: &UiScript| { + let model = s.model_ref(); + let h = model.arena.lookup("Scene").unwrap(); + match &model.arena.frame(h).unwrap().kind_state { + crate::widget::KindState::Model(m) => m.camera, + _ => unreachable!(), + } + }; + assert_eq!(camera(&s), Some(1)); + s.teardown_glue_roster(); + assert_eq!(camera(&s), Some(0)); + } + /// `0x4723d0` clears at the call: the count reads 0 straight after, and the host is asked for /// the list. #[test] diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index a0f659a7..43b4d913 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -74,7 +74,7 @@ pub use glue_realm::{ realm_load_classify, realm_load_stats, GlueRealm, GlueRealmCategory, GlueRealmCells, GlueRealmType, RealmSortKey, }; -pub use glue_select::{GlueCharacter, GlueSelectCells}; +pub use glue_select::{GlueCharacter, GlueScenePane, GlueSelectCells}; mod gm_ticket; mod gossip; mod guild; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 8b2690de..cd017fb0 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -270,6 +270,8 @@ pub(crate) struct Model { pub(crate) model_facts: HashMap>, /// Model files still waiting for their facts, for the host to load; deduplicated. pub(crate) model_facts_wanted: Vec, + /// The model instances `SetModel` has made in this state, the last one's number. + pub(crate) model_instances: u64, /// Event name to its frames in registration order, never a set: `SignalEvent` (`0x703e50`) /// dispatches in that order across frames. Re-registering keeps a frame's position. pub(crate) event_to_frames: HashMap>, @@ -1222,6 +1224,7 @@ impl Model { on_update_model_frames: Vec::new(), model_facts: HashMap::new(), model_facts_wanted: Vec::new(), + model_instances: 0, event_to_frames: HashMap::new(), frame_events: HashMap::new(), all_event_frames: Vec::new(), diff --git a/crates/benilla-ui/src/script/modelframe.rs b/crates/benilla-ui/src/script/modelframe.rs index 65a80355..324822f8 100644 --- a/crates/benilla-ui/src/script/modelframe.rs +++ b/crates/benilla-ui/src/script/modelframe.rs @@ -55,6 +55,36 @@ impl Model { crate::layout::RESOLVE_EPS * f64::from(self.layout_unit()) } + /// `SetModel` on the pane `h` (`0x76cce0`): the file, seeded now if its facts are held (else + /// asked for), and the implicit rect it may give a size-less pane. A frame that is no model + /// pane is left as it is. + pub(crate) fn set_pane_model(&mut self, h: FrameHandle, path: String) { + // The reference's asset-resident test (`0x71d5a3`): the loader's Stand seed runs now, or + // when the file lands. + let facts = self.model_facts_for(&path); + let instance = self.model_instances + 1; + let Some(KindState::Model(m)) = self.arena.frame_mut(h).map(|f| &mut f.kind_state) else { + return; + }; + m.set_file(path, facts.as_deref()); + m.instance = instance; + self.model_instances = instance; + // A size-less pane takes the file's rect as soon as the file is known. + self.apply_implicit_rect(h); + } + + /// `SetCamera(idx)` on the pane `h` (`0x76cec0`), against the facts of the file it holds. + pub(crate) fn set_pane_camera(&mut self, h: FrameHandle, idx: i32) { + let path = match self.arena.frame(h).map(|f| &f.kind_state) { + Some(KindState::Model(m)) => m.path.clone(), + _ => return, + }; + let facts = path.and_then(|p| self.model_facts_for(&p)); + if let Some(KindState::Model(m)) = self.arena.frame_mut(h).map(|f| &mut f.kind_state) { + m.install_camera(idx, facts.as_deref()); + } + } + /// The implicit rect: a pane with no authored size takes its file's bounding-box extent, in /// layout units, as the reference's geometry overrides answer it (`0x76d080`, `0x76d0d0`). pub(crate) fn apply_implicit_rect(&mut self, h: FrameHandle) { @@ -301,18 +331,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let path = string_arg(&path) .ok_or_else(|| usage(lua, &this, "SetModel(\"file\")"))? .to_string(); - // The reference's asset-resident test (`0x71d5a3`): the loader's Stand seed runs now, - // or when the file lands. - let facts = lua - .app_data_mut::() - .expect("model app_data") - .model_facts_for(&path); - with_model(lua, &this, |m| m.set_file(path, facts.as_deref()))?; - // A size-less pane takes the file's rect as soon as the file is known. + // A pane of this kind, or the error. + with_model(lua, &this, |_| ())?; let h = frame_handle_of(lua, &this)?; lua.app_data_mut::() .expect("model app_data") - .apply_implicit_rect(h); + .set_pane_model(h, path); Ok(()) })?, )?; @@ -380,8 +404,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "SetCamera", lua.create_function(|lua, (this, c): (Table, Value)| { let c = int(&c); - let facts = facts_of_pane(lua, &this)?; - with_model(lua, &this, |m| m.install_camera(c, facts.as_deref())) + with_model(lua, &this, |_| ())?; + let h = frame_handle_of(lua, &this)?; + lua.app_data_mut::() + .expect("model app_data") + .set_pane_camera(h, c); + Ok(()) })?, )?; m.set( diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index 1cec6082..62fb0ced 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -93,8 +93,11 @@ pub enum QuadContent { /// The embedded `CGLight`, off on every shipped pane; a lit batch under it draws black, as /// in the reference. light: crate::widget::ModelLight, - /// The fog, when armed; a material with the UNFOGGED bit ignores it. + /// The fog the scene draws with ([`crate::widget::ModelState::scene_fog`]); a material + /// with the UNFOGGED bit ignores it. fog: Option, + /// A `ModelFFX` (`0x46fa00`): the glue screens' scene, which the app's glue booth draws. + ffx: bool, }, /// A `Texture` region: a BLP path, a solid or vertex colour, or both (a tinted texture). Texture { diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index 4f3f7bd1..e2bcdfe9 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -351,6 +351,15 @@ pub struct ModelState { /// and its layer-2 paint callback (`0x46fad0`, registered by `0x46fa90`) brackets the model /// paint `0x76d240` with the full-screen-effect pass pair `0x6cd890`/`0x6cda70`. pub ffx: bool, + /// The scene's fill callback is the select screen's (`0x472150`, installed by + /// `SetCharSelectBackground` at `0x472144`) in place of the fog callback `SetModel` installs + /// (`0x76cd96` โ†’ `0x76d680`): the scene draws unfogged whatever the fog fields hold. The + /// callback rides the model instance, so the next `SetModel` or `ClearModel` drops it. + pub select_fill: bool, + /// The number of the instance the last `SetModel` made: `0x76cce0` builds a new one on every + /// call (`0x76ccf5`-`0x76cd0d`), its scene starting over, even for the file it held. 0 for + /// none. + pub instance: u64, } /// A model pane's armed fog, all the renderer needs ([`ModelState::armed_fog`]). @@ -424,6 +433,8 @@ impl Default for ModelState { fog_near: 0.0, fog_far: 1.0, ffx: false, + select_fill: false, + instance: 0, } } } @@ -526,6 +537,7 @@ impl ModelState { self.icon = None; self.armed = None; self.pending_seed = true; + self.select_fill = false; if let Some(facts) = facts { self.seed_from_facts(facts); } @@ -538,6 +550,7 @@ impl ModelState { self.icon = None; self.armed = None; self.pending_seed = false; + self.select_fill = false; } /// `SetSequence`/`SetSequenceTime` (`0x7121a0`): interrupts what plays, with no @@ -591,6 +604,16 @@ impl ModelState { }) } + /// The fog the scene draws with: the armed fog under the fog callback `SetModel` installs + /// (`0x76d680`), none under the select screen's ([`Self::select_fill`]). + pub fn scene_fog(&self) -> Option { + if self.select_fill { + None + } else { + self.armed_fog() + } + } + /// `SetCamera` by raw index (`0x76cec0`): deferred until the facts are known, then installed /// or answered with the NULL camera, either clearing the pending index (`0x76ce80`). pub fn install_camera(&mut self, idx: i32, facts: Option<&ModelFileFacts>) {