diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 7bf8ed8d..9625f846 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -113,7 +113,8 @@
BenillaOptionsFrame:SetScale(k)
end
- -- The era tab width, label + 40, measured after OnShow sets the scale.
+ -- The era tab width, label + 40, measured after the window's OnShow sets the scale:
+ -- the tabs' own OnShows run before it, children first.
function BenillaOptionsTab_FitWidth(tab)
tab:SetWidth(getglobal(tab:GetName() .. "Text"):GetStringWidth() + 40)
end
@@ -1082,10 +1083,11 @@
-- GFX (volumetric light) / (moonlight): appended so the other rows keep their indices.
"RowVolumetricLight", "RowVolumetricLightStrength", "RowMoonLight",
},
- -- 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 = {
@@ -1568,9 +1570,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
@@ -2068,9 +2074,6 @@
-
- BenillaOptionsTab_FitWidth(this)
-
@@ -2098,7 +2101,6 @@
this:Disable()
- BenillaOptionsTab_FitWidth(this)
@@ -3982,11 +3984,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")
@@ -4402,6 +4417,8 @@
BenillaOptionsFrame_UpdateScale()
+ BenillaOptionsTab_FitWidth(BenillaOptionsFrameGameTab)
+ BenillaOptionsTab_FitWidth(BenillaOptionsFrameAddOnsTab)
BenillaOptionsFrame_SelectCategory(this.selectedCategory or "Controls")
if UpdateMicroButtons then UpdateMicroButtons() end
diff --git a/crates/benilla-app/assets/ui/ScriptLogFrame.xml b/crates/benilla-app/assets/ui/ScriptLogFrame.xml
index 9a2abc30..13fd9dcc 100644
--- a/crates/benilla-app/assets/ui/ScriptLogFrame.xml
+++ b/crates/benilla-app/assets/ui/ScriptLogFrame.xml
@@ -232,8 +232,9 @@
-
-
+
+
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/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/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs
index 82f28201..c018d1ef 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.
@@ -82,6 +83,66 @@ pub(super) fn seed_wind_player(
));
}
+/// 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.
@@ -94,9 +155,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>,
@@ -110,7 +176,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;
@@ -265,6 +331,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,
@@ -842,6 +964,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
@@ -1279,15 +1427,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 {
@@ -1366,6 +1522,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/mod.rs b/crates/benilla-app/src/capture/mod.rs
index e6130035..628fdeaa 100644
--- a/crates/benilla-app/src/capture/mod.rs
+++ b/crates/benilla-app/src/capture/mod.rs
@@ -118,7 +118,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/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/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs
index 9d254c9c..4c083848 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,
@@ -53,6 +70,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,
@@ -113,6 +133,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 {
@@ -861,6 +885,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),
@@ -1571,6 +1604,45 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 1380,
ui: None,
},
+ // 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 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)),
+ },
];
/// GFX (volumetric light): the Goldshire lake bank among the trees (eye 2 yd over the feet).
diff --git a/crates/benilla-app/src/char_select/input.rs b/crates/benilla-app/src/char_select/input.rs
index 64170b58..c58f3008 100644
--- a/crates/benilla-app/src/char_select/input.rs
+++ b/crates/benilla-app/src/char_select/input.rs
@@ -41,7 +41,7 @@ pub(super) fn select_input(
) {
// A modal owns the input while it is up: the delete confirm, the AddOns list, the realm list
// over this screen, or the glue dialog (whose Okay must not double as Enter World or Escape).
- if dialog.open || panel.open || realms.shown || glue_dialog.is_open() {
+ if dialog.open || panel.open || realms.owns_input() || glue_dialog.is_open() {
return;
}
let now = time.elapsed_secs();
@@ -169,3 +169,78 @@ pub(super) fn rotate_model(
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::net::{RealmChoice, RealmRequest};
+ use crate::realm_select::{list_keys, take_input, Realms};
+
+ /// Escape on the realm list raised by Change Realm closes the list and nothing else: the
+ /// press is the list's (`Realms::owns_input`), so this screen does not also go back to
+ /// login, in either order the two run.
+ #[test]
+ fn escape_on_the_realm_list_does_not_also_leave_for_login() {
+ for list_first in [true, false] {
+ let (pick_tx, pick_rx) = crossbeam_channel::unbounded();
+ let (realm_tx, realm_rx) = crossbeam_channel::unbounded();
+ let mut app = App::new();
+ app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
+ .insert_state(ClientState::CharSelect)
+ .init_resource::()
+ .init_resource::>()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .insert_resource(CharPick(pick_tx))
+ .insert_resource(RealmChoice(realm_tx))
+ .add_message::()
+ .add_message::()
+ .add_systems(PreUpdate, take_input);
+ if list_first {
+ app.add_systems(Update, (list_keys, select_input).chain());
+ } else {
+ app.add_systems(Update, (select_input, list_keys).chain());
+ }
+ app.world_mut()
+ .resource_scope(|world, mut realms: Mut| {
+ crate::realm_select::open_over_char_select(
+ &mut realms,
+ world.resource::(),
+ );
+ });
+ app.world_mut()
+ .resource_mut::>()
+ .press(KeyCode::Escape);
+ app.update();
+ app.world_mut()
+ .resource_mut::>()
+ .clear();
+ app.update(); // `StateTransition` applies a pending state at the next frame
+
+ let order = if list_first {
+ "list first"
+ } else {
+ "screen first"
+ };
+ assert!(
+ realm_rx
+ .try_iter()
+ .any(|r| matches!(r, RealmRequest::Abandon)),
+ "{order}: the list's Cancel ran"
+ );
+ assert_eq!(
+ *app.world().resource::>().get(),
+ ClientState::CharSelect,
+ "{order}: the press was the list's, so the screen under it stays"
+ );
+ assert!(
+ pick_rx.try_recv().is_err(),
+ "{order}: and the session is not dropped"
+ );
+ }
+ }
+}
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, Without)>,
@@ -90,7 +91,10 @@ pub(super) fn refresh_list(
Some(realm) => {
// `GetServerName`'s `isPVP, isRP` pair. A normal realm has no suffix, unlike
// the realm list's `Normal`: the reference leaves `serverType = ""`.
- let suffix = match crate::realm_select::pvp_rp(realm.realm_type) {
+ let pvp_rp = realms
+ .as_deref()
+ .map_or((false, false), |r| r.pvp_rp(realm.realm_type));
+ let suffix = match pvp_rp {
(true, true) => strings.text("RPPVP_PARENTHESES", "(RPPVP)"),
(false, true) => strings.text("RP_PARENTHESES", "(RP)"),
(true, false) => strings.text("PVP_PARENTHESES", "(PVP)"),
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/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/cvars/mod.rs b/crates/benilla-app/src/cvars/mod.rs
index 7c137a48..fdc81f25 100644
--- a/crates/benilla-app/src/cvars/mod.rs
+++ b/crates/benilla-app/src/cvars/mod.rs
@@ -1528,10 +1528,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);
@@ -2464,6 +2469,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 0cbf81de..78b024e3 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/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs
index f794730d..0af8252b 100644
--- a/crates/benilla-app/src/game_plugins.rs
+++ b/crates/benilla-app/src/game_plugins.rs
@@ -1194,7 +1194,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,
@@ -1202,7 +1202,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,
@@ -1223,6 +1223,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/lib.rs b/crates/benilla-app/src/lib.rs
index a94b89f0..7d94c423 100644
--- a/crates/benilla-app/src/lib.rs
+++ b/crates/benilla-app/src/lib.rs
@@ -235,6 +235,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/crates/benilla-app/src/login/mod.rs b/crates/benilla-app/src/login/mod.rs
index 58cfefd4..40308fea 100644
--- a/crates/benilla-app/src/login/mod.rs
+++ b/crates/benilla-app/src/login/mod.rs
@@ -664,8 +664,9 @@ fn login_input(
time: Res,
) {
// The reference's `RealmList` is a DIALOG-strata frame over this screen, so while it is up
- // everything here is inert, Escape included (it is the list's Cancel).
- if realms.shown {
+ // everything here is inert, Escape included (it is the list's Cancel), through the frame
+ // its own Escape closes it in.
+ if realms.owns_input() {
return;
}
let empty = GlueStrings::default();
@@ -1006,6 +1007,73 @@ mod tests {
(app, rx)
}
+ /// Escape on the realm list over this screen closes the list and nothing else: the press is
+ /// the list's (`Realms::owns_input`), so the login screen does not also quit, in either
+ /// order the two run.
+ #[test]
+ fn escape_on_the_realm_list_does_not_also_quit() {
+ for list_first in [true, false] {
+ let (tx, _requests) = crossbeam_channel::unbounded();
+ let (realm_tx, realm_rx) = crossbeam_channel::unbounded();
+ let mut app = App::new();
+ app.add_plugins(MinimalPlugins)
+ .init_resource::()
+ .init_resource::>()
+ .init_resource::()
+ .insert_non_send_resource(HostClipboard::default())
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .insert_resource(crate::realmlist::Realmlist::unpinned(
+ crate::realmlist::DEFAULT_REALMLIST,
+ ))
+ .insert_resource(LoginSubmit(tx))
+ .insert_resource(LoginAbandon(std::sync::Arc::new(
+ std::sync::atomic::AtomicU64::new(0),
+ )))
+ .insert_resource(crate::net::RealmChoice(realm_tx))
+ .add_message::()
+ .add_message::()
+ .add_message::()
+ .add_systems(PreUpdate, crate::realm_select::take_input);
+ let list = crate::realm_select::list_keys;
+ if list_first {
+ app.add_systems(Update, (list, login_input).chain());
+ } else {
+ app.add_systems(Update, (login_input, list).chain());
+ }
+ // The list stands over this screen, as the realm park raises it after logon.
+ app.world_mut()
+ .resource_mut::()
+ .shown = true;
+ app.world_mut()
+ .resource_mut::>()
+ .press(KeyCode::Escape);
+ app.update();
+
+ let order = if list_first {
+ "list first"
+ } else {
+ "screen first"
+ };
+ assert!(
+ !app.world().resource::().shown,
+ "{order}: the list's Cancel closed it"
+ );
+ assert!(
+ realm_rx
+ .try_iter()
+ .any(|r| matches!(r, crate::net::RealmRequest::Abandon)),
+ "{order}: and answered the park"
+ );
+ assert!(
+ !app.world().contains_resource::(),
+ "{order}: the press was the list's, so the login screen does not also quit"
+ );
+ }
+ }
+
/// A lost session does not log itself back in: a displaced client's resubmit would kick the
/// client that displaced it. `GlueParent.lua` retries nothing.
#[test]
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/nameplates.rs b/crates/benilla-app/src/nameplates.rs
index c950c19e..007f6067 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;
@@ -29,7 +35,7 @@ use benilla_ui::script::{JustifyH, JustifyV, Outline};
use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback};
use crate::names::NameCache;
use crate::net::{Guid, NetCommands, NetEntity, ObjectStore, Reputations, SelfPlayer};
-use crate::target::{ring_reaction, ring_variant, CombatFlash, Factions, RingVariant, Selection};
+use crate::target::{selection_variant, CombatFlash, Factions, RingVariant, Selection};
use crate::ui_text::{layout_text_quads, FontSpec, Justify, TextSeat, UiFontAtlas};
use benilla_world::view::WorldCamera;
@@ -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.
@@ -121,8 +209,8 @@ pub(crate) fn height_scale(d: f32) -> f32 {
}
}
-/// What a name line is painted with: the ring's selector ([`ring_variant`]), which the reference's
-/// name render also calls (`0x605960`), or the combat flash.
+/// What a name line is painted with: the ring's selector ([`selection_variant`]), which the
+/// reference's name render also calls (`0x605960`), or the combat flash.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum NamePaint {
/// The selector's answer.
@@ -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 {
@@ -423,48 +513,38 @@ pub(crate) fn drive_nameplates(
.and_then(|s| crate::ui_guild::unit_guild_name(&s.0, &mut guilds, &net_commands)),
_ => None,
};
- // The colour: the ring's reaction rank and the shared selector.
- let rank = ring_reaction(
- factions.as_deref(),
- &reputations,
- store,
- self_store.single().ok(),
- );
- let is_dead = store.is_some_and(|s| s.0.unit_is_dead());
- // The ring's player inputs, for this unit: PvP flag (`UNIT_FIELD_FLAGS` 0x1000) and party.
- let pvp = store.is_some_and(|s| s.0.unit_flags() & 0x1000 != 0);
- let in_party = group.members.iter().any(|m| m.guid == guid.0);
// The selector's first-priority branch: the combat flash while we melee this unit.
let color = if flash.unit == Some(entity) {
NamePaint::Flash
} else {
- NamePaint::Variant(ring_variant(
- rank,
+ NamePaint::Variant(selection_variant(
+ factions.as_deref(),
+ &reputations,
+ store,
+ self_store.single().ok(),
net.kind == EntityKind::Player,
- is_dead,
- pvp,
- in_party,
+ group.members.iter().any(|m| m.guid == guid.0),
))
};
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 +575,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 +631,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 +694,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 +714,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 +746,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();
}
}
@@ -679,6 +772,7 @@ fn evict_name_meshes(
#[cfg(test)]
mod tests {
use super::*;
+ use crate::target::{ring_variant, PlayerPath, SelectorInput};
/// `>` not `>=` at the knee and `0.075*d` beyond: the jump at the knee is the reference's.
#[test]
@@ -713,12 +807,18 @@ mod tests {
/// A PvP-flagged friendly player's name is the ring's green, not the soft blue.
#[test]
fn name_color_is_the_ring_selector_itself() {
- let paint = |rank, is_player, is_dead, pvp, in_party| {
- NamePaint::Variant(ring_variant(rank, is_player, is_dead, pvp, in_party))
+ let player = |attacks_us, attackable, pvp, in_party| {
+ NamePaint::Variant(ring_variant(SelectorInput::Player(PlayerPath {
+ attacks_us,
+ attackable,
+ pvp,
+ in_party,
+ })))
};
+ let npc = |rank, dead| NamePaint::Variant(ring_variant(SelectorInput::Npc { rank, dead }));
// Flagged is green, unflagged the soft blue, and the two differ.
- let flagged = paint(6, true, false, true, false);
- let unflagged = paint(6, true, false, false, false);
+ let flagged = player(false, false, true, false);
+ let unflagged = player(false, false, false, false);
assert_eq!(flagged, NamePaint::Variant(RingVariant::Friendly));
assert_eq!(unflagged, NamePaint::Variant(RingVariant::Player));
assert_ne!(flagged.color(), unflagged.color());
@@ -728,62 +828,204 @@ mod tests {
"0xFF00FF00 — the ring's own green, byte for byte"
);
assert_eq!(
- paint(6, true, false, true, true),
+ player(false, false, true, true),
NamePaint::Variant(RingVariant::PartyPvp)
);
assert_eq!(
- paint(6, true, false, false, true),
+ player(false, false, false, true),
NamePaint::Variant(RingVariant::Party)
);
- assert_eq!(paint(0, false, false, false, false).color(), RED);
+ assert_eq!(npc(0, false).color(), RED);
assert_eq!(
- paint(6, true, true, false, false),
- NamePaint::Variant(RingVariant::Player),
- "dead player never grays"
+ player(true, true, true, false).color(),
+ RED,
+ "mutual attack"
);
- assert_eq!(paint(1, true, false, true, false).color(), RED, "hostile");
assert_eq!(
- paint(6, false, true, false, false),
- NamePaint::Variant(RingVariant::Dead)
+ player(false, true, false, false).color(),
+ Color::linear_rgb(1.0, 1.0, 0.0),
+ "attackable, not attacking: yellow"
);
+ assert_eq!(npc(6, true), NamePaint::Variant(RingVariant::Dead));
}
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 +1042,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 +1098,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/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..0ea7d84b
--- /dev/null
+++ b/crates/benilla-app/src/net/apply/world_enter_tests.rs
@@ -0,0 +1,163 @@
+//! 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`, `CMSG_BATTLEFIELD_STATUS` and `CMSG 0x296`; a worldport's fresh
+//! create sends all five 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 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();
+ 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::()
+ .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,
+ crate::ui_dialog_verbs::meeting_stone_enter_world,
+ )
+ .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_five() -> Vec {
+ vec![
+ format!("SetActiveMover {ME:#x}"),
+ "QueryTime".into(),
+ "QueryNextMailTime".into(),
+ "BattlefieldStatusRequest".into(),
+ "MeetingStoneStatusQuery".into(),
+ ]
+}
+
+/// 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]
+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_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_five());
+ // 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_five_again() {
+ let (mut app, rx) = app();
+ log_in(&mut app);
+ let first = self_create(&mut app);
+ app.update();
+ 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_five());
+}
+
+/// 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;
+ 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
+ ));
+ 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/net/io.rs b/crates/benilla-app/src/net/io.rs
index 869b97c4..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::();
@@ -607,8 +611,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();
@@ -762,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
@@ -791,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
@@ -832,7 +831,6 @@ fn writer_loop(
warned += 1;
}
}
- trace_sends(w);
}
},
recv(cmd_rx) -> cmd => {
@@ -1477,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-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/realm_select/input.rs b/crates/benilla-app/src/realm_select/input.rs
index eb49770c..2c2172dd 100644
--- a/crates/benilla-app/src/realm_select/input.rs
+++ b/crates/benilla-app/src/realm_select/input.rs
@@ -68,7 +68,7 @@ pub(super) fn clicks(
}
/// `RealmList_OnKeyDown` (ESCAPE / ENTER), plus arrow-key row cycling and the wheel.
-pub(super) fn keys(
+pub(crate) fn keys(
keys: Res>,
(mut wheel, mut wheel_carry): (MessageReader, Local),
mut realms: ResMut,
diff --git a/crates/benilla-app/src/realm_select/load.rs b/crates/benilla-app/src/realm_select/load.rs
index 46b4a04d..d8b9dc60 100644
--- a/crates/benilla-app/src/realm_select/load.rs
+++ b/crates/benilla-app/src/realm_select/load.rs
@@ -9,6 +9,8 @@
use bevy::prelude::*;
+use benilla_formats::RealmConfigs;
+
// ── The six glue font colours (`GlueFonts.xml:4-9`) ─────────────────────────────────────────────
// `BLUE_FONT_COLOR` exists only in glue; FrameXML's `Fonts.xml` has the other five.
@@ -98,10 +100,9 @@ pub(super) fn load_column(down: bool, load: f32) -> (&'static str, Color) {
}
}
-/// The Type column's key and colour; the Lua tests `pvp and rp`, `rp`, `pvp`, then neither, so
-/// RP-PvP takes `NORMAL`, not the RP green.
-pub(super) fn type_column(realm_type: u32) -> (&'static str, Color) {
- let (pvp, rp) = pvp_rp(realm_type);
+/// The Type column's key and colour for a realm's `(pvp, rp)`; the Lua tests `pvp and rp`, `rp`,
+/// `pvp`, then neither, so RP-PvP takes `NORMAL`, not the RP green.
+pub(super) fn type_column((pvp, rp): (bool, bool)) -> (&'static str, Color) {
match (pvp, rp) {
(true, true) => ("RPPVP_PARENTHESES", NORMAL),
(false, true) => ("RP_PARENTHESES", GREEN),
@@ -110,18 +111,38 @@ pub(super) fn type_column(realm_type: u32) -> (&'static str, Color) {
}
}
-/// The realm type as `(pvp, rp)`, shared by `GetRealmInfo` and `GetServerName`. The reference
-/// (`0x46efda`) scans `Cfg_Configs.dbc` for the row whose `RealmType` matches the realm's first
-/// wire dword and reads `PlayerKillingAllowed` and `RoleplayingRealm`; the table is that DBC
-/// transcribed (types 3 and 5 are PvP, 7 is RP), and a type with no row reads neither.
-pub(crate) fn pvp_rp(realm_type: u32) -> (bool, bool) {
- match realm_type {
- // RealmType → (PlayerKillingAllowed, RoleplayingRealm)
- 0 | 2 | 4 => (false, false),
- 1 | 3 | 5 => (true, false),
- 6 | 7 => (false, true),
- 8 => (true, true),
- _ => (false, false),
+/// The realm type as `(pvp, rp)`, shared by `GetRealmInfo`, `GetServerName` and the world-entry
+/// latch: each scans `Cfg_Configs.dbc` for the first row whose `RealmType` is the realm's wire
+/// type and reads `PlayerKillingAllowed` and `RoleplayingRealm` (`0x46efda`); a type with no row
+/// reads neither.
+pub(super) fn pvp_rp(types: &RealmConfigs, realm_type: u32) -> (bool, bool) {
+ types
+ .get(realm_type)
+ .map_or((false, false), |c| (c.pvp, c.rp))
+}
+
+/// Read `Cfg_Configs.dbc` once, after the archive chain opens.
+pub(super) fn load_realm_types(
+ mut realms: ResMut,
+ assets: Option>,
+) {
+ use benilla_assets::LockRecover;
+ let Some(assets) = assets else {
+ return;
+ };
+ let mut chain = assets.chain.lock_recover();
+ match benilla_formats::load_realm_configs(&mut chain) {
+ Ok(types) => {
+ info!(
+ "realm list: {} Cfg_Configs.dbc realm types",
+ types.rows().len()
+ );
+ realms.types = types;
+ }
+ Err(e) => warn!(
+ "realm list: Cfg_Configs.dbc failed to load, so every realm reads Normal and no realm \
+ is PvP: {e:#}"
+ ),
}
}
@@ -236,22 +257,37 @@ mod tests {
assert_eq!(load_column(true, 2.0), ("REALM_DOWN", GRAY));
}
- /// RP-PvP takes `NORMAL`: the Lua's `pvp and rp` arm sets `NORMAL_FONT_COLOR`.
+ /// The Type column through the install's `Cfg_Configs.dbc`: type 10 is PvP, and RP-PvP takes
+ /// `NORMAL`, as the Lua's `pvp and rp` arm sets `NORMAL_FONT_COLOR`.
#[test]
- fn the_type_column_is_the_shipped_cfg_configs_rows() {
- for t in [0, 2, 4] {
- assert_eq!(type_column(t), ("GAMETYPE_NORMAL", NORMAL), "type {t}");
+ fn the_type_column_reads_the_shipped_cfg_configs() {
+ let data = benilla_formats::wow_data_or_skip!();
+ let mut chain = benilla_formats::open_chain(&data).expect("chain");
+ let types = benilla_formats::load_realm_configs(&mut chain).expect("Cfg_Configs.dbc");
+ let column = |t| type_column(pvp_rp(&types, t));
+ for t in [0, 2, 4, 9] {
+ assert_eq!(column(t), ("GAMETYPE_NORMAL", NORMAL), "type {t}");
}
- // Cfg_Configs rows 3 and 5 are PvP too.
- for t in [1, 3, 5] {
- assert_eq!(type_column(t), ("PVP_PARENTHESES", RED), "type {t}");
+ for t in [1, 3, 5, 10] {
+ assert_eq!(column(t), ("PVP_PARENTHESES", RED), "type {t}");
}
for t in [6, 7] {
- assert_eq!(type_column(t), ("RP_PARENTHESES", GREEN), "type {t}");
+ assert_eq!(column(t), ("RP_PARENTHESES", GREEN), "type {t}");
}
- assert_eq!(type_column(8), ("RPPVP_PARENTHESES", NORMAL));
- // No row: the scan reads neither column.
- assert_eq!(type_column(77), ("GAMETYPE_NORMAL", NORMAL));
+ assert_eq!(column(8), ("RPPVP_PARENTHESES", NORMAL));
+ }
+
+ /// No row: the scan reads neither column, so the type is Normal, as it is with no table.
+ #[test]
+ fn a_type_with_no_row_is_normal() {
+ let types = RealmConfigs::from_rows(vec![benilla_formats::RealmConfig {
+ realm_type: 1,
+ pvp: true,
+ rp: false,
+ }]);
+ assert_eq!(pvp_rp(&types, 1), (true, false));
+ assert_eq!(pvp_rp(&types, 77), (false, false));
+ assert_eq!(pvp_rp(&RealmConfigs::default(), 1), (false, false));
}
/// The default gold is not `NORMAL_FONT_COLOR`.
diff --git a/crates/benilla-app/src/realm_select/mod.rs b/crates/benilla-app/src/realm_select/mod.rs
index 467b268c..1f4fefcd 100644
--- a/crates/benilla-app/src/realm_select/mod.rs
+++ b/crates/benilla-app/src/realm_select/mod.rs
@@ -25,12 +25,14 @@ mod load;
mod screen;
mod smoke;
-pub(crate) use load::pvp_rp;
+/// The list's own keys, for a screen's test to run beside its own input.
+#[cfg(test)]
+pub(crate) use input::keys as list_keys;
use bevy::prelude::*;
use crate::net::{RealmChoice, RealmListMessage, RealmRequest};
-use benilla_formats::RealmCategory;
+use benilla_formats::{RealmCategory, RealmConfigs};
use benilla_protocol::RealmInfo;
/// The persisted 1.12 CVar naming the last realm connected to (`0x83f2d0`); the SavedVariables
@@ -51,6 +53,9 @@ pub(crate) struct Realms {
/// The client Region's categories ([`category`]), read once at startup; `None` until then,
/// and every realm lists untabbed.
pub(super) categories: Option>,
+ /// `Cfg_Configs.dbc`, the realm types ([`Self::pvp_rp`]), read once at startup; empty until
+ /// then, and every type reads Normal.
+ pub(super) types: RealmConfigs,
/// The tab in front, `RealmList.selectedCategory`: a 1-based ordinal over the categories
/// holding a realm.
pub(super) category: Option,
@@ -64,6 +69,9 @@ pub(crate) struct Realms {
/// `RealmList:IsVisible()`. While set, a published list is a refresh and is never
/// auto-answered.
pub(super) shown: bool,
+ /// [`Self::shown`] as this frame's input found it, taken once in `PreUpdate` by
+ /// [`take_input`]; read through [`Self::owns_input`].
+ owns_input: bool,
/// `GetRealmInfo`'s `currentRealm`, the row highlighted when nothing else is. The reference
/// case-folds the `realmName` CVar (`0x46ef9e`, via `0x5ab7d0` on the handle from `0x63db90`)
/// against the realm name, and `ConnectToRealm` (`0x46b217`) writes the CVar before it dials.
@@ -126,6 +134,19 @@ impl Sort {
}
impl Realms {
+ /// Whether this frame's keys and clicks are the list's, not the screen's beneath it.
+ ///
+ /// The reference walks a key-down once, strata 8 down to 0 (`0x765f10`), and the first frame
+ /// whose `OnKeyDown` slot returns nonzero ends the walk (`0x765f86`, then `0x765f64`), whatever
+ /// that handler did. `RealmList` is a keyboard-enabled `DIALOG` frame with `OnKeyDown` bound,
+ /// so while it is shown `0x76b7d0` consumes every key it is reached with, ahead of
+ /// `AccountLogin` and `CharacterSelect`, which set no strata. So a press has one owner even
+ /// when its handler hides the list: the list and the screens ask this, never [`Self::shown`],
+ /// which the list's own Escape or Enter clears mid-frame.
+ pub(crate) fn owns_input(&self) -> bool {
+ self.owns_input
+ }
+
/// The rows the screen draws: indices into [`Self::realms`] for the tab in front, sorted per
/// category (`0x46e750`).
pub(super) fn rows(&self) -> Vec {
@@ -143,7 +164,7 @@ impl Realms {
la.total_cmp(&lb)
}
SortKey::Name => ra.name.to_lowercase().cmp(&rb.name.to_lowercase()),
- SortKey::Type => load::pvp_rp(ra.realm_type).cmp(&load::pvp_rp(rb.realm_type)),
+ SortKey::Type => self.pvp_rp(ra.realm_type).cmp(&self.pvp_rp(rb.realm_type)),
};
if ord != std::cmp::Ordering::Equal {
return if descending { ord.reverse() } else { ord };
@@ -156,6 +177,16 @@ impl Realms {
rows
}
+ /// A realm type's `(pvp, rp)` in `Cfg_Configs.dbc`, `(false, false)` for a type with no row.
+ pub(crate) fn pvp_rp(&self, realm_type: u32) -> (bool, bool) {
+ load::pvp_rp(&self.types, realm_type)
+ }
+
+ /// A realm type's `PlayerKillingAllowed`, `None` for a type with no row.
+ pub(crate) fn row_pvp(&self, realm_type: u32) -> Option {
+ self.types.get(realm_type).map(|c| c.pvp)
+ }
+
/// The load distribution, over every realm, not the selected category: `0x46e510` walks the
/// flat all-categories array, so switching tabs moves no band.
pub(super) fn stats(&self) -> (f32, f32) {
@@ -232,15 +263,26 @@ pub(super) fn is_invalid(realm: &RealmInfo) -> bool {
realm.flags & 0x01 != 0
}
+/// Settle who owns this frame's input before the list or any glue screen reads it
+/// ([`Realms::owns_input`]).
+pub(crate) fn take_input(mut realms: ResMut) {
+ if realms.owns_input != realms.shown {
+ realms.owns_input = realms.shown;
+ }
+}
+
/// The realm-selection subsystem: the park's policy plus the screen.
pub(crate) struct RealmSelectPlugin;
impl Plugin for RealmSelectPlugin {
fn build(&self, app: &mut App) {
app.init_resource::()
+ // `shown` is written only in `Update`, so any slot here sees the frame's start.
+ .add_systems(PreUpdate, take_input)
.add_systems(
Startup,
- category::load_categories.after(benilla_assets::AssetSet::Open),
+ (category::load_categories, load::load_realm_types)
+ .after(benilla_assets::AssetSet::Open),
)
.add_systems(
Update,
@@ -251,13 +293,14 @@ impl Plugin for RealmSelectPlugin {
// Input before the row refresh, so a click shows on the frame it landed.
smoke::debug_realm_smoke,
(
- input::clicks,
- input::keys,
- tick_refresh,
- screen::refresh_list,
+ (input::clicks, input::keys)
+ .chain()
+ .run_if(|realms: Res| realms.owns_input()),
+ (tick_refresh, screen::refresh_list)
+ .chain()
+ .run_if(|realms: Res| realms.shown),
)
- .chain()
- .run_if(|realms: Res| realms.shown),
+ .chain(),
)
.chain()
.after(benilla_world::schedule::WorldStage::Net)
diff --git a/crates/benilla-app/src/realm_select/screen.rs b/crates/benilla-app/src/realm_select/screen.rs
index cbd6710e..e0b74640 100644
--- a/crates/benilla-app/src/realm_select/screen.rs
+++ b/crates/benilla-app/src/realm_select/screen.rs
@@ -728,7 +728,7 @@ pub(super) fn refresh_list(
}
// `RealmListUpdate` turns the selected row's type and load `HIGHLIGHT_FONT_COLOR`.
Column::Type => {
- let (key, c) = load::type_column(realm.realm_type);
+ let (key, c) = load::type_column(realms.pvp_rp(realm.realm_type));
(text(key), if is_selected { load::HIGHLIGHT } else { c })
}
Column::Players => (load::players_text(realm.characters), load::HIGHLIGHT),
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/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/click.rs b/crates/benilla-app/src/target/click.rs
index 3f27b934..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;
};
@@ -1430,7 +1447,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)>,
@@ -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());
+ }
+ }
}
diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs
index 61a15f9a..4b99cd49 100644
--- a/crates/benilla-app/src/target/mod.rs
+++ b/crates/benilla-app/src/target/mod.rs
@@ -74,7 +74,9 @@ pub(crate) use by_name::{AssistRequest, PlayerLookup, TargetByNameRequest};
pub(crate) use by_name::SelectCommit;
// The reaction decode and its faction catalog, which also tint the target frame
// (`TargetFrame_CheckFaction`); `duel_rung` is the same walk, for `/reaction`.
-pub(crate) use ring::{duel_rung, ring_reaction, ring_variant, Factions, RingVariant};
+pub(crate) use ring::{duel_rung, ring_reaction, selection_variant, Factions, RingVariant};
+#[cfg(test)]
+pub(crate) use ring::{ring_variant, PlayerPath, SelectorInput};
pub(crate) use click::DeselectGuid;
pub use click::Interact;
diff --git a/crates/benilla-app/src/target/ring.rs b/crates/benilla-app/src/target/ring.rs
index 51639be4..7f2d494e 100644
--- a/crates/benilla-app/src/target/ring.rs
+++ b/crates/benilla-app/src/target/ring.rs
@@ -49,8 +49,8 @@ const RING_TEXTURE: &str = "mpq://textures/unitselecttexture.blp";
const RING_FALLBACK_RADIUS: f32 = benilla_formats::DEGENERATE_RING_FOOTPRINT;
/// The selector's own palette (`0x605960`), not the nameplate's, written as each decal vertex's
/// diffuse: NPCs by reaction rank 0-1 red, 2 orange `0xFFFF8000`, 3 yellow, 4-7 green, dead gray
-/// `0xFF7F7F7F`; players soft blue `0xFF6060FF` or, PvP-flagged, green; party members (the table
-/// `0xbc6f48`) pale blue `0xFFAAAAFF` or pale green `0xFFAAFFAA`.
+/// `0xFF7F7F7F`; players red, yellow, soft blue `0xFF6060FF` or green, and party members (the
+/// table `0xbc6f48`) pale blue `0xFFAAAAFF` or pale green `0xFFAAFFAA` ([`ring_variant`]).
// `linear_rgb` passes the authored bytes raw: the framebuffer is gamma-encoded.
const RING_HOSTILE: Color = Color::linear_rgb(1.0, 0.0, 0.0);
const RING_UNFRIENDLY: Color = Color::linear_rgb(1.0, 0.502, 0.0);
@@ -118,43 +118,81 @@ impl RingVariant {
}
}
-/// The selector's branch (`0x605960`) on [`ring_reaction`]'s rank `0..=6`. A player never grays:
-/// rank 0-1 reads red, standing in for the reference's attackability matrix, else green when
-/// PvP-flagged and blue when not, paler for a party member; self is not in the party table. An NPC
-/// reads gray when dead, else the rank palette. The reference takes the player branch on
-/// `UNIT_FIELD_FLAGS` bit 3 (`0x605baa`), which a player's pet carries too; callers pass the object
-/// type.
-pub(crate) fn ring_variant(
- rank: u8,
+/// The selector's player-path inputs (`0x605bb6`–`0x605cf6`).
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub(crate) struct PlayerPath {
+ /// X, `CanAttack(unit → player)` (`0x605bb9`).
+ pub(crate) attacks_us: bool,
+ /// Y, `CanAttack(player → unit)` (`0x605bc8`, `0x605c0b`).
+ pub(crate) attackable: bool,
+ /// `IsPvP 0x605ff0`: the unit's `UNIT_FLAG_PVP`; the reference reads its `CHARMEDBY`, else
+ /// `CREATEDBY` owner's when it has one, and answers no for a unit flagged `0x100`.
+ pub(crate) pvp: bool,
+ /// `0x4e7f70`: you or a member of the party table `0xbc6f48`; the selector tests itself first
+ /// (`0x605c46`), so here it is a member.
+ pub(crate) in_party: bool,
+}
+
+/// What the selector (`0x605960`) branches on below the combat flash.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub(crate) enum SelectorInput {
+ /// The player path, which never grays.
+ Player(PlayerPath),
+ /// Any other unit: [`ring_reaction`]'s rank `0..=6`, and whether it is dead.
+ Npc { rank: u8, dead: bool },
+}
+
+/// The selector's colour class. The player path: X∧Y red, X∧¬Y soft blue, ¬X∧Y yellow
+/// (`0x605bb6`–`0x605c2c`), else green when PvP-flagged and blue when not, paler for a party
+/// member. Any other unit: gray when dead, else the rank palette. The reference takes the player
+/// path on `UNIT_FIELD_FLAGS` bit 3 (`0x605baa`), which a player's pet carries too; callers pass
+/// the object type.
+pub(crate) fn ring_variant(input: SelectorInput) -> RingVariant {
+ match input {
+ SelectorInput::Player(p) => match (p.attacks_us, p.attackable, p.pvp, p.in_party) {
+ (true, true, ..) => RingVariant::Hostile,
+ (true, false, ..) => RingVariant::Player,
+ (false, true, ..) => RingVariant::Neutral,
+ (false, false, true, true) => RingVariant::PartyPvp,
+ (false, false, true, false) => RingVariant::Friendly,
+ (false, false, false, true) => RingVariant::Party,
+ (false, false, false, false) => RingVariant::Player,
+ },
+ SelectorInput::Npc { dead: true, .. } => RingVariant::Dead,
+ SelectorInput::Npc { rank, .. } => match rank {
+ 0..=1 => RingVariant::Hostile,
+ 2 => RingVariant::Unfriendly,
+ 3 => RingVariant::Neutral,
+ _ => RingVariant::Friendly,
+ },
+ }
+}
+
+/// `GetSelectionCircleColor 0x605960` below the combat flash, read off the stores: the ground ring
+/// and the overhead name both paint with it.
+pub(crate) fn selection_variant(
+ factions: Option<&Factions>,
+ reputations: &Reputations,
+ store: Option<&ObjectStore>,
+ self_store: Option<&ObjectStore>,
is_player: bool,
- is_dead: bool,
- pvp: bool,
in_party: bool,
) -> RingVariant {
- if is_player {
- return if rank <= 1 {
- RingVariant::Hostile
- } else if pvp {
- if in_party {
- RingVariant::PartyPvp
- } else {
- RingVariant::Friendly
- }
- } else if in_party {
- RingVariant::Party
- } else {
- RingVariant::Player
- };
- }
- if is_dead {
- return RingVariant::Dead;
- }
- match rank {
- 0..=1 => RingVariant::Hostile,
- 2 => RingVariant::Unfriendly,
- 3 => RingVariant::Neutral,
- _ => RingVariant::Friendly,
- }
+ let attack =
+ |attacker| can_attack_between(factions, reputations, store, self_store, true, attacker);
+ ring_variant(if is_player {
+ SelectorInput::Player(PlayerPath {
+ attacks_us: attack(Attacker::Unit),
+ attackable: attack(Attacker::Player),
+ pvp: store.is_some_and(|s| s.0.unit_flags() & UNIT_FLAG_PVP != 0),
+ in_party,
+ })
+ } else {
+ SelectorInput::Npc {
+ rank: ring_reaction(factions, reputations, store, self_store),
+ dead: store.is_some_and(|s| s.0.unit_is_dead()),
+ }
+ })
}
/// Load the ring texture and seed the ring record.
@@ -261,9 +299,6 @@ pub(super) fn update_ring(
self_store.single().ok(),
);
let is_player = net.is_some_and(|n| n.kind == EntityKind::Player);
- // The unit's own PvP flag (`0x1000`); the reference reads its charmer's or
- // summoner's when it has one.
- let pvp = store.is_some_and(|s| s.0.unit_flags() & 0x1000 != 0);
let in_party = selection
.guid
.is_some_and(|g| targets.2.members.iter().any(|m| m.guid == g));
@@ -294,7 +329,7 @@ pub(super) fn update_ring(
.single()
.ok()
.and_then(|s| s.0.unit_faction_template()),
- if is_player { " [player→blue]" } else { "" },
+ if is_player { " [player path]" } else { "" },
if is_dead { " [dead→gray]" } else { "" },
);
}
@@ -302,7 +337,15 @@ pub(super) fn update_ring(
state.color = if flash.unit == Some(target) {
flash.color
} else {
- ring_variant(rank, is_player, is_dead, pvp, in_party).color()
+ selection_variant(
+ factions.as_deref(),
+ &reputations,
+ store,
+ self_store.single().ok(),
+ is_player,
+ in_party,
+ )
+ .color()
};
// No ground in the box hides the ring: the reference skips the draw (`0x6d74b5`).
!projected
@@ -403,8 +446,9 @@ pub(super) fn push_ring(
}
/// The target's reaction toward our player as a rank `0..=6`, the direction every NPC colour
-/// uses (`0x605960` and `0x7cbaa0` ask `UnitReaction 0x6061e0` with the unit as `this`). When
-/// both carry `UNIT_FIELD_FLAGS` bit 3, a duel (`0x606296`) or mutual FFA decides first. Then
+/// uses (`0x605960` and `0x7cbaa0` ask `UnitReaction 0x6061e0` with the unit as `this`). One
+/// object toward itself is friendly before any rung (`0x606200`). When both carry
+/// `UNIT_FIELD_FLAGS` bit 3, a duel (`0x606296`) or mutual FFA decides next. Then
/// `0x606530`: a faction with a reputation slot (`0x605fc0`) answers with our reputation rank
/// (`0x4d63a0`), even in GM mode; any other goes to the template comparator (`0x606640`). Neutral
/// when anything is missing. `0x606439` caps that answer at 6, so Revered and Exalted both read 6.
@@ -417,12 +461,9 @@ pub(crate) fn ring_reaction(
self_store: Option<&ObjectStore>,
) -> u8 {
if let (Some(target), Some(own)) = (target_store, self_store) {
- if let Some(rank) = duel_reaction(&target.0, &own.0) {
+ if let Some(rank) = leading_rungs(target, own) {
return rank;
}
- if ffa_reaction(&target.0, &own.0) {
- return Reaction::Hostile as u8;
- }
}
let resolved = (|| {
let catalog = &factions?.0;
@@ -449,6 +490,25 @@ pub(crate) fn ring_reaction(
/// Revered, the highest rank `UnitReaction`'s generic leg answers (`0x606439`).
const REACTION_CAP: u8 = 6;
+/// Whether two stores are one object: the reference compares object pointers (`UnitReaction`
+/// `0x606200`, `CanCooperate` `0x606ba6`), and an entity holds one store.
+fn same_object(a: &ObjectStore, b: &ObjectStore) -> bool {
+ std::ptr::eq(a, b)
+}
+
+/// `UnitReaction`'s rungs ahead of the faction legs, the same in both directions: one object
+/// toward itself is friendly (`0x606200`), then the duel and both-FFA rungs. Not built: the
+/// party rung between them (`0x6062b0`).
+fn leading_rungs(target: &ObjectStore, own: &ObjectStore) -> Option {
+ if same_object(target, own) {
+ return Some(Reaction::Friendly as u8);
+ }
+ if let Some(rank) = duel_reaction(&target.0, &own.0) {
+ return Some(rank);
+ }
+ ffa_reaction(&target.0, &own.0).then_some(Reaction::Hostile as u8)
+}
+
/// `UNIT_FIELD_FLAGS` bit 3, player-controlled in behaviour: players and their pets carry it, wild
/// creatures do not. `UnitReaction` needs it on both sides before any player-vs-player rung
/// (`0x606217`, `0x60622f`), and `CanAttack` picks its reaction arm by it (`0x606a13`).
@@ -505,14 +565,11 @@ pub(crate) fn reaction_from_player(
target_store: Option<&ObjectStore>,
self_store: Option<&ObjectStore>,
) -> u8 {
- // The player-vs-player block (`0x606217`) runs first in both directions.
+ // The identity leg and the player-vs-player block (`0x606217`) run first in both directions.
if let (Some(target), Some(own)) = (target_store, self_store) {
- if let Some(rank) = duel_reaction(&target.0, &own.0) {
+ if let Some(rank) = leading_rungs(target, own) {
return rank;
}
- if ffa_reaction(&target.0, &own.0) {
- return Reaction::Hostile as u8;
- }
}
let resolved = (|| {
let catalog = &factions?.0;
@@ -573,6 +630,14 @@ pub(crate) fn can_interact_from_player(
&& reaction_from_player(factions, reputations, target_store, self_store) >= NEUTRAL
}
+/// Which side of `CanAttack 0x606980` (`this` the attacker, the argument the victim) the local
+/// player takes; the selector asks both ways (`0x605bb9`, `0x605bc8`).
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+enum Attacker {
+ Player,
+ Unit,
+}
+
/// `CanAttack` from the local player (`0x606980`), every leg in the reference's order; the V-plate
/// category turns on it. Not a reaction threshold: an unflagged opposite-faction player on a PvE
/// realm is hostile yet not attackable, a same-faction duel opponent friendly yet attackable.
@@ -583,53 +648,86 @@ pub(crate) fn can_attack_from_player(
self_store: Option<&ObjectStore>,
target_is_player: bool,
) -> bool {
- let (Some(target), Some(own)) = (target_store, self_store) else {
+ can_attack_between(
+ factions,
+ reputations,
+ target_store,
+ self_store,
+ target_is_player,
+ Attacker::Player,
+ )
+}
+
+/// `CanAttack 0x606980` between a unit and the local player, the player attacking or attacked.
+fn can_attack_between(
+ factions: Option<&Factions>,
+ reputations: &Reputations,
+ unit_store: Option<&ObjectStore>,
+ self_store: Option<&ObjectStore>,
+ unit_is_player: bool,
+ attacker: Attacker,
+) -> bool {
+ let (Some(unit), Some(own)) = (unit_store, self_store) else {
return false; // fields not streamed yet; the reference's null path refuses too
};
- let (tflags, oflags) = (target.0.unit_flags(), own.0.unit_flags());
+ let player_attacks = attacker == Attacker::Player;
+ let (att, vic, victim_is_player) = if player_attacks {
+ (own, unit, unit_is_player)
+ } else {
+ (unit, own, true)
+ };
+ // `UnitReaction(this → arg)`: [`reaction_from_player`] with the player as `this`,
+ // [`ring_reaction`] with the unit.
+ let reaction = |player_is_this: bool| {
+ if player_is_this {
+ reaction_from_player(factions, reputations, unit_store, self_store)
+ } else {
+ ring_reaction(factions, reputations, unit_store, self_store)
+ }
+ };
+ let forward = || reaction(player_attacks);
+ let (vflags, aflags) = (vic.0.unit_flags(), att.0.unit_flags());
// (a) The ghost gate (`0x606987`): only a creature whose type flags carry `0x2` may attack a
- // ghost player, so the local player never can.
+ // ghost player (`0x605f70`); a player has no creature record, and no NPC's flags are held.
const PLAYER_FLAGS_GHOST: u32 = 0x10;
- if target_is_player && target.0.player_flags() & PLAYER_FLAGS_GHOST != 0 {
+ if victim_is_player && vic.0.player_flags() & PLAYER_FLAGS_GHOST != 0 {
return false;
}
- // (b) Five target-flag disqualifiers.
- if tflags & CANNOT_BE_ATTACKED != 0 {
+ // (b) Five victim-flag disqualifiers.
+ if vflags & CANNOT_BE_ATTACKED != 0 {
return false;
}
// (c) The four cross-flag immunity legs.
- let (t_controlled, o_controlled) = (
- tflags & UNIT_FLAG_PVP_ATTACKABLE != 0,
- oflags & UNIT_FLAG_PVP_ATTACKABLE != 0,
+ let (v_controlled, a_controlled) = (
+ vflags & UNIT_FLAG_PVP_ATTACKABLE != 0,
+ aflags & UNIT_FLAG_PVP_ATTACKABLE != 0,
);
- if o_controlled && tflags & IMMUNE_TO_PLAYER_CONTROLLED != 0
- || !o_controlled && tflags & IMMUNE_TO_UNCONTROLLED != 0
- || oflags & IMMUNE_TO_PLAYER_CONTROLLED != 0 && t_controlled
- || oflags & IMMUNE_TO_UNCONTROLLED != 0 && !t_controlled
+ if a_controlled && vflags & IMMUNE_TO_PLAYER_CONTROLLED != 0
+ || !a_controlled && vflags & IMMUNE_TO_UNCONTROLLED != 0
+ || aflags & IMMUNE_TO_PLAYER_CONTROLLED != 0 && v_controlled
+ || aflags & IMMUNE_TO_UNCONTROLLED != 0 && !v_controlled
{
return false;
}
// (d) The three terminal arms, selected by the two bit-3 flags.
- let toward_target = || reaction_from_player(factions, reputations, target_store, self_store);
- match (o_controlled, t_controlled) {
+ match (a_controlled, v_controlled) {
// Neither player-controlled: hostile in either direction suffices.
(false, false) => {
- toward_target() <= Reaction::Hostile as u8
- || ring_reaction(factions, reputations, target_store, self_store)
- <= Reaction::Hostile as u8
+ forward() <= Reaction::Hostile as u8
+ || reaction(!player_attacks) <= Reaction::Hostile as u8
}
- // Both: a friendly reaction refuses; else a live duel, the target's PvP flag or mutual
+ // Both: a friendly reaction refuses; else a live duel, the victim's PvP flag or mutual
// FFA. The reference's charm-owner resolve (`0x606170`) is not applied.
(true, true) => {
- if toward_target() >= Reaction::Friendly as u8 {
+ if forward() >= Reaction::Friendly as u8 {
return false;
}
- matches!(duel_rung(&target.0, &own.0), DuelRung::Engaged { .. })
- || tflags & UNIT_FLAG_PVP != 0
- || ffa_reaction(&target.0, &own.0)
+ matches!(duel_rung(&vic.0, &att.0), DuelRung::Engaged { .. })
+ || vflags & UNIT_FLAG_PVP != 0
+ || ffa_reaction(&vic.0, &att.0)
}
- // Mixed, the player against an ordinary NPC: attackable when worse than friendly.
- _ => toward_target() < Reaction::Friendly as u8,
+ // Mixed, a player-controlled unit against an ordinary one: worse than friendly.
+ _ => forward() < Reaction::Friendly as u8,
}
}
@@ -657,8 +755,8 @@ pub(crate) fn can_cooperate_with_player(
let (Some(target), Some(own)) = (target_store, self_store) else {
return false;
};
- // The first leg: a unit never cooperates with itself (`0x606ba6` compares the objects).
- if std::ptr::eq(target, own) {
+ // The first leg: a unit never cooperates with itself (`0x606ba6`).
+ if same_object(target, own) {
return false;
}
if target.0.unit_charmed_by().is_some_and(|g| g != 0)
@@ -761,51 +859,167 @@ pub(crate) fn duel_rung(
mod tests {
use super::{
can_attack_from_player, can_cooperate_with_player, plate_is_friendly, ring_reaction,
- ring_variant, Factions, RingVariant, UNIT_FLAG_PVP_ATTACKABLE,
+ ring_variant, selection_variant, Factions, PlayerPath, RingVariant, SelectorInput,
+ UNIT_FLAG_PVP_ATTACKABLE,
};
+ use crate::net::{ObjectStore, Reputations};
+ use benilla_protocol::field::{
+ FIELD_PLAYER_FLAGS, FIELD_UNIT_FACTIONTEMPLATE, FIELD_UNIT_FLAGS,
+ };
+ use benilla_protocol::ObjectFields;
+ /// `PLAYER_FLAGS_FFA_PVP` and `UNIT_FLAG_PVP`.
+ const FFA: u32 = 0x80;
+ const PVP: u32 = 0x1000;
+
+ /// A player-controlled player: faction template, `UNIT_FIELD_FLAGS` beside bit 3, `PLAYER_FLAGS`.
+ fn player(template: u32, unit_flags: u32, player_flags: u32) -> ObjectStore {
+ ObjectStore(ObjectFields::from_pairs(&[
+ (FIELD_UNIT_FACTIONTEMPLATE, template),
+ (FIELD_UNIT_FLAGS, UNIT_FLAG_PVP_ATTACKABLE | unit_flags),
+ (FIELD_PLAYER_FLAGS, player_flags),
+ ]))
+ }
+
+ /// One object toward itself: `UnitReaction` answers 4 before any rung (`0x606200`), so the
+ /// both-FFA rung never makes you hostile to yourself and `CanAttack` refuses (`0x606b03`).
#[test]
- fn player_ring_party_and_pvp_split() {
- let v = |pvp, in_party| ring_variant(5, true, false, pvp, in_party);
+ fn you_are_friendly_to_yourself_in_a_free_for_all_area() {
+ let reps = Reputations::default();
+ for unit_flags in [0, PVP] {
+ let me = player(1, unit_flags, FFA);
+ assert_eq!(ring_reaction(None, &reps, Some(&me), Some(&me)), 4);
+ assert!(!can_attack_from_player(
+ None,
+ &reps,
+ Some(&me),
+ Some(&me),
+ true
+ ));
+ }
+ // Another FFA player is still hostile and attackable.
+ let (me, them) = (player(1, 0, FFA), player(1, 0, FFA));
+ assert_eq!(ring_reaction(None, &reps, Some(&them), Some(&me)), 1);
+ assert!(can_attack_from_player(
+ None,
+ &reps,
+ Some(&them),
+ Some(&me),
+ true
+ ));
+ }
+
+ /// The selector's player path (`0x605bb6`–`0x605cf6`): `CanAttack` both ways, X the unit
+ /// attacking us and Y us attacking it. X∧Y red, X∧¬Y blue, ¬X∧Y yellow, else the PvP split.
+ /// No catalog here, so every faction leg reads neutral (3) and only the flags decide.
+ #[test]
+ fn the_player_path_is_the_mutual_attackability_matrix() {
+ let reps = Reputations::default();
+ let variant = |unit: &ObjectStore, me: &ObjectStore| {
+ selection_variant(None, &reps, Some(unit), Some(me), true, false)
+ };
+ // Yourself in the FFA ring keeps your PvP colour: neither direction attacks.
+ let me = player(1, 0, FFA);
+ assert_eq!(variant(&me, &me), RingVariant::Player, "unflagged self");
+ let me = player(1, PVP, FFA);
+ assert_eq!(variant(&me, &me), RingVariant::Friendly, "flagged self");
+ // Another player in the ring: both FFA, both attack.
+ let ffa = |flags| player(1, flags, FFA);
+ assert_eq!(variant(&ffa(0), &ffa(0)), RingVariant::Hostile);
+ // Outside it, each side attacks the other only when that other is flagged.
+ let open = |flags| player(1, flags, 0);
+ assert_eq!(variant(&open(PVP), &open(PVP)), RingVariant::Hostile, "X∧Y");
+ assert_eq!(variant(&open(0), &open(PVP)), RingVariant::Player, "X∧¬Y");
+ assert_eq!(variant(&open(PVP), &open(0)), RingVariant::Neutral, "¬X∧Y");
+ assert_eq!(variant(&open(0), &open(0)), RingVariant::Player, "¬X∧¬Y");
+ // The party split rides the last arm only.
assert_eq!(
- v(false, false),
- RingVariant::Player,
- "friendly solo = soft blue"
+ selection_variant(None, &reps, Some(&open(0)), Some(&open(0)), true, true),
+ RingVariant::Party
);
assert_eq!(
- v(false, true),
- RingVariant::Party,
- "friendly party = pale blue"
- );
- assert_eq!(v(true, false), RingVariant::Friendly, "pvp solo = green");
- assert_eq!(
- v(true, true),
- RingVariant::PartyPvp,
- "pvp party = pale green"
- );
- assert_eq!(
- ring_variant(1, true, false, true, true),
+ selection_variant(None, &reps, Some(&open(PVP)), Some(&open(PVP)), true, true),
RingVariant::Hostile
);
- // A dead player never grays.
- assert_eq!(ring_variant(5, true, true, false, true), RingVariant::Party);
+ }
+
+ /// On the real templates, a Human toward an Orc (2) and a Gnome (115).
+ #[test]
+ fn enemy_and_ally_players_read_by_attackability_on_the_real_dbc() {
+ let data = benilla_formats::wow_data_or_skip!();
+ let mut chain = benilla_formats::open_chain(&data).expect("open chain");
+ let factions = Factions(benilla_formats::load_faction_catalog(&mut chain).expect("dbc"));
+ let reps = Reputations::default();
+ let variant = |unit: &ObjectStore, me: &ObjectStore| {
+ selection_variant(Some(&factions), &reps, Some(unit), Some(me), true, false)
+ };
+ let (me, me_flagged) = (player(1, 0, 0), player(1, PVP, 0));
+ let (orc, orc_flagged) = (player(2, 0, 0), player(2, PVP, 0));
+ assert_eq!(variant(&orc_flagged, &me_flagged), RingVariant::Hostile);
+ assert_eq!(variant(&orc, &me_flagged), RingVariant::Player);
+ assert_eq!(variant(&orc_flagged, &me), RingVariant::Neutral);
+ assert_eq!(variant(&orc, &me), RingVariant::Player);
+ // An ally never attacks or is attacked, flagged or not: the PvP split.
+ assert_eq!(
+ variant(&player(115, PVP, 0), &me_flagged),
+ RingVariant::Friendly
+ );
+ assert_eq!(variant(&player(115, 0, 0), &me), RingVariant::Player);
+ // Yourself in the ring, on the real templates too.
+ let me = player(1, PVP, FFA);
+ assert_eq!(variant(&me, &me), RingVariant::Friendly);
+ }
+
+ /// The four player-path arms (`0x605bb6`–`0x605cf6`), and the party split on the last.
+ #[test]
+ fn player_ring_matrix_party_and_pvp_split() {
+ let v = |attacks_us, attackable, pvp, in_party| {
+ ring_variant(SelectorInput::Player(PlayerPath {
+ attacks_us,
+ attackable,
+ pvp,
+ in_party,
+ }))
+ };
+ assert_eq!(v(true, true, true, true), RingVariant::Hostile, "X∧Y red");
+ assert_eq!(v(true, false, true, true), RingVariant::Player, "X∧¬Y blue");
+ assert_eq!(
+ v(false, true, true, true),
+ RingVariant::Neutral,
+ "¬X∧Y yellow"
+ );
+ assert_eq!(
+ v(false, false, false, false),
+ RingVariant::Player,
+ "solo blue"
+ );
+ assert_eq!(
+ v(false, false, false, true),
+ RingVariant::Party,
+ "party pale blue"
+ );
+ assert_eq!(v(true, false, false, false), RingVariant::Player);
+ assert_eq!(
+ v(false, false, true, false),
+ RingVariant::Friendly,
+ "pvp green"
+ );
+ assert_eq!(
+ v(false, false, true, true),
+ RingVariant::PartyPvp,
+ "pale green"
+ );
}
#[test]
- fn npc_ring_ignores_party_inputs() {
- assert_eq!(ring_variant(0, false, true, true, true), RingVariant::Dead);
- assert_eq!(
- ring_variant(2, false, false, true, true),
- RingVariant::Unfriendly
- );
- assert_eq!(
- ring_variant(3, false, false, false, false),
- RingVariant::Neutral
- );
- assert_eq!(
- ring_variant(5, false, false, true, true),
- RingVariant::Friendly
- );
+ fn npc_ring_reads_rank_and_death() {
+ let v = |rank, dead| ring_variant(SelectorInput::Npc { rank, dead });
+ assert_eq!(v(0, true), RingVariant::Dead);
+ assert_eq!(v(5, true), RingVariant::Dead);
+ assert_eq!(v(1, false), RingVariant::Hostile);
+ assert_eq!(v(2, false), RingVariant::Unfriendly);
+ assert_eq!(v(3, false), RingVariant::Neutral);
+ assert_eq!(v(5, false), RingVariant::Friendly);
}
/// The emissary's rank stays neutral, which paints its bar yellow, while its plate category is
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_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_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_chat/channels.rs b/crates/benilla-app/src/ui_chat/channels.rs
index 82175b24..12eb9584 100644
--- a/crates/benilla-app/src/ui_chat/channels.rs
+++ b/crates/benilla-app/src/ui_chat/channels.rs
@@ -444,8 +444,8 @@ pub(super) fn auto_join_zone_channels(
walk.at = Some(zone_id);
}
-/// Feed the VM the zone-less catalog before any interface file runs: world entry runs FrameXML,
-/// the addons and `PLAYER_LOGIN` in one call, ahead of the walk's first tick.
+/// Feed the VM the zone-less catalog before any interface file runs: world entry runs FrameXML
+/// and the addons in one call, ahead of the walk's first tick.
pub(crate) fn seed_zone_channel_catalog(
world: &mut World,
script: &mut benilla_ui::script::UiScript,
diff --git a/crates/benilla-app/src/ui_chat/settings.rs b/crates/benilla-app/src/ui_chat/settings.rs
index 9286ae72..dfa25fa7 100644
--- a/crates/benilla-app/src/ui_chat/settings.rs
+++ b/crates/benilla-app/src/ui_chat/settings.rs
@@ -623,8 +623,9 @@ pub(crate) fn fold_dying_vm_chat_cache(world: &mut World) {
pub(super) fn plugin(app: &mut App) {
app.init_resource::()
- // The restore runs from the world-entry UI load, not here: its events must precede the
- // first chat line and `PLAYER_LOGIN`, which no `Update` ordering guarantees.
+ // The restore runs from the world-entry UI load, not here: its events must follow
+ // `VARIABLES_LOADED` inside the load, where the reference registers its reader
+ // (`0x4900d6`), and precede the first chat line and `PLAYER_LOGIN`.
.add_systems(
Update,
// After the tick: every write it watches is Lua's.
diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs
index 27c39408..f41b8b89 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]
@@ -2399,8 +2397,9 @@ fn the_free_professions_line_is_printed_once() {
// ───────────── the chat cache restores inside the login ─────────
/// `UPDATE_CHAT_WINDOWS` registers the chat frames for `CHAT_MSG_*`, and the `UPDATE_CHAT_COLOR`
-/// burst repaints id-0 lines through `ChatTypeInfo["REPLY"]`, so both come before `PLAYER_LOGIN`.
-/// The probe is a loose addon, because the entry load builds the VM it runs on.
+/// burst repaints id-0 lines through `ChatTypeInfo["REPLY"]`, so both come before `PLAYER_LOGIN`,
+/// which the load arms and the world-enter cascade fires. The probe is a loose addon, because the
+/// entry load builds the VM it runs on.
#[test]
fn the_chat_cache_restore_is_finished_before_player_login() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -2487,6 +2486,10 @@ fn the_chat_cache_restore_is_finished_before_player_login() {
1,
));
crate::ui_script::load_ingame_ui_on_world_entry(&mut world);
+ // The cascade, as the unit feed runs it once the player is in the world.
+ world
+ .non_send_resource_mut::()
+ .fire_world_enter();
let read = |expr: &str| -> String {
world
@@ -2514,7 +2517,7 @@ fn the_chat_cache_restore_is_finished_before_player_login() {
read("ChatOrderProbe.loginRegistered")
);
// The reference's login order: `ADDON_LOADED` (`0x4900a3`), `VARIABLES_LOADED` (`0x4900b2`),
- // the chat-cache burst (`0x4900d6`), `PLAYER_LOGIN` (`0x490959`).
+ // the chat-cache burst (`0x4900d6`), the arm (`0x49011d`), `PLAYER_LOGIN` (`0x490959`).
assert_eq!(
read("ChatOrderProbe.order"),
"VARIABLES_LOADED UPDATE_CHAT_WINDOWS UPDATE_CHAT_COLOR PLAYER_LOGIN ",
diff --git a/crates/benilla-app/src/ui_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_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_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::