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:
Sam Sung
2026-10-09 11:38:06 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 87706792cb
commit f0dfa86148
11 changed files with 419 additions and 72 deletions
+2 -2
View File
@@ -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,
+3 -2
View File
@@ -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.
+8 -3
View File
@@ -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 ",
+10 -3
View File
@@ -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),
+64 -21
View File
@@ -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
+2 -2
View File
@@ -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);
}
+93 -22
View File
@@ -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 {
+4
View File
@@ -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,
+24 -1
View File
@@ -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"
);
}