From f0dfa861485ed7e0c4b400676b67585b3d6f0a64 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:38:06 +0700 Subject: [PATCH] PLAYER_LOGIN fires at world entry, once the player exists (#840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PLAYER_LOGIN` fired at the end of the UI load, before the player existed: its handlers saw `UnitExists("player")` nil and level, health and XP 0, at every login and `/reload`. Ace2's `OnEnable` runs there. In 1.12.1 the load only arms a one-shot (`0x49011d`). The world-enter cascade fires `PLAYER_LOGIN` if armed (`0x49094b`), then `PLAYER_ENTERING_WORLD` (`0x49096a`), inside a sound-suppression bracket (`0x4908d5`–`0x490a56`). It runs from the player's own create (`0x5deb60`), or on `/reload` from the load (`0x490168`) with the player still in the world. A worldport re-arms nothing. Now the load arms it, and the unit feed fires both, silenced, once the player is seated. A `/reload` loads under the live player and no longer plays the target-select sound. Tests cover login, worldport and reload; they fail without the fix. Fixes #189 Fixes #191 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/ui_chat/channels.rs | 4 +- crates/benilla-app/src/ui_chat/settings.rs | 5 +- crates/benilla-app/src/ui_chat/tests.rs | 11 +- crates/benilla-app/src/ui_script/addons.rs | 13 +- crates/benilla-app/src/ui_script/lifecycle.rs | 85 +++++++-- crates/benilla-app/src/ui_script/mod.rs | 4 +- .../src/ui_script/world_entry_tests.rs | 179 ++++++++++++++++-- crates/benilla-app/src/ui_unit.rs | 115 ++++++++--- crates/benilla-ui/src/script/model.rs | 4 + crates/benilla-ui/src/script/session.rs | 25 ++- crates/benilla-ui/src/script/tests/events.rs | 46 +++++ 11 files changed, 419 insertions(+), 72 deletions(-) diff --git a/crates/benilla-app/src/ui_chat/channels.rs b/crates/benilla-app/src/ui_chat/channels.rs index 82175b24..12eb9584 100644 --- a/crates/benilla-app/src/ui_chat/channels.rs +++ b/crates/benilla-app/src/ui_chat/channels.rs @@ -444,8 +444,8 @@ pub(super) fn auto_join_zone_channels( walk.at = Some(zone_id); } -/// Feed the VM the zone-less catalog before any interface file runs: world entry runs FrameXML, -/// the addons and `PLAYER_LOGIN` in one call, ahead of the walk's first tick. +/// Feed the VM the zone-less catalog before any interface file runs: world entry runs FrameXML +/// and the addons in one call, ahead of the walk's first tick. pub(crate) fn seed_zone_channel_catalog( world: &mut World, script: &mut benilla_ui::script::UiScript, diff --git a/crates/benilla-app/src/ui_chat/settings.rs b/crates/benilla-app/src/ui_chat/settings.rs index 9286ae72..dfa25fa7 100644 --- a/crates/benilla-app/src/ui_chat/settings.rs +++ b/crates/benilla-app/src/ui_chat/settings.rs @@ -623,8 +623,9 @@ pub(crate) fn fold_dying_vm_chat_cache(world: &mut World) { pub(super) fn plugin(app: &mut App) { app.init_resource::() - // The restore runs from the world-entry UI load, not here: its events must precede the - // first chat line and `PLAYER_LOGIN`, which no `Update` ordering guarantees. + // The restore runs from the world-entry UI load, not here: its events must follow + // `VARIABLES_LOADED` inside the load, where the reference registers its reader + // (`0x4900d6`), and precede the first chat line and `PLAYER_LOGIN`. .add_systems( Update, // After the tick: every write it watches is Lua's. diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs index 9d5e22f4..f41b8b89 100644 --- a/crates/benilla-app/src/ui_chat/tests.rs +++ b/crates/benilla-app/src/ui_chat/tests.rs @@ -2397,8 +2397,9 @@ fn the_free_professions_line_is_printed_once() { // ───────────── the chat cache restores inside the login ───────── /// `UPDATE_CHAT_WINDOWS` registers the chat frames for `CHAT_MSG_*`, and the `UPDATE_CHAT_COLOR` -/// burst repaints id-0 lines through `ChatTypeInfo["REPLY"]`, so both come before `PLAYER_LOGIN`. -/// The probe is a loose addon, because the entry load builds the VM it runs on. +/// burst repaints id-0 lines through `ChatTypeInfo["REPLY"]`, so both come before `PLAYER_LOGIN`, +/// which the load arms and the world-enter cascade fires. The probe is a loose addon, because the +/// entry load builds the VM it runs on. #[test] fn the_chat_cache_restore_is_finished_before_player_login() { let _data = benilla_formats::wow_data_or_skip!(); @@ -2485,6 +2486,10 @@ fn the_chat_cache_restore_is_finished_before_player_login() { 1, )); crate::ui_script::load_ingame_ui_on_world_entry(&mut world); + // The cascade, as the unit feed runs it once the player is in the world. + world + .non_send_resource_mut::() + .fire_world_enter(); let read = |expr: &str| -> String { world @@ -2512,7 +2517,7 @@ fn the_chat_cache_restore_is_finished_before_player_login() { read("ChatOrderProbe.loginRegistered") ); // The reference's login order: `ADDON_LOADED` (`0x4900a3`), `VARIABLES_LOADED` (`0x4900b2`), - // the chat-cache burst (`0x4900d6`), `PLAYER_LOGIN` (`0x490959`). + // the chat-cache burst (`0x4900d6`), the arm (`0x49011d`), `PLAYER_LOGIN` (`0x490959`). assert_eq!( read("ChatOrderProbe.order"), "VARIABLES_LOADED UPDATE_CHAT_WINDOWS UPDATE_CHAT_COLOR PLAYER_LOGIN ", diff --git a/crates/benilla-app/src/ui_script/addons.rs b/crates/benilla-app/src/ui_script/addons.rs index 81fba5a6..5ba49f7d 100644 --- a/crates/benilla-app/src/ui_script/addons.rs +++ b/crates/benilla-app/src/ui_script/addons.rs @@ -1692,9 +1692,10 @@ mod tests { ); } - /// `UI_Init 0x48fbf0` loads the addons at `0x4900a3` (each `ADDON_LOADED` at `0x51f5ad`) and - /// fires `VARIABLES_LOADED` at `0x4900b2`; `PLAYER_LOGIN` comes after, on a fresh login from - /// the player's create (`0x5deb60` → `0x4908c0`), on a `/reload` from `0x490168`. + /// `UI_Init 0x48fbf0` loads the addons at `0x4900a3` (each `ADDON_LOADED` at `0x51f5ad`), + /// fires `VARIABLES_LOADED` at `0x4900b2` and arms `PLAYER_LOGIN` (`0x49011d`), which the + /// world-enter cascade fires: on a fresh login from the player's create (`0x5deb60` → + /// `0x4908c0`), on a `/reload` from `0x490168`. #[test] fn the_ui_init_events_fire_in_the_reference_order() { let _l = crate::local_state::test_env::ENV_LOCK @@ -1728,6 +1729,12 @@ mod tests { let failures = load_third_party(&mut script, None, &[], true); assert!(failures.is_empty(), "load errors: {failures:?}"); crate::ui_script::finish_ui_load(&mut script); + assert_eq!( + event_log(&script), + vec!["ADDON_LOADED:EventProbe", "VARIABLES_LOADED:nil"], + "the load only arms PLAYER_LOGIN" + ); + script.fire_world_enter(); assert_eq!( event_log(&script), diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index eafdcd06..7e1a52aa 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -258,12 +258,29 @@ fn seat_raster_seam_for_load(world: &mut World, script: &mut UiScript) { super::extract::seat_text_measurer(script, atlas, s); } +/// Which `"player"` a UI load runs its files under, by the edge that runs it. +#[derive(Clone, Copy, PartialEq, Eq)] +enum LoadSeat { + /// World entry: `UI_Init` runs before `CMSG_PLAYER_LOGIN` goes out (the load at `0x46c236`, + /// the send in a later step at `0x46c272`), so no player object exists and the roster's + /// record stands in ([`seat_from_roster`]). + Roster, + /// `/reload`: the player object stays in the world through the teardown and the load, so the + /// live one is seated ([`crate::ui_unit::live_player_seat`]), the roster's if none exists. + Live, +} + /// Load the in-game UI for this session onto a VM built for it, once per world entry. The /// reference builds the UI in `UI_Init` (`0x48fbf0`, re-making the Lua state at `0x48fe97`) and /// destroys it at `0x490bd0`, so every login runs every addon's file scope afresh. Safe on the /// state edge because the initial transition runs after `PostStartup`: a capture booting straight /// into `InWorld` still loads after [`benilla_assets::AssetSet::Open`]. pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) { + load_ingame_ui_seated(world, LoadSeat::Roster); +} + +/// [`load_ingame_ui_on_world_entry`] under the `"player"` its edge has. +fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) { // The parked VM goes back into the world, where the CVar fold reads it; a reload has none // parked and uses the live one. unpark_boot_vm(world); @@ -322,19 +339,31 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) { } // The player too, before the addon walk. The record first: the reference's // `UnitName`/`UnitRace`/`UnitClass`/`UnitSex` read only a copy of the char-enum row made at the - // Enter World commit (`0x5abd9e`) and never cleared, and each rebuilt VM is told once. The - // snapshot stands in for the descriptor until that streams in. + // Enter World commit (`0x5abd9e`) and never cleared, and each rebuilt VM is told once. Then + // the unit: the live player on a `/reload`, else the roster's stand-in until the descriptor + // streams in. if let Some(record) = world .get_resource::() .and_then(record_from_roster) { script.set_player_record(record); } - if let Some(seat) = world - .get_resource::() - .and_then(seat_from_roster) + match (seat == LoadSeat::Live) + .then(|| live_player_seat(world)) + .flatten() { - script.set_unit("player", Some(seat)); + Some(live) => { + script.set_unit("player", Some(live.state)); + script.set_player_xp(live.xp.0, live.xp.1); + } + None => { + if let Some(seat) = world + .get_resource::() + .and_then(seat_from_roster) + { + script.set_unit("player", Some(seat)); + } + } } // The addon version gate, read from the persisted base the fold above just made current, so a // character-screen "Load out of date AddOns" click counts. A bare test world is check on. @@ -418,6 +447,17 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) { } } +/// The live player's seat off the world, through the unit feed's own builder; `None` before our +/// own create. A world without the feed's resources (a bare test world) has none to read. +fn live_player_seat(world: &mut World) -> Option { + world + .run_system_cached(crate::ui_unit::live_player_seat) + .unwrap_or_else(|e| { + warn!("ui_script: no live player to seat for the load ({e}); the roster stands in"); + None + }) +} + /// The wire's gender byte (0 male, 1 female) on `UnitSex`'s 2/3 scale, as `ui_unit::snapshot` /// maps the descriptor's; the record and the seat share it. fn roster_sex(gender: u8) -> u8 { @@ -648,11 +688,13 @@ pub(crate) fn end_ui_session(world: &mut World) { pub(crate) struct ReloadUiPending(pub(crate) bool); /// Run a pending `ReloadUI()`: the reference's teardown and rebuild (`0x495664 call 0x490bd0`, -/// `0x495669 call 0x48fbf0`), for us [`end_ui_session`] then [`load_ingame_ui_on_world_entry`] -/// without leaving the world, so a `DisableAddOn` staged in the dying VM reaches `AddOns.txt` -/// before the rebuild reads it. `PLAYER_ENTERING_WORLD` then refires from [`crate::ui_unit`]'s -/// feed on the new VM. In-world only, as the reference's gate (`0x494a50(0xa)`) is; elsewhere the -/// request is dropped. +/// `0x495669 call 0x48fbf0`), for us [`end_ui_session`] then the load without leaving the world, +/// so a `DisableAddOn` staged in the dying VM reaches `AddOns.txt` before the rebuild reads it. +/// The load runs under the live player, which never left. `UI_Init` then enters the world-enter +/// cascade itself (`0x490168`), firing the `PLAYER_LOGIN` it armed and `PLAYER_ENTERING_WORLD`; +/// ours fire from [`crate::ui_unit`]'s feed on the new VM's first `Update`, once it has re-seated +/// the player. In-world only, as the reference's gate (`0x494a50(0xa)`) is; elsewhere the request +/// is dropped. pub(crate) fn run_pending_reload(world: &mut World) { if !std::mem::take(&mut world.resource_mut::().0) { return; @@ -667,7 +709,7 @@ pub(crate) fn run_pending_reload(world: &mut World) { } info!("ui_script: ReloadUI — ending the UI session and building a new one"); end_ui_session(world); - load_ingame_ui_on_world_entry(world); + load_ingame_ui_seated(world, LoadSeat::Live); // `UI_Init` ends by re-entering the world-enter cascade (`0x490168`), its sends included, but // only with the active player set (`0x490166 je`): our own player's entity exists. Not // `SetActiveMover`, which only the player's create runs. @@ -710,13 +752,13 @@ pub(crate) fn shutdown_on_exit( /// saved file and fires `VARIABLES_LOADED` (`0x4900b2` → `0x4913b0`), and arms `PLAYER_LOGIN` /// (`[0xb4e260]`, set at `0x49011d`). The world-enter cascade `0x4908c0` fires `PLAYER_LOGIN` if /// armed (read at `0x49094b`, fired at `0x490959`, disarmed at `0x49095e`), then -/// `PLAYER_ENTERING_WORLD` (`0x49096a`). +/// `PLAYER_ENTERING_WORLD` (`0x49096a`): [`UiScript::fire_world_enter`]. /// /// On a `/reload`, `UI_Init` enters the cascade itself (`0x490168`). On a fresh login that call is /// skipped (`0x490166 je`, the active-player GUID still 0) and the cascade runs from the local -/// player's own create (`0x5deb60 call 0x4908c0`). This fires `PLAYER_LOGIN` at the end of the -/// load on both, the `/reload` shape; `PLAYER_ENTERING_WORLD` fires from [`crate::ui_unit`] once -/// the self descriptor lands. +/// player's own create (`0x5deb60 call 0x4908c0`). Either way the player object exists when it +/// runs, so this only arms, and [`crate::ui_unit`]'s feed fires both once it has seated the +/// player. #[cfg(test)] pub(crate) fn finish_ui_load(script: &mut UiScript) { finish_ui_load_with(script, |_| {}, |_| {}); @@ -724,9 +766,9 @@ pub(crate) fn finish_ui_load(script: &mut UiScript) { /// [`finish_ui_load`] with its two host seams. `host_settings` runs between the saved-variables /// chunk and `VARIABLES_LOADED`, carrying over a setting `config.toml` held before it moved into -/// the saved variables. `between` -/// is the chat-cache restore's `UPDATE_CHAT_WINDOWS` + `UPDATE_CHAT_COLOR` burst, after -/// `VARIABLES_LOADED` and before `PLAYER_LOGIN`, where the reference registers its reader +/// the saved variables. `between` is the chat-cache restore's `UPDATE_CHAT_WINDOWS` + +/// `UPDATE_CHAT_COLOR` burst, after `VARIABLES_LOADED` and before the `PLAYER_LOGIN` arm (so +/// before the event too), where the reference registers its reader /// (`0x4900d6` → `0x498a20`). On a fresh login the reference's cache (`0x5afe50`) defers that /// reader until the server's account data reconciles (`0x5afa42`/`0x5afd01`), after `UI_Init` has /// returned. Either way a `VARIABLES_LOADED` handler sees the boot chat colours, not the file's. @@ -736,11 +778,12 @@ pub(crate) fn finish_ui_load_with( between: impl FnOnce(&mut UiScript), ) { // Still the load edge, so still bounded: re-armed so the saved variables and every - // `PLAYER_LOGIN` handler get the full allowance; the entry edge disarms it after. + // `VARIABLES_LOADED` handler get the full allowance; the entry edge disarms it after. The + // world-enter events fire later, unbounded, as every event the feeds fire is. script.set_instruction_budget(addons::LOAD_INSTRUCTION_BUDGET); crate::ui_saved::load_saved_variables(script, host_settings); between(script); - script.fire_event("PLAYER_LOGIN", vec![]); + script.arm_player_login(); } /// Run the patch chain's `Interface\FrameXML\GlobalStrings.lua`, the first file in the reference's diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 47df8f77..05383a58 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -600,9 +600,9 @@ fn demo_unit_feed(script: Option>, mut fired: Local (App, crossbeam_channel::Receiver) { + let mut app = App::new(); + app.add_plugins(crate::ui_unit::UiUnitPlugin); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + let (tx, rx) = crossbeam_channel::unbounded(); + app.insert_resource(crate::net::NetCommands(tx)); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.add_message::(); + app.add_message::(); + super::setup_script(app.world_mut()); + (app, rx) +} + /// The unit feed's login one-shots (`PLAYER_ENTERING_WORLD`, the first `PLAYER_XP_UPDATE` and /// `UPDATE_EXHAUSTION`) wait for the in-game UI instead of reaching a VM with no frames. #[test] @@ -781,22 +803,7 @@ fn the_login_one_shots_wait_for_the_in_game_ui() { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let (tmp, _c, _h) = hermetic_probe("oneshot"); - - let mut app = App::new(); - app.add_plugins(crate::ui_unit::UiUnitPlugin); - app.init_resource::(); - app.init_resource::(); - app.init_resource::(); - app.init_resource::(); - app.init_resource::(); - let (tx, _rx) = crossbeam_channel::unbounded(); - app.insert_resource(crate::net::NetCommands(tx)); - app.init_resource::(); - app.init_resource::(); - app.init_resource::(); - app.init_resource::(); - app.add_message::(); - super::setup_script(app.world_mut()); + let (mut app, _rx) = unit_feed_app(); // A frame registered as FrameXML registers, in the VM that exists before the entry load. app.world() @@ -1576,3 +1583,143 @@ fn a_login_and_a_reload_load_without_the_lost_target_sound() { drop(world); let _ = std::fs::remove_dir_all(&tmp); } + +// ───────────── PLAYER_LOGIN fires from the world-enter cascade ───────────── + +/// Records what file scope and the two cascade events see of `"player"`, in order. +const LOGIN_PROBE_LUA: &str = "\ +LoginProbe = { order = \"\", fileExists = tostring(UnitExists(\"player\")), + fileLevel = UnitLevel(\"player\") } +local f = CreateFrame(\"Frame\") +f:RegisterEvent(\"PLAYER_LOGIN\") +f:RegisterEvent(\"PLAYER_ENTERING_WORLD\") +f:SetScript(\"OnEvent\", function() + if event == \"PLAYER_LOGIN\" then + LoginProbe.order = LoginProbe.order .. \"LOGIN:\" .. tostring(UnitExists(\"player\")) + .. \":\" .. UnitLevel(\"player\") .. \":\" .. UnitXP(\"player\") .. \" \" + else + LoginProbe.order = LoginProbe.order .. \"PEW \" + end +end) +"; + +const LOGIN_PROBE_TOC: &str = "## Interface: 11200\nLoginProbe.lua\n"; + +/// Our own player as its create lands: level 17, 1234 of 5000 XP. +fn spawn_self_player(world: &mut World) -> Entity { + use benilla_protocol::messages::{ObjectFields, ObjectType}; + // `PLAYER_XP` and `PLAYER_NEXT_LEVEL_XP`, 716 and 717 in the 1.12 descriptor. + let fields = ObjectFields::from_pairs(&[ + (benilla_protocol::field::FIELD_UNIT_LEVEL, 17), + (716, 1234), + (717, 5000), + ]) + .into_created(ObjectType::Player); + world + .spawn(( + crate::net::SelfPlayer, + crate::net::Guid(1), + crate::net::ObjectStore(fields), + )) + .id() +} + +/// A probe global off the live VM, as a string. +fn login_probe(app: &App, field: &str) -> String { + app.world() + .non_send_resource::() + .eval::>(&format!("return tostring(LoginProbe.{field})")) + .ok() + .flatten() + .unwrap_or_default() +} + +/// `PLAYER_LOGIN` has one fire site, `0x490959` in the world-enter cascade `0x4908c0`, which the +/// UI load only arms (`0x49011d`). A fresh login skips the load's own entry (`0x490166 je`) and +/// runs the cascade from the player's create (`0x5deb60`), so its handlers see the player, and +/// `PLAYER_ENTERING_WORLD` follows at once (`0x49096a`). A worldport loads no UI, so its cascade +/// finds the arm spent and fires 272 alone. +#[test] +fn player_login_fires_from_the_world_entry_with_the_player_seated() { + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (tmp, _c, _h) = hermetic_addon("login", "LoginProbe", LOGIN_PROBE_TOC, LOGIN_PROBE_LUA); + let (mut app, _rx) = unit_feed_app(); + app.insert_resource(State::new(crate::char_select::ClientState::InWorld)); + app.insert_resource(roster_named("Probelogin", 1)); + super::load_ingame_ui_on_world_entry(app.world_mut()); + + assert_eq!( + login_probe(&app, "fileExists"), + "nil", + "a fresh login's file scope runs before the player object exists" + ); + assert_eq!( + login_probe(&app, "order"), + "", + "the load arms PLAYER_LOGIN; nothing fires it before the player is in the world" + ); + + let me = spawn_self_player(app.world_mut()); + app.update(); + assert_eq!( + login_probe(&app, "order"), + "LOGIN:1:17:1234 PEW ", + "PLAYER_LOGIN sees the player's own unit, then PLAYER_ENTERING_WORLD fires at once" + ); + app.update(); + assert_eq!(login_probe(&app, "order"), "LOGIN:1:17:1234 PEW "); + + // A cross-map worldport: the player object is destroyed and created again. + app.world_mut().despawn(me); + app.update(); + spawn_self_player(app.world_mut()); + app.update(); + assert_eq!( + login_probe(&app, "order"), + "LOGIN:1:17:1234 PEW PEW ", + "a worldport re-fires PLAYER_ENTERING_WORLD and not PLAYER_LOGIN" + ); + + drop(app); + let _ = std::fs::remove_dir_all(&tmp); +} + +/// On a `/reload` the player object stays in the world: `UI_Init` runs its file scope against it +/// and enters the cascade itself (`0x490168`), so `PLAYER_LOGIN` then `PLAYER_ENTERING_WORLD` fire +/// once each on the new VM, both seeing the player. +#[test] +fn a_reload_fires_player_login_once_against_the_live_player() { + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (tmp, _c, _h) = hermetic_addon("relogin", "LoginProbe", LOGIN_PROBE_TOC, LOGIN_PROBE_LUA); + let (mut app, _rx) = unit_feed_app(); + app.insert_resource(State::new(crate::char_select::ClientState::InWorld)); + app.insert_resource(roster_named("Probelogin", 1)); + super::load_ingame_ui_on_world_entry(app.world_mut()); + spawn_self_player(app.world_mut()); + app.update(); + assert_eq!(login_probe(&app, "order"), "LOGIN:1:17:1234 PEW "); + + reload(app.world_mut(), crate::char_select::ClientState::InWorld); + assert_eq!( + ( + login_probe(&app, "fileExists"), + login_probe(&app, "fileLevel") + ), + ("1".to_string(), "17".to_string()), + "the reloaded file scope runs against the live player" + ); + app.update(); + app.update(); + assert_eq!( + login_probe(&app, "order"), + "LOGIN:1:17:1234 PEW ", + "the new VM gets PLAYER_LOGIN then PLAYER_ENTERING_WORLD, once each" + ); + + drop(app); + let _ = std::fs::remove_dir_all(&tmp); +} diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 9b963824..1f363c8f 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -101,7 +101,7 @@ struct UnitFeedMemo { /// The lazy caches' landing counters: their per-frame `&mut` misses would trip `is_changed`. names_generation: gate::Watch, guild_generation: gate::Watch, - /// Whether `PLAYER_ENTERING_WORLD` has fired for this world entry. + /// Whether the world-enter events have fired for this world entry. entered_world: bool, /// Per token, the last snapshot pushed. last: HashMap, @@ -1194,6 +1194,75 @@ pub(crate) fn fire_transitions( } } +/// `"player"`'s snapshot off our own descriptor: no reaction (the unit is ours), the raid icon, +/// both faction-group spellings and the guild. One builder for [`feed_units`] and for the seat a +/// `/reload`'s load runs under ([`live_player_seat`]). +fn player_snapshot( + store: &ObjectStore, + guid: u64, + tables: &SnapshotTables, + names: &NameCache, + commands: &NetCommands, + group: &crate::ui_party::GroupState, + factions: Option<&Factions>, + guild: &mut crate::ui_guild::GuildState, +) -> UnitState { + let name = names + .resolve_unit(guid, Some(store), commands) + .map(str::to_string); + let mut s = snapshot(store, guid, name, 0, tables.classes(), tables.types(names)); + s.is_player = true; + s.raid_target = group.raid_target_index(guid); + s.faction_group = faction_group(store, factions); + s.faction_group_localized = faction_group_localized(store, factions); + // `GetGuildInfo`: the public guild fields (191/192) joined against the lazy guild cache. + s.guild = crate::ui_guild::unit_guild(&store.0, guild, commands); + s +} + +/// `UnitXP("player")` and `UnitXPMax("player")` off our own descriptor, zero while unsent. +fn xp_pair(store: &ObjectStore) -> (u32, u32) { + ( + store.0.player_xp().unwrap_or(0), + store.0.player_next_level_xp().unwrap_or(0), + ) +} + +/// The live player a UI load seats before any file runs: its snapshot and XP pair, as +/// [`feed_units`] pushes them. +pub(crate) struct LivePlayerSeat { + pub(crate) state: UnitState, + pub(crate) xp: (u32, u32), +} + +/// [`LivePlayerSeat`] for an exclusive edge, `None` before our own create. A `/reload` loads with +/// the player object still in the world, so `UI_Init`'s file scope, `ADDON_LOADED` and +/// `VARIABLES_LOADED` read it there as `PLAYER_LOGIN` does (`0x490168`). +pub(crate) fn live_player_seat( + tables: SnapshotTables, + self_q: Query<(&ObjectStore, &Guid), With>, + names: Res, + commands: Res, + group: Res, + factions: Option>, + mut guild: ResMut, +) -> Option { + let (store, guid) = self_q.iter().next()?; + Some(LivePlayerSeat { + state: player_snapshot( + store, + guid.0, + &tables, + &names, + &commands, + &group, + factions.as_deref(), + &mut guild, + ), + xp: xp_pair(store), + }) +} + fn feed_units( script: Option>, tables: SnapshotTables, @@ -1280,17 +1349,16 @@ fn feed_units( // A missing unit is `None`, which `set_unit` clears; a name miss lands on a later frame. let self_pair = self_q.iter().next(); let player = self_pair.map(|(store, guid)| { - let name = names - .resolve_unit(guid.0, Some(store), &commands) - .map(str::to_string); - let mut s = snapshot(store, guid.0, name, 0, chr, types); - s.is_player = true; - s.raid_target = group.raid_target_index(guid.0); - s.faction_group = faction_group(store, factions.as_deref()); - s.faction_group_localized = faction_group_localized(store, factions.as_deref()); - // `GetGuildInfo`: the public guild fields (191/192) joined against the lazy guild cache. - s.guild = crate::ui_guild::unit_guild(&store.0, &mut guild, &commands); - s + player_snapshot( + store, + guid.0, + &tables, + &names, + &commands, + &group, + factions.as_deref(), + &mut guild, + ) }); // Log once when our template names no side, almost always GM mode (vmangos forces template // 35, group mask 0): every `UnitFactionGroup` surface loses its side, as in the reference. @@ -1390,12 +1458,11 @@ fn feed_units( } } - // The XP pair and `PLAYER_XP_UPDATE`, ahead of the `PLAYER_ENTERING_WORLD` fire so its - // handlers read real values. The event fires on first sight too, at login and after a - // `/reload`, where the reference's field watchers stay silent. + // The XP pair and `PLAYER_XP_UPDATE`, ahead of the world-enter events so `PLAYER_LOGIN` and + // `PLAYER_ENTERING_WORLD` handlers read real values. The event fires on first sight too, at + // login and after a `/reload`, where the reference's field watchers stay silent. if let Some((store, _)) = self_q.iter().next() { - let xp = store.0.player_xp().unwrap_or(0); - let next = store.0.player_next_level_xp().unwrap_or(0); + let (xp, next) = xp_pair(store); if memo.last_xp != Some((xp, next)) { gate.audit("feed_units", "the XP pair"); memo.last_xp = Some((xp, next)); @@ -1404,7 +1471,7 @@ fn feed_units( } } - // The rest snapshot (rest-state byte, pool, `PLAYER_FLAGS`), ahead of the entering-world fire + // The rest snapshot (rest-state byte, pool, `PLAYER_FLAGS`), ahead of the world-enter events // as the reference's descriptor is. Below `0x5ee990`'s local-GUID gate (`0x5eea93`) each arm // tests its own bits: `PLAYER_UPDATE_RESTING` on either edge of `0x20` (`0x5eead0`, fire // `0x5eeaf2`), `PLAYTIME_CHANGED` on `0x3000` (`0x5eeb65`, fire `0x5eeb6f`). @@ -1495,12 +1562,16 @@ fn feed_units( } } - // `PLAYER_ENTERING_WORLD` once per world entry, after our descriptor lands, as the reference's. - // At world exit, re-arm it and forget the player-global memos so the next character seeds them. + // The world-enter cascade's events once per world entry, after our descriptor lands and the + // pushes above seat it: the reference runs the cascade from the local player's own create + // (`0x5deb60 call 0x4908c0`) and, on a `/reload`, from `UI_Init` (`0x490168`), the player in + // the world both times. `PLAYER_LOGIN` only when this VM's load armed it, so a worldport's + // re-entry fires `PLAYER_ENTERING_WORLD` alone. At world exit, re-arm the entry and forget the + // player-global memos so the next character seeds them. if self_pair.is_some() { if !memo.entered_world { - gate.audit("feed_units", "the PLAYER_ENTERING_WORLD arm"); - script.fire_event("PLAYER_ENTERING_WORLD", vec![]); + gate.audit("feed_units", "the world-enter events"); + script.fire_world_enter(); memo.entered_world = true; } } else if memo.entered_world { diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 49c0d1a3..e3b460a9 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -346,6 +346,9 @@ pub(crate) struct Model { pub(crate) camera_view_requests: Vec, /// `Logout`, `Quit`, `CancelLogout`, `ForceQuit` calls; Lua sees the countdown only as events. pub(crate) session_requests: Vec, + /// `PLAYER_LOGIN`'s one-shot, the reference's `[0xb4e260]`: armed by the UI load, spent by + /// the next world-enter cascade ([`super::UiScript::fire_world_enter`]). + pub(crate) player_login_armed: bool, /// `TogglePVP` calls, a count because `CMSG_TOGGLE_PVP` has no body. pub(crate) pvp_toggles: u32, /// The player's private honor fields; before the first push the six self getters read zeros. @@ -1159,6 +1162,7 @@ impl Model { follow_requests: Vec::new(), camera_view_requests: Vec::new(), session_requests: Vec::new(), + player_login_armed: false, pvp_toggles: 0, honor: None, inspect_honor: None, diff --git a/crates/benilla-ui/src/script/session.rs b/crates/benilla-ui/src/script/session.rs index 4b1a7f82..8312b7df 100644 --- a/crates/benilla-ui/src/script/session.rs +++ b/crates/benilla-ui/src/script/session.rs @@ -1,6 +1,7 @@ //! The session verbs: the game menu's Logout and Exit Game, their dialogs' answers, `ReloadUI` //! and the cinematic pair. Each queues a [`SessionRequest`] the app turns into a packet or an -//! exit; the server decides whether a logout is instant or a 20-second countdown. +//! exit; the server decides whether a logout is instant or a 20-second countdown. Beside them, +//! the world-enter cascade's two events and the `PLAYER_LOGIN` arm a UI load sets. use mlua::Lua; @@ -41,6 +42,28 @@ impl super::UiScript { pub fn queue_session_request(&mut self, request: SessionRequest) { self.model_mut().session_requests.push(request); } + + /// Arm `PLAYER_LOGIN`, the UI load's last step: `UI_Init` sets the one-shot `[0xb4e260]` at + /// `0x49011d`, unconditionally, and [`Self::fire_world_enter`] spends it. The flag lives in + /// this VM, so each load arms its own and a worldport, which loads nothing, re-arms nothing. + pub fn arm_player_login(&mut self) { + self.model_mut().player_login_armed = true; + } + + /// The world-enter cascade's events (`0x4908c0`): `PLAYER_LOGIN` (270, `0x490959`) only if a + /// load armed it (read at `0x49094b`, cleared at `0x49095e`), then `PLAYER_ENTERING_WORLD` + /// (272, `0x49096a`) every time. The cascade runs with the player object in the world, so its + /// caller seats `"player"` first, and inside its own sound-suppression bracket (`0x4908d5` to + /// `0x490a56`), so a handler's `PlaySound` is dropped. + pub fn fire_world_enter(&mut self) { + let armed = std::mem::take(&mut self.model_mut().player_login_armed); + self.push_sound_suppression(); + if armed { + self.fire_event("PLAYER_LOGIN", Vec::new()); + } + self.fire_event("PLAYER_ENTERING_WORLD", Vec::new()); + self.pop_sound_suppression(); + } } pub(super) fn install(lua: &Lua) -> mlua::Result<()> { diff --git a/crates/benilla-ui/src/script/tests/events.rs b/crates/benilla-ui/src/script/tests/events.rs index e0fa1247..7be96f5f 100644 --- a/crates/benilla-ui/src/script/tests/events.rs +++ b/crates/benilla-ui/src/script/tests/events.rs @@ -330,3 +330,49 @@ fn an_all_events_listener_runs_after_the_events_own() { assert_eq!(s.eval::("return order[2]").unwrap(), "all"); assert_eq!(s.eval::("return table.getn(order)").unwrap(), 2); } + +/// The world-enter cascade (`0x4908c0`) fires `PLAYER_LOGIN` (`0x490959`) only while a load's arm +/// (`0x49011d`) is unspent, clearing it (`0x49095e`), then `PLAYER_ENTERING_WORLD` (`0x49096a`) +/// every time, inside its own sound bracket (`0x4908d5` to `0x490a56`). +#[test] +fn the_world_enter_cascade_spends_the_login_arm_once_and_plays_nothing() { + let mut s = script(); + s.run( + r#" + order = "" + local f = CreateFrame("Frame") + f:RegisterEvent("PLAYER_LOGIN") + f:RegisterEvent("PLAYER_ENTERING_WORLD") + f:SetScript("OnEvent", function() + order = order .. event .. " " + PlaySound("igMainMenuOpen") + end) + "#, + ) + .unwrap(); + s.fire_world_enter(); + assert_eq!( + s.eval::("return order").unwrap(), + "PLAYER_ENTERING_WORLD ", + "no load armed it" + ); + s.run(r#"order = """#).unwrap(); + s.arm_player_login(); + s.fire_world_enter(); + s.fire_world_enter(); + assert_eq!( + s.eval::("return order").unwrap(), + "PLAYER_LOGIN PLAYER_ENTERING_WORLD PLAYER_ENTERING_WORLD ", + "the arm is one-shot" + ); + assert!( + s.take_sounds().is_empty(), + "the cascade's handlers are silent" + ); + s.run(r#"PlaySound("igMainMenuOpen")"#).unwrap(); + assert_eq!( + s.take_sounds().len(), + 1, + "and the bracket closed behind them" + ); +}