PLAYER_LOGIN fires at world entry, once the player exists (#840)
`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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
87706792cb
commit
f0dfa86148
@@ -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,
|
||||
|
||||
@@ -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::<ChatWindowFile>()
|
||||
// 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.
|
||||
|
||||
@@ -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::<benilla_ui::script::UiScript>()
|
||||
.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 ",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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::<crate::char_select::Roster>()
|
||||
.and_then(record_from_roster)
|
||||
{
|
||||
script.set_player_record(record);
|
||||
}
|
||||
if let Some(seat) = world
|
||||
.get_resource::<crate::char_select::Roster>()
|
||||
.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::<crate::char_select::Roster>()
|
||||
.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<crate::ui_unit::LivePlayerSeat> {
|
||||
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::<ReloadUiPending>().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
|
||||
|
||||
@@ -600,9 +600,9 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<
|
||||
},
|
||||
]);
|
||||
script.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
|
||||
// 70% XP, set before `PLAYER_ENTERING_WORLD` so the bar's first update reads it.
|
||||
// 70% XP, set before the world-enter events so the bar's first update reads it.
|
||||
script.set_player_xp(4200, 6000);
|
||||
script.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||||
script.fire_world_enter();
|
||||
// The bottom multibars ship off and a capture has no toggle byte, so raise them as the
|
||||
// Options rows do; `WOW_DEMO_BOTTOM_BARS=0` leaves them down for the stance shelf art,
|
||||
// which `ShapeshiftBar_UpdatePosition` hides under the bottom-left bar.
|
||||
|
||||
@@ -773,6 +773,28 @@ fn leaving_inside_the_deferral_window_drops_the_load_and_writes_nothing() {
|
||||
|
||||
// ───────────── The login one-shots wait for the in-game UI ─────────────
|
||||
|
||||
/// An app running the unit feed after `Startup`, with what the feed reads; the receiver is the
|
||||
/// wire, kept alive beside it.
|
||||
fn unit_feed_app() -> (App, crossbeam_channel::Receiver<crate::net::ClientCommand>) {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(crate::ui_unit::UiUnitPlugin);
|
||||
app.init_resource::<super::AddOnIdentity>();
|
||||
app.init_resource::<crate::minimap::MinimapZoom>();
|
||||
app.init_resource::<super::ReloadUiPending>();
|
||||
app.init_resource::<crate::target::Selection>();
|
||||
app.init_resource::<crate::names::NameCache>();
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
app.insert_resource(crate::net::NetCommands(tx));
|
||||
app.init_resource::<crate::net::Reputations>();
|
||||
app.init_resource::<crate::ui_party::GroupState>();
|
||||
app.init_resource::<crate::ui_chat::ChatLog>();
|
||||
app.init_resource::<crate::ui_guild::GuildState>();
|
||||
app.add_message::<crate::creature_anim::SwingImpact>();
|
||||
app.add_message::<crate::net::WorldEnterCascadeMessage>();
|
||||
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::<super::AddOnIdentity>();
|
||||
app.init_resource::<crate::minimap::MinimapZoom>();
|
||||
app.init_resource::<super::ReloadUiPending>();
|
||||
app.init_resource::<crate::target::Selection>();
|
||||
app.init_resource::<crate::names::NameCache>();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
app.insert_resource(crate::net::NetCommands(tx));
|
||||
app.init_resource::<crate::net::Reputations>();
|
||||
app.init_resource::<crate::ui_party::GroupState>();
|
||||
app.init_resource::<crate::ui_chat::ChatLog>();
|
||||
app.init_resource::<crate::ui_guild::GuildState>();
|
||||
app.add_message::<crate::creature_anim::SwingImpact>();
|
||||
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::<benilla_ui::script::UiScript>()
|
||||
.eval::<Option<String>>(&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);
|
||||
}
|
||||
|
||||
@@ -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<String, UnitState>,
|
||||
@@ -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<SelfPlayer>>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
group: Res<crate::ui_party::GroupState>,
|
||||
factions: Option<Res<Factions>>,
|
||||
mut guild: ResMut<crate::ui_guild::GuildState>,
|
||||
) -> Option<LivePlayerSeat> {
|
||||
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<NonSendMut<UiScript>>,
|
||||
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 {
|
||||
|
||||
@@ -346,6 +346,9 @@ pub(crate) struct Model {
|
||||
pub(crate) camera_view_requests: Vec<camera_view::CameraViewRequest>,
|
||||
/// `Logout`, `Quit`, `CancelLogout`, `ForceQuit` calls; Lua sees the countdown only as events.
|
||||
pub(crate) session_requests: Vec<session::SessionRequest>,
|
||||
/// `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,
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -330,3 +330,49 @@ fn an_all_events_listener_runs_after_the_events_own() {
|
||||
assert_eq!(s.eval::<String>("return order[2]").unwrap(), "all");
|
||||
assert_eq!(s.eval::<i64>("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::<String>("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::<String>("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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user