From cf891dc3756a36dc0af4376f861ffb0c847ba5e9 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 3 Oct 2026 12:36:21 +0200 Subject: [PATCH 01/21] fix: close a loot window that opens while you move, as 1.12.1 does (#786) - A loot window that opens while you move closes, as in 1.12.1 (#782) - Set auto_loot in the LootConfig initializer (clippy field_reassign_with_default) - Close a window opened on the move in the feed's pass, so it never paints Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Sam --- .../benilla-app/src/creature_anim/select.rs | 5 +- crates/benilla-app/src/game_plugins.rs | 2 + crates/benilla-app/src/target/click.rs | 2 +- crates/benilla-app/src/ui_loot/mod.rs | 40 ++- crates/benilla-app/src/ui_loot/net.rs | 323 ++++++++++++++++-- crates/benilla-app/src/ui_script/test_ui.rs | 2 +- 6 files changed, 334 insertions(+), 40 deletions(-) diff --git a/crates/benilla-app/src/creature_anim/select.rs b/crates/benilla-app/src/creature_anim/select.rs index 2c35e9d9..926bb17c 100644 --- a/crates/benilla-app/src/creature_anim/select.rs +++ b/crates/benilla-app/src/creature_anim/select.rs @@ -111,10 +111,13 @@ pub(crate) mod move_flags { /// Any horizontal direction bit: the client's `[9e8] & 0xf` gate. pub const ANY_MOVE: u32 = FORWARD | BACKWARD | STRAFE_LEFT | STRAFE_RIGHT; + /// The low byte: forward, back, strafe, turn and pitch, the loot response's test (`0x5ebc40`). + pub const STEERING: u32 = 0xff; + /// The bits that get a mover integrated at all, the client's `0x20ff` (`0x616e20`, `0x6166f5`): /// without one a mover keeps its last packet's pose, and a flag-less unit is not even in the /// mover list (`0x618940`). Swim and the mode bits are not among them. - pub const INTEGRATED: u32 = 0xff | FALLING; + pub const INTEGRATED: u32 = STEERING | FALLING; /// The committed-lower-body test routing a one-shot to the masked overlay (`0x5fe6dc`); the /// client's separate mouse-turn test (`d58 & 0x1800`) reads a field benilla does not model. diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 4a9e49ac..53e216f0 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -1149,6 +1149,8 @@ pub(crate) mod schedule_tests { "both packets answer the `CMSG_LOGOUT_REQUEST`/cancel the game menu sent"), ("ui_loot/mod.rs", "drain_loot", Because::Deliberate, "the pre-VM take of `LootMoveStart` is documented at the line and publishes nothing to the VM; the event-firing takes are Lua's queues"), + ("ui_loot/mod.rs", "feed_loot", Because::PlayerRoundTrip, + "it takes `LootMoveStart` only on a window it has just opened, which answers a loot click"), ("ui_loot_roll.rs", "drain_loot_rolls", Because::FilledByVm, "only a Need/Greed/Pass click queues a confirm or vote, held in the VM"), ("ui_loot_roll.rs", "feed_loot_rolls", Because::PlayerRoundTrip, diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index 3f27b934..9c8ecef6 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -1430,7 +1430,7 @@ pub(crate) fn service_action( /// Drain the guid-scoped deselects ([`DeselectGuid`]). `ClearTarget()`, the last leg of the Esc /// chain (`UIParent.lua:1492`), is a script call and lands in call order /// ([`crate::script_calls`]). -pub(super) fn clear_target_requests( +pub(crate) fn clear_target_requests( mut selection: ResMut, mut seam: crate::creature_anim::AttackSeam, engaged: Query<(), (With, With)>, diff --git a/crates/benilla-app/src/ui_loot/mod.rs b/crates/benilla-app/src/ui_loot/mod.rs index f7e8e682..b9d41cae 100644 --- a/crates/benilla-app/src/ui_loot/mod.rs +++ b/crates/benilla-app/src/ui_loot/mod.rs @@ -363,7 +363,8 @@ impl LootLatch { /// A movement start that closes the loot: the reference's guard `0x60e990`, called by every /// movement-start emitter, turning included, but not by mouse-look facing. No loot target reaches /// the range gate `0x493230`, so there is no distance leash. vmangos also releases on movement -/// (`MovementHandler.cpp:1104`), but the close is the client's. +/// (`MovementHandler.cpp:1104`), but the close is the client's. A loot response that lands while +/// we move sets it too (`0x5ebc3a`). #[derive(Resource, Default)] pub(crate) struct LootMoveStart(pub(crate) bool); @@ -674,6 +675,7 @@ fn feed_loot( group: Res, names: Res, objects: LootSourceObjects, + mut moving: OpenWhileMoving, ) { let Some(mut script) = script else { return; @@ -755,6 +757,19 @@ fn feed_loot( } } } + // The response's tail (`0x5ebc3a`-`0x5ebc51`) closes a window that opened while we + // move, after the copier's open and sweep and in the same handler: `LOOT_CLOSED` fires + // in this pass, so the window never paints. + if std::mem::take(&mut moving.move_start.0) { + let closed = close_on_move_start(&mut loot, &mut moving.latch, &commands); + if closed.is_some() { + moving.dead.after_close(closed); + script.set_loot(None); + script.fire_event("LOOT_CLOSED", vec![]); + *last = None; + return; + } + } } // One `LOOT_SLOT_CLEARED` per row that went, with its 1-based row: the stock handler hides // that button and pages down when the page empties (`LootFrame.lua:22-50`). @@ -862,6 +877,14 @@ fn close_on_move_start( close_interaction(loot, latch, Some(commands)) } +/// What [`feed_loot`] needs to take the move-start close on a window it has just opened. +#[derive(bevy::ecs::system::SystemParam)] +struct OpenWhileMoving<'w, 's> { + move_start: ResMut<'w, LootMoveStart>, + latch: ResMut<'w, LootLatch>, + dead: DeadUnitDeselect<'w, 's>, +} + /// Sends what the Lua asked for, as the take dispatcher `0x4c2790(slot, flag)`: a row click /// (a `LootButton`'s own, flag 0) and the `LOOT_BIND` confirm (`LootSlot`, flag 1) stay apart, /// so a second click on a bind-on-pickup row asks again. @@ -1170,6 +1193,11 @@ mod tests { let (tx, _rx) = crossbeam_channel::unbounded(); let mut app = App::new(); app.add_message::() + // What the feed's move-start close reads (`OpenWhileMoving`). + .add_message::() + .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -1227,6 +1255,11 @@ mod tests { let (tx, rx) = crossbeam_channel::unbounded(); let mut app = App::new(); app.add_message::() + // What the feed's move-start close reads (`OpenWhileMoving`). + .add_message::() + .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -1460,6 +1493,11 @@ mod tests { let (tx, rx) = crossbeam_channel::unbounded(); let mut app = App::new(); app.add_message::() + // What the feed's move-start close reads (`OpenWhileMoving`). + .add_message::() + .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/ui_loot/net.rs b/crates/benilla-app/src/ui_loot/net.rs index ffd92655..fcb1ed0c 100644 --- a/crates/benilla-app/src/ui_loot/net.rs +++ b/crates/benilla-app/src/ui_loot/net.rs @@ -5,7 +5,7 @@ use benilla_protocol::messages::ItemPushResult; use benilla_protocol::{SessionEvent, SessionEventKind}; use bevy::prelude::*; -use super::{LootLatch, LootState}; +use super::{LootLatch, LootMoveStart, LootState}; use crate::net::{ClientCommand, NetCommands, NetHandlerApp, SelfGuid}; use crate::pending_item_ops::{LockTransitions, PendingItemOps}; use crate::ui_action::{UiError, UiErrorKeys}; @@ -26,8 +26,15 @@ pub(super) fn register(app: &mut App) { } /// An admitted response selects a looted unit (`0x5ebc35` → `0x48f3a0`) before the window opens -/// (`0x4c1cb0`), so the outgoing target's teardown closes a window still open on it. -fn on_response(In(ev): In, mut select: crate::target::SelectCommit) { +/// (`0x4c1cb0`), so the outgoing target's teardown closes a window still open on it. One that lands +/// while our own body moves takes the move-start close (`0x5ebc3a`-`0x5ebc51`), which the feed runs +/// after it fires `LOOT_OPENED`, or the drain when the window waits on an item template. +fn on_response( + In(ev): In, + mut select: crate::target::SelectCommit, + player: Option>, + mut move_start: ResMut, +) { let SessionEvent::LootResponse { guid, loot_type, @@ -47,6 +54,12 @@ fn on_response(In(ev): In, mut select: crate::target::SelectCommit ); select.select_unit(guid); select.seam.loot.open(guid, loot_type, gold, items); + if player.is_some_and(|p| { + p.foreign_mover.is_none() + && p.move_flags() & crate::creature_anim::move_flags::STEERING != 0 + }) { + move_start.0 = true; + } } fn on_error( @@ -763,6 +776,8 @@ mod tests { world.init_resource::(); world.init_resource::>(); world.init_resource::>(); + world.init_resource::(); + world.init_resource::(); world.spawn(( SelfPlayer, Guid(1), @@ -988,34 +1003,33 @@ mod tests { ); } - /// The issue's symptom on the stock frames: the Tab target is on `TargetFrame` when the body's - /// window opens, and the unit feed then names the body there (`TargetFrame.lua:63`, - /// `UnitFrame.lua:25`). - #[test] - fn the_stock_target_frame_names_the_looted_body() { + /// The stock unit frames over `TargetFrame`, for [`stock_app`]. + const TARGET_UI: &[&str] = &[ + "Interface\\FrameXML\\Fonts.xml", + "Interface\\FrameXML\\GlobalStrings.lua", + "Interface\\FrameXML\\UIParent.xml", + "Interface\\FrameXML\\BasicControls.xml", + "Interface\\FrameXML\\MoneyFrame.lua", + "Interface\\FrameXML\\MoneyFrame.xml", + "Interface\\FrameXML\\GameTooltip.xml", + "Interface\\FrameXML\\UIDropDownMenu.xml", + "Interface\\FrameXML\\TextStatusBar.lua", + "Interface\\FrameXML\\TextStatusBar.xml", + "Interface\\FrameXML\\BuffFrame.xml", + "Interface\\FrameXML\\CombatFeedback.xml", + "Interface\\FrameXML\\UnitPopup.xml", + "Interface\\FrameXML\\UnitFrame.xml", + "Interface\\FrameXML\\PlayerFrame.xml", + "Interface\\FrameXML\\PartyFrame.xml", + "Interface\\FrameXML\\TargetFrame.xml", + ]; + + /// [`tabbed_world`] under the stock `ui` files, with the unit feed and both creatures named. + fn stock_app(ui: &[&[&str]]) -> App { use benilla_ui::script::UiScript; - benilla_formats::wow_data_or_skip!(); let mut s = UiScript::new().expect("VM"); s.set_screen_size(1024.0, 768.0); - for f in crate::ui_script::test_ui::production_order(&[&[ - "Interface\\FrameXML\\Fonts.xml", - "Interface\\FrameXML\\GlobalStrings.lua", - "Interface\\FrameXML\\UIParent.xml", - "Interface\\FrameXML\\BasicControls.xml", - "Interface\\FrameXML\\MoneyFrame.lua", - "Interface\\FrameXML\\MoneyFrame.xml", - "Interface\\FrameXML\\GameTooltip.xml", - "Interface\\FrameXML\\UIDropDownMenu.xml", - "Interface\\FrameXML\\TextStatusBar.lua", - "Interface\\FrameXML\\TextStatusBar.xml", - "Interface\\FrameXML\\BuffFrame.xml", - "Interface\\FrameXML\\CombatFeedback.xml", - "Interface\\FrameXML\\UnitPopup.xml", - "Interface\\FrameXML\\UnitFrame.xml", - "Interface\\FrameXML\\PlayerFrame.xml", - "Interface\\FrameXML\\PartyFrame.xml", - "Interface\\FrameXML\\TargetFrame.xml", - ]]) { + for f in crate::ui_script::test_ui::production_order(ui) { crate::ui_script::test_ui::load_ui(&s, f); } @@ -1048,20 +1062,257 @@ mod tests { .init_resource::() .insert_non_send_resource(s); crate::ui_unit::add_unit_feed(&mut app); - let shown = |app: &mut App| -> String { - let s = app.world().non_send_resource::(); - assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); - s.eval("return TargetFrame:IsShown() and TargetFrame.name:GetText() or ''") - .expect("the target frame's name") - }; + app + } + + /// The name on the stock `TargetFrame`, empty while it is hidden. + fn target_frame_name(app: &App) -> String { + let s = app + .world() + .non_send_resource::(); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + s.eval("return TargetFrame:IsShown() and TargetFrame.name:GetText() or ''") + .expect("the target frame's name") + } + + /// The issue's symptom on the stock frames: the Tab target is on `TargetFrame` when the body's + /// window opens, and the unit feed then names the body there (`TargetFrame.lua:63`, + /// `UnitFrame.lua:25`). + #[test] + fn the_stock_target_frame_names_the_looted_body() { + benilla_formats::wow_data_or_skip!(); + let mut app = stock_app(&[TARGET_UI]); app.update(); - assert_eq!(shown(&mut app), "Kobold Vermin", "the Tab target"); + assert_eq!(target_frame_name(&app), "Kobold Vermin", "the Tab target"); app.world_mut().resource_mut::().0 = Some(CORPSE); respond(app.world_mut(), CORPSE, 1); app.update(); - assert_eq!(shown(&mut app), "Kobold Worker", "the looted body"); + assert_eq!(target_frame_name(&app), "Kobold Worker", "the looted body"); + } + + /// What [`super::super::drain_loot`] reads beside [`seat_tabbed`]'s, with our body's movement + /// word at `player`'s. + fn seat_drain(world: &mut World, player: crate::player::Player) { + world.insert_resource(player); + world.init_resource::(); + world.init_resource::(); + world.init_resource::>(); + } + + /// The deselects asked for since the last call. + fn deselects(world: &mut World) -> Vec { + world + .resource_mut::>() + .drain() + .map(|d| d.0) + .collect() + } + + /// A response on the corpse with `player` seated, then one drain. + fn open_while( + player: crate::player::Player, + ) -> (World, crossbeam_channel::Receiver) { + let (mut world, rx) = tabbed_world(); + seat_drain(&mut world, player); + world.resource_mut::().0 = Some(CORPSE); + respond(&mut world, CORPSE, 1); + world + .run_system_once(super::super::drain_loot) + .expect("the drain runs as a one-shot system"); + (world, rx) + } + + /// The response's tail (`0x5ebc3a`-`0x5ebc51`): a bit of our movement word's low byte, forward, + /// back, strafe, turn or pitch, takes the move-start close on the window it just opened, the + /// release after the selection and the dead body's deselect last (`0x48f369`). + #[test] + fn a_window_that_opens_while_moving_closes_as_a_movement_start_does() { + for bit in (0..8).map(|b| 1u32 << b) { + let (mut world, rx) = open_while(crate::player::Player::with_move_flags(bit)); + assert_eq!(world.resource::().source(), None, "{bit:#x}"); + assert_eq!(world.resource::().0, None, "{bit:#x}"); + let sent: Vec<_> = rx.try_iter().collect(); + assert!( + matches!( + sent[..], + [ClientCommand::SetSelection { guid: a }, ClientCommand::LootRelease { guid: b }] + if a == CORPSE && b == CORPSE + ), + "{bit:#x}: {sent:?}" + ); + assert_eq!(deselects(&mut world), [CORPSE], "{bit:#x}"); + } + } + + /// Only that byte, and only our body's: walking, falling or swimming alone leaves the window + /// open, as does a step of the creature we possess. + #[test] + fn a_window_opened_falling_swimming_or_possessing_stays_open() { + use crate::creature_anim::move_flags as f; + use crate::player::Player; + let mut possessing = Player::with_move_flags(f::FORWARD); + possessing.foreign_mover = Some(KOBOLD); + for (what, player) in [ + ("still", Player::with_move_flags(0)), + ("walk mode", Player::with_move_flags(f::WALK_MODE)), + ("falling", Player::with_move_flags(f::FALLING)), + ("swimming", Player::with_move_flags(f::SWIMMING)), + ("possessing", possessing), + ] { + let (mut world, rx) = open_while(player); + assert_eq!( + world.resource::().source(), + Some(CORPSE), + "{what}" + ); + let sent: Vec<_> = rx.try_iter().collect(); + assert!( + matches!(sent[..], [ClientCommand::SetSelection { guid }] if guid == CORPSE), + "{what}: {sent:?}" + ); + assert!(deselects(&mut world).is_empty(), "{what}"); + } + } + + /// The copier `0x4c1cb0` sweeps an auto-loot window (`0x4c1fa0`) before the response's + /// movement close (`0x5ebc51`), so a body auto-looted on the run is taken, then released. + #[test] + fn auto_loot_on_the_run_takes_the_rows_before_the_release() { + const TOUGH_JERKY: u32 = 117; + let mut app = App::new(); + let rx = seat_tabbed(app.world_mut()); + seat_drain( + app.world_mut(), + crate::player::Player::with_move_flags(crate::creature_anim::move_flags::FORWARD), + ); + app.world_mut() + .resource_mut::() + .insert_template( + TOUGH_JERKY, + Some(crate::items::test_template("Tough Jerky")), + ); + app.insert_resource(super::super::LootConfig { + auto_loot: true, + ..Default::default() + }) + .init_resource::() + .init_resource::() + .init_resource::>() + .insert_non_send_resource(benilla_ui::script::UiScript::new().expect("VM")) + .add_systems( + Update, + (super::super::feed_loot, super::super::drain_loot).chain(), + ); + + app.world_mut().resource_mut::().0 = Some(CORPSE); + app.world_mut() + .run_system_once_with( + on_response, + SessionEvent::LootResponse { + guid: CORPSE, + loot_type: 1, + gold: 0, + items: vec![benilla_protocol::messages::LootItem { + slot: 0, + item_id: TOUGH_JERKY, + count: 1, + display_info_id: 1117, + random_property_id: 0, + slot_type: 0, + }], + }, + ) + .expect("the handler runs as a one-shot system"); + app.update(); + let sent: Vec<_> = rx.try_iter().collect(); + assert!( + matches!( + sent[..], + [ + ClientCommand::SetSelection { .. }, + ClientCommand::AutostoreLootItem { slot: 0 }, + ClientCommand::LootRelease { guid }, + ] if guid == CORPSE + ), + "{sent:?}" + ); + } + + /// The issue's spot on the stock frames: a body's window that opens on the run is shown by + /// `LOOT_OPENED` and hidden by `LOOT_CLOSED` in the same frame (`LootFrame.lua:13-16`, + /// `:53-55`), its one release + /// sent, and `TargetFrame` lets the body go; standing, the window and the target stay. + #[test] + fn the_stock_loot_frame_closes_on_a_body_looted_on_the_run() { + use crate::creature_anim::move_flags as f; + use benilla_ui::script::UiScript; + benilla_formats::wow_data_or_skip!(); + for (flags, stays) in [(f::FORWARD, false), (0, true)] { + let mut app = stock_app(&[ + crate::ui_script::test_ui::LOOT_UI, + TARGET_UI, + &["Interface\\FrameXML\\LootFrame.xml"], + ]); + let (tx, rx) = crossbeam_channel::unbounded(); + app.insert_resource(crate::net::NetCommands(tx)); + seat_drain( + app.world_mut(), + crate::player::Player::with_move_flags(flags), + ); + app.init_resource::() + .init_resource::>() + .add_systems( + Update, + ( + super::super::feed_loot, + super::super::drain_loot, + crate::target::click::clear_target_requests, + ) + .chain(), + ); + app.world() + .non_send_resource::() + .run( + "LOOT_EVENTS = {} local f = CreateFrame('Frame') \ + f:RegisterEvent('LOOT_OPENED') f:RegisterEvent('LOOT_CLOSED') \ + f:SetScript('OnEvent', function() tinsert(LOOT_EVENTS, event) end)", + ) + .unwrap(); + app.update(); + + app.world_mut().resource_mut::().0 = Some(CORPSE); + respond(app.world_mut(), CORPSE, 1); + // One frame: the reference opens and closes in the response's handler. + app.update(); + let (events, shown): (String, bool) = app + .world() + .non_send_resource::() + .eval("return table.concat(LOOT_EVENTS, ','), LootFrame:IsShown() == 1") + .unwrap(); + for _ in 0..2 { + app.update(); + } + let releases = rx + .try_iter() + .filter(|c| matches!(c, ClientCommand::LootRelease { guid } if *guid == CORPSE)) + .count(); + if stays { + assert_eq!(events, "LOOT_OPENED"); + assert!(shown, "standing, the window stays"); + assert_eq!(releases, 0); + assert_eq!(target_frame_name(&app), "Kobold Worker"); + } else { + assert_eq!(events, "LOOT_OPENED,LOOT_CLOSED"); + assert!( + !shown, + "on the run, the window closes in the frame it opens" + ); + assert_eq!(releases, 1, "released once"); + assert_eq!(target_frame_name(&app), "", "and the body is let go"); + } + } } /// Only a releasing arm reaches `UnlockItem 0x495420`, through the tail `0x5ebac2`. diff --git a/crates/benilla-app/src/ui_script/test_ui.rs b/crates/benilla-app/src/ui_script/test_ui.rs index f2436e9b..00ab9176 100644 --- a/crates/benilla-app/src/ui_script/test_ui.rs +++ b/crates/benilla-app/src/ui_script/test_ui.rs @@ -501,7 +501,7 @@ pub(crate) const GOSSIP_UI: &[&str] = &[ /// lacks what the app loads at setup (the master-loot menu reads `GROUP` and `GIVE_LOOT`), /// `ItemButtonTemplate.xml` because a missing template only warns, and `PartyFrame.xml` because /// `LootFrame.lua:217` reads `MAX_PARTY_MEMBERS` at load. Needs client data. -pub(super) const LOOT_UI: &[&str] = &[ +pub(crate) const LOOT_UI: &[&str] = &[ "Interface\\FrameXML\\GlobalStrings.lua", "Interface\\FrameXML\\Fonts.xml", "Interface\\FrameXML\\BasicControls.xml", // `TEXT`, which UnitPopup.lua reads at file scope From 9bc7276dd8b75e616837f0fec85b0f75e96ab1cb Mon Sep 17 00:00:00 2001 From: goran2711 Date: Sun, 4 Oct 2026 02:29:04 +0100 Subject: [PATCH 02/21] fix: spell text $g takes the player's gender, as 1.12.1 does (#787) - spell text: $g/$G take the active player's gender and the chosen form is space-trimmed, so a female character reads "her" (0x508180, 0x508214) - spell text: trim the $g docs, drop the colon-less $g pin - spell tooltips: $g reads the view's player store; nets cover gender - item tooltips: re-substitute $-texts when the gender byte moves - tests: share one real-spells loader beside Spells Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Sam --- crates/benilla-app/src/ui_action/mod.rs | 15 +++ crates/benilla-app/src/ui_craft.rs | 1 + crates/benilla-app/src/ui_items/feed.rs | 111 +++++++++++++----- .../benilla-app/src/ui_tooltip/deps_tests.rs | 48 +++++++- .../benilla-app/src/ui_tooltip/spell_deps.rs | 9 ++ .../benilla-app/src/ui_tooltip/spell_feed.rs | 6 + crates/benilla-app/src/ui_tooltip/tests.rs | 61 +++++++--- crates/benilla-formats/src/spells/tokens.rs | 63 +++++++++- 8 files changed, 259 insertions(+), 55 deletions(-) diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index ee7dfb27..538680f0 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -144,6 +144,21 @@ impl Spells { } } +/// The 5875 spell data; `None` skips where the install is absent. +#[cfg(test)] +pub(crate) fn real_spells() -> Option { + let data = benilla_formats::wow_data_or_skip!(None); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + Some(Spells { + catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"), + forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("forms"), + ranges: benilla_formats::load_spell_ranges(&mut chain).expect("ranges"), + cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("cast times"), + durations: benilla_formats::load_spell_durations(&mut chain).expect("durations"), + radii: benilla_formats::load_spell_radii(&mut chain).expect("radii"), + }) +} + /// The reference's learned-ability latches: at learn time `0x4b25e0` stores a spell's id by its /// `Effect[0]`, and the unlearn path `0x4b2c50` clears it. The skin cursor requires the latch /// (`0x482589`), so a player who never learned Skinning gets no skin cursor. diff --git a/crates/benilla-app/src/ui_craft.rs b/crates/benilla-app/src/ui_craft.rs index 4b9fc387..82ea6d63 100644 --- a/crates/benilla-app/src/ui_craft.rs +++ b/crates/benilla-app/src/ui_craft.rs @@ -135,6 +135,7 @@ fn feed_craft( lookup: &|id| spells.catalog.get(id), mods: Some(&*spell_mods), unmodified_points: false, + gender: &|| store.0.unit_gender().unwrap_or(0), home_area: &|| None, global: &global, printf: &crate::ui_script::token_printf, diff --git a/crates/benilla-app/src/ui_items/feed.rs b/crates/benilla-app/src/ui_items/feed.rs index e66bd879..ea330dee 100644 --- a/crates/benilla-app/src/ui_items/feed.rs +++ b/crates/benilla-app/src/ui_items/feed.rs @@ -60,6 +60,8 @@ fn spell_desc_text( // The player's skill in a spell's line, the `$`-tokens' level (`TokenContext::skill`). skill: &dyn Fn(u32) -> u32, home_area: Option<&str>, + // The active player's `UNIT_FIELD_BYTES_0` byte 2, the `$g`/`$G` branch's input (`0x508214`). + gender: &dyn Fn() -> u8, mods: Option<&crate::spell::SpellModifiers>, // The VM's strings for the keyed `$d`/`$s` tokens. global: &dyn Fn(&str) -> Option, @@ -77,6 +79,7 @@ fn spell_desc_text( lookup: &|i| sp.catalog.get(i), mods: mods.map(|m| m as &dyn benilla_formats::SpellMods), unmodified_points: false, + gender, home_area: &|| home_area, global, printf: &crate::ui_script::token_printf, @@ -87,6 +90,15 @@ fn spell_desc_text( } } +/// What the `$`-tokens read of the player beside the modifiers: the skills the per-level terms +/// scale by, and the gender byte `$g`/`$G` branch on (`0x508214`). +type TokenReads = (crate::spell::SkillSnapshot, u8); + +fn token_reads(me: Option<&ObjectStore>) -> TokenReads { + let gender = me.and_then(|s| s.0.unit_gender()).unwrap_or(0); + (crate::spell::skill_snapshot(me), gender) +} + /// Only descriptions with substitution tokens can change when the caster's spell mods change. fn spell_has_mod_tokens(spells: Option<&crate::ui_action::Spells>, id: u32) -> bool { spells @@ -137,6 +149,8 @@ fn template_view( skill: &dyn Fn(u32) -> u32, skill_lines: Option<&benilla_formats::SkillLineCatalog>, home_area: Option<&str>, + // The active player's gender byte, the trigger text's `$g`/`$G` branch (`0x508214`). + gender: &dyn Fn() -> u8, factions: Option<&benilla_formats::FactionCatalog>, sub_classes: Option<&benilla_formats::ItemSubClassCatalog>, classes: Option<&benilla_formats::ItemClassCatalog>, @@ -150,7 +164,7 @@ fn template_view( .and_then(|s| s.catalog.get(id)) .map(|sd| sd.name.clone()) }; - let spell_text = |id: u32| spell_desc_text(spells, id, skill, home_area, mods, get); + let spell_text = |id: u32| spell_desc_text(spells, id, skill, home_area, gender, mods, get); benilla_ui::script::ItemTemplateView { name: t.name.clone(), quality: t.quality, @@ -254,7 +268,7 @@ pub(super) fn feed_item_sets( crate::ui_script::VmMemo>, >, mut mod_sensitive: Local>>, - mut last_skills: Local>>, + mut last_reads: Local>>, ) { let Some(mut script) = script else { return; @@ -265,11 +279,12 @@ pub(super) fn feed_item_sets( for id in script.take_item_set_asks() { pending.entry(id).or_default(); } - // The `$`-tokens' per-level terms follow the player's skills. - let skills = Some(crate::spell::skill_snapshot(me)); - let skills_changed = *last_skills.get(&script) != skills; - *last_skills.get(&script) = skills; - if spell_mods.is_changed() || skills_changed { + // The `$`-tokens follow the player's skills and gender. + let reads = token_reads(me); + let gender = reads.1; + let reads_changed = last_reads.get(&script).as_ref() != Some(&reads); + *last_reads.get(&script) = Some(reads); + if spell_mods.is_changed() || reads_changed { for &id in mod_sensitive.iter() { pending.entry(id).or_default(); } @@ -308,8 +323,16 @@ pub(super) fn feed_item_sets( .bonuses .iter() .filter_map(|&(n, spell)| { - spell_desc_text(spell_res, spell, &skill, None, Some(&spell_mods), &global) - .map(|desc| (n, desc)) + spell_desc_text( + spell_res, + spell, + &skill, + None, + &|| gender, + Some(&spell_mods), + &global, + ) + .map(|desc| (n, desc)) }) .collect(), required_skill: row.required_skill, @@ -392,7 +415,7 @@ pub(super) fn feed_item_stats( mut pending: Local>>, mut mod_sensitive: Local>>, mut last_home: Local>>, - mut last_skills: Local>>, + mut last_reads: Local>>, ) { let Some(mut script) = script else { return; @@ -401,10 +424,11 @@ pub(super) fn feed_item_stats( let mod_sensitive = mod_sensitive.get(&script); let last_home = last_home.get(&script); let me = self_q.single().ok(); - // The `$`-tokens' per-level terms follow the player's skills. - let skills = Some(crate::spell::skill_snapshot(me)); - let skills_changed = *last_skills.get(&script) != skills; - *last_skills.get(&script) = skills; + // The `$`-tokens follow the player's skills and gender. + let reads = token_reads(me); + let gender = reads.1; + let reads_changed = last_reads.get(&script).as_ref() != Some(&reads); + *last_reads.get(&script) = Some(reads); // `GetBindLocation()`'s push, here so it and the `$z` token share one name, and ahead of the // pending gate below: the bind point can arrive while the feed idles. let home_area: Option<&str> = home_bind @@ -421,7 +445,7 @@ pub(super) fn feed_item_stats( pending.extend(items.take_fresh()); pending.extend(script.take_item_stat_asks()); - if spell_mods.is_changed() || skills_changed { + if spell_mods.is_changed() || reads_changed { pending.extend(mod_sensitive.iter().copied()); } if pending.is_empty() { @@ -464,6 +488,7 @@ pub(super) fn feed_item_stats( &skill, skill_catalog, home_area, + &|| gender, factions.as_deref().map(|f| f.catalog()), sub_classes.as_deref().map(|s| &s.0), classes.as_deref().map(|c| &c.0), @@ -1692,6 +1717,7 @@ mod tests { this return" ); } + use crate::ui_action::real_spells; use benilla_protocol::messages::ItemSpellEntry; use benilla_ui::script::{ContainerState, UiScript}; use std::collections::HashMap; @@ -1911,19 +1937,7 @@ mod tests { /// line (`0x52da29`-`0x52da31`), and a described one does. #[test] fn an_undescribed_spell_prints_no_trigger_line_on_real_data() { - let data = benilla_formats::wow_data_or_skip!(); - let mut chain = benilla_formats::open_chain(&data).expect("open chain"); - let spells = crate::ui_action::Spells { - catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"), - forms: benilla_formats::load_shapeshift_forms(&mut chain) - .expect("SpellShapeshiftForm.dbc"), - ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"), - cast_times: benilla_formats::load_spell_cast_times(&mut chain) - .expect("SpellCastTimes.dbc"), - durations: benilla_formats::load_spell_durations(&mut chain) - .expect("SpellDuration.dbc"), - radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"), - }; + let Some(spells) = real_spells() else { return }; // No string table: Fireball's description reaches no keyed token. let no_strings = |_: &str| None; @@ -1936,7 +1950,7 @@ mod tests { "spell {id} has a name — which is exactly what must NOT leak into the tooltip" ); assert_eq!( - super::spell_desc_text(Some(&spells), id, &|_| 0, None, None, &no_strings), + super::spell_desc_text(Some(&spells), id, &|_| 0, None, &|| 0, None, &no_strings), None, "spell {id} ({:?}) has no description, so the reference prints no trigger line", d.name @@ -1944,11 +1958,46 @@ mod tests { } // The control: Fireball (133), described, still yields its line. - let fireball = super::spell_desc_text(Some(&spells), 133, &|_| 0, None, None, &no_strings) - .expect("a described spell still yields its line"); + let fireball = + super::spell_desc_text(Some(&spells), 133, &|_| 0, None, &|| 0, None, &no_strings) + .expect("a described spell still yields its line"); assert!( fireball.contains("damage"), "expected the substituted Fireball description, got {fireball:?}" ); } + + /// The trigger text's `$g` branch reads the gender handed in (`0x508214`): Conjure Food 587's + /// "providing the mage and `$ghis:her;` allies" takes "her" on 1, "his" on 0. + #[test] + fn a_trigger_texts_gender_branch_follows_the_player() { + let Some(spells) = real_spells() else { return }; + let no_strings = |_: &str| None; + let line = |gender: u8| { + super::spell_desc_text( + Some(&spells), + 587, + &|_| 0, + None, + &move || gender, + None, + &no_strings, + ) + .expect("a described spell") + }; + assert!(line(1).contains("the mage and her allies"), "{}", line(1)); + assert!(line(0).contains("the mage and his allies"), "{}", line(0)); + } + + /// The item feeds re-substitute a held `$`-text when what the tokens read moves: the gender + /// byte `$g`/`$G` branch on (`0x508214`), and no other byte of `UNIT_FIELD_BYTES_0`. + #[test] + fn the_token_reads_move_with_the_gender_byte_alone() { + let store = |bytes: u32| { + crate::net::ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(36, bytes)])) + }; + let male = super::token_reads(Some(&store(4 | 1 << 8))); + assert_ne!(super::token_reads(Some(&store(4 | 1 << 8 | 1 << 16))), male); + assert_eq!(super::token_reads(Some(&store(1 | 2 << 8 | 1 << 24))), male); + } } diff --git a/crates/benilla-app/src/ui_tooltip/deps_tests.rs b/crates/benilla-app/src/ui_tooltip/deps_tests.rs index af17461b..fe90a933 100644 --- a/crates/benilla-app/src/ui_tooltip/deps_tests.rs +++ b/crates/benilla-app/src/ui_tooltip/deps_tests.rs @@ -14,23 +14,25 @@ use benilla_ui::script::SpellTooltipView; use super::spell_deps::{Changes, Deps, RangeSeen, Reagents, Seen, UnitField}; use super::spell_feed::{build_view, reagent_state, PetInputs, ViewCaster, ViewCtx}; -use super::tests::{real_spells, TestCtx}; +use super::tests::TestCtx; use crate::items::Items; use crate::net::{NetCommands, ObjectStore, Objects}; use crate::spell::usable::{ slot_item_cached, worn_slots_read, SlotItem, EQUIPMENT_MASK, EQUIPMENT_SLOTS, }; use crate::spell::{ModsDiff, SpellModifiers}; -use crate::ui_action::Spells; +use crate::ui_action::{real_spells, Spells}; use crate::ui_items::TestObjects; use benilla_formats::{RangeUnit, UnitMotion}; /// One side of each input group, so that moving an input is an xor: `false` is the empty side (no -/// bind point, form 0, no percentages, default reach, no auto-attack target, both units standing, -/// nothing worn, no skills, zero cells), `true` the populated one. +/// bind point, gender 0, form 0, no percentages, default reach, no auto-attack target, both units +/// standing, nothing worn, no skills, zero cells), `true` the populated one. #[derive(Clone, Copy, Default, PartialEq, Eq, Hash, Debug)] struct Inputs { home: bool, + /// The player's gender byte, `UNIT_FIELD_BYTES_0` byte 2. + gender: bool, form: bool, /// A `Chance` bit each: block, dodge, parry, crit. avoid: u8, @@ -60,6 +62,7 @@ const POKE: u32 = 0x4020_0000; impl Inputs { const ALL: Inputs = Inputs { home: true, + gender: true, form: true, avoid: 0xf, reach: true, @@ -76,6 +79,7 @@ impl Inputs { fn xor(self, o: Inputs) -> Inputs { Inputs { home: self.home ^ o.home, + gender: self.gender ^ o.gender, form: self.form ^ o.form, avoid: self.avoid ^ o.avoid, reach: self.reach ^ o.reach, @@ -103,6 +107,15 @@ impl Inputs { }, )); } + if self.gender { + parts.push(( + "the gender byte".to_string(), + Inputs { + gender: true, + ..alone + }, + )); + } if self.form { parts.push(( "the form".to_string(), @@ -207,6 +220,7 @@ impl Inputs { fn outside(deps: &Deps, layout: &Layout) -> Inputs { Inputs { home: !deps.home, + gender: !deps.gender, form: !deps.form, avoid: !deps.avoidance & 0xf, reach: !deps.range_units, @@ -324,6 +338,9 @@ fn mat(layout: &Layout, inputs: Inputs, p: Params) -> Mat { if inputs.disarm { pairs.push((46, 0x0020_0000)); } + if inputs.gender { + pairs.push((36, 1 << 16)); + } let form = if inputs.form { p.form } else { 0 }; if form != 0 { pairs.push((138, u32::from(form) << 16)); @@ -666,6 +683,19 @@ fn every_view_that_moves_with_an_input_is_requeued_by_that_input() { assert_covered("the bind point", &s); assert!(s.moved >= 1, "no view reads the bind point"); row("home bind ($z)", &s); + let s = sweep( + &mut rig, + empty, + &any, + &own, + &cause(Inputs { + gender: true, + ..empty + }), + ); + assert_covered("the gender byte", &s); + assert!(s.moved >= 50, "only {} views read the gender byte", s.moved); + row("gender byte ($g)", &s); let s = sweep( &mut rig, empty, @@ -1337,7 +1367,7 @@ fn the_modifier_diff_names_the_family_flag_bit_of_a_changed_cell() { } /// A percentage's own bit, a worn slot's own bit, a skill line by its id, each unit field by its -/// own bit and a player appearing, through the diff the feed runs each frame. +/// own bit, the gender byte and a player appearing, through the diff the feed runs each frame. #[test] fn the_seen_diff_names_each_input_it_saw_move() { let mut objs = TestObjects::new(); @@ -1443,6 +1473,14 @@ fn the_seen_diff_names_each_input_it_saw_move() { assert!(c.disarm); let now = player(&[(22, 100), (46, 0x0000_0008)]); assert!(seen(&now).changes_since(&seen(&base)).is_empty()); + // The gender byte, `UNIT_FIELD_BYTES_0` byte 2, and no other byte of the field. + let bytes_0 = |bytes: u32| player(&[(22, 100), (36, bytes)]); + let c = seen(&bytes_0(1 << 16)).changes_since(&seen(&base)); + assert!(c.gender && !c.is_empty()); + let race_class_power = bytes_0(4 | 1 << 8 | 1 << 24); + assert!(seen(&race_class_power) + .changes_since(&seen(&base)) + .is_empty()); // Each unit field on its own bit: level 34, base mana 162, base health 163, ranged attack // time 128; max health 28 and the max pools 29-33 on none, which no cost reads. let unit = [ diff --git a/crates/benilla-app/src/ui_tooltip/spell_deps.rs b/crates/benilla-app/src/ui_tooltip/spell_deps.rs index 9be2e67b..202fd588 100644 --- a/crates/benilla-app/src/ui_tooltip/spell_deps.rs +++ b/crates/benilla-app/src/ui_tooltip/spell_deps.rs @@ -109,6 +109,8 @@ impl UnitFields { pub(super) struct Deps { /// A `$z` expanded: the bind point. pub(super) home: bool, + /// A `$g`/`$G` expanded: the branch read the active player's gender byte (`0x508214`). + pub(super) gender: bool, /// The required-form test read the shapeshift form. pub(super) form: bool, /// The range cell's melee arm read the caster's and the auto-attack target's combat reach and @@ -169,6 +171,7 @@ impl Deps { pub(super) fn hits(&self, changes: &Changes) -> bool { changes.everything || (changes.home && self.home) + || (changes.gender && self.gender) || (changes.form && self.form) || (changes.range_units && self.range_units) || changes.avoidance & self.avoidance != 0 @@ -193,6 +196,7 @@ pub(super) struct Changes { /// An input no view can be ruled out of: every view is due. pub(super) everything: bool, pub(super) home: bool, + pub(super) gender: bool, pub(super) form: bool, pub(super) range_units: bool, /// A [`Chance`] bit each. @@ -212,6 +216,7 @@ impl Changes { pub(super) fn is_empty(&self) -> bool { !self.everything && !self.home + && !self.gender && !self.form && !self.range_units && self.avoidance == 0 @@ -274,6 +279,8 @@ impl RangeSeen { pub(super) struct Seen { /// The bind point `$z` names. home: Option, + /// The gender byte `$g`/`$G` branch on (`0x508214`), 0 without a player as the views read it. + gender: u8, /// The form the required-form line's colour follows (`0x52f1e3`). form: u8, /// The skills the descriptions' per-level terms scale by ([`crate::spell::skill_snapshot`]). @@ -301,6 +308,7 @@ impl Seen { ) -> Self { Seen { home: home.map(str::to_string), + gender: player.and_then(|s| s.0.unit_gender()).unwrap_or(0), form: player.map_or(0, |s| s.0.unit_shapeshift_form()), skills: crate::spell::skill_snapshot(player), worn: player @@ -317,6 +325,7 @@ impl Seen { pub(super) fn changes_since(&self, prev: &Seen) -> Changes { let mut changes = Changes { home: self.home != prev.home, + gender: self.gender != prev.gender, form: self.form != prev.form, range_units: self.range_units != prev.range_units, disarm: self.disarmed != prev.disarmed, diff --git a/crates/benilla-app/src/ui_tooltip/spell_feed.rs b/crates/benilla-app/src/ui_tooltip/spell_feed.rs index d84579b0..d19c6125 100644 --- a/crates/benilla-app/src/ui_tooltip/spell_feed.rs +++ b/crates/benilla-app/src/ui_tooltip/spell_feed.rs @@ -112,6 +112,11 @@ pub(super) fn build_view( deps.borrow_mut().home = true; home }; + // `$g`/`$G` read the active player's gender byte (`0x508214`), a pet view's too. + let gender = || { + deps.borrow_mut().gender = true; + vctx.store.and_then(|s| s.0.unit_gender()).unwrap_or(0) + }; // `0x6e3130` for this spell: the level the cost's and the cast time's per-level terms read. let pet_level = |pet| d.skill_level(ViewCaster::pet_skill_value(pet)); let ctx = benilla_formats::TokenContext { @@ -122,6 +127,7 @@ pub(super) fn build_view( lookup: &lookup, mods: Some(vctx.spell_mods), unmodified_points: false, + gender: &gender, home_area: &home_area, global: vctx.get, printf: &crate::ui_script::token_printf, diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index d33e6829..41906606 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -4,7 +4,7 @@ use super::spell_feed::{build_view, feed_spell_tooltips, ViewCaster, ViewCtx}; use super::*; use crate::items::Items; use crate::net::{NetCommands, ObjectStore, Objects, SelfPlayer}; -use crate::ui_action::{PlayerActions, Spells}; +use crate::ui_action::{real_spells, PlayerActions, Spells}; /// The view alone, for the cell tests; the feed keeps what it read beside it. fn spell_tooltip_view( @@ -105,20 +105,6 @@ impl TestCtx { } } -/// The 5875 spell data the view builder reads; `None` skips where the install is absent. -pub(super) fn real_spells() -> Option { - let data = benilla_formats::wow_data_or_skip!(None); - let mut chain = benilla_formats::open_chain(&data).expect("open chain"); - Some(Spells { - catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"), - forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("forms"), - ranges: benilla_formats::load_spell_ranges(&mut chain).expect("ranges"), - cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("cast times"), - durations: benilla_formats::load_spell_durations(&mut chain).expect("durations"), - radii: benilla_formats::load_spell_radii(&mut chain).expect("radii"), - }) -} - /// A unit's fields: level (34) and base mana (162). fn unit(level: u32, base_mana: u32) -> benilla_protocol::ObjectFields { benilla_protocol::ObjectFields::from_pairs(&[(22u16, 100u32), (34, level), (162, base_mana)]) @@ -220,6 +206,51 @@ fn a_pet_view_costs_seduction_from_the_pets_base_mana() { assert_eq!(v.cost, None); } +/// The `$g`/`$G` branch (`0x508180`) reads the active player's `UNIT_FIELD_BYTES_0` byte 2 +/// (`0x508214`): the first form on 0, the second on anything else. A female player's Conjure Food +/// and Water (587, 5504) tooltips read "her allies" and Hellfire (1949) "to herself". +#[test] +fn a_female_players_spell_tooltips_take_the_second_gender_form() { + let Some(spells) = real_spells() else { return }; + let mut t = TestCtx::new(); + let mut objs = no_objects(); + let objects = objs.get(); + // A level 60 player whose `UNIT_FIELD_BYTES_0` (36) holds `gender` in byte 2. + let player = |gender: u32| { + let mut fields = unit(60, 1373); + fields.merge(benilla_protocol::ObjectFields::from_pairs(&[( + 36, + gender << 16, + )])); + ObjectStore(fields) + }; + let (her, him) = (player(1), player(0)); + let mut text = |id: u32, store: &ObjectStore| { + spell_tooltip_view(id, &spells, &mut t.ctx_for(&objects, 0, None, Some(store))) + .unwrap_or_else(|| panic!("spell {id} view")) + .description + }; + for id in [587u32, 5504] { + let female = text(id, &her); + assert!(female.contains("the mage and her allies"), "{id}: {female}"); + let male = text(id, &him); + assert!(male.contains("the mage and his allies"), "{id}: {male}"); + } + let hellfire = text(1949, &her); + assert!(hellfire.contains("Fire damage to herself"), "{hellfire}"); + // The branch looks the active player up (`0x508189`-`0x5081a4`): a pet view reads the + // player's gender, never the pet's. + let pet = unit(20, 0); + let pets = spell_tooltip_view( + 587, + &spells, + &mut t.pet_ctx(&objects, Some(&her), Some(&pet)), + ) + .expect("Conjure Food view") + .description; + assert!(pets.contains("the mage and her allies"), "{pets}"); +} + /// An object index with nothing streamed: no worn item, and every reagent count 0. fn no_objects() -> crate::ui_items::TestObjects { crate::ui_items::TestObjects::new() diff --git a/crates/benilla-formats/src/spells/tokens.rs b/crates/benilla-formats/src/spells/tokens.rs index a86b4945..44af6d09 100644 --- a/crates/benilla-formats/src/spells/tokens.rs +++ b/crates/benilla-formats/src/spells/tokens.rs @@ -1,8 +1,8 @@ //! The spell-description `$`-token engine the 1.12 client runs over `Spell.dbc` description and //! aura text (`0x5075f0` → `0x507710`), effect values from `GetEffectPoints 0x6e3800`. Effect -//! point values print unsigned, as the client's do. `$g` takes the first form without gender -//! input. `$c` and `$p` (`507dde`, `507ded`), which no shipped text uses, and unknown or -//! unresolved tokens stay raw. +//! point values print unsigned, as the client's do. `$g` and `$G` branch on the active player's +//! gender ([`TokenContext::gender`]). `$c` and `$p` (`507dde`, `507ded`), which no shipped text +//! uses, and unknown or unresolved tokens stay raw. use super::soft_float; use super::{SpellDisplay, SpellDurationCatalog, SpellRadiusCatalog, SpellRangeCatalog}; @@ -40,6 +40,9 @@ pub struct TokenContext<'a> { /// points skip their modifiers (`6e3925`), and so does the duration of `$d` and `$o` /// (`0x6ea000`'s flag, pushed at `507ccd` and `507917`). pub unmodified_points: bool, + /// The active player's gender, `UNIT_FIELD_BYTES_0` byte 2 (`0x508214`), which `$g`/`$G` + /// branch on. Asked only where a branch expands. + pub gender: &'a dyn Fn() -> u8, /// The `$z` token: the home-bind area's name, from `SMSG_BINDPOINTUPDATE`'s area id through /// `AreaTable.dbc`; `None` leaves the token raw. Asked only where a `$z` expands, so a caller /// can tell which texts read the bind point. @@ -416,6 +419,12 @@ fn scale_prefix(text: &str, at: usize) -> Option<(f32, usize)> { Some((scale, semi + 1)) } +/// `0x5081c8`-`0x5081d7` and `0x50822a`-`0x50823b`: a `$g`/`$G` branch arm's leading spaces are +/// skipped, and `0x508267`-`0x508280` trims its trailing spaces — both forms alike. +fn trim_spaces(arm: &str) -> &str { + arm.trim_matches(' ') +} + /// The CRT's `atoi`: leading whitespace, an optional sign, then decimal digits up to the first /// other character; nothing parsed is 0. fn atoi(bytes: &[u8]) -> i32 { @@ -494,7 +503,8 @@ pub fn substitute(text: &str, spell: &SpellDisplay, ctx: &TokenContext) -> Strin b } } - _ => a, // $g: the first form, as there is no gender input + // `$g`/`$G` (`0x508180`): the first form on gender 0, else the second. + _ => trim_spaces(if (ctx.gender)() == 0 { a } else { b }), }; out.push_str(pick); i = i + 1 + end + 1; @@ -671,6 +681,7 @@ mod tests { lookup, mods: None, unmodified_points: false, + gender: &|| 0, global: &global, printf: &printf, } @@ -737,6 +748,50 @@ mod tests { assert_eq!(d(8), ""); } + /// `0x508180`'s branch: the first form on gender 0, the second on anything else, the chosen + /// form trimmed of spaces at both ends (`0x5081c8`-`0x5081d7`, `0x50822a`-`0x50823b`, + /// `0x508267`-`0x508280`). + #[test] + fn gender_takes_the_players_side_of_the_branch() { + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let d = SpellDisplay::default(); + let gender = std::cell::Cell::new(0u8); + let c = TokenContext { + gender: &|| gender.get(), + ..ctx(&durations, &radii, &none_lookup) + }; + + assert_eq!(substitute("$ghis:her;", &d, &c), "his"); + gender.set(1); + assert_eq!(substitute("$ghis:her;", &d, &c), "her"); + gender.set(2); + assert_eq!(substitute("$ghis:her;", &d, &c), "her"); + // `G` is the same branch (`0x508138`'s table maps both spellings to `0x50786d`). + assert_eq!(substitute("$GLad:LAss;", &d, &c), "LAss"); + // The chosen form's spaces go from both ends. + gender.set(0); + assert_eq!(substitute("$g his : her ;", &d, &c), "his"); + gender.set(1); + assert_eq!(substitute("$g his : her ;", &d, &c), "her"); + // An empty chosen arm consumes the token. + gender.set(0); + assert_eq!(substitute("$g:male;", &d, &c), ""); + gender.set(1); + assert_eq!(substitute("$g:male;", &d, &c), "male"); + assert_eq!(substitute("$g female:;", &d, &c), ""); + // The shipped wording: Conjure Food 587 and Hellfire 1949. + assert_eq!( + substitute("providing the mage and $ghis:her; allies", &d, &c), + "providing the mage and her allies" + ); + gender.set(0); + assert_eq!( + substitute("damage to $ghimself:herself;", &d, &c), + "damage to himself" + ); + } + #[test] fn duration_modifier_updates_description_and_overtime_total() { let mut durations = SpellDurationCatalog::default(); From 4a389add474e42d4c69bdff4357dbc8077563dd2 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sun, 4 Oct 2026 05:01:50 +0200 Subject: [PATCH 03/21] fix: ignore a loot right-click while moving, as 1.12.1 does (#790) - A loot right-click while moving does nothing, as in 1.12.1 (#760) - loot gate: name the emitter's drain and write the landing deviation Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Sam --- crates/benilla-app/src/target/click.rs | 244 +++++++++++++++++++------ 1 file changed, 193 insertions(+), 51 deletions(-) diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index 9c8ecef6..e9aade89 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -224,6 +224,7 @@ pub(crate) struct Dispatch<'w, 's> { service: ServiceArms<'w>, feedback: Feedback<'w>, auto: crate::player::AutoMove<'w, 's>, + player: Res<'w, crate::player::Player>, } /// The Loot walk's stop radius: the arm (`0x611130`) takes the square root (`0x6111ab`) of what @@ -246,6 +247,11 @@ impl Dispatch<'_, '_> { self.stores.get(me).ok().map(|(s, _)| s) } + /// Any `0x20ff` bit in our last streamed movement word: a direction, turn, pitch or fall. + fn moving(&self) -> bool { + self.player.move_flags() & crate::creature_anim::move_flags::INTEGRATED != 0 + } + /// The reference's one mounted predicate, the player's `UNIT_FIELD_MOUNTDISPLAYID`. fn mounted(&self) -> bool { self.self_store() @@ -449,10 +455,13 @@ impl Dispatch<'_, '_> { } } - /// `0x5df130`: `CMSG_LOOT` at a lootable corpse, walked to first when `walk` and beyond its - /// 5 yd (`0x5df1c3`), stopping at [`loot_stop`] of 5. A walk that does not start falls through - /// to the send. + /// `0x5df130`: `CMSG_LOOT` at a lootable corpse, never while [`Self::moving`] (`0x5df173`), + /// walked to first when `walk` and beyond its 5 yd (`0x5df1c3`), stopping at [`loot_stop`] of + /// 5. A walk that does not start falls through to the send. fn loot_corpse(&mut self, entity: Entity, guid: u64, walk: bool) { + if self.moving() { + return; + } if walk && self .dist_sq(entity) @@ -474,10 +483,10 @@ impl Dispatch<'_, '_> { } /// `0x5df2a0`: `CMSG_LOOT` at a dead unit, which `can_loot` (`CanLootNow 0x5ec110`) must - /// allow, walked to first when `walk` and beyond melee reach. A walk that does not start falls - /// through to the send. + /// allow, never while [`Self::moving`] (`0x5df2e9`), walked to first when `walk` and beyond + /// melee reach. A walk that does not start falls through to the send. fn loot_unit(&mut self, entity: Entity, guid: u64, can_loot: bool, walk: bool) { - if !can_loot { + if !can_loot || self.moving() { return; } let reach = self.melee_reach(entity); @@ -987,6 +996,14 @@ pub(super) fn act_on_arrival( ) { use crate::player::{ApproachVerb, Subject}; use benilla_protocol::EntityKind; + // Owed while any `0x20ff` bit is set: the reference drains it from the movement emitter + // `0x60e0a0` only at a stop, strafe stop, root or unroot ack that leaves none (`0x60e352`), never + // at a landing. Deviation: it runs on the first frame with none, so an arrival that sends no stop + // (mid-air, rooted, under a held turn key) acts at once or on landing or release, where the + // reference waits for the next ground stop and can act long after, wherever the player then is. + if dispatch.moving() { + return; + } let Some((verb, guid)) = dispatch.auto.approach.arrived.take() else { return; }; @@ -2551,33 +2568,8 @@ mod tests { /// `0x5df2a0`: beyond melee reach the walk comes first and nothing is looted yet. #[test] fn a_far_body_to_loot_is_walked_to_before_the_loot() { - const BODY: u64 = 0xB0D7; - const F_DYNAMIC_FLAGS: u16 = 143; - let (mut world, _vendor, rx) = walking_world(Vec3::new(14.0, 0.0, 0.0), true); - let body = world - .spawn(( - Guid(BODY), - store(&[(F_HEALTH, 0), (F_MAXHEALTH, 100), (F_DYNAMIC_FLAGS, 0x1)]), - Transform::from_xyz(0.0, 0.0, -12.0), - )) - .id(); - *world.resource_mut::() = PressPick { - hovered: Hovered { - target: Some(body), - guid: Some(BODY), - distance: 12.0, - ..Hovered::default() - }, - cursor: WorldCursor { - kind: cursor_mode::CursorKind::Pickup, - unable: false, - }, - ..PressPick::default() - }; - world - .resource_mut::>() - .write(WorldRightClick); - world.run_system_once(act_on_right_click).unwrap(); + let (mut world, body, corpse, rx) = loot_world(12.0, 0, true); + right_click_loot(&mut world, loot_hover(false, body, corpse, 12.0)); assert!(world.resource::().active()); assert!(!rx .try_iter() @@ -2589,24 +2581,8 @@ mod tests { /// for a unit. #[test] fn a_loot_walk_stops_at_the_square_root_of_the_melee_reach() { - const BODY: u64 = 0xB0D7; - const CORPSE: u64 = 0xC0D5; const F_DYNAMIC_FLAGS: u16 = 143; const F_COMBAT_REACH: u16 = 130; - let loot_click = |world: &mut World, hovered: Hovered| { - *world.resource_mut::() = PressPick { - hovered, - cursor: WorldCursor { - kind: cursor_mode::CursorKind::Pickup, - unable: false, - }, - ..PressPick::default() - }; - world - .resource_mut::>() - .write(WorldRightClick); - world.run_system_once(act_on_right_click).unwrap(); - }; // A unit of reach 4 against our default 1.5: 4 + 1.5 + 1.3333 = 6.8333. let (mut world, _vendor, _rx) = walking_world(Vec3::new(14.0, 0.0, 0.0), true); let body = world @@ -2621,7 +2597,7 @@ mod tests { Transform::from_xyz(0.0, 0.0, -12.0), )) .id(); - loot_click( + right_click_loot( &mut world, Hovered { target: Some(body), @@ -2647,7 +2623,7 @@ mod tests { Transform::from_xyz(0.0, 0.0, -12.0), )) .id(); - loot_click( + right_click_loot( &mut world, Hovered { corpse: Some(corpse), @@ -3002,4 +2978,170 @@ mod tests { ); } } + + const BODY: u64 = 0xB0D7; + const CORPSE: u64 = 0xC0D5; + + /// A dead lootable unit and a lootable corpse `at` yd off and the vendor in reach, with our last + /// streamed movement word `flags` and Click to Move set as `walk`. + fn loot_world( + at: f32, + flags: u32, + walk: bool, + ) -> ( + World, + Entity, + Entity, + crossbeam_channel::Receiver, + ) { + const F_DYNAMIC_FLAGS: u16 = 143; + let (mut world, _vendor, rx) = walking_world(Vec3::new(2.5, 0.0, 0.0), walk); + world.insert_resource(crate::player::Player::with_move_flags(flags)); + let body = world + .spawn(( + Guid(BODY), + store(&[(F_HEALTH, 0), (F_MAXHEALTH, 100), (F_DYNAMIC_FLAGS, 0x1)]), + Transform::from_xyz(0.0, 0.0, -at), + crate::net::NetEntity { + kind: benilla_protocol::EntityKind::Unit, + display_id: None, + scale: 1.0, + }, + )) + .id(); + world + .resource_mut::() + .0 + .insert(BODY, body); + let corpse = world + .spawn(( + Guid(CORPSE), + store(&[(benilla_protocol::field::FIELD_CORPSE_DYNAMIC_FLAGS, 0x1)]), + Transform::from_xyz(0.0, 0.0, at), + )) + .id(); + (world, body, corpse, rx) + } + + fn right_click_loot(world: &mut World, hovered: Hovered) { + *world.resource_mut::() = PressPick { + hovered, + cursor: WorldCursor { + kind: cursor_mode::CursorKind::Pickup, + unable: false, + }, + ..PressPick::default() + }; + world + .resource_mut::>() + .write(WorldRightClick); + world.run_system_once(act_on_right_click).unwrap(); + } + + /// The press over the corpse when `on_corpse`, else over the dead unit. + fn loot_hover(on_corpse: bool, body: Entity, corpse: Entity, at: f32) -> Hovered { + let (unit, object) = if on_corpse { + (None, Some(corpse)) + } else { + (Some(body), None) + }; + Hovered { + target: unit, + guid: unit.map(|_| BODY), + corpse: object, + corpse_guid: object.map(|_| CORPSE), + distance: at, + ..Hovered::default() + } + } + + /// `0x5df2e9` and `0x5df173`: both loot senders return silently on any `0x20ff` bit, a + /// direction, turn, pitch or fall; swimming and walk mode are outside the mask. + #[test] + fn a_loot_click_while_moving_sends_nothing() { + use crate::creature_anim::move_flags as f; + for (flags, loots) in [ + (0, true), + (f::FORWARD, false), + (f::BACKWARD, false), + (f::STRAFE_LEFT, false), + (f::TURN_RIGHT, false), + (f::FALLING, false), + (f::FORWARD | f::FALLING, false), + (f::SWIMMING, true), + (f::WALK_MODE, true), + ] { + for on_corpse in [false, true] { + let (mut world, body, corpse, rx) = loot_world(2.0, flags, false); + right_click_loot(&mut world, loot_hover(on_corpse, body, corpse, 2.0)); + let looted = rx + .try_iter() + .any(|c| matches!(c, ClientCommand::Loot { .. })); + let latched = world.resource::().0.is_some(); + assert_eq!( + (looted, latched), + (loots, loots), + "flags {flags:#x}, corpse {on_corpse}" + ); + } + } + } + + /// The movement test comes before the walk (`0x5df2f6`, `0x5df180`): a far body or corpse + /// clicked on the run starts no Click to Move walk. + #[test] + fn a_far_loot_click_while_moving_starts_no_walk() { + use crate::creature_anim::move_flags as f; + for (flags, walks) in [(0, true), (f::FORWARD, false), (f::FALLING, false)] { + for on_corpse in [false, true] { + let (mut world, body, corpse, rx) = loot_world(12.0, flags, true); + right_click_loot(&mut world, loot_hover(on_corpse, body, corpse, 12.0)); + assert_eq!( + world.resource::().active(), + walks, + "flags {flags:#x}, corpse {on_corpse}" + ); + assert!(!rx + .try_iter() + .any(|c| matches!(c, ClientCommand::Loot { .. }))); + } + } + } + + /// `0x60e352`: the owed verb (`0x60fa20`) runs only once no `0x20ff` bit is left, and the cell + /// stays armed until then. + #[test] + fn a_verb_owed_on_arrival_waits_for_a_word_with_no_move_bit() { + use crate::creature_anim::move_flags as f; + use crate::player::ApproachVerb; + for (verb, guid) in [(ApproachVerb::Loot, BODY), (ApproachVerb::Talk, VENDOR)] { + let (mut world, _body, _corpse, rx) = loot_world(2.0, f::FALLING, true); + world.resource_mut::().arrived = Some((verb, guid)); + world.run_system_once(act_on_arrival).unwrap(); + assert_eq!(rx.try_iter().count(), 0, "{verb:?} mid-air"); + assert_eq!( + world.resource::().arrived, + Some((verb, guid)), + "{verb:?} stays owed" + ); + world.insert_resource(crate::player::Player::with_move_flags(0)); + world.run_system_once(act_on_arrival).unwrap(); + let sent: Vec = rx.try_iter().collect(); + assert!( + match verb { + ApproachVerb::Loot => + matches!(sent.as_slice(), [ClientCommand::Loot { guid: BODY }]), + _ => matches!( + sent.as_slice(), + [ClientCommand::ListInventory { guid: VENDOR }] + ), + }, + "{verb:?} landed: {sent:?}" + ); + assert!(world + .resource::() + .arrived + .is_none()); + } + } } From aa103d5a658b5eb9ca4f34aa1b8990e7343cfd76 Mon Sep 17 00:00:00 2001 From: elipp <1861512+elipp@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:18:24 +0300 Subject: [PATCH 04/21] fix: level-up gains read signed, so a loss prints -2, as 1.12.1 does (#791) - Change `LevelUpInfo` to signed to match vanilla behavior - Add parse test for negative values in SMSG_LEVELUP_INFO, update comments - grammar - level-up gains: cite the %d fire and vmangos's wrapped deltas; pin the reported line Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Sam --- crates/benilla-app/src/ui_chat/tests.rs | 68 +++++++++---------- crates/benilla-app/src/ui_unit.rs | 2 +- .../src/messages/progression.rs | 24 ++++--- crates/benilla-protocol/tests/spells.rs | 32 ++++++++- 4 files changed, 77 insertions(+), 49 deletions(-) diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs index 27c39408..9d5e22f4 100644 --- a/crates/benilla-app/src/ui_chat/tests.rs +++ b/crates/benilla-app/src/ui_chat/tests.rs @@ -2288,31 +2288,9 @@ fn level_up_gains_are_matched_by_level_and_a_miss_is_not_an_absence() { ); } -/// Stock `ChatFrame_OnEvent` prints the whole level-up block from `PLAYER_LEVEL_UP` -/// (`ChatFrame.lua:1283-1323`); the app composes none of it. -#[test] -fn the_ding_block_is_printed_once() { - let _data = benilla_formats::wow_data_or_skip!(); - use benilla_protocol::messages::LevelUpInfo; - let mut s = chat_vm(); - let mut log = super::ChatLog::default(); - - let info = LevelUpInfo { - level: 10, - health: 22, - powers: [15, 0, 0, 0, 0], - stats: [1, 0, 0, 0, 0], - }; - // The packet's apply parks the gains and prints nothing. - log.push_level_up_gains(&info, 1); - let before = lines_in_window(&s); - assert_eq!( - lines_in_window(&s) - before, - 0, - "the app composes no ding line of its own" - ); - - // Tap the window's `AddMessage` to read the block itself. +/// Fires `PLAYER_LEVEL_UP` with the nine reference args and returns every line the stock handler +/// added to `ChatFrame1`. +fn ding_lines(s: &mut benilla_ui::script::UiScript, args: [i64; 9]) -> Vec { s.run( r#" DingLines = {} @@ -2325,23 +2303,27 @@ fn the_ding_block_is_printed_once() { ) .unwrap(); - // The event `ui_unit` fires, with the reference's nine arguments. - let args: Vec = [10i64, 22, 15, 1, 1, 0, 0, 0, 0] + let args = args .into_iter() .map(benilla_ui::script::ScriptValue::Int) .collect(); s.fire_event("PLAYER_LEVEL_UP", args); assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors()); - assert_eq!( - lines_in_window(&s) - before, - 4, - "LEVEL_UP, the health/mana pair, CHAR_POINTS, and one STAT — once each" - ); - // The singular `LEVEL_UP_CHAR_POINTS` (`GetText`'s plural pick) and one `LEVEL_UP_STAT`. - let lines: Vec = (1..=4) + let n: usize = s.eval("return table.getn(DingLines)").unwrap(); + (1..=n) .map(|i| s.eval::(&format!("return DingLines[{i}]")).unwrap()) - .collect(); + .collect() +} + +/// Stock `ChatFrame_OnEvent` prints the whole level-up block from `PLAYER_LEVEL_UP` +/// (`ChatFrame.lua:1283-1323`), once; the app composes none of it. The singular +/// `LEVEL_UP_CHAR_POINTS` is `GetText`'s plural pick. +#[test] +fn the_ding_block_is_printed_once() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = chat_vm(); + let lines = ding_lines(&mut s, [10, 22, 15, 1, 1, 0, 0, 0, 0]); assert_eq!( lines, [ @@ -2353,6 +2335,22 @@ fn the_ding_block_is_printed_once() { ); } +/// A loss reaches stock signed, as the reference's `%d` fire passes it (`0x5e413e`): the health +/// line prints it, and a lost stat prints no line (`if ( argN > 0 )`, `ChatFrame.lua:1302-1321`). +#[test] +fn a_negative_gain_prints_signed() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = chat_vm(); + let lines = ding_lines(&mut s, [2, -2, 5, 0, -1, 0, 0, 0, 0]); + assert_eq!( + lines, + [ + "Congratulations, you have reached level 2!", + "You have gained -2 hit points and 5 mana.", + ] + ); +} + /// On `CHARACTER_POINTS_CHANGED` with `arg2 > 0`, stock `ChatFrame_OnEvent` prints /// `LEVEL_UP_SKILL_POINTS` (`ChatFrame.lua:1324-1334`). #[test] diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 4fdaf27c..df12b421 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -1444,7 +1444,7 @@ fn feed_units( // `ChatFrame.lua:1283-1320` reads them; missing gains are zeros, since stock's // `if ( argN > 0 )` raises on nil. let (info, talent_points) = sink.chat.take_level_up_gains(level).unzip(); - let gain = |f: fn(&benilla_protocol::messages::LevelUpInfo) -> u32| { + let gain = |f: fn(&benilla_protocol::messages::LevelUpInfo) -> i32| { ScriptValue::Int(i64::from(info.as_ref().map_or(0, f))) }; script.fire_event( diff --git a/crates/benilla-protocol/src/messages/progression.rs b/crates/benilla-protocol/src/messages/progression.rs index 8ba7843c..82da9e0f 100644 --- a/crates/benilla-protocol/src/messages/progression.rs +++ b/crates/benilla-protocol/src/messages/progression.rs @@ -2,7 +2,7 @@ use std::io::{self, Read}; -use crate::wire::{read_f32_le, read_u32_le, read_u64_le, read_u8}; +use crate::wire::{read_f32_le, read_i32_le, read_u32_le, read_u64_le, read_u8}; /// `SMSG_LOG_XPGAIN`: an XP award; `victim` is 0 for non-kill XP (vmangos `Misc.cpp:512-522`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -92,27 +92,29 @@ pub fn talent_wipe_confirm(trainer_guid: u64) -> Vec { pub struct LevelUpInfo { /// The level just reached. pub level: u32, - /// Hit points gained. - pub health: u32, + /// Hit points gained, negative on a loss. + pub health: i32, /// Power gains: mana, rage, focus, energy, happiness. The server fills only mana, which is /// the reference chat line's mana argument. - pub powers: [u32; 5], + pub powers: [i32; 5], /// Stat gains in `SPELL_STAT0..4` order (str, agi, stam, int, spirit), one chat line per /// positive entry. - pub stats: [u32; 5], + pub stats: [i32; 5], } -/// Read `SMSG_LEVELUP_INFO`: `u32` level, health, 5 powers, 5 stats (`Misc.cpp:524-532`). +/// Read `SMSG_LEVELUP_INFO`: `u32` level, `i32` health, 5 powers, 5 stats (`Misc.cpp:524-532`). +/// The reference fires the gains as `%d` (`0x5e413e`), so a loss the server wraps into its `u32` +/// (vmangos `Player.cpp:3203-3215`) reads negative. pub(super) fn read_level_up_info(r: &mut impl Read) -> io::Result { let level = read_u32_le(r)?; - let health = read_u32_le(r)?; - let mut powers = [0u32; 5]; + let health = read_i32_le(r)?; + let mut powers = [0; 5]; for p in &mut powers { - *p = read_u32_le(r)?; + *p = read_i32_le(r)?; } - let mut stats = [0u32; 5]; + let mut stats = [0; 5]; for s in &mut stats { - *s = read_u32_le(r)?; + *s = read_i32_le(r)?; } Ok(LevelUpInfo { level, diff --git a/crates/benilla-protocol/tests/spells.rs b/crates/benilla-protocol/tests/spells.rs index 232d4a1f..2843eff8 100644 --- a/crates/benilla-protocol/tests/spells.rs +++ b/crates/benilla-protocol/tests/spells.rs @@ -1003,8 +1003,8 @@ fn combat_log_wire_golden() { other => panic!("exploration xp event, got {other:?}"), } - // SMSG_LEVELUP_INFO: twelve u32, level, healthGain, powerGains[5] (mana..happiness) and - // statGains[5] (str..spirit), no guid (vmangos `Misc.cpp:524-532`). + // SMSG_LEVELUP_INFO: u32 level, then the i32 gains, healthGain, powerGains[5] (mana..happiness) + // and statGains[5] (str..spirit), no guid (vmangos `Misc.cpp:524-532`). let mut body = Vec::new(); for v in [7u32, 22, 15, 0, 0, 0, 0, 1, 0, 1, 2, 1] { body.extend_from_slice(&v.to_le_bytes()); @@ -1029,6 +1029,34 @@ fn combat_log_wire_golden() { SessionEvent::LevelUp(l) => assert_eq!((l.level, l.health, l.powers[0]), (7, 22, 15)), other => panic!("level up event, got {other:?}"), } + + // A loss the server wraps into its u32 reads negative, as the reference's `%d` fire prints it. + let mut body = Vec::new(); + for v in [ + 7u32, 0xFFFFFFFE, 0xFFFFFFFD, 0, 0, 0, 0, 0xFFFFFFFE, 0, 0xFFFFFFFB, 0xFFFFFFFA, 0xFFFFFFF9, + ] { + body.extend_from_slice(&v.to_le_bytes()); + } + assert_eq!(body.len(), 48); + let packet = messages::parse_server(messages::opcode::SMSG_LEVELUP_INFO, &body).unwrap(); + match &packet { + ServerPacket::LevelUp(l) => { + assert_eq!( + l, + &LevelUpInfo { + level: 7, + health: -2, + powers: [-3, 0, 0, 0, 0], + stats: [-2, 0, -5, -6, -7], + } + ); + } + other => panic!("level up, got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::LevelUp(l) => assert_eq!((l.level, l.health, l.powers[0]), (7, -2, -3)), + other => panic!("level up event, got {other:?}"), + } } /// Raw caster guid, u32 spellId, u32 count, raw guids (vmangos `Spell.cpp:4974-4997`); in the From 622e1c3e7a683247c5463d4ceca07948e71d6d8d Mon Sep 17 00:00:00 2001 From: goran2711 Date: Sun, 4 Oct 2026 05:04:32 +0100 Subject: [PATCH 05/21] fix: overhead names carry the player's PvP title, as 1.12.1 does (#792) - capture: a name-rank scenario shoots a ranked player's overhead name line - Overhead names carry the player's PvP title under UnitNamePlayerPVPTitle, as 1.12.1 does (#550) - review: bundle the rank-title inputs; doc the snapshot and rank-19 limits - nameplates: read rank strings per VM; test the composed name line - pvp: correct the rank-title docs; convert the credit team without a cast - Fix the titles row comment; name-rank reuses WOLF_POS, cites :900 - rank strings: the honor feed reads them off the VM; nameplates only reads the value Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Sam --- crates/benilla-app/assets/ui/OptionsFrame.xml | 30 +- crates/benilla-app/src/capture/fixtures.rs | 44 ++ crates/benilla-app/src/capture/scenarios.rs | 14 + crates/benilla-app/src/cvars/mod.rs | 11 +- crates/benilla-app/src/cvars/table.rs | 6 +- crates/benilla-app/src/nameplates.rs | 458 ++++++++++++++---- crates/benilla-app/src/ui_honor.rs | 105 +++- .../src/ui_script/options_tests.rs | 99 ++-- crates/benilla-ui/src/script/mod.rs | 2 +- crates/benilla-ui/src/script/pvp/mod.rs | 88 +++- crates/benilla-ui/src/script/pvp/tests.rs | 82 ++++ 11 files changed, 793 insertions(+), 146 deletions(-) diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index ccc06b66..7f435f33 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -453,10 +453,11 @@ "RowWeatherDensity", "RowDisplayMode", "RowVerticalSync", "RowRenderScale", }, - -- Guild Names is a child of Player Names (UIOptionsFrame.lua:703-709). + -- Guild Names and Player Titles are children of Player Names + -- (UIOptionsFrame.lua:703-709). Nameplates = { "RowEnemyPlates", "RowFriendlyPlates", - "RowPlayerNames", "RowGuildNames", "RowNpcNames", "RowOwnName", + "RowPlayerNames", "RowGuildNames", "RowPlayerTitles", "RowNpcNames", "RowOwnName", }, -- 1.12's Display box by column, then Help; target-of-target sits under Status Text. Interface = { @@ -849,9 +850,13 @@ return end if key == "Nameplates" then - -- UIOptionsFrame.lua:703-709: Player Names off greys Player Guild Names. - BenillaOptionsRow_SetEnabled(BenillaOptionsFrameContainerBodyNameplatesRowGuildNames, - GetCVar("UnitNamePlayer") ~= "0") + -- UIOptionsFrame.lua:703-709: Player Names off greys Player Guild Names and + -- Player Titles. + local on = GetCVar("UnitNamePlayer") ~= "0" + BenillaOptionsRow_SetEnabled( + BenillaOptionsFrameContainerBodyNameplatesRowGuildNames, on) + BenillaOptionsRow_SetEnabled( + BenillaOptionsFrameContainerBodyNameplatesRowPlayerTitles, on) return end if key == "Interface" then @@ -2638,11 +2643,24 @@ this.parentRow = "RowPlayerNames" - + + + + BenillaOptionsRow_OnLoad(this, "UnitNamePlayerPVPTitle", "Player Titles", + "OPTION_TOOLTIP_SHOW_PLAYER_TITLES", 1) + this.parentRow = "RowPlayerNames" + + + + + + + BenillaOptionsRow_OnLoad(this, "UnitNameNPC", "NPC Names", "OPTION_TOOLTIP_SHOW_NPC_NAMES") diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index aad4bb82..dfd42c07 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -1297,6 +1297,50 @@ pub(super) fn seed_ui_fixture( // A plated unit draws no floating name, so enemy plates go off. set_enemy_plates(script.as_deref(), false); } + UiFixture::NameRank => { + use benilla_protocol::messages::ObjectFields; + // The self player at the eye, as `name-water`; the subject is the other player's name. + const SELF_GUID: u64 = 0x51; + names.insert_player(SELF_GUID, "Benilla".into(), None); + commands.spawn(( + crate::net::ObjectStore(ObjectFields::from_pairs(&[ + (34, 2), // UNIT_FIELD_LEVEL + (35, 1), // UNIT_FIELD_FACTIONTEMPLATE: human + (36, 0x0101), // UNIT_FIELD_BYTES_0: race human, class warrior + ])), + crate::net::SelfPlayer, + crate::net::Guid(SELF_GUID), + Transform::from_translation(wow_to_bevy(scenario.eye)), + )); + // The subject at the `vplates` wolf's dry spot: a human player holding rank, + // `PLAYER_BYTES_3` byte 3 (`PLAYER_BYTES_3_OFFSET_HONOR_RANK`, vmangos + // `HonorMgr.cpp:900`) at internal 7: the visual rank 3, "Sergeant" (team 1 off race + // 1's `ChrRaces` row). + const RANKED_GUID: u64 = 0x52; + names.insert_player(RANKED_GUID, "Bob".into(), None); + commands.spawn(( + crate::net::Guid(RANKED_GUID), + crate::net::NetEntity { + kind: benilla_protocol::EntityKind::Player, + // HumanMale, the body a human `UNIT_FIELD_DISPLAYID` carries. + display_id: Some(49), + scale: 1.0, + }, + crate::net::ObjectStore(ObjectFields::from_pairs(&[ + (34, 60), // UNIT_FIELD_LEVEL + (35, 1), // UNIT_FIELD_FACTIONTEMPLATE: human + (36, 0x0101), // UNIT_FIELD_BYTES_0: race human, class warrior, male + (195, 7 << 24), // PLAYER_BYTES_3 byte 3: the current honor rank, internal 7 + ])), + Transform { + translation: wow_to_bevy(WOLF_POS), + ..default() + }, + Visibility::default(), + )); + // A plated unit draws no floating name, so enemy plates go off. + set_enemy_plates(script.as_deref(), false); + } // The lighting matrix: one spawn with a streamed entity's component set, anonymous and // with plates off, so no glyph rides over the body. UiFixture::Subject { kind, at } => { diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index 213a9f2b..3e566771 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -111,6 +111,10 @@ pub(super) enum UiFixture { /// `water-noon` camera). Deep water is opaque (`WATER_DEEP_ALPHA` 1.0), so a name sorted before /// the liquid is painted out; it must read at full strength. NameWater, + /// The ranked player's overhead name line: a level-60 human holding honor rank 7 (internal; + /// the visual rank 3, "Sergeant") at the fixture's dry subject spot, framed close off the + /// `vplates` camera's bearing. + NameRank, /// One cell of the lighting matrix: a creature or GameObject spawned through the live path at /// `at` ([`SubjectKind`], the note above [`SUBJECT_SUN`]). Subject { @@ -910,6 +914,16 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ minute: 720, ui: Some(UiFixture::NameWater), }, + // The ranked player's name line at the fixture's subject spot, 5 yd out level with it on the + // `vplates` camera's horizontal bearing, so the body and its name line fill the frame. + Scenario { + name: "name-rank", + map: Some(MAP_AZEROTH), + eye: [-8953.92, -135.53, 85.5], + look: [-8949.95, -132.49, 85.5], + minute: 720, + ui: Some(UiFixture::NameRank), + }, ]; /// The `name-close` subject: the `name-water` wolf's name, orbited at any distance. World text is diff --git a/crates/benilla-app/src/cvars/mod.rs b/crates/benilla-app/src/cvars/mod.rs index 9d54c1e4..b0aa50b9 100644 --- a/crates/benilla-app/src/cvars/mod.rs +++ b/crates/benilla-app/src/cvars/mod.rs @@ -1050,10 +1050,15 @@ mod tests { assert_eq!(d["UnitNameNPC"] != 0.0, names.npc); assert_eq!(d["UnitNameOwn"] != 0.0, names.own); assert_eq!(d["UnitNamePlayerGuild"] != 0.0, names.player_guild); + assert_eq!(d["UnitNamePlayerPVPTitle"] != 0.0, names.player_pvp_title); assert!( - names.player && !names.npc && !names.own && names.player_guild, + names.player + && !names.npc + && !names.own + && names.player_guild + && names.player_pvp_title, "the binary registers UnitNamePlayer \"1\", NPC \"0\", Own \"0\", \ - PlayerGuild \"1\"" + PlayerGuild \"1\", PlayerPVPTitle \"1\"" ); let camera_opts = crate::player::camera_dynamics::CameraOptions::default(); assert_eq!(d["cameraPivot"] != 0.0, camera_opts.pivot); @@ -1324,6 +1329,8 @@ mod tests { assert!(!res::(&app).npc); apply(&mut app, "unitnameown", "1"); assert!(res::(&app).own); + apply(&mut app, "unitnameplayerpvptitle", "0"); + assert!(!res::(&app).player_pvp_title); apply(&mut app, "ChatBubbles", "0"); assert!(!res::(&app).all); apply(&mut app, "chatbubblesparty", "0"); diff --git a/crates/benilla-app/src/cvars/table.rs b/crates/benilla-app/src/cvars/table.rs index 891fccfa..ed9f2b8a 100644 --- a/crates/benilla-app/src/cvars/table.rs +++ b/crates/benilla-app/src/cvars/table.rs @@ -294,9 +294,11 @@ pub(crate) const REGISTERED: &[Registered] = &[ same("UnitNameOwn", "0"), // `UnitNamePlayerGuild` (`0x86c680`, "1", mask bit `0x10`) is not a show gate: `ShouldShowName` // (`0x6070a0`) reads bits `0x1/0x2/0x4`, and this gates the `"\n<%s>"` guild line at - // `0x609085`. `UnitNamePlayerPVPTitle` (bit `0x20`, "1") has no row: nothing here draws the - // rank prefix. + // `0x609085`. same("UnitNamePlayerGuild", "1"), + // `UnitNamePlayerPVPTitle` (`0x86c668`, "1", mask bit `0x20`): the a4 rank prefix on the main + // name line (`0x609370`), `PVP_RANK_%d_%d` through `0x5efe60`. + same("UnitNamePlayerPVPTitle", "1"), // `WorldDetail` is no 1.12 CVar but the `GetWorldDetail`/`SetWorldDetail` verb name // (`OptionsFrame.lua:27`); stops 0/1/2 are `frillDensity` 16/32/48. `SetWorldDetail 0x488dd0` // also writes `SmallCull` {0.07, 0.04, 0.01}, and `GetWorldDetail` reads only `SmallCull`, diff --git a/crates/benilla-app/src/nameplates.rs b/crates/benilla-app/src/nameplates.rs index c950c19e..42db788e 100644 --- a/crates/benilla-app/src/nameplates.rs +++ b/crates/benilla-app/src/nameplates.rs @@ -12,12 +12,18 @@ //! - Show gate (`ShouldShowName`, `0x6070a0`): the own unit by `UnitNameOwn`, before the target //! rescue; the current target (`[0xb4e2d8]`, the selection) regardless of CVars; a dead creature //! only through that rescue; others by `UnitNamePlayer` or `UnitNameNPC`. -//! - Lines (`0x608f50`): an NPC's name and ``; a player's ``/``/`` prefixes, -//! name and ``. The guild (a5) and subname (a6) slots share the format `"\n<%s>"` -//! (`0x860f9c`) and are never both reached; a5 alone is CVar-gated (`0x609085`). +//! - Lines (`0x608f50`): an NPC's name and ``; a player's ``/``/`` +//! prefixes, the a4 rank prefix (`UnitNamePlayerPVPTitle`, bit `0x20`) and name, and ``. +//! The guild (a5) and subname (a6) slots share the format `"\n<%s>"` (`0x860f9c`) and are never +//! both reached; a5 alone is CVar-gated (`0x609085`). +//! - The a4 slot is `0x609370`'s ranked-player leg (A, `0x6093a5`): `UNIT_PVP_NAME` filled rank +//! first, `PVP_RANK__` off the unit's public `PLAYER_BYTES_3` byte 3 and its race's +//! team digit, and never a creature's. //! -//! Not built: the a4 PvP rank prefix (`UnitNamePlayerPVPTitle`, bit `0x20`), whose faction side -//! `ui_unit` does not resolve for another player; a7 and a8 have no cross-realm wire here. +//! Not built: `0x609370`'s civilian leg (`0x609449`, `PVP_RANK_CIVILIAN` and a space before a +//! hostile PvP-flagged civilian NPC's name) and its city-protector line (`0x6093ef`, `"\n"` and +//! `PVP_MEDAL` for a set `PLAYER_BYTES_3` byte 2, which vmangos's `.character citytitle` sets); +//! a7 and a8 have no cross-realm wire here. use std::collections::HashMap; @@ -49,6 +55,9 @@ pub(crate) struct NameConfig { /// `UnitNamePlayerGuild`, mask bit `0x10`, registered `"1"`: gates the a5 guild line /// (`0x609085`), not a whole name. pub(crate) player_guild: bool, + /// `UnitNamePlayerPVPTitle` (`0x86c668`, registered `"1"`), mask bit `0x20`: gates the a4 rank + /// prefix on the main name line (`0x609370`). + pub(crate) player_pvp_title: bool, } impl Default for NameConfig { @@ -58,6 +67,7 @@ impl Default for NameConfig { npc: false, own: false, player_guild: true, + player_pvp_title: true, } } } @@ -78,37 +88,115 @@ fn flag_prefix(player_flags: u32) -> String { .collect() } -/// Whether `cached` equals the stack [`drive_nameplates`] would build from these inputs, compared -/// in place so the steady frame allocates nothing; a differential test pins the equivalence. -fn lines_current( - cached: &[String], - player_flags: u32, - name: &str, - bracketed: Option<&str>, -) -> bool { - let name_line = |line: &str| { - let mut rest = line; - for (bit, tag) in FLAG_PREFIXES { - if player_flags & bit != 0 { - match rest.strip_prefix(tag) { - Some(r) => rest = r, - None => return false, - } - } - } - rest == name - }; - match (cached, bracketed) { - ([l0], None) => name_line(l0), - ([l0, l1], Some(bracketed)) => { - name_line(l0) - && l1 - .strip_prefix('<') - .and_then(|r| r.strip_suffix('>')) - .is_some_and(|r| r == bracketed) - } - _ => false, +/// The strings the a4 rank prefix resolves (`UNIT_PVP_NAME` and the `PVP_RANK_*` titles), which +/// the honor feed reads off each VM once ([`crate::ui_honor`]), where `0x609370` reads them live +/// at every rebuild: the world-text pass runs without the VM. +#[derive(Resource, Default)] +pub(crate) struct PvpNameStrings { + /// The VM session the strings were read from; `None` before the first VM. + session: Option, + /// Moves when a read changes the strings, so a line built from the old ones rebuilds. + generation: u64, + strings: HashMap, +} + +impl PvpNameStrings { + /// The VM session the strings were read from; `None` before the first VM. + pub(crate) fn session(&self) -> Option { + self.session } + + /// A VM's strings, every key it defines; a missing one is the builder's own miss. The + /// generation moves only when they differ, so a new VM with the same strings rebuilds no plate. + pub(crate) fn set(&mut self, session: u64, strings: HashMap) { + self.session = Some(session); + if strings != self.strings { + self.strings = strings; + self.generation += 1; + } + } + + /// One held string, for the feed's tests. + #[cfg(test)] + pub(crate) fn get(&self, key: &str) -> Option<&str> { + self.strings.get(key).map(String::as_str) + } + + /// Whether the snapshot holds the template: no install strings, no prefix, as `UnitPVPName` + /// answers the plain name with `_G` missing. + fn ready(&self) -> bool { + self.strings.contains_key("UNIT_PVP_NAME") + } + + /// `0x609370`'s ranked-player leg for `key`; `None` where the builder declines. + fn decorated(&self, key: TitleKey, name: &str) -> Option { + benilla_ui::script::decorated_name( + |global| self.strings.get(global).cloned(), + key.title, + name, + ) + } +} + +/// The a4 rank-prefix inputs a live line was built with: the steady frame compares these fields, +/// so a rank change, a CVar flip or a new VM's different strings rebuild without reading the +/// strings again. +#[derive(Clone, Copy, PartialEq, Eq)] +struct TitleKey { + /// The rank, team and gender the title lookup keyed on + /// ([`benilla_ui::script::RankTitle`]). + title: benilla_ui::script::RankTitle, + /// The a4 bit (`0x20`) was set and the strings held the template when the line was built. + on: bool, + /// The [`PvpNameStrings`] generation the line was built from. + strings: u64, +} + +/// The a4 inputs for one unit, cheap enough for the steady frame: `None` off a player or before +/// its descriptor arrives. +fn title_key( + cfg: &NameConfig, + strings: &PvpNameStrings, + net: &NetEntity, + store: Option<&ObjectStore>, +) -> Option { + let store = store?; + (net.kind == EntityKind::Player).then(|| TitleKey { + title: benilla_ui::script::RankTitle { + rank: store.0.player_pvp_rank().unwrap_or(0), + team: store + .0 + .unit_race() + .map_or(-1, crate::ui_unit::race_pvp_team), + female: store.0.unit_gender() == Some(1), + }, + on: cfg.player_pvp_title && strings.ready(), + strings: strings.generation, + }) +} + +/// `0x608f50`'s main name line: the a1-a3 flag tags glued on with no separator, then the a4 slot, +/// `0x609370`'s ranked-player leg where `key` is on and the builder resolves, else the plain name. +fn name_line(flags: u32, key: Option, strings: &PvpNameStrings, name: &str) -> String { + let decorated = key + .filter(|key| key.on) + .and_then(|key| strings.decorated(key, name)); + let mut line = flag_prefix(flags); + line.push_str(decorated.as_deref().unwrap_or(name)); + line +} + +/// A live plate: its entity and stack, and the inputs the stack was built from, so a steady frame +/// compares fields and re-reads neither the name cache nor the install strings. +struct LiveName { + plate: Entity, + lines: Vec, + paint: NamePaint, + /// `0x608f50`'s line inputs at build time. + flags: u32, + name: String, + bracketed: Option, + key: Option, } /// The name's world scale for a unit whose overhead anchor sits `d` world units above its feet. @@ -149,8 +237,8 @@ impl NamePaint { pub(crate) struct Nameplates { materials: HashMap>, meshes: HashMap, Handle>, - /// unit → (plate entity, the (lines, color) it was built with). - live: bevy::ecs::entity::EntityHashMap<(Entity, Vec, NamePaint)>, + /// unit → its live plate and the inputs the stack was built from. + live: bevy::ecs::entity::EntityHashMap, /// The atlas generation the meshes' UVs were built from; `None` before the first build. baked_from: Option, } @@ -165,7 +253,7 @@ impl Nameplates { /// The line count of `unit`'s live name, for the raid marker's seat (`0x6c70d8`: one pitch /// above the block, or at the bare anchor when no name shows). pub(crate) fn line_count(&self, unit: Entity) -> Option { - self.live.get(&unit).map(|(_, lines, _)| lines.len()) + self.live.get(&unit).map(|live| live.lines.len()) } } @@ -289,6 +377,8 @@ pub(crate) fn drive_nameplates( Res, // The UnitName* CVar mask. Res, + // The rank strings, read off the current VM ([`PvpNameStrings`]). + Res, ), names: Res, // The guild cache, the a5 line's text; `ResMut` because a miss sends `CMSG_GUILD_QUERY`. @@ -310,7 +400,7 @@ pub(crate) fn drive_nameplates( Query<(), With>, ), ) { - let (selection, flash, rig, vplates, bubbles, group, name_cfg) = gates; + let (selection, flash, rig, vplates, bubbles, group, name_cfg, pvp_strings) = gates; let (Ok(cam_tf), Some(atlas)) = (camera.single(), atlas.as_mut()) else { return; }; @@ -406,8 +496,8 @@ pub(crate) fn drive_nameplates( 0 }; // The AFK slot `0x5ec9e0` alone emits `` for the active player while the mirror - // `[0xb6e5cc]` is set, whatever the bit (`0x5ec9fd`). Folded into `flags` so - // `lines_current` and `flag_prefix` agree. + // `[0xb6e5cc]` is set, whatever the bit (`0x5ec9fd`). Folded into `flags`, so the cache + // compare and [`name_line`] read the same bit. let flags = if is_self && mirror.is_afk() { flags | 0x2 } else { @@ -448,23 +538,23 @@ pub(crate) fn drive_nameplates( }; seen.insert(entity); + let key = title_key(&name_cfg, &pvp_strings, net, store); match plates.live.get(&entity) { - // The steady frame compares the cached stack in place, allocating nothing. - Some((_, l, c)) if *c == color && lines_current(l, flags, name, bracketed) => {} + // The steady frame compares the inputs in place, allocating nothing; a rebuild from + // these inputs is the stack `LiveName` holds. + Some(live) + if live.paint == color + && live.flags == flags + && live.key == key + && live.name == name + && live.bracketed.as_deref() == bracketed => {} stale => { - if let Some((old, _, _)) = stale { - let old = *old; - if let Ok(mut e) = commands.get_entity(old) { + if let Some(live) = stale { + if let Ok(mut e) = commands.get_entity(live.plate) { e.despawn(); } } - // Keep in sync with `lines_current`; its differential test pins this shape. - let prefix = flag_prefix(flags); - let mut lines = vec![if prefix.is_empty() { - name.to_owned() - } else { - format!("{prefix}{name}") - }]; + let mut lines = vec![name_line(flags, key, &pvp_strings, name)]; if let Some(bracketed) = bracketed { lines.push(format!("<{bracketed}>")); } @@ -495,16 +585,27 @@ pub(crate) fn drive_nameplates( let plate = commands .spawn((Mesh3d(mesh), MeshMaterial3d(material), place, NamePlate)) .id(); - plates.live.insert(entity, (plate, lines, color)); + plates.live.insert( + entity, + LiveName { + plate, + lines, + paint: color, + flags, + name: name.to_owned(), + bracketed: bracketed.map(str::to_owned), + key, + }, + ); } } } // Plates whose unit despawned or gated off this frame. - plates.live.retain(|unit, (plate, _, _)| { + plates.live.retain(|unit, live| { if seen.contains(unit) { true } else { - if let Ok(mut e) = commands.get_entity(*plate) { + if let Ok(mut e) = commands.get_entity(live.plate) { e.despawn(); } false @@ -540,8 +641,8 @@ fn place_nameplates( }; let facing = cam_tf.rotation; let blend = 1.0 - (-ROCK_MEAN_RATE * trace.1.delta_secs()).exp(); - for (&unit, (plate, ..)) in plates.live.iter() { - let (Ok(tf), Ok((mut ptf, mut pglobal))) = (units.get(unit), plate_tfs.get_mut(*plate)) + for (&unit, live) in plates.live.iter() { + let (Ok(tf), Ok((mut ptf, mut pglobal))) = (units.get(unit), plate_tfs.get_mut(live.plate)) else { continue; // spawned this frame and not yet flushed, or the unit is despawning }; @@ -603,13 +704,14 @@ pub(crate) struct NameplatesPlugin; #[derive(Resource)] struct NameAnchorTrace(bool); -/// The overhead-name rows' change callback: the name trio and the guild line. +/// The overhead-name rows' change callback: the name trio, the guild line and the rank prefix. pub(crate) fn on_cvar(ev: On, mut names: ResMut) { match ev.key().as_str() { "unitnameplayer" => names.player = ev.flag(), "unitnamenpc" => names.npc = ev.flag(), "unitnameown" => names.own = ev.flag(), "unitnameplayerguild" => names.player_guild = ev.flag(), + "unitnameplayerpvptitle" => names.player_pvp_title = ev.flag(), _ => {} } } @@ -622,6 +724,7 @@ impl Plugin for NameplatesPlugin { )) .init_resource::() .init_resource::() + .init_resource::() // After targeting, V-key plates and chat bubbles: the gate reads their verdicts. .add_systems( Update, @@ -653,8 +756,8 @@ fn drop_stale_glyph_caches(plates: &mut Nameplates, generation: u64, commands: & plates.meshes.len(), plates.live.len(), ); - for (plate, _, _) in plates.live.values() { - if let Ok(mut e) = commands.get_entity(*plate) { + for live in plates.live.values() { + if let Ok(mut e) = commands.get_entity(live.plate) { e.despawn(); } } @@ -750,40 +853,181 @@ mod tests { const RED: Color = Color::linear_rgb(1.0, 0.0, 0.0); - /// Pinned against the build over every pair of cases, both ways: an unflagged `Bob` - /// equals a flagged `Bob`, as the built strings do. + /// The rank strings a VM defining `pairs` hands over, as the honor feed sets them. + fn strings_of(session: u64, pairs: &[(&str, &str)]) -> PvpNameStrings { + let mut strings = PvpNameStrings::default(); + strings.set(session, set_of(pairs)); + strings + } + + fn set_of(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|&(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + const TEMPLATE: (&str, &str) = ("UNIT_PVP_NAME", "%s %s"); + const SERGEANT: (&str, &str) = ("PVP_RANK_7_1", "Sergeant"); + + /// A streamed unit's kind and descriptor. + fn unit(kind: EntityKind, fields: &[(u16, u32)]) -> (NetEntity, ObjectStore) { + use benilla_protocol::messages::ObjectFields; + ( + NetEntity { + kind, + display_id: None, + scale: 1.0, + }, + ObjectStore(ObjectFields::from_pairs(fields)), + ) + } + + /// `UNIT_FIELD_BYTES_0` (36): race human, class warrior, male. + const HUMAN_MALE: (u16, u32) = (36, 0x0101); + /// `PLAYER_BYTES_3` (195) byte 3: the current honor rank, internal 7. + const RANK_7: (u16, u32) = (195, 7 << 24); + + /// The a4 inputs off the descriptor: the public rank byte, the race's team digit, the sex and + /// the CVar's strings-ready bit; a creature has no a4 leg. #[test] - fn lines_current_matches_the_built_stack() { - let build = |flags: u32, name: &str, bracketed: Option<&str>| { - let mut lines = vec![format!("{}{name}", flag_prefix(flags))]; - if let Some(bracketed) = bracketed { - lines.push(format!("<{bracketed}>")); - } - lines + fn title_key_reads_the_rank_byte_the_race_and_the_cvar() { + let cfg = NameConfig::default(); + let ready = strings_of(1, &[TEMPLATE]); + let bare = PvpNameStrings::default(); + // Human (race 1 → team 1), rank 7. + let (net, human) = unit(EntityKind::Player, &[HUMAN_MALE, RANK_7]); + let key = title_key(&cfg, &ready, &net, Some(&human)).unwrap(); + assert_eq!( + (key.title.rank, key.title.team, key.title.female, key.on), + (7, 1, false, true) + ); + // Orc (race 2 → team 0), female; the CVar off, and the strings unloaded, each drop the bit. + let (_, orc) = unit(EntityKind::Player, &[(36, 0x010102), RANK_7]); + let orc = title_key(&cfg, &ready, &net, Some(&orc)).unwrap(); + assert_eq!((orc.title.team, orc.title.female), (0, true)); + let off = NameConfig { + player_pvp_title: false, + ..cfg }; - let cases: &[(u32, &str, Option<&str>)] = &[ - (0, "Mankrik", None), - (0, "Young Wolf", Some("Beast")), - (0, "Young Wolf", None), - (0x2, "Bob", None), - (0x2 | 0x8, "Bob", None), - (0, "Bob", None), - // The a5 guild slot: a bracketed name, and one colliding with a subname above. - (0, "Bob", Some("Legacy")), - (0x2, "Bob", Some("Legacy")), - (0, "Bob", Some("")), - (0, "Young Wolf", Some("Beast Handlers")), - ]; - for &(flags, name, bracketed) in cases { - let cached = build(flags, name, bracketed); - for &(f2, n2, b2) in cases { - assert_eq!( - lines_current(&cached, f2, n2, b2), - build(f2, n2, b2) == cached, - "cache of ({flags:#x}, {name:?}, {bracketed:?}) vs ({f2:#x}, {n2:?}, {b2:?})" - ); - } - } + assert!(!title_key(&off, &ready, &net, Some(&human)).unwrap().on); + assert!(!title_key(&cfg, &bare, &net, Some(&human)).unwrap().on); + let (creature, _) = unit(EntityKind::Unit, &[]); + assert!( + title_key(&cfg, &ready, &creature, Some(&human)).is_none(), + "a creature has no a4" + ); + } + + /// The composed main line, from the descriptor up: `0x608f50` glues the a1-a3 tags onto + /// `0x609370`'s a4 with no separator; the CVar off, rank 0 or a creature keep the plain name. + #[test] + fn the_name_line_glues_the_flag_tags_onto_the_ranked_name() { + let strings = strings_of( + 1, + &[TEMPLATE, SERGEANT, ("PVP_RANK_7_1_FEMALE", "Sergeant (f)")], + ); + let line = |cfg: &NameConfig, (net, store): &(NetEntity, ObjectStore), flags, name| { + name_line( + flags, + title_key(cfg, &strings, net, Some(store)), + &strings, + name, + ) + }; + const AFK: u32 = 0x2; + let cfg = NameConfig::default(); + let sergeant = unit(EntityKind::Player, &[HUMAN_MALE, RANK_7]); + assert_eq!(line(&cfg, &sergeant, AFK, "Bob"), "Sergeant Bob"); + let off = NameConfig { + player_pvp_title: false, + ..cfg + }; + assert_eq!( + line(&off, &sergeant, AFK, "Bob"), + "Bob", + "the CVar off" + ); + let unranked = unit(EntityKind::Player, &[HUMAN_MALE]); + assert_eq!(line(&cfg, &unranked, 0, "Bob"), "Bob", "rank 0"); + // The same bytes on a creature: the kind gates the leg, not the descriptor. + let wolf = unit(EntityKind::Unit, &[HUMAN_MALE, RANK_7]); + assert_eq!(line(&cfg, &wolf, 0, "Young Wolf"), "Young Wolf"); + // `UNIT_FIELD_BYTES_0` byte 2 = 1: the `_FEMALE` twin. + let female = unit(EntityKind::Player, &[(36, 0x01_0101), RANK_7]); + assert_eq!(line(&cfg, &female, 0, "Alice"), "Sergeant (f) Alice"); + } + + /// The decoration is `0x609370`'s: title first, `None` off rank 0 or a key the install lacks. + #[test] + fn the_snapshot_decorates_a_ranked_name() { + let strings = strings_of(1, &[TEMPLATE, SERGEANT]); + let key = TitleKey { + title: benilla_ui::script::RankTitle { + rank: 7, + team: 1, + female: false, + }, + on: true, + strings: strings.generation, + }; + assert_eq!( + strings.decorated(key, "Bob").as_deref(), + Some("Sergeant Bob"), + "the rank rides in front of the name" + ); + assert_eq!( + strings.decorated( + TitleKey { + title: benilla_ui::script::RankTitle { + rank: 0, + ..key.title + }, + ..key + }, + "Bob" + ), + None, + "unranked: no a4 line" + ); + assert_eq!( + strings.decorated( + TitleKey { + title: benilla_ui::script::RankTitle { + team: 0, + ..key.title + }, + ..key + }, + "Bob" + ), + None, + "the Horde key is not the Alliance one" + ); + } + + /// A VM's strings move the key the plate cache compares only when they differ: the same + /// strings from a new VM rebuild nothing, and a renamed title reaches the line. + #[test] + fn a_changed_string_set_moves_the_key_and_reaches_the_line() { + let (net, store) = unit(EntityKind::Player, &[HUMAN_MALE, RANK_7]); + let cfg = NameConfig::default(); + let built = |strings: &PvpNameStrings| { + let key = title_key(&cfg, strings, &net, Some(&store)); + (key, name_line(0, key, strings, "Bob")) + }; + let mut strings = strings_of(1, &[TEMPLATE, SERGEANT]); + let (boot_key, boot_line) = built(&strings); + assert_eq!(boot_line, "Sergeant Bob"); + strings.set(2, set_of(&[TEMPLATE, SERGEANT])); + assert!(built(&strings).0 == boot_key, "a new VM, the same strings"); + strings.set(3, set_of(&[TEMPLATE, ("PVP_RANK_7_1", "Feldwebel")])); + let (key, line) = built(&strings); + assert_eq!(line, "Feldwebel Bob", "the new VM's title reaches the line"); + assert!( + key != boot_key, + "the cached key moves, so the live plate rebuilds" + ); } /// A parked plate's second placement leaves both change ticks alone; a moved unit still writes. @@ -800,9 +1044,18 @@ mod tests { .spawn((Transform::default(), GlobalTransform::default(), NamePlate)) .id(); let mut plates = Nameplates::default(); - plates - .live - .insert(unit, (plate, vec!["Bob".to_string()], NamePaint::Flash)); + plates.live.insert( + unit, + LiveName { + plate, + lines: vec!["Bob".to_string()], + paint: NamePaint::Flash, + flags: 0, + name: "Bob".into(), + bracketed: None, + key: None, + }, + ); world.insert_resource(plates); // First seat: the anchor fallback (no model) is the unit's own translation. @@ -847,7 +1100,18 @@ mod tests { }; plates.meshes.insert(lines.clone(), Handle::default()); plates.materials.insert(NamePaint::Flash, Handle::default()); - plates.live.insert(unit, (plate, lines, NamePaint::Flash)); + plates.live.insert( + unit, + LiveName { + plate, + lines, + paint: NamePaint::Flash, + flags: 0, + name: "Young Wolf".into(), + bracketed: None, + key: None, + }, + ); plates } diff --git a/crates/benilla-app/src/ui_honor.rs b/crates/benilla-app/src/ui_honor.rs index ccdf2c47..a58775db 100644 --- a/crates/benilla-app/src/ui_honor.rs +++ b/crates/benilla-app/src/ui_honor.rs @@ -13,6 +13,8 @@ //! Deviation: the reference watches the whole `PLAYER_BYTES_3` dword, so a drunkenness change also //! fires `PLAYER_PVP_RANK_CHANGED`; we watch byte 3 alone, because that repaint is identical. +use std::collections::HashMap; + use bevy::prelude::*; use benilla_ui::script::{HonorState, InspectHonorData, ScriptValue, UiScript}; @@ -64,10 +66,43 @@ impl Plugin for UiHonorPlugin { net::register(app); app.init_resource::() .init_resource::() - .add_systems(Update, feed_honor.in_set(UiFeed)); + .init_resource::() + .add_systems(Update, feed_honor.in_set(UiFeed)) + // A VM holder, so after the tick; before the driver, so a new VM's strings reach the + // lines the frame it arrives. + .add_systems( + Update, + feed_pvp_name_strings + .after(crate::ui_script::UiInput) + .before(crate::nameplates::drive_nameplates), + ); } } +/// The overhead name's rank strings ([`crate::nameplates::PvpNameStrings`]), read off a VM the +/// first frame it is in the world. Every VM is installed whole, outside `Update` (the boot VM with +/// `GlobalStrings.lua` run, a world VM after its FrameXML and addon load), so the read sees what +/// the load defined; any other frame pays one compare. +fn feed_pvp_name_strings( + script: Option>, + mut strings: ResMut, +) { + let Some(script) = script else { + return; + }; + if strings.session() != Some(script.session()) { + strings.set(script.session(), pvp_name_strings(&script)); + } +} + +/// The [`benilla_ui::script::pvp_name_global_keys`] that `script` defines, with their values. +fn pvp_name_strings(script: &UiScript) -> HashMap { + benilla_ui::script::pvp_name_global_keys() + .into_iter() + .filter_map(|key| benilla_ui::strings::global(script.lua(), &key).map(|value| (key, value))) + .collect() +} + /// Read the honor block off our own descriptor, `None` until it streams. That decides only when /// the first push and its events happen: the reference reads a field it was never sent as 0 /// (`0x51a4b0`-`0x51a7c0`), and so do our bindings. @@ -264,4 +299,72 @@ mod tests { fn the_first_push_fires_both() { assert_eq!(events_for(None, &state()), (true, true)); } + + fn vm(src: &str) -> UiScript { + let script = UiScript::new().expect("a VM"); + script.run(src).expect("globals set"); + script + } + + /// The read takes the overhead name's keys a VM defines, `_FEMALE` twins included, and + /// nothing else. + #[test] + fn the_rank_strings_read_takes_only_the_title_keys() { + let strings = pvp_name_strings(&vm(r#"UNIT_PVP_NAME = "%s %s" + PVP_RANK_7_1 = "Sergeant" + PVP_RANK_7_1_FEMALE = "Sergeant (f)" + GARBAGE_KEY = "x""#)); + assert_eq!( + strings.get("UNIT_PVP_NAME").map(String::as_str), + Some("%s %s") + ); + assert_eq!( + strings.get("PVP_RANK_7_1_FEMALE").map(String::as_str), + Some("Sergeant (f)") + ); + assert!(!strings.contains_key("GARBAGE_KEY"), "only the title keys"); + } + + /// Each VM is read once, the first frame it is in the world, so a world VM whose addon renames + /// a title reaches the overhead line. + #[test] + fn each_new_vm_is_read_once() { + use bevy::ecs::system::RunSystemOnce; + let mut world = World::new(); + world.init_resource::(); + world.run_system_once(feed_pvp_name_strings).unwrap(); + assert_eq!( + world + .resource::() + .session(), + None, + "no VM, no read" + ); + let stock = r#"UNIT_PVP_NAME = "%s %s" + PVP_RANK_7_1 = "Sergeant""#; + let boot = vm(stock); + let boot_session = boot.session(); + world.insert_non_send_resource(boot); + world.run_system_once(feed_pvp_name_strings).unwrap(); + let held = world.resource::(); + assert_eq!(held.session(), Some(boot_session)); + assert_eq!(held.get("PVP_RANK_7_1"), Some("Sergeant")); + + // The same VM is not read again: a global it sets later stays out. + world + .non_send_resource::() + .run(r#"PVP_RANK_7_1 = "Later""#) + .expect("global set"); + world.run_system_once(feed_pvp_name_strings).unwrap(); + let held = world.resource::(); + assert_eq!(held.get("PVP_RANK_7_1"), Some("Sergeant")); + + let world_vm = vm(&format!("{stock}\nPVP_RANK_7_1 = \"Feldwebel\"")); + let world_session = world_vm.session(); + world.insert_non_send_resource(world_vm); + world.run_system_once(feed_pvp_name_strings).unwrap(); + let held = world.resource::(); + assert_eq!(held.session(), Some(world_session)); + assert_eq!(held.get("PVP_RANK_7_1"), Some("Feldwebel")); + } } diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index ce8c4338..4b4917f7 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -1346,14 +1346,17 @@ fn the_nameplates_page_toggles_the_unit_name_cvars() { "{row} defaults unchecked" ); } - // The name rows at the reference's registered defaults: player "1", NPC and own "0". - assert!( - s.eval::( - "return BenillaOptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:GetChecked()" - ) - .unwrap(), - "Player Names defaults checked" - ); + // The name rows at the reference's registered defaults: player, guild and titles "1", NPC and + // own "0". + for row in ["RowPlayerNames", "RowGuildNames", "RowPlayerTitles"] { + assert!( + s.eval::(&format!( + "return BenillaOptionsFrameContainerBodyNameplates{row}Check:GetChecked()" + )) + .unwrap(), + "{row} defaults checked" + ); + } for row in ["RowNpcNames", "RowOwnName"] { assert!( !s.eval::(&format!( @@ -2314,9 +2317,9 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() { ); checked += 1; } - // 83 rows less the three untipped below; four of the 80 carry a `BENILLA_` key, and a dropdown + // 84 rows less the three untipped below; four of the 81 carry a `BENILLA_` key, and a dropdown // row is checked on the key it wears at rest. - assert_eq!(checked, 80, "every tipped row carries a live key"); + assert_eq!(checked, 81, "every tipped row carries a live key"); assert_eq!( untipped, vec![ @@ -2412,8 +2415,8 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() { s.errors() ); } - // Every tipped row: the same 80 the key census counts. - assert_eq!(raised, 80, "every row but Auto Loot raises a description"); + // Every tipped row: the same 81 the key census counts. + assert_eq!(raised, 81, "every row but Auto Loot raises a description"); } /// 1.12's AdvancedOptionsCombatText box as saved-global rows: a click writes the global, never a @@ -4069,13 +4072,6 @@ fn without_a_seated_measurer_the_same_fit_reads_zero() { // nothing and reads back nil, so its box would offer a setting benilla does not have. A CVar is // registered only once something reads it. const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[ - ( - "UnitNamePlayerPVPTitle", - "the PvP rank prefix on the overhead name line — slot a4 of `0x608f50`, bit `0x20` of the \ - same mask, resolved by `0x609370` through the `PVP_RANK_%d_%d` GlobalStrings key. Blocked \ - one step further back than its guild twin: the rank byte streams, but the key's second \ - index is a FACTION SIDE that `ui_unit` does not resolve for an arbitrary player yet", - ), // No slider CVar may land here: `UIOptionsFrame_Load` hands `GetCVar` to `Slider:SetValue` // (`UIOptionsFrame.lua:271-275`), which raises on nil (`0x790980`) and stops the stock // window's load. A check button's `GetCVar(x) == "1"` is nil-safe. @@ -4379,7 +4375,48 @@ fn show_target_damage_greys_its_own_pair_and_the_floating_text_master_leaves_it_ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// The reference's rules: Player Guild Names is dead while player names are off +/// The titles box is the guild row's twin: checked and live at the registered defaults (`"1"`), +/// and a click survives closing and reopening the panel because the CVar is its store. +#[test] +fn the_titles_box_sticks_across_a_panel_reopen() { + benilla_formats::wow_data_or_skip!(); + let mut s = harness_on(audio_harness()); + s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap(); + s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()") + .unwrap(); + let checked = |s: &mut UiScript| -> bool { + s.eval::( + "return BenillaOptionsFrameContainerBodyNameplatesRowPlayerTitlesCheck:GetChecked()", + ) + .unwrap() + }; + let cvar = |s: &mut UiScript| -> Option { + s.eval::>(r#"return GetCVar("UnitNamePlayerPVPTitle")"#) + .unwrap() + }; + assert!(checked(&mut s), "the registered default is \"1\""); + assert_eq!(cvar(&mut s).as_deref(), Some("1")); + + // A click writes the CVar; the box and the mirror agree. + s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerTitlesCheck:Click()") + .unwrap(); + assert!(!checked(&mut s)); + assert_eq!(cvar(&mut s).as_deref(), Some("0")); + + // Closed and reopened, the row re-reads the CVar instead of a staged value. + s.run("HideUIPanel(BenillaOptionsFrame) ShowUIPanel(BenillaOptionsFrame)") + .unwrap(); + s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()") + .unwrap(); + assert!(!checked(&mut s), "the click stuck"); + s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerTitlesCheck:Click()") + .unwrap(); + assert!(checked(&mut s)); + assert_eq!(cvar(&mut s).as_deref(), Some("1")); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// The reference's rules: Player Guild Names and Player Titles are dead while player names are off /// (`UIOptionsFrame.lua:703-709`), Auto-Follow Speed while the style is Never (`:717-721`). #[test] fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style() { @@ -4387,22 +4424,28 @@ fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style() let mut s = harness_on(audio_harness()); s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap(); - // Nameplates: `UnitNamePlayer` ships "1", so the child arrives live. + // Nameplates: `UnitNamePlayer` ships "1", so both children arrive live. s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()") .unwrap(); - let guild_on = |s: &mut UiScript| -> bool { - s.eval::( - "return BenillaOptionsFrameContainerBodyNameplatesRowGuildNamesCheck:IsEnabled() ~= 0", - ) + let child_on = |s: &mut UiScript, row: &str| -> bool { + s.eval::(&format!( + "return BenillaOptionsFrameContainerBodyNameplates{row}Check:IsEnabled() ~= 0" + )) .unwrap() }; - assert!(guild_on(&mut s)); + for row in ["RowGuildNames", "RowPlayerTitles"] { + assert!(child_on(&mut s, row), "{row} arrives live"); + } s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:Click()") .unwrap(); - assert!(!guild_on(&mut s), "no names, no guild line to gate"); + for row in ["RowGuildNames", "RowPlayerTitles"] { + assert!(!child_on(&mut s, row), "no names, no {row} to gate"); + } s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:Click()") .unwrap(); - assert!(guild_on(&mut s)); + for row in ["RowGuildNames", "RowPlayerTitles"] { + assert!(child_on(&mut s, row)); + } // Controls: the style ships "1" (Smart), so the slider arrives live; "0" is Never. s.run("BenillaOptionsFrameCategoryListRowControls:Click()") diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 55f204f8..ad73cae4 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -227,7 +227,7 @@ pub(crate) use model::Model; pub use model::{FontProbe, TextureProbe, TextureSizeProbe, WorldLocProjector}; pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo}; pub use pet::{PetActionView, PetStats}; -pub use pvp::{HonorState, InspectHonorData}; +pub use pvp::{decorated_name, pvp_name_global_keys, HonorState, InspectHonorData, RankTitle}; pub use quest::{ QuestAction, QuestItemView, QuestPanel, QuestRewardSpell, QuestSelect, QuestState, }; diff --git a/crates/benilla-ui/src/script/pvp/mod.rs b/crates/benilla-ui/src/script/pvp/mod.rs index 53c9734b..6bb8d4d3 100644 --- a/crates/benilla-ui/src/script/pvp/mod.rs +++ b/crates/benilla-ui/src/script/pvp/mod.rs @@ -129,7 +129,15 @@ impl super::UiScript { /// victim's (`0x625374`), and a victim whose team is -1 gets no line at all (`0x625321`), /// hence `team: u8`. pub fn pvp_rank_title(&self, rank: u8, team: u8, female: bool) -> Option { - rank_title_gendered(self.lua(), i64::from(rank), i64::from(team), female) + rank_title_gendered( + self.lua(), + RankTitle { + rank, + // Past `i8` names no key, the lookup's own miss. + team: i8::try_from(team).ok()?, + female, + }, + ) } } @@ -218,14 +226,35 @@ fn rank_title_ungendered(lua: &Lua, rank: i64, team: i64) -> Option { global_string(lua, &format!("PVP_RANK_{rank}_{team}")) } +/// The `PVP_RANK__[_FEMALE]` inputs, kept together because the live lookup and the +/// world-text pass's builder key on the same three. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RankTitle { + /// `PLAYER_BYTES_3` byte 3 (`UnitPVPRank`), the internal rank 0..=18; 19 is the racial + /// "Leader" the keys still name. + pub rank: u8, + /// The team digit from the race: 0 Horde, 1 Alliance, -1 no side. + pub team: i8, + /// Female takes the `_FEMALE` twin (`0x612bf0`) and falls back to the base key. + pub female: bool, +} + /// The same title gendered, `0x612bf0`: it passes 2 (male) or 3 (female) to `0x703bf0`, which /// asks FrameXML's `GetText` to append `_FEMALE` for 3 (the engine never builds that key), and -/// retries ungendered on a miss (`0x612c2d`). Used by `UnitPVPName` and the credit line. -fn rank_title_gendered(lua: &Lua, rank: i64, team: i64, female: bool) -> Option { - female - .then(|| global_string(lua, &format!("PVP_RANK_{rank}_{team}_FEMALE"))) +/// retries ungendered on a miss (`0x612c2d`). The credit line's lookup; `UnitPVPName` and the +/// overhead name reach the same keys through [`decorated_name`]. +fn rank_title_gendered(lua: &Lua, title: RankTitle) -> Option { + rank_title(&|key| global_string(lua, key), title) +} + +/// [`rank_title_gendered`]'s key construction over a string lookup, so a caller with no VM (the +/// world-text pass) resolves the same `_FEMALE` twin and fallback. +fn rank_title(lookup: &impl Fn(&str) -> Option, title: RankTitle) -> Option { + title + .female + .then(|| lookup(&format!("PVP_RANK_{}_{}_FEMALE", title.rank, title.team))) .flatten() - .or_else(|| rank_title_ungendered(lua, rank, team)) + .or_else(|| lookup(&format!("PVP_RANK_{}_{}", title.rank, title.team))) } /// Fill a two-`%s` C format string, the install's template (enUS `UNIT_PVP_NAME` is `"%s %s"`); @@ -254,14 +283,55 @@ fn format_two_strings(fmt: &str, a: &str, b: &str) -> String { out } +/// The install keys a name-line rank prefix can resolve: `UNIT_PVP_NAME` and every +/// `PVP_RANK__[_FEMALE]` for ranks 1..=19 and both team digits. The range reaches 19 +/// because the builder is range-unchecked and the install's `PVP_RANK_19_*` names the racial +/// "Leader", past the player rank byte's 0..=18. The world-text pass, which runs without the VM, +/// reads them off each VM once; `UnitPVPName` looks them up live. +pub fn pvp_name_global_keys() -> Vec { + let mut keys = Vec::with_capacity(1 + 19 * 2 * 2); + keys.push("UNIT_PVP_NAME".to_string()); + for rank in 1..=19 { + for team in 0..=1 { + keys.push(format!("PVP_RANK_{rank}_{team}")); + keys.push(format!("PVP_RANK_{rank}_{team}_FEMALE")); + } + } + keys +} + +/// `0x609370`'s ranked-player leg over a string lookup: `UNIT_PVP_NAME` filled rank first, the +/// same builder [`pvp_name`] runs for `UnitPVPName`. `None` for rank 0, or a title or template +/// the lookup lacks, where the callers keep the plain name: the reference prints a missing title +/// as `""` (`" " + name`), which a playable race's 0..=18 rank never reaches in the stock strings. +pub fn decorated_name( + lookup: impl Fn(&str) -> Option, + key: RankTitle, + name: &str, +) -> Option { + if key.rank == 0 { + return None; + } + let title = rank_title(&lookup, key)?; + let template = lookup("UNIT_PVP_NAME")?; + Some(format_two_strings(&template, &title, name)) +} + /// `0x609370`, the name builder behind `UnitPVPName` (its legs are at the binding); `name` is the /// plain `UnitName`. fn pvp_name(lua: &Lua, u: &super::UnitState, name: &str, player_level: u32) -> String { // Leg A: a ranked player; the title is gendered by this unit and range-unchecked. if u.is_player && u.pvp_rank != 0 { - let title = rank_title_gendered(lua, i64::from(u.pvp_rank), team_of(u), u.sex == 3); - if let (Some(fmt), Some(title)) = (global_string(lua, "UNIT_PVP_NAME"), title) { - let mut out = format_two_strings(&fmt, &title, name); + let decorated = decorated_name( + |key| global_string(lua, key), + RankTitle { + rank: u.pvp_rank, + team: u.pvp_team, + female: u.sex == 3, + }, + name, + ); + if let Some(mut out) = decorated { // Leg A′: the city-protector medal, on its own line, ungendered (`0x60941d push 0`). if u.pvp_medal != 0 { if let Some(medal) = global_string(lua, &format!("PVP_MEDAL{}", u.pvp_medal)) { diff --git a/crates/benilla-ui/src/script/pvp/tests.rs b/crates/benilla-ui/src/script/pvp/tests.rs index 3d71138c..eb5bee1e 100644 --- a/crates/benilla-ui/src/script/pvp/tests.rs +++ b/crates/benilla-ui/src/script/pvp/tests.rs @@ -355,6 +355,88 @@ fn the_rust_side_title_lookup_is_gendered_where_the_binding_is_not() { ); } +/// The lookup-generic builder the world-text pass runs ([`decorated_name`]): the same key +/// construction and `_FEMALE` fallback, and a plain `None` where the builder declines. +#[test] +fn the_lookup_generic_builder_resolves_the_same_keys() { + let strings: std::collections::HashMap<&str, &str> = [ + ("UNIT_PVP_NAME", "%s %s"), + ("PVP_RANK_9_1", "Sergeant Major"), + ("PVP_RANK_9_1_FEMALE", "Sergeant Major (f)"), + ("PVP_RANK_9_0", "Senior Sergeant"), + ] + .into(); + let lookup = |key: &str| strings.get(key).map(|value| (*value).to_string()); + assert_eq!( + decorated_name( + lookup, + RankTitle { + rank: 9, + team: 1, + female: false, + }, + "Bob", + ) + .as_deref(), + Some("Sergeant Major Bob"), + "rank first, name second" + ); + assert_eq!( + decorated_name( + lookup, + RankTitle { + rank: 9, + team: 1, + female: true, + }, + "Bob", + ) + .as_deref(), + Some("Sergeant Major (f) Bob"), + "the female twin wins" + ); + assert_eq!( + decorated_name( + lookup, + RankTitle { + rank: 9, + team: 0, + female: false, + }, + "Bob", + ) + .as_deref(), + Some("Senior Sergeant Bob"), + "team 0 keys the Horde list" + ); + assert_eq!( + decorated_name( + lookup, + RankTitle { + rank: 0, + team: 1, + female: false, + }, + "Bob", + ), + None, + "unranked" + ); + assert_eq!( + decorated_name( + lookup, + RankTitle { + rank: 6, + team: 1, + female: false, + }, + "Bob", + ), + None, + "a key the install lacks declines" + ); +} + /// The panes' `if not rankName` fallback needs a nil, not an empty string or a raise. #[test] fn a_missing_rank_global_reads_nil_not_empty() { From ab909df6d961baa7c2c36e415c299a6be6e50cf4 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Mon, 5 Oct 2026 16:57:08 +0200 Subject: [PATCH 06/21] fix: TargetUnit("npc") selects the interaction NPC, as 1.12.1 does (#800) `TargetUnit("npc")`, `AssistUnit("npc")` and `SpellTargetUnit("npc")` did nothing with a merchant, gossip or quest window open: the app's unit-token resolver answered nobody for `npc`. In 1.12.1 the resolver (`0x515970`) matches `npc` as a whole token (`0x515bec`, an unbounded compare, so `npctarget` stays unknown) and takes the interaction NPC's guid `[0xb4e2d0]`, which `0x4930d0` writes when the window opens. `npc` now resolves to the held unit `InteractNpc` names. Tests cover the three verbs, a closed window and a streamed-out NPC, and `/script TargetUnit("npc")` typed into the stock chat box. Fixes #707 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/spell/targeting/mod.rs | 41 ++++++ crates/benilla-app/src/target/script.rs | 120 ++++++++++++++++++ .../benilla-app/src/ui_script/chat_tests.rs | 2 +- crates/benilla-app/src/ui_script/mod.rs | 2 +- crates/benilla-app/src/ui_unit.rs | 11 +- 5 files changed, 168 insertions(+), 8 deletions(-) diff --git a/crates/benilla-app/src/spell/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs index c29b2c34..cea6ca82 100644 --- a/crates/benilla-app/src/spell/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -750,6 +750,47 @@ mod tests { } } + /// `SpellTargetUnit("npc")` binds the interaction NPC (`0x6e6de1` into `0x515970`, whose `npc` + /// compare reads `[0xb4e2d0]`), not the selection; with no window open it names no unit and + /// the cast ends with "Out of range.". + #[test] + fn spell_target_unit_binds_the_interaction_npc() { + use crate::ui_session::InteractNpc; + + let (mut world, rx, ally) = unit_world(10.0); + world + .resource_mut::() + .0 + .insert(ALLY, ally); + world.insert_resource(InteractNpc(Some(ally), Some(ALLY))); + arm(&mut world, HEAL, 0x0002); + spell_target_unit(&mut world, "npc"); + assert!(matches!( + rx.try_recv(), + Ok(ClientCommand::CastSpell { + spell_id: HEAL, + target: Some(ALLY), + }) + )); + assert!(errors(&mut world).is_empty()); + assert!(!world.resource::().active()); + + let (mut world, rx, ally) = unit_world(10.0); + world + .resource_mut::() + .0 + .insert(ALLY, ally); + world.init_resource::(); + arm(&mut world, HEAL, 0x0002); + spell_target_unit(&mut world, "npc"); + assert!(rx.try_recv().is_err(), "no window, no send"); + assert_eq!( + errors(&mut world), + vec![crate::ui_action::CastFail::local(HEAL, 0x59)] + ); + assert!(!world.resource::().active()); + } + /// `SpellCanTargetUnit` resolves a chain as `SpellTargetUnit` does and answers the verdict of /// the unit it ends on (`0x6e6d3a` into `0x515970`, then `0x6e6460`): the ally we and our pet /// target, in range of the spell or not, and nobody past a unit with no target. diff --git a/crates/benilla-app/src/target/script.rs b/crates/benilla-app/src/target/script.rs index 9aca36fb..bc154faf 100644 --- a/crates/benilla-app/src/target/script.rs +++ b/crates/benilla-app/src/target/script.rs @@ -443,4 +443,124 @@ mod tests { "THIRD targets nobody: silent, nothing deselected" ); } + + /// `TargetUnit("npc")` and `AssistUnit("npc")` name the interaction NPC, `[0xb4e2d0]`, which + /// `0x515970` reads on a whole-token `npc` compare (`0x515bec`-`0x515c00`): the vendor whose + /// window is open, and for an assist the unit it targets. With no window open, or the NPC no + /// longer held, `npc` names nobody and the selection stays. + #[test] + fn target_and_assist_unit_name_the_interaction_npc() { + use crate::net::{Guid, ObjectStore, SelfPlayer}; + use crate::ui_session::InteractNpc; + use benilla_ui::script::UiScript; + use bevy::ecs::system::RunSystemOnce; + + const ME: u64 = 1; + const VENDOR: u64 = 0xF130_0000_9800_0001; + const GUARD: u64 = 0xF130_0000_0000_0002; + // `OBJECT_FIELD_TYPE` 2 and `UNIT_FIELD_TARGET` 16, a 2-field guid. + let unit = |target: u64| { + ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ + (2, 0x09), + (16, target as u32), + (17, (target >> 32) as u32), + ])) + }; + + let mut world = script_world(); + world.spawn((SelfPlayer, Guid(ME), unit(0))); + let vendor = world.spawn((Guid(VENDOR), unit(GUARD))).id(); + let guard = world.spawn((Guid(GUARD), unit(0))).id(); + let index = &mut world.resource_mut::().0; + index.insert(VENDOR, vendor); + index.insert(GUARD, guard); + world.insert_resource(InteractNpc(Some(vendor), Some(VENDOR))); + + let run = |world: &mut World, lua: &str| { + world + .non_send_resource_mut::() + .eval::<()>(lua) + .expect("the binding runs"); + world + .run_system_once( + |mut script: NonSendMut, mut select: ScriptSelect| { + for request in script.take_selection_requests() { + select.select(request); + } + }, + ) + .expect("the applier runs as a one-shot system"); + world.resource::().guid + }; + let reset = |world: &mut World| { + let mut sel = world.resource_mut::(); + sel.target = None; + sel.guid = None; + }; + + assert_eq!(run(&mut world, r#"TargetUnit("npc")"#), Some(VENDOR)); + reset(&mut world); + assert_eq!(run(&mut world, r#"TargetUnit("NPC")"#), Some(VENDOR)); + reset(&mut world); + assert_eq!(run(&mut world, r#"AssistUnit("npc")"#), Some(GUARD)); + + // The NPC streamed out with its window still up: the object manager holds no unit. + world + .resource_mut::() + .0 + .remove(&VENDOR); + assert_eq!(run(&mut world, r#"TargetUnit("npc")"#), Some(GUARD)); + + // The window closed: nobody, and the selection stays. + world + .resource_mut::() + .0 + .insert(VENDOR, vendor); + world.insert_resource(InteractNpc::default()); + assert_eq!(run(&mut world, r#"TargetUnit("npc")"#), Some(GUARD)); + } + + /// The path a player takes: `/script TargetUnit("npc")` typed into the stock chat box runs + /// `SlashCmdList["SCRIPT"]` (`ChatFrame.lua:1074-1075`) and selects the vendor whose window is + /// open. + #[test] + fn typing_target_unit_npc_in_the_stock_chat_box_selects_the_vendor() { + benilla_formats::wow_data_or_skip!(); + use crate::net::{Guid, ObjectStore, SelfPlayer}; + use crate::ui_session::InteractNpc; + use benilla_ui::script::UiScript; + use bevy::ecs::system::RunSystemOnce; + + const ME: u64 = 1; + const VENDOR: u64 = 0xF130_0000_9800_0001; + let unit = || ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(2, 0x09)])); + + let mut world = script_world(); + world.insert_non_send_resource(crate::ui_script::chat_tests::chat_frame()); + world.spawn((SelfPlayer, Guid(ME), unit())); + let vendor = world.spawn((Guid(VENDOR), unit())).id(); + world + .resource_mut::() + .0 + .insert(VENDOR, vendor); + world.insert_resource(InteractNpc(Some(vendor), Some(VENDOR))); + + let mut script = world.non_send_resource_mut::(); + assert!(script.focus_editbox("ChatFrameEditBox")); + script.char_input(r#"/script TargetUnit("npc")"#); + assert!(script.key_input("ENTER"), "the box consumes ENTER"); + world + .run_system_once( + |mut script: NonSendMut, mut select: ScriptSelect| { + for request in script.take_selection_requests() { + select.select(request); + } + }, + ) + .expect("the applier runs as a one-shot system"); + assert_eq!( + world.resource::().guid, + Some(VENDOR) + ); + } } diff --git a/crates/benilla-app/src/ui_script/chat_tests.rs b/crates/benilla-app/src/ui_script/chat_tests.rs index 0d07bae2..7103db07 100644 --- a/crates/benilla-app/src/ui_script/chat_tests.rs +++ b/crates/benilla-app/src/ui_script/chat_tests.rs @@ -6,7 +6,7 @@ use benilla_ui::script::{ExtractedQuad, QuadContent, UiScript}; use super::test_ui::load_ui as load_xml; /// The chat stack, fonts first so `inherits="ChatFontNormal"` resolves. -fn chat_frame() -> UiScript { +pub(crate) fn chat_frame() -> UiScript { let mut s = UiScript::new().unwrap(); load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 79338c8d..a443ecf2 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -794,7 +794,7 @@ mod loot_tests; mod group_loot_tests; #[cfg(test)] -mod chat_tests; +pub(crate) mod chat_tests; /// The chat bubble's `UIMenu` kit driven as a menu: the rows' label and shortcut anchoring. #[cfg(test)] diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index df12b421..8102fe82 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -583,15 +583,15 @@ pub(crate) fn player_token_guid( /// The one unit-token resolver, the reference's `0x515970`: case-insensitive compares, then the /// object manager; a caller wanting a type tests the resolved unit. [`Selection`] is a parameter -/// because [`crate::target::SelectCommit`] holds it as `ResMut`. `npc` is recognised but -/// unresolved here, a quiet nil. +/// because [`crate::target::SelectCommit`] holds it as `ResMut`. #[derive(bevy::ecs::system::SystemParam)] pub(crate) struct UnitTokens<'w, 's> { - /// `Option`, like the two below: a UI-only harness lacks their plugins; a bare `Res` panics. + /// `Option`, like those below: a UI-only harness lacks their plugins; a bare `Res` panics. index: Option>, pet: Option>, hovered: Option>, hovered_go: Option>, + interact: Option>, group: Res<'w, crate::ui_party::GroupState>, pub(crate) stores: Query<'w, 's, &'static ObjectStore>, me: Query<'w, 's, (Entity, &'static Guid), With>, @@ -645,8 +645,8 @@ impl UnitTokens<'_, '_> { .and_then(|member| self.pet_of(member, true)), UnitBase::Raid(row) => self.raid_member(row).and_then(|guid| self.held(guid)), UnitBase::Party(row) => self.party_member(row).and_then(|guid| self.held(guid)), - // The interaction NPC is unresolved here, a quiet nil. - UnitBase::Npc => None, + // The interaction NPC's guid, `[0xb4e2d0]` (`0x515c00`). + UnitBase::Npc => self.held(self.interact.as_ref()?.1?), } } @@ -2204,7 +2204,6 @@ mod tests { "targetfoo", "mouseoverx", "raid3targettargetfoo", - "npc", "npctarget", "bogus", "", From bb84f1f1fb842aa104acba09b2cfbc3067c8b72c Mon Sep 17 00:00:00 2001 From: goran2711 Date: Mon, 5 Oct 2026 16:08:33 +0100 Subject: [PATCH 07/21] Unit tooltips title the unit with its PvP name, as 1.12.1 does (#802) A unit tooltip's title read the bare name where 1.12.1 decorates it. The tooltip calls the name builder `0x609370` itself (`0x52a1ab`, or `0x52a1db` when there is no second part) with the decoration flag a literal `1`, the same call `UnitPVPName` makes, so no CVar gates it. With the flag on, a ranked player reads `UNIT_PVP_NAME` of the gendered `PVP_RANK__` and the name, a city protector's `PVP_MEDAL` follows on a second line (`0x6093ef`), and a civilian kill (`0x612550`) reads the `PVP_RANK_CIVILIAN` prefix (`0x609461`). The title now runs the builder `UnitPVPName` already uses. The test covers rank, gender, medal, civilian, an unranked player and a creature; an on-demand `ui-tooltip-rank` capture shows a ranked player's tooltip. Fixes #794 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Sam --- crates/benilla-app/src/capture/fixtures.rs | 26 +++++ crates/benilla-app/src/capture/scenarios.rs | 12 +++ crates/benilla-ui/src/script/pvp/mod.rs | 6 +- .../src/script/tests/tooltip_unit.rs | 99 +++++++++++++++++++ crates/benilla-ui/src/script/tooltip_unit.rs | 15 +-- 5 files changed, 149 insertions(+), 9 deletions(-) diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index dfd42c07..758659c5 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -783,6 +783,32 @@ pub(super) fn seed_ui_fixture( warn!("capture: ui-tooltip-world seed failed to open the tooltip"); } } + UiFixture::TooltipRank => { + let Some(mut script) = script else { + return; + }; + // A ranked player under the cursor, pushed as the mouseover feed does, then the call + // `drive_mouseover_tooltip` makes: the title is `0x609370`'s rank leg over the name, + // "Sergeant Bob". + script.set_unit( + "mouseover", + Some(benilla_ui::script::UnitState { + exists: true, + is_player: true, + name: Some("Bob".into()), + level: 60, + reaction: 5, + pvp: true, + pvp_rank: 7, + pvp_team: 1, + sex: 2, + ..Default::default() + }), + ); + if !script.world_tooltip_unit("mouseover") { + warn!("capture: ui-tooltip-rank seed failed to open the tooltip"); + } + } UiFixture::Character => { // A synthetic self player carrying the full stat block, which the `ui_char` feed turns // into snapshots and events as live. A level-12 warrior; positive (stamina, fire) and diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index 3e566771..db3258f8 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -53,6 +53,9 @@ pub(super) enum UiFixture { /// The world-mouseover tooltip over a seeded unit: the default anchor puts it at the screen's /// bottom-right (`GameTooltip.lua:73-77`), never on the hovered model. TooltipWorld, + /// The world-mouseover tooltip over a seeded ranked player: the same anchor, with the title's + /// PvP rank leg ("Sergeant Bob"). + TooltipRank, /// The character window fed through a synthetic self player's stat block and equipped item /// guids, with the items in [`crate::items::Items`]. Character, @@ -715,6 +718,15 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ minute: 720, ui: Some(UiFixture::TooltipWorld), }, + // The same anchor over a ranked player: the tooltip title carries the rank, "Sergeant Bob". + Scenario { + name: "ui-tooltip-rank", + map: Some(MAP_AZEROTH), + eye: GROUND_EYE, + look: GROUND_LOOK, + minute: 720, + ui: Some(UiFixture::TooltipRank), + }, Scenario { name: "ui-char", map: Some(MAP_AZEROTH), diff --git a/crates/benilla-ui/src/script/pvp/mod.rs b/crates/benilla-ui/src/script/pvp/mod.rs index 6bb8d4d3..35876903 100644 --- a/crates/benilla-ui/src/script/pvp/mod.rs +++ b/crates/benilla-ui/src/script/pvp/mod.rs @@ -317,9 +317,9 @@ pub fn decorated_name( Some(format_two_strings(&template, &title, name)) } -/// `0x609370`, the name builder behind `UnitPVPName` (its legs are at the binding); `name` is the -/// plain `UnitName`. -fn pvp_name(lua: &Lua, u: &super::UnitState, name: &str, player_level: u32) -> String { +/// `0x609370`, the name builder behind `UnitPVPName` (its legs are at the binding) and the unit +/// tooltip's title (`0x52a1ab`), both with the decoration flag `1`; `name` is the plain `UnitName`. +pub(super) fn pvp_name(lua: &Lua, u: &super::UnitState, name: &str, player_level: u32) -> String { // Leg A: a ranked player; the title is gendered by this unit and range-unchecked. if u.is_player && u.pvp_rank != 0 { let decorated = decorated_name( diff --git a/crates/benilla-ui/src/script/tests/tooltip_unit.rs b/crates/benilla-ui/src/script/tests/tooltip_unit.rs index cfa1789d..867ba62a 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_unit.rs @@ -182,6 +182,105 @@ fn a_pending_name_titles_unknownobject_and_the_answer_replaces_it() { assert!(s.take_errors().is_empty()); } +/// The title is decorated by `0x609370` with the flag on (`0x52a1ab`), the builder `UnitPVPName` +/// runs: a ranked player's rank first through `UNIT_PVP_NAME`, gendered by the unit's sex, and a +/// city protector's medal on a second line; a civilian kill's `PVP_RANK_CIVILIAN` prefix; anyone +/// else the plain name. +#[test] +fn the_tooltip_title_is_the_pvp_name() { + let mut s = script(); + seed_level_strings(&mut s); + s.set_screen_size(800.0, 600.0); + s.set_player_req_state(PlayerReqState { + level: 30, + ..Default::default() + }); + let player = |sex: u8, pvp_rank: u8| UnitState { + exists: true, + is_player: true, + guid: 0x0000_0000_0000_002A, + name: Some("Pfedh".into()), + level: 1, + race: Some("Human".into()), + class: Some("Warrior".into()), + sex, + pvp_rank, + pvp_team: 1, + ..Default::default() + }; + s.run( + r#" + UNIT_PVP_NAME = "%s %s" + PVP_RANK_7_1 = "Sergeant" + PVP_RANK_7_1_FEMALE = "Sergeant (f)" + PVP_MEDAL1 = "Guardian of Stormwind" + PVP_RANK_CIVILIAN = "Civilian" + local a = CreateFrame("Button", "UF1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) + local tt = CreateFrame("GameTooltip", "TT") + tt:SetOwner(a, "ANCHOR_RIGHT") + "#, + ) + .unwrap(); + let title = |s: &UiScript| -> String { s.eval("return TTTextLeft1:GetText()").unwrap() }; + + s.set_unit("target", Some(player(2, 7))); + s.run(r#"assert(TT:SetUnit("target") == 1)"#).unwrap(); + assert_eq!(title(&s), "Sergeant Pfedh", "the rank rides first"); + + s.set_unit("target", Some(player(3, 7))); + s.run(r#"assert(TT:SetUnit("target") == 1)"#).unwrap(); + assert_eq!( + title(&s), + "Sergeant (f) Pfedh", + "the unit's sex picks the twin" + ); + + s.set_unit("target", Some(player(2, 0))); + s.run(r#"assert(TT:SetUnit("target") == 1)"#).unwrap(); + assert_eq!( + title(&s), + "Pfedh", + "an unranked player keeps the plain name" + ); + + let mut protector = player(2, 7); + protector.pvp_medal = 1; + s.set_unit("target", Some(protector)); + s.run(r#"assert(TT:SetUnit("target") == 1)"#).unwrap(); + assert_eq!( + title(&s), + "Sergeant Pfedh\nGuardian of Stormwind", + "the medal rides a second line" + ); + + let mut beast = wolf(); + beast.pvp_rank = 7; // a creature carries no rank byte, and must not read one anyway + s.set_unit("target", Some(beast)); + s.run(r#"assert(TT:SetUnit("target") == 1)"#).unwrap(); + assert_eq!(title(&s), "Timber Wolf", "a creature takes no rank"); + + // Hostile, PvP-flagged, a civilian and grey (`0x612550`): the dishonorable-kill prefix. + s.set_unit( + "target", + Some(UnitState { + exists: true, + name: Some("Defias Civilian".into()), + level: 20, + reaction: 2, + pvp: true, + civilian: true, + ..Default::default() + }), + ); + s.run(r#"assert(TT:SetUnit("target") == 1)"#).unwrap(); + assert_eq!( + title(&s), + "Civilian Defias Civilian", + "a civilian kill warns" + ); + assert!(s.take_errors().is_empty()); +} + /// The faction line sits between the level line and "PvP" (`0x529fe0`). Civilian needs the PvP /// bit, the flag, hostility and a grey level (`0x612550`); Leader only the PvP bit and the flag /// (`0x6125c0`). diff --git a/crates/benilla-ui/src/script/tooltip_unit.rs b/crates/benilla-ui/src/script/tooltip_unit.rs index 4786c9b2..fe74f1ae 100644 --- a/crates/benilla-ui/src/script/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tooltip_unit.rs @@ -1,7 +1,8 @@ -//! The engine unit tooltip, in `0x529fe0`'s line order: the name (gold; FrameXML recolours it by -//! reaction), the creature subtitle, the level line, the faction name, then "PvP", "Skinnable", -//! "Civilian" and "Leader", with health on the `StatusBar` child. The world mouseover drives -//! it through [`super::UiScript::world_tooltip_unit`]; unit-frame hovers call `SetUnit` from Lua. +//! The engine unit tooltip, in `0x529fe0`'s line order: the name (gold, decorated by `0x609370` +//! as `UnitPVPName`'s is; FrameXML recolours it by reaction), the creature subtitle, the level +//! line, the faction name, then "PvP", "Skinnable", "Civilian" and "Leader", with health on the +//! `StatusBar` child. The world mouseover drives it through +//! [`super::UiScript::world_tooltip_unit`]; unit-frame hovers call `SetUnit` from Lua. use mlua::{Lua, Table}; @@ -118,10 +119,12 @@ fn render_unit(lua: &Lua, this: &Table, token: &str) -> mlua::Result { return Ok(false); }; // The name: `CGUnit_C::GetUnitName` `0x609210` (`0x52a187`), whose every miss falls to - // `UNKNOWNOBJECT`, as `UnitName`'s does. A token with no object never reaches the builder + // `UNKNOWNOBJECT`, as `UnitName`'s does, then decorated by `0x609370` with the flag a literal + // `1` (`0x52a19b`), as `UnitPVPName` does, so no CVar gates it: a ranked player's rank and + // medal line, a civilian kill's prefix. A token with no object never reaches the builder // (`0x468460`): no plate, and `SetUnit` answers nil. let title = match &u.name { - Some(n) => n.clone(), + Some(name) => super::pvp::pvp_name(lua, &u, name, player_level), None => unknownobject(lua)?.to_str()?.to_string(), }; append_line(lua, this, (title, GOLD), None, false)?; From ffdc206ac2e87e3164e31caf26862c43a5180152 Mon Sep 17 00:00:00 2001 From: elipp <1861512+elipp@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:28:59 +0300 Subject: [PATCH 08/21] World-enter queries wait for the player's create, as 1.12.1 sends them (#801) At login benilla sent `CMSG_SET_ACTIVE_MOVER`, `CMSG_QUERY_TIME`, `MSG_QUERY_NEXT_MAIL_TIME` and `CMSG_BATTLEFIELD_STATUS` right after `CMSG_PLAYER_LOGIN`, before the server had seated the player. cmangos drops them; after a worldport or `/reload` the time and mail queries never went again. In 1.12.1 all four follow the local player's own create (`0x5dea50`): `SetActiveMover` (`0x6006e0`), then the world-enter cascade (`0x5deb60 call 0x4908c0`), whose inits send `0x1CE`, `0x284` and `0x2D3` in that order. A cross-map worldport re-creates the player and takes the same route; `/reload` re-enters the cascade alone (`0x490168`). The four now ride the self create, in that order, and `/reload` re-sends the three. Tests cover login, the create, a worldport, the schedule order and the reload; a live trace shows the order at login, after `.tele` and on `ReloadUI()`. Fixes #789 Fixes #100 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Elias Miettinen Co-Authored-By: Sam --- crates/benilla-app/src/game_plugins.rs | 2 +- crates/benilla-app/src/net.rs | 42 +++-- crates/benilla-app/src/net/apply.rs | 34 +++- .../src/net/apply/world_enter_tests.rs | 148 ++++++++++++++++++ crates/benilla-app/src/net/io.rs | 3 +- crates/benilla-app/src/ui_battlefield.rs | 14 +- crates/benilla-app/src/ui_mail/mod.rs | 17 +- crates/benilla-app/src/ui_script/lifecycle.rs | 36 +++++ crates/benilla-protocol/src/world/session.rs | 3 +- 9 files changed, 267 insertions(+), 32 deletions(-) create mode 100644 crates/benilla-app/src/net/apply/world_enter_tests.rs diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 53e216f0..0efd6118 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -1120,7 +1120,7 @@ pub(crate) mod schedule_tests { ("ui_bank/mod.rs", "feed_bank", Because::PlayerRoundTrip, "`BankErrors` answers a `BuyBankSlot` click; the OPENED edge rides `VmMemo`s"), ("ui_battlefield.rs", "feed_battlefield", Because::SelfHealing, - "`reset_on_world_enter` runs before it, clears the session and re-sends `BattlefieldStatusRequest`"), + "`reset_on_world_enter` runs before it at each world-enter cascade, clears the session and re-sends `BattlefieldStatusRequest`"), ("ui_binder.rs", "feed_binder", Because::PlayerRoundTrip, "`SMSG_BINDER_CONFIRM` only answers the innkeeper's gossip line"), ("ui_char.rs", "feed_char", Because::MemoLatched, diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index eaaca9f7..7f0a1c3b 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -29,6 +29,7 @@ mod session; mod world; pub(crate) use apply::apply_net_updates; +pub(crate) use apply::enter_world_on_self_create; use apply::tag_self_player; pub(crate) use handlers::NetHandlerApp; @@ -120,6 +121,7 @@ impl Plugin for NetPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -132,6 +134,7 @@ impl Plugin for NetPlugin { ( apply_net_updates, tag_self_player, + enter_world_on_self_create, sample_splines, // Swim state from the water at the feet (the wire never carries it for // creatures), before the clamp so a swimmer is exempt the same frame. @@ -151,8 +154,14 @@ impl Plugin for NetPlugin { // `apply_net_updates` and before `drive_display_facing`, so the `"npc"` token is // current when a window's show handler reads it. ) - // Not part of the movement chain above: one send on the world-enter message. - .add_systems(Update, send_query_time.in_set(WorldStage::Net)) + // Not part of the movement chain above: the cascade's time query, after the self create + // has claimed the mover. + .add_systems( + Update, + send_query_time + .in_set(WorldStage::Net) + .after(enter_world_on_self_create), + ) .add_systems(Update, population_pulse.in_set(WorldStage::Net)); } } @@ -553,19 +562,22 @@ impl ServerWallClock { /// `[0xbb749c]`, armed `now + 0xe10` at `0x4def11`). const RESYNC_AFTER: Duration = Duration::from_secs(3600); -/// Ask for the server's wall clock on entering the world (login, worldport, instance transfer) -/// and hourly after, which tracks a server re-clocked under us. -fn send_query_time( - mut entered: MessageReader, +/// Ask for the server's wall clock at each world-enter cascade (the quest-log init `0x4de430`, +/// called at `0x4909a1`, sends it at `0x4de45b`) and hourly after, which tracks a server +/// re-clocked under us. +pub(crate) fn send_query_time( + mut cascades: MessageReader, commands: Res, clock: Res, status: Res, mut asked_at: Local>, + mut seated: Local, ) { - let entering = entered.read().next().is_some(); - // Only while connected; the world-enter send covers a reconnect. - let due = - status.connected && clock.stale() && asked_at.is_none_or(|t| t.elapsed() >= RESYNC_AFTER); + let entering = cascades.read().next().is_some(); + // `connected` rises at `CMSG_PLAYER_LOGIN`, before the server seats us, so the resync waits + // for the first cascade; a logout or a disconnect clears `connected` and so the wait re-arms. + *seated = status.connected && (*seated || entering); + let due = *seated && clock.stale() && asked_at.is_none_or(|t| t.elapsed() >= RESYNC_AFTER); if entering || due { *asked_at = Some(Instant::now()); let _ = commands.0.send(ClientCommand::QueryTime); @@ -812,8 +824,8 @@ pub(crate) enum ClientCommand { guid: u64, lag_ms: u32, }, - /// `CMSG_SET_ACTIVE_MOVER`: at login and on possession; the server drops `MSG_MOVE_*` for an - /// unconfirmed mover. + /// `CMSG_SET_ACTIVE_MOVER`: at our own player's create and on possession; the server drops + /// `MSG_MOVE_*` for an unconfirmed mover. SetActiveMover { guid: u64, }, @@ -2004,6 +2016,12 @@ pub(crate) struct EnteredWorldMessage { pub(crate) tutorial_flags: Option>, } +/// The reference's world-enter cascade (`0x4908c0`) ran: our own player's create, at login and +/// every cross-map worldport ([`enter_world_on_self_create`]), or a `/reload`. Its sends wait for +/// this, not [`EnteredWorldMessage`], which fires before the server has seated the player. +#[derive(Message)] +pub(crate) struct WorldEnterCascadeMessage; + /// `SMSG_ADDON_INFO`: the addons the server hid from the Lua index space, or `None` if it did /// not answer. A resource, since it must exist before the world-entry UI load runs any addon; /// rewritten on every login. diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index a78014d3..6d949719 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -1,14 +1,19 @@ //! The per-frame wire→ECS bridge systems: [`apply_net_updates`] drains the inbound -//! [`SessionEvent`] channel through the handler table, and [`tag_self_player`] marks our own -//! streamed entity. Nothing is applied here: every packet's handler lives with its subsystem. +//! [`SessionEvent`] channel through the handler table, [`tag_self_player`] marks our own +//! streamed entity, and [`enter_world_on_self_create`] runs what its create starts. Nothing is +//! applied here: every packet's handler lives with its subsystem. use benilla_protocol::SessionEvent; use bevy::prelude::*; -use super::{Guid, NetEvents, SelfGuid, SelfPlayer}; +use super::{ + ClientCommand, Guid, NetCommands, NetEvents, SelfGuid, SelfPlayer, WorldEnterCascadeMessage, +}; #[cfg(test)] mod seam_tests; +#[cfg(test)] +mod world_enter_tests; // ── The per-frame bridge systems ───────────────────────────────────────────────────────────────── @@ -38,3 +43,26 @@ pub(super) fn tag_self_player( } } } + +/// Our own player's create, the reference's `0x5dea50` (reached `0x465dbc` → `0x5debe0` → +/// `0x5dec7b`): `SetActiveMover` (`0x6006e0`, sending `CMSG_SET_ACTIVE_MOVER` at `0x6007ae`), then +/// the world-enter cascade (`0x5deb60 call 0x4908c0`). `SMSG_LOGIN_VERIFY_WORLD` and +/// `SMSG_NEW_WORLD` purge the streamed world (`session::worldport`), so a login, a reconnect and +/// every cross-map worldport create us afresh and tag us again; a same-map teleport creates +/// nothing. The server has seated the player by then, so nothing sent here is dropped. +pub(crate) fn enter_world_on_self_create( + created: Query<&Guid, Added>, + net: Res, + mut cascades: MessageWriter, +) { + for guid in &created { + if benilla_assets::trace::enabled() { + benilla_assets::trace::line( + "mvr", + &format!("SET_ACTIVE_MOVER guid={:#x} (self create)", guid.0), + ); + } + let _ = net.0.send(ClientCommand::SetActiveMover { guid: guid.0 }); + cascades.write(WorldEnterCascadeMessage); + } +} diff --git a/crates/benilla-app/src/net/apply/world_enter_tests.rs b/crates/benilla-app/src/net/apply/world_enter_tests.rs new file mode 100644 index 00000000..b90bec40 --- /dev/null +++ b/crates/benilla-app/src/net/apply/world_enter_tests.rs @@ -0,0 +1,148 @@ +//! The world-enter sends wait for our own player's create, as the reference's do (`0x5dea50`): +//! none goes out on the login edge alone, which fires before the server seats the player; the +//! create sends `CMSG_SET_ACTIVE_MOVER`, then the cascade's `CMSG_QUERY_TIME`, +//! `MSG_QUERY_NEXT_MAIL_TIME` and `CMSG_BATTLEFIELD_STATUS`; and a worldport's fresh create sends +//! all four again. + +use bevy::prelude::*; +use crossbeam_channel::Receiver; + +use crate::net::{ + ClientCommand, EnteredWorldMessage, Guid, NetCommands, NetStatus, SelfGuid, ServerWallClock, + WorldEnterCascadeMessage, +}; + +const ME: u64 = 0x0000_0000_0000_0042; + +/// The self-create edge and the cascade's three senders, in the order the client schedules them +/// (`the_client_schedules_the_sends_in_the_reference_order` pins that), logged in as [`ME`]. +fn app() -> (App, Receiver) { + let (tx, rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.insert_resource(NetCommands(tx)) + .insert_resource(SelfGuid(Some(ME))) + .insert_resource(NetStatus { + connected: true, + last_reason: None, + }) + .init_resource::() + .init_resource::() + .init_resource::() + .add_message::() + .add_message::() + .add_systems( + Update, + ( + super::tag_self_player, + super::enter_world_on_self_create, + crate::net::send_query_time, + crate::ui_mail::send_query_next_mail_time_on_enter, + crate::ui_battlefield::reset_on_world_enter, + ) + .chain(), + ); + (app, rx) +} + +/// The login edge `connected` writes at `CMSG_PLAYER_LOGIN`. +fn log_in(app: &mut App) { + app.world_mut().write_message(EnteredWorldMessage { + billing_time_rested: 0, + tutorial_flags: None, + }); +} + +/// What `object_create` spawns for our own create block. +fn self_create(app: &mut App) -> Entity { + app.world_mut().spawn(Guid(ME)).id() +} + +fn sent(rx: &Receiver) -> Vec { + rx.try_iter() + .map(|c| match c { + ClientCommand::SetActiveMover { guid } => format!("SetActiveMover {guid:#x}"), + other => format!("{other:?}"), + }) + .collect() +} + +fn the_four() -> Vec { + vec![ + format!("SetActiveMover {ME:#x}"), + "QueryTime".into(), + "QueryNextMailTime".into(), + "BattlefieldStatusRequest".into(), + ] +} + +/// cmangos drops each of the four as "the player has not logged in yet" when it beats the load, +/// so the login edge sends none, and the hourly clock resync (the clock never answered, so stale) +/// waits for the create too. +#[test] +fn the_login_edge_alone_sends_nothing() { + let (mut app, rx) = app(); + log_in(&mut app); + for _ in 0..3 { + app.update(); + } + assert_eq!(sent(&rx), Vec::::new()); +} + +#[test] +fn the_self_create_sends_the_four_in_the_reference_order() { + let (mut app, rx) = app(); + log_in(&mut app); + app.update(); + self_create(&mut app); + app.update(); + assert_eq!(sent(&rx), the_four()); + // The same body on later frames is no new create. + app.update(); + app.update(); + assert_eq!(sent(&rx), Vec::::new()); +} + +/// A cross-map worldport purges the streamed world and the server creates us afresh +/// (`0x401bc0` rebuilds the object manager; the create takes the login's route). +#[test] +fn a_worldport_create_sends_the_four_again() { + let (mut app, rx) = app(); + log_in(&mut app); + let first = self_create(&mut app); + app.update(); + assert_eq!(sent(&rx), the_four()); + + app.world_mut().despawn(first); + app.update(); + assert_eq!(sent(&rx), Vec::::new(), "nothing between the two"); + self_create(&mut app); + app.update(); + assert_eq!(sent(&rx), the_four()); +} + +/// The production schedule: the self-create edge, then the time, mail and battlefield sends. +#[test] +fn the_client_schedules_the_sends_in_the_reference_order() { + use crate::test_support::runs_before; + let mut app = crate::game_plugins::schedule_tests::headless_client(); + assert!(runs_before( + &mut app, + super::tag_self_player, + super::enter_world_on_self_create + )); + assert!(runs_before( + &mut app, + super::enter_world_on_self_create, + crate::net::send_query_time + )); + assert!(runs_before( + &mut app, + crate::net::send_query_time, + crate::ui_mail::send_query_next_mail_time_on_enter + )); + assert!(runs_before( + &mut app, + crate::ui_mail::send_query_next_mail_time_on_enter, + crate::ui_battlefield::reset_on_world_enter + )); +} diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index 869b97c4..e696b9e2 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -607,8 +607,9 @@ fn run( .find(|c| c.guid == guid) .map(|c| c.name.clone()) .unwrap_or_default(); + // No `CMSG_SET_ACTIVE_MOVER` here: it waits for our own player's create, as the + // reference's does (`super::enter_world_on_self_create`); a server may drop it before then. session.player_login(refuse_once(guid))?; - session.set_active_mover(guid)?; let billing_time_rested = session.billing_time_rested(); let tutorial_flags = session.take_tutorial_flags(); diff --git a/crates/benilla-app/src/ui_battlefield.rs b/crates/benilla-app/src/ui_battlefield.rs index cc410bc6..75533b86 100644 --- a/crates/benilla-app/src/ui_battlefield.rs +++ b/crates/benilla-app/src/ui_battlefield.rs @@ -16,7 +16,7 @@ use benilla_protocol::messages::{BattlefieldList, BattlefieldStatus}; use benilla_ui::script::{BattlefieldListView, BattlefieldMapInfo, BattlefieldQueueSlot, UiScript}; use crate::names::NameCache; -use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands}; +use crate::net::{ClientCommand, NetCommands, WorldEnterCascadeMessage}; use crate::player::Player; use crate::ui_dialog_verbs::BattlefieldQueue; use crate::ui_party::GroupState; @@ -307,15 +307,16 @@ fn drain_battlefield( } } -/// World enter (`0x4a9db0`): the list, the selection and the anchor clear, the queue slots stay, -/// and the bodyless `CMSG_BATTLEFIELD_STATUS` goes out, answered slot by slot. -fn reset_on_world_enter( - mut entered: MessageReader, +/// The world-enter cascade's battlefield init (`0x4909fb` → `0x4a9db0`), after the mail query: the +/// list, the selection and the anchor clear, the queue slots stay, and the bodyless +/// `CMSG_BATTLEFIELD_STATUS` goes out, answered slot by slot. +pub(crate) fn reset_on_world_enter( + mut cascades: MessageReader, mut state: ResMut, script: Option>, commands: Res, ) { - if entered.read().next().is_none() { + if cascades.read().next().is_none() { return; } state.clear_session(); @@ -362,6 +363,7 @@ impl Plugin for BattlefieldPlugin { ( reset_on_world_enter .in_set(crate::ui_script::UiFeed) + .after(crate::ui_mail::send_query_next_mail_time_on_enter) .before(feed_battlefield), feed_battlefield .before(crate::ui_battlefield_score::feed_battlefield_score) diff --git a/crates/benilla-app/src/ui_mail/mod.rs b/crates/benilla-app/src/ui_mail/mod.rs index e4e68a69..a52bca50 100644 --- a/crates/benilla-app/src/ui_mail/mod.rs +++ b/crates/benilla-app/src/ui_mail/mod.rs @@ -19,7 +19,7 @@ use crate::entities::ItemDisplays; use crate::items::Items; use crate::names::NameCache; use crate::net::{ - ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, Objects, SelfPlayer, + ClientCommand, NetCommands, ObjectStore, Objects, SelfPlayer, WorldEnterCascadeMessage, }; use crate::query_cache::QueryCache; use crate::ui_script::{UiFeed, UiInput}; @@ -175,21 +175,22 @@ impl Plugin for UiMailPlugin { close_npc_session_out_of_range::.before(feed_mail), feed_mail.after(crate::ui_unit::UnitFeed).in_set(UiFeed), drain_mail.after(UiInput), - send_query_next_mail_time_on_enter, + // The cascade's order: the time query, this, then the battlefield status. + send_query_next_mail_time_on_enter.after(crate::net::send_query_time), ), ); } } -/// `MSG_QUERY_NEXT_MAIL_TIME` at every world enter, from the mail module's init in the world-enter -/// cascade (`0x4908c0`). The init stamps "no mail" first, so the icon goes dark across a loading -/// screen until the reply. -fn send_query_next_mail_time_on_enter( - mut entered: MessageReader, +/// `MSG_QUERY_NEXT_MAIL_TIME` at each world-enter cascade, from the mail init (`0x4acb10`, called +/// at `0x4909f6`), after the time query and before the battlefield status. The init stamps "no +/// mail" first (`0x4acb87`). +pub(crate) fn send_query_next_mail_time_on_enter( + mut cascades: MessageReader, commands: Res, mut pending: ResMut, ) { - if entered.read().next().is_some() { + if cascades.read().next().is_some() { pending.on_query_sent(); let _ = commands.0.send(ClientCommand::QueryNextMailTime); } diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index 2a7bbedf..e9744050 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -631,6 +631,17 @@ 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); + // `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. + let seated = world + .query_filtered::<(), With>() + .iter(world) + .next() + .is_some(); + if seated { + world.write_message(crate::net::WorldEnterCascadeMessage); + } } /// `AppExit`, the quit roots, read as a message because a quit from in-world never leaves @@ -842,6 +853,31 @@ mod tests { assert_eq!(*world.resource::(), on, "a reload keeps them"); } + /// A `ReloadUI()` re-enters the world-enter cascade (`0x490168`), whose readers re-send the + /// time, mail and battlefield queries, only with the active player set (`0x490166 je`). + #[test] + fn a_reload_reenters_the_world_enter_cascade_once_the_player_exists() { + use crate::net::WorldEnterCascadeMessage; + let reload = |seated: bool| { + let mut world = World::new(); + world.insert_resource(State::new(crate::char_select::ClientState::InWorld)); + world.insert_resource(crate::run_mode::CaptureMode); + world.init_resource::>(); + if seated { + world.spawn(crate::net::SelfPlayer); + } + world.insert_resource(ReloadUiPending(true)); + run_pending_reload(&mut world); + assert!(!world.resource::().0, "the reload ran"); + world + .resource_mut::>() + .drain() + .count() + }; + assert_eq!(reload(true), 1, "in the world"); + assert_eq!(reload(false), 0, "before our own player's create"); + } + /// The reference re-makes its Lua state inside `UI_Init` (`0x48fe97`). #[test] fn the_world_entry_builds_its_own_vm_rather_than_adopting_the_glue_phases() { diff --git a/crates/benilla-protocol/src/world/session.rs b/crates/benilla-protocol/src/world/session.rs index 9a8e529d..692635f6 100644 --- a/crates/benilla-protocol/src/world/session.rs +++ b/crates/benilla-protocol/src/world/session.rs @@ -284,7 +284,8 @@ impl WorldSession { self.send(opcode::CMSG_PLAYER_LOGIN, &messages::full_guid(guid)) } - /// Declare the unit we move, as the 1.12 client does at login; vmangos drops moves until then. + /// Declare the unit we move, as the 1.12 client does at its own create; vmangos drops moves + /// until then. pub fn set_active_mover(&mut self, guid: u64) -> Result<()> { self.send(opcode::CMSG_SET_ACTIVE_MOVER, &messages::full_guid(guid)) } From f8493febb0bb489e94250ed238092da2dde82482 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:45:22 +0700 Subject: [PATCH 09/21] The out trace logs every world send, the login handshake's included (#806) The `out` trace tag logged only what the long-lived writer sent, drained after each command. Everything the session sends before the reader and writer split never reached it: `CMSG_AUTH_SESSION`, `CMSG_CHAR_ENUM`, `CMSG_PLAYER_LOGIN`, character create and delete, the cinematic ack and logout. So a login trace could not show where `CMSG_PLAYER_LOGIN` sits against `SMSG_LOGIN_VERIFY_WORLD`, which wire-timing work needs. The world socket has one write path, `send_packet`. It now reports each packet to an observer right after the write, and the app installs the trace there only when the `out` tag is on. The writer's sent-log and its drains are gone; the line format is unchanged. A protocol test over the fake server checks that the handshake, a pre-split send and a writer send are all observed, in order. A live login trace now opens with `CMSG_AUTH_SESSION`, `CMSG_CHAR_ENUM` and `CMSG_PLAYER_LOGIN`. Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/net/io.rs | 35 ++++++-------- crates/benilla-protocol/src/lib.rs | 3 +- crates/benilla-protocol/src/world/mod.rs | 21 ++++++++- crates/benilla-protocol/src/world/session.rs | 1 - .../benilla-protocol/src/world/writer/mod.rs | 25 +--------- crates/benilla-protocol/tests/handshake.rs | 47 +++++++++++++++++++ 6 files changed, 85 insertions(+), 47 deletions(-) diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index e696b9e2..c993d47c 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -190,6 +190,10 @@ pub(super) struct NetHandles { /// Spawns the read thread with its park and cycle loop, and the one long-lived write thread. pub(super) fn spawn_net(connect: bool) -> NetHandles { + // The outbound opcode trace (tag `out`), armed before any thread can send. + if benilla_assets::trace::enabled_for("out") { + benilla_protocol::observe_sends(trace_out); + } let (events_tx, events_rx) = crossbeam_channel::unbounded(); let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let (pick_tx, pick_rx) = crossbeam_channel::unbounded::(); @@ -763,18 +767,16 @@ fn refuse_once(guid: u64) -> u64 { } } -/// Drains the writer's sent-packet log into the trace as `out` lines, one per packet that reached -/// the socket; a no-op unless the `out` tag armed it. -fn trace_sends(w: &mut WorldWriter) { - w.drain_sent(|opcode, len| { - benilla_assets::trace::line( - "out", - &format!( - "{opcode:#06x} {} len={len}", - benilla_protocol::messages::opcode_name(opcode).unwrap_or("?") - ), - ); - }); +/// One `out` trace line per client packet written to the world socket, whichever session or writer +/// sent it: the hook [`spawn_net`] installs when the `out` tag is on. +fn trace_out(opcode: u16, len: usize) { + benilla_assets::trace::line( + "out", + &format!( + "{opcode:#06x} {} len={len}", + benilla_protocol::messages::opcode_name(opcode).unwrap_or("?") + ), + ); } /// The write thread: app commands, writer swaps and the [`PING_INTERVAL`] keepalive. With no live @@ -792,11 +794,7 @@ fn writer_loop( loop { crossbeam_channel::select! { recv(writer_rx) -> w => match w { - Ok(mut w) => { - // Arm the outbound opcode trace (tag `out`); a fresh socket starts a fresh log. - if benilla_assets::trace::enabled_for("out") { - w.watch_sends(); - } + Ok(w) => { writer = Some(w); warned = 0; // Sequence 1 is the new socket's first ping, so an old socket's pong cannot @@ -833,7 +831,6 @@ fn writer_loop( warned += 1; } } - trace_sends(w); } }, recv(cmd_rx) -> cmd => { @@ -1478,8 +1475,6 @@ fn writer_loop( warned += 1; } } - // What reached the socket, by name (tag `out`); one command can be several packets. - trace_sends(w); }, } } diff --git a/crates/benilla-protocol/src/lib.rs b/crates/benilla-protocol/src/lib.rs index 985b73c8..23deefb5 100644 --- a/crates/benilla-protocol/src/lib.rs +++ b/crates/benilla-protocol/src/lib.rs @@ -20,7 +20,8 @@ pub use messages::{ CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME, }; pub use world::{ - WardenRequired, WorldAuthReject, WorldReader, WorldSession, WorldWriter, WORLD_PORT, + observe_sends, SendObserver, WardenRequired, WorldAuthReject, WorldReader, WorldSession, + WorldWriter, WORLD_PORT, }; use std::net::TcpStream; diff --git a/crates/benilla-protocol/src/world/mod.rs b/crates/benilla-protocol/src/world/mod.rs index 9ee98433..2654cac9 100644 --- a/crates/benilla-protocol/src/world/mod.rs +++ b/crates/benilla-protocol/src/world/mod.rs @@ -6,6 +6,7 @@ use std::io::{Read, Write}; use std::net::TcpStream; +use std::sync::OnceLock; use anyhow::{anyhow, Result}; use benilla_srp::vanilla_header::{DecrypterHalf, EncrypterHalf}; @@ -47,7 +48,21 @@ pub(super) fn recv_packet( messages::parse_server(opcode, &body).map_err(|e| anyhow!("parsing opcode {opcode:#x}: {e}")) } +/// What [`observe_sends`] takes: called with a sent packet's opcode and body length. +pub type SendObserver = fn(opcode: u16, body_len: usize); + +static SEND_OBSERVER: OnceLock = OnceLock::new(); + +/// Report every client packet that reaches a world socket, from the `CMSG_AUTH_SESSION` of the +/// handshake through each [`WorldSession`] and [`WorldWriter`] send, on the thread that wrote it +/// and right after the write. The first observer set is kept for the process; with none set a send +/// pays one atomic load. +pub fn observe_sends(observer: SendObserver) { + let _ = SEND_OBSERVER.set(observer); +} + /// Write one client packet: a 6-byte header, its size counting opcode and body, then the body. +/// The sole write path of the world socket, so the one place [`observe_sends`] reports from. pub(super) fn send_packet( stream: &mut TcpStream, encrypter: Option<&mut EncrypterHalf>, @@ -70,5 +85,9 @@ pub(super) fn send_packet( packet.extend_from_slice(body); stream .write_all(&packet) - .map_err(|e| anyhow!("sending opcode {opcode:#x}: {e}")) + .map_err(|e| anyhow!("sending opcode {opcode:#x}: {e}"))?; + if let Some(observer) = SEND_OBSERVER.get() { + observer(opcode, body.len()); + } + Ok(()) } diff --git a/crates/benilla-protocol/src/world/session.rs b/crates/benilla-protocol/src/world/session.rs index 692635f6..c1b82db3 100644 --- a/crates/benilla-protocol/src/world/session.rs +++ b/crates/benilla-protocol/src/world/session.rs @@ -778,7 +778,6 @@ impl WorldSession { stream: self.stream, encrypter, chat_language: self.chat_language, - sent: None, }, )) } diff --git a/crates/benilla-protocol/src/world/writer/mod.rs b/crates/benilla-protocol/src/world/writer/mod.rs index 7b86b8d7..c34a89ad 100644 --- a/crates/benilla-protocol/src/world/writer/mod.rs +++ b/crates/benilla-protocol/src/world/writer/mod.rs @@ -60,9 +60,6 @@ mod vendor; pub struct WorldWriter { pub(super) stream: TcpStream, pub(super) encrypter: EncrypterHalf, - /// Every packet that reached the socket since the last drain, as `(opcode, body length)`; - /// `None` until [`Self::watch_sends`]. Pushed only after a successful write. - pub(super) sent: Option>, /// The character's faction language, set at login from its race, which a chat send naming no /// language carries. vmangos drops the whole message, dot-commands included, when the /// character does not know the language. @@ -72,26 +69,6 @@ pub struct WorldWriter { impl WorldWriter { /// Frame, encrypt and write one packet: the sole write path every verb goes through. fn send(&mut self, opcode: u16, body: &[u8]) -> Result<()> { - let sent = send_packet(&mut self.stream, Some(&mut self.encrypter), opcode, body); - if sent.is_ok() { - if let Some(log) = &mut self.sent { - log.push((opcode, body.len())); - } - } - sent - } - - /// Start recording what reaches the socket; a second call keeps what is not yet drained. - pub fn watch_sends(&mut self) { - self.sent.get_or_insert_with(Vec::new); - } - - /// Hand over and clear the recorded `(opcode, body length)` pairs, in send order. - pub fn drain_sent(&mut self, mut each: impl FnMut(u16, usize)) { - if let Some(log) = &mut self.sent { - for (opcode, len) in log.drain(..) { - each(opcode, len); - } - } + send_packet(&mut self.stream, Some(&mut self.encrypter), opcode, body) } } diff --git a/crates/benilla-protocol/tests/handshake.rs b/crates/benilla-protocol/tests/handshake.rs index 1812d197..23c51f83 100644 --- a/crates/benilla-protocol/tests/handshake.rs +++ b/crates/benilla-protocol/tests/handshake.rs @@ -94,3 +94,50 @@ fn a_warden_server_is_refused_at_the_handshake() { "expected WardenRequired, got: {err:#}" ); } + +/// Every packet the observer saw, with the thread that wrote it: the tests of this file share one +/// process, so each filters for its own thread. +static SEEN: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +fn record(opcode: u16, len: usize) { + SEEN.lock() + .unwrap() + .push((thread::current().id(), opcode, len)); +} + +/// A packet sent before the session splits (the handshake's `CMSG_AUTH_SESSION`, the world entry's +/// `CMSG_PLAYER_LOGIN`) reaches the observer in write order, ahead of the writer's own. +#[test] +fn sends_before_the_split_are_observed_in_order() { + benilla_protocol::observe_sends(record); + let addr = fake_server(vec![]); + let mut session = WorldSession::connect(&addr, "one", SESSION_KEY).unwrap(); + session.player_login(0x2a).unwrap(); + let (_reader, mut writer) = session.into_split().unwrap(); + writer.complete_cinematic().unwrap(); + let me = thread::current().id(); + let mine: Vec<(u16, usize)> = SEEN + .lock() + .unwrap() + .iter() + .filter(|(t, ..)| *t == me) + .map(|&(_, op, len)| (op, len)) + .collect(); + let auth_len = messages::auth_session( + u32::from(benilla_protocol::CLIENT_BUILD), + "ONE", + 0, + &[0; 20], + &messages::STOCK_SECURE_ADDONS, + ) + .len(); + assert_eq!( + mine, + [ + (opcode::CMSG_AUTH_SESSION, auth_len), + (opcode::CMSG_PLAYER_LOGIN, messages::full_guid(0x2a).len()), + (opcode::CMSG_COMPLETE_CINEMATIC, 0), + ] + ); +} From 85980939f59ced6c3d141b9a89099bf368091dfd Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:12:02 +0700 Subject: [PATCH 10/21] A crate on top finds its own benilla-config/ through config_dir (#809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A crate built on benilla through `run_with` has its own `benilla-config/`, but could not ask where it is: `local_state::home` is crate-private. A crate that ships an addon has to write it into that folder's `AddOns/`, where benilla discovers addons and resolves their art and fonts, so without the path it needed a fork. - `benilla_app::config_dir()` answers `local_state::home()`, asked from `run_with` on, since a dev build resolves it from the launcher's folder set there. - CONTRIBUTING's "Building on top" says a crate's files and addons go there, and names `run_with` and this folder as the two seams opened for crates alone. - The example launcher logs its `AddOns/` folder through it, so the example, which clippy and the tests build, is the outside caller that keeps it public. No 1.12.1 behaviour changes. Fixes #807 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/examples/extended_launcher.rs | 11 ++++++++--- crates/benilla-app/src/lib.rs | 9 +++++++++ docs/CONTRIBUTING.md | 10 ++++++---- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/benilla-app/examples/extended_launcher.rs b/crates/benilla-app/examples/extended_launcher.rs index 8c44196c..f8c38021 100644 --- a/crates/benilla-app/examples/extended_launcher.rs +++ b/crates/benilla-app/examples/extended_launcher.rs @@ -6,7 +6,8 @@ //! cargo run -p benilla-app --example extended_launcher //! ``` //! -//! The banner reads `benilla build … · extended`, and the plugin logs one line at `Startup`. +//! The banner reads `benilla build … · extended`, and the plugin logs one line at `Startup`: the +//! `AddOns/` folder in [`benilla_app::config_dir`], where an addon the crate ships is written. //! //! A crate of its own copies the `benilla` launcher (`crates/benilla`): a build script that stamps //! its commit, a `main` like this one that reads the stamp back as `crates/benilla/src/main.rs` @@ -55,12 +56,16 @@ use benilla_app::{AppExit, BuildId}; use bevy::prelude::*; -/// The crate's own plugin: one line at `Startup`, to show it runs inside the client. +/// The crate's own plugin: one line at `Startup`, to show it runs inside the client and where its +/// addons go. struct HelloPlugin; impl Plugin for HelloPlugin { fn build(&self, app: &mut App) { - app.add_systems(Startup, || info!("a plugin on top of benilla is running")); + app.add_systems(Startup, || { + let addons = benilla_app::config_dir().map(|dir| dir.join("AddOns")); + info!("a plugin on top of benilla is running; its addons go in {addons:?}"); + }); } } diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index b6eeee9f..1a0e200b 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -215,6 +215,15 @@ pub fn run_with(build: BuildId, extend: impl FnOnce(&mut App)) -> AppExit { launch(build, Some(Box::new(extend))) } +/// The state folder, `benilla-config/`, where a crate on top keeps its own files: an addon it ships +/// goes in its `AddOns/`, where benilla finds addons and their art and fonts. `None` when +/// persistence is off (a capture run, or no executable path), and it may not exist yet. A dev +/// build resolves it from the launcher's folder, which [`run_with`] sets, so ask from `extend` or +/// a system, never before. +pub fn config_dir() -> Option { + local_state::home() +} + /// What a crate on top of benilla adds to the built app ([`run_with`]). type Extension<'a> = Box; diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index bf071393..735a1dfb 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -40,11 +40,13 @@ A feature 1.12.1 does not have lives in a crate of its own that adds its Bevy pl `benilla_app::run_with`, as `crates/benilla-app/examples/extended_launcher.rs` shows, or in a fork. A client started through `run_with` says `extended` in its build line, so its reports read apart from stock benilla's. The crate keeps its own settings, never rows in benilla's CVar table, -which every addon reads. +which every addon reads, and its files go under `benilla_app::config_dir()`, its own +`benilla-config/`: an addon it ships is written into that folder's `AddOns/` and loads as any +addon does. -benilla opens a piece of itself to such a crate only when its own 1.12.1 work creates that piece, -never for the crate alone, and promises no stable API: a crate pins the benilla revision it -builds on. What a crate cannot reach stays a fork's. +Past `run_with` and that folder, benilla opens a piece of itself to such a crate only when its own +1.12.1 work creates that piece, never for the crate alone, and promises no stable API: a crate +pins the benilla revision it builds on. What a crate cannot reach stays a fork's. ## How a change is judged From 2e82d34c1b561e3bb033f7cd910036ba63ce1760 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:29:18 +0700 Subject: [PATCH 11/21] The meeting-stone query runs at every world entry, as 1.12.1 sends it (#810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The meeting-stone status query (`CMSG 0x296`) and its text reset ran once per UI VM, and the text drop only on leaving the world. So after a cross-map worldport benilla kept the old text and never asked again. In 1.12.1 the world-enter cascade's meeting-stone init (`0x490a14` → `0x4c9f40`) resets the text to `UNKNOWN` and sends `0x296`, right after the battlefield init, on every pass: login, every cross-map worldport and every `/reload`. The leave-world sweep `0x490a80` drops the text (`0x490b2f` → `0x4c9f80`) on a worldport, a `/reload` and leaving the world. The enter leg now runs on the world-enter cascade after the battlefield status. The sweep is one edge, written whenever its latch is spent, and the drop reads it. Tests cover the cascade, a worldport, a reload and the schedule order; a live trace shows `0x296` after `0x2D3` and its reply at login, on `ReloadUI()` and after `.tele`. Fixes #99 Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/game_plugins.rs | 2 +- .../src/net/apply/world_enter_tests.rs | 37 ++- crates/benilla-app/src/ui_dialog_verbs.rs | 214 +++++++++++++----- crates/benilla-app/src/ui_script/lifecycle.rs | 82 +++++-- crates/benilla-app/src/ui_script/mod.rs | 5 +- crates/benilla-app/src/ui_unit.rs | 8 +- .../src/world/writer/meeting_stone.rs | 3 +- 7 files changed, 261 insertions(+), 90 deletions(-) diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 0efd6118..81e0ca2f 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -1128,7 +1128,7 @@ pub(crate) mod schedule_tests { ("ui_chat/recruitment.rs", "guild_recruitment_cascade", Because::SelfHealing, "`pending` is held until the zone mask and zone id are settled, which happens on the entry VM"), ("ui_dialog_verbs.rs", "feed_meeting_stone", Because::SelfHealing, - "the query is sent once per VM (`asked: VmMemo`), so the entry VM re-asks and the reply lands after the UI is up"), + "the query waits for the world-enter cascade (`meeting_stone_enter_world`), our own create a server round trip past the login edge, and every `/reload` re-asks onto its new VM"), ("ui_duel.rs", "feed_duel", Because::PlayerRoundTrip, "a duel exists only after someone's Duel cast, and the server ends any duel at logout; FINISHED/bounds ride `VmMemo` (another player's act: coincidence-only residual)"), ("ui_follow.rs", "feed_follow", Because::MemoLatched, diff --git a/crates/benilla-app/src/net/apply/world_enter_tests.rs b/crates/benilla-app/src/net/apply/world_enter_tests.rs index b90bec40..0ea7d84b 100644 --- a/crates/benilla-app/src/net/apply/world_enter_tests.rs +++ b/crates/benilla-app/src/net/apply/world_enter_tests.rs @@ -1,8 +1,8 @@ //! The world-enter sends wait for our own player's create, as the reference's do (`0x5dea50`): //! none goes out on the login edge alone, which fires before the server seats the player; the //! create sends `CMSG_SET_ACTIVE_MOVER`, then the cascade's `CMSG_QUERY_TIME`, -//! `MSG_QUERY_NEXT_MAIL_TIME` and `CMSG_BATTLEFIELD_STATUS`; and a worldport's fresh create sends -//! all four again. +//! `MSG_QUERY_NEXT_MAIL_TIME`, `CMSG_BATTLEFIELD_STATUS` and `CMSG 0x296`; a worldport's fresh +//! create sends all five again. use bevy::prelude::*; use crossbeam_channel::Receiver; @@ -14,7 +14,7 @@ use crate::net::{ const ME: u64 = 0x0000_0000_0000_0042; -/// The self-create edge and the cascade's three senders, in the order the client schedules them +/// The self-create edge and the cascade's four senders, in the order the client schedules them /// (`the_client_schedules_the_sends_in_the_reference_order` pins that), logged in as [`ME`]. fn app() -> (App, Receiver) { let (tx, rx) = crossbeam_channel::unbounded(); @@ -28,6 +28,7 @@ fn app() -> (App, Receiver) { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_message::() .add_message::() .add_systems( @@ -38,6 +39,7 @@ fn app() -> (App, Receiver) { crate::net::send_query_time, crate::ui_mail::send_query_next_mail_time_on_enter, crate::ui_battlefield::reset_on_world_enter, + crate::ui_dialog_verbs::meeting_stone_enter_world, ) .chain(), ); @@ -66,16 +68,17 @@ fn sent(rx: &Receiver) -> Vec { .collect() } -fn the_four() -> Vec { +fn the_five() -> Vec { vec![ format!("SetActiveMover {ME:#x}"), "QueryTime".into(), "QueryNextMailTime".into(), "BattlefieldStatusRequest".into(), + "MeetingStoneStatusQuery".into(), ] } -/// cmangos drops each of the four as "the player has not logged in yet" when it beats the load, +/// cmangos drops each of the five as "the player has not logged in yet" when it beats the load, /// so the login edge sends none, and the hourly clock resync (the clock never answered, so stale) /// waits for the create too. #[test] @@ -89,13 +92,13 @@ fn the_login_edge_alone_sends_nothing() { } #[test] -fn the_self_create_sends_the_four_in_the_reference_order() { +fn the_self_create_sends_the_five_in_the_reference_order() { let (mut app, rx) = app(); log_in(&mut app); app.update(); self_create(&mut app); app.update(); - assert_eq!(sent(&rx), the_four()); + assert_eq!(sent(&rx), the_five()); // The same body on later frames is no new create. app.update(); app.update(); @@ -105,22 +108,24 @@ fn the_self_create_sends_the_four_in_the_reference_order() { /// A cross-map worldport purges the streamed world and the server creates us afresh /// (`0x401bc0` rebuilds the object manager; the create takes the login's route). #[test] -fn a_worldport_create_sends_the_four_again() { +fn a_worldport_create_sends_the_five_again() { let (mut app, rx) = app(); log_in(&mut app); let first = self_create(&mut app); app.update(); - assert_eq!(sent(&rx), the_four()); + assert_eq!(sent(&rx), the_five()); app.world_mut().despawn(first); app.update(); assert_eq!(sent(&rx), Vec::::new(), "nothing between the two"); self_create(&mut app); app.update(); - assert_eq!(sent(&rx), the_four()); + assert_eq!(sent(&rx), the_five()); } -/// The production schedule: the self-create edge, then the time, mail and battlefield sends. +/// The production schedule: the self-create edge, then the time, mail, battlefield and meeting +/// stone sends (`0x4909a1`, `0x4909f6`, `0x4909fb`, `0x490a14`); and a `/reload` frame's sweep +/// drops the stone's text before its cascade resets it (`0x490c20`, then `0x490168`). #[test] fn the_client_schedules_the_sends_in_the_reference_order() { use crate::test_support::runs_before; @@ -145,4 +150,14 @@ fn the_client_schedules_the_sends_in_the_reference_order() { crate::ui_mail::send_query_next_mail_time_on_enter, crate::ui_battlefield::reset_on_world_enter )); + assert!(runs_before( + &mut app, + crate::ui_battlefield::reset_on_world_enter, + crate::ui_dialog_verbs::meeting_stone_enter_world + )); + assert!(runs_before( + &mut app, + crate::ui_dialog_verbs::meeting_stone_leave_world, + crate::ui_dialog_verbs::meeting_stone_enter_world + )); } diff --git a/crates/benilla-app/src/ui_dialog_verbs.rs b/crates/benilla-app/src/ui_dialog_verbs.rs index 036bf8db..6f76bb07 100644 --- a/crates/benilla-app/src/ui_dialog_verbs.rs +++ b/crates/benilla-app/src/ui_dialog_verbs.rs @@ -15,9 +15,11 @@ use crate::area::AreaTableRes; use crate::creature_anim::spell_visual::{meeting_stone_join_fx, SpellKitFx, SpellVisuals}; use crate::names::NameCache; -use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfGuid, SelfPlayer}; +use crate::net::{ + ClientCommand, NetCommands, ObjectStore, SelfGuid, SelfPlayer, WorldEnterCascadeMessage, +}; use crate::ui_party::GroupState; -use crate::ui_script::{UiFeed, UiInput}; +use crate::ui_script::{UiFeed, UiInput, WorldLeaveSweepMessage}; use crate::ui_session::{close_npc_session_out_of_range, NpcSession}; /// The pet trainer's pending question: the latch (`0xc4d7b0/b4`) and the cost (`0xc4d7b8`). @@ -506,21 +508,17 @@ fn feed_meeting_stone( } } -/// The enter-world bring-up's meeting-stone leg: the text reset, then the empty `CMSG 0x296`. -/// Once per VM, a [`crate::ui_script::VmMemo`] claim: the UI teardown clears the run-once byte -/// `[0xb4b424]` (`0x490a8d`) and `UI_Init` (`0x48fbf0`) re-runs the bring-up, so a `/reload` -/// re-asks the server; until the reply fires `MEETINGSTONE_CHANGED` (`0x4ca38f`) the minimap icon -/// is hidden, as in the reference. The queued area survives the reload. -fn meeting_stone_enter_world( - script: Option>, +/// The world-enter cascade's meeting-stone leg (`0x490a14` → `0x4c9f40`), after the battlefield +/// status: the text reset, then the empty `CMSG 0x296` (`0x4ca1c0`). The cascade runs at login, +/// after every cross-map worldport and on every `/reload`; until the reply fires +/// `MEETINGSTONE_CHANGED` (`0x4ca38f`) the minimap icon is hidden, as in the reference. The +/// queued area survives. +pub(crate) fn meeting_stone_enter_world( + mut cascades: MessageReader, mut stone: ResMut, commands: Res, - mut asked: Local>, ) { - let Some(script) = script else { - return; - }; - if !asked.claim(&script) { + if cascades.read().next().is_none() { return; } stone.enter_world(); @@ -636,9 +634,15 @@ fn drain_meeting_stone_joins( } } -/// The leave-world sweep's leg: the text dropped, the area kept. -fn meeting_stone_leave_world(mut stone: ResMut) { - stone.leave_world(); +/// The leave-world sweep's meeting-stone leg (`0x490b2f` → `0x4c9f80`): the text dropped, the +/// area kept. +pub(crate) fn meeting_stone_leave_world( + mut sweeps: MessageReader, + mut stone: ResMut, +) { + if sweeps.read().next().is_some() { + stone.leave_world(); + } } /// Drop the cached spirit guide when the world does, so no healer, wave or event reaches the @@ -969,6 +973,8 @@ impl Plugin for UiDialogVerbsPlugin { .init_resource::() .init_resource::() .add_message::() + .add_message::() + .add_message::() .add_systems( Update, ( @@ -986,11 +992,15 @@ impl Plugin for UiDialogVerbsPlugin { .after(crate::target::TargetUpdate) .run_if(crate::ui_script::ingame_ui_up) .in_set(crate::char_select::InWorldGated), - // In world only: the query is a world packet, and the glue screen has a VM. + // The sweep's drop, then the cascade's reset: a `/reload` runs both in one + // frame, in this order (`0x490c20`, then `0x490168`). + meeting_stone_leave_world.before(meeting_stone_enter_world), + // The cascade's order: the battlefield status, then this (`0x4909fb`, + // `0x490a14`), so `CMSG 0x296` follows `CMSG 0x2D3` on the wire. meeting_stone_enter_world - .in_set(crate::ui_script::UiFeed) - .before(feed_meeting_stone) - .in_set(crate::char_select::InWorldGated), + .in_set(UiFeed) + .after(crate::ui_battlefield::reset_on_world_enter) + .before(feed_meeting_stone), feed_meeting_stone.in_set(UiFeed), // Before the target chain, where the poll and the click can change the // healer: the reference resolves an Accept press in the UI dispatch, before @@ -1008,7 +1018,7 @@ impl Plugin for UiDialogVerbsPlugin { ) .add_systems( OnExit(crate::char_select::ClientState::InWorld), - (meeting_stone_leave_world, area_spirit_healer_leave_world), + area_spirit_healer_leave_world, ); } } @@ -1464,55 +1474,147 @@ mod tests { ); } - /// A registered schedule: `run_system_once` would build a fresh `Local>` per call, - /// an unclaimed memo every frame. - #[test] - fn a_rebuilt_vm_re_asks_the_server_for_the_meeting_stone_queue() { + /// The two meeting-stone legs behind the worldport's sweep producer, the latch armed as our + /// own create arms it; the cascade and a `/reload`'s sweep are written by hand. + fn stone_app() -> (App, crossbeam_channel::Receiver) { let (tx, rx) = crossbeam_channel::unbounded(); let mut app = App::new(); app.init_resource::() .insert_resource(NetCommands(tx)) - .add_systems(Update, meeting_stone_enter_world); + .init_resource::() + .add_message::() + .add_message::() + .add_message::() + .add_systems( + Update, + ( + crate::ui_unit::fire_leaving_world_on_worldport, + meeting_stone_leave_world, + meeting_stone_enter_world, + ) + .chain(), + ); + app.world_mut() + .resource_mut::() + .arm(); + (app, rx) + } - let queries = |rx: &crossbeam_channel::Receiver| { - rx.try_iter() - .filter(|c| matches!(c, ClientCommand::MeetingStoneStatusQuery)) - .count() - }; + fn stone_queries(rx: &crossbeam_channel::Receiver) -> usize { + rx.try_iter() + .filter(|c| matches!(c, ClientCommand::MeetingStoneStatusQuery)) + .count() + } + /// The server's answer has landed: queued at `area`, the line built. + fn answered(app: &mut App, area: u32) { + let mut stone = app.world_mut().resource_mut::(); + stone.area = area; + stone.text = StoneText::Built("Meeting Stone: Wailing Caverns".into()); + stone.dirty = false; + } + + fn stone_text(app: &App) -> StoneText { + app.world().resource::().text.clone() + } + + fn stone_port(needs_ack: bool) -> crate::net::WorldportMessage { + crate::net::WorldportMessage { + map_id: 1, + position: [0.0; 3], + orientation: 0.0, + needs_ack, + transport_entry: None, + } + } + + /// Each world-enter cascade (`0x490a14` → `0x4c9f40`) resets the text to the bare `UNKNOWN` + /// and sends `CMSG 0x296`, a VM or not; frames without one send nothing. + #[test] + fn every_world_enter_cascade_resets_the_text_and_asks_again() { + let (mut app, rx) = stone_app(); app.update(); - assert_eq!(queries(&rx), 0, "no VM, no bring-up"); + assert_eq!(stone_queries(&rx), 0, "no cascade, no query"); - app.insert_non_send_resource(UiScript::new().expect("VM")); - app.update(); - assert_eq!(queries(&rx), 1, "the first VM asks the server"); - app.update(); - app.update(); - assert_eq!(queries(&rx), 0, "…and does not ask again on later frames"); + for entry in [ + "the login create", + "a worldport's create", + "a second worldport's", + ] { + answered(&mut app, 1519); + app.world_mut().write_message(WorldEnterCascadeMessage); + app.update(); + assert_eq!(stone_queries(&rx), 1, "{entry} asks the server"); + assert_eq!( + stone_text(&app), + StoneText::Unknown, + "{entry} resets the text" + ); + let stone = app.world().resource::(); + assert!(stone.dirty, "{entry} owes the VM the reset"); + assert_eq!( + stone.area, 1519, + "{entry} keeps the queued area (`[0xb72038]`)" + ); + app.update(); + assert_eq!(stone_queries(&rx), 0, "{entry} asks once, not every frame"); + } + } - // The server answers: the player is queued. - app.world_mut().resource_mut::().area = 1519; - app.world_mut().resource_mut::().dirty = false; - - // `ReloadUI()`: a fresh VM, with no world leave. - app.insert_non_send_resource(UiScript::new().expect("VM")); + /// A cross-map worldport runs the sweep from the local player's destructor (`0x5dd543`), + /// which drops the text (`0x490b2f` → `0x4c9f80`); the login map's verify-world destroys + /// nothing, so the text stays. + #[test] + fn a_cross_map_worldport_drops_the_text_and_the_login_map_does_not() { + let (mut app, rx) = stone_app(); + app.world_mut().write_message(WorldEnterCascadeMessage); app.update(); - assert_eq!( - queries(&rx), - 1, - "a rebuilt VM re-asks — the reference's UI teardown clears the run-once byte, so \ - `UI_Init` re-sends `CMSG 0x296` (0x490a8d / 0x490168)" - ); + answered(&mut app, 1519); - let stone = app.world().resource::(); + app.world_mut().write_message(stone_port(false)); + app.update(); assert!( - stone.dirty, - "the bring-up re-armed the push, so the fresh VM is told the queued area again" + matches!(stone_text(&app), StoneText::Built(_)), + "an arrival keeps it" ); + + app.world_mut().write_message(stone_port(true)); + app.update(); assert_eq!( - stone.area, 1519, - "the queued area itself is untouched, matching `[0xb72038]` surviving the reload" + stone_text(&app), + StoneText::None, + "the worldport's sweep drops it" ); + let stone = app.world().resource::(); + assert!(stone.dirty, "the drop reaches the VM"); + assert_eq!(stone.area, 1519, "the queued area survives the sweep"); + assert_eq!( + stone_queries(&rx), + 1, + "the sweep sends nothing; the new create's cascade will" + ); + } + + /// `/reload` sweeps (`0x490c20`) and then re-enters the cascade (`0x490168`) in one frame: + /// the drop, then the reset and the query, in that order. + #[test] + fn a_reload_drops_the_text_then_resets_it_and_asks_again() { + let (mut app, rx) = stone_app(); + app.world_mut().write_message(WorldEnterCascadeMessage); + app.update(); + assert_eq!(stone_queries(&rx), 1); + answered(&mut app, 1519); + + app.world_mut().write_message(WorldLeaveSweepMessage); + app.world_mut().write_message(WorldEnterCascadeMessage); + app.update(); + assert_eq!(stone_queries(&rx), 1, "the reload re-asks the server"); + assert_eq!( + stone_text(&app), + StoneText::Unknown, + "the reset is the last word" + ); + assert_eq!(app.world().resource::().area, 1519); } #[test] diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index e9744050..eafdcd06 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -486,14 +486,53 @@ pub(crate) fn seat_from_roster( pub(crate) struct AddOnIdentity(pub(crate) Option<(String, String)>); /// The world latch, the reference's `[0xb4b424]`: set at `0x4908ce` in the world-enter cascade -/// `0x4908c0` and cleared at `0x490a8d` as `PLAYER_LEAVING_WORLD` fires (`0x490b4d`, its one -/// site), so the first departure in a world fires and the rest are no-ops until the next entry. -/// Ours has two producers, [`shutdown_ui_state`]'s tail and `ui_unit`'s worldport fire. A +/// `0x4908c0` and cleared at `0x490a8d` by the leave-world sweep `0x490a80`, which then fires +/// `PLAYER_LEAVING_WORLD` (`0x490b4d`, its one site), so the first departure in a world sweeps +/// and the rest are no-ops until the next entry. Ours is spent through [`LeaveWorldSweep`] or +/// [`run_leave_world_sweep`], by [`shutdown_ui_state`]'s roots and `ui_unit`'s worldport fire. A /// cross-map worldport spends it without leaving `InWorld`, so a quit on its loading screen /// fires nothing; a `/reload` fires, and its rebuild re-arms. #[derive(Resource, Default)] pub(crate) struct LeavingWorldArmed(bool); +/// The leave-world sweep `0x490a80` ran past its latch: a cross-map worldport (the local +/// player's destructor, `0x5dd543`), a `/reload` (`0x490c20`) or leaving the world. Its legs +/// past the clear read this, as the meeting stone's text drop (`0x490b2f` → `0x4c9f80`) does; a +/// same-map teleport destroys nothing and never sweeps. +#[derive(Message)] +pub(crate) struct WorldLeaveSweepMessage; + +/// The latch and the sweep it gates, for a system: spending one writes the other. +#[derive(bevy::ecs::system::SystemParam)] +pub(crate) struct LeaveWorldSweep<'w> { + armed: ResMut<'w, LeavingWorldArmed>, + sweeps: MessageWriter<'w, WorldLeaveSweepMessage>, +} + +impl LeaveWorldSweep<'_> { + /// Run the sweep: `true`, and [`WorldLeaveSweepMessage`] written, at most once per world. + pub(crate) fn run(&mut self) -> bool { + let swept = self.armed.spend(); + if swept { + self.sweeps.write(WorldLeaveSweepMessage); + } + swept + } +} + +/// [`LeaveWorldSweep::run`] for an exclusive edge; no latch is no world to leave. +pub(crate) fn run_leave_world_sweep(world: &mut World) -> bool { + let swept = world + .get_resource_mut::() + .is_some_and(|mut l| l.spend()); + if swept { + if let Some(mut sweeps) = world.get_resource_mut::>() { + sweeps.write(WorldLeaveSweepMessage); + } + } + swept +} + impl LeavingWorldArmed { /// A world began. Idempotent, like the reference's `mov byte [0xb4b424],1`. pub(crate) fn arm(&mut self) { @@ -501,7 +540,7 @@ impl LeavingWorldArmed { } /// Take the one-shot: `true` at most once per world, `0x490a8d`'s clear folded in. - pub(crate) fn spend(&mut self) -> bool { + fn spend(&mut self) -> bool { std::mem::take(&mut self.0) } @@ -566,9 +605,7 @@ pub(crate) fn end_ui_session(world: &mut World) { .get_resource::() .and_then(|id| id.0.clone()); // Spent at the root: of this edge and the worldport's, the first to a departure owns it. - let leaving_world = world - .get_resource_mut::() - .is_some_and(|mut l| l.spend()); + let leaving_world = run_leave_world_sweep(world); if !ui_never_loaded { if let Some(mut script) = world.get_non_send_resource_mut::() { shutdown_ui_state(&mut script, identity.as_ref(), leaving_world); @@ -651,7 +688,7 @@ pub(crate) fn shutdown_on_exit( script: Option>, id: Res, pending_entry: Option>, - mut armed: ResMut, + mut sweep: LeaveWorldSweep, mut exits: MessageReader, ) { if exits.read().next().is_none() { @@ -661,7 +698,7 @@ pub(crate) fn shutdown_on_exit( if pending_entry.is_some() { return; } - let leaving_world = armed.spend(); + let leaving_world = sweep.run(); if let Some(mut script) = script { shutdown_ui_state(&mut script, id.0.as_ref(), leaving_world); } @@ -853,29 +890,42 @@ mod tests { assert_eq!(*world.resource::(), on, "a reload keeps them"); } - /// A `ReloadUI()` re-enters the world-enter cascade (`0x490168`), whose readers re-send the - /// time, mail and battlefield queries, only with the active player set (`0x490166 je`). + /// A `ReloadUI()` runs the leave-world sweep (`0x490c20`, past the armed latch) and then + /// re-enters the world-enter cascade (`0x490168`), whose readers re-send the time, mail, + /// battlefield and meeting-stone queries, only with the active player set (`0x490166 je`). #[test] - fn a_reload_reenters_the_world_enter_cascade_once_the_player_exists() { + fn a_reload_sweeps_and_reenters_the_world_enter_cascade_once_the_player_exists() { use crate::net::WorldEnterCascadeMessage; let reload = |seated: bool| { let mut world = World::new(); world.insert_resource(State::new(crate::char_select::ClientState::InWorld)); world.insert_resource(crate::run_mode::CaptureMode); world.init_resource::>(); + world.init_resource::>(); + world.init_resource::(); + world.resource_mut::().arm(); if seated { world.spawn(crate::net::SelfPlayer); } world.insert_resource(ReloadUiPending(true)); run_pending_reload(&mut world); assert!(!world.resource::().0, "the reload ran"); - world + let sweeps = world + .resource_mut::>() + .drain() + .count(); + let cascades = world .resource_mut::>() .drain() - .count() + .count(); + (sweeps, cascades) }; - assert_eq!(reload(true), 1, "in the world"); - assert_eq!(reload(false), 0, "before our own player's create"); + assert_eq!(reload(true), (1, 1), "in the world"); + assert_eq!( + reload(false).1, + 0, + "no cascade before our own player's create" + ); } /// The reference re-makes its Lua state inside `UI_Init` (`0x48fe97`). diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index a443ecf2..47df8f77 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -295,6 +295,7 @@ impl Plugin for UiScriptPlugin { ) // `init_` here and in `UiUnitPlugin`: either plugin may be built alone in a test. .init_resource::() + .add_message::() .add_systems(Update, lifecycle::arm_leaving_world_on_self_create) // A queued `ReloadUI()` runs in `PreUpdate`, a frame after its drain (the reference's // deferral, `0x495590`) and before every `Update` seed or feed. @@ -381,8 +382,8 @@ fn arbitrate_pointer_over_ui( /// The session lifecycle: the VM's birth, identity, death and reload. mod lifecycle; pub(crate) use lifecycle::{ - end_ui_session, ingame_ui_up, run_pending_reload, setup_script, AddOnIdentity, - LeavingWorldArmed, PendingEntryUiLoad, ReloadUiPending, + end_ui_session, ingame_ui_up, run_pending_reload, setup_script, AddOnIdentity, LeaveWorldSweep, + LeavingWorldArmed, PendingEntryUiLoad, ReloadUiPending, WorldLeaveSweepMessage, }; // Test-only: other modules' tests consume these, and a plain re-export would warn unused. #[cfg(test)] diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 8102fe82..41a89027 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -64,9 +64,9 @@ pub(crate) struct CombatTextEvent { /// /// Deviation: fires with our descriptor present, so `UnitExists("player")` answers true where the /// reference's (`0x515970`) misses, because matching it breaks `UnitExists` for no stock reader. -fn fire_leaving_world_on_worldport( +pub(crate) fn fire_leaving_world_on_worldport( script: Option>, - mut armed: ResMut, + mut sweep: crate::ui_script::LeaveWorldSweep, mut ports: MessageReader, ) { // `needs_ack` false is the initial-login map, an arrival; the whole iterator is read so no @@ -77,7 +77,7 @@ fn fire_leaving_world_on_worldport( } // The world latch keeps this producer and the shutdown tail from both claiming one departure; // spent even with no VM, as the reference clears it at `0x490a8d`, ahead of the fire. - if !armed.spend() { + if !sweep.run() { return; } let Some(mut script) = script else { @@ -145,6 +145,7 @@ impl Plugin for UiUnitPlugin { .add_message::() .add_message::() .init_resource::() + .add_message::() .add_systems( Update, ( @@ -3593,6 +3594,7 @@ mod tests { let mut app = App::new(); app.add_message::() .init_resource::() + .add_message::() .add_systems(Update, fire_leaving_world_on_worldport); app.insert_non_send_resource(UiScript::new().expect("VM")); app.world_mut() diff --git a/crates/benilla-protocol/src/world/writer/meeting_stone.rs b/crates/benilla-protocol/src/world/writer/meeting_stone.rs index 62afd247..0bf79c3d 100644 --- a/crates/benilla-protocol/src/world/writer/meeting_stone.rs +++ b/crates/benilla-protocol/src/world/writer/meeting_stone.rs @@ -25,7 +25,8 @@ impl WorldWriter { ) } - /// `CMSG_MEETINGSTONE_STATUS_QUERY`, empty: the reference sends it once per world session. + /// `CMSG_MEETINGSTONE_STATUS_QUERY`, empty: the reference sends it at each world-enter + /// cascade, so at login, after every cross-map worldport and on every `/reload`. pub fn meeting_stone_status_query(&mut self) -> Result<()> { self.send(opcode::CMSG_MEETINGSTONE_STATUS_QUERY, &[]) } From 80b08486287bce1f32d0cf24ed54a1913b3e7e9e Mon Sep 17 00:00:00 2001 From: goran2711 Date: Thu, 8 Oct 2026 01:16:27 +0100 Subject: [PATCH 12/21] fix: trainer services show their spell's description, as 1.12.1 does (#803) - capture: trainer scenarios shoot the service description field (#793) - fix: trainer services show their spell's description, as 1.12.1 does (#793) - review: reuse the caster closure, cut repeated comments, pin the profession rows - trainer: the pet's skill value is the tooltip's one helper, not a copy - trainer: the recipe row's real-data test reads a product that has text Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Sam --- crates/benilla-app/src/capture/fixtures.rs | 128 ++++- crates/benilla-app/src/capture/mod.rs | 4 +- crates/benilla-app/src/capture/scenarios.rs | 46 ++ crates/benilla-app/src/spell/mod.rs | 9 + .../benilla-app/src/ui_tooltip/spell_feed.rs | 15 +- crates/benilla-app/src/ui_trainer/law.rs | 78 ++- crates/benilla-app/src/ui_trainer/mod.rs | 86 +++- crates/benilla-app/src/ui_trainer/tests.rs | 457 +++++++++++++++++- 8 files changed, 766 insertions(+), 57 deletions(-) diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index 758659c5..6ae24160 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -1,6 +1,7 @@ //! The capture UI fixtures: each arm seeds one window's synthetic but realistic state. use super::*; +use benilla_protocol::messages::trainer_spell_state; /// The `vplates` fixture's wolf, the reference screenshot's subject (vmangos `creature_template` /// 69 "Timber Wolf": level 2, faction template 32, display 604), at the scenario's look point. @@ -23,6 +24,66 @@ const CHEST_GUID: u64 = (0xF110u64 << 48) | 0x744; /// 45°), so `front` shows the lit side and `rear` the unlit one. const SUBJECT_YAW: f32 = 2.36; +/// One trainer fixture's canned seed: the trainer's identity, its `SMSG_TRAINER_LIST` type, its +/// greeting, the character's hearthstone bind area, and the wire rows +/// `(spell, state, copper cost, required level)`. +struct TrainerSeed { + name: &'static str, + subname: &'static str, + trainer_type: u32, + greeting: &'static str, + bind: u32, + rows: &'static [(u32, u8, u32, u8)], +} + +/// Llane Beshere (entry 911), Northshire's warrior trainer: the rank-1 learn wrappers a vmangos +/// trainer lists (`1605` teaches Heroic Strike 78 and so on), so each row's name and rank mirror +/// the ability while its description is the taught spell's own text. +const TRAINER_WARRIOR: TrainerSeed = TrainerSeed { + name: "Llane Beshere", + subname: "Warrior Trainer", + trainer_type: 0, + greeting: "I can train you in the ways of the warrior.", + bind: 9, // Northshire Valley, where this character's hearthstone is + rows: &[ + (1605, trainer_spell_state::GREEN, 10, 1), // Heroic Strike Rank 1 + (1738, trainer_spell_state::GREEN, 100, 4), // Charge Rank 1 + (1423, trainer_spell_state::GREEN, 100, 4), // Rend Rank 1 + (6674, trainer_spell_state::GREEN, 200, 6), // Battle Shout Rank 1 + (1716, trainer_spell_state::GREEN, 300, 8), // Hamstring Rank 1 + (3128, trainer_spell_state::GREEN, 200, 8), // Parry + ], +}; + +/// A shaman trainer's Astral Recall (wrapper 1352 teaches 556). One service only: the stock +/// window's `ClassTrainer_SelectFirstLearnableSkill` selects row 2, so the row the shot is about +/// must be the first service of the first group. +const TRAINER_SHAMAN: TrainerSeed = TrainerSeed { + name: "Sian'tsu", + subname: "Shaman Trainer", + trainer_type: 0, + greeting: "The spirits are strong within you, shaman.", + bind: 362, // Razor Hill, a Durotar hearthstone + rows: &[(1352, trainer_spell_state::GREEN, 4000, 30)], +}; + +/// Bengus Deepforge, Ironforge's blacksmithing trainer, with the two row shapes a profession +/// trainer lists: the profession-learn wrapper (2020 teaches 2018, whose own text the row shows) +/// and a recipe (7820 teaches 7818, whose text is the description of the Silver Rod it makes). +/// The learn row sorts into group 1 (`Effect` 44 `SKILL_STEP`) ahead of the recipe's group 2 +/// (`0x4d77b6`), so the stock window's row-2 selection lands on it. +const TRAINER_BLACKSMITH: TrainerSeed = TrainerSeed { + name: "Bengus Deepforge", + subname: "Blacksmithing Trainer", + trainer_type: 2, // a tradeskill trainer + greeting: "The forge shapes what the mine provides.", + bind: 1537, // Ironforge + rows: &[ + (2020, trainer_spell_state::GREEN, 9, 5), // Apprentice Blacksmith + (7820, trainer_spell_state::GREEN, 90, 0), // Silver Rod + ], +}; + /// Seeds the fixture window's state once the scene is resident; the real feeds push it into the VM /// during the settle window as live wire data would. Icons resolve through the offline /// `ItemDisplayCatalog`, and names go straight into the caches. @@ -35,9 +96,14 @@ pub(super) fn seed_ui_fixture( mut quest: ResMut, mut quest_log: ResMut, mut loot: ResMut, + mut trainer: ResMut, // One param under Bevy's 16-param cap: item objects are entities in the index, spawned as - // the wire does. - store: (ResMut, ResMut), + // the wire does; the hearthstone bind (`SMSG_BINDPOINTUPDATE`) rides along for the `$z` token. + store: ( + ResMut, + ResMut, + ResMut, + ), mut names: ResMut, icons: Option>, mut script: Option>, @@ -51,7 +117,7 @@ pub(super) fn seed_ui_fixture( ResMut, ), ) { - let (mut items, mut index) = store; + let (mut items, mut index, mut home_bind) = store; // A glue-screen capture has no world scenario, and no glue screen opens a UI fixture. let Some(scenario) = ctx.scenario else { return; @@ -206,6 +272,62 @@ pub(super) fn seed_ui_fixture( }); } } + UiFixture::Trainer(list) => { + // The seed's rows are the wire list: its copper costs, level gates and the states the + // server computed, in wire order. + use benilla_protocol::messages::TrainerSpell; + let seed = match list { + TrainerList::Warrior => &TRAINER_WARRIOR, + TrainerList::Shaman => &TRAINER_SHAMAN, + TrainerList::Blacksmith => &TRAINER_BLACKSMITH, + }; + names.insert_creature( + NPC_ENTRY, + Some(crate::names::CreatureRecord { + name: seed.name.into(), + subname: Some(seed.subname.into()), + creature_type: 7, + pet_family: 0, + rank: 0, + type_flags: 0, + civilian: false, + racial_leader: false, + display_id: 0, + }), + ); + home_bind.0 = Some(seed.bind); + let Some(script) = script.as_mut() else { + return; + }; + // The window's title and portrait read `UnitName("npc")`; no session seats it here. + script.set_unit( + "npc", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some(seed.name.into()), + level: 12, + ..Default::default() + }), + ); + // A purse large enough for every cost above, so the money frame renders white. + script.set_money(10_000); + let services: Vec = seed + .rows + .iter() + .map(|&(spell, state, cost, req_level)| TrainerSpell { + spell, + state, + cost, + can_learn_primary_prof: false, + is_primary_prof_first_rank: false, + req_level, + req_skill: 0, + req_skill_value: 0, + req_spells: [0; 3], + }) + .collect(); + trainer.open(NPC_GUID, seed.trainer_type, services, seed.greeting.into()); + } UiFixture::Gossip => { names.insert_creature( NPC_ENTRY, diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 07a4da74..cc964f8a 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -117,7 +117,9 @@ pub(crate) use probes::{ }; pub(crate) use scenarios::ui_opted_in; use scenarios::GlueScreen; -use scenarios::{Scenario, SubjectKind, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS}; +use scenarios::{ + Scenario, SubjectKind, TrainerList, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS, +}; pub(crate) mod fxview; // The scripted aim, swim-pitch and camera-rig drivers order themselves before `player::control`: diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index db3258f8..117495c0 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -18,6 +18,20 @@ pub(super) struct Scenario { pub(super) ui: Option, } +/// Which trainer [`UiFixture::Trainer`] captures. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum TrainerList { + /// Llane Beshere (entry 911), Northshire's warrior trainer: the rank-1 learn wrappers a + /// vmangos trainer lists, so the description is the taught ability's own text. + Warrior, + /// A shaman trainer's Astral Recall row (wrapper 1352 teaches 556), the shipped spell whose + /// description carries the `$z` token, so the shot pins the bind area's name too. + Shaman, + /// A profession trainer's two row shapes: the learn wrapper whose text is the taught + /// profession's, and a recipe whose text is its product's. + Blacksmith, +} + /// A UI window opened with synthetic state that mirrors what the server sends, so the capture /// runs the real feed, VM, extract and render chain. #[derive(Clone, Copy, PartialEq)] @@ -29,6 +43,9 @@ pub(super) enum UiFixture { Merchant, Gossip, Quest, + /// The class trainer window fed as `SMSG_TRAINER_LIST` would: the first learnable service + /// selected, its description expanded from `Spell.dbc`. + Trainer(TrainerList), /// The bank window fed through a synthetic self-player descriptor: `PLAYER_FIELD_BANK_SLOT` /// guids, a bank bag, the purchased count in `PLAYER_BYTES_2` byte 2, and coinage. Bank, @@ -936,6 +953,35 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ minute: 720, ui: Some(UiFixture::NameRank), }, + // The warrior trainer's window over the noon ground view, a learnable service selected. + Scenario { + name: "ui-trainer", + map: Some(MAP_AZEROTH), + eye: GROUND_EYE, + look: GROUND_LOOK, + minute: 720, + ui: Some(UiFixture::Trainer(TrainerList::Warrior)), + }, + // A shaman trainer's window: the one shipped service whose description carries `$z`, over a + // bind point, so the shot pins the token against `AreaTable`. + Scenario { + name: "ui-trainer-astral", + map: Some(MAP_AZEROTH), + eye: GROUND_EYE, + look: GROUND_LOOK, + minute: 720, + ui: Some(UiFixture::Trainer(TrainerList::Shaman)), + }, + // A blacksmithing trainer's window: the profession-learn row, whose text is the taught + // profession's, with a recipe row beneath it. + Scenario { + name: "ui-trainer-blacksmith", + map: Some(MAP_AZEROTH), + eye: GROUND_EYE, + look: GROUND_LOOK, + minute: 720, + ui: Some(UiFixture::Trainer(TrainerList::Blacksmith)), + }, ]; /// The `name-close` subject: the `name-water` wolf's name, orbited at any distance. World text is diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index ef851862..c05e564f 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -130,6 +130,15 @@ pub(crate) fn spell_skill_value( skill_line_value(me, line) } +/// A pet's `[vtbl+0xa8]`, `CGUnit`'s `0x60cd80`: `UNIT_FIELD_LEVEL × 5`, which +/// [`benilla_formats::SpellDisplay::skill_level`] caps at `maxLevel × 5` and divides by 5 as +/// `0x60cdb2`-`0x60cdb9` and `0x6e3195` do. No unit reads 0 (`0x6e31a4`). +pub(crate) fn pet_skill_value(pet: Option<&benilla_protocol::ObjectFields>) -> u32 { + pet.and_then(|p| p.unit_level()) + .unwrap_or(0) + .saturating_mul(5) +} + /// The player's skill in `line`, [`spell_skill_value`]'s second half: 0 without a player or a /// slot for it. pub(crate) fn skill_line_value(me: Option<&crate::net::ObjectStore>, line: u32) -> u32 { diff --git a/crates/benilla-app/src/ui_tooltip/spell_feed.rs b/crates/benilla-app/src/ui_tooltip/spell_feed.rs index d19c6125..234afacc 100644 --- a/crates/benilla-app/src/ui_tooltip/spell_feed.rs +++ b/crates/benilla-app/src/ui_tooltip/spell_feed.rs @@ -31,17 +31,6 @@ pub(super) enum ViewCaster<'a> { Pet(Option<&'a benilla_protocol::ObjectFields>), } -impl ViewCaster<'_> { - /// The pet's `[vtbl+0xa8]`, `CGUnit`'s `0x60cd80`: `UNIT_FIELD_LEVEL × 5`, which - /// [`benilla_formats::SpellDisplay::skill_level`] caps at `maxLevel × 5` and divides by 5 as - /// `0x60cdb2`-`0x60cdb9` and `0x6e3195` do. No unit reads 0 (`0x6e31a4`). - fn pet_skill_value(pet: Option<&benilla_protocol::ObjectFields>) -> u32 { - pet.and_then(|p| p.unit_level()) - .unwrap_or(0) - .saturating_mul(5) - } -} - /// The caster-dependent inputs of a spell view: the worn set (`0x5f0c50`), the bags, the form and /// the bind point `$z` names. pub(super) struct ViewCtx<'a, 'w, 's> { @@ -99,7 +88,7 @@ pub(super) fn build_view( line.map_or(0, |line| crate::spell::skill_line_value(vctx.store, line)) } // The pet's level is one of the pet's own inputs. - ViewCaster::Pet(pet) => ViewCaster::pet_skill_value(pet), + ViewCaster::Pet(pet) => crate::spell::pet_skill_value(pet), }; // A cross-referenced spell's own family mask decides its modifiers. let lookup = |id| { @@ -118,7 +107,7 @@ pub(super) fn build_view( vctx.store.and_then(|s| s.0.unit_gender()).unwrap_or(0) }; // `0x6e3130` for this spell: the level the cost's and the cast time's per-level terms read. - let pet_level = |pet| d.skill_level(ViewCaster::pet_skill_value(pet)); + let pet_level = |pet| d.skill_level(crate::spell::pet_skill_value(pet)); let ctx = benilla_formats::TokenContext { durations: &spells.durations, radii: &spells.radii, diff --git a/crates/benilla-app/src/ui_trainer/law.rs b/crates/benilla-app/src/ui_trainer/law.rs index 33539537..250a844c 100644 --- a/crates/benilla-app/src/ui_trainer/law.rs +++ b/crates/benilla-app/src/ui_trainer/law.rs @@ -4,10 +4,11 @@ //! not unify them. use benilla_formats::{ - SkillLineCatalog, SpellCatalog, SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM, - SPELL_EFFECT_LEARN_PET_SPELL, SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_SKILL_STEP, + substitute, SkillLineCatalog, SpellCatalog, TokenContext, SPELL_ATTR_IS_TRADESKILL, + SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_LEARN_PET_SPELL, SPELL_EFFECT_LEARN_SPELL, + SPELL_EFFECT_SKILL_STEP, }; -use benilla_protocol::messages::trainer_spell_state; +use benilla_protocol::messages::{trainer_spell_state, TrainerSpell}; use benilla_ui::script::{TrainerServiceCategory, TrainerTooltip, TRAINER_GROUP_KNOWN}; use crate::entities::ItemDisplays; @@ -92,6 +93,77 @@ pub(super) fn service_tooltip(wire_spell: u32, spells: &SpellCatalog) -> Trainer wire_only } +/// The `$`-token contexts a service description expands over, picked per row by the caster the +/// expander's level terms read (`0x6e3130`'s selector): the active player's, or the pet's, which +/// a `LEARN_PET_SPELL` wrapper selects. +pub(super) struct ServiceText<'a> { + pub(super) player: &'a TokenContext<'a>, + pub(super) pet: &'a TokenContext<'a>, +} + +/// `GetTrainerServiceDescription 0x4d9b40`: the text the stock window shows under the service's +/// cost — `Spell.dbc`'s Description column with its `$`-tokens expanded (`0x5075f0` at +/// `0x4d9d6f`). Which record is expanded depends on the row: the wire spell's own text when it +/// has one (`0x4d9c32`), else the spell the first learn effect teaches — `EffectTriggerSpell` of +/// the first `LEARN_SPELL`/`LEARN_PET_SPELL` slot, whether or not its trigger resolves +/// (`0x4d9bdd`-`0x4d9c18`) — with that spell's own text, else, for a tradeskill recipe, the +/// description of the item the taught spell creates (`0x4d9cd0`-`0x4d9d22`, returned verbatim, +/// never expanded). A used row at a mount trainer skips its own text (`0x4d9c29`), and every +/// failure falls back to the wire text again (`0x4d9d3b`, probing slot 0 alone for the caster). +/// The expansion itself is the spell tooltip's description call (`0x52f717`): level override 0, +/// the Description column, and the modifiers applied. +pub(super) fn service_description( + wire: &TrainerSpell, + trainer_type: u32, + spells: &SpellCatalog, + items: &Items, + commands: &NetCommands, + text: &ServiceText, +) -> String { + let Some(d) = spells.get(wire.spell) else { + return String::new(); + }; + let learn = (0..3).find(|&i| { + matches!( + d.effects[i], + SPELL_EFFECT_LEARN_SPELL | SPELL_EFFECT_LEARN_PET_SPELL + ) + }); + let caster = |slot: usize| { + if d.effects[slot] == SPELL_EFFECT_LEARN_PET_SPELL { + text.pet + } else { + text.player + } + }; + let wire_text = d.description.as_deref().filter(|t| !t.is_empty()); + if !(trainer_type == TRAINER_TYPE_MOUNT && wire.state == trainer_spell_state::GRAY) { + if let Some(t) = wire_text { + return substitute(t, d, learn.map_or(text.player, caster)); + } + } + if let Some(slot) = learn { + if let Some(taught) = spells.get(d.effect_trigger_spell[slot]) { + if let Some(t) = taught.description.as_deref().filter(|t| !t.is_empty()) { + return substitute(t, taught, caster(slot)); + } + // The recipe's product carries the only text a `CREATE_ITEM` spell has. + if taught.attributes & SPELL_ATTR_IS_TRADESKILL != 0 + && taught.effects[0] == SPELL_EFFECT_CREATE_ITEM + { + if let Some(t) = items + .template(taught.effect_item_type[0], 0, commands) + .map(|item| item.description.clone()) + .filter(|t| !t.is_empty()) + { + return t; + } + } + } + } + wire_text.map_or_else(String::new, |t| substitute(t, d, caster(0))) +} + /// The list builder `0x4d7560`'s group key (`0x4d7786`). Type 2 resolves no skill line, so no row /// drops: 1 when the wire spell steps a skill (`0x4d77b6`), else 2, labelled from the table at /// `0x807520`. Type 1 puts a known service in the signed `-1` group (`0x4d77e8`). The rest key on diff --git a/crates/benilla-app/src/ui_trainer/mod.rs b/crates/benilla-app/src/ui_trainer/mod.rs index 011fc598..90143f3c 100644 --- a/crates/benilla-app/src/ui_trainer/mod.rs +++ b/crates/benilla-app/src/ui_trainer/mod.rs @@ -5,7 +5,7 @@ use std::collections::BTreeSet; -use benilla_formats::{SkillLineCatalog, SpellCatalog}; +use benilla_formats::{SkillLineCatalog, SpellCatalog, TokenContext}; use benilla_protocol::messages::TrainerSpell; use bevy::prelude::*; @@ -163,6 +163,7 @@ fn resolve_service( icons: Option<&ItemDisplays>, items: &Items, commands: &NetCommands, + text: &ServiceText, // The VM's own `GlobalStrings.lua`, for the group header labels. get: &dyn Fn(&str) -> Option, ) -> TrainerService { @@ -224,9 +225,7 @@ fn resolve_service( subtext: display.and_then(|d| d.rank.clone()), // Over the wire spell, like the name and subtext ([`service_icon`]). texture: service_icon(wire.spell, trainer_type, spells, icons, items, commands), - // Empty: the 1.12 `GetTrainerServiceDescription` returns `Spell.dbc`'s Description with - // its `$s1`/`$o1`/`$d`/`$a1` tokens substituted, and that substitution is not built. - description: String::new(), + description: service_description(wire, trainer_type, spells, items, commands, text), cost: wire.cost, prof_first_rank: wire.is_primary_prof_first_rank, category: cat, @@ -249,6 +248,7 @@ fn snapshot( icons: Option<&ItemDisplays>, items: &Items, commands: &NetCommands, + text: &ServiceText, get: &dyn Fn(&str) -> Option, ) -> Option { open.trainer?; @@ -268,6 +268,7 @@ fn snapshot( icons, items, commands, + text, get, ) }) @@ -288,6 +289,16 @@ pub(crate) struct ReEvalInputs<'w, 's> { edges: MessageReader<'w, 's, FieldChanged>, } +/// The resources a service description's `$`-tokens read beside the caster's descriptor: the +/// spell-modifier tables and the bind point `$z` names, the same two reads the item and spell +/// tooltip feeds make. +#[derive(SystemParam)] +pub(crate) struct DescriptionReads<'w> { + spell_mods: Res<'w, crate::spell::SpellModifiers>, + home_bind: Option>, + area_names: Option>, +} + /// Push the current trainer into the VM and fire its events on a change. Another trainer while /// open is a close then an open: `ShowUIPanel` returns early on a visible frame. #[allow(clippy::too_many_arguments)] // one Bevy system's full input set @@ -299,6 +310,7 @@ pub(crate) fn feed_trainer( spells: Option>, skill_lines: Option>, mut re_eval: ReEvalInputs, + desc: DescriptionReads, // A tradeskill row shows its created item's icon: the template cache and `ItemDisplayInfo.dbc`. icons: Option>, items: Res, @@ -416,23 +428,63 @@ pub(crate) fn feed_trainer( &player, ); } - let fresh = snapshot( - &open, - &spells.catalog, - Some(&skill_lines.catalog), - &actions.spells, - icons.as_deref(), - &items, - &commands, - &|key: &str| { + // The spell tooltip's description reads (`0x52f717`), and `0x6e3159`'s charm-else-summon for + // a `LEARN_PET_SPELL` row. + let store = self_player.map(|(_, store)| store); + let pet = store + .and_then(|s| s.0.unit_pet_guid()) + .and_then(|guid| re_eval.index.0.get(&guid).copied()) + .and_then(|entity| re_eval.stores.get(entity).ok()); + let fresh = { + let gender = store.and_then(|s| s.0.unit_gender()).unwrap_or(0); + let home_area: Option<&str> = desc + .home_bind + .as_deref() + .and_then(|b| b.0) + .and_then(|id| desc.area_names.as_deref()?.0.resolve(id as i32)); + let player_skill = + |id: u32| crate::spell::spell_skill_value(store, Some(&skill_lines.catalog), id); + let pet_skill = |_: u32| crate::spell::pet_skill_value(pet.map(|p| &p.0)); + let get = |key: &str| { script .lua() .globals() .get::(key) .ok() .filter(|t| !t.is_empty()) - }, - ); + }; + let ctx = TokenContext { + durations: &spells.durations, + radii: &spells.radii, + ranges: Some(&spells.ranges), + skill: &player_skill, + lookup: &|id| spells.catalog.get(id), + mods: Some(&*desc.spell_mods), + unmodified_points: false, + gender: &|| gender, + home_area: &|| home_area, + global: &get, + printf: &crate::ui_script::token_printf, + }; + let pet_ctx = TokenContext { + skill: &pet_skill, + ..ctx + }; + snapshot( + &open, + &spells.catalog, + Some(&skill_lines.catalog), + &actions.spells, + icons.as_deref(), + &items, + &commands, + &ServiceText { + player: &ctx, + pet: &pet_ctx, + }, + &get, + ) + }; // A spell subject's view must be in the store before the detail icon is hovered. let mut fresh_tooltip_subjects = TrainerTooltipSubjects::default(); for service in fresh.iter().flat_map(|state| &state.services) { @@ -528,7 +580,9 @@ fn drain_trainer( } mod law; -use law::{category, service_group, service_icon, service_tooltip}; +use law::{ + category, service_description, service_group, service_icon, service_tooltip, ServiceText, +}; #[cfg(test)] mod tests; diff --git a/crates/benilla-app/src/ui_trainer/tests.rs b/crates/benilla-app/src/ui_trainer/tests.rs index 1bcb7c0f..a86ed923 100644 --- a/crates/benilla-app/src/ui_trainer/tests.rs +++ b/crates/benilla-app/src/ui_trainer/tests.rs @@ -27,6 +27,43 @@ fn probe_strings(key: &str) -> Option { } } +/// Run `f` with the description contexts over the test catalogs: the casters' skill values +/// (`level × 5` for a pet) and the bind point `$z` names. +fn with_text( + spells: &SpellCatalog, + player_skill: u32, + pet_skill: u32, + home: Option<&str>, + f: impl FnOnce(&ServiceText) -> R, +) -> R { + let durations = benilla_formats::SpellDurationCatalog::default(); + let radii = benilla_formats::SpellRadiusCatalog::default(); + let lookup = |id: u32| spells.get(id); + let skill_of = |_: u32| player_skill; + let pet_of = |_: u32| pet_skill; + let ctx = TokenContext { + durations: &durations, + radii: &radii, + ranges: None, + skill: &skill_of, + lookup: &lookup, + mods: None, + unmodified_points: false, + gender: &|| 0, + home_area: &|| home, + global: &probe_strings, + printf: &crate::ui_script::token_printf, + }; + let pet_ctx = TokenContext { + skill: &pet_of, + ..ctx + }; + f(&ServiceText { + player: &ctx, + pet: &pet_ctx, + }) +} + /// [`resolve_service`] with no icon sources. fn resolve_service( wire: &TrainerSpell, @@ -36,17 +73,20 @@ fn resolve_service( known: &BTreeSet, ) -> TrainerService { let deps = Deps::new(); - super::resolve_service( - wire, - trainer_type, - spells, - skill_lines, - known, - None, - &deps.items, - &deps.commands, - &probe_strings, - ) + with_text(spells, 0, 0, None, |text| { + super::resolve_service( + wire, + trainer_type, + spells, + skill_lines, + known, + None, + &deps.items, + &deps.commands, + text, + &probe_strings, + ) + }) } fn wire(spell: u32, state: u8, cost: u32, req_level: u8, req_skill: u32) -> TrainerSpell { @@ -66,16 +106,19 @@ fn wire(spell: u32, state: u8, cost: u32, req_level: u8, req_skill: u32) -> Trai /// [`snapshot`] with no icon sources. fn snap(open: &TrainerOpen, spells: &SpellCatalog) -> Option { let deps = Deps::new(); - snapshot( - open, - spells, - None, - &BTreeSet::new(), - None, - &deps.items, - &deps.commands, - &probe_strings, - ) + with_text(spells, 0, 0, None, |text| { + snapshot( + open, + spells, + None, + &BTreeSet::new(), + None, + &deps.items, + &deps.commands, + text, + &probe_strings, + ) + }) } /// Wrapper 100 teaches 200 by a slot-0 `LEARN_SPELL`, 200 creates item 777, and 777's display 5 @@ -461,6 +504,378 @@ fn ability_req_shows_the_required_rank_on_real_data() { assert_eq!(svc.ability_reqs[0].name, "Heroic Strike (Rank 1)"); } +/// `GetTrainerServiceDescription 0x4d9b40`: a class trainer's plain ability row expands the wire +/// spell's own `Spell.dbc` Description (`0x4d9c32`). +#[test] +fn service_description_expands_the_wire_spells_own_text() { + let d = SpellDisplay { + description: Some("Increases melee damage by $s1.".into()), + // A flat 11: base points are stored one under the total, on one one-sided die. + effect_base_points: [10, 0, 0], + effect_base_dice: [1, 0, 0], + effect_die_sides: [1, 0, 0], + ..Default::default() + }; + let spells = SpellCatalog::from_displays(HashMap::from([(78, d)])); + let deps = Deps::new(); + with_text(&spells, 0, 0, None, |text| { + assert_eq!( + service_description( + &wire(78, trainer_spell_state::GREEN, 10, 1, 0), + 0, + &spells, + &deps.items, + &deps.commands, + text, + ), + "Increases melee damage by 11." + ); + }); +} + +/// A learn wrapper has no text of its own, so the taught spell's Description is expanded +/// (`0x4d9c6f`); the description is the taught record's, not the wrapper's. +#[test] +fn service_description_hops_to_the_taught_spell() { + let wrapper = SpellDisplay { + effects: [SPELL_EFFECT_LEARN_SPELL, 0, 0], + effect_trigger_spell: [200, 0, 0], + ..Default::default() + }; + let taught = SpellDisplay { + name: "Growl".into(), + description: Some("Causes $s1 threat.".into()), + effect_base_points: [9, 0, 0], + effect_base_dice: [1, 0, 0], + effect_die_sides: [1, 0, 0], + ..Default::default() + }; + let spells = SpellCatalog::from_displays(HashMap::from([(100, wrapper), (200, taught)])); + let deps = Deps::new(); + with_text(&spells, 0, 0, None, |text| { + assert_eq!( + service_description( + &wire(100, trainer_spell_state::GREEN, 10, 1, 0), + 0, + &spells, + &deps.items, + &deps.commands, + text, + ), + "Causes 10 threat." + ); + }); +} + +/// The third arm: a tradeskill recipe has no text of its own, so the created item's Description is +/// returned verbatim — never expanded (`0x4d9cd0`-`0x4d9d22`). +#[test] +fn service_description_takes_the_recipes_product_text_verbatim() { + let wrapper = SpellDisplay { + effects: [SPELL_EFFECT_LEARN_SPELL, 0, 0], + effect_trigger_spell: [200, 0, 0], + ..Default::default() + }; + let recipe = SpellDisplay { + attributes: SPELL_ATTR_IS_TRADESKILL, + effects: [SPELL_EFFECT_CREATE_ITEM, 0, 0], + effect_item_type: [777, 0, 0], + ..Default::default() + }; + let spells = SpellCatalog::from_displays(HashMap::from([(100, wrapper), (200, recipe)])); + let mut deps = Deps::new(); + let mut item = crate::items::test_template("Copper Shortsword"); + item.description = "Keeps $s1 for later.".into(); + deps.items.insert_template(777, Some(item)); + with_text(&spells, 0, 0, None, |text| { + assert_eq!( + service_description( + &wire(100, trainer_spell_state::GREEN, 10, 1, 0), + TRAINER_TYPE_TRADESKILL, + &spells, + &deps.items, + &deps.commands, + text, + ), + "Keeps $s1 for later.", + "the product's text is the item's own, tokens and all" + ); + }); +} + +/// A used row at a mount trainer skips its own text (`0x4d9c29`); the same row at a class trainer +/// shows it. +#[test] +fn service_description_skips_the_wire_text_for_a_used_mount_row() { + let d = SpellDisplay { + description: Some("Summons a mount.".into()), + effects: [SPELL_EFFECT_LEARN_SPELL, 0, 0], + effect_trigger_spell: [200, 0, 0], + ..Default::default() + }; + let taught = SpellDisplay { + description: Some("Riding skill.".into()), + ..Default::default() + }; + let spells = SpellCatalog::from_displays(HashMap::from([(100, d), (200, taught)])); + let deps = Deps::new(); + let description = |trainer_type, state| { + with_text(&spells, 0, 0, None, |text| { + service_description( + &wire(100, state, 10, 1, 0), + trainer_type, + &spells, + &deps.items, + &deps.commands, + text, + ) + }) + }; + assert_eq!( + description(0, trainer_spell_state::GREEN), + "Summons a mount." + ); + assert_eq!( + description(TRAINER_TYPE_MOUNT, trainer_spell_state::GRAY), + "Riding skill." + ); +} + +/// The caster selector: a `LEARN_PET_SPELL` wrapper expands against the pet (`0x6e3130`'s nonzero +/// selector), the same taught spell under a plain `LEARN_SPELL` against the player. +#[test] +fn service_description_expands_against_the_pet_for_a_learn_pet_row() { + // One point per level over `baseLevel` 1: level 0 reads 10, a level-2 pet reads 11. + let taught = SpellDisplay { + description: Some("Causes $s1 threat.".into()), + base_level: 1, + effect_base_points: [9, 0, 0], + effect_base_dice: [1, 0, 0], + effect_die_sides: [1, 0, 0], + effect_dice_per_level: [1, 0, 0], + ..Default::default() + }; + let pet_wrapper = SpellDisplay { + effects: [SPELL_EFFECT_LEARN_PET_SPELL, 0, 0], + effect_trigger_spell: [200, 0, 0], + ..Default::default() + }; + let plain_wrapper = SpellDisplay { + effects: [SPELL_EFFECT_LEARN_SPELL, 0, 0], + effect_trigger_spell: [200, 0, 0], + ..Default::default() + }; + let spells = SpellCatalog::from_displays(HashMap::from([ + (100, pet_wrapper), + (101, plain_wrapper), + (200, taught), + ])); + let deps = Deps::new(); + // A level-2 pet (skill 10) against a player with no skill in the taught spell's line at all. + with_text(&spells, 0, 10, None, |text| { + assert_eq!( + service_description( + &wire(100, trainer_spell_state::GREEN, 10, 1, 0), + 0, + &spells, + &deps.items, + &deps.commands, + text, + ), + "Causes 11 threat.", + "the pet's level drives the taught spell's points" + ); + assert_eq!( + service_description( + &wire(101, trainer_spell_state::GREEN, 10, 1, 0), + 0, + &spells, + &deps.items, + &deps.commands, + text, + ), + "Causes 10 threat.", + "the same taught spell under LEARN_SPELL reads the player's" + ); + }); +} + +/// The `$z` token: the bind point's area name (`AreaTable.dbc`), and raw where no bind point has +/// arrived, as the item and tooltip feeds' `$z` behaves. +#[test] +fn service_description_expands_z_to_the_bind_area() { + let d = SpellDisplay { + description: Some("Returns you to $z.".into()), + ..Default::default() + }; + let spells = SpellCatalog::from_displays(HashMap::from([(556, d)])); + let deps = Deps::new(); + for (home, want) in [ + (Some("Razor Hill"), "Returns you to Razor Hill."), + (None, "Returns you to $z."), + ] { + with_text(&spells, 0, 0, home, |text| { + assert_eq!( + service_description( + &wire(556, trainer_spell_state::GREEN, 4000, 30, 0), + 0, + &spells, + &deps.items, + &deps.commands, + text, + ), + want + ); + }); + } +} + +/// On the shipped `Spell.dbc`: the warrior wrapper 1605 teaches 78 Heroic Strike, so the row's +/// description is the taught ability's text, not the wrapper's empty one. Skips without client +/// data. +#[test] +fn service_description_on_real_data_expands_the_taught_ability() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell"); + + let svc = resolve_service( + &wire(1605, trainer_spell_state::GREEN, 10, 1, 0), + 0, + &spells, + None, + &BTreeSet::new(), + ); + assert!( + svc.description + .starts_with("A strong attack that increases melee damage by "), + "the taught 78's description, not the wrapper's empty one: {:?}", + svc.description + ); + assert!( + !svc.description.contains('$'), + "every token resolved: {:?}", + svc.description + ); +} + +/// On the shipped `Spell.dbc`: 1352 is the shaman trainer's Astral Recall wrapper, teaching 556, +/// whose text is the one trainer-taught description carrying `$z`. Skips without client data. +#[test] +fn service_description_on_real_data_reads_astral_recalls_bind_area() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell"); + + let deps = Deps::new(); + let svc = with_text(&spells, 0, 0, Some("Razor Hill"), |text| { + super::resolve_service( + &wire(1352, trainer_spell_state::GREEN, 4000, 30, 0), + 0, + &spells, + None, + &BTreeSet::new(), + None, + &deps.items, + &deps.commands, + text, + &probe_strings, + ) + }); + assert_eq!( + svc.description, + "Yanks the caster through the twisting nether back to Razor Hill. Speak to an \ + Innkeeper in a different place to change your home location." + ); +} + +/// On the shipped `Spell.dbc`: 2020 is the "Apprentice Blacksmith" wrapper a Blacksmithing trainer +/// lists, teaching 2018 "Blacksmithing" — the profession-learn row whose text is the taught +/// profession's. Skips without client data. +#[test] +fn service_description_on_real_data_reads_the_profession_learn_row() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell"); + + // The wrapper carries no text of its own, so the taught profession's is the only one there is. + assert_eq!( + spells.get(2020).and_then(|d| d.description.as_deref()), + None + ); + let svc = resolve_service( + &wire(2020, trainer_spell_state::GREEN, 9, 5, 0), + TRAINER_TYPE_TRADESKILL, + &spells, + None, + &BTreeSet::new(), + ); + assert_eq!( + svc.description, + "Allows a Blacksmith to make basic weapons and armor up to a maximum potential skill of \ + 75. Requires stone and metal found with the mining skill." + ); +} + +/// On the shipped `Spell.dbc`: 7820 is the recipe wrapper a Blacksmithing trainer lists, 7818 the +/// recipe it teaches and 6338 the Silver Rod it makes, so the row's text is that item's +/// description, read out of the item cache (`0x4d9cd0`-`0x4d9d22`). The product's record arrives +/// with the item query, so the template seeded below, with vmangos's text, stands in for it. +/// Skips without client data. +#[test] +fn service_description_on_real_data_reaches_for_the_recipes_product() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let spells = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell"); + + // Neither the wrapper nor the recipe carries text of its own, so the item is the only source. + assert_eq!( + spells.get(7820).and_then(|d| d.description.as_deref()), + None + ); + assert_eq!( + spells.get(7818).and_then(|d| d.description.as_deref()), + None + ); + + // The law alone, never through `resolve_service`: the icon law asks for the same template + // first, and one ask per pending entry reaches the cache. + let describe = |deps: &Deps| { + with_text(&spells, 0, 0, None, |text| { + service_description( + &wire(7820, trainer_spell_state::GREEN, 90, 0, 164), + TRAINER_TYPE_TRADESKILL, + &spells, + &deps.items, + &deps.commands, + text, + ) + }) + }; + + let deps = Deps::new(); + assert_eq!( + describe(&deps), + "", + "nothing to show until the product's template lands" + ); + assert_eq!( + deps.queried_entries(), + vec![6338], + "the description law reached for the crafted rod's template" + ); + + // With the product's record present, its description is the row's text, verbatim. + let mut product = crate::items::test_template("Silver Rod"); + product.description = "Needed by Enchanters.".into(); + let landed = { + let mut deps = Deps::new(); + deps.items.insert_template(6338, Some(product)); + deps + }; + assert_eq!(describe(&landed), "Needed by Enchanters."); +} + #[test] fn snapshot_is_none_when_closed_and_lists_services_when_open() { let spells = empty_catalog(); From 63e917965d7d21f407c15ff24cf40fdf0e665c89 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:09:41 +0700 Subject: [PATCH 13/21] The quest log counts party members on each quest, as 1.12.1 does (#824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quest log never showed the `[n]` count of party members on a quest, because `IsUnitOnQuest` was a stub that always answered nil. 1.12.1 (`0x4dfe10`) checks both arguments first. It reads the row's quest id (`0x4df150`; a header or out-of-range row answers nil before the unit is resolved), resolves the token, and admits only you or one of the four party slots, by guid (`0x4e7f70`). It then needs the unit held as a player and scans the 20 `PLAYER_QUEST_LOG` ids for that quest. The answer is 1 or nil. The ids reach party members on the wire. `UNIT_QUEST_LOG_CHANGED` (`0x51bc63` → `0x51bd90`) fires once whenever any slot of a unit's window changes, so an open log recounts. Unit snapshots now carry the window, the binding follows those steps, and the event fires. Tests cover each path and run the stock `QuestLogFrame.xml` to a `[2]` row; they fail against the stub. Fixes #818 Co-Authored-By: Claude Opus 5.5 --- .../src/ui_script/questlog_tests.rs | 112 +++++++++ .../benilla-app/src/ui_script/reference_ui.rs | 6 - crates/benilla-app/src/ui_unit.rs | 92 +++++++ .../messages/update_object/fields/player.rs | 11 + .../src/messages/update_object/mod.rs | 13 + crates/benilla-ui/src/script/quest_log.rs | 236 +++++++++++++++++- crates/benilla-ui/src/script/unit/mod.rs | 5 + 7 files changed, 466 insertions(+), 9 deletions(-) diff --git a/crates/benilla-app/src/ui_script/questlog_tests.rs b/crates/benilla-app/src/ui_script/questlog_tests.rs index ee12972f..bb9ea892 100644 --- a/crates/benilla-app/src/ui_script/questlog_tests.rs +++ b/crates/benilla-app/src/ui_script/questlog_tests.rs @@ -1693,3 +1693,115 @@ fn the_stock_title_width_cap_bites_and_is_sticky_per_row_button() { "the capped box survives the row going untagged (the ref's own 275 reset never fires)" ); } + +// ── The group-mates count ── + +/// A party member held with `ids` in their quest-log window, named `name`. +fn mate_on(guid: u64, name: &str, ids: &[u32]) -> benilla_ui::script::UnitState { + let mut quest_log = [[0; 3]; 20]; + for (slot, &id) in quest_log.iter_mut().zip(ids) { + *slot = [id, 0, 0]; + } + benilla_ui::script::UnitState { + exists: true, + has_object: true, + is_player: true, + guid, + name: Some(name.into()), + quest_log, + ..Default::default() + } +} + +/// Each quest row counts the party members on it as `[n]` (`QuestLogFrame.lua:168-183`), through +/// `IsUnitOnQuest(questIndex, "partyN")`; the row tooltip names them (`:515-541`), and a mate's +/// `UNIT_QUEST_LOG_CHANGED` recounts (`:69-72`). +#[test] +fn quest_rows_count_the_party_members_on_each_quest() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); + load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); + load_xml(&s, r"Interface\FrameXML\BasicControls.xml"); + load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua"); + load_xml(&s, r"Interface\FrameXML\UIParent.xml"); + load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua"); + load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml"); + load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); + load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); + load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); + load_xml(&s, r"Interface\FrameXML\StaticPopup.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); + load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); + load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); + load_xml(&s, "ScrollTemplates.xml"); // our scroll kits + + s.set_quest_log(eight_entries()); + s.set_party(party(2)); + s.set_unit("party1", Some(mate_on(0x300, "Mate0", &[1, 3]))); + s.set_unit("party2", Some(mate_on(0x301, "Mate1", &[1]))); + s.run("ToggleQuestLog()").unwrap(); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + + let mates = |s: &mut UiScript, row: u32| { + s.eval::(&format!( + "return QuestLogTitle{row}GroupMates:GetText() or ''" + )) + .unwrap() + }; + assert_eq!(mates(&mut s, 1), "[2]", "both mates are on quest 1"); + assert_eq!(mates(&mut s, 2), "", "nobody is on quest 2"); + assert_eq!(mates(&mut s, 3), "[1]", "party1 is on quest 3"); + + // The row tooltip, as `QuestLogTitleButton_OnEnter` builds it for row 1, then row 2. + let line = |s: &mut UiScript, n: u32| { + s.eval::(&format!("return GameTooltipTextLeft{n}:GetText() or ''")) + .unwrap() + }; + s.run("this = QuestLogTitle1 QuestLog_UpdatePartyInfoTooltip()") + .unwrap(); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + assert_eq!(line(&mut s, 1), "Quest 1"); + assert!(s + .eval::( + "return GameTooltipTextLeft2:GetText() == \ + HIGHLIGHT_FONT_COLOR_CODE..PARTY_QUEST_STATUS_ON..FONT_COLOR_CODE_CLOSE" + ) + .unwrap()); + assert!(line(&mut s, 3).contains("Mate0"), "{}", line(&mut s, 3)); + assert!(line(&mut s, 4).contains("Mate1"), "{}", line(&mut s, 4)); + s.run("this = QuestLogTitle2 QuestLog_UpdatePartyInfoTooltip()") + .unwrap(); + assert!(s + .eval::( + "return GameTooltipTextLeft2:GetText() == \ + HIGHLIGHT_FONT_COLOR_CODE..PARTY_QUEST_STATUS_NONE..FONT_COLOR_CODE_CLOSE" + ) + .unwrap()); + + // party2 takes quest 3: their quest-log event recounts the open log. + s.set_unit("party2", Some(mate_on(0x301, "Mate1", &[1, 3]))); + s.fire_event( + "UNIT_QUEST_LOG_CHANGED", + vec![benilla_ui::script::ScriptValue::Str("party2".into())], + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + assert_eq!(mates(&mut s, 3), "[2]"); + + // Out of range, a mate counts for nothing, whatever their last window held (`0x468460` finds + // no object). + s.set_unit( + "party1", + Some(benilla_ui::script::UnitState { + has_object: false, + ..mate_on(0x300, "Mate0", &[1, 3]) + }), + ); + s.run("QuestLog_Update()").unwrap(); + assert_eq!(mates(&mut s, 1), "[1]"); + assert_eq!(mates(&mut s, 3), "[1]"); +} diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index 12e84344..80ad2df5 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -1937,12 +1937,6 @@ mod tests { ), ("SYSMSG", "UIErrorsFrame.lua"), ("UNIT_DEFENSE", "PetPaperDollFrame.lua"), - ( - "UNIT_QUEST_LOG_CHANGED", - "QuestLogFrame.lua — a party member's quest-log fields changing (the reference \ - fires it off the unit's PLAYER_QUEST_LOG_* descriptor updates); benilla's unit \ - feed does not derive it yet", - ), ( "UNIT_MODEL_CHANGED", "four files — the paperdoll model refresh", diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 41a89027..9b963824 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -929,6 +929,13 @@ pub(crate) fn snapshot( pvp_team: store.0.unit_race().map_or(-1, race_pvp_team), // `PLAYER_BYTES_3` byte 2, the city-protector title (`PVP_MEDAL`), unset by vmangos. pvp_medal: store.0.player_pvp_medal().unwrap_or(0), + // The quest-log window, off a player alone: `IsUnitOnQuest` looks the guid up with + // TYPEMASK_PLAYER (`0x4dfe97`) before it reads `[obj+0xe68]`. + quest_log: if matches!(store.0.object_type(), Some(ObjectType::Player)) { + store.0.player_quest_log_window() + } else { + Default::default() + }, // `is_player` and the creature-record fields come from [`enrich_unit`]. ..Default::default() } @@ -1145,6 +1152,15 @@ pub(crate) fn fire_transitions( if edges.moved(cur.guid, benilla_protocol::field::FIELD_UNIT_DYNAMIC_FLAGS) { script.fire_event("UNIT_DYNAMIC_FLAGS", vec![tok()]); } + // `UNIT_QUEST_LOG_CHANGED` (id 522), the player-window watch over the quest-log slots + // (`0x51bc63`: offset `0x28`, length `0xf0`, callback `0x51bd90`): one compare over the 60 + // dwords, so one event however many moved. A group mate's ids are all of it on the wire. + let log = benilla_protocol::field::FIELD_PLAYER_QUEST_LOG_1_1; + if (log..log + 3 * u16::from(benilla_protocol::messages::PLAYER_QUEST_LOG_SLOTS)) + .any(|i| edges.moved(cur.guid, i)) + { + script.fire_event("UNIT_QUEST_LOG_CHANGED", vec![tok()]); + } // 1.12 names the power events per resource (`UNIT_MANA`, `UNIT_MAXRAGE`, …; // `UnitFrame.lua:190-199`), and `power_token` yields the suffix. if changed(|u| u64::from(u.power)) { @@ -2579,6 +2595,46 @@ mod tests { ); } + /// `UNIT_QUEST_LOG_CHANGED` (id 522) is the player-window watch over the 60 quest-log dwords + /// (`0x51bc63`: offset `0x28`, length `0xf0`, callback `0x51bd90`): any of them moving fires it + /// once with the token, and a dword either side does not. + #[test] + fn a_quest_log_change_fires_unit_quest_log_changed_with_the_token() { + let fired = |prev: &UnitState, cur: &UnitState, edges: &[(u64, u16)]| -> Vec { + let mut s = UiScript::new().unwrap(); + s.run( + r#" + SEEN = {} + local f = CreateFrame("Frame") + f:RegisterEvent("UNIT_QUEST_LOG_CHANGED") + f:SetScript("OnEvent", function() table.insert(SEEN, event .. ":" .. arg1) end) + "#, + ) + .unwrap(); + fire_transitions(&mut s, "party1", Some(prev), cur, &FieldEdges::of(edges)); + s.eval::>("return SEEN").unwrap() + }; + const MATE: u64 = 0x300; + const LOG: u16 = benilla_protocol::field::FIELD_PLAYER_QUEST_LOG_1_1; + let before = UnitState { + exists: true, + has_object: true, + guid: MATE, + ..Default::default() + }; + let mut after = before.clone(); + after.quest_log[4][0] = 783; + let once = vec!["UNIT_QUEST_LOG_CHANGED:party1".to_string()]; + assert_eq!(fired(&before, &after, &[(MATE, LOG + 12)]), once); + // One watch over the window: two slots moving in one pass is one event. + assert_eq!( + fired(&before, &after, &[(MATE, LOG), (MATE, LOG + 59)]), + once + ); + assert!(fired(&before, &after, &[(MATE, LOG - 1), (MATE, LOG + 60)]).is_empty()); + assert!(fired(&before, &after, &[(MATE + 1, LOG + 12)]).is_empty()); + } + /// LOST going down, GAINED going up (boot is in control); far sight on every change. #[test] fn the_control_and_far_sight_edges_fire_once_each_way() { @@ -3153,6 +3209,42 @@ mod tests { ); } + /// The quest-log window rides a player's snapshot whole, and a non-player's is all zero, as + /// `IsUnitOnQuest`'s lookup is TYPEMASK_PLAYER (`0x4dfe97`). + #[test] + fn a_players_snapshot_carries_its_quest_log_window_and_a_creatures_none() { + use benilla_protocol::messages::ObjectFields; + + const OBJECT_TYPE: u16 = 2; + const QUEST_LOG_1_1: u16 = 198; + let log = [ + (QUEST_LOG_1_1, 783), + (QUEST_LOG_1_1 + 1, 0x0100_0003), + (QUEST_LOG_1_1 + 57, 7), + ]; + let snap = |type_bits: u32| { + snapshot( + &ObjectStore(ObjectFields::from_pairs( + &[log.as_slice(), &[(OBJECT_TYPE, type_bits)]].concat(), + )), + 0, + None, + 0, + None, + Default::default(), + ) + }; + let player = snap(0x19); + assert_eq!(player.quest_log[0], [783, 0x0100_0003, 0]); + assert_eq!(player.quest_log[19], [7, 0, 0]); + assert_eq!(player.quest_log[1], [0, 0, 0]); + assert_eq!( + snap(0x09).quest_log, + [[0; 3]; 20], + "a creature has no PLAYER block" + ); + } + /// The rank getter's two gates (`0x605620`), through `enrich_unit`'s wiring. #[test] fn the_rank_gate_zeroes_a_pet_or_charm() { diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs index 8b768369..61035d35 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -202,6 +202,17 @@ impl ObjectFields { timer, }) } + /// The whole `PLAYER_QUEST_LOG` window, `[id, count-state, timer]` per slot as the wire left + /// it, 0 where absent: a group mate's carries the ids alone, the one dword per slot vmangos + /// flags `GROUP_ONLY` (`UpdateFields_1_12_1.h:128-129`). + pub fn player_quest_log_window(&self) -> [[u32; 3]; PLAYER_QUEST_LOG_SLOTS as usize] { + std::array::from_fn(|slot| { + std::array::from_fn(|k| { + self.get_u32(FIELD_PLAYER_QUEST_LOG_1_1 + 3 * slot as u16 + k as u16) + .unwrap_or(0) + }) + }) + } /// `PLAYER_EXPLORED_ZONES_1 + i`: bitset dword `i`; bit `n` is `AreaTable` explore flag `n`. pub fn player_explored_zone_slot(&self, i: u16) -> u32 { if i >= PLAYER_EXPLORED_ZONES_SLOTS { diff --git a/crates/benilla-protocol/src/messages/update_object/mod.rs b/crates/benilla-protocol/src/messages/update_object/mod.rs index 0621e242..74e60c9c 100644 --- a/crates/benilla-protocol/src/messages/update_object/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/mod.rs @@ -135,4 +135,17 @@ mod tests { let f = ObjectFields::from_pairs(&[(198, 0), (199, 0), (200, 0)]); assert_eq!(f.player_quest_log(0).map(|s| s.quest_id), Some(0)); } + + #[test] + fn quest_log_window_is_every_slot_raw_and_zero_where_absent() { + // A group mate's view: ids alone in slots 0 and 19, the private dwords never sent; slot 2 + // carries a count-state dword, kept as sent rather than unpacked. + let f = ObjectFields::from_pairs(&[(198, 783), (198 + 57, 7), (205, 0x0100_0005)]); + let w = f.player_quest_log_window(); + assert_eq!(w.len(), usize::from(PLAYER_QUEST_LOG_SLOTS)); + assert_eq!(w[0], [783, 0, 0]); + assert_eq!(w[1], [0, 0, 0]); + assert_eq!(w[2], [0, 0x0100_0005, 0]); + assert_eq!(w[19], [7, 0, 0]); + } } diff --git a/crates/benilla-ui/src/script/quest_log.rs b/crates/benilla-ui/src/script/quest_log.rs index f8c2535a..24b7b3e3 100644 --- a/crates/benilla-ui/src/script/quest_log.rs +++ b/crates/benilla-ui/src/script/quest_log.rs @@ -6,8 +6,9 @@ //! does (`0x4dfb50` copies the selection `0xbb7480`, a quest id, to `0xbb7484`), so a re-index //! before the popup's Yes cannot retarget them. //! -//! Not built: `IsUnitOnQuest` and `GetAbandonQuestItems` answer nil; the first needs the party -//! members' quest logs. +//! `IsUnitOnQuest` reads a unit's quest-log window off its snapshot ([`super::UnitState`]). +//! +//! Not built: `GetAbandonQuestItems` answers nil. use mlua::{Lua, MultiValue, Value}; @@ -598,9 +599,45 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { super::quest::reward_spell_returns(lua, spell) })?, )?; + // `IsUnitOnQuest(index, "unit")` (`0x4dfe10`): 1 when the unit's quest log holds row + // `index`'s quest, else nil; the stock rows count `[n]` mates (`QuestLogFrame.lua:175`). g.set( "IsUnitOnQuest", - lua.create_function(|_, (_q, _unit): (Value, Value)| Ok(flag(false)))?, + lua.create_function(|lua, (index, token): (Value, Value)| { + const USAGE: &str = "Usage: IsUnitOnQuest(index, \"unit\")"; + // Both are checked before either is read: `lua_isnumber` (`0x4dfe1e`), `lua_isstring` + // (`0x4dfe32`); the index is `_ftol`'d (`0x4dfe4d`). + let n = number_arg(lua, index, USAGE)?; + let token = super::binding_abi::string_arg(lua, token, USAGE)?; + let model = lua.app_data_ref::().expect("model app_data"); + // The row's quest id (`0x4df150`): 0 for a header or a row past the list, which + // answers nil before the token is resolved (`0x4dfe5e`). + let quest_id = usize::try_from(n) + .ok() + .and_then(|n| n.checked_sub(1)) + .and_then(|row| model.quest_log.entries.get(row)) + .filter(|e| !e.is_header) + .map_or(0, |e| e.quest_id); + if quest_id == 0 { + return Ok(Value::Nil); + } + // The token's guid (`0x515970`), which raises on an unknown token. + super::unit::check_unit_token(&Some(token.clone()))?; + let Some(unit) = model.unit(&token) else { + return Ok(Value::Nil); + }; + // The active player or one of the four party slots, by guid (`0x4e7f70`): a raid mate + // outside our subgroup, a pet or a stranger answers nil. + let me = model.unit("player").map_or(0, |p| p.guid); + let grouped = unit.guid != 0 + && (unit.guid == me || model.party.members.iter().any(|m| m.guid == unit.guid)); + // Held as a player (`0x468460`, typemask `0x10`) and not deactivated (`0x614ea0`, a + // state the app's despawn never leaves held), then the 20 slots' ids, stride 12 + // (`0x4dfebd`-`0x4dfed7`); a non-player's window is all zero. + Ok(flag( + grouped && unit.has_object && unit.quest_log.iter().any(|s| s[0] == quest_id), + )) + })?, )?; // ── The party share and the quest watch ────────────────────────────────────────────────────── /// The selected row, which the share verbs read. @@ -1412,4 +1449,197 @@ mod tests { "a header is not a quest" ); } + + // ── IsUnitOnQuest (`0x4dfe10`) ── + + const ME: u64 = 0x10; + const MATE: u64 = 0x300; + const FAR_MATE: u64 = 0x301; + + /// A held unit whose quest-log window holds `ids`, one per slot from the last slot down, so the + /// scan must reach slot 19. + fn held(guid: u64, ids: &[u32]) -> crate::script::UnitState { + let mut quest_log = [[0; 3]; 20]; + for (slot, &id) in quest_log.iter_mut().rev().zip(ids) { + *slot = [id, 0, 0]; + } + crate::script::UnitState { + exists: true, + has_object: true, + is_player: true, + guid, + quest_log, + ..Default::default() + } + } + + /// A header, then quest 783 (row 2) and quest 7 (row 3). We hold both; `party1` holds 783 and + /// `party2` is out of range. `target` is `party1`; `mouseover` a stranger and `raid7` a raid + /// mate outside our subgroup, both holding 783. + fn on_quest_world() -> UiScript { + let mut s = UiScript::new().unwrap(); + let mut state = two_quests(); + state.entries.insert( + 0, + QuestLogEntryView { + title: "Elwynn Forest".into(), + is_header: true, + ..Default::default() + }, + ); + s.set_quest_log(state); + s.set_party(crate::script::PartyState { + members: vec![ + crate::script::PartyMemberInfo { + name: "Mate".into(), + guid: MATE, + }, + crate::script::PartyMemberInfo { + name: "Far".into(), + guid: FAR_MATE, + }, + ], + ..Default::default() + }); + s.set_unit("player", Some(held(ME, &[7, 783]))); + s.set_unit("party1", Some(held(MATE, &[783]))); + s.set_unit( + "party2", + Some(crate::script::UnitState { + exists: true, + is_player: true, + guid: FAR_MATE, + ..Default::default() + }), + ); + s.set_unit("target", Some(held(MATE, &[783]))); + s.set_unit("mouseover", Some(held(0x500, &[783]))); + s.set_unit("raid7", Some(held(0x700, &[783]))); + s.set_unit( + "pet", + Some(crate::script::UnitState { + exists: true, + has_object: true, + guid: 0xF140_0000_0000_0001, + ..Default::default() + }), + ); + s + } + + /// The number 1 when the unit's log holds the row's quest (`0x4dfef5` pushes 1.0), else nil + /// (`0x4dfedb`), never a boolean. + #[test] + fn is_unit_on_quest_answers_one_for_the_player_and_a_held_party_member_on_it() { + let mut s = on_quest_world(); + let on = |s: &mut UiScript, call: &str| -> Option { + s.eval::>(&format!("return {call}")).unwrap() + }; + assert!(s + .eval::("return type(IsUnitOnQuest(2, 'party1')) == 'number'") + .unwrap()); + assert_eq!(on(&mut s, "IsUnitOnQuest(2, 'party1')"), Some(1)); + assert_eq!(on(&mut s, "IsUnitOnQuest(3, 'party1')"), None, "not on 7"); + assert_eq!(on(&mut s, "IsUnitOnQuest(2, 'player')"), Some(1)); + assert_eq!(on(&mut s, "IsUnitOnQuest(3, 'player')"), Some(1)); + assert_eq!( + on(&mut s, "IsUnitOnQuest(2, 'PARTY1')"), + Some(1), + "tokens fold case" + ); + // The guid decides, not the token: `target` names party1 (`0x4e7f70`'s slot compare). + assert_eq!(on(&mut s, "IsUnitOnQuest(2, 'target')"), Some(1)); + } + + /// `0x4e7f70` admits the active player and the four party slots alone, and `0x468460` needs + /// the object held: every other unit answers nil whatever its log holds. + #[test] + fn is_unit_on_quest_is_nil_off_the_party_or_out_of_range() { + let mut s = on_quest_world(); + for token in [ + "party2", // in the party, not held + "party3", // no such slot + "mouseover", // held, on the quest, not grouped + "raid7", // a raid mate outside our subgroup + "pet", // not a player, no quest log + "", // names nobody + ] { + assert!( + s.eval::(&format!("return IsUnitOnQuest(2, '{token}') == nil")) + .unwrap(), + "{token:?} answers nil" + ); + } + // The party stops at four slots of guids: a member who left reads nil off a stale log. + s.set_party(crate::script::PartyState::default()); + assert!(s + .eval::("return IsUnitOnQuest(2, 'target') == nil") + .unwrap()); + } + + /// The index is `_ftol`'d and 1-based (`0x4dfe4d`, `dec` at `0x4dfe52`); a header or a row + /// past the list has no quest id (`0x4df150`) and answers nil before the token is read, so an + /// unknown token raises only on a quest row (`0x515c14`). + #[test] + fn is_unit_on_quest_reads_the_row_before_the_token() { + let s = on_quest_world(); + for index in ["1", "0", "-1", "4", "99", "-0.5"] { + assert!( + s.eval::(&format!("return IsUnitOnQuest({index}, 'party1') == nil")) + .unwrap(), + "index {index}" + ); + assert!( + s.eval::(&format!("return IsUnitOnQuest({index}, 'bogus') == nil")) + .unwrap(), + "index {index} answers before the token is resolved" + ); + } + assert!(s + .eval::("return IsUnitOnQuest(2.9, 'party1') == 1") + .unwrap()); + assert!(s + .eval::("return IsUnitOnQuest('2', 'party1') == 1") + .unwrap()); + for (token, shown) in [("'bogus'", "bogus"), ("5", "5")] { + let e = format!( + "{:?}", + s.eval::(&format!("return IsUnitOnQuest(2, {token})")) + .unwrap_err() + ); + assert!( + e.contains(&format!("Unknown unit name: {shown}")), + "{token}: {e}" + ); + } + } + + /// `lua_isnumber` on 1 and `lua_isstring` on 2, both ahead of any read (`0x4dfe1e`, + /// `0x4dfe32`), raise the one Usage line (`0x84b564`), an out-of-range index included. + #[test] + fn is_unit_on_quest_raises_usage_on_a_bad_argument() { + let s = on_quest_world(); + for args in [ + "", + "2", + "99", + "nil, 'party1'", + "'x', 'party1'", + "{}, 'party1'", + "2, nil", + "2, {}", + "2, true", + ] { + let e = format!( + "{:?}", + s.eval::(&format!("return IsUnitOnQuest({args})")) + .unwrap_err() + ); + assert!( + e.contains("Usage: IsUnitOnQuest(index, \\\"unit\\\")") + || e.contains("Usage: IsUnitOnQuest(index, \"unit\")"), + "({args}) must raise Usage:, got {e}" + ); + } + } } diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index 40e0d26d..d9705849 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -200,6 +200,11 @@ pub struct UnitState { /// `UnitAffectingCombat` (`0x517e10`): `UNIT_FIELD_FLAGS` bit `0x00080000`, for every token /// including `"player"`; the reference has no separate player combat flag. pub in_combat: bool, + /// A held player's `PLAYER_QUEST_LOG` window, `[quest id, count-state, timer]` per slot, all + /// zero off a player: the 20 slots of 12 bytes at `[player+0xe68]+0x28` that `IsUnitOnQuest` + /// scans (`0x4dfebd`) and the `UNIT_QUEST_LOG_CHANGED` watch compares (`0x51bc63`). Whole, so + /// a change anywhere in it is a snapshot change; a group mate's carries the ids alone. + pub quest_log: [[u32; 3]; 20], } /// The name of a unit whose name is not yet known, as the reference's `UnitName` (`0x517220`) and From ea7e0d087aa11998b8f497976946b58082688241 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:53:16 +0700 Subject: [PATCH 14/21] Zone names stay uncoloured on PvE realms outside capitals, as in 1.12.1 (#825) On a PvE realm, zone names were still coloured friendly, hostile or contested. In 1.12.1, `GetZonePVPInfo` (`0x48d540`) answers a type only when its display gate is open (`0x48d5c2`/`0x48d5cc`). The gate opens on the realm's PvP flag or in a zone with Flags `0x10`, which is the six capitals. The realm flag is read from `Cfg_Configs.dbc` for the selected realm's type and stored once per world entry (`0x4015de`). A type with no row skips the store. Otherwise the type is nil, so the stock zone splash shows cream and the minimap zone text gold. benilla now reads `Cfg_Configs.dbc` instead of a transcription that stopped at type 8, so realm type 10 lists as PvP. It latches the realm flag at world entry and applies the gate. Tests cover the gate, the six capitals and every shipped Cfg_Configs row; each failed before the fix. Fixes #816 Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/area.rs | 202 ++++++++++++++++-- crates/benilla-app/src/char_select/refresh.rs | 6 +- crates/benilla-app/src/realm_select/load.rs | 90 +++++--- crates/benilla-app/src/realm_select/mod.rs | 22 +- crates/benilla-app/src/realm_select/screen.rs | 2 +- .../src/ui_script/zone_text_tests.rs | 21 ++ crates/benilla-formats/src/cfg_configs.rs | 127 +++++++++++ crates/benilla-formats/src/lib.rs | 2 + crates/benilla-ui/src/script/zone_text.rs | 2 +- docs/MAP.md | 2 +- 10 files changed, 418 insertions(+), 58 deletions(-) create mode 100644 crates/benilla-formats/src/cfg_configs.rs diff --git a/crates/benilla-app/src/area.rs b/crates/benilla-app/src/area.rs index f511943a..9250f3b7 100644 --- a/crates/benilla-app/src/area.rs +++ b/crates/benilla-app/src/area.rs @@ -26,13 +26,13 @@ //! Deviation: the indoor dedup keys `(WMOID, MOGP uniqueID)`, not the client's raw per-WMO group //! index, because the raw index can alias across adjacent buildings. //! -//! The reference's PvP display gate `[0x88272c]` (a cached boolean, not the realm type) is -//! untraced; ours is always open. +//! `GetZonePVPInfo`'s type and faction show only on a PvP realm or in one of the six capitals +//! (zone Flags `0x10`); elsewhere they are nil and the stock zone texts stay uncoloured. use bevy::ecs::system::NonSendMut; use bevy::prelude::*; -use benilla_formats::AreaTableCatalog; +use benilla_formats::{AreaTableCatalog, AreaTableRow}; use benilla_ui::script::UiScript; use crate::net::{ObjectStore, SelfPlayer}; @@ -107,6 +107,7 @@ fn feed_zone_events( areas: Option>, factions: Option>, self_store: Query<&ObjectStore, With>, + realm_pvp: Option>, mut cache: Local>, ) { let (Some(mut script), Some(areas)) = (script, areas) else { @@ -193,26 +194,23 @@ fn feed_zone_events( return; } - // GetZonePVPInfo (`0x48d540`): isArena is the leaf's Flags `0x80`; the type is the zone's - // FactionGroupMask against the player template's friend then enemy masks, "contested" when - // neither; never "arena"; nil only on structural failure. factionName is FactionGroup.dbc's - // Name for the zone's mask bit; the realm type never enters. - let is_arena = leaf_row.flags & 0x80 != 0; - let zone_mask = areas.0.get(zone).map_or(0, |r| r.faction_group_mask); - let pvp = factions + let zone_row = areas.0.get(zone); + let template = factions .as_ref() .zip(self_store.single().ok()) - .and_then(|(f, store)| { - let tpl = f.catalog().template(store.0.unit_faction_template()?)?; - let ty = if zone_mask & tpl.friend_group_mask != 0 { - "friendly" - } else if zone_mask & tpl.enemy_group_mask != 0 { - "hostile" - } else { - "contested" - }; - Some((ty, f.catalog().faction_group_name(zone_mask).unwrap_or(""))) - }); + .and_then(|(f, store)| f.catalog().template(store.0.unit_faction_template()?)) + .map(|t| (t.friend_group_mask, t.enemy_group_mask)); + let realm_pvp = realm_pvp.is_some_and(|r| r.0); + let (ty, is_arena) = zone_pvp_info(realm_pvp, leaf_row, zone_row, template); + // factionName is FactionGroup.dbc's Name for the zone's mask bit. + let pvp = ty.map(|ty| { + let mask = zone_row.map_or(0, |r| r.faction_group_mask); + let name = factions + .as_ref() + .and_then(|f| f.catalog().faction_group_name(mask)) + .unwrap_or(""); + (ty, name) + }); let pvp_type = pvp.map_or("", |(ty, _)| ty); script.set_zone_texts(benilla_ui::script::ZoneTexts { @@ -247,12 +245,70 @@ fn feed_zone_events( cache.wmo_group = wmo_group; } +/// `GetZonePVPInfo` (`0x48d540`): `(pvpType, isArena)`. isArena is the leaf's Flags `0x80`, read +/// before any bail. The type is nil with no zone row, no faction template, or the display gate +/// closed: it opens on a PvP realm ([`RealmPvp`]) or in a zone whose Flags carry `0x10`, the six +/// capitals (`0x48d5c2`/`0x48d5cc`). Open, it is the zone's FactionGroupMask against the template's +/// `(friend, enemy)` masks, "contested" when neither, never "arena". +fn zone_pvp_info( + realm_pvp: bool, + leaf: &AreaTableRow, + zone: Option<&AreaTableRow>, + template: Option<(u32, u32)>, +) -> (Option<&'static str>, bool) { + let is_arena = leaf.flags & 0x80 != 0; + let ty = zone + .filter(|z| realm_pvp || z.flags & 0x10 != 0) + .zip(template) + .map(|(z, (friend, enemy))| { + let mask = z.faction_group_mask; + if mask & friend != 0 { + "friendly" + } else if mask & enemy != 0 { + "hostile" + } else { + "contested" + } + }); + (ty, is_arena) +} + +/// `[0x88272c]`, the realm's PvP flag the display gate reads: the selected realm's +/// `PlayerKillingAllowed` in `Cfg_Configs.dbc`, stored once per world entry from the character +/// list (`0x4015de` in `0x401570`), so a `/reload` or a far teleport keeps it. +#[derive(Resource, Default)] +pub(crate) struct RealmPvp(bool); + +/// Latch [`RealmPvp`] from the realm this session entered the world on. +fn latch_realm_pvp( + mut latch: ResMut, + roster: Option>, + realms: Option>, +) { + let realm_type = roster.and_then(|r| r.realm.as_ref().map(|realm| realm.realm_type)); + let row = realm_type + .zip(realms) + .and_then(|(t, realms)| realms.row_pvp(t)); + latch.0 = latched_realm_pvp(latch.0, row); +} + +/// A realm type with no `Cfg_Configs.dbc` row skips the store (`0x4015d2`) and keeps the last +/// world entry's value, which the process starts at 0 (`.data`). +fn latched_realm_pvp(previous: bool, row_pvp: Option) -> bool { + row_pvp.unwrap_or(previous) +} + /// The shared area catalog and the zone-event feed. pub(crate) struct AreaPlugin; impl Plugin for AreaPlugin { fn build(&self, app: &mut App) { - app.add_systems(Startup, load_area_table.after(AssetSet::Open)) + app.init_resource::() + .add_systems(Startup, load_area_table.after(AssetSet::Open)) + .add_systems( + OnEnter(crate::char_select::ClientState::InWorld), + latch_realm_pvp, + ) .add_systems( Update, // After the leaf authority: leaf, indoor bit and names must come from one frame, @@ -329,6 +385,108 @@ mod tests { ); } + fn row(flags: u32, faction_group_mask: u32) -> AreaTableRow { + AreaTableRow { + map_id: 0, + zone_id: 0, + explore_flag: 0, + flags, + faction_group_mask, + exploration_level: 0, + name: String::new(), + } + } + + /// An Alliance player's template: friend Alliance (2), enemy Horde (4). + const ALLIANCE: Option<(u32, u32)> = Some((2, 4)); + + /// The display gate (`0x48d5c2`/`0x48d5cc`): a PvE realm outside a capital gets no type; a + /// capital's Flags `0x10` opens it there, and a PvP realm opens it everywhere. + #[test] + fn the_pvp_type_shows_on_a_pvp_realm_or_in_a_capital() { + let elwynn = row(0x40, 2); + let stormwind = row(0x138, 2); + let orgrimmar = row(0x138, 4); + let stranglethorn = row(0x40, 0); + let pvp = + |realm_pvp, zone: &AreaTableRow| zone_pvp_info(realm_pvp, zone, Some(zone), ALLIANCE).0; + + assert_eq!(pvp(false, &elwynn), None, "PvE realm, outside a capital"); + assert_eq!( + pvp(false, &stranglethorn), + None, + "PvE realm, ownerless zone" + ); + assert_eq!( + pvp(false, &stormwind), + Some("friendly"), + "PvE realm, own capital" + ); + assert_eq!( + pvp(false, &orgrimmar), + Some("hostile"), + "PvE realm, enemy capital" + ); + + assert_eq!(pvp(true, &elwynn), Some("friendly")); + assert_eq!(pvp(true, &stranglethorn), Some("contested")); + assert_eq!(pvp(true, &orgrimmar), Some("hostile")); + } + + /// isArena is the leaf's `0x80`, read before any bail, so a closed gate keeps it. + #[test] + fn is_arena_survives_a_closed_gate() { + let battle_ring = row(0xd0, 0); + let stranglethorn = row(0x40, 0); + assert_eq!( + zone_pvp_info(false, &battle_ring, Some(&stranglethorn), ALLIANCE), + (None, true) + ); + assert_eq!( + zone_pvp_info(true, &battle_ring, Some(&stranglethorn), ALLIANCE), + (Some("contested"), true) + ); + assert_eq!( + zone_pvp_info(true, &battle_ring, Some(&stranglethorn), None), + (None, true), + "no faction template" + ); + } + + /// A realm type with no row keeps what the last world entry stored. + #[test] + fn a_realm_type_with_no_row_keeps_the_last_latch() { + assert!(!latched_realm_pvp(false, None)); + assert!(latched_realm_pvp(true, None)); + assert!(!latched_realm_pvp(true, Some(false))); + assert!(latched_realm_pvp(false, Some(true))); + } + + /// No zone row is a bail, not an ownerless zone. + #[test] + fn a_missing_zone_row_has_no_pvp_type() { + assert_eq!( + zone_pvp_info(true, &row(0, 0), None, ALLIANCE), + (None, false) + ); + } + + /// On the install's AreaTable the gate opens in exactly the six capitals for a PvE realm. + #[test] + fn the_six_capitals_are_the_zones_a_pve_realm_colours() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("chain"); + let cat = benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable"); + let open: Vec = (0..=u32::from(u16::MAX)) + .filter(|&id| { + cat.get(id).is_some_and(|r| { + r.zone_id == 0 && zone_pvp_info(false, r, Some(r), ALLIANCE).0.is_some() + }) + }) + .collect(); + assert_eq!(open, [1497, 1519, 1537, 1637, 1638, 1657]); + } + /// The `0x67e670` override skip (abbey) and fire (inn) branches on the install's data. #[test] fn indoor_naming_matches_the_byte_law_on_real_data() { diff --git a/crates/benilla-app/src/char_select/refresh.rs b/crates/benilla-app/src/char_select/refresh.rs index e2987cb0..68ab0b02 100644 --- a/crates/benilla-app/src/char_select/refresh.rs +++ b/crates/benilla-app/src/char_select/refresh.rs @@ -18,6 +18,7 @@ use super::{class_name, Roster}; pub(super) fn refresh_list( roster: Res, areas: Option>, + realms: Option>, strings: Option>, status: Res, mut rows: Query<(&SelectAction, &mut Visibility), (With + + + + + + + + + + + + + - - + + diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index 6ae24160..f5965a3c 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -1358,15 +1358,23 @@ pub(super) fn seed_ui_fixture( warn!("capture: ui-chat-tabhover select failed: {e}"); } script.resolve(); + // Each in the root's units the cursor is fed in: the frame's own times its scale. let expr = match mode.as_str() { - "2" | "3" => "return ChatFrame2Tab:GetCenter()", + "2" | "3" => { + "local x, y = ChatFrame2Tab:GetCenter() \ + local k = ChatFrame2Tab:GetEffectiveScale() return x * k, y * k" + } "0" => { - "return (ChatFrame2:GetLeft() + ChatFrame2:GetRight()) / 2, \ - (ChatFrame2:GetBottom() + ChatFrame2:GetTop()) / 2" + "local k = ChatFrame2:GetEffectiveScale() \ + return (ChatFrame2:GetLeft() + ChatFrame2:GetRight()) / 2 * k, \ + (ChatFrame2:GetBottom() + ChatFrame2:GetTop()) / 2 * k" } // 9: park far away, so the dock conceals itself. "9" => "return 2000, 2000", - _ => "return ChatFrame1Tab:GetCenter()", + _ => { + "local x, y = ChatFrame1Tab:GetCenter() \ + local k = ChatFrame1Tab:GetEffectiveScale() return x * k, y * k" + } }; let centre: Result<(f32, f32), _> = script.eval(expr); match centre { diff --git a/crates/benilla-app/src/capture/probes/act.rs b/crates/benilla-app/src/capture/probes/act.rs index bfad967f..ddc4ea14 100644 --- a/crates/benilla-app/src/capture/probes/act.rs +++ b/crates/benilla-app/src/capture/probes/act.rs @@ -550,13 +550,13 @@ struct ProbeDrag { to: (f32, f32), } -/// A frame's centre from its live `GetLeft`/`GetRight`/`GetTop`/`GetBottom`, in the space -/// [`crate::ui_script::input`] feeds the cursor in. +/// A frame's centre from its live `GetLeft`/`GetRight`/`GetTop`/`GetBottom`, times its effective +/// scale: the edges answer in the frame's own units, the cursor is fed in the root's. fn frame_centre(script: &benilla_ui::script::UiScript, name: &str) -> Option<(f32, f32)> { let read = |edge: &str| { script .eval::(&format!( - "local f = getglobal(\"{name}\") return f and f:Get{edge}()" + "local f = getglobal(\"{name}\") return f and f:Get{edge}() * f:GetEffectiveScale()" )) .ok() }; diff --git a/crates/benilla-app/src/chat_bubble.rs b/crates/benilla-app/src/chat_bubble.rs index 2ad3a8f7..1a91b740 100644 --- a/crates/benilla-app/src/chat_bubble.rs +++ b/crates/benilla-app/src/chat_bubble.rs @@ -553,7 +553,7 @@ fn draw_bubble( border_color: [1.0; 4], }; let up = GxRect::new(-frame.max.y, frame.min.x, -frame.min.y, frame.max.x); - for p in pieces(up, &bd) { + for p in pieces(up, &bd, 1.0) { // Equal insets keep every piece axis-aligned: a y-down rect from its TL and BR corners. let rect = Rect::new( p.corners[0][0], diff --git a/crates/benilla-app/src/minimap/blips/mod.rs b/crates/benilla-app/src/minimap/blips/mod.rs index 4d5bf2ce..463d8595 100644 --- a/crates/benilla-app/src/minimap/blips/mod.rs +++ b/crates/benilla-app/src/minimap/blips/mod.rs @@ -160,7 +160,6 @@ pub(super) type BlipInputs<'w, 's> = ( Query<'w, 's, &'static GlobalTransform, With>, Query<'w, 's, &'static Window, With>, ResMut<'w, MinimapBlipHover>, - Res<'w, crate::ui_script::UiScaleCvar>, Res<'w, crate::ui_party::GroupState>, TrackedCandidates<'w, 's>, Query<'w, 's, (&'static ObjectStore, &'static Guid), With>, @@ -200,11 +199,11 @@ pub(super) struct BlipCtx { pub(super) wz: f32, /// The cursor in the quads' y-down logical px. pub(super) cursor: Option, - /// The cursor in y-up UI space; the reference seats the blip tooltip at the cursor, its exact - /// offset untraced. + /// The cursor in the y-up root units; the reference seats the blip tooltip at the cursor, its + /// exact offset untraced. pub(super) cursor_ui: Option, - /// Window px per UI unit: everything else here is window px, while Lua - /// ([`Minimap:PingLocation`](super::ping)) speaks UI units. + /// Window px per root unit: everything else here is window px, while Lua + /// ([`Minimap:PingLocation`](super::ping)) hands root units. pub(super) seam: f32, } diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs index 10f72ccf..c3909946 100644 --- a/crates/benilla-app/src/minimap/mod.rs +++ b/crates/benilla-app/src/minimap/mod.rs @@ -351,7 +351,6 @@ fn emit_minimap( unit_pos, window, mut blip_hover, - ui_scale, group, tracked, self_store, @@ -621,7 +620,7 @@ fn emit_minimap( } let win = window.iter().next(); let cursor = win.and_then(|w| w.cursor_position()); - let seam = win.map_or(1.0, |w| crate::ui_script::seam_scale(w.height(), ui_scale.0)); + let seam = win.map_or(1.0, |w| crate::ui_script::seam_scale(w.height())); // The pan origin snapped to a half-logical-pixel grid: every blip offset and rim bearing // derives from `wx`/`wy`, so the layer steps together instead of rewriting the batch mesh // every frame while walking. Blip world positions stay exact. @@ -637,11 +636,11 @@ fn emit_minimap( wy: (wy / q).round() * q, wz: wow[2], cursor, - // The cursor in UI space (y-up, ÷ the seam scale): the tooltip anchor resolves in - // 768-virtual units, not window px. + // The cursor in the root's units (y-up, ÷ the seam scale): the tooltip anchor resolves + // in them, not window px. cursor_ui: cursor .zip(win) - .map(|(c, w)| Vec2::new(c.x / seam, (w.height() - c.y) / seam)), + .map(|(c, w)| crate::ui_script::window_to_ui(w.height(), c)), seam, } }); diff --git a/crates/benilla-app/src/minimap/ping.rs b/crates/benilla-app/src/minimap/ping.rs index c218c756..bf4e6ba2 100644 --- a/crates/benilla-app/src/minimap/ping.rs +++ b/crates/benilla-app/src/minimap/ping.rs @@ -5,9 +5,10 @@ //! [`drive_minimap_ping`] republishes against the live view radius, so the marker follows the pan //! and the zoom with no second copy to fall out of step. //! -//! - In: the stock `Minimap_OnClick` calls `Minimap:PingLocation(dx, dy)` in UI units from the -//! centre; the renderer converts it with the geometry of the frame it draws: UI units × the seam -//! scale = window px, ÷ `px_per_yd` = yards. +//! - In: the stock `Minimap_OnClick` calls `Minimap:PingLocation(dx, dy)` in the minimap's units +//! from the centre, which the engine hands over in root units; the renderer converts them with +//! the geometry of the frame it draws: root units × the seam scale = window px, ÷ `px_per_yd` = +//! yards. //! - Across: our ping sends `MSG_MINIMAP_PING` (raw world floats, relayed to the rest of the group //! only, `GroupHandler.cpp:384-391`); a member's seats the same way. A ping seats locally at //! click time, so a solo ping works. @@ -59,8 +60,8 @@ impl MinimapPing { } } -/// A `Minimap:PingLocation(x, y)` click to the world point it names: `ui` is centre-relative UI -/// units (x right, y up), `seam` window px per UI unit ([`crate::ui_script::seam_scale`]), `ctx` +/// A `Minimap:PingLocation(x, y)` click to the world point it names: `ui` is centre-relative root +/// units (x right, y up), `seam` window px per root unit ([`crate::ui_script::seam_scale`]), `ctx` /// the map as drawn this frame. The inverse of [`BlipCtx::offset`]: screen right is −WoW y, screen /// up +WoW x. `None` outside the disc, the stock test (`Minimap.lua:135`) in yards. fn click_to_world(ctx: &BlipCtx, ui: (f32, f32), seam: f32) -> Option<(f32, f32)> { @@ -193,13 +194,13 @@ mod tests { } } - /// The click is in UI units and `px_per_yd` in window px: at 0.9 uiScale on a 1080p window the - /// seam is ≈1.27, and a conversion that skips it seats every ping ≈27 % too far out. + /// The click is in root units and `px_per_yd` in window px: on a 1080p window the seam is + /// ≈1.41, and a conversion that skips it seats every ping ≈41 % too far out. #[test] fn a_click_converts_through_the_seam_scale() { let c = ctx(); - let seam = 1080.0 / 768.0 * 0.9; // the shipped default at 1080p - // 20 UI units right → 20·seam px → yards west (−y). + let seam = 1080.0 / 768.0; + // 20 root units right → 20·seam px → yards west (−y). let (x, y) = click_to_world(&c, (20.0, 0.0), seam).expect("inside the disc"); let expect_yd = 20.0 * seam / c.px_per_yd; assert!((x - 0.0).abs() < 1e-3, "no northing from a due-east click"); diff --git a/crates/benilla-app/src/perf/hud.rs b/crates/benilla-app/src/perf/hud.rs index ec59c09b..d963a0a0 100644 --- a/crates/benilla-app/src/perf/hud.rs +++ b/crates/benilla-app/src/perf/hud.rs @@ -113,9 +113,9 @@ pub(super) fn refresh_hud_snapshot( /// clear). The always-up world-state readout (`WorldStateAlwaysUpFrame`, stock /// `WorldStateFrame.xml`) shares the band, and the dev pill is the one that yields. /// -/// The layout answers in UI units, a screen `768/uiScale` units tall whatever the window, while -/// the pill draws in window px, so the chunk returns a fraction of the screen and the caller -/// scales it by the window height. Reading an edge settles the layout, one graph solve at most. +/// The layout answers in `UIParent`'s units, a screen `768/uiScale` units tall whatever the +/// window, while the pill draws in window px, so the chunk returns a fraction of the screen and the +/// caller scales it by the window height. Reading an edge settles the layout, one graph solve at most. pub(crate) fn top_centre_claimed(script: &UiScript, win_h: f32) -> f32 { // `WorldStateAlwaysUpFrame` is always shown; the readout occupies its `AlwaysUpFrame` rows, // built on demand and hidden when empty, so the claim is the lowest shown row's bottom. diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 972afda4..4fb1ef09 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -425,7 +425,11 @@ pub(super) fn tick_script( mut last_seam: Local, // The `scale_factor` measures were last answered under; 0 until the first frame. mut last_dpi: Local, - ui_scale: Res, + // The dial, `UIParent`'s scale in the VM, and the automatic scale's last display. + (ui_scale, mut auto_cache): ( + Res, + Option>, + ), mut ui_cost: ResMut, mut pass: ResMut, ) { @@ -442,9 +446,9 @@ pub(super) fn tick_script( }; let (w, h) = (window.width(), window.height()); // The client's UI space is 768 units tall at every aspect (`f(screenH) = 768`, `0x41ad10`; the - // caret's `H_px/192`, `0x77b8c0`), `768/uiScale` with the dial. The VM lives in it: quads - // scale ×s out, and the mouse and the measures ÷s in. - let s = super::seam_scale(h, ui_scale.0); + // caret's `H_px/192`, `0x77b8c0`), the dial being `UIParent`'s scale inside it. The VM lives in + // it: quads scale ×s out, and the mouse and the measures ÷s in. + let s = super::seam_scale(h); // A moved seam scale or DPI stales every text metric the engine caches: integer-stepped // advances do not rescale, and the size snap moves a string's width by several percent, // enough for the ellipsis to eat fitting text. A monitor hop moves the DPI alone. @@ -486,8 +490,17 @@ pub(super) fn tick_script( { let _span = bevy::log::info_span!("ui_script: tick").entered(); let resized = script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 }); - // A resize re-runs what the interface computed from the old screen size. + // A display change re-applies the UI scale (`0x492e90`), whose narrow-aspect cap follows + // the window; a moved dial applies once, as its CVar callback does (`0x490770`). if resized { + let display = (w.round() as u32, h.round() as u32); + let mut none = None; + let cache = auto_cache.as_deref_mut().map_or(&mut none, |c| &mut c.0); + script.apply_ui_scale(display, cache); + } + let rescaled = script.set_ui_scale(ui_scale.0); + // Either re-runs what the interface computed from the old `GetScreenHeight()`. + if resized || rescaled { super::manifest::on_screen_resized(&script); } script.tick(time.delta_secs()); @@ -1713,9 +1726,10 @@ mod clip_plumb_tests { ); } - /// At dial 0.5 the VM sees a 2048×1536 screen, and every window-px rect halves. + /// The dial is `UIParent`'s scale, so a ScrollFrame with no parent keeps its px rect and clip + /// at dial 0.5: the root stays 768 units tall. #[test] - fn the_uiscale_dial_scales_the_extracted_rects() { + fn the_uiscale_dial_leaves_a_parentless_frame_alone() { let mut app = app_with_scrolled_marker(); app.insert_resource(crate::ui_script::UiScaleCvar(0.5)); app.update(); @@ -1726,12 +1740,10 @@ mod clip_plumb_tests { .find(|q| q.color[0] == 1.0 && q.color[1] == 0.0 && q.color[2] == 0.0) .expect("the colored marker quad extracted"); - // WoW space: top 1536 − 100 = 1436, bottom 1236, right 300; ×0.5 is y-up px 618..718, - // and y-down through the 768 window the top is 50 and the bottom 150. assert_eq!( marker.clip, - Some(Rect::new(0.0, 50.0, 150.0, 150.0)), - "dial 0.5 halves the px rect and re-hangs the frame from the taller virtual top" + Some(Rect::new(0.0, 100.0, 300.0, 300.0)), + "dial 0.5 moves nothing outside UIParent" ); } diff --git a/crates/benilla-app/src/ui_script/extract/text.rs b/crates/benilla-app/src/ui_script/extract/text.rs index 00d95d38..e7bd9312 100644 --- a/crates/benilla-app/src/ui_script/extract/text.rs +++ b/crates/benilla-app/src/ui_script/extract/text.rs @@ -40,10 +40,11 @@ pub(super) struct TextHost<'a> { /// The focused editbox's text UI, unfiltered: [`emit`] matches it to this quad by `target`. pub ebox: Option<&'a EditBoxTextUi>, pub screen_h: f32, - /// The seam scale `windowH/768 × uiScale`, for the unit-space inputs; rects arrive in px. + /// The seam scale `windowH/768`, for the root-unit inputs; rects arrive in px. pub scale: f32, /// The frame's effective scale, already in the rect: glyph size and shadow offset ride it, as - /// the 1.12 client's text rides `SetScale`; the editbox x-offsets arrive in screen UI units. + /// the 1.12 client's text rides `SetScale` and the UI scale; the editbox x-offsets arrive in + /// root units. pub font_scale: f32, /// Captures pin the caret on for deterministic pixels; live, the engine's blink decides. pub caret_pinned: bool, @@ -387,7 +388,7 @@ mod tests { assert_eq!(band_clip_slack(Some(2.0)), 3.0); } - /// `1.0546875` is the seam scale of a 1600×900 window at uiScale 0.9. + /// `1.0546875` is a 1600×900 window's seam times the 0.9 UI scale. #[test] fn a_shadow_offset_is_a_whole_pixel_and_never_vanishes() { assert_eq!(shadow_offset_px(1.0), 1.0); diff --git a/crates/benilla-app/src/ui_script/input.rs b/crates/benilla-app/src/ui_script/input.rs index dd1b5801..2a296cc6 100644 --- a/crates/benilla-app/src/ui_script/input.rs +++ b/crates/benilla-app/src/ui_script/input.rs @@ -94,8 +94,6 @@ pub(super) fn feed_ui_input( ), // The characters the active layout makes, which name a key for a keyboard frame. layout: Res, - // The uiScale dial folded into the seam scale. - ui_scale: Res, ) { let (keyboard, keys, capture, clipboard) = (&mut kbd.0, &kbd.1, &mut kbd.2, &mut kbd.3); let world_pick = pointer.world_pick(); @@ -139,10 +137,8 @@ pub(super) fn feed_ui_input( .or_else(crate::target::hover_probe_point) .filter(|_| !synthetic) { - // The window cursor is logical px, y-down from the top left; the UI is y-up in 768-high - // units under uiScale: flip through the window height, then undo the extract seam's scale. - let s = super::seam_scale(window.height(), ui_scale.0); - let (x, y) = (cursor.x / s, (window.height() - cursor.y) / s); + // The window cursor in the root's y-up 768-high units, as `GetCursorPosition` answers it. + let Vec2 { x, y } = super::window_to_ui(window.height(), cursor); // `WOW_HIT_COST=1` meters this call's per-frame hit-test rebuild, one line a second. static HIT_COST: std::sync::OnceLock = std::sync::OnceLock::new(); let metering = diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index 7e1a52aa..d91c92bb 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -230,26 +230,22 @@ pub(crate) fn run_pending_entry_load(world: &mut World) { ); } -/// Seat the VM's screen size and text measurer for the load, under the current window and -/// `uiScale`: the load-edge half of [`super::extract::tick_script`]'s per-frame pair. A fresh VM -/// answers 1024×768 until then, and a value computed once at load keeps it: stock -/// `WorldMapFrame_OnLoad` sizes the full-screen `BlackoutWorld` from `GetScreenWidth()` and -/// `GetScreenHeight()` and never again (`WorldMapFrame.lua:19-28`). With no atlas yet (it bakes on -/// the first `Update`) the per-frame pass seats the measurer. +/// Seat the VM's screen size and text measurer for the load, under the current window: the +/// load-edge half of [`super::extract::tick_script`]'s per-frame pair. A fresh VM answers 1024×768 +/// until then, and a value computed once at load keeps it: stock `WorldMapFrame_OnLoad` sizes the +/// full-screen `BlackoutWorld` from `GetScreenWidth()` and `GetScreenHeight()` and never again +/// (`WorldMapFrame.lua:19-28`), which answer the root while `UIParent` is still at scale 1. With no +/// atlas yet (it bakes on the first `Update`) the per-frame pass seats the measurer. fn seat_raster_seam_for_load(world: &mut World, script: &mut UiScript) { - let ui_scale = world - .get_resource::() - .map_or(1.0, |c| c.0); let (w, h) = { let mut q = world.query_filtered::<&Window, With>(); q.single(world) .map_or((0.0, 0.0), |win| (win.width(), win.height())) }; - let s = super::seam_scale(h, ui_scale); + let s = super::seam_scale(h); if w > 0.0 && h > 0.0 { - // The VM lives in 768-tall virtual space, so logical size over the seam scale is its - // screen. No `UIParent_ManageFramePositions` here, unlike `tick_script`: nothing is laid - // out yet. + // The VM lives in a 768-tall root, so logical size over the seam scale is its screen. No + // `UIParent_ManageFramePositions` here, unlike `tick_script`: nothing is laid out yet. script.set_screen_size(w / s, h / s); } let Some(atlas) = world.get_resource::() else { @@ -387,6 +383,9 @@ fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) { // text width, `UIPanelTemplates.xml:370-373`), which answers 0 with no measurer. seat_raster_seam_for_load(world, &mut script); // Read out of the world first: the bracket below borrows `world` for the chat-cache restore. + let ui_scale = world + .get_resource::() + .map_or(1.0, |c| c.0); let zoom = { let z = world.resource::(); (z.outdoor, z.inside) @@ -421,6 +420,21 @@ fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) { |script| crate::vplates::seat_legacy_settings(script, &legacy_plates), |script| { crate::ui_chat::restore_chat_looks(world, script); + // The UI scale lands on `UIParent` once the files, the addons and + // `VARIABLES_LOADED` are done, after the chat reader (`0x4900d6`) and before the + // `PLAYER_LOGIN` arm (`0x49011d`), through the display handler (`0x49010e` → + // `0x492e90`). So every `OnLoad` sees `UIParent` at scale 1 and the root's size. + let display = { + let mut q = + world.query_filtered::<&Window, With>(); + q.single(world).map_or((0, 0), |w| { + (w.width().round() as u32, w.height().round() as u32) + }) + }; + let mut none = None; + let mut cache = world.get_resource_mut::(); + let cache = cache.as_deref_mut().map_or(&mut none, |c| &mut c.0); + script.seat_ui_scale(ui_scale, display, cache); }, ); }); @@ -766,10 +780,10 @@ 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 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 +/// the saved variables. `between` runs after `VARIABLES_LOADED` and before the `PLAYER_LOGIN` arm +/// (so before the event too): the chat-cache restore's `UPDATE_CHAT_WINDOWS` + +/// `UPDATE_CHAT_COLOR` burst, where the reference registers its reader (`0x4900d6` → `0x498a20`), +/// then the UI scale (`0x49010e`). 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. pub(crate) fn finish_ui_load_with( diff --git a/crates/benilla-app/src/ui_script/manifest.rs b/crates/benilla-app/src/ui_script/manifest.rs index 269d5f59..3a1da8b8 100644 --- a/crates/benilla-app/src/ui_script/manifest.rs +++ b/crates/benilla-app/src/ui_script/manifest.rs @@ -68,13 +68,13 @@ pub(super) fn apply_buff_durations(script: &UiScript) -> Result<(), String> { .map_err(|e| e.to_string()) } -/// What a screen-size change re-runs, from `extract::tick_script`'s resize arm: anchors follow the -/// screen, but not a size or seat computed from the old one. The managed pass re-wraps the open -/// bags, whose columns `updateContainerFrameAnchors` lays out by `GetScreenHeight()`. +/// What a screen-size or UI-scale change re-runs, from `extract::tick_script`: anchors follow the +/// screen, but not a size or seat computed from the old `GetScreenHeight()`. The managed pass +/// re-wraps the open bags, whose columns `updateContainerFrameAnchors` lays out by it. /// Deviation: [`FULLSCREEN_QUADS_RESEAT`] re-sizes the full-screen quads, which the stock files -/// size only in their OnLoads, because a resize or `uiScale` change here keeps the loaded UI and -/// the blackout would stop short of the new screen. Host-side, not in the layer: it adapts the -/// engine's resizable window, and changes nothing the stock UI does at a fixed size. +/// size only in their OnLoads, because a resize here keeps the loaded UI and the blackout would +/// stop short of the new screen. Host-side, not in the layer: it adapts the engine's resizable +/// window, and changes nothing the stock UI does at a fixed size. pub(super) fn on_screen_resized(script: &UiScript) { let _ = script.run("if UIParent_ManageFramePositions then UIParent_ManageFramePositions() end"); if let Err(e) = script.run(FULLSCREEN_QUADS_RESEAT) { @@ -85,11 +85,14 @@ pub(super) fn on_screen_resized(script: &UiScript) { /// The sizing arithmetic of `WorldMapFrame_OnLoad` (`WorldMapFrame.lua:19-28`) and /// `CinematicFrame_OnLoad` (`CinematicFrame.lua:6-21`), each existence-guarded: the glue screens /// have neither. Below 4:3 the bars go back to `CinematicFrame.xml`'s declared 1024 x 128, where -/// the stock OnLoad leaves them. +/// the stock OnLoad leaves them. The OnLoads read the screen root's size, `UIParent` being still at +/// scale 1 then, and their frames hang off the root, so this re-reads the root: +/// `GetScreenWidth()` times `UIParent`'s scale. const FULLSCREEN_QUADS_RESEAT: &str = r#" +local rootScale = UIParent and UIParent:GetEffectiveScale() or 1 if BlackoutWorld then - local width = GetScreenWidth() - local height = GetScreenHeight() + local width = GetScreenWidth() * rootScale + local height = GetScreenHeight() * rootScale if ( width / height < 4 / 3 ) then width = width * 1.25 height = height * 1.25 @@ -98,8 +101,8 @@ if BlackoutWorld then BlackoutWorld:SetHeight( height ) end if UpperBlackBar and LowerBlackBar then - local width = GetScreenWidth() - local height = GetScreenHeight() + local width = GetScreenWidth() * rootScale + local height = GetScreenHeight() * rootScale local barWidth, blackBarHeight = 1024, 128 if ( width / height > 4 / 3 ) then local desiredHeight = width / 2 diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 05383a58..2b01b917 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -1,8 +1,9 @@ //! The UI-engine bridge: hosts [`benilla_ui::script::UiScript`] and feeds its //! [`extract`](benilla_ui::script::UiScript::extract) output into the quad pass //! ([`crate::ui_pass::UiQuads`]) every frame. The script side is WoW UI space (y-up, origin -//! bottom-left, a screen `768/uiScale` units tall); [`seam_scale`] carries quads ×s out and the -//! mouse ÷s in, and extraction flips to y-down window px. +//! bottom-left, a screen root 768 units tall at scale 1); [`seam_scale`] carries quads ×s out and +//! the mouse ÷s in, and extraction flips to y-down window px. The UI scale is `UIParent`'s own +//! frame scale inside the VM ([`UiScaleCvar`]), never part of the seam. use bevy::prelude::*; @@ -199,8 +200,10 @@ pub(crate) fn run_or_warn(script: &benilla_ui::script::UiScript, chunk: &str) { } } -/// The reference's `uiScale` CVar: the VM's screen is `768/uiScale` units tall, so -/// `uiScale = 768/screenH` is pixel-perfect. Tests pin the `Default` 1.0. +/// The reference's `uiScale` CVar, which the VM makes `UIParent`'s own scale +/// ([`UiScript::set_ui_scale`]): the screen root stays 768 units tall, so `uiScale = 768/screenH` +/// is pixel-perfect inside `UIParent`, and a frame outside it is not scaled. Tests pin the +/// `Default` 1.0. #[derive(Resource)] pub(crate) struct UiScaleCvar(pub(crate) f32); @@ -213,8 +216,17 @@ impl Default for UiScaleCvar { /// The shipped `uiScale`. Deviation: a flat 0.9, because 1.0 reads oversized. With `useUiScale` /// off, its default (`0x8430c0`), the reference sets `max(768/H, 0.9)` above 768 px tall and 1.0 /// at or below (`0x492f70`, on a mode set and from `0x4908ad`), so the two agree from ~853 px up. +/// benilla never reads `useUiScale`: this CVar always applies to `UIParent` alone, as with it on. +/// A change goes through the CVar callback (`0x490770`: the narrow-aspect cap, the 0.64 floor); +/// the load and a display change through the display handler's ON leg (`0x492e90`: the cap, and +/// below 0.64 the automatic scale `0x492f70` instead of the floor). pub(crate) const DEFAULT_UI_SCALE: f32 = 0.9; +/// The display the reference's automatic UI scale last ran for (`0x492f70`'s `[0xb4e304]` and +/// `[0xb4e308]`), which outlives every UI: the display handler skips a display it already ran for. +#[derive(Resource, Default)] +pub(crate) struct UiAutoScaleCache(pub(crate) Option<(u32, u32)>); + /// `WOW_UI_SCALE=` if set, clamped to the dial's range, else [`DEFAULT_UI_SCALE`]. fn default_ui_scale() -> f32 { std::env::var("WOW_UI_SCALE") @@ -224,16 +236,24 @@ fn default_ui_scale() -> f32 { .unwrap_or(DEFAULT_UI_SCALE) } -/// The seam scale `s`, window px per UI unit (`windowH/768 × uiScale`), used at every crossing of -/// the VM boundary; identity for a degenerate window (h ≤ 0, before winit). -pub(crate) fn seam_scale(window_h: f32, ui_scale: f32) -> f32 { +/// The seam scale `s`, window px per screen-root unit (`windowH/768`: the root is 768 units tall at +/// every aspect, `0x41ad10`), used at every crossing of the VM boundary; identity for a degenerate +/// window (h ≤ 0, before winit). The UI scale is not in it: it is `UIParent`'s, inside the VM. +pub(crate) fn seam_scale(window_h: f32) -> f32 { if window_h > 0.0 { - window_h / 768.0 * ui_scale + window_h / 768.0 } else { 1.0 } } +/// A window cursor (logical px, y-down from the top left) in the VM's root units, y up: what the +/// pointer feed hands the engine and `GetCursorPosition` answers (`0x48b820`, no scale divided out). +pub(crate) fn window_to_ui(window_h: f32, cursor: Vec2) -> Vec2 { + let s = seam_scale(window_h); + Vec2::new(cursor.x / s, (window_h - cursor.y) / s) +} + /// The Lua UI host (a `NonSend` resource: an mlua VM is `!Send`) and its per-frame passes. pub(crate) struct UiScriptPlugin; @@ -263,6 +283,7 @@ impl Plugin for UiScriptPlugin { // The quit root runs in `Last`: the close button's `AppExit` is written in `PostUpdate`. crate::shutdown::on_app_exit(app, shutdown_on_exit.into_configs()); app.insert_resource(UiScaleCvar(default_ui_scale())) + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -706,6 +727,9 @@ mod chat_resize_tests; #[cfg(test)] mod dropdown_tests; +#[cfg(test)] +mod ui_scale_tests; + #[cfg(test)] mod action_bar_tests; @@ -986,19 +1010,26 @@ mod world_map_tests; #[cfg(test)] mod seam_scale_tests { - use super::seam_scale; + use super::{seam_scale, window_to_ui}; + use bevy::math::Vec2; #[test] - fn seam_scale_is_the_768_base_times_the_dial() { + fn seam_scale_is_the_768_tall_root() { // Identity at the design height, proportional elsewhere. - assert_eq!(seam_scale(768.0, 1.0), 1.0); - assert_eq!(seam_scale(1536.0, 1.0), 2.0); - // The dial multiplies it. - assert_eq!(seam_scale(768.0, 0.9), 0.9); - // The reference's pixel-perfect setting: uiScale = 768/screenH → 1 px per UI unit. - assert!((seam_scale(1080.0, 768.0 / 1080.0) - 1.0).abs() < 1e-6); + assert_eq!(seam_scale(768.0), 1.0); + assert_eq!(seam_scale(1536.0), 2.0); // A degenerate (pre-winit) window is identity, never a division blow-up. - assert_eq!(seam_scale(0.0, 0.9), 1.0); + assert_eq!(seam_scale(0.0), 1.0); + } + + /// `GetCursorPosition` answers root units whatever the dial: the bottom of a 1080-tall window + /// is 0 and its top 768, its middle column `768·a/2`. + #[test] + fn the_cursor_reaches_the_vm_in_root_units() { + let at = window_to_ui(1080.0, Vec2::new(960.0, 108.0)); + assert!((at.x - 960.0 * 768.0 / 1080.0).abs() < 1e-3, "{at}"); + assert!((at.y - 972.0 * 768.0 / 1080.0).abs() < 1e-3, "{at}"); + assert_eq!(window_to_ui(1080.0, Vec2::new(0.0, 1080.0)), Vec2::ZERO); } } diff --git a/crates/benilla-app/src/ui_script/ui_scale_tests.rs b/crates/benilla-app/src/ui_script/ui_scale_tests.rs new file mode 100644 index 00000000..f38e9942 --- /dev/null +++ b/crates/benilla-app/src/ui_script/ui_scale_tests.rs @@ -0,0 +1,272 @@ +//! The UI scale through the real UI pass: `uiScale` is `UIParent`'s own frame scale (`0x494550` +//! calls `0x76ac10` on the frame named `"UIParent"`), and the screen root under it keeps scale 1 +//! and is 768 units tall, so a frame outside `UIParent` is not scaled by it. + +use bevy::prelude::*; +use bevy::window::PrimaryWindow; + +use benilla_ui::script::UiScript; + +use super::extract::{paint_script, tick_script}; +use crate::portrait::PortraitImages; +use crate::ui_pass::{UiQuad, UiQuads}; + +/// A `UIParent` filling the root with a red 100-unit square inside it, 10 units in from its top +/// left, and a green 100-unit square at the root's bottom left with no parent. +const TREE: &str = r#" + local ui = CreateFrame("Frame", "UIParent") + ui:SetPoint("BOTTOMLEFT", 0, 0) + ui:SetPoint("TOPRIGHT", 0, 0) + local inside = CreateFrame("Frame", "Inside", UIParent) + inside:SetPoint("TOPLEFT", 10, -10) + inside:SetWidth(100); inside:SetHeight(100) + local red = inside:CreateTexture(nil, "ARTWORK") + red:SetTexture(1, 0, 0) + red:SetAllPoints() + local outside = CreateFrame("Frame", "Outside") + outside:SetPoint("BOTTOMLEFT", 0, 0) + outside:SetWidth(100); outside:SetHeight(100) + local green = outside:CreateTexture(nil, "ARTWORK") + green:SetTexture(0, 1, 0) + green:SetAllPoints() +"#; + +/// Both halves of the UI pass over [`TREE`] on a `w`×`h` window, the dial at `ui_scale`. +fn app(w: u32, h: u32, ui_scale: f32) -> App { + let script = UiScript::new().unwrap(); + script.run(TREE).unwrap(); + let mut app = App::new(); + app.insert_non_send_resource(script); + app.init_resource::(); + app.init_resource::(); + app.init_resource::>(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::