Stock glue screens draw their 3-D scenes through the glue booth (#1039)

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 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 21:13:47 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent eb63c8861b
commit 5c7eb5fc75
24 changed files with 1597 additions and 194 deletions
+5 -5
View File
@@ -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());
}
}
+2 -3
View File
@@ -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<PrimaryWindow>>,
) {
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);
+18 -3
View File
@@ -73,13 +73,25 @@ impl Plugin for CharSelectPlugin {
)
.init_resource::<dialog::DeleteDialog>()
.init_resource::<addons::AddonsPanel>()
.init_resource::<refresh::SelectBackground>()
.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()
+136 -9
View File
@@ -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<u8>,
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<SelectBackground>) {
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<Roster>,
mut background: ResMut<SelectBackground>,
mut preview: ResMut<GluePreview>,
mut showing: Local<Option<u64>>,
) {
@@ -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::<Roster>()
.init_resource::<SelectBackground>()
.init_resource::<GluePreview>()
.add_systems(Update, feed_glue_preview);
let shown = |app: &App| {
let p = app.world().resource::<GluePreview>();
(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>();
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::<GluePreview>().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>();
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>();
roster.select(Some(0));
}
app.update();
let dwarf_again = stage(&app);
assert!(dwarf_again != first);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
roster.select(Some(0));
}
app.update();
assert_eq!(stage(&app), dwarf_again);
{
let mut roster = app.world_mut().resource_mut::<Roster>();
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>();
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() {
+10 -4
View File
@@ -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<GluePreview>) {
// 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<GluePreview>) {
preview.look = None;
preview.scene = None;
}
pub(super) fn exit_select(
mut commands: Commands,
roots: Query<Entity, With<CharSelectUi>>,
mut preview: ResMut<GluePreview>,
mut dialog: ResMut<super::dialog::DeleteDialog>,
mut glue_dialog: ResMut<crate::glue::dialog::GlueDialog>,
) {
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.
+123 -3
View File
@@ -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<T: Resource>(
app: &mut App,
label: impl ScheduleLabel,
) -> Vec<(String, Vec<String>)> {
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::<T>()) 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<String> = 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::<crate::portrait::GluePreview>(
&mut app, Update,
));
for state in [ClientState::Login, ClientState::CharSelect] {
writers.extend(resource_writers::<crate::portrait::GluePreview>(
&mut app,
OnEnter(state),
));
writers.extend(resource_writers::<crate::portrait::GluePreview>(
&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) {
+28 -4
View File
@@ -54,8 +54,21 @@ impl Plugin for LoginPlugin {
app.init_resource::<LoginIntent>()
.init_resource::<LoginForm>()
.add_message::<GlueLogin>()
.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<f32>);
/// `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<LoginForm>, mut preview: ResMut<GluePreview>) {
/// box, check Remember when a name is saved.
fn enter_login(mut form: ResMut<LoginForm>) {
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<LoginForm>, mut preview: ResMut<GluePreview>) {
// 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<GluePreview>) {
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),
+7 -5
View File
@@ -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<Entity, With<LoginUi>>,
mut preview: ResMut<crate::portrait::GluePreview>,
mut dialog: ResMut<crate::glue::dialog::GlueDialog>,
) {
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<crate::portrait::GluePreview>) {
preview.scene = None;
preview.look = None;
}
/// `WOW_LOGIN_SHOT_OUT=<path>`: one PNG of the login screen once it has settled.
pub(super) fn debug_login_shot(
mut commands: Commands,
@@ -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 `<PlayerModel>` 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<ArtExtent>) -> 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<ArtEx
}
}
/// A stock glue `ModelFFX` pane's view of its scene, in the booth's window-sized target.
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) struct PaneView {
/// The viewport, the pane's rect in physical pixels (`0x76d5d7`).
pub(super) position: UVec2,
pub(super) size: UVec2,
/// The projection's aspect, the viewport's `w/h` (`0x7ada40`), which also turns the camera
/// record's diagonal fov into the vertical opening (`0x5c3cc0`, [`diag_to_vert`]).
pub(super) aspect: f32,
}
/// The view of a stock pane whose rect is `pane`, `[left, right, top, bottom]` as fractions of a
/// `full_w`×`full_h` window from its top-left: the scene fills the pane at the pane's own aspect,
/// with no box of its own. `None` for an empty rect.
pub(super) fn stock_pane_view(full_w: u32, full_h: u32, pane: [f32; 4]) -> Option<PaneView> {
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.
+410 -118
View File
@@ -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_<Race>` 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_<token>`: the login screen's main
/// menu (`AccountLogin.xml`'s `ModelFFX`) or a race's stage.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum GlueScene {
/// `UI_MainMenu`, the login screen's burning gate (`AccountLogin.xml`'s ModelFFX).
MainMenu,
/// A race's `UI_<Race>` 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_<token>\UI_<token>.mdx`.
pub(crate) fn token(self) -> &'static str {
match self {
GlueScene::MainMenu => "MainMenu",
GlueScene::Human => "Human",
GlueScene::Orc => "Orc",
GlueScene::Dwarf => "Dwarf",
GlueScene::NightElf => "NightElf",
GlueScene::Scourge => "Scourge",
GlueScene::Tauren => "Tauren",
}
}
/// 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_<token>`, any case, any extension);
/// `None` for a file that is none of the shipped scenes.
pub(crate) fn from_model_path(path: &str) -> Option<Self> {
let file = path.rsplit(['\\', '/']).next()?;
let stem = file.rsplit_once('.').map_or(file, |(stem, _)| stem);
let token = stem.get(..3).filter(|p| p.eq_ignore_ascii_case("UI_"))?;
let token = &stem[token.len()..];
Self::ALL
.into_iter()
.find(|s| s.token().eq_ignore_ascii_case(token))
}
/// The fog the stock files give the scene, near 0: the race rows are `GlueParent.lua`'s
/// `CharModelFogInfo`; the main menu's is `AccountLogin.xml`'s `<FogColor>` and `fogFar`.
pub(crate) fn stock_fog(self) -> SceneFog {
let (rgb, far) = match self {
GlueScene::MainMenu => ([0.25, 0.06, 0.015], 1200.0),
GlueScene::Orc => ([0.5, 0.5, 0.5], 270.0),
GlueScene::Dwarf => ([0.85, 0.88, 1.0], 500.0),
GlueScene::NightElf => ([0.25, 0.22, 0.55], 611.0),
GlueScene::Scourge => ([0.0, 0.22, 0.22], 26.0),
GlueScene::Tauren => ([1.0, 0.61, 0.42], 153.0),
GlueScene::Human => ([0.8, 0.65, 0.73], 222.0),
};
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<GlueScene>,
pub(crate) look: Option<GlueLook>,
pub(crate) yaw: f32,
pub(crate) fog: Option<SceneFog>,
/// 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_<Race>.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<ArtExtent>,
/// `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<f32>,
/// 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<u64>,
/// 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<SceneFog>,
/// 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<bevy::render::render_resource::Buffer>,
/// Booth twins of the character's materials against the scene buffer (`BoothLight::variants`).
variants: std::collections::HashMap<AssetId<WowModelMaterial>, Handle<WowModelMaterial>>,
@@ -204,6 +313,36 @@ pub(crate) struct CreateScene {
pending: Option<PendingSwap>,
}
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_<token>\UI_<token>.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<SceneFog>) -> 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 `<FogColor>` 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<GluePreview>,
mut ffx: ResMut<benilla_world::ffx_glow::GlueFfx>,
mut shown: Local<Option<u64>>,
) {
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::<Children>();
// 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::<Children>();
} 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<ArtExtent>,
pane: Option<super::framing::PaneView>,
) -> 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<CreateScene>,
preview: Res<GluePreview>,
window: Query<&Window, With<PrimaryWindow>>,
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::<Camera>(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::<GluePreview>()
.init_resource::<GlueFfx>()
.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<GlueLook>, show: bool| {
{
let mut p = app.world_mut().resource_mut::<GluePreview>();
p.look = look;
if show {
p.shown += 1;
}
}
app.update();
*app.world().resource::<GlueFfx>()
};
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]
+8 -2
View File
@@ -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)
+136 -2
View File
@@ -1140,8 +1140,9 @@ fn convert_entry(
});
}
// A `<Model>`/`<PlayerModel>` 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::<UiQuads>();
@@ -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::<Assets<Image>>()
.add(Image::default());
app.world_mut().resource_mut::<PortraitImages>().0.insert(
crate::portrait::GLUE_SLOT.to_string(),
crate::portrait::PortraitSource::Live(image.clone()),
);
app.update();
let paths: Vec<String> = app
.world()
.resource::<crate::ui_models::UiModelTiles>()
.requests
.values()
.map(|r| r.path.clone())
.collect();
assert_eq!(paths, ["Plain.mdx"], "the ModelFFX is no tile");
let quads = &app.world().resource::<UiQuads>().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 `<PlayerModel name="CharacterModelFrame">`; a pane
/// no window claims ([`crate::portrait::model_pane_booth`]) must not borrow a bake.
#[test]
+3 -3
View File
@@ -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::*;
@@ -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);
@@ -771,7 +771,16 @@ fn stock_app() -> (
.add_message::<GlueLogin>()
.add_message::<crate::sound::GlueMusicRequest>()
.add_message::<AppExit>()
.add_systems(Update, (drive_glue_mgr, crate::login::answer_glue).chain());
.init_resource::<crate::portrait::GluePreview>()
.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::<GlueLogin>()
.add_message::<crate::sound::GlueMusicRequest>()
.add_message::<AppExit>()
.init_resource::<crate::portrait::GluePreview>()
.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::<String>::new());
}
// ── The stock screens' scenes through the glue booth ────────────────────────────────────────────
fn preview(app: &App) -> &crate::portrait::GluePreview {
app.world().resource::<crate::portrait::GluePreview>()
}
/// The stock login's `AccountLogin` pane drives the glue booth: `UI_MainMenu` under the pane's
/// own fog, `AccountLogin.xml`'s `<FogColor>` and `fogFar`, with nobody standing in it.
#[test]
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::<GlueMgr>().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::<String>::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::<GlueMgr>().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::<GlueMgr>().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::<GlueMgr>().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::<String>::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.
@@ -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<GlueScene>, Option<GlueLook>, f32, Option<SceneFog>) {
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<benilla_ui::widget::FrameHandle>,
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<NonSend<UiScript>>,
mgr: Res<GlueMgr>,
state: Option<Res<State<crate::char_select::ClientState>>>,
mut preview: ResMut<GluePreview>,
mut feed: Local<super::VmMemo<StockFeed>>,
) {
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<benilla_ui::widget::ModelFog>) -> 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
);
}
}
+1
View File
@@ -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;
+2 -1
View File
@@ -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,
};
+260 -17
View File
@@ -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<crate::widget::ModelFog>,
/// 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<String>,
/// The file `SetCharSelectBackground` last gave the model frame (`0x472110`).
background: Option<String>,
}
/// `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<String> {
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<u64> {
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<GlueScenePane> {
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<crate::widget::FrameHandle> {
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::<Model>().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::<String>("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]
+1 -1
View File
@@ -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;
+3
View File
@@ -270,6 +270,8 @@ pub(crate) struct Model {
pub(crate) model_facts: HashMap<String, std::sync::Arc<crate::widget::ModelFileFacts>>,
/// Model files still waiting for their facts, for the host to load; deduplicated.
pub(crate) model_facts_wanted: Vec<String>,
/// 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<String, Vec<FrameHandle>>,
@@ -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(),
+39 -11
View File
@@ -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::<Model>()
.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::<Model>()
.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::<Model>()
.expect("model app_data")
.set_pane_camera(h, c);
Ok(())
})?,
)?;
m.set(
+4 -1
View File
@@ -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<crate::widget::ModelFog>,
/// 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 {
+23
View File
@@ -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<ModelFog> {
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>) {