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) - @@ -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 + + + + + + + + + + + + +