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:
co-authored by
Claude Opus 5.5
parent
eb63c8861b
commit
5c7eb5fc75
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
Reference in New Issue
Block a user